mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-28 19:14:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3b02f75a9 |
@@ -1,6 +1,12 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] Canvas Agent 支持通过 `--debug` 输出彩色详细日志并按日期保存到本地日志文件。
|
||||
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
|
||||
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
|
||||
|
||||
## v0.11.0 - 2026-07-28
|
||||
|
||||
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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") || "工具调用");
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,6 +5,10 @@ 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 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user