Compare commits

..

18 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
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
枯木泽 97bb330de1 feat(canvas): 优化图片编辑交互 2026-07-17 21:32:17 +08:00
41 changed files with 1810 additions and 426 deletions
+19
View File
@@ -2,6 +2,25 @@
## Unreleased
+ [新增] Canvas Agent 支持通过 `--debug` 输出彩色详细日志并按日期保存到本地日志文件。
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
## v0.11.0 - 2026-07-28
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
+ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
+ [新增] 模型渠道新增火山方舟协议。
+ [优化] 画布左侧元素列表新增图片放大预览操作。
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
+ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。
+ [修复] 画布组装提示词浮层限制正文高度,长提示词改为区域内滚动查看。
+ [修复] 修复画布节点提示词过长时无法使用鼠标滚轮滚动的问题。
+ [修复] 修复画布重试时组装提示词重复以及切换类型调用模型不一致的问题。
+ [修复] 修复生成完成后再次选中画布节点时提示词不再回显的问题。
## v0.10.0 - 2026-07-25
+ [新增] 提示词来源新增BananaPromptQuicker,并支持添加自定义标准 JSON 来源。
+1 -1
View File
@@ -25,7 +25,7 @@
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
> [!CAUTION]
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新。
>
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
+1 -1
View File
@@ -1 +1 @@
v0.10.0
v0.11.0
+8
View File
@@ -8,6 +8,14 @@
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
+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",
"@openai/codex": "^0.139.0",
"express": "^5.1.0",
"winston": "^3.19.0",
"zod": "^3.25.0",
},
"devDependencies": {
@@ -19,6 +20,10 @@
},
},
"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/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=="],
"@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/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/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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+3
View File
@@ -9,10 +9,12 @@
},
"files": [
"dist",
"agent-instructions.md",
"README.md"
],
"scripts": {
"dev": "tsx src/index.ts",
"debug": "tsx src/index.ts --debug",
"test": "tsx --test src/canvas-session.test.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
@@ -22,6 +24,7 @@
"@modelcontextprotocol/sdk": "^1.12.1",
"@openai/codex": "^0.139.0",
"express": "^5.1.0",
"winston": "^3.19.0",
"zod": "^3.25.0"
},
"devDependencies": {
+27 -4
View File
@@ -6,6 +6,7 @@ import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { AGENT_PROMPT, VERSION } from "./config.js";
import { logger } from "./utils/logger.js";
import type { AgentAttachment, AgentEmit } from "./types.js";
type Json = Record<string, unknown>;
@@ -39,6 +40,7 @@ export function interruptCodexTurn(threadId?: string) {
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
logger.info("Preparing Codex turn", { threadId: options.threadId, cwd: options.cwd, prompt, attachmentCount: attachments.length });
options.onStart?.();
files = await writeAttachmentFiles(attachments);
const app = await getCodexApp(options.appEmit || emit);
@@ -55,6 +57,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
await app.startTurn(threadId, prompt, files, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
emit("agent_error", { message: errorMessage(error) });
} finally {
options.onFinish?.();
@@ -153,12 +156,21 @@ class CodexAppClient {
private constructor(private child: ChildProcess, private 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 client = new CodexAppClient(child, emit);
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
child.on("error", (error) => emit("agent_error", { message: error.message }));
child.stderr?.on("data", (chunk) => {
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) => {
logger.warn("Codex app-server exited", { code });
client.failAll(`Codex app-server exited: ${code ?? 0}`);
codexApp = null;
codexThreadId = "";
@@ -214,6 +226,7 @@ class CodexAppClient {
interruptCurrentTurn() {
if (this.activeTurns.size === 0) return false;
try {
logger.warn("Interrupting active Codex turn", { threadId: codexThreadId, activeTurns: this.activeTurns.size });
this.child.kill("SIGINT");
return true;
} catch {
@@ -232,6 +245,7 @@ class CodexAppClient {
}
private write(value: unknown) {
logger.debug("Codex app-server request", value);
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
}
@@ -242,13 +256,15 @@ class CodexAppClient {
lines.filter(Boolean).forEach((line) => {
try {
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 });
}
});
}
private handle(message: Json) {
logger.debug("Codex app-server response", message);
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 (this.pending.has(id)) return this.resolve(id, message.result);
@@ -261,6 +277,13 @@ class CodexAppClient {
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
const event = normalizeCodexNotification(method, params);
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;
this.emit("agent_event", { agent: "codex", ...event });
if (event.type === "turn.completed") {
@@ -432,7 +455,7 @@ function threadMessages(thread: unknown): AgentHistoryMessage[] {
}
if (type === "agentMessage") {
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") {
const tool = String(field(item, "tool") || "工具调用");
+12
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import type { ServerResponse } from "node:http";
import { logger } from "./utils/logger.js";
import { type ToolName } from "./schemas.js";
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
@@ -51,12 +52,14 @@ export class CanvasSession {
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) {
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
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" });
if (!statusOnly) {
this.clients.set(clientId, res);
@@ -70,6 +73,7 @@ export class CanvasSession {
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => {
clearInterval(timer);
logger.info("SSE client disconnected", { clientId, statusOnly });
if (statusOnly || this.clients.get(clientId) !== res) return;
this.clients.delete(clientId);
this.clientFocusOrder.delete(clientId);
@@ -88,21 +92,25 @@ export class CanvasSession {
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 });
}
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[]) {
@@ -142,6 +150,7 @@ export class CanvasSession {
const item = body.requestId ? this.pending.get(body.requestId) : null;
if (!item || !body.requestId || item.clientId !== clientId) return false;
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);
return true;
}
@@ -156,6 +165,7 @@ export class CanvasSession {
async callTool(name: unknown, rawInput: unknown) {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
let tool: ToolName = name;
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
if (SITE_TOOLS.has(tool)) {
@@ -296,9 +306,11 @@ export class CanvasSession {
const client = this.clients.get(clientId);
if (!client) throw new Error("当前没有已连接画布");
sendEvent(client, "tool_call", { requestId, name, input });
logger.debug("Canvas tool request sent", { requestId, name, input, clientId });
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
logger.warn("Canvas tool request timed out", { requestId, name, clientId });
reject(new Error("画布操作超时"));
}, 30000);
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
+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_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
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。本轮若有用户上传的图片附件,会同时给出 attachmentId;用户要求把附件放入画布或作为生成参考图时,必须先用 canvas_create_attachment_nodes 创建真实图片节点,再把返回的节点 ID 传给 canvas_create_generation_flow.referenceNodeIds,不要创建空图片占位节点。若当前不在画布页,画布工具会报错,需先用 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 CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
@@ -31,12 +32,12 @@ export function ensureSiteWorkspace(config: CanvasAgentConfig) {
const current = config.workspace;
if (current?.workspacePath) {
const workspacePath = resolveWorkspacePath(current.workspacePath);
fs.mkdirSync(workspacePath, { recursive: true });
initializeWorkspace(workspacePath);
return { ...current, workspacePath };
}
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
config.workspace = { workspacePath };
fs.mkdirSync(workspacePath, { recursive: true });
initializeWorkspace(workspacePath);
saveConfig(config);
return { workspacePath };
}
@@ -46,11 +47,19 @@ export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<Si
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
const next = { ...current, ...patch, workspacePath };
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
fs.mkdirSync(workspacePath, { recursive: true });
initializeWorkspace(workspacePath);
saveConfig(config);
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) {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
+19 -2
View File
@@ -3,6 +3,7 @@ import express, { type NextFunction, type Request, type Response } from "express
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.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";
export function startHttpServer() {
@@ -15,6 +16,7 @@ export function startHttpServer() {
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> = {}) => {
@@ -25,6 +27,13 @@ export function startHttpServer() {
const app = express();
app.disable("x-powered-by");
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) => {
const url = requestUrl(req, config);
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
@@ -108,6 +117,7 @@ export function startHttpServer() {
const prompt = String(req.body?.prompt || "");
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
const clientId = String(req.body?.clientId || "");
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 })) });
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
try {
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
@@ -130,7 +140,7 @@ export function startHttpServer() {
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
session.emitThread(type, threadId, data);
};
void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
appEmit: emit,
@@ -148,9 +158,11 @@ export function startHttpServer() {
},
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 });
@@ -171,7 +183,10 @@ export function startHttpServer() {
res.json({ ok: true });
});
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", () => {
console.log("Infinite Canvas Agent");
@@ -180,6 +195,8 @@ export function startHttpServer() {
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("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 });
});
}
+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();
@@ -5,7 +5,23 @@ 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 原文。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`OpenAI 模板应按自动或指定档位正确组装请求。
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
- 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。
- 画布组装提示词:输入或连接超长提示词后,浮层正文高度不应继续撑大,内容应可在正文区域内滚动查看和编辑,标题及关闭按钮保持可见。
- 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布。
- 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。
- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。
- 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。
- 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。
- 提示词详情弹窗:封面和参考图应固定显示在上方,复制及加入资产操作栏固定在底部,只有中间的标签、描述及提示词内容区域可以滚动;弹窗宽高应受视口限制且不超出屏幕。
- 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
@@ -126,7 +126,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
ref={editorRef}
contentEditable
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 }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
@@ -3,12 +3,13 @@ import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings
import { Button, Segmented } from "antd";
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 { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type CanvasConfigNodePanelProps = {
@@ -95,7 +96,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</button>
</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 />
{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))} />
@@ -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 })} />
) : 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))} />
) : 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>
<Button
@@ -143,17 +146,10 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
}
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 {
...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,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
@@ -1,6 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import { App, Button, Checkbox, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { ChevronDown, Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, MessageSquare, PlugZap, Plus, RefreshCw, Square, Terminal, Trash2 } from "lucide-react";
@@ -49,6 +49,7 @@ type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?:
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
type AgentHealth = { codexBusy?: boolean };
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean };
type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem };
@@ -102,6 +103,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(randomId());
const loadThreadsSequenceRef = useRef(0);
const turnSyncSequenceRef = useRef(0);
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async (skipHistory = false) => {
@@ -124,6 +126,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
}
}, [endpoint, setAgentState, token]);
const syncTurnMessages = async (sequence: number) => {
while (sequence === turnSyncSequenceRef.current && useAgentStore.getState().connected) {
try {
const health = await fetchAgentJson<AgentHealth>(endpoint, token, "/health");
if (!health.codexBusy) {
await wait(300);
if (sequence !== turnSyncSequenceRef.current) return;
await loadThreads();
if (sequence === turnSyncSequenceRef.current) setAgentState({ waiting: false, sending: false, activity: "完成" });
return;
}
} catch {}
await wait(500);
}
};
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
useEffect(() => {
@@ -313,7 +330,9 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
});
setAgentState({ prompt: "", attachments: [] });
setAgentState({ prompt: "", attachments: [], sending: false, waiting: true, activity: "Codex 正在运行" });
const sequence = ++turnSyncSequenceRef.current;
void syncTurnMessages(sequence);
} catch (error) {
const text = error instanceof Error ? error.message : "发送失败";
const busy = text.includes("Codex 正在运行");
@@ -512,6 +531,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
loadThreadsSequenceRef.current += 1;
turnSyncSequenceRef.current += 1;
setAgentState({
messages: [],
threads: [],
@@ -554,18 +574,19 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}
};
const deleteThread = async (threadId: string) => {
if (!connected || !threadId || sending || waiting) return;
const deleteThreads = async (threadIds: string[]) => {
if (!connected || !threadIds.length || sending || waiting) return;
setAgentState({ loadingThreads: true });
try {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
await Promise.all(threadIds.map((threadId) => fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) })));
const current = useAgentStore.getState();
const deleted = new Set(threadIds);
setAgentState({
threads: current.threads.filter((thread) => thread.id !== threadId),
activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId,
messages: current.activeThreadId === threadId ? [] : current.messages,
threads: current.threads.filter((thread) => !deleted.has(thread.id)),
activeThreadId: deleted.has(current.activeThreadId) ? "" : current.activeThreadId,
messages: deleted.has(current.activeThreadId) ? [] : current.messages,
});
message.success("记录已删除");
message.success(`已删除 ${threadIds.length} 条记录`);
} catch (error) {
addEventLog("删除对话失败", error);
message.error(error instanceof Error ? error.message : "删除对话失败");
@@ -574,15 +595,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}
};
const confirmDeleteThread = (thread: AgentThreadSummary) => {
const label = thread.name || thread.preview || "未命名对话";
const confirmDeleteThreads = (threadIds: string[]) => {
modal.confirm({
title: "删除对话记录",
content: `确定删除「${label.length > 48 ? `${label.slice(0, 48)}...` : label}」吗?`,
title: `删除 ${threadIds.length} 条对话记录`,
content: "删除后无法恢复,确定继续吗?",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => deleteThread(thread.id),
onOk: () => deleteThreads(threadIds),
});
};
@@ -616,10 +636,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const handleAgentEvent = (event: AgentEventPayload) => {
if (shouldLogAgentEvent(event)) addEventLog(eventTitle(event), event, event);
if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id });
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
if (index >= 0) {
const text = stringText(event.item.text);
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: text || message.text, meta: usageText(event) || message.meta, streamId: undefined } : message)) });
return;
}
}
if (event.type === "turn.completed") setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
const item = formatAgentEvent(event);
if (item) addMessage(item);
};
const streaming = messages.some((message) => message.streamId);
const content = (
<>
<AgentPanelTabs
@@ -669,7 +700,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
onRefresh={() => void loadThreads()}
onNewThread={() => void startNewThread()}
onResumeThread={(threadId) => void resumeThread(threadId)}
onDeleteThread={confirmDeleteThread}
onDeleteThreads={confirmDeleteThreads}
/>
) : activeTab === "log" ? (
<AgentLogView
@@ -696,7 +727,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
onApprove={approvePendingTool}
/>
) : null}
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
{showScrollToBottom ? (
<Tooltip title="滚动到底部" placement="left">
@@ -967,7 +998,7 @@ function AgentHistoryView({
onRefresh,
onNewThread,
onResumeThread,
onDeleteThread,
onDeleteThreads,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
threads: AgentThreadSummary[];
@@ -979,8 +1010,18 @@ function AgentHistoryView({
onRefresh: () => void;
onNewThread: () => void;
onResumeThread: (threadId: string) => void;
onDeleteThread: (thread: AgentThreadSummary) => void;
onDeleteThreads: (threadIds: string[]) => void;
}) {
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
const toggleThread = (threadId: string) => {
setSelectedIds((current) => {
const next = new Set(current);
next.has(threadId) ? next.delete(threadId) : next.add(threadId);
return next;
});
};
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
@@ -992,10 +1033,16 @@ function AgentHistoryView({
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm" style={{ color: theme.node.muted }}>
{threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}
<div className="flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}>
{threads.length ? <Checkbox checked={allSelected} indeterminate={Boolean(selectedThreads.length) && !allSelected} disabled={loading || busy} onChange={() => setSelectedIds(allSelected ? new Set() : new Set(threads.map((thread) => thread.id)))} /> : null}
<span>{selectedThreads.length ? `已选 ${selectedThreads.length}` : threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}</span>
</div>
<div className="flex items-center gap-2">
{selectedThreads.length ? (
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={loading || busy} onClick={() => onDeleteThreads(selectedThreads.map((thread) => thread.id))}>
{selectedThreads.length}
</Button>
) : null}
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
</Button>
@@ -1008,8 +1055,28 @@ function AgentHistoryView({
{threads.map((thread) => {
const active = thread.id === activeThreadId;
return (
<div key={thread.id} className="rounded-lg border px-2.5 py-1.5 transition" style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}>
<div
key={thread.id}
role="button"
tabIndex={0}
className="cursor-pointer rounded-lg border px-2.5 py-2 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20 dark:hover:bg-white/10"
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
onClick={() => onResumeThread(thread.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onResumeThread(thread.id);
}}
>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(thread.id)}
disabled={loading || busy}
aria-label={`选择${thread.name || thread.preview || "未命名对话"}`}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onChange={() => toggleThread(thread.id)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{active ? (
@@ -1021,15 +1088,7 @@ function AgentHistoryView({
</div>
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" disabled={loading || busy} onClick={() => onResumeThread(thread.id)}>
</Button>
<Tooltip title="删除记录">
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" disabled={loading || busy} icon={<Trash2 className="size-3.5" />} onClick={() => onDeleteThread(thread)} />
</Tooltip>
</div>
<span className="shrink-0 text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
</div>
</div>
);
@@ -1076,7 +1135,7 @@ function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChat
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), meta: usageText(event), streamId: item.id };
if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), meta: usageText(event), streamId: event.type === "item.updated" ? item.id : undefined };
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };
const text = eventText(event);
if (text) return { role: "assistant", title: "Codex", text, meta: usageText(event) };
@@ -1343,6 +1402,7 @@ function normalizeHistoryMessages(messages: AgentChatItem[]) {
...item,
id: item.id || `history-${index}`,
text: normalizeText(item.text),
streamId: undefined,
}))
.filter((item) => item.text);
}
@@ -1356,6 +1416,10 @@ function createId() {
return randomId();
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react";
import { Button, Modal, Segmented, Tooltip } from "antd";
import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
import { readImageMeta } from "@/lib/image-utils";
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 minSize = 0.06;
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 }) {
const boxRef = useRef<HTMLDivElement>(null);
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 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;
useEffect(() => {
if (open) setCrop(defaultCrop);
if (open) {
setCrop(defaultCrop);
setRatioPreset("free");
setFixedRatio(null);
}
}, [dataUrl, open]);
useEffect(() => {
@@ -34,44 +51,78 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
dragAbortRef.current?.abort();
const controller = new AbortController();
dragAbortRef.current = controller;
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
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 = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
const stop = () => controller.abort();
document.addEventListener("pointermove", move, { signal: controller.signal });
document.addEventListener("pointerup", stop, { signal: controller.signal });
document.addEventListener("pointercancel", stop, { signal: controller.signal });
};
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="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<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
ref={viewport.viewportRef}
{...viewport.panHandlers}
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="relative" style={viewport.contentStyle}>
<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="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="block h-full w-full object-contain opacity-90" draggable={false} />
</div>
<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 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 gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
@@ -82,9 +133,20 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</span>
) : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
<Segmented
size="small"
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 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) };
}
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 };
if (handle.includes("e")) next.width = crop.width + dx;
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.height = crop.height - dy;
}
if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width;
next.height = size / box.height;
if (aspectRatio) {
const normalizedRatio = aspectRatio * (box.height / box.width);
const horizontalOnly = (handle.includes("e") || handle.includes("w")) && !handle.includes("n") && !handle.includes("s");
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("n")) next.y = crop.y + crop.height - next.height;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
if (aspectRatio) {
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.y = clamp(next.y, 0, 1 - next.height);
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) {
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 { Button, Input, Modal, Slider } from "antd";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom";
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 { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageMaskEditPayload = {
prompt: string;
@@ -10,20 +12,29 @@ export type CanvasImageMaskEditPayload = {
};
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 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 }) {
const maskCanvasRef = 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 [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint");
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(() => {
if (!open) return;
@@ -31,6 +42,13 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
setBrushSize(defaultBrushSize);
setMode("paint");
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);
}, [dataUrl, open]);
@@ -42,53 +60,144 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d");
if (!maskCanvas || !context) return;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
if (!drawingRef.current.last) {
drawMaskStroke(context, point, point, brushSize);
} else {
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
}
renderMaskPreview(maskCanvas, previewCanvasRef.current);
drawingRef.current.last = point;
if (mode === "paint") {
const context = maskCanvas?.getContext("2d", { willReadFrequently: true });
const previewContext = previewCanvasRef.current?.getContext("2d");
const stroke = drawingRef.current.stroke;
if (!maskCanvas || !context || !previewContext || !stroke) return;
configureStrokeContext(context, stroke);
configurePreviewStrokeContext(previewContext, stroke);
const last = stroke.points.at(-1);
drawMaskStroke(context, last || point, point, stroke.size);
drawMaskStroke(previewContext, last || point, point, stroke.size);
stroke.points.push(point);
if (stroke.mode === "paint") {
setError("");
}
};
const updateBrushPreview = (event: ReactPointerEvent<HTMLCanvasElement>, size = brushSize, adjusting = false) => {
setBrushPreview({
x: event.clientX,
y: event.clientY,
size,
adjusting,
});
};
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.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
drawingRef.current = { active: true, last: null };
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
updateBrushPreview(event);
drawingRef.current = { active: true, stroke: { mode, size: brushSize, points: [] } };
draw(event);
};
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;
event.preventDefault();
draw(event);
};
const stopDraw = () => {
drawingRef.current = { active: false, last: null };
const maskCanvas = maskCanvasRef.current;
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
const stopDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const brushAdjust = brushAdjustRef.current;
if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) {
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 = () => {
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
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 nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current;
@@ -99,33 +208,58 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
<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 className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
/>
</>
) : null}
<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]" data-canvas-no-zoom>
<div
ref={viewport.viewportRef}
{...viewport.panHandlers}
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" : ""}`}
>
<div className="relative" style={viewport.contentStyle}>
<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}>
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full bg-transparent object-contain" draggable={false} />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-none touch-none"
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>
{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>
<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-xs leading-5 opacity-55"> · + · Alt+/ · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </div>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -137,6 +271,26 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
</Button>
</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="flex items-center justify-between text-sm">
<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) {
const context = canvas?.getContext("2d");
const context = canvas?.getContext("2d", { willReadFrequently: true });
if (!canvas || !context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
}
@@ -206,8 +364,43 @@ function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y:
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) {
const context = canvas.getContext("2d");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return false;
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < data.length; index += 4) {
@@ -216,54 +409,13 @@ function canvasHasPaint(canvas: HTMLCanvasElement) {
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) {
const canvas = document.createElement("canvas");
canvas.width = selectionCanvas.width;
canvas.height = selectionCanvas.height;
const context = canvas.getContext("2d");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return selectionCanvas.toDataURL("image/png");
const selectionContext = selectionCanvas.getContext("2d");
const selectionContext = selectionCanvas.getContext("2d", { willReadFrequently: true });
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height);
if (!selectionContext) return canvas.toDataURL("image/png");
@@ -3,7 +3,7 @@ import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
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 { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
@@ -11,6 +11,7 @@ import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasPromptChipInput } from "./canvas-prompt-chip-input";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -37,11 +38,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
// 仅在切换到其它节点时重置输入框;同一节点生成完成后(内容写回自身导致 isEditingExistingContent 变化)保留用户输入
// 仅在切换到其它节点时恢复对应提示词;同一节点生成完成后继续保留当前输入
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
setPrompt(node.metadata?.prompt || "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.id]);
@@ -58,6 +59,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
return (
<div
data-canvas-no-zoom
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
@@ -100,7 +102,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<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)} className="max-w-[190px]" />
<>
<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>
<Button
@@ -133,17 +138,10 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
}
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 {
...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,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal, Tooltip } from "antd";
import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageSplitParams = ImageSplitParams;
@@ -15,7 +16,13 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(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 verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1;
@@ -28,6 +35,10 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
setParams(defaultParams);
setActive(null);
setImage(null);
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
}, [dataUrl, open]);
useEffect(() => {
@@ -35,20 +46,28 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]);
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
};
const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns };
});
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const spot = findLineSpot(params[key] || []);
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 = () => {
if (!active) return;
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
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) => {
event.preventDefault();
event.stopPropagation();
setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect();
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 up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
const stop = () => controller.abort();
window.addEventListener("pointermove", move, { signal: controller.signal });
window.addEventListener("pointerup", stop, { signal: controller.signal });
window.addEventListener("pointercancel", stop, { signal: controller.signal });
};
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => {
@@ -78,28 +100,93 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
});
};
const resetLines = () => {
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null);
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 };
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-5" data-canvas-no-zoom>
<div>
<h2 className="text-xl font-semibold"></h2>
<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 className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
<div
ref={viewport.viewportRef}
{...viewport.panHandlers}
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 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>
</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={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</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>
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
线
</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 className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
@@ -184,3 +279,19 @@ function clampGrid(value: string | number) {
const numberValue = Number(value);
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);
}
+24 -20
View File
@@ -29,6 +29,7 @@ type Props = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
onFocusNode: (nodeId: string) => void;
onPreviewNode: (nodeId: string) => void;
onInsertAsset: (payload: InsertAssetPayload) => void;
};
@@ -48,7 +49,7 @@ const STATUS_COLOR: Record<string, string> = {
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 [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
@@ -103,7 +104,7 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertA
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">
{tab === "canvas" ? (
<CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} />
<CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} onPreviewNode={onPreviewNode} theme={theme} />
) : tab === "assets" ? (
<CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />
) : (
@@ -144,7 +145,7 @@ function nodePreviewText(node: CanvasNodeData) {
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 [typeFilter, setTypeFilter] = useState<string>("all");
@@ -216,23 +217,26 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
const isChecked = checked.has(node.id);
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))}
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")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{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>
<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}>
<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 : "定位到节点"}>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{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>
{selectMode || !isImage ? null : (
<div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5">
<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="放大预览">
<Eye className="size-3.5" />
</button>
</div>
)}
</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,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));
}
+30 -3
View File
@@ -1,10 +1,11 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
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 { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
@@ -51,6 +52,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const configInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [editingChannelId, setEditingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false);
@@ -80,6 +82,17 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
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 addChannel = () => {
@@ -153,6 +166,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
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
activeKey={activeTab}
onChange={(key) => setActiveTab(key as ConfigTabKey)}
@@ -357,7 +382,9 @@ function normalizeImageCount(value: string) {
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
if (apiFormat === "gemini") return "Gemini";
if (apiFormat === "ark") return "火山方舟";
return "OpenAI";
}
function formatWebdavTime(value: string) {
@@ -9,6 +9,7 @@ import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: "火山方舟", value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
@@ -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 { 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 { modelOptionName, type AiConfig } from "@/stores/use-config-store";
import { type AiConfig } from "@/stores/use-config-store";
const resolutionOptions = [
{ value: "720", label: "720p" },
@@ -105,8 +105,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
}
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
const model = modelOptionName(config.model || config.videoModel);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const resolution = normalizeSeedanceResolution(config.vquality);
const ratio = normalizeSeedanceRatio(config.size);
const duration = normalizeSeedanceDuration(config.videoSeconds);
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}
<SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{seedanceResolutionOptions.map((item) => {
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
return (
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
);
})}
{seedanceResolutionOptions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
))}
</div>
{isSeedanceFastModel(model) ? <div className="text-[11px] leading-4 opacity-55">fast 1080p使 720p</div> : null}
</SettingGroup>
<SettingGroup title="比例" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
@@ -1,4 +1,4 @@
import { defaultConfig, type AiConfig } from "@/stores/use-config-store";
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";
@@ -90,10 +90,10 @@ export function getInputSummary(inputs: NodeGenerationInput[]) {
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
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,
+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 { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -7,9 +7,10 @@ export const SEEDANCE_REFERENCE_LIMITS = {
videos: 3,
audios: 3,
imageMaxBytes: 30 * 1024 * 1024,
videoMaxBytes: 50 * 1024 * 1024,
videoMaxBytes: 200 * 1024 * 1024,
audioMaxBytes: 15 * 1024 * 1024,
};
export const SEEDANCE_VIDEO_MIME_TYPES = ["video/mp4", "video/quicktime"];
export const seedanceResolutionOptions = [
{ value: "480p", label: "480p" },
@@ -56,28 +57,13 @@ const seedancePixels = {
},
} 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;
return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl);
return requestConfig.apiFormat === "ark";
}
export function isSeedanceVideoModel(model: 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 = "") {
export function normalizeSeedanceResolution(value: string) {
const normalized = normalizeResolutionToken(value);
if (isSeedanceFastModel(model) && normalized === "1080p") return "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) {
const video = videos[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 < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
totalDurationMs += video.durationMs;
@@ -159,7 +146,7 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
const ratio = video.width / video.height;
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`;
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 秒";
+125 -71
View File
@@ -1847,13 +1847,31 @@ function InfiniteCanvasPage() {
const handleImageInputChange = useCallback(
async (event: ReactChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
const files = Array.from(event.target.files || []).filter(
(f) => f.type.startsWith("image/") || f.type.startsWith("video/") || isAudioFile(f),
);
if (!files.length) {
uploadTargetRef.current = null;
event.target.value = "";
return;
}
const target = uploadTargetRef.current;
const basePosition =
target?.position ||
screenToCanvas(
(containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2,
(containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2,
);
const STAGGER = 40; // 多文件时的偏移间距
// 如果有替换目标节点,第一个文件替换它,其余在附近新建
if (target?.nodeId) {
if (isAudioFile(file)) {
const audio = await uploadMediaFile(file, "audio");
const [first, ...rest] = files;
// 第一个文件:替换目标节点
if (isAudioFile(first)) {
const audio = await uploadMediaFile(first, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
setNodes((prev) =>
prev.map((node) =>
@@ -1861,7 +1879,7 @@ function InfiniteCanvasPage() {
? {
...node,
type: CanvasNodeType.Audio,
title: file.name,
title: first.name,
position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 },
width: spec.width,
height: spec.height,
@@ -1872,12 +1890,8 @@ function InfiniteCanvasPage() {
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
uploadTargetRef.current = null;
event.target.value = "";
return;
}
if (file.type.startsWith("video/")) {
const video = await uploadMediaFile(file, "video");
} else if (first.type.startsWith("video/")) {
const video = await uploadMediaFile(first, "video");
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) =>
prev.map((node) =>
@@ -1885,7 +1899,7 @@ function InfiniteCanvasPage() {
? {
...node,
type: CanvasNodeType.Video,
title: file.name,
title: first.name,
position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 },
width: nextSize.width,
height: nextSize.height,
@@ -1896,50 +1910,69 @@ function InfiniteCanvasPage() {
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
uploadTargetRef.current = null;
event.target.value = "";
return;
} else {
const image = await uploadImage(first);
const s = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? {
...node,
type: CanvasNodeType.Image,
title: first.name,
width: s.width,
height: s.height,
metadata: {
...node.metadata,
...imageMetadata(image),
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
}
: node,
),
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
}
// 剩余文件:在目标节点附近新建
for (let i = 0; i < rest.length; i++) {
const offsetPos = { x: basePosition.x + (i + 1) * STAGGER, y: basePosition.y + (i + 1) * STAGGER };
const f = rest[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, offsetPos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, offsetPos);
} else {
void createImageFileNode(f, offsetPos);
}
}
const image = await uploadImage(file);
const size = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? {
...node,
type: CanvasNodeType.Image,
title: file.name,
width: size.width,
height: size.height,
metadata: {
...node.metadata,
...imageMetadata(image),
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
}
: node,
),
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
} else {
const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
// 无替换目标:所有文件在画布中心附近新建
for (let i = 0; i < files.length; i++) {
const offsetPos = { x: basePosition.x + i * STAGGER, y: basePosition.y + i * STAGGER };
const f = files[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, offsetPos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, offsetPos);
} else {
void createImageFileNode(f, offsetPos);
}
}
}
uploadTargetRef.current = null;
@@ -1951,11 +1984,24 @@ function InfiniteCanvasPage() {
const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!file) return;
const files = Array.from(event.dataTransfer.files).filter(
(item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item),
);
if (!files.length) return;
const pos = screenToCanvas(event.clientX, event.clientY);
void (isAudioFile(file) ? createAudioFileNode(file, pos) : file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos));
const basePos = screenToCanvas(event.clientX, event.clientY);
const STAGGER = 40;
for (let i = 0; i < files.length; i++) {
const pos = { x: basePos.x + i * STAGGER, y: basePos.y + i * STAGGER };
const f = files[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, pos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, pos);
} else {
void createImageFileNode(f, pos);
}
}
},
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas],
);
@@ -2041,14 +2087,13 @@ function InfiniteCanvasPage() {
return;
}
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
finishGenerationRequest(nodeId, runController);
setRunningNodeId(null);
return;
}
let pendingChildIds: string[] = [];
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(node.type === CanvasNodeType.Config ? {} : { prompt }), status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
if (mode === "image") {
@@ -2112,7 +2157,7 @@ function InfiniteCanvasPage() {
? isConfigNode
? {
...node,
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined },
}
: isEmptyImageNode
? {
@@ -2151,6 +2196,7 @@ function InfiniteCanvasPage() {
if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller);
let hasSuccess = false;
let hasFailure = false;
let firstError = "";
await Promise.all(
targetIds.map(async (targetId) => {
try {
@@ -2189,6 +2235,7 @@ function InfiniteCanvasPage() {
} catch (error) {
if (isGenerationCanceled(error)) return false;
const errorDetails = error instanceof Error ? error.message : "生成失败";
if (!firstError) firstError = errorDetails;
hasFailure = true;
setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
} finally {
@@ -2202,14 +2249,16 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
return;
}
if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
if (hasFailure) {
message.error(hasSuccess ? "部分图片生成失败" : firstError || "生成失败");
}
setNodes((prev) =>
prev.map((node) =>
node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } }
: node.id === nodeId && isEmptyImageNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === rootId && !hasSuccess
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } }
: node.id === rootId && !hasSuccess && !targetIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } }
: node,
),
@@ -2334,9 +2383,9 @@ function InfiniteCanvasPage() {
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort },
}));
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
}
@@ -2370,7 +2419,12 @@ function InfiniteCanvasPage() {
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } }
: node.id === nodeId && !editingTextNode
? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } }
? {
...node,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Generated Text",
metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort, status: NODE_STATUS_SUCCESS },
}
: node,
),
);
@@ -2706,7 +2760,7 @@ function InfiniteCanvasPage() {
return (
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onPreviewNode={setPreviewNodeId} onInsertAsset={handleAssetInsert} />
<section className="relative min-w-0 flex-1 overflow-hidden">
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
@@ -2928,7 +2982,7 @@ function InfiniteCanvasPage() {
/>
) : null}
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
+24 -2
View File
@@ -69,6 +69,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function ImagePage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -89,6 +90,7 @@ export default function ImagePage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [isReferenceDragActive, setIsReferenceDragActive] = useState(false);
const [autoRunToken, setAutoRunToken] = useState(0);
const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand);
const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand);
@@ -427,7 +429,27 @@ export default function ImagePage() {
</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) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault();
@@ -449,7 +471,7 @@ export default function ImagePage() {
</button>
</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>
@@ -5,42 +5,40 @@ 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 }) {
return (
<>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
{prompt ? (
<>
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-3">
{prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> : <div className="grid aspect-[4/3] w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-9" /></div>}
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-3 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}
{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>
<div className="min-w-0">
<div className="flex flex-wrap gap-1.5">
{prompt.tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
<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}
<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>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}>
{prompt ? (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 space-y-3 pb-4">
{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>}
{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}
</div>
<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 className="flex flex-wrap gap-1.5">
{prompt.tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
</>
) : null}
</Modal>
</>
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : 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}
<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>
);
}
+59 -11
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 { 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 localforage from "localforage";
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 { canvasThemes } from "@/lib/canvas-theme";
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 { resolveImageUrl, uploadImage } from "@/services/image-storage";
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() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const activeLogIdsRef = useRef<Set<string>>(new Set());
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
@@ -94,6 +95,7 @@ export default function VideoPage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [referenceDragTarget, setReferenceDragTarget] = useState<"image" | "video" | "audio" | null>(null);
const [autoRunToken, setAutoRunToken] = useState(0);
const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand);
const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand);
@@ -116,13 +118,13 @@ export default function VideoPage() {
const addReferences = async (files?: FileList | null) => {
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 音频");
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);
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 的参考音频");
const nextReferences = await Promise.all(
imageFiles.map(async (file) => {
@@ -151,6 +153,25 @@ export default function VideoPage() {
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 () => {
try {
const items = await navigator.clipboard.read();
@@ -431,7 +452,16 @@ export default function VideoPage() {
</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 === "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) => (
<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" />
@@ -442,7 +472,7 @@ export default function VideoPage() {
</button>
</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>
@@ -453,7 +483,16 @@ export default function VideoPage() {
</Button>
</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) => (
<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" />
@@ -464,7 +503,7 @@ export default function VideoPage() {
</button>
</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>
@@ -475,7 +514,16 @@ export default function VideoPage() {
</Button>
</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) => (
<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">
@@ -490,7 +538,7 @@ export default function VideoPage() {
</button>
</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>
+40 -4
View File
@@ -102,17 +102,53 @@ async function assertAudioBlob(blob: Blob) {
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) {
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;
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) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
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;
}
+92 -8
View File
@@ -244,33 +244,84 @@ function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
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 =
payload.data
?.map(resolveImageDataUrl)
imageList
.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
.map((dataUrl) => ({ id: nanoid(), dataUrl }));
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;
}
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) {
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;
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 "请求已取消";
return error instanceof Error ? error.message : fallback;
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
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) {
@@ -750,6 +801,38 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
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 requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
@@ -810,6 +893,7 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
...(requestConfig.reasoningEffort === "auto" ? {} : { reasoning: { effort: requestConfig.reasoningEffort } }),
}, onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(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):
* prompt / images / messages / params
* model / baseUrl / apiKey / systemPrompt
* model / baseUrl / apiKey / systemPrompt / reasoningEffort
* http / request / poll / sleep / signal / onDelta
* 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",
"apiKey",
"systemPrompt",
"reasoningEffort",
"http",
"request",
"poll",
@@ -142,6 +143,7 @@ export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<
config.baseUrl,
config.apiKey,
config.systemPrompt || "",
config.reasoningEffort,
http,
request,
poll,
@@ -169,6 +171,7 @@ export const PLUGIN_VARIABLES: PluginVariable[] = [
{ name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" },
{ name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" },
{ 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: "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 返回真值即结束" },
@@ -321,12 +324,16 @@ return { data: audio.data };`,
text: [
{
label: "OpenAI 规范",
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model、reasoningEffort
const data = await request({
method: "post",
url: \`\${baseUrl}/v1/responses\`,
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
|| (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("")
+19 -4
View File
@@ -180,7 +180,7 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
model: modelOptionName(model),
content,
ratio: normalizeSeedanceRatio(config.size),
resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)),
resolution: normalizeSeedanceResolution(config.vquality),
duration: normalizeSeedanceDuration(config.videoSeconds),
generate_audio: boolConfig(config.videoGenerateAudio, true),
watermark: boolConfig(config.videoWatermark, false),
@@ -339,14 +339,29 @@ function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
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 {
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?: { message?: unknown } };
return readApiErrorMessage(payload.msg) || readApiErrorMessage(payload.message) || readApiErrorMessage(payload.error?.message);
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) {
+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);
}
+18 -3
View File
@@ -3,8 +3,9 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
export type ApiCallFormat = "openai" | "gemini";
export type ApiCallFormat = "openai" | "gemini" | "ark";
export type ModelCapability = "image" | "video" | "text" | "audio";
export type ReasoningEffort = "auto" | "low" | "medium" | "high" | "xhigh";
export type ChannelModel = {
name: string;
@@ -41,6 +42,7 @@ export type AiConfig = {
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
reasoningEffort: ReasoningEffort;
models: string[];
quality: string;
size: string;
@@ -62,6 +64,7 @@ export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
const CHANNEL_MODEL_SEPARATOR = "::";
const OPENAI_BASE_URL = "https://api.openai.com";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
const ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
export const defaultConfig: AiConfig = {
channelMode: "local",
@@ -97,6 +100,7 @@ export const defaultConfig: AiConfig = {
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
reasoningEffort: "auto",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
@@ -157,6 +161,14 @@ export function modelMatchesCapability(config: AiConfig, value: string, capabili
return modelCapabilityOf(config, value) === capability;
}
export function resolveModelForCapability(config: AiConfig, currentModel: string | undefined, capability: ModelCapability) {
const defaultModel = capability === "image" ? config.imageModel : capability === "video" ? config.videoModel : capability === "audio" ? config.audioModel : config.textModel;
const fallbackModel = capability === "image" ? defaultConfig.imageModel : capability === "video" ? defaultConfig.videoModel : capability === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
if (currentModel && modelMatchesCapability(config, currentModel, capability)) return currentModel;
if (defaultModel && modelMatchesCapability(config, defaultModel, capability)) return defaultModel;
return fallbackModel;
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return config.channels.flatMap((channel) => channel.models.filter((model) => model.capability === capability).map((model) => encodeChannelModel(channel.id, model.name)));
@@ -227,6 +239,7 @@ export const useConfigStore = create<ConfigStore>()(
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
reasoningEffort: config.reasoningEffort || "auto",
videoSeconds: config.videoSeconds || "6",
vquality: config.vquality || "720",
videoGenerateAudio: config.videoGenerateAudio || "true",
@@ -356,11 +369,13 @@ function normalizeChannels(config: AiConfig) {
}
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL;
if (apiFormat === "gemini") return GEMINI_BASE_URL;
if (apiFormat === "ark") return ARK_BASE_URL;
return OPENAI_BASE_URL;
}
function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
return apiFormat === "gemini" ? "gemini" : "openai";
return apiFormat === "gemini" || apiFormat === "ark" ? apiFormat : "openai";
}
function uniqueModelOptions(models: string[]) {
+1
View File
@@ -35,6 +35,7 @@ export type CanvasNodeMetadata = {
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
model?: string;
reasoningEffort?: "auto" | "low" | "medium" | "high" | "xhigh";
size?: string;
quality?: string;
background?: string;