diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b13a81..b892be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # CHANGELOG ## Unreleased + ++ [新增] Canvas Agent 支持通过 `--debug` 输出彩色详细日志并按日期保存到本地日志文件。 ++ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。 ++ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。 ++ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。 + ## v0.11.0 - 2026-07-28 + [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。 diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 5ac8fca..975b8be 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -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 diff --git a/canvas-agent/agent-instructions.md b/canvas-agent/agent-instructions.md new file mode 100644 index 0000000..9354485 --- /dev/null +++ b/canvas-agent/agent-instructions.md @@ -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。 diff --git a/canvas-agent/bun.lock b/canvas-agent/bun.lock index da19660..6941cde 100644 --- a/canvas-agent/bun.lock +++ b/canvas-agent/bun.lock @@ -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=="], diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 69ebc60..1121730 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -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": { diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index fb23c5b..4764ef7 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -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; @@ -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") || "工具调用"); diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index 6e47e04..f537d62 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -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) { 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), 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; 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)) }); diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts index 633df39..4fcaa87 100644 --- a/canvas-agent/src/config.ts +++ b/canvas-agent/src/config.ts @@ -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(); 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 { const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : { 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 = {}) => { @@ -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 : { 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 }); }); } diff --git a/canvas-agent/src/utils/date.ts b/canvas-agent/src/utils/date.ts new file mode 100644 index 0000000..c96886d --- /dev/null +++ b/canvas-agent/src/utils/date.ts @@ -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}`; +} diff --git a/canvas-agent/src/utils/logger.ts b/canvas-agent/src/utils/logger.ts new file mode 100644 index 0000000..398f1ad --- /dev/null +++ b/canvas-agent/src/utils/logger.ts @@ -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()): 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).map(([field, item]) => [field, sanitize(item, field, seen)])); +} + +export const logger = new Logger(); diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 355073b..7ffc73d 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index a8828a3..f08c3dc 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -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()); 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(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[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 = ( <> void loadThreads()} onNewThread={() => void startNewThread()} onResumeThread={(threadId) => void resumeThread(threadId)} - onDeleteThread={confirmDeleteThread} + onDeleteThreads={confirmDeleteThreads} /> ) : activeTab === "log" ? ( ) : null} - {waiting && !pendingTool ? : null} + {(sending || waiting) && !streaming && !pendingTool ? : null} {showScrollToBottom ? ( @@ -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()); + 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 (
@@ -992,10 +1033,16 @@ function AgentHistoryView({
-
- {threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"} +
+ {threads.length ? setSelectedIds(allSelected ? new Set() : new Set(threads.map((thread) => thread.id)))} /> : null} + {selectedThreads.length ? `已选 ${selectedThreads.length} 条` : threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}
+ {selectedThreads.length ? ( + + ) : null} @@ -1008,8 +1055,28 @@ function AgentHistoryView({ {threads.map((thread) => { const active = thread.id === activeThreadId; return ( -
+
onResumeThread(thread.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onResumeThread(thread.id); + }} + >
+ event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onChange={() => toggleThread(thread.id)} + />
{active ? ( @@ -1021,15 +1088,7 @@ function AgentHistoryView({
{thread.preview || thread.id}
-
- {formatThreadTime(thread.updatedAt || thread.createdAt)} - - -
+ {formatThreadTime(thread.updatedAt || thread.createdAt)}
); @@ -1076,7 +1135,7 @@ function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChat function formatAgentEvent(event: AgentEventPayload): Omit | 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)); }