mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-31 21:51:15 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afc7bfebaf | |||
| 520c12038f | |||
| 80756b6170 | |||
| 5b689c8feb | |||
| 1291dd08c3 | |||
| f2757baf20 | |||
| 45addead96 | |||
| 2002dff78a | |||
| 2afe2f3898 | |||
| 7a48f441cf | |||
| 8babf7faa0 | |||
| fe1ba781d1 | |||
| b926feeb14 | |||
| bd8cf318f7 | |||
| a9243b6c2e | |||
| b628b742d5 | |||
| d76594a7e7 | |||
| fa58ec4715 | |||
| 5a607bea6c | |||
| 88e86c0566 | |||
| d2141d5f59 | |||
| eda3481533 | |||
| 0526290c8f | |||
| ed32e44e69 | |||
| 0f5eb1fbcb | |||
| 20844d5e99 | |||
| ea67a5abf5 | |||
| c6f8b1e394 | |||
| 1f6a652799 | |||
| 40c47dd7ff | |||
| 92fd0ce129 | |||
| b3b02f75a9 |
@@ -1,6 +1,44 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.12.0 - 2026-07-30
|
||||
|
||||
+ [新增] 画布 Agent 支持三档 Codex 权限,并可在对话中审批操作。
|
||||
+ [新增] Canvas Agent 支持 `--debug` 日志并按日期保存。
|
||||
+ [新增] Agent 新增可折叠任务进度,实时显示各步骤处理状态。
|
||||
+ [调整] Agent 工具确认模式移至输入框并默认自动确认。
|
||||
+ [调整] 进入画布后默认开启空白对话,历史对话改为主动恢复。
|
||||
+ [修复] Agent 默认通过节点生成,明确指定时才使用 Codex 内置生图。
|
||||
+ [修复] Agent 本地路径改为在文件管理器定位,不再拼接 localhost。
|
||||
+ [修复] Agent 启用 Codex 思考摘要,并修复历史恢复后过程记录缺失。
|
||||
+ [修复] 修复 Agent 图片预览打开后产生额外空行的问题。
|
||||
+ [修复] Canvas Agent 停止任务仅中断 turn,不终止服务进程。
|
||||
+ [修复] Agent 修复运行提示闪退和回复需切换标签页才显示的问题。
|
||||
+ [修复] Agent 任务失败时立即结束等待,并在对话和历史中显示中文原因。
|
||||
+ [修复] Agent 修复图片附件偏移,历史恢复保留缩略图且不回显内部说明。
|
||||
+ [修复] 修复画布复制快捷键拦截 Agent 对话和节点详情文本复制。
|
||||
+ [优化] Agent「新对话」立即清空并后台同步,不再等待创建 Codex 线程。
|
||||
+ [优化] Agent「读取画布」按节点类型和连线分类展示内容概览。
|
||||
+ [优化] Agent 发送后立即清空输入框并展示消息,避免内容滞留或草稿被清空。
|
||||
+ [优化] Agent 动态工具卡片显示名称、状态和失败原因,替代「工具操作」。
|
||||
+ [优化] Agent 面板标题、功能标签和新对话操作合并为单行顶栏。
|
||||
+ [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式。
|
||||
+ [优化] Agent 思考摘要支持 Markdown 渲染,改善列表和代码阅读。
|
||||
+ [优化] Agent 思考摘要和命令改为无边框折叠行,保留实时返回内容。
|
||||
+ [优化] Agent 用户图片改为紧凑缩略图并支持单击放大。
|
||||
+ [优化] Canvas Agent 的 Codex CLI 升级至 0.145.0。
|
||||
+ [优化] Canvas Agent Debug 日志改为纯文本单行格式。
|
||||
+ [优化] 精简 Agent HTTP 诊断日志,过滤轮询、流式重复事件和原始响应。
|
||||
+ [优化] Agent 对话移除头像,工具活动紧凑展示,Token 用量支持数字动画。
|
||||
+ [优化] Canvas Agent 流式回复改为增量传输,减少长对话重复渲染。
|
||||
+ [优化] Agent 新增过程时间线,工具卡片改为中文摘要和可读详情。
|
||||
+ [优化] Agent 长时间等待时显示阶段、时长和停止提示,避免空会话读取失败。
|
||||
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||
+ [优化] Agent 优先操作当前画布,不再无故查询列表或重复导航。
|
||||
+ [优化] Canvas Agent 独立维护工作区指令,不再重复拼接提示词。
|
||||
+ [优化] Canvas Agent 目录按 Agent、画布、服务和工具拆分职责。
|
||||
|
||||
## v0.11.0 - 2026-07-28
|
||||
|
||||
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/50077?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-50077" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50077/daily?language=TypeScript" alt="basketikun%2Finfinite-canvas | Trendshift" width="250" height="55"></a>
|
||||
<a href="https://trendshift.io/repositories/50077?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-50077" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/50077" alt="basketikun%2Finfinite-canvas | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -128,6 +128,10 @@ docker compose up -d
|
||||
|
||||
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
||||
|
||||
你可以在遵守 AGPL-3.0 的前提下自由使用、复制、修改和分发本项目。如果你分发修改后的版本,或将其作为网站、SaaS 等网络服务提供给他人使用,需要向对应用户公开基于本项目形成的完整源代码,继续使用 AGPL-3.0 协议,并保留原项目的作者、版权、许可证和来源说明。
|
||||
|
||||
本项目禁止未经授权的闭源商用。如果你希望将无限画布用于商业项目,请尊重开源,遵循 AGPL-3.0 协议,继续开源基于本项目修改或开发的对应代码,回馈开源社区;如果无法公开对应代码,请联系作者购买商业授权后闭源使用。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
|
||||
|
||||
@@ -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
|
||||
@@ -126,7 +134,7 @@ default_tools_approval_mode = "approve"
|
||||
|
||||
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
||||
|
||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
|
||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把任务进度、计划、搜索、文件修改与工具操作整理为中文过程时间线。
|
||||
|
||||
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Infinite Canvas Agent
|
||||
|
||||
你正在帮助用户操作 Infinite Canvas 网站。
|
||||
|
||||
- 用户要求操作画布时,默认目标就是网页当前已经打开的画布。需要了解内容时先使用 `canvas_get_state`;读取成功后直接在该画布执行任务,不要调用 `canvas_list_projects`,也不要用 `site_navigate` 重复进入画布。
|
||||
- 只有用户明确要求查看、选择或切换其他画布,或者 `canvas_get_state` 明确提示当前没有已连接画布时,才使用 `canvas_list_projects` 和 `site_navigate`。`site_navigate` 可跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`。
|
||||
- 修改当前画布时根据任务使用已配置的 infinite-canvas MCP 工具;复杂批量改动使用 `canvas_apply_ops`。
|
||||
- 用户要求把上传附件放入画布或作为生成参考图时,必须先用 `canvas_create_attachment_nodes` 创建真实图片节点,再把节点 ID 传给生成流程,不要创建空图片占位节点。
|
||||
- 生图与视频工作台分别使用 `workbench_image_*`、`workbench_video_*` 工具;提示词和素材分别使用 `prompts_search`、`assets_*` 工具。
|
||||
- 用户要求生成图片、视频、音频或文本时,默认调用对应的 `canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_generate_text`,通过当前画布的生成节点完成任务。
|
||||
- 只有用户明确要求使用“Codex 内置生图”“ImageGen 技能”或意思明确相同的能力时,才使用 Codex 自带的 `imagegen`;不要因为用户只说“生成图片”就自行改用内置生图。内置生图完成后,其结果会由 Canvas Agent 自动展示到对话并插入当前画布,无需再创建空节点或重复生成。
|
||||
- 只有用户明确说要在生图/视频工作台生成时,才使用 `workbench_image_*`、`workbench_video_*`。生成任务提交后应说明已经在画布或工作台开始生成,不要在实际没有结果时声称“已生成”。
|
||||
- 需要生成内容时直接调用对应生成工具,不要绑定特定业务场景,不要模拟鼠标点击,不要要求用户手动复制 JSON。
|
||||
+61
-8
@@ -6,8 +6,9 @@
|
||||
"name": "@basketikun/canvas-agent",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "^0.139.0",
|
||||
"@openai/codex": "0.145.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=="],
|
||||
@@ -75,19 +80,21 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@openai/codex": ["@openai/codex@0.139.0", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0.tgz", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.139.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.139.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.139.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.139.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.139.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.139.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-wr2fRE+fzW0CjEbfFsLh1ftarVEcw0CMLWS7QyA0nyOz5qacQPVq3cq2+/U7oEbwm1TOqoi0Fm1nxniB5FkpmA=="],
|
||||
"@openai/codex": ["@openai/codex@0.145.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.145.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.145.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.145.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.145.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.145.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.145.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ=="],
|
||||
|
||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.139.0-darwin-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-arm64.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-o+0ZKWwgDFMMLO7rwinzO0PQsgK+Vme1pMN2GeAxsX29ZgGZcyPICfpJbeGSUO1mb2a36Skjx6nfdRnxMY0r7w=="],
|
||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.145.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA=="],
|
||||
|
||||
"@openai/codex-darwin-x64": ["@openai/codex@0.139.0-darwin-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-x64.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-9gkBWzu6DB2rqU4DbpxD3DE5bofGpsK46Lp0h0I+bKWc2IIcxvSi8K2utKmBLoJCbKrn4JQu7dFNGRqEfENung=="],
|
||||
"@openai/codex-darwin-x64": ["@openai/codex@0.145.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA=="],
|
||||
|
||||
"@openai/codex-linux-arm64": ["@openai/codex@0.139.0-linux-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-arm64.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-tBQE5lZciRHeWZGuURgjP9S717MvTIpQMc593+DNxY2LQxozkngOkzFSQd1+/UmQKGrCqdFLu5irIwPXpSZyEw=="],
|
||||
"@openai/codex-linux-arm64": ["@openai/codex@0.145.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ=="],
|
||||
|
||||
"@openai/codex-linux-x64": ["@openai/codex@0.139.0-linux-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-x64.tgz", { "os": "linux", "cpu": "x64" }, "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw=="],
|
||||
"@openai/codex-linux-x64": ["@openai/codex@0.145.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA=="],
|
||||
|
||||
"@openai/codex-win32-arm64": ["@openai/codex@0.139.0-win32-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-arm64.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ=="],
|
||||
"@openai/codex-win32-arm64": ["@openai/codex@0.145.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg=="],
|
||||
|
||||
"@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="],
|
||||
"@openai/codex-win32-x64": ["@openai/codex@0.145.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"name": "@basketikun/canvas-agent",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"canvas-agent": "./dist/index.js"
|
||||
"canvas-agent": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"agent-instructions.md",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "tsx --test src/canvas-session.test.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",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "^0.139.0",
|
||||
"@openai/codex": "0.145.0",
|
||||
"express": "^5.1.0",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { AGENT_PROMPT } from "../config.js";
|
||||
import { errorMessage } from "../utils/value.js";
|
||||
import type { AgentEmit } from "./types.js";
|
||||
|
||||
/** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
const fullPrompt = withAgentPrompt(prompt);
|
||||
if (!fullPrompt) return;
|
||||
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", fullPrompt], emit);
|
||||
if (child) pipeJsonLines(child, emit, "claude");
|
||||
}
|
||||
|
||||
/** 为 Claude CLI 请求拼接 Canvas Agent 指令。 */
|
||||
function withAgentPrompt(prompt: string) {
|
||||
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
||||
}
|
||||
|
||||
/** 将 Claude CLI 的 JSON Lines 输出转换为 Agent 事件。 */
|
||||
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
out += chunk.toString();
|
||||
const lines = out.split(/\r?\n/);
|
||||
out = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
emit("agent_event", { agent, ...JSON.parse(line) });
|
||||
} catch {
|
||||
emit("agent_event", { agent, type: "raw", text: line });
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("close", (code) => emit("agent_done", { agent, code }));
|
||||
}
|
||||
|
||||
/** 启动外部 Agent CLI,并将同步启动异常转换为事件。 */
|
||||
function spawnAgent(name: string, args: string[], emit: AgentEmit) {
|
||||
try {
|
||||
return spawn(name, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { VERSION } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { field, type JsonRecord } from "../utils/value.js";
|
||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
||||
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
const STREAM_UPDATE_INTERVAL_MS = 40;
|
||||
|
||||
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
||||
export class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private currentThreadId = "";
|
||||
private currentTurnId = "";
|
||||
private textByItem = new Map<string, string>();
|
||||
private lastUsage: unknown = null;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private activeTurns = new Map<string, PendingRequest>();
|
||||
private completedTurns = new Map<string, Error | null>();
|
||||
private pendingDeltas = new Map<string, PendingDelta>();
|
||||
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
||||
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
|
||||
|
||||
/** 保存 app-server 子进程和事件出口。 */
|
||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
||||
|
||||
/** 启动并初始化 Codex app-server。 */
|
||||
static async start(emit: AgentEmit, onExit: () => void) {
|
||||
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) => {
|
||||
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}`);
|
||||
onExit();
|
||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||
});
|
||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||
client.notify("initialized");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 创建新的 Codex 线程。 */
|
||||
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread;
|
||||
}
|
||||
|
||||
/** 恢复已有 Codex 线程。 */
|
||||
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread;
|
||||
}
|
||||
|
||||
/** 查询 Codex 线程列表。 */
|
||||
listThreads(params: CodexRequestParams<"thread/list">) {
|
||||
return this.request("thread/list", params);
|
||||
}
|
||||
|
||||
/** 读取指定 Codex 线程。 */
|
||||
readThread(threadId: string, includeTurns = true) {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
/** 归档指定 Codex 线程。 */
|
||||
archiveThread(threadId: string) {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
||||
planUpdates(threadId: string) {
|
||||
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
||||
}
|
||||
|
||||
/** 清理已归档线程的任务计划缓存。 */
|
||||
clearPlanUpdates(threadId: string) {
|
||||
this.plansByTurn.forEach((item, turnId) => {
|
||||
if (item.threadId === threadId) this.plansByTurn.delete(turnId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
|
||||
this.currentThreadId = threadId;
|
||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
|
||||
const turnId = turn.id;
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
this.currentTurnId = turnId;
|
||||
onTurn?.(turnId);
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
this.currentThreadId = "";
|
||||
this.currentTurnId = "";
|
||||
if (completed) throw completed;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||
}
|
||||
|
||||
/** 中断当前正在运行的 Codex turn。 */
|
||||
async interruptCurrentTurn() {
|
||||
const threadId = this.currentThreadId;
|
||||
const turnId = this.currentTurnId;
|
||||
if (!threadId || !turnId) return false;
|
||||
try {
|
||||
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
||||
await this.request("turn/interrupt", { threadId, turnId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn("Failed to interrupt Codex turn", { error, threadId, turnId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 回复网页端已经确认的 Codex 权限请求。 */
|
||||
resolveApproval(requestId: string, decision: string) {
|
||||
const request = this.approvalRequests.get(requestId);
|
||||
if (!request) return false;
|
||||
this.approvalRequests.delete(requestId);
|
||||
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
||||
const result = request.method === "item/permissions/requestApproval"
|
||||
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
||||
: { decision };
|
||||
this.write({ id: request.id, result });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
||||
private request<Method extends CodexRequestMethod>(method: Method, params: CodexRequestParams<Method>) {
|
||||
const id = this.nextId++;
|
||||
this.write({ id, method, params });
|
||||
return new Promise<CodexRequestResult<Method>>((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result as CodexRequestResult<Method>), reject }));
|
||||
}
|
||||
|
||||
/** 发送无需响应的 JSON-RPC 通知。 */
|
||||
private notify(method: string, params?: unknown) {
|
||||
this.write(params === undefined ? { method } : { method, params });
|
||||
}
|
||||
|
||||
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
|
||||
private write(value: unknown) {
|
||||
const method = String(field(value, "method") || "");
|
||||
const params = field(value, "params");
|
||||
if (method) logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
|
||||
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
/** 按行解析 app-server 标准输出。 */
|
||||
private read(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
this.handle(JSON.parse(line) as JsonRecord);
|
||||
} catch (error) {
|
||||
logger.warn("Invalid Codex app-server output", { error, line });
|
||||
this.emit("agent_log", { text: line });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 分派单条 JSON-RPC 响应、请求或通知。 */
|
||||
private handle(message: JsonRecord) {
|
||||
const id = Number(message.id);
|
||||
if (message.error && this.pending.has(id)) {
|
||||
const error = String(field(message.error, "message") || "Codex request failed");
|
||||
if (/not materialized yet.*includeTurns/i.test(error)) logger.debug("Codex thread has no messages yet", { id });
|
||||
else logger.warn("Codex request failed", { id, error });
|
||||
return this.reject(id, error);
|
||||
}
|
||||
if (this.pending.has(id)) return this.resolve(id, message.result);
|
||||
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
||||
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as JsonRecord);
|
||||
}
|
||||
|
||||
/** 转换并广播 app-server 通知。 */
|
||||
private handleNotification(method: string, params: JsonRecord) {
|
||||
if (method === "serverRequest/resolved") {
|
||||
const requestId = String(field(params, "requestId") || "");
|
||||
if (requestId) this.approvalRequests.delete(requestId);
|
||||
this.emit("codex_approval_resolved", { requestId, ...params });
|
||||
return;
|
||||
}
|
||||
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId };
|
||||
if (method === "item/agentMessage/delta") {
|
||||
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
|
||||
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
|
||||
return this.emitDelta("agent_message", value);
|
||||
}
|
||||
if (method === "item/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
|
||||
if (method === "item/reasoning/summaryTextDelta") return this.emitDelta("reasoning", params as unknown as CodexNotificationParams<"item/reasoning/summaryTextDelta">);
|
||||
if (method === "item/commandExecution/outputDelta") return this.emitDelta("command_execution", params as unknown as CodexNotificationParams<"item/commandExecution/outputDelta">);
|
||||
if (method === "turn/plan/updated") {
|
||||
const value = params as unknown as CodexNotificationParams<"turn/plan/updated">;
|
||||
const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" };
|
||||
if (update.threadId && update.turnId) this.plansByTurn.set(update.turnId, update);
|
||||
params = update as unknown as JsonRecord;
|
||||
}
|
||||
if (method === "thread/tokenUsage/updated") {
|
||||
this.lastUsage = normalizeUsage(params as unknown as CodexNotificationParams<"thread/tokenUsage/updated">);
|
||||
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
|
||||
return;
|
||||
}
|
||||
const event = normalizeCodexNotification(method, params);
|
||||
if (!event) return;
|
||||
if (event.type === "item.completed") {
|
||||
const item = field(event, "item") as JsonRecord | undefined;
|
||||
const id = String(field(item, "id") || "");
|
||||
this.flushDelta(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") {
|
||||
const turn = field(params, "turn");
|
||||
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
||||
const plan = this.plansByTurn.get(turnId);
|
||||
if (plan) this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
||||
}
|
||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
||||
this.emit("agent_event", { agent: "codex", ...event });
|
||||
if (event.type === "turn.completed") {
|
||||
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
|
||||
const turnId = turn.id;
|
||||
const pending = this.activeTurns.get(turnId);
|
||||
const error = turn.error;
|
||||
if (pending) {
|
||||
this.activeTurns.delete(turnId);
|
||||
error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
|
||||
}
|
||||
if (turnId === this.currentTurnId) {
|
||||
this.currentThreadId = "";
|
||||
this.currentTurnId = "";
|
||||
}
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
||||
}
|
||||
}
|
||||
|
||||
/** 合并并广播 Agent 文本或执行输出增量。 */
|
||||
private emitDelta(itemType: string, params: ItemDeltaParams) {
|
||||
const id = params.itemId;
|
||||
const pending = this.pendingDeltas.get(id);
|
||||
if (pending) {
|
||||
pending.delta += params.delta;
|
||||
pending.itemType = itemType;
|
||||
pending.params = params;
|
||||
return;
|
||||
}
|
||||
this.pendingDeltas.set(id, {
|
||||
delta: params.delta,
|
||||
itemType,
|
||||
params,
|
||||
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
|
||||
});
|
||||
}
|
||||
|
||||
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
||||
private flushDelta(id: string) {
|
||||
const pending = this.pendingDeltas.get(id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingDeltas.delete(id);
|
||||
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
|
||||
}
|
||||
|
||||
/** 自动回复 app-server 发起的授权或交互请求。 */
|
||||
private answerServerRequest(message: JsonRecord) {
|
||||
const method = String(message.method);
|
||||
const params = (field(message, "params") as JsonRecord) || {};
|
||||
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
|
||||
const requestId = String(message.id);
|
||||
this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
|
||||
this.emit("codex_approval", { requestId, method, ...params });
|
||||
return;
|
||||
}
|
||||
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
||||
this.write({ id: message.id, result });
|
||||
this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
|
||||
}
|
||||
|
||||
/** 完成指定 JSON-RPC 请求。 */
|
||||
private resolve(id: number, result: unknown) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.resolve(result));
|
||||
}
|
||||
|
||||
/** 拒绝指定 JSON-RPC 请求。 */
|
||||
private reject(id: number, message: string) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
||||
}
|
||||
|
||||
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
||||
private failAll(message: string) {
|
||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
||||
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
||||
this.pending.clear();
|
||||
this.activeTurns.clear();
|
||||
this.pendingDeltas.clear();
|
||||
this.textByItem.clear();
|
||||
this.approvalRequests.clear();
|
||||
this.currentThreadId = "";
|
||||
this.currentTurnId = "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
||||
function canvasAgentMcpCommand() {
|
||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||
const entry = path.resolve(current || fileURLToPath(new URL("../index.js", import.meta.url)));
|
||||
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
||||
}
|
||||
|
||||
/** 生成 Codex app-server 使用的 MCP 配置。 */
|
||||
function codexConfig(permissionMode: AgentPermissionMode) {
|
||||
return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
||||
}
|
||||
|
||||
function threadSettings(permissionMode: AgentPermissionMode) {
|
||||
return { approvalPolicy: permissionMode === "full" ? "never" as const : "on-request" as const, sandbox: permissionMode === "full" ? "danger-full-access" as const : "workspace-write" as const, config: codexConfig(permissionMode) };
|
||||
}
|
||||
|
||||
function turnSettings(permissionMode: AgentPermissionMode) {
|
||||
return {
|
||||
approvalPolicy: permissionMode === "full" ? "never" as const : "on-request" as const,
|
||||
sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" as const } : { type: "workspaceWrite" as const, networkAccess: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** 将文本和本地图片转换为 Codex turn 输入。 */
|
||||
function codexInput(prompt: string, images: string[]): CodexTurnInput[] {
|
||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map<CodexTurnInput>((file) => ({ type: "localImage", path: file }))];
|
||||
}
|
||||
|
||||
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
|
||||
function normalizeCodexNotification(method: string, params: JsonRecord): AgentEvent | null {
|
||||
const scope = codexEventScope(params);
|
||||
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||
if (method === "turn/completed") return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
|
||||
if (method === "turn/plan/updated") return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "error") return { type: "error", message: field(field(params, "error"), "message"), ...scope };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 提取 Codex 事件所属的线程和 turn。 */
|
||||
function codexEventScope(params: JsonRecord) {
|
||||
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
||||
}
|
||||
|
||||
/** 统一 app-server item 的类型和参数格式。 */
|
||||
function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as JsonRecord) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||
if (value.type === "commandExecution") value.type = "command_execution";
|
||||
if (value.type === "fileChange") value.type = "file_change";
|
||||
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
|
||||
if (value.type === "collabToolCall") value.type = "collab_tool_call";
|
||||
if (value.type === "webSearch") value.type = "web_search";
|
||||
if (value.type === "imageView") value.type = "image_view";
|
||||
if (value.type === "imageGeneration") value.type = "image_generation";
|
||||
if (value.type === "contextCompaction") value.type = "context_compaction";
|
||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 将 Codex token usage 转换为前端字段。 */
|
||||
function normalizeUsage(params: CodexNotificationParams<"thread/tokenUsage/updated">) {
|
||||
const last = params.tokenUsage.last;
|
||||
return {
|
||||
input_tokens: last.inputTokens,
|
||||
cached_input_tokens: last.cachedInputTokens,
|
||||
output_tokens: last.outputTokens,
|
||||
reasoning_output_tokens: last.reasoningOutputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** 尝试将字符串解析为 JSON,失败时保留原值。 */
|
||||
function parseMaybeJson(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** 定位当前依赖中 Codex CLI 的执行文件。 */
|
||||
function codexBin() {
|
||||
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { field } from "../utils/value.js";
|
||||
import type { CodexPlanUpdate } from "./codex-protocol.js";
|
||||
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
/** 将 Codex 线程转换为列表展示所需的摘要。 */
|
||||
export function summarizeCodexThread(thread: unknown) {
|
||||
return {
|
||||
id: String(field(thread, "id") || ""),
|
||||
sessionId: String(field(thread, "sessionId") || ""),
|
||||
preview: displayUserText(String(field(thread, "preview") || "")),
|
||||
name: stringOrNull(field(thread, "name")),
|
||||
cwd: String(field(thread, "cwd") || ""),
|
||||
status: String(field(thread, "status") || ""),
|
||||
source: field(thread, "source"),
|
||||
threadSource: field(thread, "threadSource"),
|
||||
createdAt: Number(field(thread, "createdAt") || 0),
|
||||
updatedAt: Number(field(thread, "updatedAt") || 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 Codex turn items 转换为网页聊天历史。 */
|
||||
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = []): AgentHistoryMessage[] {
|
||||
const turns = arrayValue(field(thread, "turns"));
|
||||
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
|
||||
const messages: AgentHistoryMessage[] = [];
|
||||
turns.forEach((turn, turnIndex) => {
|
||||
const turnId = String(field(turn, "id") || turnIndex);
|
||||
const turnError = String(field(field(turn, "error"), "message") || "").trim();
|
||||
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") });
|
||||
let planAdded = false;
|
||||
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
||||
if (type === "userMessage") {
|
||||
const text = displayUserText(userInputText(field(item, "content")));
|
||||
if (text) messages.push({ id, role: "user", text });
|
||||
if (planMessage && !planAdded) {
|
||||
messages.push(planMessage);
|
||||
planAdded = true;
|
||||
}
|
||||
}
|
||||
if (type === "agentMessage") {
|
||||
const text = String(field(item, "text") || "").trim();
|
||||
if (text) messages.push({ id, role: "assistant", title: "Codex", text });
|
||||
}
|
||||
if (type === "mcpToolCall") {
|
||||
const tool = String(field(item, "tool") || "工具调用");
|
||||
const error = String(field(field(item, "error"), "message") || "");
|
||||
const input = toolArguments(field(item, "arguments"));
|
||||
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, item, input), detail: toolHistoryDetail(tool, item, input, error) });
|
||||
}
|
||||
if (type === "commandExecution") {
|
||||
const command = String(field(item, "command") || "").trim();
|
||||
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
|
||||
}
|
||||
if (type === "fileChange") {
|
||||
const changes = arrayValue(field(item, "changes"));
|
||||
messages.push({ id, role: "tool", title: "修改文件", text: fileChangeSummary(changes), detail: { kind: "file", status: field(item, "status"), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })) } });
|
||||
}
|
||||
if (type === "reasoning") {
|
||||
const text = readableText(field(item, "summary"));
|
||||
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
|
||||
}
|
||||
if (type === "plan") {
|
||||
const text = String(field(item, "text") || "").trim();
|
||||
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
|
||||
}
|
||||
if (type === "webSearch") messages.push({ id, role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status: "completed", rows: webSearchRows(item) } });
|
||||
if (type === "imageView") messages.push({ id, role: "tool", title: "查看图片", text: String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: "completed" } });
|
||||
if (type === "imageGeneration") messages.push({ id, role: "tool", title: "内置生图", text: String(field(item, "savedPath") || "图片生成完成"), detail: { kind: "image", status: field(item, "status"), savedPath: field(item, "savedPath") } });
|
||||
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
|
||||
if (type === "dynamicToolCall") {
|
||||
const tool = String(field(item, "tool") || "");
|
||||
const title = toolName(tool);
|
||||
const error = String(field(field(item, "error"), "message") || "");
|
||||
const status = String(field(item, "status") || "");
|
||||
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error";
|
||||
messages.push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")) || `${title}${failed ? "失败" : "完成"}`, detail: { kind: "tool", status: failed ? "failed" : status } });
|
||||
}
|
||||
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
|
||||
});
|
||||
if (planMessage && !planAdded) messages.push(planMessage);
|
||||
if (turnError) {
|
||||
const error = userFacingCodexError(turnError);
|
||||
messages.push({ id: `error-${turnId}`, role: "error", title: error.title, text: error.text });
|
||||
}
|
||||
});
|
||||
return messages.filter((item) => item.text).slice(-120);
|
||||
}
|
||||
|
||||
/** 将结构化任务计划转换为聊天进度卡片。 */
|
||||
function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | null {
|
||||
const tasks = arrayValue(update.plan).flatMap((item) => {
|
||||
const step = String(field(item, "step") || "").trim();
|
||||
return step ? [{ step, status: String(field(item, "status") || "pending") }] : [];
|
||||
});
|
||||
if (!tasks.length) return null;
|
||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||
return {
|
||||
id: `plan-${update.turnId}`,
|
||||
role: "tool",
|
||||
title: "任务进度",
|
||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||
detail: { kind: "todo", status: planStatus(tasks, update.turnStatus), tasks, explanation: update.explanation || "" },
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据步骤和 turn 状态生成任务卡片状态。 */
|
||||
function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) {
|
||||
if (turnStatus === "failed") return "failed";
|
||||
if (turnStatus === "interrupted") return "interrupted";
|
||||
if (tasks.every((item) => item.status === "completed")) return "completed";
|
||||
return turnStatus === "completed" ? "finished" : "inProgress";
|
||||
}
|
||||
|
||||
/** 将常见 Codex 错误转换为普通用户可理解的提示。 */
|
||||
function userFacingCodexError(message: string) {
|
||||
if (/selected model is at capacity/i.test(message)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" };
|
||||
return { title: "任务失败", text: message || "Codex 未能完成本次任务,请稍后重试。" };
|
||||
}
|
||||
|
||||
/** 提取用户输入条目中的文本与附件占位信息。 */
|
||||
function userInputText(content: unknown) {
|
||||
return arrayValue(content)
|
||||
.map((item) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
if (type === "text") return String(field(item, "text") || "");
|
||||
if (type === "image" || type === "localImage") return "图片附件";
|
||||
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** 移除用户消息中由旧流程拼接的 Agent 前置提示词。 */
|
||||
function displayUserText(text: string) {
|
||||
const value = text.trim();
|
||||
const marker = "用户请求:";
|
||||
const index = value.lastIndexOf(marker);
|
||||
const prompt = index >= 0 ? value.slice(index + marker.length) : value;
|
||||
return prompt.split("\n\n本轮可用图片附件(顺序与图片输入一致):", 1)[0].trim();
|
||||
}
|
||||
|
||||
/** 将未知值转换为数组。 */
|
||||
function arrayValue(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** 将非空字符串保留为字符串,否则返回 null。 */
|
||||
function stringOrNull(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
/** 生成命令执行的用户可读详情。 */
|
||||
function commandDetail(item: unknown) {
|
||||
const rows = [
|
||||
textRow("工作目录", field(item, "cwd")),
|
||||
textRow("退出状态", field(item, "exitCode")),
|
||||
durationRow(field(item, "durationMs")),
|
||||
].filter(Boolean);
|
||||
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
|
||||
}
|
||||
|
||||
/** 生成 MCP 工具的用户可读详情。 */
|
||||
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
|
||||
return { kind: "tool", status: error ? "failed" : field(item, "status"), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
|
||||
}
|
||||
|
||||
/** 生成 MCP 工具在对话中的结果摘要。 */
|
||||
function toolHistorySummary(tool: string, item: unknown, input: unknown) {
|
||||
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
|
||||
if (tool === "canvas_list_projects") return "已读取画布列表";
|
||||
if (tool === "canvas_get_state") {
|
||||
const result = parseToolResult(field(item, "result"));
|
||||
const nodes = arrayValue(field(result, "nodes"));
|
||||
const connections = arrayValue(field(result, "connections"));
|
||||
return nodes.length || connections.length || result ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
|
||||
}
|
||||
if (tool === "canvas_get_selection") return "已读取当前选中内容";
|
||||
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}”`;
|
||||
if (tool === "assets_list") return "已读取我的素材";
|
||||
if (tool === "generation_get_status") return "已检查生成任务状态";
|
||||
return `${toolName(tool)}已完成`;
|
||||
}
|
||||
|
||||
/** 按节点类型生成人类可读的画布内容概览。 */
|
||||
function canvasContentSummary(nodes: unknown[], connections: number) {
|
||||
const counts = nodes.reduce<Record<string, number>>((result, node) => {
|
||||
const type = String(field(node, "type") || "other");
|
||||
result[type] = (result[type] || 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
const known = new Set(["text", "image", "config", "video", "audio", "group"]);
|
||||
const other = Object.entries(counts).reduce((total, [type, count]) => total + (known.has(type) ? 0 : count), 0);
|
||||
const parts = [
|
||||
counts.text ? `${counts.text} 个文本` : "",
|
||||
counts.image ? `${counts.image} 张图片` : "",
|
||||
counts.config ? `${counts.config} 个配置` : "",
|
||||
counts.video ? `${counts.video} 个视频` : "",
|
||||
counts.audio ? `${counts.audio} 个音频` : "",
|
||||
counts.group ? `${counts.group} 个分组` : "",
|
||||
other ? `${other} 个其他节点` : "",
|
||||
connections ? `${connections} 条连线` : "",
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join("、") : "当前画布为空";
|
||||
}
|
||||
|
||||
/** 从 MCP 历史结果中还原工具返回的数据。 */
|
||||
function parseToolResult(result: unknown) {
|
||||
const content = field(result, "content");
|
||||
const text = arrayValue(content)
|
||||
.map((item) => field(item, "text"))
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join("\n");
|
||||
try {
|
||||
return text ? JSON.parse(text) : result;
|
||||
} catch {
|
||||
return text || result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 提取工具参数中适合普通用户查看的信息。 */
|
||||
function toolInputRows(tool: string, input: unknown) {
|
||||
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
|
||||
if (tool === "prompts_search") return [textRow("搜索内容", field(input, "query"))].filter(Boolean);
|
||||
if (tool === "canvas_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
|
||||
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 生成人类可读的文件变更摘要。 */
|
||||
function fileChangeSummary(changes: unknown[]) {
|
||||
if (!changes.length) return "已完成文件修改";
|
||||
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
|
||||
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
|
||||
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
|
||||
}
|
||||
|
||||
/** 生成网页搜索摘要。 */
|
||||
function webSearchSummary(item: unknown) {
|
||||
const action = field(item, "action");
|
||||
const type = String(field(action, "type") || "");
|
||||
if (type === "openPage") return `打开网页:${String(field(action, "url") || "")}`;
|
||||
if (type === "findInPage") return `在网页中查找“${String(field(action, "pattern") || "内容")}”`;
|
||||
return `搜索:${String(field(item, "query") || field(action, "query") || "相关资料")}`;
|
||||
}
|
||||
|
||||
/** 生成网页搜索详情行。 */
|
||||
function webSearchRows(item: unknown) {
|
||||
const action = field(item, "action");
|
||||
return [textRow("关键词", field(item, "query") || field(action, "query")), textRow("网页", field(action, "url"))].filter(Boolean);
|
||||
}
|
||||
|
||||
/** 从 reasoning 结构中提取可读文本。 */
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
return readableText(field(value, "text"));
|
||||
}
|
||||
|
||||
/** 将历史工具参数解析为对象。 */
|
||||
function toolArguments(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建非空详情行。 */
|
||||
function textRow(label: string, value: unknown) {
|
||||
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||
}
|
||||
|
||||
/** 创建命令耗时详情行。 */
|
||||
function durationRow(value: unknown) {
|
||||
const duration = Number(value || 0);
|
||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||
}
|
||||
|
||||
/** 将文件变更类型转换为中文。 */
|
||||
function changeKind(value: unknown) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
/** 将站点路由转换为中文页面名称。 */
|
||||
function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 将 MCP 工具名称转换为聊天记录中的中文标题。 */
|
||||
function toolName(name: string) {
|
||||
if (name === "imagegen" || name.endsWith("__imagegen")) return "生成图片";
|
||||
if (name === "view_image" || name.endsWith("__view_image")) return "查看图片";
|
||||
if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return "执行命令";
|
||||
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
|
||||
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
|
||||
if (name === "site_navigate") return "打开页面";
|
||||
if (name === "canvas_list_projects") return "查看画布列表";
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_node") return "创建节点";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_config_node") return "创建生成配置";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
if (name === "workbench_image_get_config") return "读取生图设置";
|
||||
if (name === "workbench_image_generate") return "在生图工作台生成";
|
||||
if (name === "workbench_video_get_config") return "读取视频设置";
|
||||
if (name === "workbench_video_generate") return "在视频工作台生成";
|
||||
if (name === "prompts_search") return "搜索提示词";
|
||||
if (name === "assets_list") return "查看我的素材";
|
||||
if (name === "assets_add") return "添加到我的素材";
|
||||
if (name === "generation_get_status") return "查看生成状态";
|
||||
return name ? `调用工具:${name}` : "工具操作";
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { JsonRecord } from "../utils/value.js";
|
||||
|
||||
export type CodexThread = JsonRecord & { id: string; cwd: string; turns?: CodexTurn[] };
|
||||
export type CodexTurn = JsonRecord & { id: string; error?: CodexTurnError | null; durationMs?: number | null };
|
||||
export type CodexTurnError = JsonRecord & { message: string };
|
||||
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
||||
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
||||
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
|
||||
|
||||
export type CodexTurnInput =
|
||||
| { type: "text"; text: string; text_elements: [] }
|
||||
| { type: "localImage"; path: string };
|
||||
|
||||
type ThreadOptions = {
|
||||
approvalPolicy: "never" | "on-request";
|
||||
sandbox: "workspace-write" | "danger-full-access";
|
||||
config: JsonRecord;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
type CodexRequestSpec = {
|
||||
initialize: {
|
||||
params: {
|
||||
clientInfo: { name: string; title: string; version: string };
|
||||
capabilities: { experimentalApi: boolean; requestAttestation: boolean };
|
||||
};
|
||||
result: JsonRecord;
|
||||
};
|
||||
"thread/start": {
|
||||
params: ThreadOptions & { threadSource: "user" };
|
||||
result: { thread: CodexThread };
|
||||
};
|
||||
"thread/resume": {
|
||||
params: ThreadOptions & { threadId: string };
|
||||
result: { thread: CodexThread };
|
||||
};
|
||||
"thread/list": {
|
||||
params: {
|
||||
limit: number;
|
||||
sortKey: "updated_at";
|
||||
sortDirection: "desc";
|
||||
sourceKinds: Array<"cli" | "vscode" | "appServer" | "exec">;
|
||||
cwd: string;
|
||||
searchTerm?: string;
|
||||
};
|
||||
result: { data: CodexThread[]; nextCursor: string | null; backwardsCursor: string | null };
|
||||
};
|
||||
"thread/read": {
|
||||
params: { threadId: string; includeTurns: boolean };
|
||||
result: { thread: CodexThread };
|
||||
};
|
||||
"thread/archive": {
|
||||
params: { threadId: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
"turn/start": {
|
||||
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
|
||||
result: { turn: CodexTurn };
|
||||
};
|
||||
"turn/interrupt": {
|
||||
params: { threadId: string; turnId: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodexRequestMethod = keyof CodexRequestSpec;
|
||||
export type CodexRequestParams<Method extends CodexRequestMethod> = CodexRequestSpec[Method]["params"];
|
||||
export type CodexRequestResult<Method extends CodexRequestMethod> = CodexRequestSpec[Method]["result"];
|
||||
|
||||
type TokenUsageBreakdown = {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningOutputTokens: number;
|
||||
};
|
||||
|
||||
type CodexNotificationSpec = {
|
||||
"thread/started": { thread: CodexThread };
|
||||
"turn/started": { threadId?: string; turn: CodexTurn };
|
||||
"turn/completed": { threadId?: string; turn: CodexTurn };
|
||||
"turn/plan/updated": { threadId?: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[] };
|
||||
"item/started": { threadId: string; turnId: string; item: CodexItem };
|
||||
"item/completed": { threadId: string; turnId: string; item: CodexItem };
|
||||
"item/agentMessage/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"item/plan/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
|
||||
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
||||
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||
};
|
||||
|
||||
export type CodexNotificationMethod = keyof CodexNotificationSpec;
|
||||
export type CodexNotificationParams<Method extends CodexNotificationMethod> = CodexNotificationSpec[Method];
|
||||
@@ -0,0 +1,216 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { errorMessage, field } from "../utils/value.js";
|
||||
import { CodexAppClient } from "./codex-client.js";
|
||||
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const unmaterializedThreadIds = new Set<string>();
|
||||
|
||||
export { summarizeCodexThread } from "./codex-history.js";
|
||||
|
||||
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
/** 中断当前线程正在执行的 Codex turn。 */
|
||||
export async function interruptCodexTurn(threadId?: string) {
|
||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||
return await codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
/** 回复当前 app-server 的待处理权限请求。 */
|
||||
export async function resolveCodexApproval(requestId: string, decision: string) {
|
||||
return Boolean(codexApp?.resolveApproval(requestId, decision));
|
||||
}
|
||||
|
||||
/** 创建新的 Codex 线程并记录当前线程 ID。 */
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd, permissionMode);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await app.resumeThread(threadId, cwd, permissionMode);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true);
|
||||
return { thread, messages: threadMessages(historyThread, app.planUpdates(threadId)) };
|
||||
}
|
||||
|
||||
/** 查询当前工作空间中的 Codex 线程。 */
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||
cwd: options.cwd,
|
||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||
});
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||
}
|
||||
|
||||
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
let thread: unknown;
|
||||
try {
|
||||
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
|
||||
} catch (error) {
|
||||
if (!/not materialized yet.*includeTurns/i.test(errorMessage(error))) throw error;
|
||||
unmaterializedThreadIds.add(threadId);
|
||||
thread = await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread, app.planUpdates(threadId)) };
|
||||
}
|
||||
|
||||
/** 确认指定 Codex 线程属于当前工作空间。 */
|
||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
|
||||
/** 归档指定 Codex 线程。 */
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await app.archiveThread(threadId);
|
||||
app.clearPlanUpdates(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
}
|
||||
|
||||
/** 判断线程异常是否允许自动新建线程后重试。 */
|
||||
export function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
options.onStart?.();
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Codex turn failed", error);
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
options.onFinish?.();
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复请求线程或创建新的 Codex 线程。 */
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||||
if (options.threadId) {
|
||||
if (options.threadId === codexThreadId) return codexThreadId;
|
||||
try {
|
||||
const result = await app.readThread(options.threadId, false);
|
||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||
const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request");
|
||||
assertThreadWorkspace(thread, options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||
return codexThreadId;
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
if (!codexThreadId) {
|
||||
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||
}
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
/** 从 app-server 读取线程并校验工作空间。 */
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/** 获取已启动的 Codex app-server 客户端。 */
|
||||
async function getCodexApp(emit: AgentEmit) {
|
||||
if (codexApp) return codexApp;
|
||||
codexAppStart ||= CodexAppClient.start(emit, () => {
|
||||
codexApp = null;
|
||||
codexThreadId = "";
|
||||
});
|
||||
try {
|
||||
codexApp = await codexAppStart;
|
||||
return codexApp;
|
||||
} finally {
|
||||
codexAppStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验线程是否属于指定工作空间。 */
|
||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
}
|
||||
|
||||
/** 判断线程工作目录是否与当前工作空间一致。 */
|
||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||
const threadCwd = String(field(thread, "cwd") || "");
|
||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||
}
|
||||
|
||||
/** 将图片附件写入临时文件供 Codex 读取。 */
|
||||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||||
}
|
||||
|
||||
/** 将单个 Data URL 图片附件写入临时文件。 */
|
||||
async function writeAttachmentFile(item: AgentAttachment) {
|
||||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 根据图片 MIME 类型返回临时文件扩展名。 */
|
||||
function imageExt(type = "") {
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("webp")) return "webp";
|
||||
return "jpg";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Agent 向网页广播事件的函数类型。 */
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
|
||||
/** 用户随当前 Agent 消息上传的附件。 */
|
||||
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
|
||||
|
||||
/** Codex 文件、命令和网络权限模式。 */
|
||||
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||
@@ -1,549 +0,0 @@
|
||||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AGENT_PROMPT, VERSION } from "./config.js";
|
||||
import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function withAgentPrompt(prompt: string) {
|
||||
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
||||
}
|
||||
|
||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
export function interruptCodexTurn(threadId?: string) {
|
||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||
return codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
options.onStart?.();
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
options.onFinish?.();
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await app.resumeThread(threadId, cwd);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
return { thread, messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||
cwd: options.cwd,
|
||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||
});
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||
}
|
||||
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await app.archiveThread(threadId);
|
||||
}
|
||||
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
if (!prompt.trim()) return;
|
||||
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", prompt], ["ignore", "pipe", "pipe"], emit);
|
||||
if (!child) return;
|
||||
pipeJsonLines(child, emit, "claude");
|
||||
}
|
||||
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||||
if (options.threadId) {
|
||||
if (options.threadId === codexThreadId) return codexThreadId;
|
||||
try {
|
||||
const result = await app.readThread(options.threadId, false);
|
||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
||||
assertThreadWorkspace(thread, options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||
return codexThreadId;
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
if (!codexThreadId) {
|
||||
const thread = await app.startThread(options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
}
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
export function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private textByItem = new Map<string, string>();
|
||||
private deltaCount = 0;
|
||||
private lastUsage: unknown = null;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private activeTurns = new Map<string, PendingRequest>();
|
||||
private completedTurns = new Map<string, Error | null>();
|
||||
|
||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
||||
|
||||
static async start(emit: AgentEmit) {
|
||||
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.on("exit", (code) => {
|
||||
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
||||
codexApp = null;
|
||||
codexThreadId = "";
|
||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||
});
|
||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||
client.notify("initialized");
|
||||
return client;
|
||||
}
|
||||
|
||||
async startThread(cwd?: string) {
|
||||
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||
const thread = field(result, "thread") as Json | undefined;
|
||||
const id = String(field(thread, "id") || "");
|
||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread || {};
|
||||
}
|
||||
|
||||
async resumeThread(threadId: string, cwd?: string) {
|
||||
const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
|
||||
const thread = field(result, "thread") as Json | undefined;
|
||||
const id = String(field(thread, "id") || "");
|
||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread || {};
|
||||
}
|
||||
|
||||
listThreads(params: Json) {
|
||||
return this.request("thread/list", params);
|
||||
}
|
||||
|
||||
readThread(threadId: string, includeTurns = true) {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
archiveThread(threadId: string) {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
|
||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
onTurn?.(turnId);
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
if (completed) throw completed;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||
}
|
||||
|
||||
interruptCurrentTurn() {
|
||||
if (this.activeTurns.size === 0) return false;
|
||||
try {
|
||||
this.child.kill("SIGINT");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown) {
|
||||
const id = this.nextId++;
|
||||
this.write({ id, method, params });
|
||||
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
||||
}
|
||||
|
||||
private notify(method: string, params?: unknown) {
|
||||
this.write(params === undefined ? { method } : { method, params });
|
||||
}
|
||||
|
||||
private write(value: unknown) {
|
||||
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
private read(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
this.handle(JSON.parse(line) as Json);
|
||||
} catch {
|
||||
this.emit("agent_log", { text: line });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handle(message: Json) {
|
||||
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);
|
||||
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
||||
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as Json);
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: Json) {
|
||||
if (method === "item/agentMessage/delta") return this.emitDelta(params);
|
||||
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
|
||||
const event = normalizeCodexNotification(method, params);
|
||||
if (!event) return;
|
||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
||||
this.emit("agent_event", { agent: "codex", ...event });
|
||||
if (event.type === "turn.completed") {
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
const pending = this.activeTurns.get(turnId);
|
||||
const error = field(field(params, "turn"), "error");
|
||||
if (pending) {
|
||||
this.activeTurns.delete(turnId);
|
||||
error ? pending.reject(new Error(String(field(error, "message") || "Codex turn failed"))) : pending.resolve(event);
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
||||
}
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount, ...codexEventScope(params) });
|
||||
this.deltaCount = 0;
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
||||
}
|
||||
}
|
||||
|
||||
private emitDelta(params: Json) {
|
||||
const id = String(field(params, "itemId") || "");
|
||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
this.textByItem.set(id, text);
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
|
||||
}
|
||||
|
||||
private answerServerRequest(message: Json) {
|
||||
const method = String(message.method);
|
||||
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
||||
this.write({ id: message.id, result });
|
||||
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
|
||||
}
|
||||
|
||||
private resolve(id: number, result: unknown) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.resolve(result));
|
||||
}
|
||||
|
||||
private reject(id: number, message: string) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
||||
}
|
||||
|
||||
failAll(message: string) {
|
||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
||||
this.pending.clear();
|
||||
this.activeTurns.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function canvasAgentMcpCommand() {
|
||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||
const entry = path.resolve(current || fileURLToPath(new URL("./index.js", import.meta.url)));
|
||||
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
||||
}
|
||||
|
||||
function codexConfig() {
|
||||
return { mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
||||
}
|
||||
|
||||
function codexInput(prompt: string, images: string[]) {
|
||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
const scope = codexEventScope(params);
|
||||
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
|
||||
return null;
|
||||
}
|
||||
|
||||
function codexEventScope(params: Json) {
|
||||
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
||||
}
|
||||
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
async function getCodexApp(emit: AgentEmit) {
|
||||
if (codexApp) return codexApp;
|
||||
codexAppStart ||= CodexAppClient.start(emit);
|
||||
try {
|
||||
codexApp = await codexAppStart;
|
||||
return codexApp;
|
||||
} finally {
|
||||
codexAppStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
}
|
||||
|
||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||
const threadCwd = String(field(thread, "cwd") || "");
|
||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||
}
|
||||
|
||||
function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUsage(params: Json) {
|
||||
const total = field(field(params, "tokenUsage"), "total") as Json | undefined;
|
||||
return {
|
||||
input_tokens: field(total, "inputTokens"),
|
||||
cached_input_tokens: field(total, "cachedInputTokens"),
|
||||
output_tokens: field(total, "outputTokens"),
|
||||
reasoning_output_tokens: field(total, "reasoningOutputTokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMaybeJson(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Json)[key] : undefined;
|
||||
}
|
||||
|
||||
export function summarizeCodexThread(thread: unknown) {
|
||||
return {
|
||||
id: String(field(thread, "id") || ""),
|
||||
sessionId: String(field(thread, "sessionId") || ""),
|
||||
preview: displayUserText(String(field(thread, "preview") || "")),
|
||||
name: stringOrNull(field(thread, "name")),
|
||||
cwd: String(field(thread, "cwd") || ""),
|
||||
status: String(field(thread, "status") || ""),
|
||||
source: field(thread, "source"),
|
||||
threadSource: field(thread, "threadSource"),
|
||||
createdAt: Number(field(thread, "createdAt") || 0),
|
||||
updatedAt: Number(field(thread, "updatedAt") || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function threadMessages(thread: unknown): AgentHistoryMessage[] {
|
||||
const turns = arrayValue(field(thread, "turns"));
|
||||
const messages: AgentHistoryMessage[] = [];
|
||||
turns.forEach((turn, turnIndex) => {
|
||||
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
||||
if (type === "userMessage") {
|
||||
const text = displayUserText(userInputText(field(item, "content")));
|
||||
if (text) messages.push({ id, role: "user", text });
|
||||
}
|
||||
if (type === "agentMessage") {
|
||||
const text = String(field(item, "text") || "").trim();
|
||||
if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id });
|
||||
}
|
||||
if (type === "mcpToolCall") {
|
||||
const tool = String(field(item, "tool") || "工具调用");
|
||||
const error = field(field(item, "error"), "message");
|
||||
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
|
||||
}
|
||||
if (type === "commandExecution") {
|
||||
const command = String(field(item, "command") || "").trim();
|
||||
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
|
||||
}
|
||||
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
|
||||
});
|
||||
});
|
||||
return messages.filter((item) => item.text).slice(-120);
|
||||
}
|
||||
|
||||
function userInputText(content: unknown) {
|
||||
return arrayValue(content)
|
||||
.map((item) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
if (type === "text") return String(field(item, "text") || "");
|
||||
if (type === "image" || type === "localImage") return "图片附件";
|
||||
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function displayUserText(text: string) {
|
||||
const value = text.trim();
|
||||
const marker = "用户请求:";
|
||||
const index = value.lastIndexOf(marker);
|
||||
return (index >= 0 ? value.slice(index + marker.length) : value).trim();
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function toolName(name: string) {
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
}
|
||||
|
||||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||||
}
|
||||
|
||||
async function writeAttachmentFile(item: AgentAttachment) {
|
||||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
function imageExt(type = "") {
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("webp")) return "webp";
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function codexBin() {
|
||||
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
||||
}
|
||||
|
||||
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
out += chunk.toString();
|
||||
const lines = out.split(/\r?\n/);
|
||||
out = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
emit("agent_event", { agent, ...JSON.parse(line) });
|
||||
} catch {
|
||||
emit("agent_event", { agent, type: "raw", text: line });
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("close", (code) => emit("agent_done", { agent, code }));
|
||||
}
|
||||
|
||||
function spawnAgent(name: string, args: string[], stdio: StdioOptions, emit: AgentEmit) {
|
||||
try {
|
||||
return spawn(name, args, { stdio, shell: process.platform === "win32", windowsHide: true });
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
import { type ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private turnAttachments = new Map<string, TurnAttachment>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
}
|
||||
|
||||
get codexBusy() {
|
||||
return this.codexState.busy;
|
||||
}
|
||||
|
||||
setCodexState(patch: Partial<CodexState>) {
|
||||
this.codexState = { ...this.codexState, ...patch };
|
||||
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";
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
if (!statusOnly) {
|
||||
this.clients.set(clientId, res);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||
this.clients.delete(clientId);
|
||||
this.clientFocusOrder.delete(clientId);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
activateClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
|
||||
bindClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.boundClientId = clientId;
|
||||
}
|
||||
|
||||
releaseClient(clientId: string) {
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
}
|
||||
|
||||
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||
this.turnAttachments.clear();
|
||||
return attachments.flatMap((item, index) => {
|
||||
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||
const attachment: TurnAttachment = {
|
||||
clientId,
|
||||
id,
|
||||
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||
size: positiveNumber(item.size, 0),
|
||||
width: positiveNumber(item.width, 1024),
|
||||
height: positiveNumber(item.height, 1024),
|
||||
dataUrl: item.dataUrl,
|
||||
};
|
||||
this.turnAttachments.set(id, attachment);
|
||||
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||
});
|
||||
}
|
||||
|
||||
clearTurnAttachments(clientId?: string) {
|
||||
this.turnAttachments.forEach((item, id) => {
|
||||
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||
const attachment = this.turnAttachments.get(attachmentId);
|
||||
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||
return attachment;
|
||||
}
|
||||
|
||||
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||
this.emitAll(type, { ...payload, threadId });
|
||||
}
|
||||
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
|
||||
if (SITE_TOOLS.has(tool)) {
|
||||
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
|
||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||
if (tool === "canvas_get_selection") {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (tool === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||
if (tool === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_text_node") {
|
||||
const text = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
||||
input = { ops: [textNodeOp(text, text.x ?? nextCanvasX(this.canvasState), text.y ?? 0)] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_text_nodes") {
|
||||
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(data.y ?? 0);
|
||||
const gap = Number(data.gap ?? 40);
|
||||
input = {
|
||||
ops: data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))),
|
||||
};
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_image_prompt_flow") {
|
||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: "image" }, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_config_node") {
|
||||
const data = input as Record<string, unknown>;
|
||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(data.y ?? 0);
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const mode = generationMode(data.mode);
|
||||
const prompt = String(data.prompt || "");
|
||||
input = { ops: [configNodeOp(configId, data, x, y), ...(data.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_generation_flow") {
|
||||
input = { ops: generationFlowOps(input as Record<string, unknown>, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_generate_text" || tool === "canvas_generate_image" || tool === "canvas_generate_video" || tool === "canvas_generate_audio") {
|
||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: tool.replace("canvas_generate_", ""), autoRun: true }, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_update_node") {
|
||||
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_update_node_text") {
|
||||
const data = input as { id: string; text: string; title?: string };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_move_nodes") {
|
||||
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
||||
input = {
|
||||
ops: data.items.map((item) => {
|
||||
const current = findNode(this.canvasState, item.id);
|
||||
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
||||
}),
|
||||
};
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_resize_node") {
|
||||
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_delete_nodes") {
|
||||
input = { ops: [{ type: "delete_node", ids: (input as { ids: string[] }).ids }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_connect_nodes") {
|
||||
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
||||
input = { ops: data.connections.map((connection) => ({ type: "connect_nodes", ...connection })) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_select_nodes") {
|
||||
input = { ops: [{ type: "select_nodes", ids: (input as { ids: string[] }).ids }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_set_viewport") {
|
||||
input = { ops: [{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_run_generation") {
|
||||
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
||||
input = { ops: [runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`);
|
||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
|
||||
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||
const clientId = this.targetClientId;
|
||||
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(input.y ?? 0);
|
||||
const gap = Number(input.gap ?? 40);
|
||||
const direction = input.direction || "row";
|
||||
let offset = 0;
|
||||
const nodes = attachments.map((attachment) => {
|
||||
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||
const node = {
|
||||
id: `image-${crypto.randomUUID()}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.name,
|
||||
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||
return node;
|
||||
});
|
||||
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||
}
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const clientId = this.targetClientId;
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
||||
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
||||
}
|
||||
|
||||
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return {
|
||||
type: "add_node",
|
||||
id,
|
||||
nodeType: "config",
|
||||
title: String(input.title || generationTitle(mode)),
|
||||
position: { x, y },
|
||||
width: typeof input.width === "number" ? input.width : undefined,
|
||||
height: typeof input.height === "number" ? input.height : undefined,
|
||||
metadata: cleanRecord({
|
||||
generationMode: mode,
|
||||
composerContent: prompt,
|
||||
prompt,
|
||||
status: "idle",
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
count: input.count,
|
||||
seconds: input.seconds,
|
||||
vquality: input.vquality,
|
||||
generateAudio: input.generateAudio,
|
||||
watermark: input.watermark,
|
||||
audioVoice: input.audioVoice,
|
||||
audioFormat: input.audioFormat,
|
||||
audioSpeed: input.audioSpeed,
|
||||
audioInstructions: input.audioInstructions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const textId = `text-${crypto.randomUUID()}`;
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
||||
const configInput = { ...input, prompt: tokens.join("\n") };
|
||||
return [
|
||||
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
||||
configNodeOp(configId, configInput, x + 420, y),
|
||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
||||
{ type: "select_nodes", ids: [configId] },
|
||||
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
||||
return { type: "run_generation", nodeId, mode, prompt };
|
||||
}
|
||||
|
||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||
}
|
||||
|
||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return "文本生成";
|
||||
if (mode === "video") return "视频生成";
|
||||
if (mode === "audio") return "音频生成";
|
||||
return "图片生成";
|
||||
}
|
||||
|
||||
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
||||
return (state?.nodes || []).find((node) => node.id === id);
|
||||
}
|
||||
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown, fallback: number) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function fitAttachmentNodeSize(width: number, height: number) {
|
||||
const scale = Math.min(1, 640 / width, 640 / height);
|
||||
return { width: width * scale, height: height * scale };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ToolName } from "./schemas.js";
|
||||
import { nextCanvasX } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
export type CanvasToolRequest = { name: "canvas_apply_ops"; input: Record<string, unknown> };
|
||||
|
||||
/** 将上层画布工具调用转换为前端可执行的批量操作。 */
|
||||
export function buildCanvasToolRequest(name: ToolName, input: Record<string, unknown>, state: CanvasSnapshot | null): CanvasToolRequest {
|
||||
if (name === "canvas_apply_ops") return { name, input };
|
||||
if (name === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
return applyOps([{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(state), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }]);
|
||||
}
|
||||
if (name === "canvas_create_text_node") {
|
||||
const data = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
||||
return applyOps([textNodeOp(data, data.x ?? nextCanvasX(state), data.y ?? 0)]);
|
||||
}
|
||||
if (name === "canvas_create_text_nodes") {
|
||||
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
||||
const x = Number(data.x ?? nextCanvasX(state));
|
||||
const y = Number(data.y ?? 0);
|
||||
const gap = Number(data.gap ?? 40);
|
||||
return applyOps(data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))));
|
||||
}
|
||||
if (name === "canvas_create_image_prompt_flow") return applyOps(generationFlowOps({ ...input, mode: "image" }, state));
|
||||
if (name === "canvas_create_config_node") {
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return applyOps([configNodeOp(configId, input, x, y), ...(input.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])]);
|
||||
}
|
||||
if (name === "canvas_create_generation_flow") return applyOps(generationFlowOps(input, state));
|
||||
if (name === "canvas_generate_text" || name === "canvas_generate_image" || name === "canvas_generate_video" || name === "canvas_generate_audio") {
|
||||
return applyOps(generationFlowOps({ ...input, mode: name.replace("canvas_generate_", ""), autoRun: true }, state));
|
||||
}
|
||||
if (name === "canvas_update_node") {
|
||||
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }]);
|
||||
}
|
||||
if (name === "canvas_update_node_text") {
|
||||
const data = input as { id: string; text: string; title?: string };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }]);
|
||||
}
|
||||
if (name === "canvas_move_nodes") {
|
||||
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
||||
return applyOps(data.items.map((item) => {
|
||||
const current = findNode(state, item.id);
|
||||
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
||||
}));
|
||||
}
|
||||
if (name === "canvas_resize_node") {
|
||||
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }]);
|
||||
}
|
||||
if (name === "canvas_delete_nodes") return applyOps([{ type: "delete_node", ids: (input as { ids: string[] }).ids }]);
|
||||
if (name === "canvas_connect_nodes") {
|
||||
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
||||
return applyOps(data.connections.map((connection) => ({ type: "connect_nodes", ...connection })));
|
||||
}
|
||||
if (name === "canvas_select_nodes") return applyOps([{ type: "select_nodes", ids: (input as { ids: string[] }).ids }]);
|
||||
if (name === "canvas_set_viewport") return applyOps([{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }]);
|
||||
if (name === "canvas_run_generation") {
|
||||
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
||||
return applyOps([runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)]);
|
||||
}
|
||||
throw new Error(`未知工具:${name}`);
|
||||
}
|
||||
|
||||
/** 按最大边限制计算附件图片节点尺寸,并保持原始比例。 */
|
||||
export function fitAttachmentNodeSize(width: number, height: number) {
|
||||
const scale = Math.min(1, 640 / width, 640 / height);
|
||||
return { width: width * scale, height: height * scale };
|
||||
}
|
||||
|
||||
/** 创建统一的批量画布操作请求。 */
|
||||
function applyOps(ops: unknown[]): CanvasToolRequest {
|
||||
return { name: "canvas_apply_ops", input: { ops } };
|
||||
}
|
||||
|
||||
/** 创建文本节点操作。 */
|
||||
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
||||
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
||||
}
|
||||
|
||||
/** 创建生成配置节点操作。 */
|
||||
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return {
|
||||
type: "add_node",
|
||||
id,
|
||||
nodeType: "config",
|
||||
title: String(input.title || generationTitle(mode)),
|
||||
position: { x, y },
|
||||
width: typeof input.width === "number" ? input.width : undefined,
|
||||
height: typeof input.height === "number" ? input.height : undefined,
|
||||
metadata: cleanRecord({
|
||||
generationMode: mode,
|
||||
composerContent: prompt,
|
||||
prompt,
|
||||
status: "idle",
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
count: input.count,
|
||||
seconds: input.seconds,
|
||||
vquality: input.vquality,
|
||||
generateAudio: input.generateAudio,
|
||||
watermark: input.watermark,
|
||||
audioVoice: input.audioVoice,
|
||||
audioFormat: input.audioFormat,
|
||||
audioSpeed: input.audioSpeed,
|
||||
audioInstructions: input.audioInstructions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建包含提示词、配置节点和引用连线的生成流程。 */
|
||||
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const textId = `text-${crypto.randomUUID()}`;
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
||||
return [
|
||||
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
||||
configNodeOp(configId, { ...input, prompt: tokens.join("\n") }, x + 420, y),
|
||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
||||
{ type: "select_nodes", ids: [configId] },
|
||||
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** 创建触发节点生成的画布操作。 */
|
||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
||||
return { type: "run_generation", nodeId, mode, prompt };
|
||||
}
|
||||
|
||||
/** 将未知生成模式归一为画布支持的模式。 */
|
||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||
}
|
||||
|
||||
/** 获取生成模式对应的默认节点标题。 */
|
||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return "文本生成";
|
||||
if (mode === "video") return "视频生成";
|
||||
if (mode === "audio") return "音频生成";
|
||||
return "图片生成";
|
||||
}
|
||||
|
||||
/** 按节点 ID 查找当前画布节点。 */
|
||||
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
||||
return (state?.nodes || []).find((node) => node.id === id);
|
||||
}
|
||||
|
||||
/** 移除对象中未设置的生成参数。 */
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() })
|
||||
const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
||||
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
||||
|
||||
/** Canvas Agent 对外提供的工具名称。 */
|
||||
export const toolNames = [
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
@@ -3,7 +3,7 @@ import type { ServerResponse } from "node:http";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { CanvasSession } from "./session.js";
|
||||
|
||||
test("MCP 读取当前激活网页的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
@@ -251,38 +251,46 @@ test("closing the bound client falls back to the active client", async (t) => {
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
/** 创建用于测试的画布 SSE 连接。 */
|
||||
function connect(session: CanvasSession, clientId: string) {
|
||||
const response = new FakeSseResponse();
|
||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 创建最小画布快照。 */
|
||||
function snapshot(projectId: string) {
|
||||
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
}
|
||||
|
||||
/** 安全读取测试对象字段。 */
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
/** 模拟 Node SSE 响应并提供事件读取能力。 */
|
||||
class FakeSseResponse extends EventEmitter {
|
||||
private chunks: string[] = [];
|
||||
|
||||
/** 模拟写入响应头。 */
|
||||
writeHead() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 保存写入的 SSE 文本块。 */
|
||||
write(chunk: string) {
|
||||
this.chunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 读取指定类型的首个 SSE 事件数据。 */
|
||||
event(type: string) {
|
||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||
}
|
||||
|
||||
/** 触发连接关闭事件。 */
|
||||
close() {
|
||||
this.emit("close");
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
import type { AgentAttachment } from "../agent/types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { buildCanvasToolRequest, fitAttachmentNodeSize } from "./operations.js";
|
||||
import type { ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
/** 管理网页画布连接、状态、附件和工具请求。 */
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private turnAttachments = new Map<string, TurnAttachment>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
/** 获取当前目标网页的画布状态。 */
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
/** 返回 Canvas Agent 当前连接状态。 */
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
}
|
||||
|
||||
/** 返回 Codex 是否正在执行任务。 */
|
||||
get codexBusy() {
|
||||
return this.codexState.busy;
|
||||
}
|
||||
|
||||
/** 更新并广播 Codex 运行状态。 */
|
||||
setCodexState(patch: Partial<CodexState>) {
|
||||
const next = { ...this.codexState, ...patch };
|
||||
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
|
||||
this.codexState = next;
|
||||
logger.debug("Codex state changed", this.codexState);
|
||||
this.emitAll("codex_state", this.codexState);
|
||||
}
|
||||
|
||||
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
|
||||
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);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
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);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存指定网页上报的最新画布快照。 */
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
if (!targetClientId) return;
|
||||
const state = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot;
|
||||
this.canvasStates.set(targetClientId, state);
|
||||
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: state.nodes?.length || 0, connections: state.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 });
|
||||
}
|
||||
|
||||
/** 将当前 Agent turn 固定绑定到指定网页。 */
|
||||
bindClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.boundClientId = clientId;
|
||||
logger.debug("Canvas client bound to turn", { clientId });
|
||||
}
|
||||
|
||||
/** 解除当前 Agent turn 的网页绑定。 */
|
||||
releaseClient(clientId: string) {
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
logger.debug("Canvas client released from turn", { clientId });
|
||||
}
|
||||
|
||||
/** 保存当前 turn 可用的图片附件并返回安全引用。 */
|
||||
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||
this.turnAttachments.clear();
|
||||
return attachments.flatMap((item, index) => {
|
||||
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||
const attachment: TurnAttachment = {
|
||||
clientId,
|
||||
id,
|
||||
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||
size: positiveNumber(item.size, 0),
|
||||
width: positiveNumber(item.width, 1024),
|
||||
height: positiveNumber(item.height, 1024),
|
||||
dataUrl: item.dataUrl,
|
||||
};
|
||||
this.turnAttachments.set(id, attachment);
|
||||
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理指定网页或全部 turn 附件。 */
|
||||
clearTurnAttachments(clientId?: string) {
|
||||
this.turnAttachments.forEach((item, id) => {
|
||||
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取属于指定网页 turn 的图片附件。 */
|
||||
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||
const attachment = this.turnAttachments.get(attachmentId);
|
||||
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/** 接收网页返回的工具调用结果。 */
|
||||
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 向全部已连接网页广播事件。 */
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
/** 向全部网页广播带线程归属的事件。 */
|
||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||
this.emitAll(type, { ...payload, threadId });
|
||||
}
|
||||
|
||||
/** 校验工具参数并将调用分派到当前目标网页。 */
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
|
||||
const input = parseToolInput(name, rawInput) as Record<string, unknown>;
|
||||
if (SITE_TOOLS.has(name)) {
|
||||
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
||||
return await this.requestCanvasTool(name, input);
|
||||
}
|
||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(name);
|
||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||
if (name === "canvas_get_state" || name === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||
if (name === "canvas_get_selection") {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (name === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||
const request = buildCanvasToolRequest(name, input, this.canvasState);
|
||||
return await this.requestCanvasTool(request.name, request.input);
|
||||
}
|
||||
|
||||
/** 将当前 turn 的附件转换为画布图片节点。 */
|
||||
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||
const clientId = this.targetClientId;
|
||||
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(input.y ?? 0);
|
||||
const gap = Number(input.gap ?? 40);
|
||||
const direction = input.direction || "row";
|
||||
let offset = 0;
|
||||
const nodes = attachments.map((attachment) => {
|
||||
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||
const node = {
|
||||
id: `image-${crypto.randomUUID()}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.name,
|
||||
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||
return node;
|
||||
});
|
||||
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||
}
|
||||
|
||||
/** 向目标网页发送工具请求并等待调用结果。 */
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const clientId = this.targetClientId;
|
||||
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)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 向 SSE 连接写入一个事件。 */
|
||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
/** 将未知数值转换为正数,否则使用默认值。 */
|
||||
function positiveNumber(value: unknown, fallback: number) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
||||
|
||||
/** 判断传入名称是否为已注册的画布工具。 */
|
||||
export function isToolName(name: unknown): name is ToolName {
|
||||
return typeof name === "string" && toolNames.includes(name as ToolName);
|
||||
}
|
||||
|
||||
/** 按工具名称校验并解析调用参数。 */
|
||||
export function parseToolInput(name: ToolName, input: unknown) {
|
||||
return toolInputSchemas[name].parse(input ?? {});
|
||||
}
|
||||
|
||||
/** 压缩画布快照,避免向 Agent 返回过长的节点内容。 */
|
||||
export function compactCanvasState(state: CanvasSnapshot | null) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
||||
}
|
||||
|
||||
/** 压缩单个画布节点的元数据内容。 */
|
||||
export function compactNode(node: CanvasNode) {
|
||||
const metadata = { ...(node.metadata || {}) };
|
||||
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
||||
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
||||
}
|
||||
|
||||
/** 计算新节点在当前画布右侧的默认横坐标。 */
|
||||
export function nextCanvasX(state: CanvasSnapshot | null) {
|
||||
const nodes = state?.nodes || [];
|
||||
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||
@@ -1,8 +1,7 @@
|
||||
/** 画布坐标。 */
|
||||
export type Position = { x: number; y: number };
|
||||
export type Viewport = { x: number; y: number; k: number };
|
||||
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
||||
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
|
||||
@@ -7,11 +7,13 @@ 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 };
|
||||
|
||||
/** 读取本地 Canvas Agent 配置,不存在时生成默认配置。 */
|
||||
export function loadConfig(create = false): CanvasAgentConfig {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
|
||||
@@ -22,41 +24,56 @@ export function loadConfig(create = false): CanvasAgentConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 Canvas Agent 配置写入用户配置目录。 */
|
||||
export function saveConfig(config: CanvasAgentConfig) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/** 确保站点级 Codex 工作空间存在并已初始化。 */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 更新站点级 Codex 工作空间配置。 */
|
||||
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
|
||||
const current = ensureSiteWorkspace(config);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 创建工作空间目录并写入默认 AGENTS.md。 */
|
||||
function initializeWorkspace(workspacePath: string) {
|
||||
if (initializedWorkspaces.has(workspacePath)) return;
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
const instructionsFile = path.join(workspacePath, "AGENTS.md");
|
||||
const current = fs.existsSync(instructionsFile) ? fs.readFileSync(instructionsFile, "utf8") : "";
|
||||
if (!current || current.startsWith("# Infinite Canvas Agent")) 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));
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
/** 从当前包信息中读取 Canvas Agent 版本号。 */
|
||||
function readPackageVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { startHttpServer } from "./http-server.js";
|
||||
import { startMcpServer } from "./mcp-server.js";
|
||||
import { startHttpServer } from "./server/http.js";
|
||||
import { startMcpServer } from "./server/mcp.js";
|
||||
|
||||
if (process.argv[2] === "mcp") await startMcpServer();
|
||||
else startHttpServer();
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 type { AgentAttachment } from "./types.js";
|
||||
import { runClaudeTurn } from "../agent/claude.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
|
||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||
import { CanvasSession } from "../canvas/session.js";
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||
export function startHttpServer() {
|
||||
const config = loadConfig(true);
|
||||
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
|
||||
@@ -12,11 +18,13 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||
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 || "");
|
||||
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||
};
|
||||
/** 保存并广播当前站点工作空间的活跃线程。 */
|
||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||
@@ -25,6 +33,16 @@ 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", () => {
|
||||
if (req.method === "OPTIONS" || (res.statusCode < 400 && ["/health", "/canvas/state", "/canvas/activate"].includes(url.pathname))) return;
|
||||
logger.debug(`HTTP ${req.method} ${url.pathname}`, { status: res.statusCode, durationMs: Date.now() - startedAt });
|
||||
});
|
||||
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" });
|
||||
@@ -57,6 +75,21 @@ export function startHttpServer() {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type(attachment.type).send(Buffer.from(data, "base64"));
|
||||
}));
|
||||
app.post("/agent/local-file/reveal", route(async (req, res) => {
|
||||
const filePath = String(req.body?.path || "");
|
||||
if (!path.isAbsolute(filePath)) return res.status(400).json({ ok: false, error: "文件路径必须是绝对路径" });
|
||||
const file = await stat(filePath);
|
||||
await revealLocalFile(filePath, file.isDirectory());
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/local-image", route(async (req, res) => {
|
||||
const filePath = String(req.body?.path || "");
|
||||
if (!path.isAbsolute(filePath) || !/\.(?:avif|gif|jpe?g|png|webp)$/i.test(filePath)) return res.status(400).json({ ok: false, error: "图片路径无效" });
|
||||
const file = await stat(filePath);
|
||||
if (!file.isFile()) return res.status(400).json({ ok: false, error: "图片文件无效" });
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type(path.extname(filePath)).send(await readFile(filePath));
|
||||
}));
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (_req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
@@ -67,14 +100,18 @@ export function startHttpServer() {
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
res.json({ ok: true, workspace, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/new", route(async (_req, res) => {
|
||||
app.post("/agent/codex/threads/new", route(async (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.post("/agent/codex/threads/reset", (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true }) });
|
||||
});
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
@@ -89,7 +126,7 @@ export function startHttpServer() {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
const nextWorkspace = setActiveThread(threadId);
|
||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||
}));
|
||||
@@ -108,12 +145,13 @@ 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", { threadId: req.body?.threadId, promptLength: prompt.length, attachmentCount: attachments.length });
|
||||
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
try {
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
let turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
@@ -126,13 +164,15 @@ export function startHttpServer() {
|
||||
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
|
||||
};
|
||||
let chatThreadId = "";
|
||||
/** 将当前 turn 事件固定广播到实际线程。 */
|
||||
const turnEmit = (type: string, payload: unknown) => {
|
||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
session.emitThread(type, threadId, data);
|
||||
session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
|
||||
};
|
||||
void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
|
||||
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
|
||||
threadId,
|
||||
cwd: workspace.workspacePath,
|
||||
permissionMode: permissionMode(req.body?.permissionMode),
|
||||
appEmit: emit,
|
||||
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
||||
onThread: (actualThreadId) => {
|
||||
@@ -148,9 +188,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 });
|
||||
@@ -162,16 +204,22 @@ export function startHttpServer() {
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
app.post("/agent/codex/interrupt", (req, res) => {
|
||||
const ok = interruptCodexTurn(String(req.body?.threadId || ""));
|
||||
res.json({ ok });
|
||||
});
|
||||
app.post("/agent/codex/approval", route(async (req, res) => {
|
||||
const decision = String(req.body?.decision || "");
|
||||
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });
|
||||
const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision);
|
||||
res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) });
|
||||
}));
|
||||
app.post("/agent/codex/interrupt", route(async (req, res) => res.json({ ok: await interruptCodexTurn(String(req.body?.threadId || "")) })));
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
||||
runClaudeTurn(String(req.body?.prompt || ""), emit);
|
||||
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,21 +228,49 @@ 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 });
|
||||
});
|
||||
}
|
||||
|
||||
/** 将异步 Express 路由异常交给统一错误处理中间件。 */
|
||||
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
|
||||
}
|
||||
|
||||
/** 从 Express 路由参数中读取单个字符串。 */
|
||||
function routeParam(value: string | string[]) {
|
||||
return Array.isArray(value) ? value[0] || "" : value;
|
||||
}
|
||||
|
||||
function permissionMode(value: unknown): AgentPermissionMode {
|
||||
return value === "automatic" || value === "full" ? value : "request";
|
||||
}
|
||||
|
||||
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||
const args = process.platform === "darwin"
|
||||
? ["-R", filePath]
|
||||
: process.platform === "win32"
|
||||
? [isDirectory ? filePath : `/select,${filePath}`]
|
||||
: [isDirectory ? filePath : path.dirname(filePath)];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** 结合服务配置解析当前请求 URL。 */
|
||||
function requestUrl(req: Request, config: CanvasAgentConfig) {
|
||||
return new URL(req.originalUrl || req.url || "/", config.url);
|
||||
}
|
||||
|
||||
/** 设置跨域响应头并记录通过 token 授权的来源。 */
|
||||
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
|
||||
const origin = req.headers.origin;
|
||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||
@@ -211,11 +287,13 @@ function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfi
|
||||
return config.origins.includes(origin);
|
||||
}
|
||||
|
||||
/** 校验请求查询参数或请求头中的连接 token。 */
|
||||
function validToken(req: Request, url: URL, token: string) {
|
||||
const header = req.headers["x-canvas-agent-token"];
|
||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||
}
|
||||
|
||||
/** 向 Agent 提示词追加本轮图片附件引用说明。 */
|
||||
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
|
||||
if (!attachments.length) return prompt;
|
||||
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
|
||||
@@ -1,11 +1,12 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "./config.js";
|
||||
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "../canvas/schemas.js";
|
||||
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "../config.js";
|
||||
|
||||
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
|
||||
|
||||
/** 启动通过标准输入输出通信的 MCP 服务。 */
|
||||
export async function startMcpServer() {
|
||||
const config = loadConfig(true);
|
||||
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
||||
@@ -13,6 +14,7 @@ export async function startMcpServer() {
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
/** 向 MCP Server 注册单个 Canvas Agent 工具。 */
|
||||
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
|
||||
const schema = toolInputSchemas[name];
|
||||
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
|
||||
@@ -21,6 +23,7 @@ function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name:
|
||||
});
|
||||
}
|
||||
|
||||
/** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
|
||||
async function postCanvasAgentTool(config: CanvasAgentConfig, name: ToolName, input: unknown) {
|
||||
const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
|
||||
const body = (await res.json()) as CanvasAgentToolResponse;
|
||||
@@ -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,78 @@
|
||||
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}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
||||
this.logger = winston.createLogger({
|
||||
level: "debug",
|
||||
transports: [
|
||||
new transports.Console({format: format.combine(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})}`;
|
||||
const text = Object.entries(details).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${inspect(value, {depth: null, breakLength: Infinity})}`).join(" ");
|
||||
return text ? ` ${text}` : "";
|
||||
}
|
||||
|
||||
/** 清理日志内容中的敏感数据和不可序列化引用。 */
|
||||
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();
|
||||
@@ -0,0 +1,11 @@
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** 安全读取未知对象中的指定字段。 */
|
||||
export function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as JsonRecord)[key] : undefined;
|
||||
}
|
||||
|
||||
/** 将未知异常转换为可展示的错误信息。 */
|
||||
export function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -137,12 +137,10 @@ Codex 调 canvas_create_text_node
|
||||
|
||||
## 工具确认和撤销
|
||||
|
||||
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
|
||||
浏览器侧默认自动执行 `canvas_apply_ops` 写操作。需要逐次审核时,可在右侧对话输入框左下方把「自动确认」切换为「手动确认」;收到写操作后,对话中会展示待执行工具调用,用户确认后才真正应用到画布。
|
||||
|
||||
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
|
||||
|
||||
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- `Canvas Agent` 默认只监听 `127.0.0.1`。
|
||||
|
||||
@@ -5,6 +5,28 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;此时不应生成空历史记录,第一次发送消息时才创建线程;多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话。
|
||||
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
||||
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
||||
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
|
||||
- Agent 画布生图:重启 Canvas Agent 后,其自动生成的工作区 `AGENTS.md` 应同步为最新版指令(用户自行编写的其他 `AGENTS.md` 不应被覆盖);输入“生成一张图片”时应调用 `canvas_generate_image`,通过当前画布节点创建并运行生成流程;只有明确输入“使用 Codex 内置生图/ImageGen 技能生成”时才允许调用内置能力,Codex App Server 返回的 `imageGeneration` 结果应显示在对话中并作为保持原比例的图片节点添加到当前画布;Agent 不应在没有生成结果时提前声称“已生成”。
|
||||
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。
|
||||
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
|
||||
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
|
||||
- Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
||||
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
|
||||
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
||||
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
||||
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
||||
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
||||
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,完成事件不应再用「已完成分析」覆盖已经收到的摘要;命令执行也应使用无边框紧凑行,显示「已执行 1 条命令」和单行命令预览,点击后再展开工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。
|
||||
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。
|
||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
||||
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧显示为约 40px 的紧凑缩略图,不再撑大消息区域,单击缩略图应打开大图预览,打开或关闭预览不应改变消息间距或产生额外空行;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
||||
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
||||
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
|
||||
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
|
||||
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
|
||||
- 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。
|
||||
@@ -27,4 +49,4 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
|
||||
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
|
||||
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息。
|
||||
- Agent 消息区分:用户消息应在右侧以主题浅底气泡显示,AI 回复应在左侧保留 Agent 图标并使用开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
||||
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
|
||||
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||
try {
|
||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function activateAgentClient(endpoint: string, token: string, clientId: string) {
|
||||
try {
|
||||
await fetch(`${endpoint}/canvas/activate?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
|
||||
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") {
|
||||
await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) });
|
||||
}
|
||||
|
||||
export async function revealAgentLocalFile(endpoint: string, token: string, path: string) {
|
||||
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
|
||||
}
|
||||
|
||||
export async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
|
||||
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function discoverAgentConfig(endpoint: string) {
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/config`);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as AgentConfigResponse;
|
||||
return data.ok ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import type { AgentPermissionMode } from "@/stores/use-agent-store";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
disabled,
|
||||
sending,
|
||||
placeholder,
|
||||
theme,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
confirmTools,
|
||||
onConfirmToolsChange,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
left,
|
||||
}: {
|
||||
prompt: string;
|
||||
attachments?: AgentChatAttachment[];
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
placeholder: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
confirmTools?: boolean;
|
||||
onConfirmToolsChange?: (confirmTools: boolean) => void;
|
||||
permissionMode?: AgentPermissionMode;
|
||||
onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
return (
|
||||
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
{onRemoveAttachment ? (
|
||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
void onAddFiles(images);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isPlainEnterKey(event)) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{onAddFiles ? (
|
||||
<>
|
||||
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
|
||||
void onAddFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
|
||||
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{sending && onStop ? (
|
||||
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
|
||||
) : (
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
|
||||
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
items: permissionOptions.map((item) => ({
|
||||
key: item.key,
|
||||
label: <ConfirmationOption icon={item.icon} title={item.title} description={item.description} selected={permissionMode === item.key} />,
|
||||
onClick: () => onChange(item.key),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label="选择 Codex 权限模式">
|
||||
{current.icon}
|
||||
<span>{current.shortTitle}</span>
|
||||
<ChevronUp className="size-3 opacity-50" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortTitle: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "request", title: "请求批准", shortTitle: "请求批准", description: "编辑工作区外文件或联网时始终询问", icon: <ShieldAlert className="size-3.5" /> },
|
||||
{ key: "automatic", title: "自动审查", shortTitle: "自动审查", description: "由 Codex 审查风险操作,必要时再询问", icon: <ShieldCheck className="size-3.5" /> },
|
||||
{ key: "full", title: "完全访问权限", shortTitle: "完全访问", description: "不受限制地访问网络和本机文件", icon: <ShieldOff className="size-3.5" /> },
|
||||
];
|
||||
|
||||
function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) {
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "manual",
|
||||
label: <ConfirmationOption icon={<Hand className="size-4" />} title="手动确认" description="Agent 执行画布写入前会请求确认" selected={confirmTools} />,
|
||||
onClick: () => onChange(true),
|
||||
},
|
||||
{
|
||||
key: "automatic",
|
||||
label: <ConfirmationOption icon={<RefreshCw className="size-4" />} title="自动确认" description="Agent 会自动执行画布写入操作" selected={!confirmTools} />,
|
||||
onClick: () => onChange(false),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="选择工具确认模式">
|
||||
{confirmTools ? <Hand className="size-3.5" /> : <RefreshCw className="size-3.5" />}
|
||||
<span>{confirmTools ? "手动确认" : "自动确认"}</span>
|
||||
<ChevronUp className="size-3 opacity-50" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationOption({ icon, title, description, selected }: { icon: ReactNode; title: string; description: string; selected: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-64 items-start gap-3 py-1">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{title}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 opacity-60">{description}</span>
|
||||
</span>
|
||||
{selected ? <Check className="mt-0.5 size-4 shrink-0" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Button, Image, Modal } from "antd";
|
||||
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
||||
import { Streamdown, type LinkSafetyModalProps } from "streamdown";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store";
|
||||
import { revealAgentLocalFile } from "./agent-api";
|
||||
|
||||
const streamdownProps = {
|
||||
className: "agent-streamdown",
|
||||
controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } },
|
||||
linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => <AgentLinkModal {...props} /> },
|
||||
lineNumbers: false,
|
||||
translations: {
|
||||
close: "关闭",
|
||||
copied: "已复制",
|
||||
copyCode: "复制代码",
|
||||
copyLink: "复制链接",
|
||||
externalLinkWarning: "即将打开以下外部链接,请确认链接可信。",
|
||||
openExternalLink: "打开外部链接?",
|
||||
openLink: "继续打开",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
const localPath = localFilePath(url);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const open = async () => {
|
||||
if (!localPath) return onConfirm();
|
||||
const { url: endpoint, token } = useAgentStore.getState();
|
||||
setOpening(true);
|
||||
try {
|
||||
await revealAgentLocalFile(endpoint, token, localPath);
|
||||
message.success("已在文件管理器中定位");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "无法打开本地文件");
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal open={isOpen} onCancel={onClose} footer={null} centered width={420} title={localPath ? "打开本地文件?" : "打开外部链接?"}>
|
||||
<div className="text-sm text-black/55 dark:text-white/55">
|
||||
{localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"}
|
||||
</div>
|
||||
<div className="mt-4 max-h-32 overflow-auto break-all rounded-lg bg-black/[.035] px-3 py-2.5 font-mono text-xs leading-5 dark:bg-white/[.06]">{localPath || url}</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, localPath ? "路径已复制" : "链接已复制")}>
|
||||
{localPath ? "复制路径" : "复制链接"}
|
||||
</Button>
|
||||
<Button type="text" loading={opening} icon={localPath ? <FolderOpen className="size-4" /> : <ExternalLink className="size-4" />} onClick={open}>
|
||||
{localPath ? "在文件管理器中显示" : "继续打开"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function localFilePath(value: string) {
|
||||
let decoded = value;
|
||||
try {
|
||||
decoded = decodeURI(value);
|
||||
} catch {}
|
||||
if (decoded.startsWith("file://")) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(decoded).pathname);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/.test(decoded)) return decoded;
|
||||
let pathname = decoded;
|
||||
if (decoded.startsWith("http://") || decoded.startsWith("https://")) {
|
||||
try {
|
||||
const parsed = new URL(decoded);
|
||||
if (!["localhost", "127.0.0.1"].includes(parsed.hostname)) return "";
|
||||
pathname = parsed.pathname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return /^\/(?:Users|home|private|tmp|Volumes|var\/folders)\//.test(pathname) ? decodeURIComponent(pathname) : "";
|
||||
}
|
||||
|
||||
export type AgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type AgentChatMessageItem = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system" | "tool" | "error";
|
||||
title?: string;
|
||||
text: string;
|
||||
meta?: string;
|
||||
detail?: unknown;
|
||||
attachments?: AgentChatAttachment[];
|
||||
/** Present while the message is actively streaming; cleared on completion. */
|
||||
streamId?: string;
|
||||
};
|
||||
|
||||
export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: { item: AgentChatMessageItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center text-xs">
|
||||
<div className="max-w-[88%] px-3 py-1.5 text-center" style={{ color: theme.node.muted }}>
|
||||
{item.text}
|
||||
{item.meta ? <span className="ml-2 opacity-60">{item.meta}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.role === "tool") {
|
||||
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
|
||||
return <AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />;
|
||||
}
|
||||
return (
|
||||
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={isUser ? "min-w-0 max-w-[82%] py-1 text-right text-sm leading-6" : "min-w-0 w-full text-left text-sm leading-6"}
|
||||
style={{ color }}
|
||||
>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
) : (
|
||||
<Streamdown {...streamdownProps} animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
)}
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
|
||||
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
|
||||
const view = userDetail(detail);
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
|
||||
<details className="group">
|
||||
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
|
||||
<CircleAlert className="size-4 shrink-0 text-amber-600" />
|
||||
<span className="min-w-0 flex-1">等待确认</span>
|
||||
{view ? <ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
</div>
|
||||
<div className="mt-1 pl-6 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
|
||||
</summary>
|
||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
</details>
|
||||
{onReject || onApprove ? (
|
||||
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentApprovalCard({ approval, theme, onDecision }: { approval: AgentPendingApproval; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onDecision: (decision: "accept" | "acceptForSession" | "decline") => void }) {
|
||||
const isFile = approval.method === "item/fileChange/requestApproval";
|
||||
const isNetwork = Boolean(approval.networkApprovalContext);
|
||||
const title = isNetwork ? "请求网络访问" : isFile ? "请求编辑文件" : approval.method === "item/permissions/requestApproval" ? "请求扩展权限" : "请求执行命令";
|
||||
const target = isNetwork ? approvalTarget(approval.networkApprovalContext) : isFile ? approval.grantRoot || approval.cwd : commandText(approval.command) || approval.cwd;
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(234,88,12,.32)", background: "rgba(234,88,12,.035)", color: theme.node.text }}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-orange-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{approval.reason ? <div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{approval.reason}</div> : null}
|
||||
{target ? <div className="mt-1.5 break-all rounded-lg px-2.5 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{target}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<Button danger type="text" className="!h-8" onClick={() => onDecision("decline")}>拒绝</Button>
|
||||
<Button type="text" className="!h-8" onClick={() => onDecision("accept")}>允许一次</Button>
|
||||
<Button type="text" className="!h-8" style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const plan = planDetail(detail);
|
||||
if (plan) return <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
||||
const kind = String(objectField(detail, "kind") || "");
|
||||
if (kind === "reasoning") return <AgentReasoningSummary text={text} detail={detail} theme={theme} />;
|
||||
if (kind === "command") return <AgentCommandSummary text={text} detail={detail} theme={theme} />;
|
||||
const state = toolCardState(title, text, detail);
|
||||
const view = userDetail(detail);
|
||||
const showText = title !== "读取画布" || text !== "已读取当前画布内容";
|
||||
return (
|
||||
<details className="group min-w-0 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
|
||||
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
||||
<span className="min-w-0 truncate font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
||||
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
</div>
|
||||
{showText ? (
|
||||
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
) : null}
|
||||
</summary>
|
||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const status = String(objectField(detail, "status") || "");
|
||||
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
|
||||
return (
|
||||
<details className="group min-w-0 text-left">
|
||||
<summary className="cursor-pointer list-none py-1 text-sm" style={{ color: theme.node.muted }}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <Brain className="size-4 shrink-0" />}
|
||||
<span>{running ? "正在思考" : "思考摘要"}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" />
|
||||
</div>
|
||||
</summary>
|
||||
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
|
||||
<Streamdown {...streamdownProps} animated isAnimating={running}>{text}</Streamdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCommandSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const view = userDetail(detail);
|
||||
const status = String(objectField(detail, "status") || "");
|
||||
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
|
||||
const failed = ["failed", "error"].includes(status);
|
||||
const color = failed ? "#dc2626" : running ? "#d97706" : theme.node.muted;
|
||||
return (
|
||||
<details className="group min-w-0 text-left">
|
||||
<summary className={`list-none py-1 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
|
||||
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
|
||||
<span className="font-medium">{failed ? "命令执行失败" : running ? "正在执行命令" : "已执行 1 条命令"}</span>
|
||||
{view ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate pl-6 font-mono text-[12px] leading-5" style={{ color: failed ? color : theme.node.muted }} title={text}>{text}</div>
|
||||
</summary>
|
||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const completed = plan.tasks.filter((item) => item.status === "completed").length;
|
||||
const state = planCardState(plan, completed);
|
||||
return (
|
||||
<details open={open} onToggle={(event) => setOpen(event.currentTarget.open)} className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className="flex min-w-0 cursor-pointer list-none items-center gap-2.5">
|
||||
<ListChecks className="size-4 shrink-0" style={{ color: state.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
||||
<span aria-live="polite" className="shrink-0 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>{completed}/{plan.tasks.length}</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} />
|
||||
</summary>
|
||||
{plan.explanation ? <div className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{plan.explanation}</div> : null}
|
||||
<div className="mt-2.5 space-y-2 border-t pt-2.5" style={{ borderColor: theme.node.stroke }}>
|
||||
{plan.tasks.map((item, index) => {
|
||||
const task = planTaskState(item.status, theme.node.muted);
|
||||
return (
|
||||
<div key={`${index}-${item.step}`} className="flex items-start gap-2 text-sm leading-5">
|
||||
<span className="mt-0.5 shrink-0" style={{ color: task.color }}>{task.icon}</span>
|
||||
<span className={`min-w-0 flex-1 ${item.status === "completed" ? "opacity-55" : item.status === "inProgress" ? "font-medium" : ""}`} style={{ color: item.status === "inProgress" ? theme.node.text : theme.node.muted }}>{item.step}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: task.color }}>{task.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
setElapsed(0);
|
||||
const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activityKey]);
|
||||
return (
|
||||
<div className="min-w-0 py-1" aria-live="polite">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm" style={{ color: theme.node.muted }}>
|
||||
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
|
||||
<span className="min-w-0">{text}</span>
|
||||
{elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
|
||||
</div>
|
||||
{elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function waitingTime(seconds: number) {
|
||||
if (seconds < 60) return `已等待 ${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `已等待 ${minutes} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
|
||||
function commandText(value: unknown) {
|
||||
if (Array.isArray(value)) return value.map(String).join(" ");
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function approvalTarget(value: unknown) {
|
||||
const host = String(objectField(value, "host") || "");
|
||||
const protocol = String(objectField(value, "protocol") || "");
|
||||
const port = String(objectField(value, "port") || "");
|
||||
return host ? `${protocol ? `${protocol}://` : ""}${host}${port ? `:${port}` : ""}` : "";
|
||||
}
|
||||
|
||||
type PlanTask = { step: string; status: string };
|
||||
type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string };
|
||||
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-2.5 border-t pt-3 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
{detail.rows?.length ? (
|
||||
<dl className="space-y-1.5">
|
||||
{detail.rows.map((row) => (
|
||||
<div key={`${row.label}-${row.value}`} className="grid grid-cols-[64px_minmax(0,1fr)] gap-2">
|
||||
<dt className="opacity-60">{row.label}</dt>
|
||||
<dd className="min-w-0 break-words" style={{ color: theme.node.text }}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{detail.files?.length ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="opacity-60">涉及文件</div>
|
||||
{detail.files.map((file) => (
|
||||
<div key={`${file.action}-${file.path}`} className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 break-all" style={{ color: theme.node.text }}>{file.path}</span>
|
||||
{file.action ? <span className="shrink-0 opacity-60">{file.action}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{detail.output ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="opacity-60">{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}</div>
|
||||
<pre className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg px-3 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{detail.output}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMessageAttachments({ attachments, alignRight }: { attachments: AgentChatAttachment[]; alignRight?: boolean }) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div className={`mt-1.5 flex flex-wrap gap-1.5 ${alignRight ? "justify-end" : "justify-start"}`}>
|
||||
{attachments.map((item) => (
|
||||
<img
|
||||
key={item.id}
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
title="点击查看大图"
|
||||
className="size-10 cursor-zoom-in rounded-lg object-cover"
|
||||
draggable={false}
|
||||
onClick={() => setPreviewUrl(item.url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div className="hidden">
|
||||
<Image src={previewUrl} alt="图片附件预览" preview={{ visible: true, src: previewUrl, onVisibleChange: (visible) => !visible && setPreviewUrl(null) }} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
|
||||
const lower = raw.toLowerCase();
|
||||
const status = String(objectField(detail, "status") || "").toLowerCase();
|
||||
if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-4 animate-spin" />, isError: false };
|
||||
if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "已记录", color: "#2563eb", icon: <Wrench className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
function toolIcon(kind: string | undefined, fallback: ReactNode) {
|
||||
if (kind === "search") return <Search className="size-4" />;
|
||||
if (kind === "file") return <FilePenLine className="size-4" />;
|
||||
if (kind === "plan") return <ListChecks className="size-4" />;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function planCardState(plan: PlanDetail, completed: number) {
|
||||
if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" };
|
||||
if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" };
|
||||
if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" };
|
||||
if (plan.status === "finished") return { label: "已结束", color: "#2563eb" };
|
||||
return { label: "进行中", color: "#d97706" };
|
||||
}
|
||||
|
||||
function planTaskState(status: string, muted: string) {
|
||||
if (status === "completed") return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> };
|
||||
if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> };
|
||||
return { label: "待处理", color: muted, icon: <Circle className="size-3.5" /> };
|
||||
}
|
||||
|
||||
function planDetail(value: unknown): PlanDetail | null {
|
||||
if (!value || typeof value !== "object" || objectField(value, "kind") !== "todo") return null;
|
||||
const tasks = Array.isArray(objectField(value, "tasks"))
|
||||
? (objectField(value, "tasks") as unknown[]).flatMap((item) => {
|
||||
const step = String(objectField(item, "step") || "").trim();
|
||||
return step ? [{ step, status: String(objectField(item, "status") || "pending") }] : [];
|
||||
})
|
||||
: [];
|
||||
if (!tasks.length) return null;
|
||||
const explanation = String(objectField(value, "explanation") || "").trim();
|
||||
return { status: String(objectField(value, "status") || "inProgress"), tasks, ...(explanation ? { explanation } : {}) };
|
||||
}
|
||||
|
||||
function userDetail(value: unknown): UserDetail | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const detail = value as Record<string, unknown>;
|
||||
const rows = Array.isArray(detail.rows)
|
||||
? detail.rows.flatMap((row) => {
|
||||
if (!row || typeof row !== "object") return [];
|
||||
const label = String((row as Record<string, unknown>).label || "");
|
||||
const value = String((row as Record<string, unknown>).value || "");
|
||||
return label && value ? [{ label, value }] : [];
|
||||
})
|
||||
: [];
|
||||
const files = Array.isArray(detail.files)
|
||||
? detail.files.flatMap((file) => {
|
||||
if (!file || typeof file !== "object") return [];
|
||||
const path = String((file as Record<string, unknown>).path || "");
|
||||
return path ? [{ path, action: String((file as Record<string, unknown>).action || "") || undefined }] : [];
|
||||
})
|
||||
: [];
|
||||
const error = objectField(detail.error, "message");
|
||||
const output = typeof detail.output === "string" ? detail.output.trim() : typeof error === "string" ? error : "";
|
||||
if (!rows.length && !files.length && !output) return null;
|
||||
return { kind: typeof detail.kind === "string" ? detail.kind : undefined, status: typeof detail.status === "string" ? detail.status : undefined, rows, files, output };
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value == null) return "";
|
||||
return String(objectField(value, "message") || "");
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { useAgentStore, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
||||
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
|
||||
export function AgentChatTimeline({
|
||||
theme,
|
||||
pendingTool,
|
||||
pendingApprovals,
|
||||
sending,
|
||||
waiting,
|
||||
onRejectTool,
|
||||
onApproveTool,
|
||||
onApprovalDecision,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
pendingApprovals: AgentPendingApproval[];
|
||||
sending: boolean;
|
||||
waiting: boolean;
|
||||
onRejectTool: () => void;
|
||||
onApproveTool: () => void;
|
||||
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
||||
}) {
|
||||
const messages = useAgentStore((state) => state.messages);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||
followMessagesRef.current = atBottom;
|
||||
setShowScrollToBottom(!atBottom);
|
||||
}, []);
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
followMessagesRef.current = true;
|
||||
list.scrollTo({ top: list.scrollHeight, behavior });
|
||||
setShowScrollToBottom(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
||||
{messages.map((item) => (
|
||||
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} />
|
||||
))}
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||
theme={theme}
|
||||
onReject={onRejectTool}
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Tooltip title="滚动到底部" placement="left">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
aria-label="滚动到底部"
|
||||
className="!absolute bottom-3 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5"
|
||||
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||
icon={<ChevronDown className="size-4" />}
|
||||
onClick={() => scrollToBottom()}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; busy: boolean }) {
|
||||
const plan = useAgentStore((state) => busy ? currentPlanMessage(state.messages) : latestPlanMessage(state.messages));
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<div className="shrink-0 px-4 pt-2">
|
||||
<AgentToolCard key={plan.id} title={plan.title || "任务进度"} text={plan.text} detail={plan.detail} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
|
||||
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
<span className="opacity-70">最新调用</span>
|
||||
<UsageNumber label="输入" value={usage.input} color={theme.node.text} />
|
||||
<UsageNumber label="缓存" value={usage.cached} color={theme.node.text} />
|
||||
<UsageNumber label="输出" value={usage.output} color={theme.node.text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageNumber({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const spring = useSpring(value, { stiffness: 110, damping: 24, mass: 0.7 });
|
||||
const text = useTransform(spring, (current) => Math.round(current).toLocaleString());
|
||||
useEffect(() => spring.set(value), [spring, value]);
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1" aria-label={`${label} ${value.toLocaleString()}`}>
|
||||
<span>{label}</span>
|
||||
<motion.span aria-hidden className="font-medium" style={{ color }}>
|
||||
{text}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Fragment } from "react";
|
||||
import { App, Button, Input, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, KeyRound, Link2, PlugZap } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
const AGENT_CONNECT_STEPS = [
|
||||
{ title: "方式一:在 Codex 中使用插件", text: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。" },
|
||||
{ title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" },
|
||||
];
|
||||
const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas";
|
||||
const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
|
||||
|
||||
export function AgentConnectView({
|
||||
theme,
|
||||
url,
|
||||
token,
|
||||
enabled,
|
||||
connected,
|
||||
activity,
|
||||
connectError,
|
||||
onUrlChange,
|
||||
onTokenChange,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
url: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
activity: string;
|
||||
connectError: string;
|
||||
onUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
const copyCommand = (command: string) => {
|
||||
copyToClipboard(command);
|
||||
message.success("命令已复制");
|
||||
};
|
||||
const codexPluginReminder = (
|
||||
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<div className="font-medium" style={{ color: theme.node.text }}>
|
||||
Codex 插件提醒
|
||||
</div>
|
||||
<div className="mt-1">只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。</div>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{[
|
||||
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
|
||||
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
|
||||
].map(([label, command]) => (
|
||||
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{label}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||
<Tooltip title="复制命令">
|
||||
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
按使用场景选择一种连接方式。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{AGENT_CONNECT_STEPS.map((step, index) => {
|
||||
const command = "command" in step ? step.command : "";
|
||||
return (
|
||||
<Fragment key={step.title}>
|
||||
<div className="rounded-lg px-3 py-2.5">
|
||||
<div className="text-sm font-medium leading-5">{step.title}</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{step.text}
|
||||
</div>
|
||||
{command ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||
<Tooltip title="复制命令">
|
||||
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{index === 0 ? codexPluginReminder : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||
<span
|
||||
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4"
|
||||
style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
默认自动读取 Local URL 和 Connect token,失败时再手动填写。
|
||||
</div>
|
||||
</div>
|
||||
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
|
||||
{enabled ? "断开" : "连接"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<Link2 className="size-3.5" />
|
||||
本地地址
|
||||
<span className="font-normal opacity-70">Local URL</span>
|
||||
</span>
|
||||
<Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="例如 http://127.0.0.1:17371" />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<KeyRound className="size-3.5" />
|
||||
连接 Token
|
||||
<span className="font-normal opacity-70">Connect token</span>
|
||||
</span>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="自动发现,或手动填入 Connect token"
|
||||
/>
|
||||
</label>
|
||||
{connectError ? (
|
||||
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
||||
{connectError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
import { isSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
|
||||
export type AgentEventPayload = {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
threadId?: string;
|
||||
thread_id?: string;
|
||||
turn_id?: string;
|
||||
item?: AgentEventItem;
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
status?: string;
|
||||
explanation?: unknown;
|
||||
plan?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
duration_ms?: number;
|
||||
};
|
||||
export type AgentEventItem = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
text?: unknown;
|
||||
delta?: unknown;
|
||||
message?: unknown;
|
||||
server?: string;
|
||||
tool?: string;
|
||||
status?: string;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
aggregatedOutput?: unknown;
|
||||
exitCode?: unknown;
|
||||
durationMs?: unknown;
|
||||
contentItems?: unknown;
|
||||
success?: unknown;
|
||||
changes?: unknown;
|
||||
summary?: unknown;
|
||||
query?: unknown;
|
||||
action?: unknown;
|
||||
path?: unknown;
|
||||
savedPath?: unknown;
|
||||
revisedPrompt?: unknown;
|
||||
};
|
||||
export type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }>; tasks?: Array<{ step: string; status: string }>; explanation?: string };
|
||||
|
||||
export type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
|
||||
|
||||
export function agentMessageToChatMessage(item: AgentChatItem) {
|
||||
return { ...item, meta: item.role === "user" || item.role === "assistant" ? undefined : item.meta, attachments: item.attachments?.map(agentAttachmentToChatAttachment) };
|
||||
}
|
||||
|
||||
export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentChatAttachment {
|
||||
return { id: item.id, name: item.name, url: item.dataUrl || item.url };
|
||||
}
|
||||
|
||||
export 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.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const item = event.item;
|
||||
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
|
||||
const completed = event.type === "item.completed";
|
||||
const status = String(item.status || (completed ? "completed" : "inProgress"));
|
||||
if (item.type === "reasoning") {
|
||||
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
|
||||
}
|
||||
if (item.type === "plan") {
|
||||
const text = stringText(item.text) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
|
||||
}
|
||||
if (item.type === "command_execution") {
|
||||
const command = stringText(item.command) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
|
||||
}
|
||||
if (item.type === "file_change") {
|
||||
const files = activityFiles(item.changes);
|
||||
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
|
||||
}
|
||||
if (item.type === "web_search") {
|
||||
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
|
||||
}
|
||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
|
||||
if (item.type === "image_generation") return { role: "tool", title: "内置生图", text: completed ? "图片生成完成" : "正在生成图片…", detail: { kind: "image", status } };
|
||||
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…", detail: { kind: "context", status } };
|
||||
if (isMcpToolItem(item) && isReadTool(String(item.tool || ""))) {
|
||||
const name = String(item.tool || "");
|
||||
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}…`, detail: toolDetail(item, item.error ? "failed" : status) };
|
||||
}
|
||||
if (item.type === "dynamic_tool_call") {
|
||||
const name = String(item.tool || "");
|
||||
const title = toolName(name);
|
||||
const failed = Boolean(item.error) || item.success === false || ["failed", "error"].includes(status);
|
||||
const currentStatus = failed ? "failed" : status;
|
||||
return {
|
||||
role: "tool",
|
||||
title,
|
||||
text: completed ? item.error?.message || readableText(item.contentItems) || `${title}${failed ? "失败" : "完成"}` : `正在${toolAction(name)}…`,
|
||||
detail: toolDetail(item, currentStatus),
|
||||
};
|
||||
}
|
||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatAgentPlan(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const tasks = planTasks(event.plan);
|
||||
if (!tasks.length) return null;
|
||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||
return {
|
||||
role: "tool",
|
||||
title: "任务进度",
|
||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||
detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) },
|
||||
};
|
||||
}
|
||||
|
||||
function planTasks(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((item) => {
|
||||
const step = stringText(objectField(item, "step")).trim();
|
||||
return step ? [{ step, status: stringText(objectField(item, "status")) || "pending" }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function turnPlanStatus(detail: unknown, turnStatus?: string) {
|
||||
const tasks = planTasks(objectField(detail, "tasks"));
|
||||
if (turnStatus === "failed") return "failed";
|
||||
if (turnStatus === "interrupted") return "interrupted";
|
||||
if (tasks.length && tasks.every((item) => item.status === "completed")) return "completed";
|
||||
return turnStatus === "completed" ? "finished" : "inProgress";
|
||||
}
|
||||
|
||||
export function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
|
||||
if (item.type === "command_execution") return { id: item.id || randomId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
|
||||
return { id: item.id || randomId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
|
||||
}
|
||||
|
||||
export function activityPlaceholder(type?: string) {
|
||||
if (type === "plan") return "正在整理执行步骤…";
|
||||
if (type === "command_execution") return "正在执行命令…";
|
||||
return "正在分析任务…";
|
||||
}
|
||||
|
||||
export function activityKind(type?: string) {
|
||||
if (type === "command_execution") return "command";
|
||||
if (type === "plan") return "plan";
|
||||
return "reasoning";
|
||||
}
|
||||
|
||||
export function activityDetail(value: unknown, kind: string, status: string): AgentUserDetail {
|
||||
const current = value && typeof value === "object" ? (value as Partial<AgentUserDetail>) : {};
|
||||
return { kind, status, rows: current.rows, output: current.output, files: current.files, tasks: current.tasks, explanation: current.explanation };
|
||||
}
|
||||
|
||||
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
||||
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
|
||||
return { kind: "command", status, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
|
||||
}
|
||||
|
||||
function activityFiles(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((change) => {
|
||||
const path = stringText(objectField(change, "path"));
|
||||
return path ? [{ path, action: changeAction(objectField(change, "kind")) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) {
|
||||
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…";
|
||||
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`;
|
||||
const names = files.slice(0, 3).map((file) => file.path).join("、");
|
||||
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`;
|
||||
}
|
||||
|
||||
function webSearchSummary(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
const type = stringText(objectField(action, "type"));
|
||||
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}”`;
|
||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||
}
|
||||
|
||||
function webSearchDetailRows(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
return [detailRow("关键词", item.query || objectField(action, "query")), detailRow("网页", objectField(action, "url"))].flatMap((row) => (row ? [row] : []));
|
||||
}
|
||||
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
return readableText(objectField(value, "text"));
|
||||
}
|
||||
|
||||
function detailRow(label: string, value: unknown) {
|
||||
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||
}
|
||||
|
||||
function durationDetailRow(value: unknown) {
|
||||
const duration = Number(value || 0);
|
||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||
}
|
||||
|
||||
function changeAction(value: unknown) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
export function parseEventData<T>(event: Event) {
|
||||
try {
|
||||
return JSON.parse((event as MessageEvent).data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentThreadEvent(event: { threadId?: string; thread_id?: string }) {
|
||||
const threadId = event.threadId || event.thread_id || "";
|
||||
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
|
||||
}
|
||||
|
||||
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
const head = [
|
||||
"Infinite Canvas Agent 诊断",
|
||||
`地址:${context.endpoint}`,
|
||||
`连接:${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"} · 状态:${context.activity}`,
|
||||
`消息:${context.messages} · 工具:${context.pendingTool ? toolName(context.pendingTool) : "无"}`,
|
||||
].join("\n");
|
||||
const body = logs.map((item) => `${item.time} ${item.title}${item.text && item.text !== item.title ? ` · ${item.text}` : ""}`).join("\n");
|
||||
return [head, body || "暂无事件日志"].join("\n\n");
|
||||
}
|
||||
|
||||
export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
return JSON.stringify({ context, logs: logs.map(({ time, title, text, raw }) => ({ time, title, text, raw })) }, null, 2);
|
||||
}
|
||||
|
||||
export function formatAgentEventLog(event: AgentEventPayload) {
|
||||
const item = event.item;
|
||||
if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) };
|
||||
if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) };
|
||||
if (event.type === "plan.updated") {
|
||||
const tasks = planTasks(event.plan);
|
||||
return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length} 项` };
|
||||
}
|
||||
if (event.type === "turn.completed" && event.status === "failed") return { title: "处理失败", text: agentErrorView(event.error?.message).text };
|
||||
if (event.type === "turn.completed") return { title: event.status === "interrupted" ? "处理已停止" : "处理完成", text: turnSummary(event) };
|
||||
if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: agentErrorView(event.message || event.error?.message).text };
|
||||
if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) };
|
||||
if (event.type === "item.completed" && isMcpToolItem(item)) return { title: item.error ? "工具失败" : "工具完成", text: `${toolName(String(item?.tool || ""))}${item.error?.message ? ` · ${item.error.message}` : ""}` };
|
||||
if (event.type === "item.completed" && item?.type === "agent_message") return { title: "收到回复", text: compactText(stringText(item.text)) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function turnSummary(event: AgentEventPayload) {
|
||||
return event.duration_ms ? `${(event.duration_ms / 1000).toFixed(1)} 秒` : "完成";
|
||||
}
|
||||
|
||||
export function agentErrorView(value: unknown) {
|
||||
const text = normalizeText(value);
|
||||
if (/selected model is at capacity/i.test(text)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" };
|
||||
return { title: "任务失败", text: text || "Codex 未能完成本次任务,请稍后重试。" };
|
||||
}
|
||||
|
||||
export function eventUsage(event: AgentEventPayload): AgentTokenUsage {
|
||||
return {
|
||||
input: numberField(event.usage, "input_tokens"),
|
||||
cached: numberField(event.usage, "cached_input_tokens"),
|
||||
output: numberField(event.usage, "output_tokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function shortId(value?: string) {
|
||||
return value ? value.slice(0, 8) : "";
|
||||
}
|
||||
|
||||
export function compactText(value: string, maxLength = 120) {
|
||||
const text = value.replace(/\s+/g, " ").trim();
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
}
|
||||
|
||||
export function isConnectionErrorMessage(item: AgentChatItem) {
|
||||
return item.role === "error" && /连接失败|无法连接本地 Agent|本地 Agent 连接失败/.test(item.text);
|
||||
}
|
||||
|
||||
export function toolName(name: string) {
|
||||
if (name === "imagegen" || name.endsWith("__imagegen")) return "生成图片";
|
||||
if (name === "view_image" || name.endsWith("__view_image")) return "查看图片";
|
||||
if (name === "exec" || name === "exec_command" || name.endsWith("__exec_command")) return "执行命令";
|
||||
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
|
||||
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_node") return "创建节点";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_config_node") return "创建生成配置";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
if (name === "site_navigate") return "打开页面";
|
||||
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
|
||||
return name ? `调用工具:${name}` : "工具操作";
|
||||
}
|
||||
|
||||
export function siteToolSummary(name: string, result: unknown) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||
if (name === "assets_add") return "已加入我的资产";
|
||||
if (name === "generation_get_status") {
|
||||
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
|
||||
return `共 ${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
|
||||
}
|
||||
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
||||
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||
return "已完成";
|
||||
}
|
||||
|
||||
export function isReadTool(name: string) {
|
||||
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
|
||||
}
|
||||
|
||||
function isMcpToolItem(item?: AgentEventItem) {
|
||||
return item?.type === "mcp_tool_call";
|
||||
}
|
||||
|
||||
export function toolDetail(item: AgentEventItem | undefined, status: string): AgentUserDetail {
|
||||
const name = String(item?.tool || "");
|
||||
return { kind: "tool", status, rows: toolInputRows(name, item?.arguments), ...(item?.error?.message ? { output: item.error.message } : {}) };
|
||||
}
|
||||
|
||||
export function toolCallDetail(name: string, input: unknown, status: string, error = ""): AgentUserDetail {
|
||||
return { kind: "tool", status, rows: toolInputRows(name, input), ...(error ? { output: error } : {}) };
|
||||
}
|
||||
|
||||
function toolInputRows(name: string, input: unknown) {
|
||||
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "nodes")) ? (objectField(input, "nodes") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toolSummary(item?: AgentEventItem) {
|
||||
const result = parseToolResult(item?.result);
|
||||
const nodeField = objectField(result, "nodes");
|
||||
const connectionField = objectField(result, "connections");
|
||||
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
||||
const connections = Array.isArray(connectionField) ? connectionField : [];
|
||||
if (item?.tool === "canvas_get_state" && (Array.isArray(nodeField) || Array.isArray(connectionField))) return canvasContentSummary(nodes, connections.length);
|
||||
if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`;
|
||||
return "工具调用完成";
|
||||
}
|
||||
|
||||
function canvasContentSummary(nodes: unknown[], connections: number) {
|
||||
const counts = nodes.reduce<Record<string, number>>((result, node) => {
|
||||
const type = stringText(objectField(node, "type")) || "other";
|
||||
result[type] = (result[type] || 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
const known = new Set(["text", "image", "config", "video", "audio", "group"]);
|
||||
const other = Object.entries(counts).reduce((total, [type, count]) => total + (known.has(type) ? 0 : count), 0);
|
||||
const parts = [
|
||||
counts.text ? `${counts.text} 个文本` : "",
|
||||
counts.image ? `${counts.image} 张图片` : "",
|
||||
counts.config ? `${counts.config} 个配置` : "",
|
||||
counts.video ? `${counts.video} 个视频` : "",
|
||||
counts.audio ? `${counts.audio} 个音频` : "",
|
||||
counts.group ? `${counts.group} 个分组` : "",
|
||||
other ? `${other} 个其他节点` : "",
|
||||
connections ? `${connections} 条连线` : "",
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join("、") : "当前画布为空";
|
||||
}
|
||||
|
||||
export function toolAction(name: string) {
|
||||
const label = toolName(name);
|
||||
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label;
|
||||
return `执行${label}`;
|
||||
}
|
||||
|
||||
export function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
return path;
|
||||
}
|
||||
|
||||
export function workingActivity(item?: AgentChatItem) {
|
||||
const status = String(objectField(item?.detail, "status") || "");
|
||||
const output = stringText(objectField(item?.detail, "output"));
|
||||
const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`;
|
||||
if (item?.role !== "tool") return { key, text: "正在思考..." };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
|
||||
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
|
||||
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` };
|
||||
}
|
||||
|
||||
export function currentPlanMessage(messages: AgentChatItem[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (isPlanMessage(message)) return message;
|
||||
if (message.role === "user") return;
|
||||
}
|
||||
}
|
||||
|
||||
export function latestPlanMessage(messages: AgentChatItem[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (isPlanMessage(messages[index])) return messages[index];
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlanMessage(message: AgentChatItem) {
|
||||
return message.role === "tool" && objectField(message.detail, "kind") === "todo";
|
||||
}
|
||||
|
||||
function parseToolResult(result: unknown) {
|
||||
const content = objectField(result, "content");
|
||||
const text = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => objectField(item, "text"))
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join("\n")
|
||||
: "";
|
||||
try {
|
||||
return text ? JSON.parse(text) : result;
|
||||
} catch {
|
||||
return text || result;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value == null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function stringText(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function objectField(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
function numberField(value: unknown, key: string) {
|
||||
const field = objectField(value, key);
|
||||
return typeof field === "number" ? field : 0;
|
||||
}
|
||||
|
||||
export function mergeAgentText(prev: string, next: string) {
|
||||
if (!next || prev === next || prev.endsWith(next)) return prev;
|
||||
if (next.startsWith(prev)) return next;
|
||||
for (let size = Math.min(prev.length, next.length); size > 0; size--) {
|
||||
if (prev.endsWith(next.slice(0, size))) return `${prev}${next.slice(size)}`;
|
||||
}
|
||||
const half = Math.floor(prev.length / 2);
|
||||
if (prev.length > 12 && next.length > 12 && prev.slice(half) === next.slice(0, prev.length - half)) return prev;
|
||||
return `${prev}${next}`;
|
||||
}
|
||||
|
||||
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
|
||||
return text || (attachments.length ? "请处理上传的图片附件。" : "");
|
||||
}
|
||||
|
||||
export function attachmentPayloadBytes(attachments: AgentAttachment[]) {
|
||||
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
|
||||
}
|
||||
|
||||
export function isCanvasWriteTool(name: string) {
|
||||
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
|
||||
}
|
||||
|
||||
export function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
||||
return messages
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || `history-${index}`,
|
||||
text: normalizeText(item.text),
|
||||
streamId: undefined,
|
||||
}))
|
||||
.filter((item) => item.text);
|
||||
}
|
||||
|
||||
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||
const currentUsers = currentMessages.filter((item) => item.role === "user" && item.attachments?.length).reverse();
|
||||
return [...messages]
|
||||
.reverse()
|
||||
.map((item) => {
|
||||
if (item.role !== "user") return item;
|
||||
const index = currentUsers.findIndex((current) => current.text === item.text || current.historyText === item.text);
|
||||
if (index < 0) return item;
|
||||
const current = currentUsers.splice(index, 1)[0];
|
||||
return { ...item, id: current.id, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
export function mergeHistoryMessages(historyMessages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||
if (!currentMessages.length) return historyMessages;
|
||||
const remaining = [...historyMessages];
|
||||
const messages = currentMessages.map((current) => {
|
||||
const index = remaining.findIndex((history) => history.id === current.id || ((current.role === "user" || current.role === "assistant") && history.role === current.role && history.text === current.text));
|
||||
if (index < 0) return current;
|
||||
const history = remaining.splice(index, 1)[0];
|
||||
return { ...current, ...history, id: current.id, attachments: current.attachments || history.attachments };
|
||||
});
|
||||
return [...messages, ...remaining];
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Checkbox } from "antd";
|
||||
import { FolderOpen, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
|
||||
export function AgentHistoryView({
|
||||
theme,
|
||||
threads,
|
||||
activeThreadId,
|
||||
workspacePath,
|
||||
loading,
|
||||
busy,
|
||||
connected,
|
||||
onRefresh,
|
||||
onNewThread,
|
||||
onResumeThread,
|
||||
onDeleteThreads,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
workspacePath: string;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
connected: boolean;
|
||||
onRefresh: () => void;
|
||||
onNewThread: () => void;
|
||||
onResumeThread: (threadId: string) => 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">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0">工作空间</span>
|
||||
<span className="min-w-0 truncate" title={workspacePath}>
|
||||
{workspacePath || "默认画布目录"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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>
|
||||
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}>
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{threads.map((thread) => {
|
||||
const active = thread.id === activeThreadId;
|
||||
return (
|
||||
<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 ? (
|
||||
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!threads.length ? (
|
||||
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
{connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadTime(value?: number) {
|
||||
if (!value) return "";
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Segmented } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentEventLog } from "@/stores/use-agent-store";
|
||||
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
||||
|
||||
export function AgentLogView({
|
||||
logs,
|
||||
theme,
|
||||
context,
|
||||
onClear,
|
||||
onCopied,
|
||||
onCopyBlocked,
|
||||
}: {
|
||||
logs: AgentEventLog[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
context: AgentLogContext;
|
||||
onClear: () => void;
|
||||
onCopied: (text: string) => void;
|
||||
onCopyBlocked: (text: string) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"text" | "json">("text");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||
const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`));
|
||||
const copy = async (value = content, tip = "日志已复制") => {
|
||||
if (await copyToClipboard(value)) {
|
||||
onCopied(tip);
|
||||
return;
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
onCopyBlocked("已选中日志,请手动复制");
|
||||
};
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="flex min-h-full flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as "text" | "json")}
|
||||
options={[
|
||||
{ label: "排查日志", value: "text" },
|
||||
{ label: "原始 JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
{logs.length} 条
|
||||
</span>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
|
||||
复制
|
||||
</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
|
||||
最近错误
|
||||
</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={content}
|
||||
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
export function AgentPanelTabs<T extends string>({ value, items, theme, leading, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; leading?: ReactNode; right?: ReactNode; onChange: (value: T) => void }) {
|
||||
return (
|
||||
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex h-12 items-center justify-between gap-3">
|
||||
{leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
|
||||
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
|
||||
{items.map((item) => (
|
||||
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-12 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
|
||||
{item.icon}
|
||||
{item.label}{item.count ? ` ${item.count}` : ""}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Bot, PanelRightClose } from "lucide-react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { LocalAgentPanel } from "./local-agent-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -17,11 +15,7 @@ export function AgentPanel() {
|
||||
const panelMounted = useAgentStore((state) => state.panelMounted);
|
||||
const panelOpen = useAgentStore((state) => state.panelOpen);
|
||||
const panelClosing = useAgentStore((state) => state.panelClosing);
|
||||
const confirmTools = useAgentStore((state) => state.confirmTools);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const closePanel = useAgentStore((state) => state.closePanel);
|
||||
|
||||
|
||||
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
@@ -54,33 +48,14 @@ export function AgentPanel() {
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l"
|
||||
data-canvas-shortcuts-ignore
|
||||
initial={{ x: 48 }}
|
||||
animate={{ x: panelClosing ? 28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
>
|
||||
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整右侧面板宽度" />
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-lg">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-5">Agent</div>
|
||||
<div className="truncate text-xs" style={{ color: theme.node.muted }}>全站助手</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
|
||||
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
|
||||
工具确认
|
||||
</label>
|
||||
<Tooltip title="收起对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<CanvasLocalAgentPanel embedded />
|
||||
<LocalAgentPanel embedded />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,996 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button, Tooltip } from "antd";
|
||||
import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Terminal } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { deleteAgentThreadMessages, readAgentUserMessages, saveAgentUserMessage } from "@/services/agent-chat-storage";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
|
||||
import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "./agent-api";
|
||||
import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat";
|
||||
import { AgentChatComposer } from "./agent-chat-composer";
|
||||
import { AgentConnectView } from "./agent-connect-view";
|
||||
import {
|
||||
activityDeltaFallback,
|
||||
activityDetail,
|
||||
activityKind,
|
||||
activityPlaceholder,
|
||||
agentAttachmentToChatAttachment,
|
||||
agentErrorView,
|
||||
attachmentPayloadBytes,
|
||||
compactText,
|
||||
eventUsage,
|
||||
formatAgentActivity,
|
||||
formatAgentEvent,
|
||||
formatAgentEventLog,
|
||||
formatAgentPlan,
|
||||
formatBytes,
|
||||
isCanvasWriteTool,
|
||||
isConnectionErrorMessage,
|
||||
isCurrentThreadEvent,
|
||||
mergeHistoryAttachments,
|
||||
mergeHistoryMessages,
|
||||
mergeAgentText,
|
||||
normalizeHistoryMessages,
|
||||
normalizeText,
|
||||
parseEventData,
|
||||
promptWithAttachments,
|
||||
routeName,
|
||||
siteToolSummary,
|
||||
stringText,
|
||||
toolCallDetail,
|
||||
toolName,
|
||||
turnPlanStatus,
|
||||
type AgentEventItem,
|
||||
type AgentEventPayload,
|
||||
} from "./agent-event-formatters";
|
||||
import { AgentHistoryView } from "./agent-history-view";
|
||||
import { AgentLogView } from "./agent-log-view";
|
||||
import { AgentPanelTabs } from "./agent-panel-tabs";
|
||||
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
|
||||
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
|
||||
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
|
||||
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean; draftThread?: boolean };
|
||||
type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem };
|
||||
|
||||
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
useShallow((state) => ({
|
||||
width: state.width,
|
||||
url: state.url,
|
||||
token: state.token,
|
||||
connected: state.connected,
|
||||
enabled: state.enabled,
|
||||
prompt: state.prompt,
|
||||
attachments: state.attachments,
|
||||
sending: state.sending,
|
||||
waiting: state.waiting,
|
||||
tokenUsage: state.tokenUsage,
|
||||
eventLogs: state.eventLogs,
|
||||
threads: state.threads,
|
||||
activeThreadId: state.activeThreadId,
|
||||
workspacePath: state.workspacePath,
|
||||
loadingThreads: state.loadingThreads,
|
||||
activeTab: state.activeTab,
|
||||
confirmTools: state.confirmTools,
|
||||
permissionMode: state.permissionMode,
|
||||
activity: state.activity,
|
||||
connectError: state.connectError,
|
||||
pendingTool: state.pendingTool,
|
||||
pendingApprovals: state.pendingApprovals,
|
||||
})),
|
||||
);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const closePanel = useAgentStore((state) => state.closePanel);
|
||||
const pushMessage = useAgentStore((state) => state.addMessage);
|
||||
const pushEventLog = useAgentStore((state) => state.addEventLog);
|
||||
const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
|
||||
const messageCount = useAgentStore((state) => state.messages.length);
|
||||
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
|
||||
const confirmToolsRef = useRef(confirmTools);
|
||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||
const autoConnectRef = useRef(false);
|
||||
const connectedRef = useRef(false);
|
||||
const errorLoggedRef = useRef(false);
|
||||
const attachmentUrlsRef = useRef(new Set<string>());
|
||||
const clientIdRef = useRef(randomId());
|
||||
const loadThreadsSequenceRef = useRef(0);
|
||||
const resetThreadRef = useRef<Promise<unknown> | null>(null);
|
||||
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
|
||||
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
|
||||
const loadThreads = useCallback(async (skipHistory = false) => {
|
||||
if (!connectedRef.current && !useAgentStore.getState().connected) return;
|
||||
const sequence = ++loadThreadsSequenceRef.current;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const currentThreadId = useAgentStore.getState().activeThreadId;
|
||||
const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null;
|
||||
const storedMessagesRequest = currentThreadId && !skipHistory ? readAgentUserMessages(currentThreadId) : null;
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
|
||||
let nextMessages: AgentChatItem[] = [];
|
||||
if (currentThreadId && !skipHistory) {
|
||||
let thread = await currentThreadRequest;
|
||||
thread ||= await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`);
|
||||
const storedMessages = (await storedMessagesRequest) || [];
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
nextMessages = mergeHistoryMessages(mergeHistoryAttachments(normalizeHistoryMessages(thread.messages || []), [...storedMessages, ...currentMessages]), currentMessages);
|
||||
}
|
||||
if (sequence !== loadThreadsSequenceRef.current) return;
|
||||
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", ...(skipHistory ? {} : { messages: nextMessages }) });
|
||||
} catch (error) {
|
||||
addEventLog("读取历史失败", error);
|
||||
} finally {
|
||||
if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
|
||||
}
|
||||
}, [endpoint, setAgentState, token]);
|
||||
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = useAgentStore.subscribe((state) => {
|
||||
if (state.canvasContext === canvasContextRef.current) return;
|
||||
canvasContextRef.current = state.canvasContext;
|
||||
if (!useAgentStore.getState().connected) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [endpoint, token]);
|
||||
useEffect(() => {
|
||||
confirmToolsRef.current = confirmTools;
|
||||
}, [confirmTools]);
|
||||
useEffect(() => {
|
||||
pendingToolRef.current = pendingTool;
|
||||
}, [pendingTool]);
|
||||
useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !token.trim()) return;
|
||||
localStorage.setItem("canvas-agent-url", endpoint);
|
||||
localStorage.setItem("canvas-agent-token", token);
|
||||
const clientId = clientIdRef.current;
|
||||
let eventQueue = Promise.resolve();
|
||||
const enqueueEvent = (task: () => void | Promise<void>) => {
|
||||
eventQueue = eventQueue.then(task).catch((error) => addEventLog("同步会话失败", error));
|
||||
};
|
||||
const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
|
||||
source.addEventListener("hello", (event) => {
|
||||
const busy = Boolean(parseEventData<AgentHelloEvent>(event)?.codex?.busy);
|
||||
errorLoggedRef.current = false;
|
||||
connectedRef.current = true;
|
||||
setAgentState({ connected: true, activity: busy ? "Codex 正在运行" : "已连接", waiting: busy, sending: false, connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
|
||||
if (!headless) message.success("本地 Agent 已连接");
|
||||
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
|
||||
});
|
||||
source.addEventListener("codex_state", (event) => {
|
||||
const data = parseEventData<AgentCodexState>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(async () => {
|
||||
const busy = Boolean(data.busy);
|
||||
setAgentState({ activity: busy ? "Codex 正在运行" : "完成", waiting: busy, ...(busy ? {} : { sending: false }) });
|
||||
if (!busy) await loadThreads();
|
||||
});
|
||||
});
|
||||
source.addEventListener("tool_call", (event) => {
|
||||
const data = parseEventData<AgentPendingToolCall>(event);
|
||||
if (data) void handleToolCall(endpoint, token, data);
|
||||
});
|
||||
source.addEventListener("codex_approval", (event) => {
|
||||
const data = parseEventData<AgentPendingApproval>(event);
|
||||
if (!data || !isCurrentThreadEvent(data)) return;
|
||||
setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: "等待权限确认" });
|
||||
addEventLog("等待权限确认", data.reason || data.method, data);
|
||||
});
|
||||
source.addEventListener("codex_approval_resolved", (event) => {
|
||||
const data = parseEventData<{ requestId?: string }>(event);
|
||||
if (data?.requestId) setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId) });
|
||||
});
|
||||
source.addEventListener("agent_event", (event) => {
|
||||
const data = parseEventData<AgentEventPayload>(event);
|
||||
if (data) enqueueEvent(() => {
|
||||
if (isCurrentThreadEvent(data)) handleAgentEvent(data);
|
||||
});
|
||||
});
|
||||
source.addEventListener("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(async () => {
|
||||
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
|
||||
const current = useAgentStore.getState();
|
||||
const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.activeThreadId === nextThreadId);
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null, pendingApprovals: [] });
|
||||
if (!data.draftThread) await loadThreads(Boolean(data.emptyThread));
|
||||
});
|
||||
});
|
||||
source.addEventListener("chat_message", (event) => {
|
||||
const data = parseEventData<AgentChatEvent>(event);
|
||||
if (!data?.message) return;
|
||||
enqueueEvent(() => {
|
||||
if (!isCurrentThreadEvent(data)) return;
|
||||
addMessage(data.message!);
|
||||
});
|
||||
});
|
||||
source.addEventListener("agent_log", (event) => {
|
||||
const text = parseEventData<{ text?: unknown }>(event)?.text;
|
||||
addEventLog("日志", text, text);
|
||||
});
|
||||
source.addEventListener("agent_error", (event) => {
|
||||
const data = parseEventData<AgentEventPayload>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(() => {
|
||||
if (!isCurrentThreadEvent(data)) return;
|
||||
showAgentError(data.message, data.turn_id);
|
||||
});
|
||||
});
|
||||
source.onerror = () => {
|
||||
const wasConnected = connectedRef.current;
|
||||
const silent = useAgentStore.getState().silentConnect && !wasConnected;
|
||||
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
|
||||
if (!errorLoggedRef.current || wasConnected) {
|
||||
addEventLog(wasConnected ? "连接断开" : "连接失败", text);
|
||||
if (!headless && !silent) message.error(text);
|
||||
}
|
||||
errorLoggedRef.current = true;
|
||||
connectedRef.current = false;
|
||||
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false });
|
||||
if (!wasConnected) {
|
||||
source.close();
|
||||
setAgentState({ enabled: false });
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
source.close();
|
||||
connectedRef.current = false;
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
};
|
||||
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) void loadThreads();
|
||||
}, [connected, loadThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
|
||||
const activateVisible = () => {
|
||||
if (document.visibilityState === "visible") activate();
|
||||
};
|
||||
window.addEventListener("focus", activate);
|
||||
document.addEventListener("visibilitychange", activateVisible);
|
||||
return () => {
|
||||
window.removeEventListener("focus", activate);
|
||||
document.removeEventListener("visibilitychange", activateVisible);
|
||||
};
|
||||
}, [connected, endpoint, token]);
|
||||
const sendPrompt = async () => {
|
||||
const text = prompt.trim();
|
||||
const files = attachments;
|
||||
const requestPrompt = promptWithAttachments(text, files);
|
||||
if (!connected || !requestPrompt || sending || waiting) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
return;
|
||||
}
|
||||
const messageId = createId();
|
||||
const userText = text || `发送了 ${files.length} 张图片`;
|
||||
setAgentState({ prompt: "", attachments: [], activity: "发送中", sending: true });
|
||||
addMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files });
|
||||
let threadId = useAgentStore.getState().activeThreadId;
|
||||
try {
|
||||
await resetThreadRef.current;
|
||||
if (!threadId) {
|
||||
const created = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) });
|
||||
threadId = created.thread?.id || created.workspace?.activeThreadId || "";
|
||||
if (!threadId) throw new Error("新建对话失败");
|
||||
setAgentState({ activeThreadId: threadId, tokenUsage: null });
|
||||
}
|
||||
if (files.length) void saveAgentUserMessage(threadId, { id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }).catch(() => undefined);
|
||||
addEventLog("发送任务", `${compactText(text) || "仅附件"}${files.length ? ` · 附件 ${files.length}` : ""}`);
|
||||
const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: requestPrompt,
|
||||
messageText: userText,
|
||||
messageId,
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
permissionMode,
|
||||
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
|
||||
}),
|
||||
});
|
||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||
files.forEach((item) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
attachmentUrlsRef.current.delete(item.url);
|
||||
});
|
||||
setAgentState({ sending: false, waiting: true, activity: "Codex 正在运行" });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : "发送失败";
|
||||
const busy = text.includes("Codex 正在运行");
|
||||
const state = useAgentStore.getState();
|
||||
setAgentState({
|
||||
activity: busy ? "Codex 正在运行" : "发送失败",
|
||||
...(state.prompt || state.attachments.length ? {} : { prompt, attachments: files }),
|
||||
});
|
||||
addMessage({ role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
|
||||
addEventLog("发送失败", error);
|
||||
} finally {
|
||||
setAgentState({ sending: false });
|
||||
}
|
||||
};
|
||||
|
||||
const stopTurn = async () => {
|
||||
if (!connected || (!sending && !waiting)) return;
|
||||
setAgentState({ activity: "停止中" });
|
||||
try {
|
||||
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId: useAgentStore.getState().activeThreadId || undefined }) });
|
||||
addEventLog("停止任务", "已发送停止请求");
|
||||
} catch {
|
||||
setAgentState({ activity: "停止失败" });
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachments = async (files: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||
const prev = useAgentStore.getState().attachments;
|
||||
try {
|
||||
const next = await Promise.all(
|
||||
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||
const dataUrl = await readDataUrl(file);
|
||||
const meta = await readImageMeta(dataUrl);
|
||||
const url = URL.createObjectURL(file);
|
||||
attachmentUrlsRef.current.add(url);
|
||||
return { id: createId(), name: file.name, type: file.type, size: file.size, width: meta.width, height: meta.height, url, dataUrl };
|
||||
}),
|
||||
);
|
||||
const merged = [...prev, ...next];
|
||||
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
next.forEach((item) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
attachmentUrlsRef.current.delete(item.url);
|
||||
});
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件最多约 30MB。" });
|
||||
return;
|
||||
}
|
||||
if (next.length) setAgentState({ attachments: merged });
|
||||
} catch (error) {
|
||||
addMessage({ role: "error", title: "图片读取失败", text: error instanceof Error ? error.message : "图片读取失败" });
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const removed = attachments.find((item) => item.id === id);
|
||||
if (removed) {
|
||||
URL.revokeObjectURL(removed.url);
|
||||
attachmentUrlsRef.current.delete(removed.url);
|
||||
}
|
||||
setAgentState({ attachments: attachments.filter((item) => item.id !== id) });
|
||||
};
|
||||
|
||||
const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
|
||||
if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) {
|
||||
if (pendingToolRef.current) {
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
|
||||
return;
|
||||
}
|
||||
pendingToolRef.current = payload;
|
||||
setAgentState({ pendingTool: payload });
|
||||
addEventLog("等待确认", payload, payload);
|
||||
return;
|
||||
}
|
||||
await runToolCall(endpoint, token, payload);
|
||||
};
|
||||
|
||||
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
|
||||
if (isSiteTool(payload.name)) {
|
||||
try {
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: siteToolSummary(payload.name, result), detail: toolCallDetail(payload.name, payload.input, "completed") });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "工具执行失败";
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
let result: unknown;
|
||||
let appliedOps = input.ops || [];
|
||||
if (payload.name === "site_navigate") {
|
||||
const path = input.path || "/";
|
||||
navigate(path);
|
||||
result = { ok: true, path };
|
||||
} else if (payload.name === "canvas_apply_ops") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = context.applyOps(appliedOps);
|
||||
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else if (payload.name === "canvas_create_attachment_nodes") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes);
|
||||
result = context.applyOps(appliedOps);
|
||||
await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else {
|
||||
const snapshot = canvasContextRef.current?.snapshot;
|
||||
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = snapshot;
|
||||
}
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({
|
||||
role: "tool",
|
||||
title: toolName(payload.name),
|
||||
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "已完成画布操作" : payload.name === "site_navigate" ? `已打开${routeName(input.path || "/")}` : "已完成",
|
||||
detail: toolCallDetail(payload.name, input, "completed"),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
};
|
||||
|
||||
const rejectPendingTool = async () => {
|
||||
if (!pendingTool) return;
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
|
||||
addMessage({ role: "tool", title: toolName(pendingTool.name), text: "用户已取消本次操作", detail: toolCallDetail(pendingTool.name, pendingTool.input, "declined") });
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ pendingTool: null });
|
||||
};
|
||||
|
||||
const approvePendingTool = async () => {
|
||||
if (!pendingTool) return;
|
||||
const tool = pendingTool;
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ pendingTool: null });
|
||||
await runToolCall(endpoint, token, tool);
|
||||
};
|
||||
|
||||
const decideApproval = async (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => {
|
||||
setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== approval.requestId), activity: decision === "decline" ? "已拒绝权限请求" : "Codex 正在运行" });
|
||||
try {
|
||||
await postCodexApproval(endpoint, token, approval.requestId, decision);
|
||||
addEventLog(decision === "decline" ? "已拒绝权限" : "已批准权限", approval.reason || approval.method, approval);
|
||||
} catch (error) {
|
||||
addEventLog("权限审批失败", error);
|
||||
message.error(error instanceof Error ? error.message : "权限审批失败");
|
||||
}
|
||||
};
|
||||
|
||||
const changePermissionMode = (nextMode: AgentPermissionMode) => {
|
||||
const apply = () => {
|
||||
localStorage.setItem("canvas-agent-permission-mode", nextMode);
|
||||
setAgentState({ permissionMode: nextMode });
|
||||
};
|
||||
if (nextMode !== "full") return apply();
|
||||
modal.confirm({
|
||||
title: "启用完全访问权限",
|
||||
content: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。",
|
||||
okText: "启用完全访问",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: apply,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
if (enabled) {
|
||||
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
|
||||
return;
|
||||
}
|
||||
const urlToken = searchParams.get("agentToken") || "";
|
||||
const urlEndpoint = searchParams.get("agentUrl") || "";
|
||||
const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL);
|
||||
const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, "");
|
||||
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
|
||||
if (!nextEndpoint) {
|
||||
const text = "请填写本地 Agent 地址";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!nextToken) {
|
||||
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(nextEndpoint);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
|
||||
} catch {
|
||||
const text = "本地 Agent 地址格式不正确";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
errorLoggedRef.current = false;
|
||||
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false });
|
||||
}, [confirmTools, setAgentState, urlAgentAutoConnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
|
||||
autoConnectRef.current = true;
|
||||
void toggleAgentConnection({ silent: true });
|
||||
}, [autoConnect, connected, enabled]);
|
||||
|
||||
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
setAgentState({
|
||||
messages: [],
|
||||
tokenUsage: null,
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
waiting: false,
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
...patch,
|
||||
});
|
||||
pendingToolRef.current = null;
|
||||
}
|
||||
|
||||
const startNewThread = () => {
|
||||
if (!connected || sending || waiting) return;
|
||||
setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, activeTab: "chat", activity: "新对话", pendingTool: null, pendingApprovals: [] });
|
||||
pendingToolRef.current = null;
|
||||
const request = fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST" }).catch((error) => {
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
throw error;
|
||||
});
|
||||
resetThreadRef.current = request;
|
||||
void request.finally(() => {
|
||||
if (resetThreadRef.current === request) resetThreadRef.current = null;
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const resumeThread = async (threadId: string) => {
|
||||
if (!connected || !threadId || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const current = useAgentStore.getState();
|
||||
const [data, storedMessages] = await Promise.all([
|
||||
fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }),
|
||||
readAgentUserMessages(threadId),
|
||||
]);
|
||||
const localMessages = current.activeThreadId === threadId ? current.messages : [];
|
||||
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: mergeHistoryAttachments(normalizeHistoryMessages(data.messages || []), [...storedMessages, ...localMessages]), tokenUsage: null, activeTab: "chat", activity: "已恢复会话" });
|
||||
} catch (error) {
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
} finally {
|
||||
setAgentState({ loadingThreads: false });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteThreads = async (threadIds: string[]) => {
|
||||
if (!connected || !threadIds.length || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
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({}) })));
|
||||
void deleteAgentThreadMessages(threadIds).catch(() => undefined);
|
||||
const current = useAgentStore.getState();
|
||||
const deleted = new Set(threadIds);
|
||||
setAgentState({
|
||||
threads: current.threads.filter((thread) => !deleted.has(thread.id)),
|
||||
activeThreadId: deleted.has(current.activeThreadId) ? "" : current.activeThreadId,
|
||||
messages: deleted.has(current.activeThreadId) ? [] : current.messages,
|
||||
tokenUsage: deleted.has(current.activeThreadId) ? null : current.tokenUsage,
|
||||
});
|
||||
message.success(`已删除 ${threadIds.length} 条记录`);
|
||||
} catch (error) {
|
||||
addEventLog("删除对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "删除对话失败");
|
||||
} finally {
|
||||
setAgentState({ loadingThreads: false });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteThreads = (threadIds: string[]) => {
|
||||
modal.confirm({
|
||||
title: `删除 ${threadIds.length} 条对话记录`,
|
||||
content: "删除后无法恢复,确定继续吗?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteThreads(threadIds),
|
||||
});
|
||||
};
|
||||
|
||||
const addMessage = (item: Omit<AgentChatItem, "id"> & { id?: string }) => {
|
||||
const text = normalizeText(item.text);
|
||||
if (!text && !item.attachments?.length) return;
|
||||
const next = { ...item, id: item.id || `${Date.now()}-${Math.random()}`, text } as AgentChatItem;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
if (currentMessages.some((message) => message.id === next.id)) return;
|
||||
if (next.streamId) {
|
||||
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
||||
if (index >= 0) {
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const last = currentMessages.at(-1);
|
||||
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
|
||||
const merged = mergeAgentText(last.text, next.text);
|
||||
if (merged === last.text) return;
|
||||
setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
|
||||
return;
|
||||
}
|
||||
pushMessage(next);
|
||||
};
|
||||
|
||||
const addEventLog = (title: string, text: unknown, raw?: unknown) => {
|
||||
const value = normalizeText(text) || title;
|
||||
const last = useAgentStore.getState().eventLogs.at(-1);
|
||||
if (last?.title === title && last.text === value) return;
|
||||
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: value, raw });
|
||||
};
|
||||
|
||||
const upsertActivityMessage = (item: AgentChatItem) => {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
pushMessage(item);
|
||||
return;
|
||||
}
|
||||
setAgentState({
|
||||
messages: currentMessages.map((message, i) => {
|
||||
if (i !== index) return message;
|
||||
const preserveReasoning = item.title === "思考摘要" && item.text === "已完成分析" && Boolean(message.text.trim()) && message.text !== activityPlaceholder("reasoning");
|
||||
return { ...message, ...item, ...(preserveReasoning ? { text: message.text } : {}) };
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const appendActivityDelta = (item: AgentEventItem) => {
|
||||
if (!item.id) return;
|
||||
const delta = stringText(item.delta);
|
||||
if (!delta) return;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
if (!delta.trim()) return;
|
||||
upsertActivityMessage(activityDeltaFallback(item, delta));
|
||||
return;
|
||||
}
|
||||
const current = currentMessages[index];
|
||||
if (item.type === "command_execution") {
|
||||
const detail = activityDetail(current.detail, "command", "inProgress");
|
||||
detail.output = `${detail.output || ""}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
return;
|
||||
}
|
||||
const placeholder = activityPlaceholder(item.type);
|
||||
if (!delta.trim() && current.text === placeholder) return;
|
||||
const text = current.text === placeholder ? delta : `${current.text}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) });
|
||||
};
|
||||
|
||||
const finishPlanActivity = (turnId: string, status?: string) => {
|
||||
const id = `plan-${turnId}`;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === id);
|
||||
if (index < 0) return;
|
||||
const current = currentMessages[index];
|
||||
const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, status));
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
};
|
||||
|
||||
const showAgentError = (value: unknown, turnId?: string) => {
|
||||
const error = agentErrorView(value);
|
||||
const item = { id: turnId ? `error-${turnId}` : createId(), role: "error" as const, title: error.title, text: error.text };
|
||||
upsertActivityMessage(item);
|
||||
setAgentState({ activity: "处理失败", waiting: false, sending: false });
|
||||
addEventLog("处理失败", error.text, value);
|
||||
};
|
||||
|
||||
const handleAgentEvent = async (event: AgentEventPayload) => {
|
||||
if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) });
|
||||
const log = formatAgentEventLog(event);
|
||||
if (log) addEventLog(log.title, log.text);
|
||||
if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id });
|
||||
if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) {
|
||||
appendStreamDelta(event.item.id, stringText(event.item.delta));
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.updated" && event.item) {
|
||||
appendActivityDelta(event.item);
|
||||
return;
|
||||
}
|
||||
if (event.type === "plan.updated" && event.turn_id) {
|
||||
const plan = formatAgentPlan(event);
|
||||
if (plan) upsertActivityMessage({ ...plan, id: `plan-${event.turn_id}` });
|
||||
return;
|
||||
}
|
||||
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, streamId: undefined } : message)) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.type === "item.completed" && event.item?.type === "image_generation" && event.item.id) {
|
||||
const generated = await importGeneratedImages(endpoint, token, event.item);
|
||||
if (generated.length) {
|
||||
const context = canvasContextRef.current;
|
||||
if (context) {
|
||||
const right = Math.max(0, ...context.snapshot.nodes.map((node) => node.position.x + node.width)) + 80;
|
||||
const ops = generated.map<CanvasAgentOp>((image, index) => {
|
||||
const size = fitNodeSize(image.upload.width, image.upload.height);
|
||||
return {
|
||||
type: "add_node",
|
||||
id: `image-${createId()}`,
|
||||
nodeType: "image",
|
||||
title: image.name,
|
||||
position: { x: right + index * 40, y: index * 40 },
|
||||
...size,
|
||||
metadata: imageMetadata(image.upload),
|
||||
};
|
||||
});
|
||||
const result = context.applyOps(ops);
|
||||
void postState(endpoint, token, clientIdRef.current, result);
|
||||
}
|
||||
addMessage({ id: `generated-${event.item.id}`, role: "assistant", text: context ? "已将生成图片添加到当前画布。" : "图片已生成。", attachments: generated.map((image) => image.attachment) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const activity = formatAgentActivity(event);
|
||||
if (activity && event.item?.id) {
|
||||
upsertActivityMessage({ ...activity, id: event.item.id });
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.completed") {
|
||||
if (event.turn_id) finishPlanActivity(event.turn_id, event.status);
|
||||
setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
|
||||
if (event.status === "failed") showAgentError(event.error?.message, event.turn_id);
|
||||
}
|
||||
const item = formatAgentEvent(event);
|
||||
if (item) addMessage(item);
|
||||
};
|
||||
|
||||
const appendStreamDelta = (streamId: string, delta: string) => {
|
||||
if (!delta) return;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.streamId === streamId);
|
||||
if (index < 0) {
|
||||
pushMessage({ id: `stream-${streamId}`, role: "assistant", title: "Codex", text: delta, streamId });
|
||||
return;
|
||||
}
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: `${message.text}${delta}` } : message)) });
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<AgentPanelTabs
|
||||
value={activeTab}
|
||||
theme={theme}
|
||||
leading={
|
||||
<div className="flex items-center gap-2 pr-1">
|
||||
<span className="grid size-8 place-items-center">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="text-base font-semibold leading-5">Agent</div>
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
|
||||
{ value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> },
|
||||
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
|
||||
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
|
||||
]}
|
||||
onChange={(activeTab) => {
|
||||
setAgentState({ activeTab });
|
||||
if (activeTab === "history") void loadThreads();
|
||||
}}
|
||||
right={
|
||||
<>
|
||||
<Button size="small" type="text" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
新对话
|
||||
</Button>
|
||||
<Tooltip title="收起对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{activeTab === "setup" ? (
|
||||
<AgentConnectView
|
||||
theme={theme}
|
||||
url={url}
|
||||
token={token}
|
||||
enabled={enabled}
|
||||
connected={connected}
|
||||
activity={activity}
|
||||
connectError={connectError}
|
||||
onUrlChange={(url) => setAgentState({ url, connectError: "" })}
|
||||
onTokenChange={(token) => setAgentState({ token, connectError: "" })}
|
||||
onToggleEnabled={toggleAgentConnection}
|
||||
/>
|
||||
) : activeTab === "history" ? (
|
||||
<AgentHistoryView
|
||||
theme={theme}
|
||||
threads={threads}
|
||||
activeThreadId={activeThreadId}
|
||||
workspacePath={workspacePath}
|
||||
loading={loadingThreads}
|
||||
busy={sending || waiting}
|
||||
connected={connected}
|
||||
onRefresh={() => void loadThreads()}
|
||||
onNewThread={() => void startNewThread()}
|
||||
onResumeThread={(threadId) => void resumeThread(threadId)}
|
||||
onDeleteThreads={confirmDeleteThreads}
|
||||
/>
|
||||
) : activeTab === "log" ? (
|
||||
<AgentLogView
|
||||
logs={eventLogs}
|
||||
theme={theme}
|
||||
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messageCount, pendingTool: pendingTool?.name }}
|
||||
onClear={clearEventLogs}
|
||||
onCopied={(text) => message.success(text)}
|
||||
onCopyBlocked={(text) => message.warning(text)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AgentChatTimeline theme={theme} pendingTool={pendingTool} pendingApprovals={pendingApprovals} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} onApprovalDecision={decideApproval} />
|
||||
<AgentTaskProgress theme={theme} busy={sending || waiting} />
|
||||
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected}
|
||||
sending={sending || waiting}
|
||||
placeholder="询问 Codex,或让它操作网站/画布"
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
onStop={stopTurn}
|
||||
onAddFiles={addAttachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
confirmTools={confirmTools}
|
||||
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
|
||||
permissionMode={permissionMode}
|
||||
onPermissionModeChange={changePermissionMode}
|
||||
left={
|
||||
attachments.length ? (
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (headless) return null;
|
||||
return embedded ? content : null;
|
||||
}
|
||||
|
||||
async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> {
|
||||
const nodes = Array.isArray(value) ? value : [];
|
||||
if (!nodes.length) throw new Error("没有可添加的图片附件");
|
||||
return await Promise.all(
|
||||
nodes.map(async (value) => {
|
||||
const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown };
|
||||
const id = String(item.id || "");
|
||||
const attachmentId = String(item.attachmentId || "");
|
||||
if (!id || !attachmentId) throw new Error("图片附件节点参数无效");
|
||||
const res = await fetch(`${endpoint}/agent/attachments/${encodeURIComponent(attachmentId)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(body?.error || "读取图片附件失败");
|
||||
}
|
||||
const image = await uploadImage(await res.blob());
|
||||
const size = fitNodeSize(image.width, image.height);
|
||||
const position = item.position && typeof item.position === "object" ? (item.position as { x?: unknown; y?: unknown }) : {};
|
||||
return {
|
||||
type: "add_node" as const,
|
||||
id,
|
||||
nodeType: "image" as const,
|
||||
title: String(item.title || "参考图"),
|
||||
position: { x: Number(position.x) || 0, y: Number(position.y) || 0 },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: imageMetadata(image),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return randomId();
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
async function importGeneratedImages(endpoint: string, token: string, item: AgentEventItem) {
|
||||
const sources = Array.from(generatedImageSources(item));
|
||||
return await Promise.all(
|
||||
sources.map(async (source, index) => {
|
||||
const response = source.startsWith("data:image/")
|
||||
? await fetch(source)
|
||||
: await fetch(`${endpoint}/agent/local-image?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: source }) });
|
||||
if (!response.ok) throw new Error("读取 Codex 生成图片失败");
|
||||
const blob = await response.blob();
|
||||
const upload = await uploadImage(blob);
|
||||
const dataUrl = await readDataUrl(blob);
|
||||
const name = source.startsWith("/") ? source.split("/").at(-1) || `生成图片 ${index + 1}` : `生成图片 ${index + 1}`;
|
||||
return { upload, name, attachment: { id: createId(), name, type: blob.type || upload.mimeType, size: blob.size, width: upload.width, height: upload.height, url: upload.url, dataUrl } };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function generatedImageSources(value: unknown, result = new Set<string>()) {
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("data:image/") || (/^\/.+\.(?:avif|gif|jpe?g|png|webp)$/i.test(value) && !value.includes("\n"))) result.add(value);
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(value)) value.forEach((item) => generatedImageSources(item, result));
|
||||
else if (value && typeof value === "object") Object.values(value).forEach((item) => generatedImageSources(item, result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function readDataUrl(file: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type CanvasAgentChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system" | "tool" | "error";
|
||||
title?: string;
|
||||
text: string;
|
||||
meta?: string;
|
||||
detail?: unknown;
|
||||
attachments?: CanvasAgentChatAttachment[];
|
||||
/** Present while the message is actively streaming; cleared on completion. */
|
||||
streamId?: string;
|
||||
};
|
||||
|
||||
const WORKING_TEXT = "working...";
|
||||
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center text-xs">
|
||||
<div className="max-w-[88%] px-3 py-1.5 text-center" style={{ color: theme.node.muted }}>
|
||||
{item.text}
|
||||
{item.meta ? <span className="ml-2 opacity-60">{item.meta}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.role === "tool") {
|
||||
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar theme={theme} />
|
||||
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
{!isUser ? <AgentAvatar theme={theme} /> : null}
|
||||
<div
|
||||
className={isUser ? "min-w-0 max-w-[82%] rounded-xl rounded-br-sm border px-3.5 py-2.5 text-left text-sm leading-6" : "min-w-0 flex-1 text-left text-sm leading-6"}
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
color,
|
||||
background: `color-mix(in srgb, ${theme.node.text} 7%, ${theme.toolbar.panel})`,
|
||||
borderColor: `color-mix(in srgb, ${theme.node.text} 14%, transparent)`,
|
||||
}
|
||||
: { color }
|
||||
}
|
||||
>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
) : (
|
||||
<Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
)}
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
|
||||
{item.meta ? <div className={`mt-1 text-[11px] opacity-45 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
||||
</div>
|
||||
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<details>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: "rgba(217,119,6,.22)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
等待确认
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
|
||||
</details>
|
||||
{onReject || onApprove ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
return (
|
||||
<details className="min-w-0 flex-1 rounded-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [length, setLength] = useState(1);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [setLength]);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 max-w-[82%]">
|
||||
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
|
||||
<span className="inline-block w-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
disabled,
|
||||
sending,
|
||||
placeholder,
|
||||
theme,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
left,
|
||||
}: {
|
||||
prompt: string;
|
||||
attachments?: CanvasAgentChatAttachment[];
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
placeholder: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
return (
|
||||
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
{onRemoveAttachment ? (
|
||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
void onAddFiles(images);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isPlainEnterKey(event)) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{onAddFiles ? (
|
||||
<>
|
||||
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
|
||||
void onAddFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{sending && onStop ? (
|
||||
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
|
||||
) : (
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
|
||||
return (
|
||||
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
|
||||
{items.map((item) => (
|
||||
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
|
||||
{item.icon}
|
||||
{item.label}{item.count ? ` ${item.count}` : ""}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
{JSON.stringify(detail, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<span className="grid size-8 shrink-0 place-items-center" role="img" aria-label="OpenAI">
|
||||
<span className="size-5 opacity-80" style={{ background: theme.node.text, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const avatarUrl = user?.avatarUrl?.trim();
|
||||
return (
|
||||
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
|
||||
{avatarUrl ? <img src={avatarUrl} alt="" className="size-full object-cover" referrerPolicy="no-referrer" /> : <UserRound className="size-4" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{attachments.map((item) => (
|
||||
<img key={item.id} src={item.url} alt={item.name} className="aspect-square w-full rounded-lg object-cover" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
|
||||
const lower = raw.toLowerCase();
|
||||
const tool = String(objectField(detail, "name") || objectField(detail, "tool") || "");
|
||||
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value == null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,7 +252,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
return (
|
||||
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
|
||||
{node ? (
|
||||
<div className="h-[56vh] min-h-[360px] text-sm">
|
||||
<div className="h-[56vh] min-h-[360px] select-text text-sm" data-canvas-shortcuts-ignore>
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||
<InfoRow label="ID" value={node.id} />
|
||||
|
||||
@@ -157,6 +157,7 @@ function InfiniteCanvasPage() {
|
||||
const agentPanelOpen = useAgentStore((state) => state.panelOpen);
|
||||
const toggleAgentPanel = useAgentStore((state) => state.togglePanel);
|
||||
const openAgentPanel = useAgentStore((state) => state.openPanel);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
|
||||
@@ -352,6 +353,11 @@ function InfiniteCanvasPage() {
|
||||
void restore();
|
||||
}, [hydrated, navigate, openProject, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, pendingTool: null });
|
||||
}, [projectId, projectLoaded, setAgentState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
|
||||
if (!searchParams.has("agentUrl")) openAgentPanel();
|
||||
@@ -1376,11 +1382,13 @@ function InfiniteCanvasPage() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom],[data-canvas-shortcuts-ignore]")) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (isModifierShortcut && key === "c" && window.getSelection()?.toString()) return;
|
||||
|
||||
if (isModifierShortcut && !event.altKey && key === "z") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redoCanvas();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store";
|
||||
|
||||
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string };
|
||||
|
||||
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
|
||||
const indexKey = (threadId: string) => `thread:${threadId}`;
|
||||
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
|
||||
|
||||
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||
if (!message.attachments?.length) return;
|
||||
const attachments = await Promise.all((message.attachments || []).map(createThumbnail));
|
||||
await store.setItem(messageKey(threadId, message.id), { ...message, attachments });
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
||||
}
|
||||
|
||||
export async function readAgentUserMessages(threadId: string) {
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
return (await Promise.all(ids.map((id) => store.getItem<StoredAgentUserMessage>(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item));
|
||||
}
|
||||
|
||||
export async function deleteAgentThreadMessages(threadIds: string[]) {
|
||||
await Promise.all(
|
||||
threadIds.map(async (threadId) => {
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id))));
|
||||
await store.removeItem(indexKey(threadId));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createThumbnail(attachment: AgentAttachment): Promise<AgentAttachment> {
|
||||
const dataUrl = Math.max(attachment.width, attachment.height) > 512 ? await upscaleDataUrl(attachment.dataUrl, { targetLongEdge: 512, algorithm: "high" }) : attachment.dataUrl;
|
||||
return { ...attachment, size: dataUrl.length, url: dataUrl, dataUrl };
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-age
|
||||
|
||||
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
|
||||
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
||||
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown };
|
||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
export type AgentTokenUsage = { input: number; cached: number; output: number };
|
||||
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 6000;
|
||||
@@ -31,6 +34,7 @@ type AgentStore = {
|
||||
sending: boolean;
|
||||
waiting: boolean;
|
||||
messages: AgentChatItem[];
|
||||
tokenUsage: AgentTokenUsage | null;
|
||||
eventLogs: AgentEventLog[];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
@@ -38,9 +42,11 @@ type AgentStore = {
|
||||
loadingThreads: boolean;
|
||||
activeTab: AgentPanelTab;
|
||||
confirmTools: boolean;
|
||||
permissionMode: AgentPermissionMode;
|
||||
activity: string;
|
||||
connectError: string;
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
pendingApprovals: AgentPendingApproval[];
|
||||
setAgentState: (patch: Partial<Omit<AgentStore, "setAgentState" | "connectAgent" | "disconnectAgent" | "addMessage" | "addEventLog" | "clearEventLogs" | "openPanel" | "closePanel" | "togglePanel" | "setCanvasContext">>) => void;
|
||||
openPanel: () => void;
|
||||
closePanel: () => void;
|
||||
@@ -71,16 +77,19 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
sending: false,
|
||||
waiting: false,
|
||||
messages: [],
|
||||
tokenUsage: null,
|
||||
eventLogs: [],
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
activeTab: "setup",
|
||||
confirmTools: true,
|
||||
confirmTools: false,
|
||||
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
|
||||
activity: "就绪",
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
setAgentState: (patch) => set(patch),
|
||||
openPanel: () => set({ panelOpen: true, panelMounted: true, panelClosing: false }),
|
||||
closePanel: () => {
|
||||
@@ -105,7 +114,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
}
|
||||
localStorage.setItem("canvas-agent-url", endpoint);
|
||||
localStorage.setItem("canvas-agent-token", token);
|
||||
// 只设 enabled=true,由 CanvasLocalAgentPanel 的 useEffect 统一负责开 SSE
|
||||
// 只设 enabled=true,由 LocalAgentPanel 的 useEffect 统一负责开 SSE
|
||||
set({ url: endpoint, token, enabled: true, silentConnect: silent, activity: "连接中", connectError: "" });
|
||||
},
|
||||
disconnectAgent: (patch = {}) => {
|
||||
|
||||
@@ -471,3 +471,146 @@
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
.agent-streamdown {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-streamdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-streamdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block"] {
|
||||
position: relative;
|
||||
display: block;
|
||||
contain: none;
|
||||
contain-intrinsic-size: none !important;
|
||||
width: 100%;
|
||||
margin-block: 0.35rem;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0.4rem;
|
||||
background: color-mix(in srgb, currentColor 5%, transparent);
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-header"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block"] > div:has(> [data-streamdown="code-block-actions"]) {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
z-index: 1;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-body"] {
|
||||
min-height: 0;
|
||||
padding: 0.4rem 2rem 0.4rem 0.6rem;
|
||||
overflow-x: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.15rem;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-actions"] {
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.3rem;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block"]:hover [data-streamdown="code-block-actions"],
|
||||
.agent-streamdown [data-streamdown="code-block-actions"]:focus-within {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-actions"] button {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-actions"] button:hover {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="inline-code"] {
|
||||
padding: 0.12rem 0.35rem;
|
||||
border: 0;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="link"] {
|
||||
color: inherit;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] {
|
||||
padding: 1rem;
|
||||
background: rgb(28 25 23 / 24%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div {
|
||||
max-width: 24rem;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border-color: color-mix(in srgb, currentColor 12%, transparent);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 20px 60px rgb(28 25 23 / 18%);
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > button {
|
||||
top: 0.9rem;
|
||||
right: 0.9rem;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > div:first-of-type {
|
||||
gap: 0.25rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > div:first-of-type > div {
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > div:nth-of-type(2) {
|
||||
max-height: 7rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 9%, transparent);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.15rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > div:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-streamdown="link-safety-modal"] > div > div:last-child button {
|
||||
flex: none;
|
||||
min-width: 7rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user