Compare commits

..

52 Commits

Author SHA1 Message Date
HouYunFei b3b02f75a9 feat(logging): implement detailed logging for Codex operations and HTTP requests 2026-07-28 17:42:11 +08:00
HouYunFei b6e9954c0a chore(release): update version to v0.11.0 and add changelog entries for new features 2026-07-28 10:29:06 +08:00
HouYunFei c23a819f27 feat(canvas): add reasoning effort settings for text nodes in canvas configuration 2026-07-28 10:28:27 +08:00
HouYunFei 6aa9c8bddc fix(canvas): retain prompt visibility for selected canvas nodes after generation 2026-07-28 10:15:53 +08:00
HouYunFei bde33e7816 fix(canvas): restrict prompt layer height for long input and enable scrolling for node prompts 2026-07-28 10:10:31 +08:00
HouYunFei edc7667830 feat(drag-and-drop): implement file upload via drag-and-drop for reference assets in image and video workspaces 2026-07-28 10:03:23 +08:00
HouYunFei f76f81c4c7 feat(canvas): add image preview functionality in the left-side element list 2026-07-27 17:37:35 +08:00
HouYunFei 87ea3a3954 feat(config): add JSON import/export support for user preferences and configurations 2026-07-27 17:27:00 +08:00
HouYunFei ac126a61b9 feat(api): add support for 火山方舟 protocol and enhance video reference validation 2026-07-27 17:02:50 +08:00
HouYunFei d6d6b7bb8d Merge branch 'fork/chen3737/fix/seedream-error-and-edit' 2026-07-27 16:36:33 +08:00
HouYunFei 84fc159d4f Merge branch 'fork/chen3737/main' 2026-07-27 16:31:22 +08:00
HouYunFei 6c61081698 feat(canvas): enhance image editing interactions and fix flickering in high-resolution previews 2026-07-27 16:30:43 +08:00
HouYunFei f8191d13b2 Merge branch 'fork/kumu-ze/codex/upstream-image-editing'
# Conflicts:
#	CHANGELOG.md
#	docs/content/docs/progress/pending-test.mdx
2026-07-27 16:11:25 +08:00
HouYunFei a0287a5e34 chore: bump version to v0.10.0 2026-07-25 11:55:13 +08:00
HouYunFei 0179724467 feat(prompts): add atlascloud logo SVG and update prompt source handling 2026-07-25 11:55:00 +08:00
yu 49851f2b5f feat(prompts): unify sources and add personal library 2026-07-21 01:08:11 +08:00
yu c81bb8b651 style(agent): distinguish chat participants 2026-07-20 12:14:36 +08:00
yu b7d0fe10bc fix(agent): improve chat navigation 2026-07-20 12:13:33 +08:00
HouYunFei d91e67a5ad feat(canvas): retain prompt input after node generation completion 2026-07-20 11:34:46 +08:00
HouYunFei d566d5cc46 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CHANGELOG.md
2026-07-20 07:55:56 +08:00
yu 8fb8d0b224 fix(agent): add uploaded images to canvas flows 2026-07-19 18:17:57 +08:00
yuki 0ca35f46ca fix(api): 增强API错误信息展示并修复Seedream图生图接口兼容性 2026-07-19 15:01:37 +08:00
yuki ccf6412a7c feat(canvas): 画布工具栏上传和拖拽支持多选文件 2026-07-19 12:54:39 +08:00
枯木泽 4236af0c58 merge: 同步上游 Agent 会话更新 2026-07-18 20:23:04 +08:00
枯木泽 8bcedfe51b fix(canvas): 防止空格平移误触弹窗按钮 2026-07-18 20:21:18 +08:00
yu 5e1fd7a825 fix(agent): synchronize shared Codex sessions 2026-07-18 15:29:58 +08:00
yu 062e4569aa feat(agent): add task status and client-scoped operations 2026-07-18 15:29:58 +08:00
HouYunFei b5dc9c8a87 feat(canvas): optimize CanvasAssetsTab and CanvasPromptsTab with memoization to improve performance 2026-07-18 08:27:20 +08:00
HouYunFei 75c22c2f9f feat(canvas): add "prompts" tab to the left panel with collapsible prompt sources 2026-07-18 08:19:59 +08:00
枯木泽 97bb330de1 feat(canvas): 优化图片编辑交互 2026-07-17 21:32:17 +08:00
HouYunFei bdca6b0a5c feat(prompt-sources): add davidwu-gpt-image2-prompts to default prompt sources 2026-07-17 17:45:50 +08:00
HouYunFei d4130bbb79 feat(prompt-sources): add custom script fetching functionality for prompt sources 2026-07-17 17:36:42 +08:00
HouYunFei 0e1edd1484 chore(release): bump version to v0.9.0 and update changelog 2026-07-17 16:43:01 +08:00
HouYunFei 5107be0acf feat(canvas): 资产卡增删改重做 + 节点名称默认隐藏
- 资产 Tab 卡片重做:视频取首帧封面、文本铺内容、去标题成矩形,hover 居中显示插入/删除两个 icon 按钮
- 修复删除确认框「取消」经 React Portal 事件冒泡误触插入
- 资产 Tab 支持上传添加图片/视频、卡片移除资产
- 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现
- 操作按钮改用扁平无灰底样式,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:42:08 +08:00
HouYunFei 219b3e84db feat(canvas): 左侧面板可调宽收起 + 元素多选导出与跳转动画
- 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),状态与宽度持久化
- 顶栏菜单左侧新增面板开关按钮,并缩小菜单/开关按钮
- 顶栏菜单新增「导出当前画布」为压缩包
- 画布元素列表支持多选并批量导出:图片/视频/音频为文件、文本为 txt、其余各自为 json
- 画布/资产切换改为滑动下划线动画,点击元素跳转带缓动动画
- 移除文本/配置/视频等非图片元素图标的灰色底色,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:21:26 +08:00
HouYunFei a3411cf9d9 docs(changelog): note the drag-resize React #185 crash fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:17:57 +08:00
HouYunFei 430d1bd956 refactor(canvas-project): extract plugin host into usePluginHost hook
Move the plugin node host capabilities (pluginAi generation bridge, pluginHost
canvas accessors, renderPluginPanel, buildNodeToolbarItems) and the plugin-load
effect out of InfiniteCanvasPage into usePluginHost. The hook receives the
shared config/refs/setters explicitly and keeps identical dependency arrays.

This also relocates the AI-service and plugin imports (requestVideoGeneration,
selectableModelsByCapability, buildNodeContext, ensurePluginsLoaded, the
canvas-plugin types, etc.) that were only used by this cluster.

project.tsx is now ~2950 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:19:00 +08:00
HouYunFei cf0561d12a refactor(canvas-project): extract agent bridge into useAgentBridge hook
Move the canvas<->local-Agent bridge (agentSnapshot memo, applyAgentOps /
undoAgentOps, agentUndoSnapshot state, and the publish-to-agent-store effect)
out of InfiniteCanvasPage into a useAgentBridge hook. Only applyAgentOps is
consumed outside (by the plugin host); the rest is fully encapsulated.

The hook receives the shared canvas state/refs/setters explicitly and keeps the
exact same dependency arrays, so behavior is unchanged. project.tsx drops the
now-unused agent-ops imports and the setCanvasContext selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:14:10 +08:00
HouYunFei e6dcd7a832 refactor(canvas-project): extract CanvasTopBar into its own component
Move CanvasTopBar and its private helpers (MenuLabel, CompactAgentStatus,
Shortcut) out of project.tsx into canvas-top-bar.tsx. The component already took
all data via explicit props, so the move is mechanical with no behavior change.
Drop the imports (DOCS_URL, UserStatusActions, Dropdown, several icons) that
were only used by the top bar.

project.tsx is now ~3080 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:04:25 +08:00
HouYunFei 187c499ef9 refactor(canvas-project): extract refresh shell and create menus into components
Move the module-scope presentational subcomponents out of project.tsx:
- canvas-refresh-shell.tsx: CanvasRefreshShell (pre-load placeholder).
- canvas-create-menus.tsx: ConnectionCreateMenu, ConnectionCreateOption,
  NodeCreateMenu, plus the PendingConnectionCreate type they define.

They already communicated via explicit props, so the move is mechanical. No
behavior change; project.tsx imports them back and drops now-unused icon and
node-registry imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:00:53 +08:00
HouYunFei cf1ea2646f refactor(canvas-project): extract pure helpers into @/lib/canvas modules
Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into
three focused lib modules, with identical names and signatures. No behavior
change; project.tsx imports them back.

- canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl,
  applyNodeConfigPatch.
- canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors,
  normalizeConnection, hidden-batch checks.
- canvas-generation-helpers.ts: generation config/inputs, reference hydration,
  retry-source lookup, angle prompt builders, misc pure helpers.

project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only
used by the moved helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:46:07 +08:00
HouYunFei 57db0fe752 feat(canvas-local-agent-panel): optimize state management and rendering performance 2026-07-17 13:38:55 +08:00
HouYunFei c5f53454aa feat(changelog): add support for Google Analytics 4 and Baidu statistics 2026-07-17 11:32:11 +08:00
HouYunFei ca6efdf0c3 feat(analytics): implement runtime configuration and analytics tracking for GA4 and Baidu 2026-07-17 11:24:22 +08:00
HouYunFei a2586ffe34 feat(canvas-node-manual): enhance input box to support resource references with thumbnail display 2026-07-17 11:19:34 +08:00
HouYunFei 5a27aae99e feat(changelog): update entries for canvas node enhancements and image reference display 2026-07-17 10:15:16 +08:00
HouYunFei 66961ce10f feat(canvas-prompt-chip-input): implement new component for enhanced prompt input with reference chips 2026-07-17 10:06:57 +08:00
HouYunFei 0c4288b832 chore: update changelog for v0.8.2 and bump version to v0.8.2 2026-07-16 17:44:20 +08:00
HouYunFei 093260f7b2 feat(image-settings-panel): clarify transparent background feature availability in tooltip 2026-07-16 17:44:05 +08:00
HouYunFei 805573b980 feat(prompt-chip-input): implement contentEditable input for image references with thumbnail display 2026-07-16 17:32:43 +08:00
HouYunFei e6829333c5 feat(canvas-node): enhance input area styling and improve cursor visibility in prompt panel 2026-07-16 17:28:30 +08:00
HouYunFei 2959b5d340 feat(image-settings): add transparent background toggle for image generation 2026-07-16 15:45:02 +08:00
105 changed files with 6553 additions and 2001 deletions
+2
View File
@@ -51,6 +51,8 @@
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。 - 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。 - 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。 - 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill``#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg``#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。 - 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。 - 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
+52
View File
@@ -2,6 +2,58 @@
## Unreleased ## Unreleased
+ [新增] Canvas Agent 支持通过 `--debug` 输出彩色详细日志并按日期保存到本地日志文件。
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
## v0.11.0 - 2026-07-28
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
+ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
+ [新增] 模型渠道新增火山方舟协议。
+ [优化] 画布左侧元素列表新增图片放大预览操作。
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
+ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。
+ [修复] 画布组装提示词浮层限制正文高度,长提示词改为区域内滚动查看。
+ [修复] 修复画布节点提示词过长时无法使用鼠标滚轮滚动的问题。
+ [修复] 修复画布重试时组装提示词重复以及切换类型调用模型不一致的问题。
+ [修复] 修复生成完成后再次选中画布节点时提示词不再回显的问题。
## v0.10.0 - 2026-07-25
+ [新增] 提示词来源新增BananaPromptQuicker,并支持添加自定义标准 JSON 来源。
+ [新增] Agent 新增统一状态查询,支持查看画布、生图工作台和视频工作台任务进度。
+ [调整] 内置提示词来源改为读取ImagePrompts 统一JSON 数据,不再由画布端分别解析。
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
+ [优化] 提示词中心标题居中、搜索输入增加防抖,并采用左侧筛选、右侧内容的双栏布局。
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
## v0.9.0 - 2026-07-17
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
## v0.8.2 - 2026-07-16
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
+ [修复] 画布节点提示词输入框补上悬停文本光标。
## v0.8.1 - 2026-07-16 ## v0.8.1 - 2026-07-16
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。 + [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
+2
View File
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
COPY --from=web-build /app/web/dist /usr/share/nginx/html COPY --from=web-build /app/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
EXPOSE 3000 EXPOSE 3000
+20 -25
View File
@@ -25,17 +25,32 @@
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。 无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
> [!CAUTION] > [!CAUTION]
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用 > 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新。
> >
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。 > 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
## 赞助商
<table>
<tr>
<td width="190" align="center">
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas"><img src="assets/atlascloud.svg" width="163" alt="Atlas Cloud"></a>
</td>
<td>
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas">Atlas Cloud</a> is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out <a href="https://www.atlascloud.ai/console/coding-plan">Atlas Cloud's new coding plan promotion</a> for more budget-friendly API access.
</td>
</tr>
</table>
## 核心功能 ## 核心功能
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。 - 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成Seedance 2.0 可通过火山方舟 Agent Plan 接入 - AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。 - 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布; - 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。 - Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。 - 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。 完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
@@ -68,16 +83,7 @@ docker compose up -d
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL``API Key` 首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL``API Key`
## New API 自动配置 如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
```text
https://canvas.best?apiKey={key}&baseUrl={address}
```
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
## 效果展示 ## 效果展示
@@ -108,20 +114,9 @@ https://canvas.best?apiKey={key}&baseUrl={address}
## 赞助支持 ## 赞助支持
<div align="center"> 本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力! 有广告赞助意向请通过上方联系方式沟通。
<br>
<a href="https://ifdian.net/a/basketikun">
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
</a>
<br>
<br>
</div>
## 社区支持 ## 社区支持
+1 -1
View File
@@ -1 +1 @@
v0.8.1 v0.11.0
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+8
View File
@@ -8,6 +8,14 @@
npx -y @basketikun/canvas-agent npx -y @basketikun/canvas-agent
``` ```
需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
```bash
npx -y @basketikun/canvas-agent --debug
```
Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
本仓库开发时也可以直接运行: 本仓库开发时也可以直接运行:
```bash ```bash
+10
View File
@@ -0,0 +1,10 @@
# Infinite Canvas Agent
你正在帮助用户操作 Infinite Canvas 网站。
- 切换网站页面使用 `site_navigate`,可跳转 `/``/canvas``/canvas/:id``/image``/video``/prompts``/assets``/config`
- 修改画布前优先使用 `canvas_get_state`,再根据任务使用已配置的 infinite-canvas MCP 工具;复杂批量改动使用 `canvas_apply_ops`
- 用户要求把上传附件放入画布或作为生成参考图时,必须先用 `canvas_create_attachment_nodes` 创建真实图片节点,再把节点 ID 传给生成流程,不要创建空图片占位节点。
- 当前不在画布页时,先用 `site_navigate` 打开画布。查看已有画布时,先用 `canvas_list_projects` 获取画布清单和 ID。
- 生图与视频工作台分别使用 `workbench_image_*``workbench_video_*` 工具;提示词和素材分别使用 `prompts_search``assets_*` 工具。
- 需要生成内容时直接调用对应生成工具,不要绑定特定业务场景,不要模拟鼠标点击,不要要求用户手动复制 JSON。
+53
View File
@@ -8,6 +8,7 @@
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"@openai/codex": "^0.139.0", "@openai/codex": "^0.139.0",
"express": "^5.1.0", "express": "^5.1.0",
"winston": "^3.19.0",
"zod": "^3.25.0", "zod": "^3.25.0",
}, },
"devDependencies": { "devDependencies": {
@@ -19,6 +20,10 @@
}, },
}, },
"packages": { "packages": {
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -89,6 +94,8 @@
"@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="], "@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="],
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
@@ -109,12 +116,16 @@
"@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -123,6 +134,14 @@
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
"color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
"color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="],
"color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
"content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -143,6 +162,8 @@
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -169,8 +190,12 @@
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
@@ -203,6 +228,8 @@
"is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -211,6 +238,10 @@
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -233,6 +264,8 @@
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
"parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -249,10 +282,16 @@
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -273,10 +312,18 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
"tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], "tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
@@ -287,10 +334,16 @@
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+4
View File
@@ -9,10 +9,13 @@
}, },
"files": [ "files": [
"dist", "dist",
"agent-instructions.md",
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"debug": "tsx src/index.ts --debug",
"test": "tsx --test src/canvas-session.test.ts",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/index.js", "start": "node dist/index.js",
"prepack": "npm run build" "prepack": "npm run build"
@@ -21,6 +24,7 @@
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"@openai/codex": "^0.139.0", "@openai/codex": "^0.139.0",
"express": "^5.1.0", "express": "^5.1.0",
"winston": "^3.19.0",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
+81 -33
View File
@@ -6,16 +6,18 @@ import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AGENT_PROMPT, VERSION } from "./config.js"; import { AGENT_PROMPT, VERSION } from "./config.js";
import { logger } from "./utils/logger.js";
import type { AgentAttachment, AgentEmit } from "./types.js"; import type { AgentAttachment, AgentEmit } from "./types.js";
type Json = Record<string, unknown>; type Json = Record<string, unknown>;
type AgentEvent = Json & { type: string; usage?: unknown }; type AgentEvent = Json & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type CodexRunOptions = { threadId?: string; cwd?: string }; type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string }; type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
let codexQueue: Promise<unknown> = Promise.resolve(); let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null; let codexApp: CodexAppClient | null = null;
let codexAppStart: Promise<CodexAppClient> | null = null;
let codexThreadId = ""; let codexThreadId = "";
const canvasAgentMcp = canvasAgentMcpCommand(); const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -30,52 +32,58 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
await codexQueue; await codexQueue;
} }
export function interruptCodexTurn() { export function interruptCodexTurn(threadId?: string) {
if (!codexApp) return false; if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
return codexApp.interruptCurrentTurn(); return codexApp.interruptCurrentTurn();
} }
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) { async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = []; let files: string[] = [];
try { try {
logger.info("Preparing Codex turn", { threadId: options.threadId, cwd: options.cwd, prompt, attachmentCount: attachments.length });
options.onStart?.();
files = await writeAttachmentFiles(attachments); files = await writeAttachmentFiles(attachments);
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(codexApp, options, emit); let threadId = await ensureCodexThread(app, options, emit);
options.onThread?.(threadId);
try { try {
await codexApp.startTurn(threadId, prompt, files); await app.startTurn(threadId, prompt, files, options.onTurn);
} catch (error) { } catch (error) {
if (!isRecoverableThreadError(error)) throw error; if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = ""; codexThreadId = "";
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit); threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
await codexApp.startTurn(threadId, prompt, files); options.onThread?.(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
} }
} catch (error) { } catch (error) {
logger.error("Codex turn failed", error);
emit("agent_error", { message: errorMessage(error) }); emit("agent_error", { message: errorMessage(error) });
} finally { } finally {
options.onFinish?.();
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined))); await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
} }
} }
export async function startCodexThread(emit: AgentEmit, cwd?: string) { export async function startCodexThread(emit: AgentEmit, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const thread = await codexApp.startThread(cwd); const thread = await app.startThread(cwd);
codexThreadId = String(field(thread, "id") || ""); codexThreadId = String(field(thread, "id") || "");
return thread; return thread;
} }
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false); await loadCodexThread(emit, threadId, cwd, false);
const thread = await codexApp.resumeThread(threadId, cwd); const thread = await app.resumeThread(threadId, cwd);
assertThreadWorkspace(thread, cwd); assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId); codexThreadId = String(field(thread, "id") || threadId);
return { thread, messages: threadMessages(thread) }; return { thread, messages: threadMessages(thread) };
} }
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) { export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const result = await codexApp.listThreads({ const result = await app.listThreads({
limit: options.limit || 40, limit: options.limit || 40,
sortKey: "updated_at", sortKey: "updated_at",
sortDirection: "desc", sortDirection: "desc",
@@ -97,9 +105,9 @@ export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: stri
} }
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false); await loadCodexThread(emit, threadId, cwd, false);
await codexApp.archiveThread(threadId); await app.archiveThread(threadId);
} }
export function runClaudeTurn(prompt: string, emit: AgentEmit) { export function runClaudeTurn(prompt: string, emit: AgentEmit) {
@@ -131,7 +139,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
return codexThreadId; return codexThreadId;
} }
function isRecoverableThreadError(error: unknown) { export function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error)); return /thread not loaded|no rollout found/i.test(errorMessage(error));
} }
@@ -148,12 +156,21 @@ class CodexAppClient {
private constructor(private child: ChildProcess, private emit: AgentEmit) {} private constructor(private child: ChildProcess, private emit: AgentEmit) {}
static async start(emit: AgentEmit) { static async start(emit: AgentEmit) {
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
const client = new CodexAppClient(child, emit); const client = new CodexAppClient(child, emit);
child.stdout?.on("data", (chunk) => client.read(chunk.toString())); child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() })); child.stderr?.on("data", (chunk) => {
child.on("error", (error) => emit("agent_error", { message: error.message })); const text = chunk.toString();
logger.warn("Codex app-server stderr", { text });
emit("agent_log", { text });
});
child.on("error", (error) => {
logger.error("Codex app-server process error", error);
emit("agent_error", { message: error.message });
});
child.on("exit", (code) => { child.on("exit", (code) => {
logger.warn("Codex app-server exited", { code });
client.failAll(`Codex app-server exited: ${code ?? 0}`); client.failAll(`Codex app-server exited: ${code ?? 0}`);
codexApp = null; codexApp = null;
codexThreadId = ""; codexThreadId = "";
@@ -192,10 +209,11 @@ class CodexAppClient {
return this.request("thread/archive", { threadId }); return this.request("thread/archive", { threadId });
} }
async startTurn(threadId: string, prompt: string, images: string[]) { async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" }); const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
const turnId = String(field(field(result, "turn"), "id") || ""); const turnId = String(field(field(result, "turn"), "id") || "");
if (!turnId) throw new Error("Codex app-server 没有返回 turn id"); if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
onTurn?.(turnId);
const completed = this.completedTurns.get(turnId); const completed = this.completedTurns.get(turnId);
if (this.completedTurns.has(turnId)) { if (this.completedTurns.has(turnId)) {
this.completedTurns.delete(turnId); this.completedTurns.delete(turnId);
@@ -208,6 +226,7 @@ class CodexAppClient {
interruptCurrentTurn() { interruptCurrentTurn() {
if (this.activeTurns.size === 0) return false; if (this.activeTurns.size === 0) return false;
try { try {
logger.warn("Interrupting active Codex turn", { threadId: codexThreadId, activeTurns: this.activeTurns.size });
this.child.kill("SIGINT"); this.child.kill("SIGINT");
return true; return true;
} catch { } catch {
@@ -226,6 +245,7 @@ class CodexAppClient {
} }
private write(value: unknown) { private write(value: unknown) {
logger.debug("Codex app-server request", value);
this.child.stdin?.write(`${JSON.stringify(value)}\n`); this.child.stdin?.write(`${JSON.stringify(value)}\n`);
} }
@@ -236,13 +256,15 @@ class CodexAppClient {
lines.filter(Boolean).forEach((line) => { lines.filter(Boolean).forEach((line) => {
try { try {
this.handle(JSON.parse(line) as Json); this.handle(JSON.parse(line) as Json);
} catch { } catch (error) {
logger.warn("Invalid Codex app-server output", { error, line });
this.emit("agent_log", { text: line }); this.emit("agent_log", { text: line });
} }
}); });
} }
private handle(message: Json) { private handle(message: Json) {
logger.debug("Codex app-server response", message);
const id = Number(message.id); const id = Number(message.id);
if (message.error && this.pending.has(id)) return this.reject(id, String(field(message.error, "message") || "Codex request failed")); if (message.error && this.pending.has(id)) return this.reject(id, String(field(message.error, "message") || "Codex request failed"));
if (this.pending.has(id)) return this.resolve(id, message.result); if (this.pending.has(id)) return this.resolve(id, message.result);
@@ -255,6 +277,13 @@ class CodexAppClient {
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params); if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
const event = normalizeCodexNotification(method, params); const event = normalizeCodexNotification(method, params);
if (!event) return; if (!event) return;
if (event.type === "item.completed") {
const item = field(event, "item") as Json | undefined;
const id = String(field(item, "id") || "");
const streamedText = this.textByItem.get(id);
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
if (id) this.textByItem.delete(id);
}
if (event.type === "turn.completed") event.usage = this.lastUsage; if (event.type === "turn.completed") event.usage = this.lastUsage;
this.emit("agent_event", { agent: "codex", ...event }); this.emit("agent_event", { agent: "codex", ...event });
if (event.type === "turn.completed") { if (event.type === "turn.completed") {
@@ -267,9 +296,9 @@ class CodexAppClient {
} else if (turnId) { } else if (turnId) {
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null); this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
} }
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount }); this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount, ...codexEventScope(params) });
this.deltaCount = 0; this.deltaCount = 0;
this.emit("agent_done", { agent: "codex", usage: event.usage }); this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
} }
} }
@@ -278,7 +307,7 @@ class CodexAppClient {
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`; const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
this.deltaCount += 1; this.deltaCount += 1;
this.textByItem.set(id, text); this.textByItem.set(id, text);
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } }); this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
} }
private answerServerRequest(message: Json) { private answerServerRequest(message: Json) {
@@ -321,23 +350,41 @@ function codexInput(prompt: string, images: string[]) {
} }
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null { function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") }; const scope = codexEventScope(params);
if (method === "turn/started") return { type: "turn.started" }; if (method === "thread/started") return { type: "thread.started", ...scope };
if (method === "turn/completed") return { type: "turn.completed", usage: null }; if (method === "turn/started") return { type: "turn.started", ...scope };
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) }; if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) }; if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message") }; if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
return null; return null;
} }
function codexEventScope(params: Json) {
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
}
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) { async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
codexApp ||= await CodexAppClient.start(emit); const app = await getCodexApp(emit);
const result = await codexApp.readThread(threadId, includeTurns); const result = await app.readThread(threadId, includeTurns);
const thread = field(result, "thread") || {}; const thread = field(result, "thread") || {};
assertThreadWorkspace(thread, cwd); assertThreadWorkspace(thread, cwd);
return thread; return thread;
} }
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit);
try {
codexApp = await codexAppStart;
return codexApp;
} finally {
codexAppStart = null;
}
}
function assertThreadWorkspace(thread: unknown, cwd?: string) { function assertThreadWorkspace(thread: unknown, cwd?: string) {
if (!cwd || threadInWorkspace(thread, cwd)) return; if (!cwd || threadInWorkspace(thread, cwd)) return;
throw new Error("该 Codex 会话不属于当前画布工作空间"); throw new Error("该 Codex 会话不属于当前画布工作空间");
@@ -408,7 +455,7 @@ function threadMessages(thread: unknown): AgentHistoryMessage[] {
} }
if (type === "agentMessage") { if (type === "agentMessage") {
const text = String(field(item, "text") || "").trim(); const text = String(field(item, "text") || "").trim();
if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id }); if (text) messages.push({ id, role: "assistant", title: "Codex", text });
} }
if (type === "mcpToolCall") { if (type === "mcpToolCall") {
const tool = String(field(item, "tool") || "工具调用"); const tool = String(field(item, "tool") || "工具调用");
@@ -458,6 +505,7 @@ function toolName(name: string) {
if (name === "canvas_get_state") return "读取画布"; if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区"; if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照"; if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
if (name === "canvas_create_text_node") return "创建文本"; if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程"; if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程"; if (name === "canvas_create_generation_flow") return "创建生成流程";
+289
View File
@@ -0,0 +1,289 @@
import { EventEmitter } from "node:events";
import type { ServerResponse } from "node:http";
import assert from "node:assert/strict";
import test from "node:test";
import { CanvasSession } from "./canvas-session.js";
test("MCP 读取当前激活网页的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("画布写操作只发送给当前激活网页", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "canvas_apply_ops");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
t.after(() => first.close());
const dataUrl = "data:image/png;base64,aW1hZ2U=";
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
session.bindClient("first");
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
const call = first.event("tool_call");
const input = field(call, "input") as Record<string, unknown>;
const nodes = input.nodes as Array<Record<string, unknown>>;
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
assert.equal(nodes.length, 1);
assert.equal(nodes[0].attachmentId, "attachment-1");
assert.equal(nodes[0].title, "商品.png");
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
assert.equal(nodes[0].width, 640);
assert.equal(nodes[0].height, 320);
assert.equal("dataUrl" in nodes[0], false);
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
assert.equal(created.nodes[0].id, nodes[0].id);
assert.equal(created.nodes[0].attachmentId, "attachment-1");
session.clearTurnAttachments("first");
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
});
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
session.bindClient("second");
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
assert.equal(first.event("tool_call"), undefined);
assert.equal(second.event("tool_call"), undefined);
});
test("tool result is accepted only from the request client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("first");
const result = session.callTool("canvas_create_text_node", { text: "first only" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
assert.deepEqual(await result, { client: "first" });
});
test("生成状态查询由当前激活网页返回", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("second");
const result = session.callTool("generation_get_status", { scope: "all" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "generation_get_status");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
});
test("活动网页关闭后回退到仍连接的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
});
test("closing the active client falls back to the most recently focused client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
const third = connect(session, "third");
t.after(() => {
first.close();
second.close();
third.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.updateState(snapshot("canvas-third"), "third");
session.activateClient("third");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
});
test("closing a client rejects its pending tool requests", async () => {
const session = new CanvasSession();
const first = connect(session, "first");
const result = session.callTool("canvas_create_text_node", { text: "pending" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
first.close();
const outcome = await Promise.race([
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
]);
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
assert.match(outcome, /断开/);
});
test("shared thread events are broadcast with the active thread id", (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
});
test("new clients receive the current Codex state and later updates", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
const client = connect(session, "first");
t.after(() => client.close());
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
session.setCodexState({ busy: false });
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
});
test("a bound client remains the tool target while focus changes", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
const result = session.callTool("canvas_create_text_node", { text: "bound" });
const call = first.event("tool_call");
assert.equal(second.event("tool_call"), undefined);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
session.releaseClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("closing the bound client falls back to the active client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
first.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
const call = second.event("tool_call");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
function connect(session: CanvasSession, clientId: string) {
const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
return response;
}
function snapshot(projectId: string) {
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
}
function field(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
class FakeSseResponse extends EventEmitter {
private chunks: string[] = [];
writeHead() {
return this;
}
write(chunk: string) {
this.chunks.push(chunk);
return true;
}
event(type: string) {
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? (JSON.parse(data) as unknown) : undefined;
}
close() {
this.emit("close");
}
}
+160 -13
View File
@@ -1,11 +1,14 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { ServerResponse } from "node:http"; import type { ServerResponse } from "node:http";
import { logger } from "./utils/logger.js";
import { type ToolName } from "./schemas.js"; import { type ToolName } from "./schemas.js";
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js"; import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js"; import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
export type CodexState = { busy: boolean; threadId: string; turnId: string };
const SITE_TOOLS = new Set<ToolName>([ const SITE_TOOLS = new Set<ToolName>([
"site_navigate", "site_navigate",
@@ -17,48 +20,152 @@ const SITE_TOOLS = new Set<ToolName>([
"prompts_search", "prompts_search",
"assets_list", "assets_list",
"assets_add", "assets_add",
"generation_get_status",
]); ]);
export class CanvasSession { export class CanvasSession {
private clients = new Map<string, ServerResponse>(); private clients = new Map<string, ServerResponse>();
private clientFocusOrder = new Map<string, number>();
private pending = new Map<string, PendingRequest>(); private pending = new Map<string, PendingRequest>();
private canvasState: CanvasSnapshot | null = null; private canvasStates = new Map<string, CanvasSnapshot>();
private turnAttachments = new Map<string, TurnAttachment>();
private activeClientId = "";
private boundClientId = "";
private focusSequence = 0;
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
private get canvasState() {
return this.canvasStates.get(this.targetClientId) || null;
}
private get targetClientId() {
return this.boundClientId || this.activeClientId;
}
health() { health() {
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size }; return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
}
get codexBusy() {
return this.codexState.busy;
}
setCodexState(patch: Partial<CodexState>) {
this.codexState = { ...this.codexState, ...patch };
logger.debug("Codex state changed", this.codexState);
this.emitAll("codex_state", this.codexState);
} }
openEvents(url: URL, res: ServerResponse) { openEvents(url: URL, res: ServerResponse) {
const clientId = url.searchParams.get("clientId") || crypto.randomUUID(); const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
const statusOnly = url.searchParams.get("role") === "status"; const statusOnly = url.searchParams.get("role") === "status";
logger.info("SSE client connected", { clientId, statusOnly });
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
if (!statusOnly) this.clients.set(clientId, res); if (!statusOnly) {
sendEvent(res, "hello", { ok: true, clientId }); this.clients.set(clientId, res);
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
if (!this.activeClientId) {
this.activeClientId = clientId;
this.clientFocusOrder.set(clientId, ++this.focusSequence);
}
}
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000); const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => { res.on("close", () => {
clearInterval(timer); clearInterval(timer);
if (!statusOnly) this.clients.delete(clientId); logger.info("SSE client disconnected", { clientId, statusOnly });
if (this.canvasState?.clientId === clientId) this.canvasState = null; if (statusOnly || this.clients.get(clientId) !== res) return;
this.clients.delete(clientId);
this.clientFocusOrder.delete(clientId);
this.canvasStates.delete(clientId);
if (this.boundClientId === clientId) this.boundClientId = "";
this.pending.forEach((item, requestId) => {
if (item.clientId !== clientId) return;
this.pending.delete(requestId);
item.reject(new Error("请求页面已断开"));
});
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
}); });
} }
updateState(body: unknown, clientId?: string) { updateState(body: unknown, clientId?: string) {
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot; const targetClientId = clientId || this.activeClientId;
if (!targetClientId) return;
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: Array.isArray((body as CanvasSnapshot | null)?.nodes) ? (body as CanvasSnapshot).nodes.length : 0, connections: Array.isArray((body as CanvasSnapshot | null)?.connections) ? (body as CanvasSnapshot).connections.length : 0 });
} }
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) { activateClient(clientId: string) {
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
this.activeClientId = clientId;
this.clientFocusOrder.set(clientId, ++this.focusSequence);
logger.debug("Canvas client activated", { clientId });
}
bindClient(clientId: string) {
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
this.boundClientId = clientId;
logger.debug("Canvas client bound to turn", { clientId });
}
releaseClient(clientId: string) {
if (this.boundClientId === clientId) this.boundClientId = "";
logger.debug("Canvas client released from turn", { clientId });
}
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
this.turnAttachments.clear();
return attachments.flatMap((item, index) => {
if (!item.dataUrl?.startsWith("data:image/")) return [];
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
const attachment: TurnAttachment = {
clientId,
id,
name: item.name?.trim() || `图片 ${index + 1}`,
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
size: positiveNumber(item.size, 0),
width: positiveNumber(item.width, 1024),
height: positiveNumber(item.height, 1024),
dataUrl: item.dataUrl,
};
this.turnAttachments.set(id, attachment);
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
});
}
clearTurnAttachments(clientId?: string) {
this.turnAttachments.forEach((item, id) => {
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
});
}
getTurnAttachment(clientId: string, attachmentId: string) {
const attachment = this.turnAttachments.get(attachmentId);
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
return attachment;
}
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
const item = body.requestId ? this.pending.get(body.requestId) : null; const item = body.requestId ? this.pending.get(body.requestId) : null;
if (!item || !body.requestId) return; if (!item || !body.requestId || item.clientId !== clientId) return false;
this.pending.delete(body.requestId); this.pending.delete(body.requestId);
logger.debug("Canvas tool result received", { clientId, requestId: body.requestId, error: body.error, result: body.result });
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result); body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
return true;
} }
emitAll(type: string, payload: unknown) { emitAll(type: string, payload: unknown) {
this.clients.forEach((client) => sendEvent(client, type, payload)); this.clients.forEach((client) => sendEvent(client, type, payload));
} }
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
this.emitAll(type, { ...payload, threadId });
}
async callTool(name: unknown, rawInput: unknown) { async callTool(name: unknown, rawInput: unknown) {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`); if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
let tool: ToolName = name; let tool: ToolName = name;
let input = parseToolInput(tool, rawInput) as Record<string, unknown>; let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
if (SITE_TOOLS.has(tool)) { if (SITE_TOOLS.has(tool)) {
@@ -72,6 +179,7 @@ export class CanvasSession {
const ids = new Set(this.canvasState?.selectedNodeIds || []); const ids = new Set(this.canvasState?.selectedNodeIds || []);
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) }; return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
} }
if (tool === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
if (tool === "canvas_create_node") { if (tool === "canvas_create_node") {
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> }; const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] }; input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
@@ -166,17 +274,46 @@ export class CanvasSession {
return await this.requestCanvasTool(tool, input); return await this.requestCanvasTool(tool, input);
} }
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
const clientId = this.targetClientId;
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
const x = Number(input.x ?? nextCanvasX(this.canvasState));
const y = Number(input.y ?? 0);
const gap = Number(input.gap ?? 40);
const direction = input.direction || "row";
let offset = 0;
const nodes = attachments.map((attachment) => {
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
const node = {
id: `image-${crypto.randomUUID()}`,
attachmentId: attachment.id,
title: attachment.name,
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
width: size.width,
height: size.height,
};
offset += (direction === "row" ? size.width : size.height) + gap;
return node;
});
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
}
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) { private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value; const clientId = this.targetClientId;
const client = this.clients.get(clientId);
if (!client) throw new Error("当前没有已连接画布"); if (!client) throw new Error("当前没有已连接画布");
sendEvent(client, "tool_call", { requestId, name, input }); sendEvent(client, "tool_call", { requestId, name, input });
logger.debug("Canvas tool request sent", { requestId, name, input, clientId });
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.pending.delete(requestId); this.pending.delete(requestId);
logger.warn("Canvas tool request timed out", { requestId, name, clientId });
reject(new Error("画布操作超时")); reject(new Error("画布操作超时"));
}, 30000); }, 30000);
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) }); this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
}); });
} }
} }
@@ -263,3 +400,13 @@ function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefi
function cleanRecord(value: Record<string, unknown>) { function cleanRecord(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "")); return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
} }
function positiveNumber(value: unknown, fallback: number) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function fitAttachmentNodeSize(width: number, height: number) {
const scale = Math.min(1, 640 / width, 640 / height);
return { width: width * scale, height: height * scale };
}
+13 -4
View File
@@ -7,7 +7,8 @@ export const DEFAULT_PORT = 17371;
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas"); export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json"); export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion(); export const VERSION = readPackageVersion();
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; export const AGENT_PROMPT = fs.readFileSync(new URL("../agent-instructions.md", import.meta.url), "utf8");
const initializedWorkspaces = new Set<string>();
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig }; export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
@@ -31,12 +32,12 @@ export function ensureSiteWorkspace(config: CanvasAgentConfig) {
const current = config.workspace; const current = config.workspace;
if (current?.workspacePath) { if (current?.workspacePath) {
const workspacePath = resolveWorkspacePath(current.workspacePath); const workspacePath = resolveWorkspacePath(current.workspacePath);
fs.mkdirSync(workspacePath, { recursive: true }); initializeWorkspace(workspacePath);
return { ...current, workspacePath }; return { ...current, workspacePath };
} }
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site"); const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
config.workspace = { workspacePath }; config.workspace = { workspacePath };
fs.mkdirSync(workspacePath, { recursive: true }); initializeWorkspace(workspacePath);
saveConfig(config); saveConfig(config);
return { workspacePath }; return { workspacePath };
} }
@@ -46,11 +47,19 @@ export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<Si
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath; const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
const next = { ...current, ...patch, workspacePath }; const next = { ...current, ...patch, workspacePath };
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds }; config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
fs.mkdirSync(workspacePath, { recursive: true }); initializeWorkspace(workspacePath);
saveConfig(config); saveConfig(config);
return config.workspace; return config.workspace;
} }
function initializeWorkspace(workspacePath: string) {
if (initializedWorkspaces.has(workspacePath)) return;
fs.mkdirSync(workspacePath, { recursive: true });
const instructionsFile = path.join(workspacePath, "AGENTS.md");
if (!fs.existsSync(instructionsFile)) fs.writeFileSync(instructionsFile, AGENT_PROMPT);
initializedWorkspaces.add(workspacePath);
}
function resolveWorkspacePath(value: string) { function resolveWorkspacePath(value: string) {
if (value === "~") return os.homedir(); if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
+120 -23
View File
@@ -2,7 +2,8 @@ import express, { type NextFunction, type Request, type Response } from "express
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js"; import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.js"; import { CanvasSession } from "./canvas-session.js";
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js"; import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import { logger } from "./utils/logger.js";
import type { AgentAttachment } from "./types.js"; import type { AgentAttachment } from "./types.js";
export function startHttpServer() { export function startHttpServer() {
@@ -12,10 +13,27 @@ export function startHttpServer() {
saveConfig(config); saveConfig(config);
const session = new CanvasSession(); const session = new CanvasSession();
const emit = (type: string, payload: unknown) => session.emitAll(type, payload); const emit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
logger.debug("Agent event", { type, threadId, payload: data });
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
return workspace;
};
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(express.json({ limit: "30mb" })); app.use(express.json({ limit: "30mb" }));
app.use((req, res, next) => {
if (!logger.enabled) return next();
const startedAt = Date.now();
const url = requestUrl(req, config);
res.on("finish", () => logger.debug("HTTP request", { method: req.method, path: url.pathname, status: res.statusCode, durationMs: Date.now() - startedAt, origin: req.headers.origin, clientId: url.searchParams.get("clientId") }));
next();
});
app.use((req, res, next) => { app.use((req, res, next) => {
const url = requestUrl(req, config); const url = requestUrl(req, config);
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" }); if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
@@ -33,10 +51,21 @@ export function startHttpServer() {
session.updateState(req.body, String(req.query.clientId || "") || undefined); session.updateState(req.body, String(req.query.clientId || "") || undefined);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post("/canvas/result", (req, res) => { app.post("/canvas/activate", (req, res) => {
session.resolveResult(req.body); session.activateClient(String(req.query.clientId || ""));
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post("/canvas/result", (req, res) => {
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
res.status(ok ? 200 : 409).json({ ok });
});
app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
const data = attachment.dataUrl.split(",", 2)[1];
if (!data) throw new Error("图片附件内容无效");
res.setHeader("Cache-Control", "no-store");
res.type(attachment.type).send(Buffer.from(data, "base64"));
}));
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) }))); app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
app.get("/agent/codex/workspace", (_req, res) => { app.get("/agent/codex/workspace", (_req, res) => {
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
@@ -48,48 +77,105 @@ export function startHttpServer() {
res.json({ ok: true, workspace, ...result }); res.json({ ok: true, workspace, ...result });
})); }));
app.post("/agent/codex/threads/new", route(async (_req, res) => { app.post("/agent/codex/threads/new", route(async (_req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath); const thread = await startCodexThread(emit, workspace.workspacePath);
const activeThreadId = String((thread as Record<string, unknown>).id || ""); const activeThreadId = String((thread as Record<string, unknown>).id || "");
updateSiteWorkspace(config, { activeThreadId }); const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] }); res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
})); }));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => { app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) }); try {
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
} catch (error) {
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
}
})); }));
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => { app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath); const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateSiteWorkspace(config, { activeThreadId: threadId }); const nextWorkspace = setActiveThread(threadId);
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result }); res.json({ ok: true, workspace: nextWorkspace, ...result });
})); }));
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => { app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId); const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath); await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined }); setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
res.json({ ok: true }); res.json({ ok: true });
})); }));
app.post("/agent/codex/turn", route(async (req, res) => { app.post("/agent/codex/turn", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : []; const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureSiteWorkspace(config); const workspace = ensureSiteWorkspace(config);
let threadId = String(req.body?.threadId || workspace.activeThreadId || ""); const prompt = String(req.body?.prompt || "");
if (!threadId) { if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
const thread = await startCodexThread(emit, workspace.workspacePath); const clientId = String(req.body?.clientId || "");
threadId = String((thread as Record<string, unknown>).id || ""); logger.info("Codex turn accepted", { clientId, threadId: req.body?.threadId, prompt, attachments: attachments.map(({ id, name, type, size, width, height }) => ({ id, name, type, size, width, height })) });
updateSiteWorkspace(config, { activeThreadId: threadId }); session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
} else if (threadId !== workspace.activeThreadId) { try {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath); let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
updateSiteWorkspace(config, { activeThreadId: threadId }); let turnId = "";
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath);
threadId = String((thread as Record<string, unknown>).id || "");
setActiveThread(threadId, { emptyThread: true });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
setActiveThread(threadId);
}
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
const chatMessage = {
sourceClientId: clientId,
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
};
let chatThreadId = "";
const turnEmit = (type: string, payload: unknown) => {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
session.emitThread(type, threadId, data);
};
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
appEmit: emit,
onStart: clientId ? () => session.bindClient(clientId) : undefined,
onThread: (actualThreadId) => {
if (actualThreadId !== threadId) {
threadId = actualThreadId;
setActiveThread(threadId, { emptyThread: true });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
if (chatThreadId !== threadId) {
chatThreadId = threadId;
session.emitThread("chat_message", threadId, chatMessage);
}
},
onTurn: (actualTurnId) => {
turnId = actualTurnId;
logger.info("Codex turn started", { threadId, turnId });
session.setCodexState({ busy: true, threadId, turnId });
},
onFinish: () => {
logger.info("Codex turn finished", { threadId, turnId });
session.clearTurnAttachments(clientId);
if (clientId) session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId });
},
});
res.json({ ok: true, threadId });
} catch (error) {
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
throw error;
} }
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
res.json({ ok: true, threadId });
})); }));
app.post("/agent/codex/interrupt", (_req, res) => { app.post("/agent/codex/interrupt", (req, res) => {
const ok = interruptCodexTurn(); const ok = interruptCodexTurn(String(req.body?.threadId || ""));
res.json({ ok }); res.json({ ok });
}); });
app.post("/agent/claude/turn", (req, res) => { app.post("/agent/claude/turn", (req, res) => {
@@ -97,7 +183,10 @@ export function startHttpServer() {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" })); app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => res.status(500).json({ ok: false, error: error.message })); app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
logger.error("HTTP request failed", { method: req.method, path: req.path, error });
res.status(500).json({ ok: false, error: error.message });
});
app.listen(port, "127.0.0.1", () => { app.listen(port, "127.0.0.1", () => {
console.log("Infinite Canvas Agent"); console.log("Infinite Canvas Agent");
@@ -106,6 +195,8 @@ export function startHttpServer() {
console.log("Codex MCP is not installed by this command."); console.log("Codex MCP is not installed by this command.");
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp"); console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
console.log("Remove manually added MCP: codex mcp remove infinite-canvas"); console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
if (logger.enabled) console.log(`Debug log: ${logger.filePath}`);
logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
}); });
} }
@@ -141,3 +232,9 @@ function validToken(req: Request, url: URL, token: string) {
const header = req.headers["x-canvas-agent-token"]; const header = req.headers["x-canvas-agent-token"];
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token)); return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
} }
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
if (!attachments.length) return prompt;
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
}
+8 -2
View File
@@ -14,6 +14,7 @@ export const toolNames = [
"canvas_export_snapshot", "canvas_export_snapshot",
"canvas_apply_ops", "canvas_apply_ops",
"canvas_create_node", "canvas_create_node",
"canvas_create_attachment_nodes",
"canvas_create_text_node", "canvas_create_text_node",
"canvas_create_text_nodes", "canvas_create_text_nodes",
"canvas_create_config_node", "canvas_create_config_node",
@@ -32,6 +33,7 @@ export const toolNames = [
"canvas_select_nodes", "canvas_select_nodes",
"canvas_set_viewport", "canvas_set_viewport",
"canvas_run_generation", "canvas_run_generation",
"generation_get_status",
"workbench_image_get_config", "workbench_image_get_config",
"workbench_image_generate", "workbench_image_generate",
"workbench_video_get_config", "workbench_video_get_config",
@@ -93,6 +95,7 @@ export const toolInputSchemas = {
canvas_export_snapshot: z.object({}).passthrough(), canvas_export_snapshot: z.object({}).passthrough(),
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }), canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }), canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }), canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }), canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema), canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
@@ -111,6 +114,7 @@ export const toolInputSchemas = {
canvas_select_nodes: z.object({ ids: z.array(z.string()) }), canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
canvas_set_viewport: z.object({ viewport: viewportSchema }), canvas_set_viewport: z.object({ viewport: viewportSchema }),
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }), canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
workbench_image_get_config: z.object({}).passthrough(), workbench_image_get_config: z.object({}).passthrough(),
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }), workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
workbench_video_get_config: z.object({}).passthrough(), workbench_video_get_config: z.object({}).passthrough(),
@@ -128,6 +132,7 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_export_snapshot: "导出当前画布快照,用于理解布局。", canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。", canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。", canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
canvas_create_text_node: "在当前画布创建单个文本节点。", canvas_create_text_node: "在当前画布创建单个文本节点。",
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。", canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。", canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
@@ -146,10 +151,11 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_select_nodes: "设置当前选中节点。", canvas_select_nodes: "设置当前选中节点。",
canvas_set_viewport: "调整画布视口。", canvas_set_viewport: "调整画布视口。",
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。", canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。", workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。", workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。", workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。", workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。", prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
assets_list: "列出用户「我的素材」,支持 kindtext/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。", assets_list: "列出用户「我的素材」,支持 kindtext/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。", assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
+1 -1
View File
@@ -5,4 +5,4 @@ export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; pos
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string }; export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string }; export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
export type AgentEmit = (type: string, payload: unknown) => void; export type AgentEmit = (type: string, payload: unknown) => void;
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string }; export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
+7
View File
@@ -0,0 +1,7 @@
/** 将日期格式化为适合文件名使用的本地日期字符串。 */
export function formatDateForFilename(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {inspect} from "node:util";
import winston, {format, transports, type Logger as WinstonLogger} from "winston";
import {formatDateForFilename} from "./date.js";
/** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
export class Logger {
readonly enabled = process.argv.includes("--debug");
readonly filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
private readonly logger: WinstonLogger | null;
/** 根据命令行 Debug 参数初始化日志输出。 */
constructor() {
if (!this.enabled) {
this.logger = null;
return;
}
fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
const line = format.printf(({level, message, timestamp, details}) => `[${level.toUpperCase()}][${timestamp}] ${message}${formatDetails(details)}`);
this.logger = winston.createLogger({
level: "debug",
transports: [
new transports.Console({format: format.combine(format.colorize(), format.timestamp({format: "HH:mm:ss"}), line)}),
new transports.File({filename: this.filePath, format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
],
});
}
/** 输出 Debug 级别日志。 */
debug(message: string, details?: unknown) {
if (details === undefined) this.logger?.debug(message);
else this.logger?.debug(message, {details: sanitize(details)});
}
/** 输出 Info 级别日志。 */
info(message: string, details?: unknown) {
if (details === undefined) this.logger?.info(message);
else this.logger?.info(message, {details: sanitize(details)});
}
/** 输出 Warn 级别日志。 */
warn(message: string, details?: unknown) {
if (details === undefined) this.logger?.warn(message);
else this.logger?.warn(message, {details: sanitize(details)});
}
/** 输出 Error 级别日志。 */
error(message: string, details?: unknown) {
if (details === undefined) this.logger?.error(message);
else this.logger?.error(message, {details: sanitize(details)});
}
}
/** 将日志详情格式化为紧凑的单行文本。 */
function formatDetails(details: unknown) {
if (details === undefined) return "";
if (!details || typeof details !== "object" || Array.isArray(details)) return ` ${inspect(details, {depth: null, breakLength: Infinity})}`;
return ` ${Object.entries(details).map(([key, value]) => `${key}=${inspect(value, {depth: null, breakLength: Infinity})}`).join(" ")}`;
}
/** 清理日志内容中的敏感数据和不可序列化引用。 */
function sanitize(value: unknown, key = "", seen = new WeakSet<object>()): unknown {
if (/token|authorization|api.?key|dataurl/i.test(key)) return "[REDACTED]";
if (typeof value === "string" && value.startsWith("data:")) return `[DATA URL ${value.length} chars]`;
if (value instanceof Error) return {name: value.name, message: value.message, stack: value.stack};
if (!value || typeof value !== "object") return value;
if (seen.has(value)) return "[CIRCULAR]";
seen.add(value);
if (Array.isArray(value)) return value.map((item) => sanitize(item, key, seen));
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([field, item]) => [field, sanitize(item, field, seen)]));
}
export const logger = new Logger();
+2 -1
View File
@@ -10,5 +10,6 @@
"rootDir": "src", "rootDir": "src",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
} }
+4
View File
@@ -5,3 +5,7 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
restart: unless-stopped restart: unless-stopped
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
# environment:
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。 - 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。 - 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
- 输入内容可以手写,也可以从提示词库选择。 - 输入内容可以手写,也可以从提示词库选择。
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。 - 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。 - 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
+2 -1
View File
@@ -4,6 +4,7 @@
"defaultOpen": true, "defaultOpen": true,
"pages": [ "pages": [
"local-development", "local-development",
"canvas-data-structure" "canvas-data-structure",
"local-codex-canvas"
] ]
} }
+39
View File
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
## 数据说明 ## 数据说明
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。 当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
## 统计分析(可选)
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
| 变量 | 说明 |
| --- | --- |
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID`G-XXXX` |
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID`hm.js?` 后面那串 hash |
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
```yaml
services:
app:
image: ghcr.io/basketikun/infinite-canvas:latest
ports:
- "3000:3000"
environment:
ANALYTICS_GA4_ID: G-XXXXXXXXXX
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
```
或使用 `docker run`
```bash
docker run -d -p 3000:3000 \
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
ghcr.io/basketikun/infinite-canvas:latest
```
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
<Callout type="warn">
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
</Callout>
+15
View File
@@ -85,9 +85,12 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 生成数量。 - 生成数量。
- 视频模型。 - 视频模型。
- 视频比例、清晰度、时长、生成声音和水印。 - 视频比例、清晰度、时长、生成声音和水印。
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。 普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。 视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
## 画布助手 ## 画布助手
@@ -106,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 把助手生成的图片插入画布。 - 把助手生成的图片插入画布。
- 折叠和展开助手面板。 - 折叠和展开助手面板。
## 节点插件系统
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
## 提示词库 ## 提示词库
前台提示词库支持: 前台提示词库支持:
+1 -2
View File
@@ -9,7 +9,6 @@
"render", "render",
"docker", "docker",
"third-party-prompt-repositories", "third-party-prompt-repositories",
"[在线体验](https://canvas.best/)", "[在线体验](https://canvas.best/)"
"local-codex-canvas"
] ]
} }
@@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库
| 地址 | 状态 | | 地址 | 状态 |
| --- | --- | | --- | --- |
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 | | https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 | | https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 | | https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
+1
View File
@@ -5,6 +5,7 @@
"pages": [ "pages": [
"[更新日志](/docs/progress/changelog)", "[更新日志](/docs/progress/changelog)",
"local-agent-integration-plan", "local-agent-integration-plan",
"prompt-chip-input-plan",
"pending-test", "pending-test",
"todo" "todo"
] ]
+27 -44
View File
@@ -5,47 +5,30 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试 # 待测试
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件 - Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应以 `[DEBUG][HH:mm:ss]` 等传统格式输出带级别颜色的详细日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成不含颜色代码的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线) - Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 {{input}} 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互 - Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空
- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载 - Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用
- 画布节点插件系统:每个插件为 `plugins/canvas/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装 - 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`OpenAI 模板应按自动或指定档位正确组装请求
- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效 - 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单 - 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰 - 画布组装提示词:输入或连接超长提示词后,浮层正文高度不应继续撑大,内容应可在正文区域内滚动查看和编辑,标题及关闭按钮保持可见
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰 - 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开 - 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空
- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失 - 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型
- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位
- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享
- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段 - 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出 - 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产
- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组 - 提示词详情弹窗:封面和参考图应固定显示在上方,复制及加入资产操作栏固定在底部,只有中间的标签、描述及提示词内容区域可以滚动;弹窗宽高应受视口限制且不超出屏幕
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点 - 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退 - 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览 - 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点
- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确 - 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回
- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰 - 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认 - 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关 - 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示
- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态 - 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布
- 画布 AgentCodex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found` - Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话 - Agent 消息区分:用户消息应在右侧以主题浅底气泡显示,AI 回复应在左侧保留 Agent 图标并使用开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出
- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例。
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。
- 前端构建:修复 TypeScript 严格检查下的构建报错。
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
- 环境变量:移除过期的后端 `.env.example` 示例文件。
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
@@ -0,0 +1,66 @@
---
title: 提示词面板引用显示图片缩略图规划
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
---
# 提示词面板 @ 引用显示真实图片缩略图
## 背景 / 要解决的问题
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
## 关键约束(为什么不能直接改现有组件)
`CanvasResourceMentionTextarea` 被两处复用:
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 truevalue 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx``isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**`CanvasResourceMentionTextarea`textarea 版)保持不动,继续服务节点内文本编辑。
## 方案:新增 `CanvasPromptChipInput` 组件
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
### 组件契约(与现用法一致)
Props`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
### 实现要点(大量复用 composer 的做法)
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
3. **value ← DOMsyncFromEditor**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing``@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
7. **占位符 & 样式**:空值时显示 placeholderabsolute 占位层);容器套用面板传入的 `className/style``min-h-40`、透明底、无边框,与当前一致)。
### 替换点
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
### 不改动
- `CanvasResourceMentionTextarea`textarea 版)保留,节点内文本编辑继续用它。
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
## 影响文件
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
## 验证
1. `npx tsc --noEmit` 通过。
2. dev server 刷新后:
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
- 中文输入法输入不吞字、不误触发发送。
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
-25
View File
@@ -1,25 +0,0 @@
---
title: 打赏支持
description: 支持无限画布项目继续维护
---
# 打赏支持
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
## 支持方式
支持方式包括但不限于:
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
</a>
</div>
-1
View File
@@ -4,7 +4,6 @@
"defaultOpen": true, "defaultOpen": true,
"pages": [ "pages": [
"security", "security",
"donate",
"sponsor" "sponsor"
] ]
} }
+19 -5
View File
@@ -1,19 +1,33 @@
--- ---
title: 广告赞助 title: 赞助支持
description: 广告赞助、项目露出和社区推广说明 description: 赞助支持、广告合作、项目露出和社区推广说明
--- ---
# 广告赞助 # 赞助支持
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
## 支持方式
支持方式包括但不限于:
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
- 通过广告赞助合作支持项目维护成本。
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
## 广告赞助
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。 如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
## 适合的赞助内容 ### 适合的赞助内容
- AI 工具、模型服务、API 服务或开发者工具。 - AI 工具、模型服务、API 服务或开发者工具。
- 与图片、视频、文本生成工作流相关的产品。 - 与图片、视频、文本生成工作流相关的产品。
- 适合开源项目用户和 AI 创作者使用的服务。 - 适合开源项目用户和 AI 创作者使用的服务。
## 建议提供的信息 ### 建议提供的信息
- 品牌或产品名称。 - 品牌或产品名称。
- 希望展示的位置和周期。 - 希望展示的位置和周期。
+2 -3
View File
@@ -27,8 +27,7 @@
## 支持与安全 ## 支持与安全
- [漏洞提交](/docs/support/security) - [漏洞提交](/docs/support/security)
- [打赏支持](/docs/support/donate) - [赞助支持](/docs/support/sponsor)
- [广告赞助](/docs/support/sponsor)
## 项目进度 ## 项目进度
@@ -43,4 +42,4 @@
## 原理说明 ## 原理说明
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas) - [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
+1 -1
View File
@@ -10,7 +10,7 @@ const tabs = [
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' }, { title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' }, { title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' }, { title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' }, { title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
]; ];
export function DocsTopTabs() { export function DocsTopTabs() {
+5
View File
@@ -7,4 +7,9 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
location = /config.js {
add_header Cache-Control "no-store";
}
} }
-18
View File
@@ -32,7 +32,6 @@
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"three": "^0.180.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.12", "zustand": "^5.0.12",
}, },
@@ -42,7 +41,6 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "19.1.12", "@types/react": "19.1.12",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.1.9",
"@types/three": "^0.180.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"typescript": "^5", "typescript": "^5",
@@ -159,8 +157,6 @@
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="], "@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
@@ -613,8 +609,6 @@
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -713,20 +707,14 @@
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], "@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="], "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="], "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
@@ -741,8 +729,6 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"@webgpu/types": ["@webgpu/types@0.1.71", "", {}, "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -1343,8 +1329,6 @@
"mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="],
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -1707,8 +1691,6 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
sanitize_id() {
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
}
GA4_ID=$(sanitize_id "${ANALYTICS_GA4_ID:-}")
BAIDU_ID=$(sanitize_id "${ANALYTICS_BAIDU_ID:-}")
cat > /usr/share/nginx/html/config.js <<EOF
window.__RUNTIME_CONFIG__ = {
ANALYTICS_GA4_ID: "${GA4_ID}",
ANALYTICS_BAIDU_ID: "${BAIDU_ID}"
};
EOF
+1
View File
@@ -17,6 +17,7 @@
</head> </head>
<body class="bg-background text-foreground antialiased"> <body class="bg-background text-foreground antialiased">
<div id="root"></div> <div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+3
View File
@@ -0,0 +1,3 @@
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
@@ -48,14 +48,25 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
return ( return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}> <div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null} {!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}> <div
className={isUser ? "min-w-0 max-w-[82%] rounded-xl rounded-br-sm border px-3.5 py-2.5 text-left text-sm leading-6" : "min-w-0 flex-1 text-left text-sm leading-6"}
style={
isUser
? {
color,
background: `color-mix(in srgb, ${theme.node.text} 7%, ${theme.toolbar.panel})`,
borderColor: `color-mix(in srgb, ${theme.node.text} 14%, transparent)`,
}
: { color }
}
>
{isUser ? ( {isUser ? (
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div> <div className="whitespace-pre-wrap break-words">{item.text}</div>
) : ( ) : (
<Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown> <Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
)} )}
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null} {item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null} {item.meta ? <div className={`mt-1 text-[11px] opacity-45 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
</div> </div>
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null} {isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
</div> </div>
@@ -303,7 +314,7 @@ function toolCardState(title: string, text: string, detail?: unknown) {
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false }; if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true }; if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true }; if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false }; if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false }; return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
} }
@@ -120,13 +120,13 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
</div> </div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} /> <Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div> </div>
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}> <div className="relative rounded-xl">
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}> @ </div> : null} {!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}> @ </div> : null}
<div <div
ref={editorRef} ref={editorRef}
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none" className="thin-scrollbar min-h-28 max-h-72 w-full overflow-y-auto overscroll-contain whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
style={{ color: theme.node.text }} style={{ color: theme.node.text }}
onInput={() => { onInput={() => {
if (!composingRef.current) syncFromEditor(); if (!composingRef.current) syncFromEditor();
@@ -3,12 +3,13 @@ import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings
import { Button, Segmented } from "antd"; import { Button, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type CanvasConfigNodePanelProps = { type CanvasConfigNodePanelProps = {
@@ -95,7 +96,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</button> </button>
</div> </div>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}> <div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_148px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth /> <ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? ( {mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} /> <CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
@@ -103,7 +104,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} /> <CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : mode === "audio" ? ( ) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} /> <CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null} ) : (
<CanvasTextSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
)}
</div> </div>
<Button <Button
@@ -143,19 +146,13 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
} }
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig { function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return { return {
...globalConfig, ...globalConfig,
model, model: resolveModelForCapability(globalConfig, node.metadata?.model, mode),
reasoningEffort: node.metadata?.reasoningEffort || globalConfig.reasoningEffort || defaultConfig.reasoningEffort,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size, size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality, vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
@@ -0,0 +1,112 @@
import { useEffect, useRef } from "react";
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type ConnectionHandle, type Position } from "@/types/canvas";
export type PendingConnectionCreate = {
connection: ConnectionHandle;
position: Position;
};
export function ConnectionCreateMenu({
pending,
onCreate,
onClose,
}: {
pending: PendingConnectionCreate;
onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void;
onClose: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
data-connection-create-menu
style={{ left: pending.position.x, top: pending.position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
</span>
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
</div>
</div>
);
}
export function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
return (
<button
type="button"
className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition"
style={{ color: theme.node.text }}
onClick={onClick}
onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)}
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
>
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
{description ? (
<span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>
{description}
</span>
) : null}
</span>
</button>
);
}
export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useNodeRegistryVersion();
const menuRef = useRef<HTMLDivElement>(null);
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
// 点击菜单外的空白处自动关闭
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [onClose]);
return (
<div
ref={menuRef}
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
data-canvas-no-zoom
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
</span>
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
<X className="size-4" />
</button>
</div>
<div className="grid gap-1">
{definitions.map((def) => (
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
))}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd"; import { Button, Modal, Segmented, Tooltip } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react"; import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = { export type CanvasImageCropRect = {
@@ -17,16 +18,32 @@ type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06; const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 }; const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
const ratioOptions = [
{ label: "自由", value: "free" },
{ label: "固定", value: "fixed" },
{ label: "原图", value: "original" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) { export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop); const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false); const [ratioPreset, setRatioPreset] = useState("free");
const [fixedRatio, setFixedRatio] = useState<number | null>(null);
const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const viewport = useImageEditorViewport(image, open);
const boxRef = viewport.stageRef;
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null; const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
useEffect(() => { useEffect(() => {
if (open) setCrop(defaultCrop); if (open) {
setCrop(defaultCrop);
setRatioPreset("free");
setFixedRatio(null);
}
}, [dataUrl, open]); }, [dataUrl, open]);
useEffect(() => { useEffect(() => {
@@ -34,44 +51,78 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
void readImageMeta(dataUrl).then(setImage); void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]); }, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => { const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect(); const box = boxRef.current?.getBoundingClientRect();
if (!box) return; if (!box) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
dragAbortRef.current?.abort();
const controller = new AbortController();
dragAbortRef.current = controller;
const start = { x: event.clientX, y: event.clientY, crop }; const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => { const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width; const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height; const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box)); setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", resolveRatio(ratioPreset, image, fixedRatio), box));
}; };
const up = () => { const stop = () => controller.abort();
document.removeEventListener("pointermove", move); document.addEventListener("pointermove", move, { signal: controller.signal });
document.removeEventListener("pointerup", up); document.addEventListener("pointerup", stop, { signal: controller.signal });
}; document.addEventListener("pointercancel", stop, { signal: controller.signal });
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
}; };
return ( return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden> <Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-center"> <div
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none"> ref={viewport.viewportRef}
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} /> {...viewport.panHandlers}
<CropMask crop={crop} /> className={`relative h-[min(62vh,620px)] min-h-[340px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}> >
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" /> <div className="relative" style={viewport.contentStyle}>
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" /> <div ref={boxRef} className="absolute isolate overflow-hidden rounded-lg bg-black select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" /> <div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" /> <img src={dataUrl} alt="" className="block h-full w-full object-contain opacity-90" draggable={false} />
{handles.map((handle) => ( </div>
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" /> <CropMask crop={crop} />
))} <div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button
key={handle}
type="button"
className="absolute size-3 rounded-full border border-black bg-white"
style={handleStyle(handle)}
onPointerDown={(event) => startDrag("resize", event, handle)}
aria-label="调整裁剪框"
/>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
<span className="ml-2 text-xs opacity-55"> · +</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80"> <div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span> <span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
@@ -82,9 +133,20 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</span> </span>
) : null} ) : null}
</div> </div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}> <Segmented
{locked ? "锁定比例" : "自由比例"} size="small"
</Button> options={ratioOptions}
value={ratioPreset}
onChange={(value) => {
const preset = String(value);
setRatioPreset(preset);
const currentRatio = image ? (crop.width * image.width) / Math.max(1, crop.height * image.height) : null;
const nextFixedRatio = preset === "fixed" ? currentRatio : null;
setFixedRatio(nextFixedRatio);
const ratio = resolveRatio(preset, image, nextFixedRatio);
if (ratio && image) setCrop((current) => fitCropToRatio(current, ratio, image));
}}
/>
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -116,7 +178,7 @@ function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImag
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) }; return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
} }
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect { function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, aspectRatio: number | null, box: DOMRect): CanvasImageCropRect {
let next = { ...crop }; let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx; if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy; if (handle.includes("s")) next.height = crop.height + dy;
@@ -128,20 +190,68 @@ function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: R
next.y = crop.y + dy; next.y = crop.y + dy;
next.height = crop.height - dy; next.height = crop.height - dy;
} }
if (locked) { if (aspectRatio) {
const size = Math.max(next.width * box.width, next.height * box.height); const normalizedRatio = aspectRatio * (box.height / box.width);
next.width = size / box.width; const horizontalOnly = (handle.includes("e") || handle.includes("w")) && !handle.includes("n") && !handle.includes("s");
next.height = size / box.height; const useWidth = horizontalOnly || (handle.length > 1 && Math.abs(dx * box.width) >= Math.abs(dy * box.height));
if (useWidth) next.height = next.width / normalizedRatio;
else next.width = next.height * normalizedRatio;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width; if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height; if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
} }
next.width = clamp(next.width, minSize, 1); if (aspectRatio) {
next.height = clamp(next.height, minSize, 1); const normalizedRatio = aspectRatio * (box.height / box.width);
const scaleDown = Math.min(1, 1 / Math.max(next.width, 0.001), 1 / Math.max(next.height, 0.001));
next.width *= scaleDown;
next.height *= scaleDown;
if (next.width < minSize || next.height < minSize) {
const minimumScale = Math.max(minSize / Math.max(next.width, 0.001), minSize / Math.max(next.height, 0.001));
next.width *= minimumScale;
next.height *= minimumScale;
}
next.width = Math.min(next.width, next.height * normalizedRatio);
next.height = next.width / normalizedRatio;
} else {
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
}
next.x = clamp(next.x, 0, 1 - next.width); next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height); next.y = clamp(next.y, 0, 1 - next.height);
return next; return next;
} }
function resolveRatio(preset: string, image: { width: number; height: number } | null, fixedRatio: number | null) {
if (preset === "free" || !image) return null;
if (preset === "fixed") return fixedRatio;
if (preset === "original") return image.width / image.height;
const [width, height] = preset.split(":").map(Number);
return width > 0 && height > 0 ? width / height : null;
}
function fitCropToRatio(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect {
const normalizedRatio = ratio * (image.height / image.width);
let width = crop.width;
let height = width / normalizedRatio;
if (height > crop.height) {
height = crop.height;
width = height * normalizedRatio;
}
if (width > 1) {
width = 1;
height = width / normalizedRatio;
}
if (height > 1) {
height = 1;
width = height * normalizedRatio;
}
return {
x: clamp(crop.x + (crop.width - width) / 2, 0, 1 - width),
y: clamp(crop.y + (crop.height - height) / 2, 0, 1 - height),
width,
height,
};
}
function cropStyle(crop: CanvasImageCropRect) { function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` }; return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
} }
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd"; import { createPortal } from "react-dom";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react"; import { Button, Input, Modal, Slider, Tooltip } from "antd";
import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageMaskEditPayload = { export type CanvasImageMaskEditPayload = {
prompt: string; prompt: string;
@@ -10,20 +12,29 @@ export type CanvasImageMaskEditPayload = {
}; };
type DrawMode = "paint" | "erase"; type DrawMode = "paint" | "erase";
type Point = { x: number; y: number };
type MaskStroke = { mode: DrawMode; size: number; points: Point[] };
type BrushPreview = { x: number; y: number; size: number; adjusting: boolean };
const defaultBrushSize = 100; const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)"; const maskFillColor = "rgba(37, 99, 235, .38)";
const maskBorderColor = "rgba(255, 255, 255, .72)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) { export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const maskCanvasRef = useRef<HTMLCanvasElement>(null); const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null); const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null }); const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null });
const brushAdjustRef = useRef<{ active: boolean; pointerId: number; startX: number; startSize: number; previewX: number; previewY: number } | null>(null);
const historyRef = useRef<MaskStroke[]>([]);
const redoRef = useRef<MaskStroke[]>([]);
const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(defaultBrushSize); const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint"); const [mode, setMode] = useState<DrawMode>("paint");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [historySize, setHistorySize] = useState(0);
const [redoSize, setRedoSize] = useState(0);
const [brushPreview, setBrushPreview] = useState<BrushPreview | null>(null);
const viewport = useImageEditorViewport(image, open);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -31,6 +42,13 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
setBrushSize(defaultBrushSize); setBrushSize(defaultBrushSize);
setMode("paint"); setMode("paint");
setError(""); setError("");
setHistorySize(0);
setRedoSize(0);
setBrushPreview(null);
historyRef.current = [];
redoRef.current = [];
brushAdjustRef.current = null;
drawingRef.current = { active: false, stroke: null };
void readImageMeta(dataUrl).then(setImage); void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]); }, [dataUrl, open]);
@@ -42,53 +60,144 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => { const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY); const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current; const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d"); const context = maskCanvas?.getContext("2d", { willReadFrequently: true });
if (!maskCanvas || !context) return; const previewContext = previewCanvasRef.current?.getContext("2d");
context.lineCap = "round"; const stroke = drawingRef.current.stroke;
context.lineJoin = "round"; if (!maskCanvas || !context || !previewContext || !stroke) return;
context.lineWidth = brushSize; configureStrokeContext(context, stroke);
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out"; configurePreviewStrokeContext(previewContext, stroke);
context.strokeStyle = "#000"; const last = stroke.points.at(-1);
context.fillStyle = "#000"; drawMaskStroke(context, last || point, point, stroke.size);
if (!drawingRef.current.last) { drawMaskStroke(previewContext, last || point, point, stroke.size);
drawMaskStroke(context, point, point, brushSize); stroke.points.push(point);
} else { if (stroke.mode === "paint") {
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
}
renderMaskPreview(maskCanvas, previewCanvasRef.current);
drawingRef.current.last = point;
if (mode === "paint") {
setError(""); setError("");
} }
}; };
const updateBrushPreview = (event: ReactPointerEvent<HTMLCanvasElement>, size = brushSize, adjusting = false) => {
setBrushPreview({
x: event.clientX,
y: event.clientY,
size,
adjusting,
});
};
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => { const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if ((event.button === 0 || event.button === 2) && event.altKey) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
brushAdjustRef.current = {
active: true,
pointerId: event.pointerId,
startX: event.clientX,
startSize: brushSize,
previewX: event.clientX,
previewY: event.clientY,
};
updateBrushPreview(event, brushSize, true);
return;
}
if (event.button !== 0) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
drawingRef.current = { active: true, last: null }; updateBrushPreview(event);
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current); drawingRef.current = { active: true, stroke: { mode, size: brushSize, points: [] } };
draw(event); draw(event);
}; };
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => { const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const brushAdjust = brushAdjustRef.current;
if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) {
event.preventDefault();
event.stopPropagation();
const nextSize = clampBrushSize(brushAdjust.startSize + event.clientX - brushAdjust.startX);
setBrushSize(nextSize);
setBrushPreview({
x: brushAdjust.previewX,
y: brushAdjust.previewY,
size: nextSize,
adjusting: true,
});
return;
}
updateBrushPreview(event);
if (!drawingRef.current.active) return; if (!drawingRef.current.active) return;
event.preventDefault(); event.preventDefault();
draw(event); draw(event);
}; };
const stopDraw = () => { const stopDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
drawingRef.current = { active: false, last: null }; const brushAdjust = brushAdjustRef.current;
const maskCanvas = maskCanvasRef.current; if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) {
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas)); brushAdjustRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
updateBrushPreview(event, brushSize);
return;
}
const stroke = drawingRef.current.stroke;
drawingRef.current = { active: false, stroke: null };
if (stroke?.points.length) {
historyRef.current.push(stroke);
setHistorySize(historyRef.current.length);
redoRef.current = [];
setRedoSize(0);
}
}; };
const undoMask = useCallback(() => {
if (drawingRef.current.active || !historyRef.current.length) return;
const stroke = historyRef.current.pop();
if (stroke) redoRef.current.push(stroke);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current);
setError("");
}, []);
const redoMask = useCallback(() => {
if (drawingRef.current.active || !redoRef.current.length) return;
const stroke = redoRef.current.pop();
if (stroke) historyRef.current.push(stroke);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current);
setError("");
}, []);
const resetMask = () => { const resetMask = () => {
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
clearCanvas(maskCanvasRef.current); clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current); clearCanvas(previewCanvasRef.current);
setError(""); setError("");
}; };
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
const key = event.key.toLowerCase();
const modifier = (event.metaKey || event.ctrlKey) && !event.altKey;
const isUndo = modifier && !event.shiftKey && key === "z";
const isRedo = modifier && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y"));
if (!isUndo && !isRedo) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isRedo) redoMask();
else undoMask();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [open, redoMask, undoMask]);
const submit = () => { const submit = () => {
const nextPrompt = prompt.trim(); const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current; const canvas = maskCanvasRef.current;
@@ -99,33 +208,58 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
}; };
return ( return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]"> <div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]" data-canvas-no-zoom>
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10"> <div
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none"> ref={viewport.viewportRef}
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} /> {...viewport.panHandlers}
{image ? ( className={`relative h-[min(68vh,720px)] min-h-[360px] rounded-xl border border-black/10 bg-transparent dark:border-white/10 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
<> >
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" /> <div className="relative" style={viewport.contentStyle}>
<canvas <div ref={viewport.stageRef} className="absolute isolate overflow-hidden rounded-lg bg-transparent select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
ref={previewCanvasRef} {image ? (
width={image.width} <>
height={image.height} <canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
className="absolute inset-0 h-full w-full cursor-crosshair touch-none" <div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
onPointerDown={startDraw} <img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full bg-transparent object-contain" draggable={false} />
onPointerMove={moveDraw} <canvas
onPointerUp={stopDraw} ref={previewCanvasRef}
onPointerCancel={stopDraw} width={image.width}
/> height={image.height}
</> className="absolute inset-0 h-full w-full cursor-none touch-none"
) : null} onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
onPointerEnter={(event) => updateBrushPreview(event)}
onPointerLeave={() => {
if (!drawingRef.current.active && !brushAdjustRef.current?.active) setBrushPreview(null);
}}
onContextMenu={(event) => event.preventDefault()}
/>
</div>
</>
) : null}
</div>
</div> </div>
</div> </div>
{brushPreview
? createPortal(
<div
className={`pointer-events-none fixed z-[1100] rounded-full border-2 ${brushPreview.adjusting ? "border-[#fbbf24] bg-black/10" : "border-white/90 bg-black/5"} shadow-[0_0_0_1px_rgba(0,0,0,.8)]`}
style={{ left: brushPreview.x, top: brushPreview.y, width: Math.max(4, brushPreview.size * viewport.imageScale), aspectRatio: 1, transform: "translate(-50%, -50%)" }}
>
{brushPreview.adjusting ? <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded bg-black/75 px-1.5 py-0.5 text-xs font-semibold text-white">{brushSize}px</span> : null}
</div>,
document.body,
)
: null}
<div className="flex min-h-[360px] flex-col gap-5"> <div className="flex min-h-[360px] flex-col gap-5">
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold"></h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div> <div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
<div className="mt-2 text-xs leading-5 opacity-55"> · + · Alt+/ · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -137,6 +271,26 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10">
<Tooltip title="撤回局部涂抹 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} />
</Tooltip>
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} />
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span> <span className="font-medium opacity-75"></span>
@@ -187,8 +341,12 @@ function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: nu
}; };
} }
function clampBrushSize(value: number) {
return Math.min(160, Math.max(8, Math.round(value / 2) * 2));
}
function clearCanvas(canvas: HTMLCanvasElement | null) { function clearCanvas(canvas: HTMLCanvasElement | null) {
const context = canvas?.getContext("2d"); const context = canvas?.getContext("2d", { willReadFrequently: true });
if (!canvas || !context) return; if (!canvas || !context) return;
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
} }
@@ -206,8 +364,43 @@ function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y:
context.stroke(); context.stroke();
} }
function configureStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) {
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = stroke.size;
context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
}
function configurePreviewStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) {
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = stroke.size;
context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = maskFillColor;
context.fillStyle = maskFillColor;
}
function replayMask(strokes: MaskStroke[], maskCanvas: HTMLCanvasElement | null, previewCanvas: HTMLCanvasElement | null) {
const context = maskCanvas?.getContext("2d", { willReadFrequently: true });
const previewContext = previewCanvas?.getContext("2d");
if (!maskCanvas || !context || !previewCanvas || !previewContext) return;
context.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
previewContext.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
for (const stroke of strokes) {
configureStrokeContext(context, stroke);
configurePreviewStrokeContext(previewContext, stroke);
stroke.points.forEach((point, index) => {
const previous = stroke.points[index - 1] || point;
drawMaskStroke(context, previous, point, stroke.size);
drawMaskStroke(previewContext, previous, point, stroke.size);
});
}
}
function canvasHasPaint(canvas: HTMLCanvasElement) { function canvasHasPaint(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d"); const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return false; if (!context) return false;
const data = context.getImageData(0, 0, canvas.width, canvas.height).data; const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < data.length; index += 4) { for (let index = 3; index < data.length; index += 4) {
@@ -216,54 +409,13 @@ function canvasHasPaint(canvas: HTMLCanvasElement) {
return false; return false;
} }
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
const context = previewCanvas?.getContext("2d");
if (!previewCanvas || !context) return;
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
context.fillStyle = maskFillColor;
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
context.globalCompositeOperation = "destination-in";
context.drawImage(maskCanvas, 0, 0);
context.globalCompositeOperation = "source-over";
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
}
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return;
const { width, height } = maskCanvas;
const data = maskContext.getImageData(0, 0, width, height).data;
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
const dash = step * 8;
const gap = step * 5;
const period = dash + gap;
context.save();
context.fillStyle = maskBorderColor;
context.shadowColor = "rgba(0, 0, 0, .24)";
context.shadowBlur = step * 1.5;
for (let y = step; y < height - step; y += step) {
for (let x = step; x < width - step; x += step) {
const offset = (y * width + x) * 4 + 3;
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
if ((x + y) % period > dash) continue;
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
}
}
context.restore();
}
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
}
function buildEditMask(selectionCanvas: HTMLCanvasElement) { function buildEditMask(selectionCanvas: HTMLCanvasElement) {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = selectionCanvas.width; canvas.width = selectionCanvas.width;
canvas.height = selectionCanvas.height; canvas.height = selectionCanvas.height;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return selectionCanvas.toDataURL("image/png"); if (!context) return selectionCanvas.toDataURL("image/png");
const selectionContext = selectionCanvas.getContext("2d"); const selectionContext = selectionCanvas.getContext("2d", { willReadFrequently: true });
context.fillStyle = "#fff"; context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height); context.fillRect(0, 0, canvas.width, canvas.height);
if (!selectionContext) return canvas.toDataURL("image/png"); if (!selectionContext) return canvas.toDataURL("image/png");
@@ -3,14 +3,15 @@ import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd"; import { Button } from "antd";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library"; import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasPromptChipInput } from "./canvas-prompt-chip-input";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -37,11 +38,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent; const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || ""); const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
// 仅在切换到其它节点时恢复对应提示词;同一节点生成完成后继续保留当前输入。
useEffect(() => { useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || ""); setPrompt(node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.id]);
const updatePrompt = (value: string) => { const updatePrompt = (value: string) => {
setPrompt(value); setPrompt(value);
@@ -52,24 +55,24 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const text = prompt.trim(); const text = prompt.trim();
if (!text || isRunning) return; if (!text || isRunning) return;
onGenerate(node.id, mode, text); onGenerate(node.id, mode, text);
setPrompt("");
}; };
return ( return (
<div <div
data-canvas-no-zoom
className="rounded-2xl border p-3 shadow-2xl backdrop-blur" className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}
> >
<CanvasResourceMentionTextarea <CanvasPromptChipInput
value={prompt} value={prompt}
references={mentionReferences} references={mentionReferences}
onChange={updatePrompt} onChange={updatePrompt}
onSubmit={submit} onSubmit={submit}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none" className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }} style={{ background: "transparent", color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)} placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
/> />
@@ -78,7 +81,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<CanvasPromptLibrary onSelect={updatePrompt} /> <CanvasPromptLibrary onSelect={updatePrompt} />
{mode === "image" ? ( {mode === "image" ? (
<> <>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} /> <ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasImageSettingsPopover <CanvasImageSettingsPopover
config={config} config={config}
placement="topLeft" placement="topLeft"
@@ -90,16 +93,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</> </>
) : mode === "video" ? ( ) : mode === "video" ? (
<> <>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} /> <ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} /> <CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
</> </>
) : mode === "audio" ? ( ) : mode === "audio" ? (
<> <>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} /> <ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} /> <CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
</> </>
) : ( ) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} /> <>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasTextSettingsPopover config={config} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
</>
)} )}
</div> </div>
<Button <Button
@@ -132,19 +138,13 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
} }
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig { function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return { return {
...globalConfig, ...globalConfig,
model, model: resolveModelForCapability(globalConfig, node.metadata?.model, mode),
reasoningEffort: node.metadata?.reasoningEffort || globalConfig.reasoningEffort || defaultConfig.reasoningEffort,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size, size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality, vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd"; import { Button, InputNumber, Modal, Tooltip } from "antd";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react"; import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data"; import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageSplitParams = ImageSplitParams; export type CanvasImageSplitParams = ImageSplitParams;
@@ -15,7 +16,13 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
const [params, setParams] = useState(defaultParams); const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [active, setActive] = useState<ActiveLine>(null); const [active, setActive] = useState<ActiveLine>(null);
const previewRef = useRef<HTMLDivElement>(null); const historyRef = useRef<CanvasImageSplitParams[]>([]);
const redoRef = useRef<CanvasImageSplitParams[]>([]);
const dragAbortRef = useRef<AbortController | null>(null);
const [historySize, setHistorySize] = useState(0);
const [redoSize, setRedoSize] = useState(0);
const viewport = useImageEditorViewport(image, open);
const previewRef = viewport.stageRef;
const horizontalLines = params.horizontalLines || []; const horizontalLines = params.horizontalLines || [];
const verticalLines = params.verticalLines || []; const verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1; const rows = horizontalLines.length + 1;
@@ -28,6 +35,10 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
setParams(defaultParams); setParams(defaultParams);
setActive(null); setActive(null);
setImage(null); setImage(null);
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
}, [dataUrl, open]); }, [dataUrl, open]);
useEffect(() => { useEffect(() => {
@@ -35,20 +46,28 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
void readImageMeta(dataUrl).then(setImage); void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]); }, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const update = (key: "rows" | "columns", value: string | number | null) => { const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]); const count = clampGrid(value ?? params[key]);
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null); setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) })); setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
}; };
const addLine = (axis: "horizontal" | "vertical") => { const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => { pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines"; const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b); const spot = findLineSpot(params[key] || []);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns }; const lines = [...(params[key] || []), spot].sort((a, b) => a - b);
}); setActive({ axis, index: lines.indexOf(spot) });
setParams({ ...params, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : params.rows, columns: axis === "vertical" ? lines.length + 1 : params.columns });
}; };
const deleteLine = () => { const deleteLine = () => {
if (!active) return; if (!active) return;
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setParams((current) => { setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines"; const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = (current[key] || []).filter((_, index) => index !== active.index); const lines = (current[key] || []).filter((_, index) => index !== active.index);
@@ -58,16 +77,19 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
}; };
const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => { const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
setActive({ axis, index }); setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect(); const box = previewRef.current?.getBoundingClientRect();
if (!box) return; if (!box) return;
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
dragAbortRef.current?.abort();
const controller = new AbortController();
dragAbortRef.current = controller;
const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width); const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width);
const up = () => { const stop = () => controller.abort();
window.removeEventListener("pointermove", move); window.addEventListener("pointermove", move, { signal: controller.signal });
window.removeEventListener("pointerup", up); window.addEventListener("pointerup", stop, { signal: controller.signal });
}; window.addEventListener("pointercancel", stop, { signal: controller.signal });
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}; };
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => { const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => { setParams((current) => {
@@ -78,28 +100,93 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
}); });
}; };
const resetLines = () => { const resetLines = () => {
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null); setActive(null);
setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) })); setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) }));
}; };
const undoSplit = useCallback(() => {
const previous = historyRef.current.pop();
if (!previous) return;
redoRef.current.push(cloneSplitParams(params));
setParams(previous);
setActive(null);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
}, [params]);
const redoSplit = useCallback(() => {
const next = redoRef.current.pop();
if (!next) return;
historyRef.current.push(cloneSplitParams(params));
setParams(next);
setActive(null);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
}, [params]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
const key = event.key.toLowerCase();
const isUndo = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && key === "z";
const isRedo = (event.metaKey || event.ctrlKey) && !event.altKey && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y"));
const isDelete = event.key === "Delete" || event.key === "Backspace";
if (!isUndo && !isRedo && !isDelete) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isDelete) deleteLine();
else if (isRedo) redoSplit();
else undoSplit();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [active, open, params, redoSplit, undoSplit]);
const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns }; const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns };
return ( return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-5"> <div className="space-y-5" data-canvas-no-zoom>
<div> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p> <p className="mt-1 text-sm opacity-60"> {total} </p>
<p className="mt-2 text-xs leading-5 opacity-55"> · + · Delete 线 · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]"> <div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4"> <div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5"> <div
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl"> ref={viewport.viewportRef}
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} /> {...viewport.panHandlers}
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} /> className={`relative isolate h-[340px] min-h-[300px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
>
<div className="relative" style={viewport.contentStyle}>
<div ref={previewRef} className="absolute isolate overflow-hidden rounded-lg bg-black [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
<div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="block h-full w-full object-contain" draggable={false} />
</div>
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
</div>
</div> </div>
</div> </div>
<div className="mt-3 flex items-center justify-between text-sm"> <div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span> <div className="flex items-center gap-1">
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} />
</Tooltip>
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} />
</Tooltip>
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
</div>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span> <span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div> </div>
</div> </div>
@@ -107,10 +194,18 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} /> <NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} /> <NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</Button> <Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>线</Button> 线
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>线</Button> </Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>线</Button> <Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
线
</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
线
</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
线
</Button>
</div> </div>
<div className="rounded-xl border px-4 py-3 text-sm"> <div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -184,3 +279,19 @@ function clampGrid(value: string | number) {
const numberValue = Number(value); const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1))); return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
} }
function cloneSplitParams(params: CanvasImageSplitParams) {
return {
...params,
horizontalLines: [...(params.horizontalLines || [])],
verticalLines: [...(params.verticalLines || [])],
};
}
function pushHistory(historyRef: { current: CanvasImageSplitParams[] }, redoRef: { current: CanvasImageSplitParams[] }, params: CanvasImageSplitParams, setHistorySize: (size: number) => void, setRedoSize: (size: number) => void) {
historyRef.current.push(cloneSplitParams(params));
if (historyRef.current.length > 50) historyRef.current.shift();
redoRef.current = [];
setHistorySize(historyRef.current.length);
setRedoSize(0);
}
+36 -45
View File
@@ -26,7 +26,6 @@ type CanvasNodeProps = {
editRequestNonce?: number; editRequestNonce?: number;
showPanel: boolean; showPanel: boolean;
showImageInfo: boolean; showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[]; mentionReferences?: CanvasResourceReference[];
pluginHost?: CanvasPluginHost; pluginHost?: CanvasPluginHost;
registryVersion?: number; registryVersion?: number;
@@ -89,7 +88,6 @@ export const CanvasNode = React.memo(function CanvasNode({
editRequestNonce = 0, editRequestNonce = 0,
showPanel, showPanel,
showImageInfo, showImageInfo,
resourceLabel,
mentionReferences = [], mentionReferences = [],
pluginHost, pluginHost,
renderPanel, renderPanel,
@@ -313,39 +311,41 @@ export const CanvasNode = React.memo(function CanvasNode({
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)} onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
onContextMenu={(event) => onContextMenu(event, data.id)} onContextMenu={(event) => onContextMenu(event, data.id)}
> >
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> {(isSelected || hovered || isEditingTitle) && (
{isEditingTitle ? ( <div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<input {isEditingTitle ? (
ref={titleInputRef} <input
value={titleDraft} ref={titleInputRef}
maxLength={64} value={titleDraft}
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none" maxLength={64}
style={{ borderColor: theme.node.muted, color: theme.node.text }} className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
onChange={(event) => setTitleDraft(event.target.value)} style={{ borderColor: theme.node.muted, color: theme.node.text }}
onBlur={finishTitleEditing} onChange={(event) => setTitleDraft(event.target.value)}
onKeyDown={(event) => { onBlur={finishTitleEditing}
if (event.key === "Enter") finishTitleEditing(); onKeyDown={(event) => {
if (event.key === "Escape") { if (event.key === "Enter") finishTitleEditing();
setTitleDraft(data.title || ""); if (event.key === "Escape") {
setIsEditingTitle(false); setTitleDraft(data.title || "");
} setIsEditingTitle(false);
}} }
/> }}
) : ( />
<button ) : (
type="button" <button
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100" type="button"
style={{ color: theme.node.text }} className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
title="双击修改节点名称" style={{ color: theme.node.text }}
onDoubleClick={(event) => { title="双击修改节点名称"
event.stopPropagation(); onDoubleClick={(event) => {
setIsEditingTitle(true); event.stopPropagation();
}} setIsEditingTitle(true);
> }}
{data.title || "未命名节点"} >
</button> {data.title || "未命名节点"}
)} </button>
</div> )}
</div>
)}
<div <div
className="relative h-full w-full overflow-visible rounded-3xl border-2" className="relative h-full w-full overflow-visible rounded-3xl border-2"
@@ -414,7 +414,6 @@ export const CanvasNode = React.memo(function CanvasNode({
</div> </div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null} {showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null} {!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
@@ -427,7 +426,7 @@ export const CanvasNode = React.memo(function CanvasNode({
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null} {!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null} {!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null} {showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[600px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div> </div>
); );
}); });
@@ -568,14 +567,6 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
); );
} }
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
return (
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
{reference.label}
</span>
);
}
function ImageNodeContent(props: NodeContentRendererProps) { function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) { if (!props.node.metadata?.content && props.isBatchRoot) {
const content = const content =
@@ -0,0 +1,425 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { createPortal } from "react-dom";
import { Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type Props = {
value: string;
references: CanvasResourceReference[];
onChange: (value: string) => void;
onSubmit?: () => void;
className?: string;
style?: CSSProperties;
placeholder?: string;
};
type MentionState = {
query: string;
rect: DOMRect | null;
};
type Token =
| { type: "text"; value: string }
| { type: "reference"; label: string };
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
const lastEmittedRef = useRef(value);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
if (!query) return activeReferences;
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
}, [mention, activeReferences]);
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (document.activeElement === editor && value === lastEmittedRef.current) return;
editor.textContent = "";
tokens.forEach((token) => {
if (token.type === "text") {
editor.append(document.createTextNode(token.value));
return;
}
const reference = referenceByLabel.get(token.label);
if (reference) editor.append(createReferenceChip(reference, theme, setImagePreview));
else editor.append(document.createTextNode(token.label));
});
lastEmittedRef.current = value;
}, [tokens, referenceByLabel, theme, value]);
const emit = (next: string) => {
lastEmittedRef.current = next;
onChange(next);
};
const syncFromEditor = () => {
const editor = editorRef.current;
if (!editor) return;
emit(serializeEditor(editor));
syncMention();
};
const syncMention = () => {
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match || !activeReferences.length) {
closeMention();
return;
}
setMention({ query: match[1] || "", rect: caretRect() });
setActiveIndex(0);
};
const closeMention = () => {
setMention(null);
setActiveIndex(0);
};
const insertReference = (reference: CanvasResourceReference) => {
const editor = editorRef.current;
if (!editor) return;
removeActiveMention();
const chip = createReferenceChip(reference, theme, setImagePreview);
const space = document.createTextNode(" ");
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range) {
range.insertNode(space);
range.insertNode(chip);
range.setStartAfter(space);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
editor.append(chip, space);
placeCaretAtEnd(editor);
}
closeMention();
emit(serializeEditor(editor));
};
const showPlaceholder = !value.trim();
return (
<div className="relative w-full">
{showPlaceholder && placeholder ? (
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-5" style={{ color: theme.node.placeholder }}>
{placeholder}
</div>
) : null}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
className={`${className || ""} overflow-y-auto whitespace-pre-wrap break-words outline-none`}
style={{ ...style, cursor: "text" }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
syncFromEditor();
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
event.stopPropagation();
if (isImeComposing(event)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
event.preventDefault();
requestAnimationFrame(syncFromEditor);
return;
}
if (isPlainEnterKey(event) && onSubmit) {
event.preventDefault();
onSubmit();
return;
}
requestAnimationFrame(syncMention);
}}
onBlur={() => window.setTimeout(closeMention, 120)}
/>
{mention && candidates.length ? (
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
) : null}
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
}
function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect: DOMRect | null; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
const selectedRef = useRef(false);
const activeItemRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex, references]);
const selectReference = (reference: CanvasResourceReference) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(reference);
};
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
const menuWidth = 256;
const maxMenuHeight = 224;
const gap = 6;
const anchor = rect || new DOMRect(16, 16, 0, 0);
const left = clamp(anchor.left, 8, window.innerWidth - menuWidth - 8);
const showAbove = anchor.bottom + gap + maxMenuHeight > window.innerHeight && anchor.top - gap - maxMenuHeight >= 0;
const top = showAbove ? anchor.top - gap - maxMenuHeight : anchor.bottom + gap;
return createPortal(
<div
data-canvas-resource-mention-menu="true"
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
onClick={(event) => event.stopPropagation()}
>
{references.map((reference, index) => (
<button
key={reference.id}
ref={index === activeIndex ? activeItemRef : undefined}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
>
<ReferencePreview reference={reference} />
<span className="min-w-0 flex-1">
<span className="block font-medium">{reference.label}</span>
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
</span>
</button>
))}
</div>,
document.body,
);
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Icon className="size-4" />
</span>
);
}
function createReferenceChip(reference: CanvasResourceReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
const wrapper = document.createElement("span");
wrapper.contentEditable = "false";
wrapper.dataset.refLabel = reference.label;
if (reference.kind === "image" && reference.previewUrl) {
const image = document.createElement("img");
image.src = reference.previewUrl;
image.alt = reference.title;
image.className = "size-6 rounded object-cover";
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
wrapper.appendChild(image);
wrapper.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onImagePreview(reference.previewUrl || "");
});
} else {
wrapper.className = "mx-px inline-flex h-6 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text } as CSSProperties);
wrapper.title = reference.text || reference.title;
const text = document.createElement("span");
text.className = "block truncate";
text.textContent = reference.kind === "text" ? reference.text || reference.title : reference.label;
wrapper.appendChild(text);
}
return wrapper;
}
function serializeEditor(editor: HTMLElement) {
return serializeNodes(editor.childNodes).replace(//g, "");
}
function serializeNodes(nodes: NodeListOf<ChildNode>) {
let result = "";
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
if (!(node instanceof HTMLElement)) return;
const label = node.dataset.refLabel;
if (label) result += label;
else if (node.tagName === "BR") result += "\n";
else result += serializeNodes(node.childNodes);
});
return result;
}
function removeActiveMention() {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match) return;
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
range.deleteContents();
}
// chip 是 contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
function deleteAdjacentReference(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const target = adjacentReferenceNode(range, key);
if (!target) return false;
const nextCaretNode = document.createTextNode("");
target.replaceWith(nextCaretNode);
range.setStart(nextCaretNode, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function adjacentReferenceNode(range: Range, key: string) {
const container = range.startContainer;
const offset = range.startOffset;
const previous = key === "Backspace";
if (container.nodeType === Node.TEXT_NODE) {
const text = container.textContent || "";
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
return findReferenceSibling(container, previous);
}
const children = Array.from(container.childNodes);
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
}
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
return current instanceof HTMLElement && current.dataset.refLabel ? current : null;
}
function textBeforeCaret() {
const selection = window.getSelection();
if (!selection?.rangeCount) return "";
const range = selection.getRangeAt(0).cloneRange();
const editor = closestEditor(range.startContainer);
if (!editor) return "";
range.setStart(editor, 0);
return range.toString();
}
function caretRect(): DOMRect | null {
const selection = window.getSelection();
if (!selection?.rangeCount) return null;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getBoundingClientRect();
if (rect.width || rect.height || rect.left || rect.top) return rect;
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
const editor = closestEditor(range.startContainer);
return editor ? editor.getBoundingClientRect() : null;
}
function closestEditor(node: Node) {
const element = node instanceof Element ? node : node.parentElement;
return element?.closest("[contenteditable='true']") || null;
}
function placeCaretAtEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
function parseTokens(value: string, labels: string[]): Token[] {
if (!labels.length) return value ? [{ type: "text", value }] : [];
const escaped = labels.map(escapeRegExp).join("|");
const pattern = new RegExp(`(${escaped})`, "g");
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of value.matchAll(pattern)) {
if (match.index === undefined) continue;
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
tokens.push({ type: "reference", label: match[0] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
return tokens;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
@@ -0,0 +1,34 @@
export function CanvasRefreshShell() {
return (
<main className="relative h-full min-h-0 overflow-hidden bg-background text-foreground">
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<div className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
{Array.from({ length: 7 }).map((_, index) => (
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
))}
</div>
<div className="absolute bottom-24 left-6 z-50 h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
<div className="absolute inset-5 rounded border border-current opacity-15" />
</div>
<div className="absolute bottom-5 left-5 z-50 flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
<div className="h-4 w-10 rounded bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
</div>
</main>
);
}
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
...(style || {}), ...(style || {}),
color: showOverlay ? "transparent" : style?.color, color: showOverlay ? "transparent" : style?.color,
caretColor: style?.color || theme.node.text, caretColor: style?.color || theme.node.text,
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}), cursor: "text",
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
} as CSSProperties; } as CSSProperties;
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null; const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
return ( return (
<div className={`relative h-full w-full ${containerClassName || ""}`}> <div className={`relative h-full w-full ${containerClassName || ""}`}>
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
onKeyDown?.(event); onKeyDown?.(event);
return; return;
} }
if ((event.key === "Backspace" || event.key === "Delete") && !mention) {
const el = textareaRef.current;
if (el && el.selectionStart === el.selectionEnd) {
const result = deleteAdjacentLabel(value, el.selectionStart, event.key === "Backspace" ? "backward" : "forward", activeLabels);
if (result) {
event.preventDefault();
updateValue(result.value, result.caret);
requestAnimationFrame(updateSelectionState);
return;
}
}
}
if (mention && candidates.length) { if (mention && candidates.length) {
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
@@ -199,16 +213,24 @@ function MentionHighlightText({ value, labels, placeholder }: { value: string; l
); );
} }
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) { function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; caretIndex: number; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
const selectedRef = useRef(false); const selectedRef = useRef(false);
const rect = textarea.getBoundingClientRect(); const rect = textarea.getBoundingClientRect();
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 }; const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
const menuWidth = 256; const menuWidth = 256;
const maxMenuHeight = 224; const maxMenuHeight = 224;
const gap = 6; const gap = 6;
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8); // 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top; // 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8); const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
const computed = window.getComputedStyle(textarea);
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
const caret = getCaretPoint(textarea, caretIndex);
const caretLeft = rect.left + (caret.left - textarea.scrollLeft) * scale;
const caretTop = rect.top + (caret.top - textarea.scrollTop) * scale;
const left = clamp(caretLeft, boundary.left + 8, boundary.right - menuWidth - 8);
const showAbove = caretTop + lineHeight + gap + maxMenuHeight > boundary.bottom && caretTop - gap - maxMenuHeight >= boundary.top;
const top = clamp(showAbove ? caretTop - gap - maxMenuHeight : caretTop + lineHeight + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => { const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -273,6 +295,58 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const style = mirror.style;
style.position = "absolute";
style.top = "0";
style.left = "-9999px";
style.visibility = "hidden";
style.whiteSpace = "pre-wrap";
style.overflowWrap = "break-word";
for (const prop of MIRROR_STYLE_PROPS) style[prop] = computed[prop];
mirror.textContent = textarea.value.slice(0, index);
const marker = document.createElement("span");
marker.textContent = textarea.value.slice(index) || ".";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const point = { left: marker.offsetLeft, top: marker.offsetTop };
document.body.removeChild(mirror);
return point;
}
function escapeRegExp(value: string) { function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
if (direction === "backward") {
const before = value.slice(0, caret);
for (const label of labels) {
if (!label) continue;
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
if (match) {
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
return { value: value.slice(0, start) + value.slice(caret), caret: start };
}
}
} else {
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
const after = value.slice(caret);
for (const label of labels) {
if (!label) continue;
const match = new RegExp(`^(\\s*)(${escapeRegExp(label)})(?=\\s|$)`).exec(after);
if (match) {
return { value: value.slice(0, caret) + value.slice(caret + match[0].length), caret };
}
}
}
return null;
}
+407 -44
View File
@@ -1,22 +1,35 @@
import { useMemo, useState } from "react"; import { memo, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Empty, Input, Select, Tag } from "antd"; import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd";
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store"; import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { InsertAssetPayload } from "./asset-picker-modal"; import type { InsertAssetPayload } from "./asset-picker-modal";
type PanelTab = "canvas" | "assets"; const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000;
const PANEL_EASE = [0.22, 1, 0.36, 1] as const;
type PanelTab = "canvas" | "assets" | "prompts";
type Props = { type Props = {
nodes: CanvasNodeData[]; nodes: CanvasNodeData[];
selectedNodeIds: Set<string>; selectedNodeIds: Set<string>;
onFocusNode: (nodeId: string) => void; onFocusNode: (nodeId: string) => void;
onPreviewNode: (nodeId: string) => void;
onInsertAsset: (payload: InsertAssetPayload) => void; onInsertAsset: (payload: InsertAssetPayload) => void;
}; };
@@ -36,25 +49,79 @@ const STATUS_COLOR: Record<string, string> = {
idle: "transparent", idle: "transparent",
}; };
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) { export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas"); const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted);
const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing);
const setWidth = useCanvasSidePanelStore((state) => state.setWidth);
const [resizing, setResizing] = useState(false);
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
setWidth(nextWidth);
};
const onUp = () => {
localStorage.setItem("canvas-side-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
if (!panelMounted) return null;
return ( return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom> <motion.div
<div className="flex items-center gap-1 px-3 pt-3"> className="relative z-[60] flex h-full shrink-0"
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} /> initial={{ width: 0, opacity: 0 }}
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} /> animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
</div> transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div> style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
</aside> >
<motion.aside
className="relative flex h-full shrink-0 flex-col overflow-hidden border-r"
initial={{ x: -48 }}
animate={{ x: panelClosing ? -28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
style={{ width, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
data-canvas-no-zoom
>
<div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
<TabButton label="提示词库" active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">
{tab === "canvas" ? (
<CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} onPreviewNode={onPreviewNode} theme={theme} />
) : tab === "assets" ? (
<CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />
) : (
<CanvasPromptsTab onInsert={onInsertAsset} theme={theme} />
)}
</div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
</motion.aside>
</motion.div>
); );
} }
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) { function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
return ( return (
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}> <button type="button" onClick={onClick} className="relative pb-1.5 text-sm font-semibold transition-opacity" style={{ color: theme.node.text, opacity: active ? 1 : 0.45 }}>
{label} {label}
{active ? <motion.span layoutId="sidePanelTabIndicator" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full" style={{ background: theme.toolbar.activeText }} transition={{ type: "spring", stiffness: 500, damping: 34 }} /> : null}
</button> </button>
); );
} }
@@ -78,21 +145,65 @@ function nodePreviewText(node: CanvasNodeData) {
return getNodeDefinition(node.type)?.title || node.type; return getNodeDefinition(node.type)?.title || node.type;
} }
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) { function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all"); const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase(); const query = keyword.trim().toLowerCase();
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query))); return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
}, [nodes, keyword, typeFilter]); }, [nodes, keyword, typeFilter]);
const exitSelect = () => {
setSelectMode(false);
setChecked(new Set());
};
const toggleChecked = (id: string) =>
setChecked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id));
const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id)));
const handleExport = async () => {
const targets = nodes.filter((node) => checked.has(node.id));
if (!targets.length) return;
setExporting(true);
const hide = message.loading("正在导出选中元素…", 0);
try {
await exportCanvasNodes(targets, `画布元素-${targets.length}`);
message.success(`已导出 ${targets.length} 个元素`);
exitSelect();
} catch (error) {
console.error(error);
message.error("导出失败,请重试");
} finally {
hide();
setExporting(false);
}
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1"> <div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span> <span className="text-xs font-medium opacity-60"></span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null} {filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} /> <button
type="button"
onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
>
<ListChecks className="size-3.5" />
{selectMode ? "取消" : "选择"}
</button>
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />}
</div> </div>
<div className="px-3 pb-2.5"> <div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
@@ -103,24 +214,29 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
{filtered.map((node) => { {filtered.map((node) => {
const Icon = NODE_TYPE_ICON[node.type] || FileText; const Icon = NODE_TYPE_ICON[node.type] || FileText;
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content; const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
const active = selectedNodeIds.has(node.id); const isChecked = checked.has(node.id);
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return ( return (
<button <div key={node.id} className={cn("group flex w-full items-center rounded-lg transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} style={active ? { background: theme.toolbar.activeBg } : undefined}>
key={node.id} <button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : "定位到节点"}>
type="button" {selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
onClick={() => onFocusNode(node.id)} <span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} {isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
style={active ? { background: theme.toolbar.activeBg } : undefined} </span>
> <span className="min-w-0 flex-1 space-y-0.5">
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}> <span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />} <span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span> </span>
<span className="min-w-0 flex-1 space-y-0.5"> {node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span> </button>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span> {selectMode || !isImage ? null : (
</span> <div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5">
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null} <button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="放大预览" title="放大预览">
</button> <Eye className="size-3.5" />
</button>
</div>
)}
</div>
); );
})} })}
</div> </div>
@@ -128,10 +244,36 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
<div className="pt-16 text-center text-sm opacity-40"></div> <div className="pt-16 text-center text-sm opacity-40"></div>
)} )}
</div> </div>
{selectMode ? (
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
{allChecked ? "取消全选" : "全选"}
</button>
<span className="text-xs opacity-45"> {checked.size}</span>
<button
type="button"
onClick={() => void handleExport()}
disabled={!checked.size || exporting}
className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
<Download className="size-3.5" />
</button>
</div>
) : null}
</div> </div>
); );
} }
function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) {
return (
<span className="grid size-4 shrink-0 place-items-center rounded border transition" style={{ borderColor: checked ? theme.toolbar.activeText : theme.node.stroke, background: checked ? theme.toolbar.activeText : "transparent" }}>
{checked ? <Check className="size-3 text-white" /> : null}
</span>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布 // 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -148,11 +290,16 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload {
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }; return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
} }
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const assets = useAssetStore((state) => state.assets); const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [tagFilter, setTagFilter] = useState<string>("all"); const [tagFilter, setTagFilter] = useState<string>("all");
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]); const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
@@ -163,10 +310,51 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]); const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
const handleFiles = async (fileList: FileList | null) => {
const files = Array.from(fileList || []);
if (!files.length) return;
setUploading(true);
const hide = message.loading("正在添加资产…", 0);
let added = 0;
try {
for (const file of files) {
if (file.type.startsWith("image/")) {
const image = await uploadImage(file);
addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
added += 1;
} else if (file.type.startsWith("video/")) {
const media = await uploadMediaFile(file, "video");
addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
added += 1;
}
}
if (added) message.success(`已添加 ${added} 个资产`);
else message.warning("仅支持图片或视频文件");
} catch (error) {
console.error(error);
message.error("添加失败,请重试");
} finally {
hide();
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="px-3 pb-2"> <div className="flex items-center gap-2 px-3 pb-2 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
<Plus className="size-3.5" />
</button>
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
</div> </div>
{allTags.length ? ( {allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2"> <div className="flex flex-wrap gap-1.5 px-3 pb-2">
@@ -187,7 +375,11 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
const isCollapsed = collapsed[group.kind]; const isCollapsed = collapsed[group.kind];
return ( return (
<div key={group.kind}> <div key={group.kind}>
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100"> <button
type="button"
onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))}
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100"
>
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} /> <ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" /> <group.icon className="size-3.5" />
<span>{group.label}</span> <span>{group.label}</span>
@@ -196,7 +388,7 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
{isCollapsed ? null : ( {isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1"> <div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => ( {group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} /> <AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} />
))} ))}
</div> </div>
)} )}
@@ -210,15 +402,186 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
</div> </div>
</div> </div>
); );
} });
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) { function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
return ( return (
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}> <div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>} <AssetCover asset={asset} />
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div> <div className="absolute inset-0 flex items-center justify-center gap-2.5 opacity-0 transition duration-200 group-hover:opacity-100">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div> <button
</button> type="button"
onClick={onInsert}
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80"
aria-label="插入画布"
>
<Plus className="size-4" />
</button>
<Popconfirm title="移除该资产?" okText="移除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onRemove}>
<button
type="button"
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400"
aria-label="移除资产"
>
<Trash2 className="size-4" />
</button>
</Popconfirm>
</div>
</div>
);
}
function AssetCover({ asset }: { asset: Asset }) {
if (asset.kind === "text") return <div className="size-full overflow-hidden whitespace-pre-wrap break-words p-2.5 text-[11px] leading-snug opacity-80">{asset.data.content}</div>;
if (asset.kind === "video") {
if (asset.coverUrl) return <img src={asset.coverUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
return <video src={`${asset.data.url}#t=0.1`} muted playsInline preload="metadata" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
}
return <img src={asset.coverUrl || asset.data.dataUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
}
// ---------------------------------------------------------------------------
// 提示词库 Tab —— 按来源折叠分组,展开时按需加载,点击复制 / 插入文本节点
// ---------------------------------------------------------------------------
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const sources = usePromptSourceStore((state) => state.sources);
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
const [keyword, setKeyword] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [detail, setDetail] = useState<Prompt | null>(null);
const copyPrompt = async (prompt: string) => {
try {
await navigator.clipboard.writeText(prompt);
message.success("已复制提示词");
} catch {
message.error("复制失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2.5 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="space-y-1">
{enabledSources.length ? enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
</div>
);
});
function PromptSourceGroup({
sourceId,
sourceName,
keyword,
open,
theme,
onToggle,
onInsert,
onView,
}: {
sourceId: string;
sourceName: string;
keyword: string;
open: boolean;
theme: CanvasTheme;
onToggle: () => void;
onInsert: (payload: InsertAssetPayload) => void;
onView: (prompt: Prompt) => void;
}) {
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
const showResults = open || !!keyword.trim();
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
const filtered = useMemo(() => {
const items = query.data || [];
const q = keyword.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => [item.title, item.prompt, ...item.tags].join(" ").toLowerCase().includes(q));
}, [query.data, keyword]);
const insertPrompt = (item: Prompt) => onInsert({ kind: "text", content: item.prompt, title: item.title });
return (
<div>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
<BookOpen className="size-3.5" />
<span className="min-w-0 flex-1 truncate">{sourceName}</span>
{showResults && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
</button>
{showResults ? (
<div className="px-1 pb-2 pt-1">
{query.isLoading ? (
<div className="flex justify-center py-6">
<Spin size="small" />
</div>
) : query.isError ? (
<button type="button" onClick={() => void query.refetch()} className="block w-full py-4 text-center text-xs text-red-500 opacity-80 transition hover:opacity-100">
,
</button>
) : filtered.length ? (
<div className="space-y-1.5">
{filtered.map((item) => (
<PromptRow key={item.id} item={item} theme={theme} onInsert={() => insertPrompt(item)} onView={() => onView(item)} />
))}
</div>
) : (
<div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "该来源暂无提示词"}</div>
)}
</div>
) : null}
</div>
);
}
function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) {
return (
<div className="group relative flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-black/5 dark:hover:bg-white/5">
{item.coverUrl ? (
<img src={item.coverUrl} alt="" className="size-10 shrink-0 rounded-md object-cover" loading="lazy" />
) : (
<span className="grid size-10 shrink-0 place-items-center rounded-md" style={{ background: theme.node.panel }}>
<FileText className="size-4 opacity-50" />
</span>
)}
<button type="button" onClick={onView} className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-medium leading-snug">{item.title}</div>
<div className="mt-0.5 truncate text-xs leading-snug opacity-50">{item.prompt}</div>
</button>
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="查看详情" title="查看详情">
<Eye className="size-3.5" />
</button>
<button
type="button"
onClick={onInsert}
className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
style={{ color: theme.toolbar.activeText }}
aria-label="插入画布"
title="插入画布"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
); );
} }
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
type CanvasTextSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node) || buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate"> · {reasoningEffortLabel(config.reasoningEffort)}</span>
</Button>
</span>
{panel}
</>
);
}
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, onConfigChange }: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasTextSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap } : { top: buttonRect.bottom + gap }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
color: theme.node.text,
} as const;
return createPortal(
<div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<TextSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} />
</div>,
document.body,
);
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { canvasThemes } from "@/lib/canvas-theme";
import { useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { DOCS_URL } from "@/constant/env";
export function CanvasTopBar({
title,
titleDraft,
isTitleEditing,
onTitleDraftChange,
onStartTitleEditing,
onFinishTitleEditing,
onCancelTitleEditing,
canUndo,
canRedo,
onHome,
onProjects,
onCreateProject,
onDeleteProject,
onExportProject,
onImportImage,
onOpenPlugins,
onUndo,
onRedo,
agentOpen,
compactAgentStatus,
onToggleAgent,
}: {
title: string;
titleDraft: string;
isTitleEditing: boolean;
onTitleDraftChange: (value: string) => void;
onStartTitleEditing: () => void;
onFinishTitleEditing: () => void;
onCancelTitleEditing: () => void;
canUndo: boolean;
canRedo: boolean;
onHome: () => void;
onProjects: () => void;
onCreateProject: () => void;
onDeleteProject: () => void;
onExportProject: () => void;
onImportImage: () => void;
onOpenPlugins: () => void;
onUndo: () => void;
onRedo: () => void;
agentOpen: boolean;
compactAgentStatus: { connected: boolean; enabled: boolean; activity: string };
onToggleAgent: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const sidePanelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const toggleSidePanel = useCanvasSidePanelStore((state) => state.togglePanel);
useEffect(() => {
if (!isTitleEditing) return;
const close = (event: PointerEvent) => {
if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing();
};
document.addEventListener("pointerdown", close, true);
return () => document.removeEventListener("pointerdown", close, true);
}, [isTitleEditing, onFinishTitleEditing]);
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}>
<button
type="button"
onClick={toggleSidePanel}
aria-label={sidePanelOpen ? "收起面板" : "展开面板"}
className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
{sidePanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
</Tooltip>
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
],
}}
>
<button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<Menu className="size-4" />
</button>
</Dropdown>
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
{isTitleEditing ? (
<input
autoFocus
value={titleDraft}
onChange={(event) => onTitleDraftChange(event.target.value)}
onBlur={onFinishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") onFinishTitleEditing();
if (event.key === "Escape") onCancelTitleEditing();
}}
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
style={{ color: theme.node.text }}
/>
) : (
<button
type="button"
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
onDoubleClick={onStartTitleEditing}
title="双击修改画布名称"
>
{title}
</button>
)}
</div>
<CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} />
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
>
Agent
</Button>
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["拖动画布"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
</div>
</Modal>
</>
);
}
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
return (
<span className="flex min-w-36 items-center justify-between gap-8">
<span>{text}</span>
<span className="text-xs opacity-45">{shortcut}</span>
</span>
);
}
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return (
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[140px] truncate">{label}</span>
</button>
);
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{keys.map((key, index) => (
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
{index ? <span className="text-xs opacity-35">+</span> : null}
<kbd
className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]"
style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}
>
{key}
</kbd>
</span>
))}
</span>
<span className="text-right text-sm opacity-55">{value}</span>
</div>
);
}
@@ -152,7 +152,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
onCanvasDeselect?.(); onCanvasDeselect?.();
} }
panState.current.isPanning = false; panState.current.isPanning = false;
document.body.style.cursor = "default"; document.body.style.cursor = "";
}; };
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
type ImageSize = { width: number; height: number };
const minZoom = 1;
const maxZoom = 4;
const zoomStep = 1.2;
const viewportPadding = 16;
export function useImageEditorViewport(image: ImageSize | null, open: boolean) {
const viewportNodeRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const panRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
const zoomAnchorRef = useRef<{ zoom: number; ratioX: number; ratioY: number; viewportX: number; viewportY: number } | null>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const [viewportSize, setViewportSize] = useState<ImageSize>({ width: 0, height: 0 });
const [zoom, setZoom] = useState(minZoom);
const [isPanning, setIsPanning] = useState(false);
const [spacePressed, setSpacePressed] = useState(false);
const spacePressedRef = useRef(false);
const viewportRef = useCallback((node: HTMLDivElement | null) => {
viewportNodeRef.current = node;
setViewportElement(node);
}, []);
useEffect(() => {
if (!open) return;
zoomAnchorRef.current = null;
setZoom(minZoom);
}, [open, image?.width, image?.height]);
useEffect(() => {
if (!open) return;
const releaseSpace = () => {
spacePressedRef.current = false;
setSpacePressed(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space" || event.repeat) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (document.activeElement instanceof HTMLElement && document.activeElement.matches("button,a,[role='button']")) document.activeElement.blur();
spacePressedRef.current = true;
setSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code !== "Space" || !spacePressedRef.current) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
releaseSpace();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", releaseSpace);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", releaseSpace);
spacePressedRef.current = false;
};
}, [open]);
useEffect(() => {
if (!open || !viewportElement) return;
const updateSize = () => {
const width = viewportElement.clientWidth;
const height = viewportElement.clientHeight;
setViewportSize((current) => (current.width === width && current.height === height ? current : { width, height }));
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(viewportElement);
return () => observer.disconnect();
}, [open, viewportElement]);
const baseSize = fitImage(image, viewportSize);
const stageSize = { width: baseSize.width * zoom, height: baseSize.height * zoom };
const contentSize = {
width: Math.max(viewportSize.width, stageSize.width),
height: Math.max(viewportSize.height, stageSize.height),
};
const stageOffset = {
left: Math.max(0, Math.round((contentSize.width - stageSize.width) / 2)),
top: Math.max(0, Math.round((contentSize.height - stageSize.height) / 2)),
};
useLayoutEffect(() => {
const viewport = viewportNodeRef.current;
const anchor = zoomAnchorRef.current;
if (!viewport || !anchor || Math.abs(anchor.zoom - zoom) > 0.001) return;
const nextWidth = baseSize.width * zoom;
const nextHeight = baseSize.height * zoom;
const nextLeft = Math.max(0, (Math.max(viewport.clientWidth, nextWidth) - nextWidth) / 2);
const nextTop = Math.max(0, (Math.max(viewport.clientHeight, nextHeight) - nextHeight) / 2);
viewport.scrollLeft = nextLeft + anchor.ratioX * nextWidth - anchor.viewportX;
viewport.scrollTop = nextTop + anchor.ratioY * nextHeight - anchor.viewportY;
zoomAnchorRef.current = null;
}, [baseSize.height, baseSize.width, zoom]);
const setZoomAround = useCallback(
(nextZoom: number, clientX?: number, clientY?: number) => {
const viewport = viewportNodeRef.current;
const stage = stageRef.current;
if (!viewport || !stage || !baseSize.width || !baseSize.height) return;
const boundedZoom = clamp(nextZoom, minZoom, maxZoom);
if (Math.abs(boundedZoom - zoom) < 0.001) return;
const viewportRect = viewport.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const pointerX = clientX ?? viewportRect.left + viewportRect.width / 2;
const pointerY = clientY ?? viewportRect.top + viewportRect.height / 2;
const ratioX = clamp((pointerX - stageRect.left) / Math.max(1, stageRect.width), 0, 1);
const ratioY = clamp((pointerY - stageRect.top) / Math.max(1, stageRect.height), 0, 1);
const viewportX = pointerX - viewportRect.left;
const viewportY = pointerY - viewportRect.top;
zoomAnchorRef.current = { zoom: boundedZoom, ratioX, ratioY, viewportX, viewportY };
setZoom(boundedZoom);
},
[baseSize.height, baseSize.width, zoom],
);
useEffect(() => {
if (!open || !viewportElement) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
setZoomAround(event.deltaY < 0 ? zoom * zoomStep : zoom / zoomStep, event.clientX, event.clientY);
};
viewportElement.addEventListener("wheel", handleWheel, { passive: false });
return () => viewportElement.removeEventListener("wheel", handleWheel);
}, [open, setZoomAround, viewportElement, zoom]);
const startPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 1 && !(event.button === 0 && spacePressedRef.current)) return;
event.preventDefault();
event.stopPropagation();
const viewport = event.currentTarget;
panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, scrollLeft: viewport.scrollLeft, scrollTop: viewport.scrollTop };
viewport.setPointerCapture(event.pointerId);
setIsPanning(true);
}, []);
const movePan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
if (!pan || event.pointerId !== pan.pointerId) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollLeft = pan.scrollLeft - (event.clientX - pan.x);
event.currentTarget.scrollTop = pan.scrollTop - (event.clientY - pan.y);
}, []);
const stopPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
if (!pan || event.pointerId !== pan.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
panRef.current = null;
setIsPanning(false);
}, []);
const preventAuxClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button !== 1) return;
event.preventDefault();
event.stopPropagation();
}, []);
return {
viewportRef,
stageRef,
zoom,
isPanning,
spacePressed,
scrollClassName: zoom > minZoom + 0.001 ? "overflow-scroll" : "overflow-hidden",
panHandlers: {
onPointerDownCapture: startPan,
onPointerMoveCapture: movePan,
onPointerUpCapture: stopPan,
onPointerCancelCapture: stopPan,
onAuxClick: preventAuxClick,
},
canZoomIn: zoom < maxZoom,
canZoomOut: zoom > minZoom,
imageScale: image ? stageSize.width / image.width : 0,
zoomIn: () => setZoomAround(zoom * zoomStep),
zoomOut: () => setZoomAround(zoom / zoomStep),
resetZoom: () => setZoomAround(minZoom),
contentStyle: { width: contentSize.width, height: contentSize.height } satisfies CSSProperties,
stageStyle: {
left: stageOffset.left,
top: stageOffset.top,
width: stageSize.width,
height: stageSize.height,
} satisfies CSSProperties,
mediaStyle: {
width: baseSize.width,
height: baseSize.height,
transform: `translateZ(0) scale(${zoom})`,
transformOrigin: "top left",
} satisfies CSSProperties,
};
}
function fitImage(image: ImageSize | null, viewport: ImageSize): ImageSize {
if (!image || !viewport.width || !viewport.height) return { width: 0, height: 0 };
const availableWidth = Math.max(1, viewport.width - viewportPadding * 2);
const availableHeight = Math.max(1, viewport.height - viewportPadding * 2);
const scale = Math.min(availableWidth / image.width, availableHeight / image.height, 1);
return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+13 -1
View File
@@ -33,7 +33,7 @@ export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.siz
type ImageSettingsPanelProps = { type ImageSettingsPanelProps = {
config: AiConfig; config: AiConfig;
onConfigChange: (key: "quality" | "size" | "count", value: string) => void; onConfigChange: (key: "quality" | "size" | "count" | "background", value: string) => void;
theme: CanvasTheme; theme: CanvasTheme;
showTitle?: boolean; showTitle?: boolean;
className?: string; className?: string;
@@ -46,6 +46,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
const quality = config.quality || "auto"; const quality = config.quality || "auto";
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1))); const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto"; const activeSize = config.size || "auto";
const transparentBackground = config.background === "transparent";
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize); const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]); const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const selectAspect = (value: string) => { const selectAspect = (value: string) => {
@@ -117,6 +118,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="text-xs" style={{ color: theme.node.muted, opacity: 0.75 }}>
()
</div>
</div>
<span onMouseDown={(event) => event.stopPropagation()}>
<Switch size="small" checked={transparentBackground} onChange={(checked) => onConfigChange("background", checked ? "transparent" : "")} />
</span>
</div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { trackPageview } from "@/lib/analytics";
// 监听 SPA 路由变化并上报 pageview。无统计配置时 trackPageview 为空操作。
export function AnalyticsTracker() {
const location = useLocation();
useEffect(() => {
trackPageview(`${location.pathname}${location.search}`);
}, [location.pathname, location.search]);
return null;
}
+36 -3
View File
@@ -1,9 +1,11 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react"; import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { exportAppConfig, importAppConfig } from "@/services/config-file";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
@@ -50,6 +52,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) { export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp(); const { message } = App.useApp();
const configInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab); const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [editingChannelId, setEditingChannelId] = useState(""); const [editingChannelId, setEditingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false); const [testingWebdav, setTestingWebdav] = useState(false);
@@ -79,6 +82,17 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
clearPromptContinue(); clearPromptContinue();
}; };
const loadConfigFile = async (file: File) => {
try {
await importAppConfig(file);
message.success("配置与用户偏好已导入");
} catch (error) {
message.error(error instanceof Error ? error.message : "配置文件读取失败");
} finally {
if (configInputRef.current) configInputRef.current.value = "";
}
};
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels)); const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => { const addChannel = () => {
@@ -152,6 +166,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
return ( return (
<> <>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800">
<div className="text-xs text-stone-500">JSON API Key WebDAV </div>
<div className="flex gap-2">
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
</Button>
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
</Button>
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
</div>
</div>
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={(key) => setActiveTab(key as ConfigTabKey)} onChange={(key) => setActiveTab(key as ConfigTabKey)}
@@ -240,6 +266,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Form> </Form>
), ),
}, },
{
key: "prompt-sources",
label: "提示词来源",
children: <ConfigPromptSources />,
},
{ {
key: "webdav", key: "webdav",
label: "WebDAV", label: "WebDAV",
@@ -351,7 +382,9 @@ function normalizeImageCount(value: string) {
} }
function apiFormatLabel(apiFormat: ApiCallFormat) { function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI"; if (apiFormat === "gemini") return "Gemini";
if (apiFormat === "ark") return "火山方舟";
return "OpenAI";
} }
function formatWebdavTime(value: string) { function formatWebdavTime(value: string) {
@@ -9,6 +9,7 @@ import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [ const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" }, { label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" }, { label: "Gemini", value: "gemini" },
{ label: "火山方舟", value: "ark" },
]; ];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [ const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import { App } from "antd"; import { App } from "antd";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store"; import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
export function ClientRootInit({ children }: { children: ReactNode }) { export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp(); const { message } = App.useApp();
@@ -11,6 +12,8 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
usePromptSourceScheduler();
useEffect(() => { useEffect(() => {
if (handledConfigParams.current) return; if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -0,0 +1,155 @@
import { App, Button, Select, Switch, Tag } from "antd";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
import { PromptSourceContentModal } from "./prompt-source-content-modal";
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
export function ConfigPromptSources() {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const sources = usePromptSourceStore((state) => state.sources);
const schedule = usePromptSourceStore((state) => state.schedule);
const addSource = usePromptSourceStore((state) => state.addSource);
const saveSource = usePromptSourceStore((state) => state.saveSource);
const removeSource = usePromptSourceStore((state) => state.removeSource);
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
const statusQuery = useQuery({ queryKey: STATUS_QUERY_KEY, queryFn: fetchPromptSourceStatuses });
const [editingSource, setEditingSource] = useState<PromptSource | null>(null);
const [viewingId, setViewingId] = useState("");
const [refreshingId, setRefreshingId] = useState("");
const [refreshingAll, setRefreshingAll] = useState(false);
const viewingSource = sources.find((item) => item.id === viewingId) || null;
const invalidatePrompts = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }),
]);
};
const handleSave = (source: PromptSource) => {
saveSource(source);
void invalidatePrompts();
};
const handleDelete = (source: PromptSource) => {
modal.confirm({
title: `删除「${source.name}」?`,
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
removeSource(source.id);
await invalidatePrompts();
},
});
};
const handleRefreshOne = async (source: PromptSource) => {
setRefreshingId(source.id);
try {
const result = await refreshSource(source.id);
await invalidatePrompts();
message.success(`${source.name}」已更新 ${result.count}`);
} catch (error) {
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存");
} finally {
setRefreshingId("");
}
};
const handleRefreshAll = async () => {
setRefreshingAll(true);
try {
const result = await refreshAllSources();
updateSchedule("lastFetchedAt", new Date().toISOString());
await invalidatePrompts();
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "更新失败");
} finally {
setRefreshingAll(false);
}
};
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
</Button>
</div>
<div className="space-y-2">
{sources.map((source) => {
const status = statusQuery.data?.[source.id];
return (
<div key={source.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<Switch size="small" checked={source.enabled} onChange={(checked) => { toggleSource(source.id, checked); void invalidatePrompts(); }} />
<div className="min-w-[220px] flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{source.name}</span>
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]"></Tag> : null}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
{source.homepage || source.url}
</a>
<span className="tabular-nums">{status?.count ?? 0} </span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}></Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]"></Tag> : <Tag className="m-0 text-[10px]"></Tag>}
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
</div>
</div>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
</Button>
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}></Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}></Button> : null}
</div>
</div>
);
})}
</div>
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold"></div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-stone-500"></span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
</Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span>
</div>
<div className="mt-2 text-xs text-stone-400"></div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
</div>
);
}
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
@@ -0,0 +1,130 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchSourcePrompts, refreshSource, type Prompt } from "@/services/api/prompts";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp();
const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null);
const copyText = useCopyText();
const addAsset = useAssetStore((state) => state.addAsset);
const load = useCallback(
async (force: boolean) => {
if (!source) return;
setLoading(true);
try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败");
} finally {
setLoading(false);
}
},
[source, message],
);
useEffect(() => {
if (source) void load(false);
else setItems([]);
}, [source, load]);
const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产");
};
return (
<>
<Modal
open={Boolean(source)}
onCancel={onClose}
width={980}
footer={null}
title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div>
<div className="text-base font-semibold">{source?.name || ""} · </div>
<div className="mt-0.5 text-xs font-normal text-stone-500"> {items.length} </div>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
</Button>
</div>
}
>
<Table<Prompt>
rowKey="id"
size="small"
loading={loading}
dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }}
columns={[
{
title: "封面",
dataIndex: "coverUrl",
width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
},
{
title: "标题",
dataIndex: "title",
render: (title: string, item) => (
<div className="min-w-0">
<div className="truncate font-medium">{title}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-stone-500">{item.prompt}</div>
</div>
),
},
{
title: "标签",
dataIndex: "tags",
width: 200,
render: (tags: string[]) => (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
),
},
{
title: "操作",
width: 210,
render: (_, item) => (
<Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}>
</Button>
<Button size="small" type="text" onClick={() => setDetail(item)}>
</Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
</Button>
</Space>
),
},
]}
/>
</Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} />
</>
);
}
async function refreshSourceItems(sourceId: string) {
await refreshSource(sourceId);
return fetchSourcePrompts(sourceId);
}
@@ -0,0 +1,86 @@
import { App, Button, Drawer, Input, Space, Switch } from "antd";
import { useEffect, useState } from "react";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const { message } = App.useApp();
const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => {
if (open && source) setDraft(source);
}, [open, source]);
if (!draft) return null;
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
const save = () => {
const name = draft.name.trim();
const url = draft.url.trim();
if (!name) return message.warning("请输入来源名称");
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
onClose();
};
return (
<Drawer
open={open}
width={560}
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="space-y-5">
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
</label>
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
<span className="text-sm font-medium"></span>
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
</div>
<div>
<div className="mb-2 text-sm font-medium">JSON </div>
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
{
"id": "product-photo-1",
"title": "白底商品图",
"prompt": "生成专业白底商品摄影图",
"description": "",
"coverUrl": "",
"referenceImageUrls": [],
"tags": ["商品", "摄影"]
}
]`}</pre>
</div>
</div>
</Drawer>
);
}
function isHttpUrl(value: string) {
try {
return ["http:", "https:"].includes(new URL(value).protocol);
} catch {
return false;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { Copy } from "lucide-react"; import { Copy, FileText } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd"; import { Button, Card, Tag } from "antd";
@@ -28,7 +28,7 @@ export function PromptCard({
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
cover={ cover={
<button type="button" className="block w-full text-left" onClick={onOpen}> <button type="button" className="block w-full text-left" onClick={onOpen}>
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /> {item.coverUrl ? <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" loading="lazy" /> : <span className="grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-8" /></span>}
</button> </button>
} }
> >
@@ -38,7 +38,7 @@ export function PromptCard({
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2> <h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span> <span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div> </div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p> <p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => ( {item.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]"> <Tag key={tag} className="m-0 text-[11px]">
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts"; import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
@@ -6,9 +6,14 @@ import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20; export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) { export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword);
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => clearTimeout(timer);
}, [keyword]);
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category], queryKey: ["prompts", debouncedKeyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }), queryFn: ({ pageParam }) => fetchPrompts({ keyword: debouncedKeyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined), getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
enabled, enabled,
@@ -0,0 +1,60 @@
import { type ReactNode } from "react";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
const reasoningEffortOptions: Array<{ value: ReasoningEffort; label: string }> = [
{ value: "auto", label: "自动" },
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "xhigh", label: "极高" },
];
type TextSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
theme: CanvasTheme;
className?: string;
};
export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) {
return (
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<div className="text-lg font-semibold"></div>
<div className="space-y-2.5">
<div className="text-sm font-medium" style={{ color: theme.node.muted }}>
</div>
<div className="grid grid-cols-5 gap-2">
{reasoningEffortOptions.map((item) => (
<OptionPill key={item.value} selected={config.reasoningEffort === item.value} theme={theme} onClick={() => onConfigChange("reasoningEffort", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</div>
</div>
</ImageSettingsTheme>
);
}
export function reasoningEffortLabel(value: ReasoningEffort) {
return reasoningEffortOptions.find((item) => item.value === value)?.label || value;
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>
{children}
</button>
);
}
+8 -13
View File
@@ -2,9 +2,9 @@ import { type ReactNode } from "react";
import { Switch } from "antd"; import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme"; import { type CanvasTheme } from "@/lib/canvas-theme";
import { modelOptionName, type AiConfig } from "@/stores/use-config-store"; import { type AiConfig } from "@/stores/use-config-store";
const resolutionOptions = [ const resolutionOptions = [
{ value: "720", label: "720p" }, { value: "720", label: "720p" },
@@ -105,8 +105,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
} }
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
const model = modelOptionName(config.model || config.videoModel); const resolution = normalizeSeedanceResolution(config.vquality);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const ratio = normalizeSeedanceRatio(config.size); const ratio = normalizeSeedanceRatio(config.size);
const duration = normalizeSeedanceDuration(config.videoSeconds); const duration = normalizeSeedanceDuration(config.videoSeconds);
const generateAudio = boolConfig(config.videoGenerateAudio, true); const generateAudio = boolConfig(config.videoGenerateAudio, true);
@@ -118,16 +117,12 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
{showTitle ? <div className="text-lg font-semibold"></div> : null} {showTitle ? <div className="text-lg font-semibold"></div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}> <SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
{seedanceResolutionOptions.map((item) => { {seedanceResolutionOptions.map((item) => (
const disabled = item.value === "1080p" && isSeedanceFastModel(model); <OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
return ( {item.label}
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}> </OptionPill>
{item.label} ))}
</OptionPill>
);
})}
</div> </div>
{isSeedanceFastModel(model) ? <div className="text-[11px] leading-4 opacity-55">fast 1080p使 720p</div> : null}
</SettingGroup> </SettingGroup>
<SettingGroup title="比例" color={theme.node.muted}> <SettingGroup title="比例" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-3 gap-2.5">
+30
View File
@@ -0,0 +1,30 @@
// 运行期配置读取层。
// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。
// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。
//
// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。
type RuntimeConfig = {
ANALYTICS_GA4_ID?: string; // GA4 衡量 IDG-XXXX
ANALYTICS_BAIDU_ID?: string; // 百度统计站点 ID
};
declare global {
interface Window {
__RUNTIME_CONFIG__?: RuntimeConfig;
}
}
const runtime: RuntimeConfig = (typeof window !== "undefined" && window.__RUNTIME_CONFIG__) || {};
function read(key: keyof RuntimeConfig, buildTime: string | undefined, fallback = ""): string {
const value = runtime[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof buildTime === "string" && buildTime.trim()) return buildTime.trim();
return fallback;
}
export const ANALYTICS_GA4_ID = read("ANALYTICS_GA4_ID", import.meta.env.VITE_ANALYTICS_GA4_ID);
export const ANALYTICS_BAIDU_ID = read("ANALYTICS_BAIDU_ID", import.meta.env.VITE_ANALYTICS_BAIDU_ID);
@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { refreshDueSources } from "@/services/api/prompts";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
const CHECK_INTERVAL_MS = 60_000;
/** Periodically update only the sources whose last successful refresh is due. */
export function usePromptSourceScheduler() {
const queryClient = useQueryClient();
const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes);
useEffect(() => {
if (!intervalMinutes) return;
let running = false;
const tick = async () => {
if (running) return;
const { updateSchedule } = usePromptSourceStore.getState();
running = true;
try {
const result = await refreshDueSources(intervalMinutes * 60_000);
if (!result.results.length) return;
updateSchedule("lastFetchedAt", new Date().toISOString());
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
queryClient.invalidateQueries({ queryKey: ["prompt-source-statuses"] }),
]);
} catch {
// 单个来源的错误已写入来源状态,下一个检查周期会继续尝试。
} finally {
running = false;
}
};
void tick();
const timer = window.setInterval(() => void tick(), CHECK_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [intervalMinutes, queryClient]);
}
+63 -5
View File
@@ -4,6 +4,7 @@ import { fetchPrompts } from "@/services/api/prompts";
import { uploadImage } from "@/services/image-storage"; import { uploadImage } from "@/services/image-storage";
import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel"; import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel";
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel"; import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAssetStore } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store";
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store"; import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
@@ -14,6 +15,7 @@ import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
export const SITE_TOOL_NAMES = [ export const SITE_TOOL_NAMES = [
"canvas_list_projects", "canvas_list_projects",
"generation_get_status",
"workbench_image_get_config", "workbench_image_get_config",
"workbench_image_generate", "workbench_image_generate",
"workbench_video_get_config", "workbench_video_get_config",
@@ -31,6 +33,7 @@ export function isSiteTool(name: string): name is SiteToolName {
export const SITE_TOOL_LABELS: Record<SiteToolName, string> = { export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
canvas_list_projects: "画布列表", canvas_list_projects: "画布列表",
generation_get_status: "生成任务状态",
workbench_image_get_config: "生图配置", workbench_image_get_config: "生图配置",
workbench_image_generate: "生图工作台生成", workbench_image_generate: "生图工作台生成",
workbench_video_get_config: "视频配置", workbench_video_get_config: "视频配置",
@@ -41,11 +44,16 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
}; };
type SiteToolInput = Record<string, unknown>; type SiteToolInput = Record<string, unknown>;
type SiteToolContext = { canvasSnapshot?: CanvasAgentSnapshot | null };
type GenerationStatus = "idle" | "queued" | "running" | "succeeded" | "failed";
type GenerationStatusItem = { id: string; source: "canvas" | "image" | "video"; status: GenerationStatus; kind?: string; title?: string; prompt?: string; projectId?: string; createdAt?: string; updatedAt?: string; successCount?: number; failCount?: number; error?: string };
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction): Promise<unknown> { export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction, context: SiteToolContext = {}): Promise<unknown> {
switch (name) { switch (name) {
case "canvas_list_projects": case "canvas_list_projects":
return listCanvasProjects(input); return listCanvasProjects(input);
case "generation_get_status":
return getGenerationStatus(input, context.canvasSnapshot);
case "workbench_image_get_config": case "workbench_image_get_config":
return getImageConfig(); return getImageConfig();
case "workbench_image_generate": case "workbench_image_generate":
@@ -65,6 +73,56 @@ export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navi
} }
} }
function getGenerationStatus(input: SiteToolInput, canvasSnapshot?: CanvasAgentSnapshot | null) {
const scope = input.scope === "canvas" || input.scope === "image" || input.scope === "video" ? input.scope : "all";
const taskId = typeof input.taskId === "string" ? input.taskId : "";
const nodeIds = new Set(Array.isArray(input.nodeIds) ? input.nodeIds.filter((id): id is string => typeof id === "string") : []);
const limit = Math.max(1, Math.min(100, Math.floor(Number(input.limit)) || 20));
const tasks: GenerationStatusItem[] = [];
const includeCanvas = (scope === "all" || scope === "canvas") && (!taskId || nodeIds.size > 0);
const includeWorkbench = !nodeIds.size || Boolean(taskId);
if (includeCanvas && canvasSnapshot) {
canvasSnapshot.nodes.forEach((node) => {
const status = normalizeCanvasGenerationStatus(node.metadata?.status);
if (!status || (nodeIds.size && !nodeIds.has(node.id))) return;
const metadata = node.metadata || {};
if (!nodeIds.size && node.type !== "config" && status !== "running" && status !== "failed" && !metadata.generationMode && !metadata.generationType && !metadata.model) return;
tasks.push({ id: node.id, source: "canvas", status, kind: metadata.generationMode || node.type, title: node.title, prompt: compactPrompt(metadata.prompt || metadata.composerContent), projectId: canvasSnapshot.projectId, error: metadata.errorDetails });
});
}
if (includeWorkbench) {
useWorkbenchAgentStore.getState().tasks.forEach((task) => {
if ((scope === "image" || scope === "video") && task.kind !== scope) return;
if (scope === "canvas" || (taskId && task.id !== taskId)) return;
tasks.push({ ...task, source: task.kind, prompt: compactPrompt(task.prompt) });
});
}
tasks.sort((a, b) => generationStatusOrder(a.status) - generationStatusOrder(b.status) || (b.updatedAt || "").localeCompare(a.updatedAt || ""));
const summary: Record<GenerationStatus, number> = { idle: 0, queued: 0, running: 0, succeeded: 0, failed: 0 };
tasks.forEach((task) => (summary[task.status] += 1));
return { total: tasks.length, summary, tasks: tasks.slice(0, limit) };
}
function generationStatusOrder(status: GenerationStatus) {
return status === "running" ? 0 : status === "queued" ? 1 : 2;
}
function normalizeCanvasGenerationStatus(status: unknown): GenerationStatus | null {
if (status === "idle") return "idle";
if (status === "loading") return "running";
if (status === "success") return "succeeded";
if (status === "error") return "failed";
return null;
}
function compactPrompt(prompt: unknown) {
const value = typeof prompt === "string" ? prompt.trim() : "";
return value ? `${value.slice(0, 200)}${value.length > 200 ? "..." : ""}` : undefined;
}
function listCanvasProjects(input: SiteToolInput) { function listCanvasProjects(input: SiteToolInput) {
const { projects, hydrated } = useCanvasStore.getState(); const { projects, hydrated } = useCanvasStore.getState();
if (!hydrated) throw new Error("画布还在加载中,请稍后重试"); if (!hydrated) throw new Error("画布还在加载中,请稍后重试");
@@ -118,8 +176,8 @@ function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined; const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false; const run = input.run !== false;
navigate("/image"); navigate("/image");
useWorkbenchAgentStore.getState().dispatchImage({ prompt, run }); const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, applied, note: run ? "已跳转生图工作台并触发生成,结果请稍后在工作台查看" : "已跳转生图工作台并填入参数,未触发生成" }; return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: run ? "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务" : "已跳转生图工作台并填入参数,未触发生成" };
} }
function getVideoConfig() { function getVideoConfig() {
@@ -173,8 +231,8 @@ function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined; const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false; const run = input.run !== false;
navigate("/video"); navigate("/video");
useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run }); const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, applied, note: run ? "已跳转视频创作台并触发生成,结果请稍后在工作台查看" : "已跳转视频创作台并填入参数,未触发生成" }; return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: run ? "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务" : "已跳转视频创作台并填入参数,未触发生成" };
} }
async function searchPrompts(input: SiteToolInput) { async function searchPrompts(input: SiteToolInput) {
+85
View File
@@ -0,0 +1,85 @@
// 统计分析加载器:默认关闭,可同时启用多家。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接——
// 不接受任意脚本 URL / 内联 JS,避免「配置项被用来在访客浏览器执行任意代码」。
// 全空时不注入任何脚本、不发任何外部请求。这是开源项目的硬要求:
// fork/自托管者默认零统计,官方站点仅通过环境变量注入自己的 ID(ID 不入库)。
import { ANALYTICS_BAIDU_ID, ANALYTICS_GA4_ID } from "@/constant/runtime-config";
type GtagFn = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: unknown[];
gtag?: GtagFn;
_hmt?: unknown[][];
}
}
let initialized = false;
// 记录实际启用了哪些统计,供路由上报时按需分发。
const active = { ga4: false, baidu: false };
function appendScript(src: string, attrs: Record<string, string> = {}) {
const el = document.createElement("script");
el.async = true;
el.src = src;
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
document.head.appendChild(el);
return el;
}
function initGa4(id: string) {
window.dataLayer = window.dataLayer || [];
const gtag: GtagFn = (...args) => {
window.dataLayer!.push(args);
};
window.gtag = gtag;
appendScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`);
gtag("js", new Date());
// SPA 路由上报交给 trackPageview,这里关闭默认的自动 page_view,避免重复。
gtag("config", id, { send_page_view: false });
active.ga4 = true;
}
function initBaidu(id: string) {
window._hmt = window._hmt || [];
appendScript(`https://hm.baidu.com/hm.js?${encodeURIComponent(id)}`);
active.baidu = true;
}
export function initAnalytics() {
if (initialized || typeof window === "undefined") return;
initialized = true;
// 各家相互独立,逐个判断并启用;任一家出错都不影响其它家与主应用。
if (ANALYTICS_GA4_ID) {
try {
initGa4(ANALYTICS_GA4_ID);
} catch {
/* 忽略 */
}
}
if (ANALYTICS_BAIDU_ID) {
try {
initBaidu(ANALYTICS_BAIDU_ID);
} catch {
/* 忽略 */
}
}
}
// SPA 路由切换时上报页面浏览,分发给所有已启用的统计。
export function trackPageview(path: string) {
try {
if (active.ga4 && window.gtag) {
window.gtag("event", "page_view", { page_path: path, page_location: window.location.href });
}
if (active.baidu && window._hmt) {
window._hmt.push(["_trackPageview", path]);
}
} catch {
/* 忽略 */
}
}
+37
View File
@@ -5,6 +5,7 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage"; import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export"; import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") { export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = []; const zipFiles: { name: string; data: BlobPart }[] = [];
@@ -29,6 +30,39 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
saveAs(zip, `${safeFileName(fileName)}.zip`); saveAs(zip, `${safeFileName(fileName)}.zip`);
} }
export async function exportCanvasNodes(nodes: CanvasNodeData[], fileName = "画布元素") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const used = new Set<string>();
const uniqueName = (base: string, ext: string) => {
const safe = safeFileName(base) || "元素";
let name = `${safe}.${ext}`;
for (let i = 1; used.has(name); i += 1) name = `${safe}-${i}.${ext}`;
used.add(name);
return name;
};
await Promise.all(
nodes.map(async (node) => {
const title = node.title || node.type;
const storageKey = node.metadata?.storageKey || "";
if (storageKey) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (blob) return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
if (node.type === CanvasNodeType.Text) return void zipFiles.push({ name: uniqueName(title, "txt"), data: node.metadata?.content || node.metadata?.prompt || "" });
const content = node.metadata?.content;
if (content && content.startsWith("data:")) {
const blob = await (await fetch(content)).blob();
return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
zipFiles.push({ name: uniqueName(title, "json"), data: JSON.stringify(node, null, 2) });
}),
);
const zip = await createZip(zipFiles);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) { function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys]; if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey); if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
@@ -47,5 +81,8 @@ function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("gif")) return "gif"; if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4"; if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm"; if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("ogg")) return "ogg";
return storageKey.startsWith("image:") ? "png" : "bin"; return storageKey.startsWith("image:") ? "png" : "bin";
} }
@@ -0,0 +1,159 @@
import { defaultConfig, resolveModelForCapability, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
export function imageExtension(dataUrl: string) {
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
}
export function audioExtension(mimeType?: string) {
if (mimeType?.includes("wav")) return "wav";
if (mimeType?.includes("opus")) return "opus";
if (mimeType?.includes("aac")) return "aac";
if (mimeType?.includes("flac")) return "flac";
if (mimeType?.includes("pcm")) return "pcm";
return "mp3";
}
export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
return [
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
];
}
export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
if (metadata.generationType !== "edit") return [];
if (!metadata.references?.length) return null;
const references = await Promise.all(
metadata.references.map(async (url, index) => {
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
}),
);
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
}
export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all(
nodes.map(async (node) => {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
}),
);
}
export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
if (item.dataUrl?.startsWith("data:image/")) {
const image = await uploadImage(item.dataUrl);
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
}
return item;
};
return Promise.all(
sessions.map(async (session) => ({
...session,
messages: await Promise.all(
session.messages.map(async (message) => ({
...message,
references: await Promise.all((message.references || []).map(hydrateItem)),
})),
),
})),
);
}
export function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
export function getInputSummary(inputs: NodeGenerationInput[]) {
return {
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: inputs.filter((input) => input.type === "image").length,
videoCount: inputs.filter((input) => input.type === "video").length,
audioCount: inputs.filter((input) => input.type === "audio").length,
};
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
return {
...config,
model: resolveModelForCapability(config, node?.metadata?.model, mode),
reasoningEffort: node?.metadata?.reasoningEffort || config.reasoningEffort || defaultConfig.reasoningEffort,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
};
}
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
}
export function isGenerationCanceled(error: unknown) {
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError");
}
export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
const visited = new Set<string>();
while (queue.length) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodes.find((item) => item.id === id);
if (node?.type === CanvasNodeType.Config) return node;
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
}
return null;
}
export function sourceNodeReferenceImages(node: CanvasNodeData | null) {
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
return [
{
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
},
];
}
export function isAudioFile(file: File) {
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
}
export function buildAngleLabel(params: CanvasImageAngleParams) {
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle}` : `向左旋转 ${Math.abs(params.horizontalAngle)}`;
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle}` : `仰视 ${Math.abs(params.pitchAngle)}`;
return `AI 多角度:${horizontal}${pitch},镜头距离 ${params.cameraDistance.toFixed(1)}${params.wideAngle ? "广角" : "标准"}镜头`;
}
export function buildAnglePrompt(params: CanvasImageAngleParams) {
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}`;
}
+71
View File
@@ -0,0 +1,71 @@
import { getNodeSpec, NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import type { AiConfig } from "@/stores/use-config-store";
import type { UploadedImage } from "@/services/image-storage";
import type { UploadedFile } from "@/services/file-storage";
import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type Position } from "@/types/canvas";
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return {
id,
type,
title: spec.title,
position: {
x: position.x - spec.width / 2,
y: position.y - spec.height / 2,
},
width: spec.width,
height: spec.height,
metadata: { ...spec.metadata, ...metadata },
};
}
export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
}
export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
}
export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
}
export function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
}
export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
generationType: type,
model: config.model,
size: config.size,
quality: config.quality,
...(config.background ? { background: config.background } : {}),
count,
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
};
}
export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
return {
model: config.model,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
};
}
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
const safePatch = patch || {};
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null;
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
}
@@ -0,0 +1,91 @@
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle } from "@/types/canvas";
export function nodeBounds(nodes: CanvasNodeData[]) {
return nodes.reduce(
(acc, node) => ({
left: Math.min(acc.left, node.position.x),
top: Math.min(acc.top, node.position.y),
right: Math.max(acc.right, node.position.x + node.width),
bottom: Math.max(acc.bottom, node.position.y + node.height),
}),
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
);
}
export function findGroupDropTarget(movedIds: Set<string>, nodes: CanvasNodeData[]) {
if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
if (!movingNodes.length) return null;
return (
[...nodes].reverse().find((group) => {
if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
return movingNodes.some((node) => {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
});
}) || null
);
}
export function snapNodesIntoGroup(movedIds: Set<string>, nodes: CanvasNodeData[], group: CanvasNodeData) {
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
if (!movingNodes.length) return nodes;
const pad = 24;
const bounds = nodeBounds(movingNodes);
const left = group.position.x + pad;
const top = group.position.y + pad;
const right = group.position.x + group.width - pad;
const bottom = group.position.y + group.height - pad;
const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
return nodes.map((node) => {
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
});
}
export function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return (
[...nodes]
.reverse()
.find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id ||
undefined
);
}
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
return {
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
y: node.position.y + node.height / 2,
};
}
export function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
const first = nodes.find((node) => node.id === firstNodeId);
const second = nodes.find((node) => node.id === secondNodeId);
if (!first || !second || first.id === second.id) return null;
if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
return { fromNodeId: first.id, toNodeId: second.id };
}
export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
if (root && collapsingBatchIds?.has(rootId)) return false;
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
@@ -16,13 +16,6 @@ export type CanvasResourceReference = {
active: boolean; active: boolean;
}; };
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
}
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) { export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true); return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
} }
+9 -22
View File
@@ -1,4 +1,4 @@
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -7,9 +7,10 @@ export const SEEDANCE_REFERENCE_LIMITS = {
videos: 3, videos: 3,
audios: 3, audios: 3,
imageMaxBytes: 30 * 1024 * 1024, imageMaxBytes: 30 * 1024 * 1024,
videoMaxBytes: 50 * 1024 * 1024, videoMaxBytes: 200 * 1024 * 1024,
audioMaxBytes: 15 * 1024 * 1024, audioMaxBytes: 15 * 1024 * 1024,
}; };
export const SEEDANCE_VIDEO_MIME_TYPES = ["video/mp4", "video/quicktime"];
export const seedanceResolutionOptions = [ export const seedanceResolutionOptions = [
{ value: "480p", label: "480p" }, { value: "480p", label: "480p" },
@@ -56,28 +57,13 @@ const seedancePixels = {
}, },
} as const; } as const;
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) { export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "apiFormat">) {
const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config; const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl); return requestConfig.apiFormat === "ark";
} }
export function isSeedanceVideoModel(model: string) { export function normalizeSeedanceResolution(value: string) {
const value = model.toLowerCase();
return value.includes("seedance") || value.includes("doubao-seedance");
}
export function isSeedanceFastModel(model: string) {
const value = model.toLowerCase();
return isSeedanceVideoModel(value) && value.includes("fast");
}
export function isArkPlanBaseUrl(baseUrl: string) {
return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3");
}
export function normalizeSeedanceResolution(value: string, model = "") {
const normalized = normalizeResolutionToken(value); const normalized = normalizeResolutionToken(value);
if (isSeedanceFastModel(model) && normalized === "1080p") return "720p";
return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p"; return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
} }
@@ -149,7 +135,8 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
for (let index = 0; index < videos.length; index += 1) { for (let index = 0; index < videos.length; index += 1) {
const video = videos[index]; const video = videos[index];
const label = seedanceReferenceLabel("video", index); const label = seedanceReferenceLabel("video", index);
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`; if (!SEEDANCE_VIDEO_MIME_TYPES.includes(video.type)) return `${label} 仅支持 mp4/mov 格式`;
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 200MB,请压缩后再上传`;
if (video.durationMs) { if (video.durationMs) {
if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`; if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
totalDurationMs += video.durationMs; totalDurationMs += video.durationMs;
@@ -159,7 +146,7 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
const ratio = video.width / video.height; const ratio = video.width / video.height;
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`; if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`;
const pixels = video.width * video.height; const pixels = video.width * video.height;
if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`; if (pixels < 640 * 640 || pixels > 3326 * 2494) return `${label} 像素需要在 409600-8295044 之间`;
} }
} }
if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒"; if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒";
+3
View File
@@ -6,8 +6,11 @@ import "./styles/globals.css";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { AppProviders } from "@/components/layout/app-providers"; import { AppProviders } from "@/components/layout/app-providers";
import { initAnalytics } from "@/lib/analytics";
import { router } from "@/router"; import { router } from "@/router";
initAnalytics();
document.body.style.fontFamily = '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif'; document.body.style.fontFamily = '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif';
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { useAgentStore } from "@/stores/use-agent-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
type GenerateNodeRef = MutableRefObject<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null>;
type AgentBridgeParams = {
projectId: string;
title: string | undefined;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
selectedNodeIds: Set<string>;
viewport: ViewportTransform;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
selectedNodeIdsRef: MutableRefObject<Set<string>>;
viewportRef: MutableRefObject<ViewportTransform>;
generateNodeRef: GenerateNodeRef;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
};
/**
* Agent apply/undo agent store
* Codex applyAgentOps宿
*/
export function useAgentBridge(params: AgentBridgeParams) {
const { projectId, title, nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu } =
params;
const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext);
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
const projectTitle = title || "未命名画布";
const agentSnapshot = useMemo<CanvasAgentSnapshot>(() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), [connections, projectTitle, nodes, projectId, selectedNodeIds, viewport]);
const applyAgentOps = useCallback(
(ops?: CanvasAgentOp[]) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(
before,
safeOps.filter((op) => op.type !== "run_generation"),
);
nodesRef.current = next.nodes;
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
viewportRef.current = next.viewport;
setAgentUndoSnapshot(before);
setNodes(next.nodes);
setConnections(next.connections);
setSelectedNodeIds(new Set(next.selectedNodeIds));
setSelectedConnectionId(null);
setViewport(next.viewport);
setContextMenu(null);
if (generationOps.length) {
queueMicrotask(() =>
generationOps.forEach((op) => {
const target = nodesRef.current.find((node) => node.id === op.nodeId);
const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
}),
);
}
return { ...next, projectId, title: projectTitle };
},
[projectTitle, projectId],
);
const undoAgentOps = useCallback(() => {
if (!agentUndoSnapshot) return null;
nodesRef.current = agentUndoSnapshot.nodes;
connectionsRef.current = agentUndoSnapshot.connections;
selectedNodeIdsRef.current = new Set(agentUndoSnapshot.selectedNodeIds);
viewportRef.current = agentUndoSnapshot.viewport;
setNodes(agentUndoSnapshot.nodes);
setConnections(agentUndoSnapshot.connections);
setSelectedNodeIds(new Set(agentUndoSnapshot.selectedNodeIds));
setSelectedConnectionId(null);
setViewport(agentUndoSnapshot.viewport);
setContextMenu(null);
setAgentUndoSnapshot(null);
return { ...agentUndoSnapshot, projectId, title: projectTitle };
}, [agentUndoSnapshot, projectTitle, projectId]);
useEffect(() => {
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
return () => setAgentCanvasContext(null);
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
return { applyAgentOps };
}
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { buildGenerationConfig } from "@/lib/canvas/canvas-generation-helpers";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import type { ReferenceImage } from "@/types/image";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type PluginHostParams = {
effectiveConfig: AiConfig;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
openConfigDialog: (open: boolean) => void;
theme: CanvasTheme;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
viewportRef: MutableRefObject<ViewportTransform>;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
applyAgentOps: (ops?: CanvasAgentOp[]) => unknown;
};
/**
* 宿宿 AI host/ai
*
*/
export function usePluginHost(params: PluginHostParams) {
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
const pluginAi = useMemo<CanvasPluginAi>(() => {
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true);
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
}
};
return {
generateImage: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
ensureReady(config);
const references = toReferences(options?.references);
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
return { images: items.map((item) => item.dataUrl) };
},
generateVideo: async (prompt, options) => {
const config = {
...buildGenerationConfig(effectiveConfig, undefined, "video"),
...(options?.model ? { model: options.model } : {}),
...(options?.size ? { size: options.size } : {}),
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
};
ensureReady(config);
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
},
generateText: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
ensureReady(config);
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
return { text };
},
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
};
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
const pluginHost = useMemo<CanvasPluginHost>(
() => ({
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
getNodes: () => nodesRef.current,
getConnections: () => connectionsRef.current,
getUpstream: (nodeId) =>
connectionsRef.current
.filter((conn) => conn.toNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node)),
getDownstream: (nodeId) =>
connectionsRef.current
.filter((conn) => conn.fromNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId))
.filter((node): node is CanvasNodeData => Boolean(node)),
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
applyOps: (ops) => applyAgentOps(ops),
ai: pluginAi,
openPanel: (nodeId) => setDialogNodeId(nodeId),
closePanel: () => setDialogNodeId(null),
}),
[applyAgentOps, pluginAi],
);
const renderPluginPanel = useCallback(
(panelNode: CanvasNodeData) => {
const Panel = getNodeDefinition(panelNode.type)?.Panel;
if (!Panel) return null;
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
},
[pluginHost, theme],
);
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
const buildNodeToolbarItems = useCallback(
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
const definition = getNodeDefinition(node.type);
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
const custom = definition?.toolbar?.(ctx) || [];
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = {
id: "node-interaction-toggle",
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
label: interactive ? "移动" : "交互",
icon: interactive ? "✋" : "🖐",
active: interactive,
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
};
return [toggle, ...custom];
},
[pluginHost, theme],
);
// 启动时加载已安装的远程插件
useEffect(() => {
void ensurePluginsLoaded();
}, []);
return { pluginHost, renderPluginPanel, buildNodeToolbarItems };
}
File diff suppressed because it is too large Load Diff
+46 -5
View File
@@ -69,6 +69,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function ImagePage() { export default function ImagePage() {
const { message } = App.useApp(); const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig(); const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig); const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -89,10 +90,13 @@ export default function ImagePage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]); const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null); const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [isReferenceDragActive, setIsReferenceDragActive] = useState(false);
const [autoRunToken, setAutoRunToken] = useState(0); const [autoRunToken, setAutoRunToken] = useState(0);
const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand); const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand);
const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand); const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0); const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.imageModel || effectiveConfig.model; const model = effectiveConfig.imageModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim()); const canGenerate = Boolean(prompt.trim());
@@ -141,22 +145,30 @@ export default function ImagePage() {
}; };
const generate = async () => { const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const text = prompt.trim(); const text = prompt.trim();
if (!text) { if (!text) {
message.error("请输入生图提示词"); message.error("请输入生图提示词");
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "请输入生图提示词" });
return; return;
} }
if (!isAiConfigReady(effectiveConfig, model)) { if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置"); message.warning("请先完成配置");
openConfigDialog(true); openConfigDialog(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图配置不完整" });
return; return;
} }
const snapshot = buildRequestSnapshot(); const snapshot = buildRequestSnapshot();
if (!snapshot) return; if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图参数无效" });
return;
}
setElapsedMs(0); setElapsedMs(0);
setRunning(true); setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null); setPreviewLog(null);
setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" }))); setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" })));
const batchStartedAt = performance.now(); const batchStartedAt = performance.now();
@@ -169,6 +181,8 @@ export default function ImagePage() {
const successCount = successImages.length; const successCount = successImages.length;
const failCount = generationCount - successCount; const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? "生成失败" : undefined;
if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error });
try { try {
const logImages = await Promise.all( const logImages = await Promise.all(
@@ -202,8 +216,15 @@ export default function ImagePage() {
processedCommandRef.current = imageCommand.nonce; processedCommandRef.current = imageCommand.nonce;
clearImageCommand(); clearImageCommand();
if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt); if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt);
if (imageCommand.run && !running) setAutoRunToken((value) => value + 1); if (imageCommand.run && running) {
}, [imageCommand, clearImageCommand, running]); if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: "生图工作台已有任务正在运行" });
return;
}
if (imageCommand.run) {
agentTaskIdRef.current = imageCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [imageCommand, clearImageCommand, running, updateAgentTask]);
useEffect(() => { useEffect(() => {
if (!autoRunToken) return; if (!autoRunToken) return;
@@ -408,7 +429,27 @@ export default function ImagePage() {
</div> </div>
</div> </div>
<div <div
className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" className={`hover-scrollbar hover-scrollbar-hint relative flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${isReferenceDragActive ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => {
event.preventDefault();
dragDepthRef.current += 1;
if (event.dataTransfer.types.includes("Files")) setIsReferenceDragActive(true);
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (!dragDepthRef.current) setIsReferenceDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
dragDepthRef.current = 0;
setIsReferenceDragActive(false);
void addReferences(event.dataTransfer.files);
}}
onWheel={(event) => { onWheel={(event) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return; if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault(); event.preventDefault();
@@ -430,7 +471,7 @@ export default function ImagePage() {
</button> </button>
</div> </div>
))} ))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"></div> : null} {!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{isReferenceDragActive ? "松开即可添加参考图" : "暂无参考图,可将图片拖到这里"}</div> : null}
</div> </div>
</div> </div>
@@ -1,46 +1,44 @@
import { Copy, FolderPlus } from "lucide-react"; import { Copy, FileText, FolderPlus } from "lucide-react";
import { Button, Modal, Space, Tag } from "antd"; import { Button, Modal, Space, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts"; import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) { export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return ( return (
<> <Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}> {prompt ? (
{prompt ? ( <div className="flex h-full min-h-0 flex-col">
<> <div className="shrink-0 space-y-3 pb-4">
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]"> {prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="h-48 w-full rounded-lg object-cover sm:h-56" /> : <div className="grid h-48 w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600 sm:h-56"><FileText className="size-9" /></div>}
<div className="space-y-3"> {prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-6 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> </div>
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null} <div className="min-h-0 min-w-0 flex-1 overflow-y-auto border-y border-stone-200 py-4 pr-2 dark:border-stone-800">
</div> <div className="flex flex-wrap gap-1.5">
<div className="min-w-0"> {prompt.tags.map((tag) => (
<div className="flex flex-wrap gap-1.5"> <Tag key={tag} className="m-0">
{prompt.tags.map((tag) => ( {tag}
<Tag key={tag} className="m-0"> </Tag>
{tag} ))}
</Tag>
))}
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
{formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}
</div>
<Space wrap className="mt-5">
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space>
</div>
</div> </div>
</> {prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
) : null} {prompt.preview ? <pre className="mt-4 whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
</Modal> <p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
</> {prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
</div>
<div className="shrink-0 pt-4">
<Space wrap>
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space>
</div>
</div>
) : null}
</Modal>
); );
} }
+35 -76
View File
@@ -1,5 +1,5 @@
import { FolderPlus, Search } from "lucide-react"; import { FolderPlus, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react"; import { type ReactNode, type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } from "antd"; import { App, Button, Empty, Input, Spin, Tag } from "antd";
import { PromptCard } from "@/components/prompts/prompt-card"; import { PromptCard } from "@/components/prompts/prompt-card";
@@ -21,9 +21,7 @@ export default function PromptsPage() {
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory }); const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => { useEffect(() => {
if (query.isError) { if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}
}, [message, query.error, query.isError]); }, [message, query.error, query.isError]);
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
@@ -38,89 +36,50 @@ export default function PromptsPage() {
const handleListScroll = (event: UIEvent<HTMLDivElement>) => { const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) { if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
void query.fetchNextPage();
}
}; };
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100"> <div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main <main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" <div className="mx-auto max-w-7xl">
onScroll={handleListScroll} <div className="text-center">
> <h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<div className="pb-8"> <p className="mt-1 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</div> </div>
{query.isLoading ? ( <div className="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
<div className="flex h-60 items-center justify-center"> <aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800">
<Spin /> <PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
</div> <div className="mt-6">
) : null} <div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500"></div>
{!query.isLoading ? ( <div className="flex flex-wrap gap-1.5">
<> {promptTags.map((tag) => {
<div className="mx-auto mt-8 w-full max-w-2xl"> const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} /> return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag}</Tag.CheckableTag>;
</div> })}
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategoryOptions.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
onChange={() => toggleTag(tag)}
>
{tag}
</Tag.CheckableTag>
))}
</div>
</div> </div>
</div> </div>
</> </aside>
) : null} <section className="min-w-0">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" /></div> : null}
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</section>
</div>
</div> </div>
{!query.isLoading ? (
<div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{promptItems.map((item) => (
<PromptCard
key={item.id}
item={item}
onOpen={() => setSelectedPrompt(item)}
onCopy={() => copyText(item.prompt, "提示词已复制")}
extraAction={
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
</Button>
}
/>
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
</div>
</div>
) : null}
</main> </main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} /> <PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</div> </div>
); );
} }
function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) {
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option}</Tag.CheckableTag>)}</div></div>;
}
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
}
+88 -22
View File
@@ -1,5 +1,5 @@
import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react"; import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type DragEvent } from "react";
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd"; import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
import localforage from "localforage"; import localforage from "localforage";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -11,7 +11,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel"; import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, formatDuration } from "@/lib/image-utils"; import { formatBytes, formatDuration } from "@/lib/image-utils";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS, SEEDANCE_VIDEO_MIME_TYPES } from "@/lib/seedance-video";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video"; import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video";
@@ -71,6 +71,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function VideoPage() { export default function VideoPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const activeLogIdsRef = useRef<Set<string>>(new Set()); const activeLogIdsRef = useRef<Set<string>>(new Set());
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig(); const effectiveConfig = useEffectiveConfig();
@@ -94,10 +95,13 @@ export default function VideoPage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]); const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null); const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [referenceDragTarget, setReferenceDragTarget] = useState<"image" | "video" | "audio" | null>(null);
const [autoRunToken, setAutoRunToken] = useState(0); const [autoRunToken, setAutoRunToken] = useState(0);
const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand); const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand);
const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand); const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0); const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.videoModel || effectiveConfig.model; const model = effectiveConfig.videoModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim()); const canGenerate = Boolean(prompt.trim());
@@ -114,13 +118,13 @@ export default function VideoPage() {
const addReferences = async (files?: FileList | null) => { const addReferences = async (files?: FileList | null) => {
const selectedFiles = Array.from(files || []); const selectedFiles = Array.from(files || []);
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file)); const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && !isSupportedAudioFile(file));
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频"); if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频");
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length); const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length); const videoFiles = selectedFiles.filter((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length); const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图"); if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图");
if (selectedFiles.some((file) => file.type.startsWith("video/") && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 50MB 的参考视频"); if (selectedFiles.some((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 200MB 的参考视频");
if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频"); if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频");
const nextReferences = await Promise.all( const nextReferences = await Promise.all(
imageFiles.map(async (file) => { imageFiles.map(async (file) => {
@@ -149,6 +153,25 @@ export default function VideoPage() {
setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios)); setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios));
}; };
const handleReferenceDragEnter = (event: DragEvent<HTMLDivElement>, target: "image" | "video" | "audio") => {
event.preventDefault();
dragDepthRef.current += 1;
if (event.dataTransfer.types.includes("Files")) setReferenceDragTarget(target);
};
const handleReferenceDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (!dragDepthRef.current) setReferenceDragTarget(null);
};
const handleReferenceDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepthRef.current = 0;
setReferenceDragTarget(null);
void addReferences(event.dataTransfer.files);
};
const addReferencesFromClipboard = async () => { const addReferencesFromClipboard = async () => {
try { try {
const items = await navigator.clipboard.read(); const items = await navigator.clipboard.read();
@@ -170,10 +193,16 @@ export default function VideoPage() {
} }
}; };
const generate = async () => { const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const snapshot = buildRequestSnapshot(); const snapshot = buildRequestSnapshot();
if (!snapshot) return; if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "视频生成参数无效" });
return;
}
setElapsedMs(0); setElapsedMs(0);
setRunning(true); setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null); setPreviewLog(null);
setResults([{ id: nanoid(), status: "pending" }]); setResults([{ id: nanoid(), status: "pending" }]);
const batchStartedAt = performance.now(); const batchStartedAt = performance.now();
@@ -181,11 +210,12 @@ export default function VideoPage() {
try { try {
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences); const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task }); const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
await saveLog(log); await saveLog(log, false);
void pollGenerationLog(log, snapshot.config); void pollGenerationLog(log, snapshot.config, agentTaskId);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败"; const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage); message.error(errorMessage);
setRunning(false); setRunning(false);
@@ -198,8 +228,15 @@ export default function VideoPage() {
processedCommandRef.current = videoCommand.nonce; processedCommandRef.current = videoCommand.nonce;
clearVideoCommand(); clearVideoCommand();
if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt); if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt);
if (videoCommand.run && !running) setAutoRunToken((value) => value + 1); if (videoCommand.run && running) {
}, [videoCommand, clearVideoCommand, running]); if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: "视频工作台已有任务正在运行" });
return;
}
if (videoCommand.run) {
agentTaskIdRef.current = videoCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [videoCommand, clearVideoCommand, running, updateAgentTask]);
useEffect(() => { useEffect(() => {
if (!autoRunToken) return; if (!autoRunToken) return;
@@ -276,7 +313,7 @@ export default function VideoPage() {
.filter((log) => selectedLogIds.includes(log.id)) .filter((log) => selectedLogIds.includes(log.id))
.map((log) => log.video?.storageKey) .map((log) => log.video?.storageKey)
.filter((key): key is string => Boolean(key)); .filter((key): key is string => Boolean(key));
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs); void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(() => refreshLogs());
if (previewLog && selectedLogIds.includes(previewLog.id)) { if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null); setPreviewLog(null);
setResults([]); setResults([]);
@@ -285,15 +322,15 @@ export default function VideoPage() {
setDeleteConfirmOpen(false); setDeleteConfirmOpen(false);
}; };
const saveLog = async (log: GenerationLog) => { const saveLog = async (log: GenerationLog, resumePending = true) => {
await logStore.setItem(log.id, serializeLog(log)); await logStore.setItem(log.id, serializeLog(log));
await refreshLogs(); await refreshLogs(resumePending);
}; };
const refreshLogs = async () => { const refreshLogs = async (resumePending = true) => {
const nextLogs = await readStoredLogs(); const nextLogs = await readStoredLogs();
setLogs(nextLogs); setLogs(nextLogs);
resumePendingLogs(nextLogs); if (resumePending) resumePendingLogs(nextLogs);
return nextLogs; return nextLogs;
}; };
@@ -303,7 +340,7 @@ export default function VideoPage() {
} }
}; };
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => { const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig, agentTaskId?: string) => {
if (!log.task || activeLogIdsRef.current.has(log.id)) return; if (!log.task || activeLogIdsRef.current.has(log.id)) return;
activeLogIdsRef.current.add(log.id); activeLogIdsRef.current.add(log.id);
setRunning(true); setRunning(true);
@@ -326,6 +363,7 @@ export default function VideoPage() {
mimeType: stored.mimeType, mimeType: stored.mimeType,
}; };
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined });
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined }); await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
message.success("视频已生成"); message.success("视频已生成");
return; return;
@@ -337,6 +375,7 @@ export default function VideoPage() {
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败"; const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: log.id, status: "failed", error: errorMessage }]); setResults([{ id: log.id, status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage }); await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
message.error(errorMessage); message.error(errorMessage);
} finally { } finally {
@@ -413,7 +452,16 @@ export default function VideoPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"> <div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "image" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "image")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{references.map((item, index) => ( {references.map((item, index) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800"> <div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" /> <img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
@@ -424,7 +472,7 @@ export default function VideoPage() {
</button> </button>
</div> </div>
))} ))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 9 </div> : null} {!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "image" ? "松开即可上传参考资产" : "暂无参考图,可拖入文件,最多 9 张"}</div> : null}
</div> </div>
</div> </div>
@@ -435,7 +483,16 @@ export default function VideoPage() {
</Button> </Button>
</div> </div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"> <div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "video" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "video")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{videoReferences.map((item, index) => ( {videoReferences.map((item, index) => (
<div key={item.id} className="group relative h-20 w-32 shrink-0 overflow-hidden rounded-md border border-stone-200 bg-black dark:border-stone-800"> <div key={item.id} className="group relative h-20 w-32 shrink-0 overflow-hidden rounded-md border border-stone-200 bg-black dark:border-stone-800">
<video src={item.url} className="size-full object-cover" muted preload="metadata" /> <video src={item.url} className="size-full object-cover" muted preload="metadata" />
@@ -446,7 +503,7 @@ export default function VideoPage() {
</button> </button>
</div> </div>
))} ))}
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 3 </div> : null} {!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "video" ? "松开即可上传参考资产" : "暂无参考视频,可拖入文件,最多 3 个"}</div> : null}
</div> </div>
</div> </div>
@@ -457,7 +514,16 @@ export default function VideoPage() {
</Button> </Button>
</div> </div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"> <div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "audio" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "audio")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{audioReferences.map((item, index) => ( {audioReferences.map((item, index) => (
<div key={item.id} className="group relative flex h-20 w-48 shrink-0 flex-col justify-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2 dark:border-stone-800 dark:bg-stone-900"> <div key={item.id} className="group relative flex h-20 w-48 shrink-0 flex-col justify-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2 dark:border-stone-800 dark:bg-stone-900">
<div className="flex min-w-0 items-center gap-2 text-xs text-stone-500 dark:text-stone-400"> <div className="flex min-w-0 items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
@@ -472,7 +538,7 @@ export default function VideoPage() {
</button> </button>
</div> </div>
))} ))}
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500"> 3 mp3/wav 15MB </div> : null} {!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">{referenceDragTarget === "audio" ? "松开即可上传参考资产" : "暂无参考音频,可拖入文件,最多 3 个,mp3/wav,单个 15MB 内"}</div> : null}
</div> </div>
</div> </div>
+2
View File
@@ -1,5 +1,6 @@
import { createBrowserRouter, Outlet } from "react-router-dom"; import { createBrowserRouter, Outlet } from "react-router-dom";
import { AnalyticsTracker } from "@/components/layout/analytics-tracker";
import UserLayout from "@/layouts/user-layout"; import UserLayout from "@/layouts/user-layout";
import AssetsPage from "@/pages/assets"; import AssetsPage from "@/pages/assets";
import CanvasPage from "@/pages/canvas"; import CanvasPage from "@/pages/canvas";
@@ -15,6 +16,7 @@ export const router = createBrowserRouter([
{ {
element: ( element: (
<UserLayout> <UserLayout>
<AnalyticsTracker />
<Outlet /> <Outlet />
</UserLayout> </UserLayout>
), ),
+40 -4
View File
@@ -102,17 +102,53 @@ async function assertAudioBlob(blob: Blob) {
if (payload.error?.message) throw new Error(payload.error.message); if (payload.error?.message) throw new Error(payload.error.message);
} }
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) { function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消"; if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) { if (axios.isAxiosError(error)) {
const responseData = error.response?.data; const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || statusMessage(error.response?.status, fallback); const apiMsg = readApiErrorMessage(responseData);
if (apiMsg) return apiMsg;
const statusMsg = statusMessage(error.response?.status, fallback);
if (statusMsg) return statusMsg;
return error.message || fallback;
} }
return error instanceof Error ? error.message : fallback; if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
} }
function statusMessage(status: number | undefined, fallback: string) { function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限"; if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试"; if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback; if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
if (status === 503) return "服务繁忙(503),请稍后重试";
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
} }
+107 -10
View File
@@ -122,6 +122,11 @@ function normalizeQuality(quality: string) {
return QUALITY_BASE[normalized] ? normalized : undefined; return QUALITY_BASE[normalized] ? normalized : undefined;
} }
/** Only "transparent" is forwarded; any other value (incl. empty) means keep the default opaque background. */
function normalizeBackground(background: string | undefined) {
return background?.trim().toLowerCase() === "transparent" ? "transparent" : undefined;
}
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */ /** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */
function resolveSize(quality: string | undefined, ratio: string): string { function resolveSize(quality: string | undefined, ratio: string): string {
const parsedRatio = parseImageRatio(ratio); const parsedRatio = parseImageRatio(ratio);
@@ -239,33 +244,84 @@ function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) { if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败"); throw new Error(payload.msg || "请求失败");
} }
// 支持 data / images / results 三种返回字段(兼容不同 API)
const imageList = payload.data
|| (payload as Record<string, unknown>).images as Array<Record<string, unknown>> | undefined
|| (payload as Record<string, unknown>).results as Array<Record<string, unknown>> | undefined
|| [];
const images = const images =
payload.data imageList
?.map(resolveImageDataUrl) .map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value)) .filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || []; .map((dataUrl) => ({ id: nanoid(), dataUrl }));
if (images.length === 0) { if (images.length === 0) {
throw new Error("接口没有返回图片"); // 尝试检查是否有返回了但格式不被识别的数据
const rawKeys = Object.keys(payload).filter((k) => k !== "code" && k !== "msg" && k !== "error");
throw new Error(rawKeys.length > 0
? `接口返回了未知格式的数据(字段:${rawKeys.join("、")}),请检查模型或接口兼容性`
: "接口没有返回图片,请检查提示词是否触发安全审核或模型是否支持该操作");
} }
return images; return images;
} }
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
// 可能是 JSON 字符串(如 error.message 被序列化)或纯文本错误
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
// 如果 JSON 解析后得到 "{}" 这种空对象,返回原始字符串
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
// 检查是否是 HTML 错误页面
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error 可能是字符串或含 message 的对象
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) { function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消"; if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) { if (axios.isAxiosError(error)) {
const responseData = error.response?.data; const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || readStatusError(error.response?.status, fallback); // 优先从响应体提取业务错误
const apiMsg = readApiErrorMessage(responseData);
if (apiMsg) return apiMsg;
// 响应体无法提取时用 HTTP 状态推断
const statusMsg = readStatusError(error.response?.status, fallback);
if (statusMsg) return statusMsg;
// 最后用 axios 自身的错误文本
return error.message || fallback;
} }
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消"; if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? error.message : fallback; return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
} }
function readStatusError(status: number | undefined, fallback: string) { function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限"; if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试"; if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback; if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
if (status === 503) return "服务繁忙(503),请稍后重试";
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
} }
function withSystemPrompt(config: AiConfig, prompt: string) { function withSystemPrompt(config: AiConfig, prompt: string) {
@@ -661,6 +717,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
if (script) { if (script) {
const quality = normalizeQuality(config.quality); const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size); const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
try { try {
const result = await runModelPlugin({ const result = await runModelPlugin({
capability: "image", capability: "image",
@@ -668,7 +725,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
config: requestConfig, config: requestConfig,
prompt: withSystemPrompt(requestConfig, prompt), prompt: withSystemPrompt(requestConfig, prompt),
images: [], images: [],
params: { size: requestSize, quality, count: n }, params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
signal: options?.signal, signal: options?.signal,
}); });
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl })); return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
@@ -685,6 +742,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
} }
const quality = normalizeQuality(config.quality); const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size); const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
try { try {
const response = await axios.post<ImageApiResponse>( const response = await axios.post<ImageApiResponse>(
aiApiUrl(requestConfig, "/images/generations"), aiApiUrl(requestConfig, "/images/generations"),
@@ -694,6 +752,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
n, n,
...(quality ? { quality } : {}), ...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}), ...(requestSize ? { size: requestSize } : {}),
...(background ? { background } : {}),
response_format: "b64_json", response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT, output_format: IMAGE_OUTPUT_FORMAT,
}, },
@@ -717,6 +776,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (script) { if (script) {
const quality = normalizeQuality(config.quality); const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size); const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const refs = await Promise.all(references.map((image) => imageToDataUrl(image))); const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
try { try {
const result = await runModelPlugin({ const result = await runModelPlugin({
@@ -725,7 +785,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
config: requestConfig, config: requestConfig,
prompt: withSystemPrompt(requestConfig, requestPrompt), prompt: withSystemPrompt(requestConfig, requestPrompt),
images: refs, images: refs,
params: { size: requestSize, quality, count: n }, params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
signal: options?.signal, signal: options?.signal,
}); });
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl })); return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
@@ -741,8 +801,41 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
throw new Error(readAxiosError(error, "请求失败")); throw new Error(readAxiosError(error, "请求失败"));
} }
} }
if (requestConfig.apiFormat === "ark") {
if (mask) throw new Error("蒙版编辑暂不支持该模型,请使用其他渠道");
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(requestConfig, "/images/generations"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
n,
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
image: refs,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
...(background ? { background } : {}),
},
{
headers: aiHeaders(requestConfig, "application/json"),
signal: options?.signal,
},
);
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
const quality = normalizeQuality(config.quality); const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size); const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const formData = new FormData(); const formData = new FormData();
formData.set("model", requestConfig.model); formData.set("model", requestConfig.model);
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt)); formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
@@ -755,6 +848,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (requestSize) { if (requestSize) {
formData.set("size", requestSize); formData.set("size", requestSize);
} }
if (background) {
formData.set("background", background);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) }))); const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file)); files.forEach((file) => formData.append("image", file));
if (mask) formData.set("mask", dataUrlToFile(mask)); if (mask) formData.set("mask", dataUrlToFile(mask));
@@ -797,6 +893,7 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
const answer = (await requestStreamingResponse(requestConfig, { const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model, model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)), input: toResponseInput(withSystemMessage(requestConfig, messages)),
...(requestConfig.reasoningEffort === "auto" ? {} : { reasoning: { effort: requestConfig.reasoningEffort } }),
}, onDelta, options)).content || "没有返回内容"; }, onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer); if (answer === "没有返回内容") onDelta(answer);
return answer; return answer;
+10 -3
View File
@@ -106,7 +106,7 @@ function createPoll(signal?: AbortSignal) {
/** /**
* Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES): * Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES):
* prompt / images / messages / params * prompt / images / messages / params
* model / baseUrl / apiKey / systemPrompt * model / baseUrl / apiKey / systemPrompt / reasoningEffort
* http / request / poll / sleep / signal / onDelta * http / request / poll / sleep / signal / onDelta
* The script must `return` the result; each caller normalizes it to its capability's shape. * The script must `return` the result; each caller normalizes it to its capability's shape.
*/ */
@@ -124,6 +124,7 @@ export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<
"baseUrl", "baseUrl",
"apiKey", "apiKey",
"systemPrompt", "systemPrompt",
"reasoningEffort",
"http", "http",
"request", "request",
"poll", "poll",
@@ -142,6 +143,7 @@ export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<
config.baseUrl, config.baseUrl,
config.apiKey, config.apiKey,
config.systemPrompt || "", config.systemPrompt || "",
config.reasoningEffort,
http, http,
request, request,
poll, poll,
@@ -169,6 +171,7 @@ export const PLUGIN_VARIABLES: PluginVariable[] = [
{ name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" }, { name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" },
{ name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" }, { name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" },
{ name: "systemPrompt", type: "string", desc: "系统提示词原文" }, { name: "systemPrompt", type: "string", desc: "系统提示词原文" },
{ name: "reasoningEffort", type: '"auto" | "low" | "medium" | "high" | "xhigh"', desc: "文本推理强度;auto 表示由脚本决定是否传递", capabilities: ["text"] },
{ name: "http", type: "object", desc: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1" }, { name: "http", type: "object", desc: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1" },
{ name: "request", type: "function", desc: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1" }, { name: "request", type: "function", desc: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1" },
{ name: "poll", type: "function", desc: "轮询 poll(request, extract, {intervalMs,timeoutMs})extract 返回真值即结束" }, { name: "poll", type: "function", desc: "轮询 poll(request, extract, {intervalMs,timeoutMs})extract 返回真值即结束" },
@@ -321,12 +324,16 @@ return { data: audio.data };`,
text: [ text: [
{ {
label: "OpenAI 规范", label: "OpenAI 规范",
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model、reasoningEffort
const data = await request({ const data = await request({
method: "post", method: "post",
url: \`\${baseUrl}/v1/responses\`, url: \`\${baseUrl}/v1/responses\`,
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` }, headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
data: { model, input: messages }, data: {
model,
input: messages,
...(reasoningEffort === "auto" ? {} : { reasoning: { effort: reasoningEffort } }),
},
}); });
const text = data.output_text const text = data.output_text
|| (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("") || (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("")
@@ -0,0 +1,37 @@
import { nanoid } from "nanoid";
export type PromptSource = {
id: string;
name: string;
url: string;
homepage: string;
enabled: boolean;
builtIn: boolean;
};
export const PROMPT_REGISTRY_HOMEPAGE = "https://github.com/yukkcat/image-prompts";
const PROMPT_REGISTRY_SOURCE_BASE = "https://raw.githubusercontent.com/yukkcat/image-prompts/main/dist/sources";
export function createPromptSource(source?: Partial<PromptSource>): PromptSource {
return {
id: source?.id?.trim() || nanoid(),
name: source?.name?.trim() || "新来源",
url: source?.url?.trim() || "",
homepage: source?.homepage?.trim() || "",
enabled: source?.enabled ?? true,
builtIn: source?.builtIn ?? false,
};
}
export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [
registrySource("banana-prompt-quicker", "Banana Prompt Quicker", "https://glidea.github.io/banana-prompt-quicker/"),
registrySource("davidwu-gpt-image2-prompts", "DavidWu GPT Image 2", "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts"),
registrySource("awesome-gpt-image", "Awesome GPT Image", "https://github.com/ZeroLu/awesome-gpt-image"),
registrySource("awesome-gpt4o-image-prompts", "Awesome GPT-4o", "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts"),
registrySource("youmind-gpt-image-2", "YouMind GPT Image 2", "https://github.com/YouMind-OpenLab/awesome-gpt-image-2"),
registrySource("youmind-nano-banana-pro", "YouMind Nano Banana Pro", "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts"),
];
function registrySource(id: string, name: string, homepage: string): PromptSource {
return { id, name, url: `${PROMPT_REGISTRY_SOURCE_BASE}/${id}.json`, homepage, enabled: true, builtIn: true };
}
@@ -0,0 +1,118 @@
import type { PromptSource } from "./prompt-source-presets";
export type RawPrompt = {
id: string;
title: string;
prompt: string;
description: string;
coverUrl: string;
referenceImageUrls: string[];
tags: string[];
preview: string;
createdAt: string;
updatedAt: string;
author?: string;
sourceUrl?: string;
imageMode?: string;
imageModel?: string;
imageSize?: string;
imageCount?: number;
};
type RunOptions = { signal?: AbortSignal };
async function fetchSource(source: PromptSource, options?: RunOptions) {
const response = await fetch(source.url, { cache: "no-store", signal: options?.signal });
if (!response.ok) throw new Error(`请求失败(${response.status}`);
return response.json();
}
export async function runPromptSource(source: PromptSource, options?: RunOptions): Promise<RawPrompt[]> {
if (!source.url.trim()) throw new Error("JSON URL 不能为空");
let data: unknown;
try {
data = await fetchSource(source, options);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new Error(`${source.name}」拉取失败:${error instanceof Error ? error.message : String(error)}`);
}
const items = parseJsonSource(data, source);
if (source.builtIn && !items.length) throw new Error(`${source.name}」未解析到有效提示词`);
return items;
}
function parseJsonSource(data: unknown, source: PromptSource) {
if (!Array.isArray(data)) throw new Error(`${source.name}」格式错误:根节点必须是数组`);
return normalizeItems(data, source);
}
function normalizeItems(values: unknown[], source: PromptSource) {
const seen = new Set<string>();
const items: RawPrompt[] = [];
values.forEach((value, index) => {
const record = asRecord(value);
const title = stringValue(record.title).trim();
const prompt = stringValue(record.prompt).trim();
if (!title || !prompt) return;
const id = stringValue(record.id).trim() || `${source.id}-${leftPad(index + 1)}`;
if (seen.has(id)) return;
seen.add(id);
const referenceImageUrls = stringArray(record.referenceImageUrls).map((url) => absoluteUrl(source.url, url));
const coverUrl = absoluteUrl(source.url, stringValue(record.coverUrl)) || referenceImageUrls[0] || "";
items.push({
id,
title,
prompt,
description: stringValue(record.description),
coverUrl,
referenceImageUrls,
tags: stringArray(record.tags),
preview: stringValue(record.preview),
createdAt: stringValue(record.createdAt),
updatedAt: stringValue(record.updatedAt),
author: stringValue(record.author),
sourceUrl: absoluteUrl(source.url, stringValue(record.sourceUrl)),
imageMode: optionalString(record.imageMode),
imageModel: optionalString(record.imageModel),
imageSize: optionalString(record.imageSize),
imageCount: optionalNumber(record.imageCount),
});
});
return items;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown) {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.map(stringValue).map((item) => item.trim()).filter(Boolean) : [];
}
function optionalString(value: unknown) {
const result = stringValue(value).trim();
return result || undefined;
}
function optionalNumber(value: unknown) {
const result = Number(value);
return Number.isFinite(result) && result > 0 ? result : undefined;
}
function absoluteUrl(baseUrl: string, path: string) {
if (!path) return "";
try {
return new URL(path, baseUrl).toString();
} catch {
return path;
}
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
+174 -193
View File
@@ -1,22 +1,13 @@
import localforage from "localforage"; import localforage from "localforage";
export type Prompt = { import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
id: string; import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
title: string; import type { PromptSource } from "./prompt-source-presets";
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
type PromptCategory = { export type Prompt = RawPrompt & {
sourceId: string;
category: string; category: string;
githubUrl: string; githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
}; };
export const ALL_PROMPTS_OPTION = "全部"; export const ALL_PROMPTS_OPTION = "全部";
@@ -28,65 +19,187 @@ export type PromptListResponse = {
total: number; total: number;
}; };
const awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"; export type PromptSourceStatus = {
const awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"; sourceId: string;
const youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"; count: number;
const youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"; lastSuccessAt: string;
const davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"; lastError: string;
};
export type PromptSourceRefreshResult = PromptSourceStatus & {
sourceName: string;
success: boolean;
};
export type PromptSourceRefreshSummary = {
results: PromptSourceRefreshResult[];
total: number;
successCount: number;
failureCount: number;
};
type SourceCache = PromptSourceStatus & {
items: Prompt[];
fetchedAt: number;
signature: string;
};
const cacheTtlMs = 1000 * 60 * 60; const cacheTtlMs = 1000 * 60 * 60;
const promptCacheKey = "third-party-prompts";
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" }); const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
const loadingSources = new Map<string, Promise<PromptSourceRefreshResult>>();
const categories: PromptCategory[] = [ function enabledSources() {
{ category: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", build: buildAwesomeGptImagePrompts }, return usePromptSourceStore.getState().sources.filter((source) => source.enabled);
{ category: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", build: buildAwesomeGpt4oImagePrompts },
{ category: "youmind-gpt-image-2", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", build: () => buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2") },
{ category: "youmind-nano-banana-pro", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", build: () => buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") },
{ category: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", build: buildDavidWuGptImage2Prompts },
];
let loadingPrompts: Promise<Prompt[]> | null = null;
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
};
} }
async function getPrompts() { function cacheKey(sourceId: string) {
const cached = await promptCacheStore.getItem<{ items?: Prompt[]; fetchedAt?: number }>(promptCacheKey); return `prompt-source:${sourceId}`;
if (cached?.items?.length && cached.fetchedAt && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.items;
if (loadingPrompts) return loadingPrompts;
loadingPrompts = loadPrompts().finally(() => {
loadingPrompts = null;
});
return loadingPrompts;
} }
async function loadPrompts() { function sourceSignature(source: PromptSource) {
const value = `${source.name}\n${source.url}\n${source.homepage}`;
let hash = 0;
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) | 0;
return `${value.length}:${hash}`;
}
function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
return items.map((item) => ({
...item,
description: item.description || "",
referenceImageUrls: Array.isArray(item.referenceImageUrls) ? item.referenceImageUrls : [],
sourceId: source.id,
category: source.name,
githubUrl: item.sourceUrl || source.homepage,
}));
}
async function readSourceCache(sourceId: string) {
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
}
async function refreshSourceRecord(source: PromptSource): Promise<PromptSourceRefreshResult> {
const previous = await readSourceCache(source.id);
try {
const items = withSourceMeta(source, await runPromptSource(source));
const lastSuccessAt = new Date().toISOString();
const cache: SourceCache = { sourceId: source.id, items, count: items.length, fetchedAt: Date.now(), lastSuccessAt, lastError: "", signature: sourceSignature(source) };
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: items.length, lastSuccessAt, lastError: "", success: true };
} catch (error) {
const lastError = error instanceof Error ? error.message : String(error);
const cache: SourceCache = {
sourceId: source.id,
items: previous?.items || [],
count: previous?.items?.length || 0,
fetchedAt: previous?.fetchedAt || 0,
lastSuccessAt: previous?.lastSuccessAt || "",
lastError,
signature: previous?.signature || sourceSignature(source),
};
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: cache.count, lastSuccessAt: cache.lastSuccessAt, lastError, success: false };
}
}
function getOrStartRefresh(source: PromptSource) {
const current = loadingSources.get(source.id);
if (current) return current;
const loading = refreshSourceRecord(source).finally(() => loadingSources.delete(source.id));
loadingSources.set(source.id, loading);
return loading;
}
async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
const cached = await readSourceCache(source.id);
if (cached) {
const stale = cached.signature !== sourceSignature(source) || Date.now() - cached.fetchedAt >= cacheTtlMs;
if (stale) void getOrStartRefresh(source).catch(() => undefined);
return withSourceMeta(source, cached.items);
}
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return (await readSourceCache(source.id))?.items || [];
}
async function getAllPrompts(): Promise<Prompt[]> {
const settled = await Promise.all( const settled = await Promise.all(
categories.map(async (category) => { enabledSources().map(async (source) => {
try { try {
const items = await category.build(); return await getSourcePrompts(source);
return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl }));
} catch { } catch {
return []; return [];
} }
}), }),
); );
const items = settled.flat(); return settled.flat();
await promptCacheStore.setItem(promptCacheKey, { items, fetchedAt: Date.now() }); }
return items;
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getAllPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
const categories = enabledSources().map((source) => source.name);
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories,
total: filtered.length,
};
}
export async function fetchSourcePrompts(sourceId: string): Promise<Prompt[]> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
return getSourcePrompts(source);
}
export async function refreshSource(sourceId: string): Promise<PromptSourceRefreshResult> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return result;
}
export async function refreshAllSources(): Promise<PromptSourceRefreshSummary> {
const results = await Promise.all(enabledSources().map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function refreshDueSources(maxAgeMs: number): Promise<PromptSourceRefreshSummary> {
const sources = await Promise.all(
enabledSources().map(async (source) => {
const cached = await readSourceCache(source.id);
const lastSuccess = cached?.lastSuccessAt ? new Date(cached.lastSuccessAt).getTime() : 0;
return !lastSuccess || Boolean(cached?.lastError) || Date.now() - lastSuccess >= maxAgeMs || cached?.signature !== sourceSignature(source) ? source : null;
}),
);
const results = await Promise.all(sources.filter((source): source is PromptSource => Boolean(source)).map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function fetchPromptSourceStatuses(): Promise<Record<string, PromptSourceStatus>> {
const entries = await Promise.all(
usePromptSourceStore.getState().sources.map(async (source) => {
const cache = await readSourceCache(source.id);
return [source.id, { sourceId: source.id, count: cache?.items?.length || 0, lastSuccessAt: cache?.lastSuccessAt || "", lastError: cache?.lastError || "" }] as const;
}),
);
return Object.fromEntries(entries);
}
function summarizeRefresh(results: PromptSourceRefreshResult[]): PromptSourceRefreshSummary {
return {
results,
total: results.reduce((total, item) => total + item.count, 0),
successCount: results.filter((item) => item.success).length,
failureCount: results.filter((item) => !item.success).length,
};
} }
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) { function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
@@ -94,148 +207,16 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st
if (isActiveOption(options.category) && item.category !== options.category) return false; if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false; if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true; if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword); return [item.title, item.prompt, item.description, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
}); });
} }
async function buildAwesomeGptImagePrompts() {
const markdown = await fetchText(awesomeGptImageRawBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const section of splitBeforeHeading(markdown, "## ")) {
const tags = tagsFromHeading(firstMatch(section, /^##\s+(.+)$/m));
for (const block of splitBeforeHeading(section, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).replace(/\[([^\]]+)]\([^)]+\)/g, "$1").trim();
const prompt = firstMatch(block, /\*\*:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGptImageRawBase, block);
items.push(defaultPrompt(`awesome-gpt-image-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", tags, markdownPreview(images)));
}
}
return items;
}
async function buildAwesomeGpt4oImagePrompts() {
const markdown = await fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \*\*\*\*\s*`(.*?)`/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGpt4oImagePromptsBase, block);
items.push(defaultPrompt(`awesome-gpt4o-image-prompts-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", ["gpt4o"], markdownPreview(images)));
}
return items;
}
async function buildYouMindPrompts(baseUrl: string, idPrefix: string, modelTag: string) {
const markdown = await fetchText(baseUrl, "README_zh.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+No\.\s*\d+:\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(baseUrl, block);
items.push(defaultPrompt(`${idPrefix}-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", youMindTags(title, modelTag), markdownPreview(images)));
}
return items;
}
async function buildDavidWuGptImage2Prompts() {
const data = await fetchJson<Array<{ id?: number; title_en?: string; title_cn?: string; category?: string; category_cn?: string; prompt?: string; note?: string; author?: string; source?: string; needs_ref?: boolean; image?: string }>>(davidWuGptImage2RawBase, "prompts.json");
return data
.map((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return null;
const image = absoluteImage(davidWuGptImage2RawBase, item.image || "");
const preview = [item.title_en, item.note, image ? `![](${image})` : ""].filter(Boolean).join("\n\n");
return defaultPrompt(`davidwu-gpt-image2-prompts-${leftPad(item.id || index + 1)}`, title, prompt, image, davidWuTags(item), preview);
})
.filter((item): item is Omit<Prompt, "category" | "githubUrl"> => Boolean(item));
}
function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit<Prompt, "category" | "githubUrl"> {
return { id, title, coverUrl, prompt, tags, preview, createdAt: "", updatedAt: "" };
}
async function fetchText(baseUrl: string, file: string) {
const response = await fetch(`${baseUrl}/${file}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${file} 拉取失败`);
return response.text();
}
async function fetchJson<T>(baseUrl: string, file: string) {
return JSON.parse(await fetchText(baseUrl, file)) as T;
}
function splitBeforeHeading(markdown: string, prefix: string) {
const blocks: string[] = [];
let current: string[] = [];
for (const line of markdown.split("\n")) {
if (line.startsWith(prefix) && current.length) {
blocks.push(current.join("\n"));
current = [];
}
current.push(line);
}
blocks.push(current.join("\n"));
return blocks;
}
function firstMatch(value: string, pattern: RegExp) {
return pattern.exec(value)?.[1] || "";
}
function extractMarkdownImages(baseUrl: string, markdown: string) {
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteImage(baseUrl, match[1])).filter(Boolean);
}
function absoluteImage(baseUrl: string, image: string) {
if (!image) return "";
if (/^https?:\/\//i.test(image)) return image;
return `${baseUrl}/${image.replace(/^\.?\//, "")}`;
}
function tagsFromCategory(category: string) {
return splitTags(category.replace(/\s+Cases$/i, ""), /\s*(?:&|and)\s*/);
}
function tagsFromHeading(heading: string) {
return splitTags(heading.replace(/[^\p{L}\p{N}/& ]/gu, ""), /\s*(?:\/|&||)\s*/);
}
function youMindTags(title: string, modelTag: string) {
const [, prefix] = title.match(/^(.+?) - /) || [];
return [modelTag, ...tagsFromHeading(prefix || "")];
}
function davidWuTags(item: { category_cn?: string; category?: string; author?: string; source?: string; needs_ref?: boolean }) {
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\//);
if (item.needs_ref) tags.push("需要参考图");
return tags;
}
function splitTags(value: string, pattern: RegExp) {
return value
.split(pattern)
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
}
function markdownPreview(images: string[]) {
return images.filter(Boolean).map((image) => `![](${image})`).join("\n\n");
}
function collectTags(items: Prompt[]) { function collectTags(items: Prompt[]) {
return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean))); return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean)));
} }
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
function isActiveOption(value: string) { function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all"; return value && value !== ALL_PROMPTS_OPTION && value !== "all";
} }
export function formatPromptDate(value: string) { export function formatPromptDate(value: string) {
+19 -4
View File
@@ -180,7 +180,7 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
model: modelOptionName(model), model: modelOptionName(model),
content, content,
ratio: normalizeSeedanceRatio(config.size), ratio: normalizeSeedanceRatio(config.size),
resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)), resolution: normalizeSeedanceResolution(config.vquality),
duration: normalizeSeedanceDuration(config.videoSeconds), duration: normalizeSeedanceDuration(config.videoSeconds),
generate_audio: boolConfig(config.videoGenerateAudio, true), generate_audio: boolConfig(config.videoGenerateAudio, true),
watermark: boolConfig(config.videoWatermark, false), watermark: boolConfig(config.videoWatermark, false),
@@ -339,14 +339,29 @@ function readApiErrorMessage(value: unknown): string {
if (!value) return ""; if (!value) return "";
if (typeof value === "string") { if (typeof value === "string") {
try { try {
return readApiErrorMessage(JSON.parse(value)) || value; const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch { } catch {
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value; return value;
} }
} }
if (typeof value !== "object") return ""; if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: { message?: unknown } }; const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
return readApiErrorMessage(payload.msg) || readApiErrorMessage(payload.message) || readApiErrorMessage(payload.error?.message); // error 可能是字符串或含 message 的对象
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
} }
function readAxiosError(error: unknown, fallback: string) { function readAxiosError(error: unknown, fallback: string) {
+36
View File
@@ -0,0 +1,36 @@
import { saveAs } from "file-saver";
import { useConfigStore, type AiConfig, type WebdavSyncConfig } from "@/stores/use-config-store";
import { usePromptSourceStore, type PromptSourceSchedule } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
type AppConfigFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
config: AiConfig;
webdav: WebdavSyncConfig;
promptSources: {
sources: PromptSource[];
schedule: PromptSourceSchedule;
};
};
export function exportAppConfig() {
const { config, webdav } = useConfigStore.getState();
const { sources, schedule } = usePromptSourceStore.getState();
const data: AppConfigFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), config, webdav, promptSources: { sources, schedule } };
saveAs(new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }), "infinite-canvas-config.json");
}
export async function importAppConfig(file: File) {
let data: AppConfigFile;
try {
data = JSON.parse(await file.text()) as AppConfigFile;
} catch {
throw new Error("配置文件格式不正确");
}
if (data.app !== "infinite-canvas" || data.version !== 1 || !data.config || !data.webdav || !data.promptSources) throw new Error("配置文件格式不正确");
useConfigStore.setState({ config: data.config, webdav: data.webdav });
usePromptSourceStore.setState(data.promptSources);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { create } from "zustand";
import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error"; export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
export type AgentAttachment = { id: string; name: string; type: string; size: number; url: string; dataUrl: string }; export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string }; export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown }; export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> }; export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
@@ -0,0 +1,53 @@
import { create } from "zustand";
export const CANVAS_SIDE_PANEL_MOTION_MS = 500;
export const CANVAS_SIDE_PANEL_MIN_WIDTH = 220;
export const CANVAS_SIDE_PANEL_MAX_WIDTH = 480;
export const CANVAS_SIDE_PANEL_DEFAULT_WIDTH = 280;
const WIDTH_KEY = "canvas-side-panel-width";
const OPEN_KEY = "canvas-side-panel-open";
function initialWidth() {
if (typeof window === "undefined") return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
const stored = Number(localStorage.getItem(WIDTH_KEY));
if (!stored) return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
return Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, stored));
}
function initialOpen() {
if (typeof window === "undefined") return true;
return localStorage.getItem(OPEN_KEY) !== "0";
}
type CanvasSidePanelStore = {
width: number;
panelOpen: boolean;
panelMounted: boolean;
panelClosing: boolean;
setWidth: (width: number) => void;
openPanel: () => void;
closePanel: () => void;
togglePanel: () => void;
};
export const useCanvasSidePanelStore = create<CanvasSidePanelStore>((set, get) => ({
width: initialWidth(),
panelOpen: initialOpen(),
panelMounted: initialOpen(),
panelClosing: false,
setWidth: (width) => set({ width }),
openPanel: () => {
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "1");
set({ panelOpen: true, panelMounted: true, panelClosing: false });
},
closePanel: () => {
if (!get().panelMounted || get().panelClosing) return;
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "0");
set({ panelOpen: false, panelClosing: true });
setTimeout(() => {
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
}, CANVAS_SIDE_PANEL_MOTION_MS);
},
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
}));

Some files were not shown because too many files have changed in this diff Show More