Compare commits

..

76 Commits

Author SHA1 Message Date
HouYunFei 335467ec0d chore: update version to v0.13.0 and add changelog entries for recent optimizations 2026-08-03 11:40:20 +08:00
HouYunFei 1e92157a23 fix(canvas): stabilize node resizing by hiding toolbar during resize to prevent rendering issues 2026-08-03 11:37:19 +08:00
HouYunFei 8efd714221 feat(agent): enhance Codex initialization with MCP preheating and structured status updates 2026-08-03 10:54:15 +08:00
yu ea0414e88c fix(agent): unify live and historical conversation state 2026-08-01 22:11:05 +08:00
yu ee5804e586 feat(agent): unify scroll-to-latest controls 2026-07-31 13:23:21 +08:00
yu 29c5dbbc8c feat(agent): improve diagnostic log viewer 2026-07-31 13:23:10 +08:00
HouYunFei 119931e724 chore: remove publish canvas agent workflow 2026-07-31 11:41:43 +08:00
HouYunFei 6f1b6633b7 chore: update version to v0.12.1 and add changelog entries for new features and optimizations 2026-07-31 11:34:21 +08:00
HouYunFei ed2b0dabad feat(agent): optimize Agent's streaming response by removing word-by-word animation queue and adjusting message placeholder behavior 2026-07-31 11:33:25 +08:00
HouYunFei 29e52fecd6 fix(agent): retain and display user messages immediately upon first send, improving user experience 2026-07-31 11:14:35 +08:00
HouYunFei 576f7485bf feat(agent): extend Codex reasoning effort options to include 'max' and 'ultra', and update related logging and UI components 2026-07-31 11:12:03 +08:00
HouYunFei 9330bdb318 feat(agent): add support for selecting Codex model and reasoning effort in the canvas agent, retaining choices for subsequent tasks 2026-07-31 11:03:19 +08:00
HouYunFei 3c6af91e8d feat(agent): upgrade Codex to version 0.146.0 and implement version checks at startup 2026-07-31 10:15:33 +08:00
HouYunFei afc7bfebaf chore(release): update version to v0.12.0 and add changelog entries for new features 2026-07-30 13:35:23 +08:00
HouYunFei 520c12038f feat(agent): enhance canvas agent functionality with new Codex permissions, improved task progress display, and streamlined user interactions 2026-07-30 13:33:17 +08:00
HouYunFei 80756b6170 feat(agent): enhance canvas agent functionality with new Codex permissions, improved task progress display, and streamlined user interactions 2026-07-30 13:27:50 +08:00
HouYunFei 5b689c8feb feat(agent): implement immediate reset of conversation state and synchronize empty session status without waiting for Codex thread creation 2026-07-30 13:18:09 +08:00
HouYunFei 1291dd08c3 feat(agent): enhance canvas content summary to display detailed node and connection counts 2026-07-30 13:15:43 +08:00
HouYunFei f2757baf20 feat(agent): optimize initial message handling to clear input and display user messages immediately 2026-07-30 13:11:03 +08:00
HouYunFei 45addead96 feat(agent): enhance dynamic tool call handling to display specific tool names and accurate execution status 2026-07-30 13:07:47 +08:00
HouYunFei 2002dff78a feat(agent): add support for image generation type in event handling and messaging 2026-07-29 17:32:06 +08:00
HouYunFei 2afe2f3898 feat(agent): implement local image handling and enhance generated image integration in canvas 2026-07-29 17:19:20 +08:00
HouYunFei 7a48f441cf feat(agent): optimize agent panel header and functionality layout for improved space efficiency 2026-07-29 17:08:17 +08:00
HouYunFei 8babf7faa0 feat(agent): add local file reveal functionality in system file manager 2026-07-29 17:03:41 +08:00
HouYunFei fe1ba781d1 feat(agent): standardize approval policy and sandbox naming conventions in Codex settings 2026-07-29 16:50:51 +08:00
HouYunFei b926feeb14 feat(agent): implement permission mode handling for Codex threads and approvals 2026-07-29 16:44:51 +08:00
HouYunFei bd8cf318f7 feat(agent): enhance Codex reasoning summary with Markdown rendering and improve message history merging 2026-07-29 16:34:44 +08:00
HouYunFei a9243b6c2e feat(agent): enhance reasoning and command summaries with compact collapsible design and real-time updates 2026-07-29 16:10:05 +08:00
HouYunFei b628b742d5 fix(agent): update button styles for tool confirmation mode in chat input 2026-07-29 15:50:45 +08:00
HouYunFei d76594a7e7 feat(agent): implement tool confirmation mode in chat input with automatic confirmation by default 2026-07-29 15:44:56 +08:00
HouYunFei fa58ec4715 feat(agent): update image message display to compact thumbnails with click-to-zoom preview 2026-07-29 15:26:26 +08:00
HouYunFei 5a607bea6c fix(agent): ensure image attachments retain thumbnails after history sync and prevent internal context from displaying in user messages 2026-07-29 15:17:10 +08:00
HouYunFei 88e86c0566 fix(agent): correct image attachment positioning and prevent disappearance after task completion 2026-07-29 14:33:45 +08:00
HouYunFei d2141d5f59 feat(agent): prioritize current canvas operations when receiving tasks, avoiding unnecessary project queries 2026-07-29 14:28:28 +08:00
HouYunFei eda3481533 feat(agent): update agent connection behavior to start with a blank conversation and allow user-initiated session restoration 2026-07-29 14:16:35 +08:00
HouYunFei 0526290c8f feat(agent): restructure task progress display in conversation flow to a collapsible format above the input area, ensuring real-time updates for pending, in-progress, and completed tasks 2026-07-29 13:36:34 +08:00
HouYunFei ed32e44e69 fix(agent): handle task failure by displaying user-friendly error messages in conversation flow 2026-07-29 13:20:31 +08:00
HouYunFei 0f5eb1fbcb feat(agent): enhance structured task progress display in conversation flow, ensuring real-time updates and fixed visibility during execution 2026-07-29 13:06:25 +08:00
HouYunFei 20844d5e99 feat(codex): add structured task progress support in conversation flow, displaying status updates for pending, in-progress, and completed tasks 2026-07-29 12:57:21 +08:00
HouYunFei ea67a5abf5 feat(agent): enhance conversation flow with incremental updates and user-friendly summaries for reasoning, plans, commands, searches, and file changes 2026-07-29 12:19:13 +08:00
HouYunFei c6f8b1e394 feat(codex): optimize streaming response handling by merging deltas and improving message rendering performance 2026-07-29 11:32:03 +08:00
HouYunFei 1f6a652799 feat(codex): upgrade Codex CLI to version 0.145.0 and refactor interrupt handling for improved turn management 2026-07-29 11:14:15 +08:00
HouYunFei 40c47dd7ff feat(canvas): restructure source directory for Canvas Agent, enhancing code maintainability 2026-07-29 10:47:06 +08:00
HouYunFei 92fd0ce129 feat(logging): update logging format to plain text and enhance HTTP diagnostics 2026-07-29 10:28:24 +08:00
HouYunFei b3b02f75a9 feat(logging): implement detailed logging for Codex operations and HTTP requests 2026-07-28 17:42:11 +08:00
HouYunFei b6e9954c0a chore(release): update version to v0.11.0 and add changelog entries for new features 2026-07-28 10:29:06 +08:00
HouYunFei c23a819f27 feat(canvas): add reasoning effort settings for text nodes in canvas configuration 2026-07-28 10:28:27 +08:00
HouYunFei 6aa9c8bddc fix(canvas): retain prompt visibility for selected canvas nodes after generation 2026-07-28 10:15:53 +08:00
HouYunFei bde33e7816 fix(canvas): restrict prompt layer height for long input and enable scrolling for node prompts 2026-07-28 10:10:31 +08:00
HouYunFei edc7667830 feat(drag-and-drop): implement file upload via drag-and-drop for reference assets in image and video workspaces 2026-07-28 10:03:23 +08:00
HouYunFei f76f81c4c7 feat(canvas): add image preview functionality in the left-side element list 2026-07-27 17:37:35 +08:00
HouYunFei 87ea3a3954 feat(config): add JSON import/export support for user preferences and configurations 2026-07-27 17:27:00 +08:00
HouYunFei ac126a61b9 feat(api): add support for 火山方舟 protocol and enhance video reference validation 2026-07-27 17:02:50 +08:00
HouYunFei d6d6b7bb8d Merge branch 'fork/chen3737/fix/seedream-error-and-edit' 2026-07-27 16:36:33 +08:00
HouYunFei 84fc159d4f Merge branch 'fork/chen3737/main' 2026-07-27 16:31:22 +08:00
HouYunFei 6c61081698 feat(canvas): enhance image editing interactions and fix flickering in high-resolution previews 2026-07-27 16:30:43 +08:00
HouYunFei f8191d13b2 Merge branch 'fork/kumu-ze/codex/upstream-image-editing'
# Conflicts:
#	CHANGELOG.md
#	docs/content/docs/progress/pending-test.mdx
2026-07-27 16:11:25 +08:00
HouYunFei a0287a5e34 chore: bump version to v0.10.0 2026-07-25 11:55:13 +08:00
HouYunFei 0179724467 feat(prompts): add atlascloud logo SVG and update prompt source handling 2026-07-25 11:55:00 +08:00
yu 49851f2b5f feat(prompts): unify sources and add personal library 2026-07-21 01:08:11 +08:00
yu c81bb8b651 style(agent): distinguish chat participants 2026-07-20 12:14:36 +08:00
yu b7d0fe10bc fix(agent): improve chat navigation 2026-07-20 12:13:33 +08:00
HouYunFei d91e67a5ad feat(canvas): retain prompt input after node generation completion 2026-07-20 11:34:46 +08:00
HouYunFei d566d5cc46 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CHANGELOG.md
2026-07-20 07:55:56 +08:00
yu 8fb8d0b224 fix(agent): add uploaded images to canvas flows 2026-07-19 18:17:57 +08:00
yuki 0ca35f46ca fix(api): 增强API错误信息展示并修复Seedream图生图接口兼容性 2026-07-19 15:01:37 +08:00
yuki ccf6412a7c feat(canvas): 画布工具栏上传和拖拽支持多选文件 2026-07-19 12:54:39 +08:00
枯木泽 4236af0c58 merge: 同步上游 Agent 会话更新 2026-07-18 20:23:04 +08:00
枯木泽 8bcedfe51b fix(canvas): 防止空格平移误触弹窗按钮 2026-07-18 20:21:18 +08:00
yu 5e1fd7a825 fix(agent): synchronize shared Codex sessions 2026-07-18 15:29:58 +08:00
yu 062e4569aa feat(agent): add task status and client-scoped operations 2026-07-18 15:29:58 +08:00
HouYunFei b5dc9c8a87 feat(canvas): optimize CanvasAssetsTab and CanvasPromptsTab with memoization to improve performance 2026-07-18 08:27:20 +08:00
HouYunFei 75c22c2f9f feat(canvas): add "prompts" tab to the left panel with collapsible prompt sources 2026-07-18 08:19:59 +08:00
枯木泽 97bb330de1 feat(canvas): 优化图片编辑交互 2026-07-17 21:32:17 +08:00
HouYunFei bdca6b0a5c feat(prompt-sources): add davidwu-gpt-image2-prompts to default prompt sources 2026-07-17 17:45:50 +08:00
HouYunFei d4130bbb79 feat(prompt-sources): add custom script fetching functionality for prompt sources 2026-07-17 17:36:42 +08:00
102 changed files with 15257 additions and 3385 deletions
@@ -1,59 +0,0 @@
name: Publish Canvas Agent
on:
push:
branches:
- main
paths:
- "canvas-agent/**"
- ".github/workflows/publish-canvas-agent.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: publish-canvas-agent
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: canvas-agent
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm install --no-audit --no-fund
- name: Check package version
id: package
run: |
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
published=$(npm view "$name@$version" version 2>/dev/null || true)
echo "name=$name" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
if [ -n "$published" ]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
echo "should_publish=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish to npm
if: steps.package.outputs.should_publish == 'true'
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Skip existing version
if: steps.package.outputs.should_publish != 'true'
run: echo "${{ steps.package.outputs.name }}@${{ steps.package.outputs.version }} already exists on npm, skipping publish."
+1
View File
@@ -82,3 +82,4 @@
- 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。
- 当前 AI API Key 存在浏览器本地,并由前端直接请求 OpenAI 兼容接口;涉及安全说明时要写清楚。
- Docker 静态资源路径目前仍是待办项,文档中不要过度承诺生产部署已经完全验证。
- Agent 对话消息必须同时按 `threadId``turnId``itemId` 归属;实时事件只用于补充未物化的 turn,历史快照成为权威后不得重复合并同一条消息。
+87 -3
View File
@@ -2,15 +2,99 @@
## Unreleased
## v0.13.0 - 2026-08-03
+ [优化] Agent 进入空白对话或点击新对话后后台预热 Codex 与 MCP。
+ [优化] Agent 排查日志改为筛选栏结构化滚动列表,支持筛选、展开详情并折叠连续重复事件。
+ [优化] Agent 日志统一使用居中的回到底部入口,向上浏览时暂停跟随并可快速返回最新内容。
+ [优化] Agent 对话将同一轮连续命令合并为按数量折叠的命令组,默认隐藏冗长命令预览。
+ [修复] MCP 初始化期间禁用 Agent 按钮与回车发送,避免首条消息提前清除服务加载状态。
+ [修复] Agent 对话代码块关闭行号后多行内容被拼接为一行的问题。
+ [修复] 统一 Agent 实时事件与线程历史的消息归属,避免多窗口或刷新后出现过程重复等问题。
+ [修复] 节点缩放期间暂停渲染跟随节点的浮层工具条高频更新导致页面崩溃。
## v0.12.1 - 2026-07-31
+ [新增] 画布右侧Agent支持按当前账号可用范围选择Codex 模型与推理强度。
+ [优化] Canvas Agent 升级至最新 Codex,启动时检查版本更新,
+ [优化] 统一通过公共日志输出 Info 以上信息,美化转发日志时间。
+ [优化] Agent高速流式回复取消逐词动画排队和当前消息离屏占位。
+ [修复] Agent首次发送消息时立即保留并展示用户内容,不再晚于思考状态。
## 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
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
+ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
+ [新增] 模型渠道新增火山方舟协议。
+ [优化] 画布左侧元素列表新增图片放大预览操作。
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
+ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。
+ [修复] 画布组装提示词浮层限制正文高度,长提示词改为区域内滚动查看。
+ [修复] 修复画布节点提示词过长时无法使用鼠标滚轮滚动的问题。
+ [修复] 修复画布重试时组装提示词重复以及切换类型调用模型不一致的问题。
+ [修复] 修复生成完成后再次选中画布节点时提示词不再回显的问题。
## v0.10.0 - 2026-07-25
+ [新增] 提示词来源新增BananaPromptQuicker,并支持添加自定义标准 JSON 来源。
+ [新增] Agent 新增统一状态查询,支持查看画布、生图工作台和视频工作台任务进度。
+ [调整] 内置提示词来源改为读取ImagePrompts 统一JSON 数据,不再由画布端分别解析。
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
+ [优化] 提示词中心标题居中、搜索输入增加防抖,并采用左侧筛选、右侧内容的双栏布局。
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
## v0.9.0 - 2026-07-17
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
@@ -18,8 +102,8 @@
## v0.8.2 - 2026-07-16
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
+ [修复] 画布节点提示词输入框补上悬停文本光标。
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
+ [修复] 画布节点提示词输入框补上悬停文本光标。
## v0.8.1 - 2026-07-16
+19 -2
View File
@@ -15,7 +15,7 @@
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50077?utm_source=trendshift-badge&amp;utm_medium=badge&amp;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&amp;utm_medium=badge&amp;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">
@@ -25,10 +25,23 @@
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
> [!CAUTION]
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新。
>
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
## 赞助商
<table>
<tr>
<td width="190" align="center">
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas"><img src="assets/atlascloud.svg" width="163" alt="Atlas Cloud"></a>
</td>
<td>
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas">Atlas Cloud</a> is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out <a href="https://www.atlascloud.ai/console/coding-plan">Atlas Cloud's new coding plan promotion</a> for more budget-friendly API access.
</td>
</tr>
</table>
## 核心功能
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
@@ -115,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">
+1 -1
View File
@@ -1 +1 @@
v0.9.0
v0.13.0
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+9 -1
View File
@@ -8,6 +8,14 @@
npx -y @basketikun/canvas-agent
```
需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
```bash
npx -y @basketikun/canvas-agent --debug
```
Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
本仓库开发时也可以直接运行:
```bash
@@ -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。
+13
View File
@@ -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
View File
@@ -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
+1532
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,26 +1,31 @@
{
"name": "@basketikun/canvas-agent",
"version": "0.2.0",
"version": "0.4.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",
"debug": "tsx src/index.ts --debug",
"test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.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.146.0",
"express": "^5.1.0",
"strip-ansi": "7.2.0",
"winston": "^3.19.0",
"zod": "^3.25.0"
},
"devDependencies": {
+48
View File
@@ -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;
}
}
+408
View File
@@ -0,0 +1,408 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CodexAppClient } from "./codex-client.js";
type TestClient = {
currentThreadId: string;
currentTurnId: string;
completedTurns: Map<string, Error | null>;
plansByTurn: Map<string, unknown>;
lastUsage: unknown;
answerServerRequest(message: Record<string, unknown>): void;
failAll(message: string): void;
handle(message: Record<string, unknown>): void;
handleNotification(method: string, params: Record<string, unknown>): void;
};
const emptyEventHistory = { record: () => Promise.resolve(), recordTurn: () => Promise.resolve() };
test("审批只在 app-server 确认 resolved 后清除", () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.answerServerRequest({ id: 17, method: "item/commandExecution/requestApproval", params: { threadId: "thread-1", turnId: "turn-1" } });
assert.equal(events.filter((item) => item.type === "codex_approval").length, 1);
assert.equal(client.resolveApproval("17", "accept"), true);
assert.equal(writes.length, 1);
assert.equal(events.some((item) => item.type === "codex_approval_resolved"), false);
assert.equal(client.resolveApproval("17", "accept"), true);
assert.equal(writes.length, 1);
testClient.handleNotification("serverRequest/resolved", { requestId: "17" });
const resolved = events.find((item) => item.type === "codex_approval_resolved");
assert.deepEqual(resolved?.payload, { threadId: "thread-1", turnId: "turn-1", requestId: "17", decision: "accept" });
assert.equal(client.resolveApproval("17", "accept"), false);
});
test("中断请求只作用于当前运行线程", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.currentThreadId = "thread-1";
testClient.currentTurnId = "turn-1";
assert.equal(await client.interruptCurrentTurn("thread-2"), false);
assert.equal(writes.length, 0);
const interrupt = client.interruptCurrentTurn("thread-1");
const request = writes.find((item) => item.method === "turn/interrupt");
assert.ok(request);
testClient.handle({ id: request.id, result: {} });
assert.equal(await interrupt, true);
});
test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handleNotification("turn/started", { turn: { id: "turn-1", status: "inProgress" } });
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("item/started", { item: { id: "reasoning-1", type: "reasoning" } });
testClient.handleNotification("turn/completed", { turn: { id: "turn-1", status: "completed" } });
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
const scopedEvents = events.filter((item) => item.type === "agent_event");
assert.deepEqual(scopedEvents.map((item) => eventScope(item.payload)), [
{ threadId: "thread-1", turnId: "turn-1" },
{ threadId: "thread-1", turnId: "turn-1" },
{ threadId: "thread-1", turnId: "turn-1" },
]);
assert.deepEqual(eventScope(events.find((item) => item.type === "agent_done")?.payload), { threadId: "thread-1", turnId: "turn-1" });
});
test("turn/start 响应早于通知时 onTurn 仍只调用一次", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
assert.equal(events.filter((item) => item.type === "agent_event" && eventType(item.payload) === "turn.started").length, 1);
});
test("turn/started 通知缺失时使用 turn/start 响应回调", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(turnIds, ["turn-1"]);
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await running;
assert.deepEqual(turnIds, ["turn-1"]);
});
test("onTurn 按 threadId 和 turnId 去重", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const turnIds: string[] = [];
const first = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-1:${turnId}`));
const firstRequest = writes.find((item) => item.method === "turn/start");
assert.ok(firstRequest);
testClient.handle({ id: firstRequest.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await first;
const second = client.startTurn("thread-2", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-2:${turnId}`));
const secondRequest = writes.filter((item) => item.method === "turn/start").at(-1);
assert.ok(secondRequest);
testClient.handle({ id: secondRequest.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/started", { threadId: "thread-2", turn: { id: "turn-1", status: "inProgress" } });
testClient.handleNotification("turn/completed", { threadId: "thread-2", turn: { id: "turn-1", status: "completed" } });
await second;
assert.deepEqual(turnIds, ["thread-1:turn-1", "thread-2:turn-1"]);
});
test("稀疏的命令完成通知会保留开始通知中的命令内容", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
testClient.handleNotification("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "command-1", delta: "D:\\infinite-canvas" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", status: "completed", exitCode: 0 } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
const item = (completed?.payload as { item?: Record<string, unknown> })?.item;
assert.equal(item?.command, "Get-Location");
assert.equal(item?.cwd, "D:\\infinite-canvas");
assert.equal(item?.status, "completed");
assert.equal(item?.aggregatedOutput, "D:\\infinite-canvas");
assert.deepEqual(persisted, [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item,
}]);
});
test("稀疏的 plan 完成通知会保留流式正文", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/plan/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "plan-1", delta: "第一步\n第二步" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "plan-1", type: "plan", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { text?: string } })?.item?.text, "第一步\n第二步");
assert.equal(((persisted[0] as { item?: { text?: string } })?.item?.text), "第一步\n第二步");
});
test("reasoning 完成通知缺少 summary 时保留流式摘要", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/reasoning/summaryTextDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", summaryIndex: 0, delta: "分析结果" });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "reasoning-1", type: "reasoning", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { summary?: string } })?.item?.summary, "分析结果");
assert.equal(((persisted[0] as { item?: { summary?: string } })?.item?.summary), "分析结果");
});
test("流式更新只发送当前增量而不重复传输累计正文", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: () => true } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第一段" });
await new Promise((resolve) => setTimeout(resolve, 50));
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第二段" });
await new Promise((resolve) => setTimeout(resolve, 50));
const updates = events.filter((event) => event.type === "agent_event" && eventType(event.payload) === "item.updated");
assert.deepEqual(updates.map((event) => (event.payload as { item: { delta?: string; text?: string } }).item), [
{ id: "assistant-1", type: "agent_message", delta: "第一段" },
{ id: "assistant-1", type: "agent_message", delta: "第二段" },
]);
});
test("新版协作工具完成通知会归一化并写入补充历史", () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "collab-1", type: "collabAgentToolCall", status: "completed" } });
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
assert.equal((completed?.payload as { item?: { type?: string } })?.item?.type, "collab_tool_call");
assert.equal(((persisted[0] as { item?: { type?: string } })?.item?.type), "collab_tool_call");
});
test("并行条目按开始顺序保存而不是完成顺序", () => {
const persisted: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", command: "first" } });
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", command: "second" } });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", status: "completed" } });
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", status: "completed" } });
assert.deepEqual(persisted.map((entry) => ({ itemId: (entry as { itemId: string }).itemId, sequence: (entry as { sequence: number }).sequence })), [
{ itemId: "second", sequence: 2 },
{ itemId: "first", sequence: 1 },
]);
});
test("turn 完成通知会保存本轮输入与终态 turn", async () => {
const persistedTurns: unknown[] = [];
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "执行 Get-Location", [], "request");
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", durationMs: 120 } });
await running;
assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "执行 Get-Location" } }]);
});
test("turn 完成状态会等待补充历史落盘后再广播", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
let release!: () => void;
const persisted = new Promise<void>((resolve) => { release = resolve; });
const child = { stdin: { write: () => true } };
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
assert.equal(events.some((event) => event.type === "agent_done"), false);
release();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
test("app-server 失效时清除不能跨进程复用的 turn 状态", () => {
const child = { stdin: { write: () => true } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
testClient.completedTurns.set("thread-1\0turn-1", null);
testClient.plansByTurn.set("thread-1\0turn-1", { threadId: "thread-1" });
testClient.lastUsage = { inputTokens: 1 };
testClient.failAll("app-server stopped");
assert.equal(testClient.completedTurns.size, 0);
assert.equal(testClient.plansByTurn.size, 0);
assert.equal(testClient.lastUsage, null);
});
test("app-server 在 turn 完成通知前退出时保存失败终态", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const persistedTurns: unknown[] = [];
const child = { stdin: { write: () => true } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "执行失败任务", [], "request");
testClient.handle({ id: 1, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.failAll("Codex app-server exited: 1");
await assert.rejects(running, /Codex app-server exited/);
assert.deepEqual(persistedTurns, [{
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "执行失败任务" },
}]);
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed");
assert.equal((completed?.payload as { status?: string })?.status, "failed");
});
test("turn/started 已到达但 turn/start 尚未响应时退出仍保存失败终态", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const persistedTurns: unknown[] = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "响应前退出", [], "request");
assert.ok(writes.some((item) => item.method === "turn/start"));
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
testClient.failAll("Codex app-server exited: 1");
await assert.rejects(running, /Codex app-server exited/);
assert.deepEqual(persistedTurns, [{
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "响应前退出" },
}]);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
test("app-server 退出时等待失败历史落盘后再结束 turn", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
let release!: () => void;
const persisted = new Promise<void>((resolve) => { release = resolve; });
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const running = client.startTurn("thread-1", "等待落盘", [], "request");
const request = writes.find((item) => item.method === "turn/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
await new Promise((resolve) => setImmediate(resolve));
let settled = false;
const outcome = running.then(() => { settled = true; }, () => { settled = true; });
testClient.failAll("Codex app-server exited: 1");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
assert.equal(events.some((event) => event.type === "agent_done"), false);
release();
await outcome;
assert.equal(settled, true);
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
assert.equal(events.some((event) => event.type === "agent_done"), true);
});
function eventScope(payload: unknown) {
const value = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
return { threadId: value.thread_id, turnId: value.turn_id };
}
function eventType(payload: unknown) {
return payload && typeof payload === "object" ? (payload as Record<string, unknown>).type : undefined;
}
+662
View File
@@ -0,0 +1,662 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import { VERSION } from "../config.js";
import { logger } from "../utils/logger.js";
import { field, type JsonRecord } from "../utils/value.js";
import { codexEventHistory, type CodexEventHistory } from "./codex-event-history.js";
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, 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 ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string };
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex?: number };
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
type ApprovalRequest = { id: number; method: string; params: JsonRecord; decision?: string };
type PendingTurnStart = { threadId: string; prompt: string; turnId?: string; onTurn?: (turnId: string) => void };
const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url);
const STREAM_UPDATE_INTERVAL_MS = 40;
const supplementalItemTypes = new Set(["agent_message", "reasoning", "plan", "mcp_tool_call", "command_execution", "file_change", "dynamic_tool_call", "collab_tool_call", "web_search", "image_view", "image_generation", "context_compaction"]);
/** 表示错误已经通过 app-server 终态或进程事件通知过网页。 */
export class CodexReportedError extends Error {
override name = "CodexReportedError";
}
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
export class CodexAppClient {
private nextId = 1;
private buffer = "";
private currentThreadId = "";
private currentTurnId = "";
private pendingTurnStart?: PendingTurnStart;
private startedTurnKeys = new Set<string>();
private textByItem = new Map<string, string>();
private reasoningTextByItem = new Map<string, Map<number, string>>();
private lastUsage: unknown = null;
private pending = new Map<number, PendingRequest>();
private activeTurns = new Map<string, ActiveTurn>();
private completedTurns = new Map<string, Error | null>();
private pendingDeltas = new Map<string, PendingDelta>();
private startedItems = new Map<string, JsonRecord>();
private itemSequences = new Map<string, number>();
private nextItemSequences = new Map<string, number>();
private plansByTurn = new Map<string, CodexPlanUpdate>();
private approvalRequests = new Map<string, ApprovalRequest>();
private finalizingTurns = new Map<string, Promise<void>>();
private failing = false;
/** 保存 app-server 子进程和事件出口。 */
private constructor(private child: ChildProcess, private emit: AgentEmit, private eventHistory: Pick<CodexEventHistory, "record" | "recordTurn"> = codexEventHistory) {}
/** 启动并初始化 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);
let stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
onExit();
};
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
child.stderr?.on("data", (chunk) => {
const text = stripAnsi(chunk.toString()).replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
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 });
client.failAll(error.message, true);
stop();
});
child.on("exit", (code) => {
logger.warn("Codex app-server exited", { code });
client.failAll(`Codex app-server exited: ${code ?? 0}`);
stop();
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 });
}
/** 查询当前账号可用的 Codex 模型。 */
listModels() {
return this.request("model/list", { limit: 100, includeHidden: false });
}
/** 返回指定线程在当前进程中收到的最新任务计划。 */
planUpdates(threadId: string) {
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
}
/** 清理已归档线程的任务计划缓存。 */
clearPlanUpdates(threadId: string) {
this.plansByTurn.forEach((item, key) => {
if (item.threadId === threadId) this.plansByTurn.delete(key);
});
}
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
this.currentThreadId = threadId;
this.currentTurnId = "";
this.lastUsage = null;
const pendingStart: PendingTurnStart = { threadId, prompt, onTurn };
this.pendingTurnStart = pendingStart;
try {
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
const turnId = turn.id;
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
pendingStart.turnId = turnId;
this.currentTurnId = turnId;
this.notifyTurnStarted(threadId, turnId, pendingStart);
const turnKey = turnCacheKey(threadId, turnId);
const completed = this.completedTurns.get(turnKey);
if (this.completedTurns.has(turnKey)) {
this.completedTurns.delete(turnKey);
this.currentThreadId = "";
this.currentTurnId = "";
if (completed) throw completed;
return;
}
await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt }));
} catch (error) {
if (!this.currentTurnId) this.currentThreadId = "";
throw error;
} finally {
if (this.pendingTurnStart === pendingStart) this.pendingTurnStart = undefined;
if (pendingStart.turnId) this.startedTurnKeys.delete(turnCacheKey(threadId, pendingStart.turnId));
}
}
/** 中断当前正在运行且属于指定线程的 Codex turn。 */
async interruptCurrentTurn(requestedThreadId?: string) {
const threadId = this.currentThreadId;
const turnId = this.currentTurnId;
if (!threadId || !turnId || (requestedThreadId && requestedThreadId !== threadId)) 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;
if (request.decision) return true;
request.decision = decision;
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
const accepted = decision === "accept" || decision === "acceptForSession";
const result = request.method === "item/permissions/requestApproval"
? { permissions: accepted ? 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") || "");
const request = requestId ? this.approvalRequests.get(requestId) : undefined;
if (request) {
this.approvalRequests.delete(requestId);
this.emit("codex_approval_resolved", { ...request.params, ...params, requestId, decision: request.decision });
}
return;
}
if (method === "mcpServer/startupStatus/updated") {
const value = params as unknown as CodexNotificationParams<"mcpServer/startupStatus/updated">;
this.emit("agent_bootstrap", {
type: "mcp.startup",
threadId: value.threadId || this.currentThreadId,
name: value.name,
status: value.status,
error: value.error,
failureReason: value.failureReason,
});
return;
}
const turnEvent = method.startsWith("turn/") || method.startsWith("item/") || method === "thread/tokenUsage/updated" || method === "error";
if (turnEvent) {
const threadId = String(field(params, "threadId") || this.currentThreadId);
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || this.currentTurnId);
if (method === "turn/started") {
this.currentThreadId = threadId;
this.currentTurnId = turnId;
this.notifyTurnStarted(threadId, turnId);
}
params = { ...params, ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
}
if (method === "item/agentMessage/delta") {
const value = params as unknown as CodexNotificationParams<"item/agentMessage/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(turnCacheKey(update.threadId, 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;
const eventScope = codexEventScope(params);
if (event.type === "item.started" || event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
const threadId = String(field(event, "thread_id") || this.currentThreadId);
const turnId = String(field(event, "turn_id") || this.currentTurnId);
const key = itemCacheKey({ threadId, turnId, itemId: id });
if (id && threadId && turnId && event.type === "item.started") {
this.assignItemSequence(threadId, turnId, id);
this.startedItems.set(key, item || {});
}
if (id && event.type === "item.completed") {
const started = this.startedItems.get(key);
if (started) event.item = mergeDefinedRecord(started, item || {});
this.startedItems.delete(key);
}
}
if (event.type === "item.completed") {
const item = field(event, "item") as JsonRecord | undefined;
const id = String(field(item, "id") || "");
const key = itemCacheKey({ threadId: String(field(event, "thread_id") || this.currentThreadId), turnId: String(field(event, "turn_id") || this.currentTurnId), itemId: id });
this.flushDelta(key);
const streamedText = this.textByItem.get(key);
if ((item?.type === "agent_message" || item?.type === "plan") && streamedText && !String(item.text || "").trim()) item.text = streamedText;
if (item?.type === "reasoning" && streamedText && !readableCodexText(field(item, "summary"))) item.summary = streamedText;
if (item?.type === "command_execution" && streamedText && !String(item.aggregatedOutput || "").trim()) item.aggregatedOutput = streamedText;
const threadId = String(field(event, "thread_id") || this.currentThreadId);
const turnId = String(field(event, "turn_id") || this.currentTurnId);
if (id && threadId && turnId && item && supplementalItemTypes.has(String(item.type || ""))) {
const sequence = this.assignItemSequence(threadId, turnId, id);
void this.eventHistory.record({ threadId, turnId, itemId: id, sequence, item }).catch((error) => logger.warn("Failed to persist Codex event history", { threadId, turnId, itemId: id, error }));
}
if (id) {
this.textByItem.delete(key);
this.reasoningTextByItem.delete(key);
}
}
let turnPersistence: Promise<void> | undefined;
if (event.type === "turn.completed") {
const turn = field(params, "turn");
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
const threadId = String(field(params, "threadId") || field(event, "thread_id") || "");
const planKey = turnCacheKey(threadId, turnId);
const plan = this.plansByTurn.get(planKey);
if (plan) this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
if (threadId && turnId) {
const input = this.pendingTurnStart?.threadId === threadId ? this.pendingTurnStart.prompt : "";
const turnRecord = { ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), ...(input ? { input } : {}) };
turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error }));
this.finalizingTurns.set(planKey, turnPersistence);
}
this.finishTurnDeltas(threadId, turnId);
}
if (event.type === "turn.completed") {
event.usage = this.lastUsage;
const complete = () => this.completeTurn(event, params, eventScope);
if (turnPersistence) void turnPersistence.then(complete);
else complete();
return;
}
this.emit("agent_event", { agent: "codex", ...event });
}
/** 补充历史落盘后再广播 turn 终态,确保界面完成状态可跨 Agent 重启恢复。 */
private completeTurn(event: AgentEvent, params: JsonRecord, eventScope: ReturnType<typeof codexEventScope>) {
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
const turnId = turn.id;
const turnKey = turnCacheKey(String(field(params, "threadId") || field(event, "thread_id") || ""), turnId);
this.finalizingTurns.delete(turnKey);
this.emit("agent_event", { agent: "codex", ...event });
const pending = this.activeTurns.get(turnKey);
const error = turn.error;
const failure = error ? new CodexReportedError(error.message || "Codex turn failed") : null;
if (pending) {
this.activeTurns.delete(turnKey);
failure ? pending.reject(failure) : pending.resolve(event);
} else if (turnId) {
this.completedTurns.set(turnKey, failure);
}
if (turnId === this.currentTurnId) {
this.currentThreadId = "";
this.currentTurnId = "";
}
this.emit("agent_done", { agent: "codex", usage: event.usage, ...eventScope });
}
/** 合并并广播 Agent 文本或执行输出增量。 */
private emitDelta(itemType: string, params: ItemDeltaParams) {
params = { ...params, threadId: params.threadId || this.currentThreadId, turnId: params.turnId || this.currentTurnId };
const key = itemCacheKey(params);
this.textByItem.set(key, itemType === "reasoning" ? this.appendReasoningText(key, params) : `${this.textByItem.get(key) || ""}${params.delta}`);
const pending = this.pendingDeltas.get(key);
if (pending) {
pending.delta += params.delta;
pending.itemType = itemType;
pending.params = params;
return;
}
this.pendingDeltas.set(key, {
delta: params.delta,
itemType,
params,
timer: setTimeout(() => this.flushDelta(key), STREAM_UPDATE_INTERVAL_MS),
});
}
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
private flushDelta(key: string) {
const pending = this.pendingDeltas.get(key);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingDeltas.delete(key);
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id: pending.params.itemId, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
}
/** 按 Codex summaryIndex 保存 reasoning 分段,顺序与线程历史一致。 */
private appendReasoningText(key: string, params: ItemDeltaParams) {
const segments = this.reasoningTextByItem.get(key) || new Map<number, string>();
this.reasoningTextByItem.set(key, segments);
return appendReasoningDelta(segments, params.summaryIndex, params.delta);
}
/** turn 结束时发送最后一批增量并清理未收到 item.completed 的缓存。 */
private finishTurnDeltas(threadId: string, turnId: string) {
const prefix = `${turnCacheKey(threadId, turnId)}\0`;
[...this.pendingDeltas.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.flushDelta(key));
[...this.textByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.textByItem.delete(key));
[...this.reasoningTextByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.reasoningTextByItem.delete(key));
[...this.startedItems.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.startedItems.delete(key));
[...this.itemSequences.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.itemSequences.delete(key));
this.nextItemSequences.delete(turnCacheKey(threadId, turnId));
}
/** 为一个 turn 内的 item 固定开始顺序,完成通知只更新内容。 */
private assignItemSequence(threadId: string, turnId: string, itemId: string) {
const key = itemCacheKey({ threadId, turnId, itemId });
const existing = this.itemSequences.get(key);
if (existing !== undefined) return existing;
const turnKey = turnCacheKey(threadId, turnId);
const sequence = (this.nextItemSequences.get(turnKey) || 0) + 1;
this.nextItemSequences.set(turnKey, sequence);
this.itemSequences.set(key, sequence);
return sequence;
}
/** 在通知或 turn/start 响应到达时回调一次 turn 启动状态。 */
private notifyTurnStarted(threadId: string, turnId: string, fallback?: PendingTurnStart) {
if (!threadId || !turnId) return;
const pending = this.pendingTurnStart;
const registration = pending?.threadId === threadId && (!pending.turnId || pending.turnId === turnId) ? pending : fallback;
if (registration && !registration.turnId) registration.turnId = turnId;
const key = turnCacheKey(threadId, turnId);
if (this.startedTurnKeys.has(key)) return;
if (!registration) return;
this.startedTurnKeys.add(key);
registration.onTurn?.(turnId);
}
/** 自动回复 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, reported = false) {
if (this.failing) return;
this.failing = true;
this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" }));
const failedTurns = new Map<string, { threadId: string; turnId: string; prompt: string }>();
this.activeTurns.forEach(({ threadId, turnId, prompt }, key) => {
if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt });
});
const pendingStart = this.pendingTurnStart;
if (pendingStart?.turnId) {
const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId);
if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt });
}
const finalizing = [...this.finalizingTurns.values()];
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt }) => {
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError }));
});
const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message);
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
this.pendingDeltas.clear();
this.textByItem.clear();
this.reasoningTextByItem.clear();
this.startedItems.clear();
this.itemSequences.clear();
this.nextItemSequences.clear();
this.plansByTurn.clear();
this.completedTurns.clear();
this.approvalRequests.clear();
this.pendingTurnStart = undefined;
this.startedTurnKeys.clear();
this.lastUsage = null;
this.currentThreadId = "";
this.currentTurnId = "";
void Promise.all([...finalizing, ...persistence]).then(() => {
failedTurns.forEach(({ threadId, turnId, prompt }) => {
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn });
this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId });
});
this.pending.forEach((item) => item.reject(error));
this.activeTurns.forEach((item) => item.reject(error));
this.pending.clear();
this.activeTurns.clear();
this.finalizingTurns.clear();
});
}
}
/** 将 Codex 的字符串、摘要数组或文本对象转换为展示文本。 */
function readableCodexText(value: unknown): string {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) return value.map(readableCodexText).filter(Boolean).join("\n");
if (!value || typeof value !== "object") return "";
return readableCodexText(field(value, "text"));
}
/** 合并单个 reasoning 分段增量并按 summaryIndex 输出。 */
export function appendReasoningDelta(segments: Map<number, string>, summaryIndex: number | undefined, delta: string) {
const index = Number.isInteger(summaryIndex) ? Number(summaryIndex) : 0;
segments.set(index, `${segments.get(index) || ""}${delta}`);
return [...segments.entries()].sort(([left], [right]) => left - right).map(([, text]) => text.trim()).filter(Boolean).join("\n");
}
/** 生成仅供进程内缓存使用的完整 turn 与 item 作用域键。 */
function itemCacheKey(scope: { threadId: string; turnId: string; itemId: string }) {
return `${turnCacheKey(scope.threadId, scope.turnId)}\0${scope.itemId}`;
}
function turnCacheKey(threadId: string, turnId: string) {
return `${threadId}\0${turnId}`;
}
/** 生成 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 === "collabAgentToolCall") 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;
}
function mergeDefinedRecord(started: JsonRecord, completed: JsonRecord) {
const merged = { ...started };
Object.entries(completed).forEach(([key, value]) => {
if (value !== undefined) merged[key] = value;
});
return merged;
}
/** 将 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,170 @@
import fs from "node:fs/promises";
import path from "node:path";
import { CONFIG_DIR } from "../config.js";
import type { CodexSupplementalHistory, CodexSupplementalHistoryItem, CodexSupplementalHistoryTurn } from "./codex-history.js";
type CodexEventHistoryData = { version: 1; items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
const MAX_ITEMS = 5000;
const MAX_STRING_LENGTH = 100_000;
export const CODEX_EVENT_HISTORY_FILE = path.join(CONFIG_DIR, "codex-event-history.json");
/** 保存 Codex 持久线程投影可能省略的实时完成事件。 */
export class CodexEventHistory {
private data?: CodexEventHistoryData;
private queue: Promise<void> = Promise.resolve();
constructor(private file = CODEX_EVENT_HISTORY_FILE) {}
/** 按 threadId、turnId 和 itemId 新增或更新一条补充事件。 */
record(entry: CodexSupplementalHistoryItem) {
return this.run(async () => {
const data = await this.load();
const index = data.items.findIndex((item) => sameItem(item, entry));
const previous = index >= 0 ? data.items[index] : undefined;
const nextEntry = normalizeEntry({
...entry,
...(entry.sequence === undefined && previous?.sequence !== undefined ? { sequence: previous.sequence } : {}),
item: mergeRecord(previous?.item, entry.item),
});
const items = [...data.items];
if (index >= 0) items[index] = nextEntry;
else items.push(nextEntry);
const nextData = { ...data, items: items.slice(-MAX_ITEMS) };
await this.save(nextData);
this.data = nextData;
});
}
/** 保存 turn 终态,使标准线程历史尚未物化时仍可恢复完整轮次。 */
recordTurn(entry: CodexSupplementalHistoryTurn) {
return this.run(async () => {
const data = await this.load();
const index = data.turns.findIndex((turn) => sameTurn(turn, entry));
const previous = index >= 0 ? data.turns[index] : undefined;
const nextEntry = normalizeTurn({ ...entry, turn: mergeRecord(previous?.turn, entry.turn) });
const turns = [...data.turns];
if (index >= 0) turns[index] = nextEntry;
else turns.push(nextEntry);
const nextData = { ...data, turns: turns.slice(-MAX_ITEMS) };
await this.save(nextData);
this.data = nextData;
});
}
/** 按 item 开始顺序返回指定线程的补充事件。 */
readThread(threadId: string) {
return this.run(async (): Promise<CodexSupplementalHistory> => {
const data = await this.load();
return {
items: data.items.filter((item) => item.threadId === threadId).sort(compareEntries).map(cloneEntry),
turns: data.turns.filter((turn) => turn.threadId === threadId).map(cloneTurn),
};
});
}
/** 归档线程后删除其补充事件。 */
removeThread(threadId: string) {
return this.run(async () => {
const data = await this.load();
const items = data.items.filter((item) => item.threadId !== threadId);
const turns = data.turns.filter((turn) => turn.threadId !== threadId);
if (items.length === data.items.length && turns.length === data.turns.length) return;
const nextData = { version: 1 as const, items, turns };
await this.save(nextData);
this.data = nextData;
});
}
private run<T>(task: () => Promise<T>) {
const result = this.queue.then(task, task);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async load() {
if (this.data) return this.data;
try {
const value = JSON.parse(await fs.readFile(this.file, "utf8")) as Partial<CodexEventHistoryData>;
this.data = value.version === 1 && Array.isArray(value.items) && Array.isArray(value.turns) ? { version: 1, items: value.items.map(normalizeEntry), turns: value.turns.map(normalizeTurn) } : emptyHistory();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
this.data = emptyHistory();
}
return this.data;
}
private async save(data: CodexEventHistoryData) {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const temporaryFile = `${this.file}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2));
await fs.rename(temporaryFile, this.file);
} finally {
await fs.unlink(temporaryFile).catch(() => undefined);
}
}
}
export const codexEventHistory = new CodexEventHistory();
function emptyHistory(): CodexEventHistoryData {
return { version: 1, items: [], turns: [] };
}
function sameItem(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
return left.threadId === right.threadId && left.turnId === right.turnId && left.itemId === right.itemId;
}
function sameTurn(left: CodexSupplementalHistoryTurn, right: CodexSupplementalHistoryTurn) {
return left.threadId === right.threadId && left.turnId === right.turnId;
}
function cloneEntry(entry: CodexSupplementalHistoryItem) {
return structuredClone(entry);
}
function cloneTurn(entry: CodexSupplementalHistoryTurn) {
return structuredClone(entry);
}
function compareEntries(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
if (left.sequence !== undefined) return -1;
if (right.sequence !== undefined) return 1;
return 0;
}
function normalizeEntry(entry: CodexSupplementalHistoryItem): CodexSupplementalHistoryItem {
return {
...entry,
...(entry.sequence === undefined ? {} : { sequence: entry.sequence }),
item: truncateRecord(entry.item),
};
}
function normalizeTurn(entry: CodexSupplementalHistoryTurn): CodexSupplementalHistoryTurn {
return { ...entry, turn: truncateRecord({ ...entry.turn, id: entry.turnId }) };
}
function mergeRecord(previous: Record<string, unknown> | undefined, next: Record<string, unknown>) {
if (!previous) return next;
const merged = { ...previous };
Object.entries(next).forEach(([key, value]) => {
if (value !== undefined) merged[key] = value;
});
return merged;
}
function truncateRecord(value: Record<string, unknown>) {
return truncateValue(value) as Record<string, unknown>;
}
function truncateValue(value: unknown): unknown {
if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}\n[输出已截断]` : value;
if (Array.isArray(value)) return value.map(truncateValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, truncateValue(item)]));
return value;
}
@@ -0,0 +1,426 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { appendReasoningDelta } from "./codex-client.js";
import { CodexEventHistory } from "./codex-event-history.js";
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
test("线程摘要提取结构化状态类型", () => {
assert.equal(summarizeCodexThread({ id: "thread-1", status: { type: "notLoaded" } }).status, "notLoaded");
assert.equal(summarizeCodexThread({ id: "thread-1", status: "idle" }).status, "idle");
});
test("reasoning 增量按 summaryIndex 聚合", () => {
const segments = new Map<number, string>();
appendReasoningDelta(segments, 1, "第二");
appendReasoningDelta(segments, 0, "第一");
assert.equal(appendReasoningDelta(segments, 1, "段"), "第一\n第二段");
});
test("只有终态 turn 会交给历史快照作为权威内容", () => {
assert.deepEqual(settledTurnIds({ turns: [
{ id: "completed", status: "completed" },
{ id: "failed", status: "failed" },
{ id: "interrupted", status: "interrupted" },
{ id: "running", status: "inProgress" },
] }), ["completed", "failed", "interrupted"]);
});
test("标准历史的运行中状态不会覆盖本地已经完成的 turn", () => {
const supplemental = {
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 1, item: { id: "assistant-1", type: "agent_message", text: "完整回答" } }],
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
};
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "inProgress", items: [] }] };
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
assert.deepEqual(threadMessages(thread, [], supplemental).map((item) => item.text), ["问题", "完整回答"]);
});
test("标准历史进入终态后保持权威状态", () => {
const supplemental = {
items: [],
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
};
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "failed", error: { message: "标准错误" }, items: [] }] };
const messages = threadMessages(thread, [], supplemental);
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
assert.equal(messages.at(-1)?.role, "error");
assert.equal(messages.at(-1)?.text, "标准错误");
});
test("线程历史将多个 reasoning 条目投影为一张稳定卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] },
{ id: "reasoning-1", type: "reasoning", summary: ["第一段", "第二段"] },
{ id: "reasoning-2", type: "reasoning", summary: [{ text: "第三段" }] },
{ id: "assistant-1", type: "agentMessage", text: "回答" },
],
}],
});
const reasoning = messages.filter((item) => item.itemId === "synthetic:reasoning");
assert.equal(reasoning.length, 1);
assert.equal(reasoning[0].id, "thread-1:turn-1:synthetic:reasoning");
assert.equal(reasoning[0].text, "第一段\n第二段\n\n第三段");
assert.deepEqual(reasoning[0].activityItems, { "reasoning-1": "第一段\n第二段", "reasoning-2": "第三段" });
});
test("空 reasoning 不进入完成后的线程历史", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "reasoning-1", type: "reasoning", summary: [] }] }] });
assert.equal(messages.some((item) => item.itemId === "synthetic:reasoning"), false);
});
test("用户消息使用与实时消息一致的 turn 级稳定 ID", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "codex-user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] }] }] });
assert.equal(messages[0].id, "thread-1:turn-1:synthetic:user");
assert.equal(messages[0].itemId, "synthetic:user");
});
test("Codex 历史省略命令时使用补充事件恢复完整命令卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
{ id: "assistant-1", type: "agentMessage", text: "完成" },
],
}],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "Get-Location");
assert.deepEqual(command?.detail, {
kind: "command",
status: "completed",
rows: [{ label: "退出状态", value: "0" }],
output: "D:\\infinite-canvas",
});
});
test("Codex 标准历史与补充事件重复时以标准历史为准", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "补充命令", status: "completed" },
}], turns: [] });
assert.equal(messages.filter((item) => item.itemId === "command-1").length, 1);
assert.equal(messages.find((item) => item.itemId === "command-1")?.text, "标准命令");
});
test("标准历史重写 item id 时仍与同一条实时事件合并", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "item-13", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
{ id: "item-14", type: "agentMessage", text: "我将仅查看当前路径。" },
{ id: "item-15", type: "agentMessage", text: "完成。" },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-commentary", sequence: 2, item: { id: "msg-commentary", type: "agent_message", text: "我将仅查看当前路径。" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 3, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-final", sequence: 4, item: { id: "msg-final", type: "agent_message", text: "完成。" } },
], turns: [] });
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "msg-commentary", "command-1", "msg-final"]);
});
test("标准历史正文损坏且重写 item id 时不会重复显示同一条回复", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "item-38", type: "agentMessage", text: "连接" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-007367",
sequence: 1,
item: { id: "msg-007367", type: "agent_message", text: "连接服务" },
}], turns: [] });
const replies = messages.filter((item) => item.role === "assistant");
assert.equal(replies.length, 1);
assert.equal(replies[0].itemId, "msg-007367");
assert.equal(replies[0].text, "连接服务");
});
test("标准历史同时保留损坏临时条目和稳定条目时移除损坏副本", () => {
const cleanText = "目前能排除“网页没开”和“Canvas Agent 没连”:前端和 Agent 均正常。";
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "检查连接" }] },
{ id: "item-38", type: "agentMessage", text: "目前能排除“网页没开”和“Canvas Agent 没连:前端和 Agent 均正常。" },
{ id: "command-1", type: "commandExecution", command: "Get-NetTCPConnection", status: "completed" },
{ id: "msg-stable", type: "agentMessage", text: cleanText },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-NetTCPConnection", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-stable", sequence: 2, item: { id: "msg-stable", type: "agent_message", text: cleanText } },
], turns: [] });
const replies = messages.filter((item) => item.role === "assistant");
assert.equal(replies.length, 1);
assert.equal(replies[0].itemId, "msg-stable");
assert.equal(replies[0].text, cleanText);
});
test("标准历史条目稀疏时按字段补全补充事件", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "D:\\infinite-canvas", aggregatedOutput: "输出", exitCode: 0 },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "标准命令");
assert.deepEqual(command?.detail, {
kind: "command",
status: "completed",
rows: [{ label: "工作目录", value: "D:\\infinite-canvas" }, { label: "退出状态", value: "0" }],
output: "输出",
});
});
test("标准历史的 falsy 字段仍优先于补充事件", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "", cwd: null, status: "completed", exitCode: 0, success: false, aggregatedOutput: "" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
sequence: 1,
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "补充目录", exitCode: 9, success: true, aggregatedOutput: "补充输出" },
}], turns: [] });
const command = messages.find((item) => item.itemId === "command-1");
assert.equal(command?.text, "命令执行失败");
assert.deepEqual(command?.detail, { kind: "command", status: "failed", rows: [{ label: "退出状态", value: "0" }], output: "" });
});
test("补充事件按 item 开始顺序插入标准历史锚点之间", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行" }] },
{ id: "second", type: "commandExecution", command: "second", status: "completed" },
{ id: "assistant", type: "agentMessage", text: "完成" },
] }],
}, [], { items: [
{ threadId: "thread-1", turnId: "turn-1", itemId: "first", sequence: 1, item: { id: "first", type: "command_execution", command: "first", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "second", sequence: 2, item: { id: "second", type: "command_execution", command: "补充 second", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "third", sequence: 3, item: { id: "third", type: "command_execution", command: "third", status: "completed" } },
{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant", sequence: 4, item: { id: "assistant", type: "agent_message", text: "补充回答" } },
], turns: [] });
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "first", "second", "third", "assistant"]);
assert.equal(messages.find((item) => item.itemId === "second")?.text, "second");
assert.equal(messages.find((item) => item.itemId === "assistant")?.text, "完成");
});
test("补充事件写入本地 JSON 后可在 Agent 重启后恢复", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
const entry = {
threadId: "thread-1",
turnId: "turn-1",
itemId: "command-1",
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" },
};
await new CodexEventHistory(file).record(entry);
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), { items: [entry], turns: [] });
});
test("补充历史 JSON 损坏后会从空历史恢复并允许重新写入", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
await fs.writeFile(file, "{\"version\":1,\"items\":[");
const history = new CodexEventHistory(file);
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } });
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), {
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } }],
turns: [],
});
});
test("标准历史尚未物化 turn 时从本地终态事件恢复完整对话", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "codex-event-history.json");
const history = new CodexEventHistory(file);
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" } });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 2, item: { id: "assistant-1", type: "agent_message", text: "完成" } });
await history.recordTurn({ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "执行 Get-Location" } });
const supplemental = await new CodexEventHistory(file).readThread("thread-1");
const thread = { id: "thread-1", turns: [] };
const messages = threadMessages(thread, [], supplemental);
assert.deepEqual(messages.map((message) => message.itemId), ["synthetic:user", "command-1", "assistant-1"]);
assert.equal(messages[0].text, "执行 Get-Location");
assert.equal(messages[1].text, "Get-Location");
assert.equal(messages[2].text, "完成");
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
});
test("归档线程只清除该线程的补充事件", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
const entry = (threadId: string) => ({ threadId, turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } });
await history.record(entry("thread-1"));
await history.record(entry("thread-2"));
await history.removeThread("thread-1");
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
assert.deepEqual(await history.readThread("thread-2"), { items: [entry("thread-2")], turns: [] });
});
test("补充事件更新时保留已有字段并限制单项输出大小", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", status: "completed", aggregatedOutput: "x".repeat(100_001) } });
const [entry] = (await history.readThread("thread-1")).items;
assert.equal(entry.sequence, 1);
assert.equal(entry.item.command, "Get-Location");
assert.equal(entry.item.cwd, "D:\\infinite-canvas");
assert.equal(String(entry.item.aggregatedOutput).endsWith("[输出已截断]"), true);
});
test("补充事件落盘失败时不污染内存读取", async (context) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
context.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "history-target");
const history = new CodexEventHistory(file);
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
await fs.mkdir(file);
await assert.rejects(() => history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } }));
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
});
test("turn 错误优先于条目错误且只生成一张错误卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "failed", error: { message: "turn error" }, items: [{ id: "error-1", type: "error", message: "item error" }] }],
});
const errors = messages.filter((item) => item.role === "error");
assert.equal(errors.length, 1);
assert.equal(errors[0].id, "thread-1:turn-1:synthetic:error");
assert.equal(errors[0].text, "turn error");
});
test("失败条目的状态与错误详情在历史中保持完整", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [
{ id: "command-1", type: "commandExecution", command: "exit 1", status: "completed", exitCode: 1, aggregatedOutput: "failed" },
{ id: "image-1", type: "imageGeneration", status: "completed", success: false },
{ id: "context-1", type: "contextCompaction", status: "failed", error: { message: "compact failed" } },
{ id: "collab-1", type: "collabToolCall", status: "completed", success: false },
{ id: "reasoning-1", type: "reasoning", status: "failed", summary: ["分析失败"] },
],
}],
});
const byId = new Map(messages.map((item) => [item.itemId, item]));
assert.equal((byId.get("command-1")?.detail as { status?: string }).status, "failed");
assert.equal((byId.get("image-1")?.detail as { status?: string }).status, "failed");
assert.equal(byId.get("context-1")?.text, "compact failed");
assert.equal((byId.get("context-1")?.detail as { output?: string }).output, "compact failed");
assert.equal((byId.get("collab-1")?.detail as { status?: string }).status, "failed");
assert.equal((byId.get("synthetic:reasoning")?.detail as { status?: string }).status, "failed");
});
test("新版协作工具条目在标准历史和补充历史中保持同一张卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "collab-1", type: "collabAgentToolCall", status: "completed" }] }],
}, [], { items: [{
threadId: "thread-1",
turnId: "turn-1",
itemId: "collab-1",
sequence: 1,
item: { id: "collab-1", type: "collab_tool_call", status: "completed" },
}], turns: [] });
assert.equal(messages.filter((item) => item.itemId === "collab-1").length, 1);
assert.equal(messages[0].title, "协作处理");
});
test("尚未完成的 turn 只由实时事件展示,不进入历史快照", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "inProgress", items: [{ id: "reasoning-1", type: "reasoning", status: "pending", summary: ["正在分析"] }] }],
});
assert.deepEqual(messages, []);
});
test("缺少稳定 item id 的历史条目不会生成无法对齐的临时消息", () => {
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ type: "agentMessage", text: "回答" }] }] });
assert.equal(messages.length, 0);
});
test("通用工具只由标题和结构化状态表达完成结果", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{ id: "turn-1", status: "completed", items: [{ id: "tool-1", type: "mcpToolCall", tool: "list_mcp_resources", status: "completed" }] }],
});
assert.equal(messages.length, 1);
assert.equal(messages[0].title, "调用工具:list_mcp_resources");
assert.equal(messages[0].text, "");
assert.equal((messages[0].detail as { status?: string }).status, "completed");
});
test("命令完成条目缺少 command 时仍保留历史卡片", () => {
const messages = threadMessages({
id: "thread-1",
turns: [{
id: "turn-1",
status: "completed",
items: [{ id: "command-1", type: "commandExecution", status: "completed", exitCode: 0, aggregatedOutput: "done" }],
}],
});
assert.equal(messages.length, 1);
assert.equal(messages[0].itemId, "command-1");
assert.equal(messages[0].title, "执行命令");
assert.equal(messages[0].text, "命令已完成");
assert.equal((messages[0].detail as { status?: string }).status, "completed");
});
+737
View File
@@ -0,0 +1,737 @@
import { field } from "../utils/value.js";
import type { CodexPlanUpdate } from "./codex-protocol.js";
type AgentHistoryMessage = { id: string; itemId: string; threadId: string; turnId: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; activityItems?: Record<string, string> };
export type CodexSupplementalHistoryItem = { threadId: string; turnId: string; itemId: string; sequence?: number; item: Record<string, unknown> };
export type CodexSupplementalHistoryTurn = { threadId: string; turnId: string; turn: Record<string, unknown> };
export type CodexSupplementalHistory = { items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
const emptySupplementalHistory: CodexSupplementalHistory = { items: [], turns: [] };
/** 将 Codex 线程转换为列表展示所需的摘要。 */
export function summarizeCodexThread(thread: unknown) {
const status = field(thread, "status");
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: typeof status === "string" ? status : String(field(status, "type") || ""),
source: field(thread, "source"),
threadSource: field(thread, "threadSource"),
createdAt: Number(field(thread, "createdAt") || 0),
updatedAt: Number(field(thread, "updatedAt") || 0),
};
}
/** 返回已经终结、可由持久化历史作为权威内容的 turn。 */
export function settledTurnIds(thread: unknown, supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory) {
return mergeHistoryTurns(thread, supplementalHistory).flatMap((turn) => {
const id = String(field(turn, "id") || "");
return id && isSettledTurn(turn) ? [id] : [];
});
}
/** 将 Codex turn items 转换为网页聊天历史。 */
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = [], supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory): AgentHistoryMessage[] {
const threadId = String(field(thread, "id") || "");
if (!threadId) return [];
const turns = mergeHistoryTurns(thread, supplementalHistory);
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
const supplementalByTurn = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalHistory.items.filter((item) => item.threadId === threadId).forEach((item) => supplementalByTurn.set(item.turnId, [...(supplementalByTurn.get(item.turnId) || []), item]));
const messages: AgentHistoryMessage[] = [];
turns.forEach((turn) => {
const turnId = String(field(turn, "id") || "");
if (!turnId || !isSettledTurn(turn)) return;
const turnError = String(field(field(turn, "error"), "message") || "").trim();
const items = mergeHistoryItems(arrayValue(field(turn, "items")), supplementalByTurn.get(turnId) || []);
const push = (message: Omit<AgentHistoryMessage, "itemId" | "threadId" | "turnId">) => messages.push({ ...message, id: historyMessageId(threadId, turnId, message.id), itemId: message.id, threadId, turnId });
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") || "") }, threadId);
const reasoningItems = items.flatMap((item) => {
if (field(item, "type") !== "reasoning") return [];
const id = String(field(item, "id") || "");
const text = readableText(field(item, "summary"));
return id && text ? [{ id, text, status: itemStatus(item, itemErrorMessage(item)) }] : [];
});
const reasoningText = reasoningItems.map((item) => item.text).join("\n\n");
const reasoningStatus = aggregateItemStatus(reasoningItems.map((item) => item.status));
const itemErrors = items.flatMap((item) => field(item, "type") === "error" ? [readableText(field(item, "message"))] : []).filter(Boolean);
const itemError = itemErrors[itemErrors.length - 1] || "";
let userAdded = false;
let planAdded = false;
let reasoningAdded = false;
const fallbackUserText = displayUserText(String(field(turn, "input") || ""));
if (fallbackUserText && !items.some((item) => field(item, "type") === "userMessage")) {
push({ id: "synthetic:user", role: "user", text: fallbackUserText });
userAdded = true;
if (planMessage) {
messages.push(planMessage);
planAdded = true;
}
}
items.forEach((item) => {
const type = String(field(item, "type") || "");
const id = String(field(item, "id") || "");
if (!id) return;
if (type === "userMessage") {
const text = displayUserText(userInputText(field(item, "content")));
if (text && !userAdded) {
push({ id: "synthetic:user", role: "user", text });
userAdded = true;
}
if (planMessage && !planAdded) {
messages.push(planMessage);
planAdded = true;
}
}
if (type === "agentMessage") {
const text = String(field(item, "text") || "").trim();
if (text) 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"));
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();
const detail = commandDetail(item);
push({ id, role: "tool", title: "执行命令", text: command || (detail.status === "failed" ? "命令执行失败" : "命令已完成"), detail });
}
if (type === "fileChange") {
const changes = arrayValue(field(item, "changes"));
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "修改文件", text: error || fileChangeSummary(changes), detail: { kind: "file", status: itemStatus(item, error), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })), ...(error ? { output: error } : {}) } });
}
if (type === "reasoning" && reasoningText && !reasoningAdded) {
const sourceItemIds = reasoningItems.map((item) => item.id);
push({ id: "synthetic:reasoning", role: "tool", title: "思考摘要", text: reasoningText, activityItems: Object.fromEntries(reasoningItems.map((item) => [item.id, item.text])), detail: { kind: "reasoning", status: reasoningStatus, sourceItemIds } });
reasoningAdded = true;
}
if (type === "plan") {
const text = String(field(item, "text") || "").trim();
const error = itemErrorMessage(item);
if (text || error) push({ id, role: "tool", title: "执行计划", text: error || text, detail: { kind: "plan", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "webSearch") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "搜索资料", text: error || webSearchSummary(item), detail: { kind: "search", status: itemStatus(item, error), rows: webSearchRows(item), ...(error ? { output: error } : {}) } });
}
if (type === "imageView") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "查看图片", text: error || String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "imageGeneration") {
const error = String(field(field(item, "error"), "message") || "");
const normalizedStatus = itemStatus(item, error);
push({ id, role: "tool", title: "内置生图", text: error || (normalizedStatus === "failed" ? "图片生成失败" : "图片生成完成"), detail: { kind: "image", status: normalizedStatus, savedPath: field(item, "savedPath"), ...(error ? { output: error } : {}) } });
}
if (type === "contextCompaction") {
const error = itemErrorMessage(item);
push({ id, role: "tool", title: "整理上下文", text: error || "已整理当前对话,继续处理任务", detail: { kind: "context", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
}
if (type === "dynamicToolCall") {
const tool = String(field(item, "tool") || "");
const title = toolName(tool);
const error = itemErrorMessage(item);
push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")), detail: toolHistoryDetail(tool, item, toolArguments(field(item, "arguments")), error) });
}
if (type === "collabToolCall" || type === "collabAgentToolCall") {
const error = String(field(field(item, "error"), "message") || "");
const normalizedStatus = itemStatus(item, error);
push({ id, role: "tool", title: "协作处理", text: error || (normalizedStatus === "failed" ? "协作任务失败" : "已完成协作任务"), detail: { kind: "tool", status: normalizedStatus, ...(error ? { output: error } : {}) } });
}
});
if (planMessage && !planAdded) messages.push(planMessage);
if (itemError || turnError) {
const error = userFacingCodexError(turnError || itemError);
push({ id: "synthetic:error", role: "error", title: error.title, text: error.text });
}
});
return latestCompleteTurns(messages.filter((item) => item.text || item.role === "tool"), 120);
}
/** 合并标准 turn 与本地终态事件,标准历史已有字段保持优先。 */
function mergeHistoryTurns(thread: unknown, supplementalHistory: CodexSupplementalHistory) {
const threadId = String(field(thread, "id") || "");
const standardTurns = arrayValue(field(thread, "turns"));
const supplementalTurns = supplementalHistory.turns.filter((entry) => entry.threadId === threadId && entry.turnId);
const supplementalById = new Map(supplementalTurns.map((entry) => [entry.turnId, entry.turn]));
const standardIds = new Set(standardTurns.map((turn) => String(field(turn, "id") || "")).filter(Boolean));
return [
...standardTurns.map((turn) => {
const turnId = String(field(turn, "id") || "");
const supplemental = supplementalById.get(turnId);
return supplemental ? mergeHistoryTurn(turn, supplemental) : turn;
}),
...supplementalTurns.filter((entry) => !standardIds.has(entry.turnId)).map((entry) => entry.turn),
];
}
/** 终态不可被标准历史中尚未物化完成的临时状态降级。 */
function mergeHistoryTurn(standard: unknown, supplemental: Record<string, unknown>) {
const merged = mergeHistoryItem(standard, supplemental);
const standardStatus = String(field(standard, "status") || "");
const supplementalStatus = String(field(supplemental, "status") || "");
if (!isSettledStatus(standardStatus) && isSettledStatus(supplementalStatus)) merged.status = supplementalStatus;
return merged;
}
/** 以 Codex 标准历史为准,只补入同一 turn 中被持久线程投影省略的实时条目。 */
function mergeHistoryItems(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const supplemental = supplementalItems
.filter((entry) => entry.itemId)
.map((entry, index) => ({ ...entry, item: normalizeSupplementalItem(entry.item), fallbackIndex: index }))
.sort(compareSupplementalEntries);
items = removeCorruptedHistoryDuplicates(items, supplemental);
items = reconcileHistoryItemIds(items, supplemental);
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
const supplementalById = new Map(supplemental.map((entry) => [entry.itemId, entry]));
const mergedStandard = items.map((item) => {
const id = String(field(item, "id") || "");
const supplementalEntry = id ? supplementalById.get(id) : undefined;
return supplementalEntry ? mergeHistoryItem(item, supplementalEntry.item) : item;
});
const sequenced = supplemental.filter((entry) => entry.sequence !== undefined);
let merged = sequenced.length ? mergeSequencedItems(mergedStandard, sequenced) : mergedStandard;
supplemental.forEach((entry, index) => {
if (standardIds.has(entry.itemId) || entry.sequence !== undefined) return;
const nextStandardId = supplemental.slice(index + 1).find((candidate) => standardIds.has(candidate.itemId))?.itemId;
const insertAt = nextStandardId ? merged.findIndex((candidate) => String(field(candidate, "id") || "") === nextStandardId) : -1;
if (insertAt >= 0) merged.splice(insertAt, 0, entry.item);
else merged.push(entry.item);
});
return merged;
}
/** thread/read 会重写部分 item id;按类型、内容和出现顺序恢复实时事件的稳定身份。 */
function reconcileHistoryItemIds(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const supplementalById = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
const reservedIds = new Set(items.map((item) => String(field(item, "id") || "")).filter((id) => supplementalById.has(id)));
const candidates = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalItems.forEach((entry) => {
if (reservedIds.has(entry.itemId)) return;
const identity = historyItemIdentity(entry.item);
if (identity) candidates.set(identity, [...(candidates.get(identity) || []), entry]);
});
const replacements = new Map<number, string>();
const usedSupplementalIds = new Set(reservedIds);
items.forEach((item, index) => {
const id = String(field(item, "id") || "");
if (!id || supplementalById.has(id)) return;
const identity = historyItemIdentity(item);
const candidate = identity ? candidates.get(identity)?.shift() : undefined;
if (!candidate || usedSupplementalIds.has(candidate.itemId)) return;
replacements.set(index, candidate.itemId);
usedSupplementalIds.add(candidate.itemId);
});
const remainingStandard = new Map<string, number[]>();
items.forEach((item, index) => {
const id = String(field(item, "id") || "");
if (!id || supplementalById.has(id) || replacements.has(index)) return;
const type = historyItemType(item);
if (type) remainingStandard.set(type, [...(remainingStandard.get(type) || []), index]);
});
const remainingSupplemental = new Map<string, CodexSupplementalHistoryItem[]>();
supplementalItems.forEach((entry) => {
if (usedSupplementalIds.has(entry.itemId)) return;
const type = historyItemType(entry.item);
if (type) remainingSupplemental.set(type, [...(remainingSupplemental.get(type) || []), entry]);
});
remainingStandard.forEach((standardIndexes, type) => {
const supplemental = remainingSupplemental.get(type) || [];
if (standardIndexes.length === supplemental.length) {
standardIndexes.forEach((index, position) => {
const entry = supplemental[position];
replacements.set(index, entry.itemId);
usedSupplementalIds.add(entry.itemId);
});
return;
}
if (supplemental.length !== 1) return;
const corrupted = standardIndexes.filter((index) => hasReplacementCharacter(items[index]));
if (corrupted.length === 1) {
replacements.set(corrupted[0], supplemental[0].itemId);
usedSupplementalIds.add(supplemental[0].itemId);
}
});
return items.map((item, index) => {
const itemId = replacements.get(index);
if (!itemId || !item || typeof item !== "object" || Array.isArray(item)) return item;
return { ...(item as Record<string, unknown>), id: itemId };
});
}
function historyItemType(item: unknown) {
const type = String(field(normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {}), "type") || "");
return type;
}
function hasReplacementCharacter(item: unknown) {
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
return [value.text, value.summary, value.message, value.aggregatedOutput].some((fieldValue) => readableText(fieldValue).includes("\uFFFD"));
}
/** thread/read 偶尔同时返回损坏临时条目和已物化条目,只保留补充历史已确认的稳定副本。 */
function removeCorruptedHistoryDuplicates(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
const stableItems = supplementalItems.filter((entry) => standardIds.has(entry.itemId) && !hasReplacementCharacter(entry.item));
return items.filter((item) => {
if (!hasReplacementCharacter(item)) return true;
const identity = comparableHistoryText(item);
if (identity.length < 8) return true;
return !stableItems.some((entry) => historyItemType(entry.item) === historyItemType(item) && comparableHistoryText(entry.item) === identity);
});
}
function comparableHistoryText(item: unknown) {
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
const type = historyItemType(value);
const text = type === "agentMessage" ? value.text : type === "reasoning" ? value.summary : type === "plan" ? value.text : "";
return readableText(text).normalize("NFKC").toLocaleLowerCase().replace(/[\uFFFD\p{P}\p{S}\p{Z}]+/gu, "");
}
/** 只使用消息本身的稳定语义字段,不使用状态、结果或临时投影 id。 */
function historyItemIdentity(item: unknown) {
const normalized = normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {});
const type = String(field(normalized, "type") || "");
let value: unknown;
if (type === "agentMessage") value = String(field(normalized, "text") || "").trim();
else if (type === "reasoning") value = readableText(field(normalized, "summary"));
else if (type === "plan") value = String(field(normalized, "text") || "").trim();
else if (type === "mcpToolCall" || type === "dynamicToolCall") value = [field(normalized, "tool"), toolArguments(field(normalized, "arguments"))];
else if (type === "commandExecution") value = String(field(normalized, "command") || "").trim();
else if (type === "fileChange") value = arrayValue(field(normalized, "changes")).map((change) => [field(change, "path"), field(change, "kind")]);
else if (type === "collabToolCall" || type === "collabAgentToolCall") value = [field(normalized, "tool"), field(normalized, "prompt"), field(normalized, "receiverThreadId"), field(normalized, "receiverAgentId")];
else if (type === "webSearch") value = [field(normalized, "query"), field(field(normalized, "action"), "type"), field(field(normalized, "action"), "query"), field(field(normalized, "action"), "url"), field(field(normalized, "action"), "pattern")];
else if (type === "imageView") value = field(normalized, "path");
else if (type === "imageGeneration") value = field(normalized, "prompt") || field(normalized, "savedPath");
else if (type === "contextCompaction") value = true;
else return "";
return `${type}\0${JSON.stringify(stableHistoryValue(value))}`;
}
function stableHistoryValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableHistoryValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableHistoryValue(item)]));
return value;
}
function mergeSequencedItems(standardItems: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
const byId = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
const emitted = new Set<string>();
const merged: unknown[] = [];
standardItems.forEach((item) => {
const id = String(field(item, "id") || "");
const sequence = id ? byId.get(id)?.sequence : undefined;
if (sequence !== undefined) {
supplementalItems.forEach((entry) => {
if (entry.sequence !== undefined && entry.sequence < sequence && !emitted.has(entry.itemId)) {
merged.push(findStandardItem(standardItems, entry.itemId) || entry.item);
emitted.add(entry.itemId);
}
});
if (emitted.has(id)) return;
if (id) emitted.add(id);
merged.push(item);
return;
}
merged.push(item);
});
supplementalItems.forEach((entry) => {
if (!emitted.has(entry.itemId)) merged.push(entry.item);
});
return merged;
}
function findStandardItem(items: unknown[], itemId: string) {
return items.find((item) => String(field(item, "id") || "") === itemId);
}
function compareSupplementalEntries(left: CodexSupplementalHistoryItem & { fallbackIndex: number }, right: CodexSupplementalHistoryItem & { fallbackIndex: number }) {
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
if (left.sequence !== undefined) return -1;
if (right.sequence !== undefined) return 1;
return left.fallbackIndex - right.fallbackIndex;
}
/** 标准历史字段优先,补充事件只填充标准历史没有返回的字段。 */
function mergeHistoryItem(standard: unknown, supplemental: Record<string, unknown>) {
if (!standard || typeof standard !== "object" || Array.isArray(standard)) return supplemental;
const merged = { ...supplemental };
Object.entries(standard as Record<string, unknown>).forEach(([key, value]) => {
const supplementalValue = merged[key];
if (typeof value === "string" && typeof supplementalValue === "string" && value.includes("\uFFFD") && !supplementalValue.includes("\uFFFD")) return;
if (value !== undefined) merged[key] = value;
});
return merged;
}
/** 将实时事件的 snake_case 类型还原为 thread/read 使用的 camelCase 类型。 */
function normalizeSupplementalItem(item: Record<string, unknown>) {
const type = String(field(item, "type") || "");
const normalizedType = supplementalItemTypes[type] || type;
return normalizedType === type ? item : { ...item, type: normalizedType };
}
const supplementalItemTypes: Record<string, string> = {
agent_message: "agentMessage",
mcp_tool_call: "mcpToolCall",
command_execution: "commandExecution",
file_change: "fileChange",
dynamic_tool_call: "dynamicToolCall",
collab_tool_call: "collabToolCall",
web_search: "webSearch",
image_view: "imageView",
image_generation: "imageGeneration",
context_compaction: "contextCompaction",
};
/** 只按完整 turn 截取最近历史,避免列表从某轮中间开始。 */
function latestCompleteTurns(messages: AgentHistoryMessage[], limit: number) {
const turns: AgentHistoryMessage[][] = [];
messages.forEach((message) => {
const current = turns.at(-1);
if (!current || current[0].turnId !== message.turnId) turns.push([message]);
else current.push(message);
});
const selected: AgentHistoryMessage[][] = [];
let count = 0;
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (selected.length && count + turn.length > limit) break;
selected.unshift(turn);
count += turn.length;
}
return selected.flat();
}
function isSettledTurn(turn: unknown) {
return isSettledStatus(String(field(turn, "status") || ""));
}
function isSettledStatus(status: string) {
return ["completed", "failed", "interrupted", "cancelled", "canceled"].includes(status);
}
/** 将结构化任务计划转换为聊天进度卡片。 */
function structuredPlanMessage(update: CodexPlanUpdate, threadId: string): 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: historyMessageId(threadId, update.turnId, "synthetic:plan"),
itemId: "synthetic:plan",
threadId,
turnId: update.turnId,
role: "tool",
title: "任务进度",
text: `已完成 ${completed}/${tasks.length}`,
detail: { kind: "todo", status: planStatus(tasks, update.turnStatus), tasks, explanation: update.explanation || "" },
};
}
/** 生成跨线程和 turn 唯一的聊天消息 ID。 */
function historyMessageId(threadId: string, turnId: string, itemId: string) {
return `${threadId}:${turnId}:${itemId}`;
}
/** 根据步骤和 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 error = String(field(field(item, "error"), "message") || "");
const status = String(field(item, "status") || "completed");
const exitCode = field(item, "exitCode");
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error" || (typeof exitCode === "number" && exitCode !== 0);
const rows = [
textRow("工作目录", field(item, "cwd")),
textRow("退出状态", exitCode),
durationRow(field(item, "durationMs")),
].filter(Boolean);
return { kind: "command", status: failed ? "failed" : status, rows, output: error || String(field(item, "aggregatedOutput") || "").trim() };
}
/** 生成 MCP 工具的用户可读详情。 */
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
return { kind: "tool", status: itemStatus(item, error), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
}
function itemErrorMessage(item: unknown) {
return String(field(field(item, "error"), "message") || "");
}
function itemStatus(item: unknown, error = "") {
const status = String(field(item, "status") || "completed");
return error || field(item, "success") === false || status === "failed" || status === "error" ? "failed" : status;
}
function aggregateItemStatus(statuses: string[]) {
if (statuses.includes("failed")) return "failed";
if (statuses.includes("interrupted")) return "interrupted";
if (statuses.some((status) => ["inProgress", "in_progress", "running", "started", "pending"].includes(status))) return "inProgress";
return "completed";
}
/** 生成 MCP 工具在对话中的结果摘要。 */
function toolHistorySummary(tool: string, item: unknown, input: unknown) {
const result = parseToolResult(field(item, "result"));
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
if (tool === "canvas_list_projects") return `${numberValue(field(result, "total"))} 个画布`;
if (tool === "canvas_get_state") {
const nodes = arrayValue(field(result, "nodes"));
const connections = arrayValue(field(result, "connections"));
return Array.isArray(field(result, "nodes")) || Array.isArray(field(result, "connections")) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
}
if (tool === "canvas_get_selection") return "已读取当前选中内容";
if (tool === "prompts_search") return `找到 ${numberValue(field(result, "total"))} 条提示词`;
if (tool === "assets_list") return `${numberValue(field(result, "total"))} 个资产`;
if (tool === "assets_add") return "已加入我的素材";
if (tool === "generation_get_status") {
const summary = field(result, "summary");
return `${numberValue(field(result, "total"))} 个任务,排队 ${numberValue(field(summary, "queued"))},运行中 ${numberValue(field(summary, "running"))},成功 ${numberValue(field(summary, "succeeded"))},失败 ${numberValue(field(summary, "failed"))}`;
}
if (tool === "workbench_image_generate" || tool === "workbench_video_generate") return String(field(result, "note") || "已在工作台执行");
if (tool === "workbench_image_get_config" || tool === "workbench_video_get_config") return "已读取工作台配置";
return "";
}
/** 按节点类型生成人类可读的画布内容概览。 */
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("操作内容", summarizeCanvasOps(arrayValue(field(input, "ops"))))].filter(Boolean);
if (tool === "canvas_create_attachment_nodes") return [textRow("图片数量", arrayValue(field(input, "attachmentIds")).length)].filter(Boolean);
return [];
}
function summarizeCanvasOps(ops: unknown[]) {
const counts = ops.reduce<Record<string, number>>((result, op) => {
const type = String(field(op, "type") || "");
if (type) result[type] = (result[type] || 0) + 1;
return result;
}, {});
return Object.entries(counts).map(([type, count]) => `${canvasOpLabel(type)} ${count}`).join("");
}
function canvasOpLabel(type: string) {
if (type === "add_node") return "新增节点";
if (type === "update_node") return "更新节点";
if (type === "delete_node") return "删除节点";
if (type === "delete_connections") return "删除连线";
if (type === "connect_nodes") return "连接";
if (type === "set_viewport") return "调整视图";
if (type === "select_nodes") return "选择节点";
if (type === "run_generation") return "触发生成";
return type;
}
/** 生成人类可读的文件变更摘要。 */
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");
if (!value || typeof value !== "object") return "";
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 numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
/** 将文件变更类型转换为中文。 */
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}` : "工具操作";
}
+108
View File
@@ -0,0 +1,108 @@
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 CodexMcpStartupStatus = { threadId: string | null; name: string; status: "starting" | "ready" | "failed" | "cancelled"; error: string | null; failureReason: "reauthenticationRequired" | null };
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
export type CodexModel = JsonRecord & {
id: string;
model: string;
displayName: string;
defaultReasoningEffort: CodexReasoningEffort;
supportedReasoningEfforts: Array<{ reasoningEffort: CodexReasoningEffort; description?: string }>;
isDefault?: boolean;
};
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>;
};
"model/list": {
params: { limit: number; includeHidden: boolean };
result: { data: CodexModel[]; nextCursor: string | null };
};
"turn/start": {
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort };
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 } };
"mcpServer/startupStatus/updated": CodexMcpStartupStatus;
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
};
export type CodexNotificationMethod = keyof CodexNotificationSpec;
export type CodexNotificationParams<Method extends CodexNotificationMethod> = CodexNotificationSpec[Method];
+235
View File
@@ -0,0 +1,235 @@
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, CodexReportedError } from "./codex-client.js";
import { codexEventHistory } from "./codex-event-history.js";
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
import type { CodexReasoningEffort } from "./codex-protocol.js";
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; 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;
/** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */
let loadedThreadId = "";
export { summarizeCodexThread } from "./codex-history.js";
/** 将 Codex turn 加入串行队列并等待执行完成。 */
export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
if (!prompt.trim()) return;
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options));
await codexQueue;
}
/** 中断当前线程正在执行的 Codex turn。 */
export async function interruptCodexTurn(threadId?: string) {
if (!codexApp) return false;
return await codexApp.interruptCurrentTurn(threadId);
}
/** 回复当前 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);
loadedThreadId = String(field(thread, "id") || "");
return thread;
}
/** 恢复指定 Codex 线程并返回聊天历史。 */
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const app = await getCodexApp(emit);
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true);
const history = await loadCodexHistory(emit, threadId, cwd);
const supplementalItems = await codexEventHistory.readThread(threadId);
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
}
/** 查询当前工作空间中的 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 listCodexModels(emit: AgentEmit) {
return await (await getCodexApp(emit)).listModels();
}
/** 读取指定 Codex 线程及其聊天历史。 */
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
const history = await loadCodexHistory(emit, threadId, cwd);
const supplementalItems = await codexEventHistory.readThread(threadId);
return { thread: summarizeCodexThread(history.thread), messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
}
/** 归档指定 Codex 线程。 */
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const app = await getCodexApp(emit);
try {
await loadCodexThread(emit, threadId, cwd, false);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
await resumeLoadedThread(app, threadId, cwd, "request", false);
}
await app.archiveThread(threadId);
app.clearPlanUpdates(threadId);
await codexEventHistory.removeThread(threadId);
if (loadedThreadId === threadId) loadedThreadId = "";
}
/** 判断线程异常是否允许自动新建线程后重试。 */
export function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error));
}
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
options.onStart?.();
files = await writeAttachmentFiles(attachments);
const app = await getCodexApp(options.appEmit || lifecycleEmit);
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
options.onThread?.(threadId);
try {
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
loadedThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
options.onThread?.(threadId);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
if (!(error instanceof CodexReportedError)) lifecycleEmit("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 === loadedThreadId) return loadedThreadId;
try {
await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true);
return loadedThreadId;
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
loadedThreadId = "";
}
}
if (!loadedThreadId) {
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
loadedThreadId = String(field(thread, "id") || "");
}
return loadedThreadId;
}
/** 从 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 是否已经物化 turns。 */
async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) {
try {
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
} catch (error) {
if (/not materialized yet.*includeTurns/i.test(errorMessage(error))) return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false };
if (!isRecoverableThreadError(error)) throw error;
const app = await getCodexApp(emit);
const thread = await resumeLoadedThread(app, threadId, cwd, "request", false);
try {
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
} catch (historyError) {
if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError))) return { thread, historyReady: false };
throw historyError;
}
}
}
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true) {
const thread = await app.resumeThread(threadId, cwd, permissionMode);
assertThreadWorkspace(thread, cwd);
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
return thread;
}
/** 获取已启动的 Codex app-server 客户端。 */
async function getCodexApp(emit: AgentEmit) {
if (codexApp) return codexApp;
codexAppStart ||= CodexAppClient.start(emit, () => {
codexApp = null;
loadedThreadId = "";
});
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";
}
+8
View File
@@ -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";
-524
View File
@@ -1,524 +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 };
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 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() {
if (!codexApp) return false;
return codexApp.interruptCurrentTurn();
}
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
files = await writeAttachmentFiles(attachments);
codexApp ||= await CodexAppClient.start(emit);
let threadId = await ensureCodexThread(codexApp, options, emit);
try {
await codexApp.startTurn(threadId, prompt, files);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = "";
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
await codexApp.startTurn(threadId, prompt, files);
}
} catch (error) {
emit("agent_error", { message: errorMessage(error) });
} finally {
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
}
}
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit);
const thread = await codexApp.startThread(cwd);
codexThreadId = String(field(thread, "id") || "");
return thread;
}
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit);
await loadCodexThread(emit, threadId, cwd, false);
const thread = await codexApp.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 }) {
codexApp ||= await CodexAppClient.start(emit);
const result = await codexApp.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) {
codexApp ||= await CodexAppClient.start(emit);
await loadCodexThread(emit, threadId, cwd, false);
await codexApp.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;
}
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[]) {
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");
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 });
this.deltaCount = 0;
this.emit("agent_done", { agent: "codex", usage: event.usage });
}
}
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 } });
}
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 {
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
if (method === "turn/started") return { type: "turn.started" };
if (method === "turn/completed") return { type: "turn.completed", usage: null };
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
if (method === "error") return { type: "error", message: field(params, "message") };
return null;
}
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
codexApp ||= await CodexAppClient.start(emit);
const result = await codexApp.readThread(threadId, includeTurns);
const thread = field(result, "thread") || {};
assertThreadWorkspace(thread, cwd);
return thread;
}
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_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);
}
-265
View File
@@ -1,265 +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 { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
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",
]);
export class CanvasSession {
private clients = new Map<string, ServerResponse>();
private pending = new Map<string, PendingRequest>();
private canvasState: CanvasSnapshot | null = null;
health() {
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
}
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);
sendEvent(res, "hello", { ok: true, clientId });
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => {
clearInterval(timer);
if (!statusOnly) this.clients.delete(clientId);
if (this.canvasState?.clientId === clientId) this.canvasState = null;
});
}
updateState(body: unknown, clientId?: string) {
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
}
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
const item = body.requestId ? this.pending.get(body.requestId) : null;
if (!item || !body.requestId) return;
this.pending.delete(body.requestId);
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
}
emitAll(type: string, payload: unknown) {
this.clients.forEach((client) => sendEvent(client, type, payload));
}
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_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 requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
const requestId = crypto.randomUUID();
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
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, { 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 !== ""));
}
+168
View File
@@ -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",
@@ -14,6 +15,7 @@ export const toolNames = [
"canvas_export_snapshot",
"canvas_apply_ops",
"canvas_create_node",
"canvas_create_attachment_nodes",
"canvas_create_text_node",
"canvas_create_text_nodes",
"canvas_create_config_node",
@@ -32,6 +34,7 @@ export const toolNames = [
"canvas_select_nodes",
"canvas_set_viewport",
"canvas_run_generation",
"generation_get_status",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
@@ -93,6 +96,7 @@ export const toolInputSchemas = {
canvas_export_snapshot: z.object({}).passthrough(),
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
@@ -111,6 +115,7 @@ export const toolInputSchemas = {
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
canvas_set_viewport: z.object({ viewport: viewportSchema }),
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
workbench_image_get_config: z.object({}).passthrough(),
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
workbench_video_get_config: z.object({}).passthrough(),
@@ -128,6 +133,7 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
canvas_create_text_node: "在当前画布创建单个文本节点。",
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
@@ -146,10 +152,11 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_select_nodes: "设置当前选中节点。",
canvas_set_viewport: "调整画布视口。",
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
assets_list: "列出用户「我的素材」,支持 kindtext/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
+479
View File
@@ -0,0 +1,479 @@
import { EventEmitter } from "node:events";
import type { ServerResponse } from "node:http";
import assert from "node:assert/strict";
import test from "node:test";
import { CanvasSession } from "./session.js";
test("MCP 读取当前激活网页的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("画布写操作只发送给当前激活网页", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "canvas_apply_ops");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
t.after(() => first.close());
const dataUrl = "data:image/png;base64,aW1hZ2U=";
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
session.bindClient("first");
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
const call = first.event("tool_call");
const input = field(call, "input") as Record<string, unknown>;
const nodes = input.nodes as Array<Record<string, unknown>>;
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
assert.equal(nodes.length, 1);
assert.equal(nodes[0].attachmentId, "attachment-1");
assert.equal(nodes[0].title, "商品.png");
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
assert.equal(nodes[0].width, 640);
assert.equal(nodes[0].height, 320);
assert.equal("dataUrl" in nodes[0], false);
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
assert.equal(created.nodes[0].id, nodes[0].id);
assert.equal(created.nodes[0].attachmentId, "attachment-1");
session.clearTurnAttachments("first");
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
});
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
session.bindClient("second");
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
assert.equal(first.event("tool_call"), undefined);
assert.equal(second.event("tool_call"), undefined);
});
test("tool result is accepted only from the request client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("first");
const result = session.callTool("canvas_create_text_node", { text: "first only" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
assert.deepEqual(await result, { client: "first" });
});
test("生成状态查询由当前激活网页返回", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.activateClient("second");
const result = session.callTool("generation_get_status", { scope: "all" });
const call = second.event("tool_call");
assert.equal(first.event("tool_call"), undefined);
assert.equal(field(call, "name"), "generation_get_status");
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
});
test("活动网页关闭后回退到仍连接的画布", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
});
test("closing the active client falls back to the most recently focused client", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
const third = connect(session, "third");
t.after(() => {
first.close();
second.close();
third.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.updateState(snapshot("canvas-third"), "third");
session.activateClient("third");
session.activateClient("second");
second.close();
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
});
test("closing a client rejects its pending tool requests", async () => {
const session = new CanvasSession();
const first = connect(session, "first");
const result = session.callTool("canvas_create_text_node", { text: "pending" });
const call = first.event("tool_call");
const requestId = String(field(call, "requestId"));
first.close();
const outcome = await Promise.race([
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
]);
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
assert.match(outcome, /断开/);
});
test("shared thread events are broadcast with the active thread id", (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
});
test("new clients receive the current Codex state and later updates", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
session.trackCodexEvent("codex_approval", { requestId: "approval-1", threadId: "thread-2" });
const client = connect(session, "first", "thread-2");
t.after(() => client.close());
const hello = client.event("hello");
assert.equal(field(hello, "protocolVersion"), 3);
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
session.trackCodexEvent("codex_approval_resolved", { requestId: "approval-1" });
assert.deepEqual(session.codexPendingApprovals, []);
session.trackCodexEvent("codex_approval", { requestId: "approval-2", threadId: "thread-2" });
session.setCodexState({ busy: false });
assert.deepEqual(session.codexPendingApprovals, [{ requestId: "approval-2", threadId: "thread-2" }]);
session.trackCodexEvent("agent_error", { message: "app-server exited" });
assert.deepEqual(session.codexPendingApprovals, []);
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
});
test("Codex 写操作在多窗口之间互斥且不能与运行 turn 并发", () => {
const session = new CanvasSession();
assert.equal(session.beginCodexMutation(), true);
assert.equal(session.beginCodexMutation(), false);
session.endCodexMutation();
assert.equal(session.beginCodexMutation(), true);
session.endCodexMutation();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
assert.equal(session.beginCodexMutation(), false);
});
test("a bound client remains the tool target while focus changes", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
const result = session.callTool("canvas_create_text_node", { text: "bound" });
const call = first.event("tool_call");
assert.equal(second.event("tool_call"), undefined);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
session.releaseClient("first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
});
test("a disconnected bound client never falls back and can resume with the same client id", async (t) => {
const session = new CanvasSession();
const first = connect(session, "first");
const second = connect(session, "second");
t.after(() => {
first.close();
second.close();
});
session.updateState(snapshot("canvas-first"), "first");
session.updateState(snapshot("canvas-second"), "second");
session.bindClient("first");
session.activateClient("second");
first.close();
await assert.rejects(session.callTool("canvas_get_state", {}), /当前没有已连接画布/);
assert.equal(second.event("tool_call"), undefined);
const reconnected = connect(session, "first");
t.after(() => reconnected.close());
session.updateState(snapshot("canvas-first-reconnected"), "first");
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first-reconnected");
const result = session.callTool("canvas_create_text_node", { text: "reconnected" });
const call = reconnected.event("tool_call");
assert.equal(second.event("tool_call"), undefined);
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
assert.deepEqual(await result, { ok: true });
});
test("新连接会回放当前运行 turn 的最新事件快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("chat_message"), [{ threadId: "thread-1", turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" }, replayed: true }]);
assert.deepEqual(client.events("agent_event"), [{ threadId: "thread-1", turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" }, replayed: true }]);
});
test("同一 item 的多次增量只回放最新内容", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段和第二段" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.equal(events.length, 1);
assert.equal(field(field(events[0], "item"), "text"), "第一段和第二段");
});
test("增量事件重放时转换为完整文本快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第一段" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第二段" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const [event] = client.events("agent_event") as Array<Record<string, unknown>>;
assert.deepEqual(field(event, "item"), { id: "assistant-1", type: "agent_message", text: "第一段第二段" });
});
test("并行 item 更新后重放仍保留开始顺序和命令字段", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "second", type: "command_execution", command: "second" } });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "first", type: "command_execution", delta: "output" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.deepEqual(events.map((event) => field(field(event, "item"), "id")), ["first", "second"]);
assert.deepEqual(field(events[0], "item"), { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas", text: "output" });
});
test("长 turn 不会淘汰仍在更新的活动条目快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "active-item", type: "command_execution", command: "long-running" } });
for (let index = 0; index < 260; index += 1) {
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: `completed-${index}`, type: "command_execution", command: `command-${index}` } });
}
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.equal((client.events("agent_event") as Array<Record<string, unknown>>).some((event) => field(field(event, "item"), "id") === "active-item"), true);
});
test("内置生图事件会回放展示但标记为不可重复执行", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("agent_event"), [{
threadId: "thread-1",
turnId: "turn-1",
type: "item.completed",
item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" },
replayed: true,
}]);
});
test("turn 结束后保留实时快照,直到网页确认权威历史", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
session.setCodexState({ busy: false });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.equal(client.events("agent_event").length, 1);
session.acknowledgeCodexHistory("thread-1", ["turn-1"]);
const next = connect(session, "second", "thread-1");
t.after(() => next.close());
assert.deepEqual(next.events("agent_event"), []);
});
test("开始下一 turn 时只回放当前 turn 的事件", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第一轮" } });
session.setCodexState({ busy: false, turnId: "turn-1" });
session.setCodexState({ busy: true, turnId: "turn-2" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-2", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第二轮" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
const events = client.events("agent_event") as Array<Record<string, unknown>>;
assert.equal(events.length, 1);
assert.deepEqual(events.map((event) => field(field(event, "item"), "text")), ["第二轮"]);
});
test("同一用户消息从 pending 绑定 turn 后只回放最终版本", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "" });
session.emitThread("chat_message", "thread-1", { message: { id: "pending", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
session.setCodexState({ turnId: "turn-1" });
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("chat_message"), [{
threadId: "thread-1",
turnId: "turn-1",
message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" },
replayed: true,
}]);
});
test("切换活动线程会清除上一线程的实时快照", (t) => {
const session = new CanvasSession();
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
session.setCodexState({ busy: false, threadId: "thread-1", turnId: "turn-1" });
session.setCodexState({ threadId: "thread-2", turnId: "" });
session.setCodexState({ threadId: "thread-1", turnId: "" });
const client = connect(session, "first", "thread-1");
t.after(() => client.close());
assert.deepEqual(client.events("agent_event"), []);
});
/** 创建用于测试的画布 SSE 连接。 */
function connect(session: CanvasSession, clientId: string, activeThreadId = "") {
const response = new FakeSseResponse();
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse, activeThreadId);
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) {
return this.events(type)[0];
}
/** 读取指定类型的全部 SSE 事件数据。 */
events(type: string) {
return this.chunks.flatMap((chunk) => {
if (!chunk.startsWith(`event: ${type}\n`)) return [];
const data = chunk.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
return data ? [JSON.parse(data) as unknown] : [];
});
}
/** 触发连接关闭事件。 */
close() {
this.emit("close");
}
}
+407
View File
@@ -0,0 +1,407 @@
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 };
type ReplayEvent = { type: string; payload: Record<string, unknown> };
export type CodexState = { busy: boolean; threadId: string; turnId: string };
export const AGENT_PROTOCOL_VERSION = 3;
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 pendingApprovals = new Map<string, Record<string, unknown>>();
private canvasStates = new Map<string, CanvasSnapshot>();
private turnAttachments = new Map<string, TurnAttachment>();
private codexReplayEvents = new Map<string, ReplayEvent>();
private codexReplayActiveItems = new Set<string>();
private codexMutationBusy = false;
private activeClientId = "";
private boundClientId = "";
private focusSequence = 0;
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
/** 获取当前目标网页的画布状态。 */
private get canvasState() {
return this.clients.has(this.targetClientId) ? this.canvasStates.get(this.targetClientId) || null : null;
}
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
private get targetClientId() {
return this.boundClientId || this.activeClientId;
}
/** 返回 Canvas Agent 当前连接状态。 */
health() {
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
}
/** 返回 Codex 是否正在执行任务。 */
get codexBusy() {
return this.codexState.busy;
}
get codexThreadId() {
return this.codexState.threadId;
}
/** 判断网页客户端是否仍连接到当前 Agent。 */
hasClient(clientId: string) {
return this.clients.has(clientId);
}
/** 原子取得 Codex 写操作权限,避免多个网页并发切换或修改会话。 */
beginCodexMutation() {
if (this.codexState.busy || this.codexMutationBusy) return false;
this.codexMutationBusy = true;
return true;
}
/** 释放 Codex 写操作权限。 */
endCodexMutation() {
this.codexMutationBusy = false;
}
/** 返回当前 Codex turn 的线程、turn 和发起网页。 */
get codexEventScope() {
return {
threadId: this.codexState.threadId,
turnId: this.codexState.busy ? this.codexState.turnId : "",
sourceClientId: this.codexState.busy ? this.boundClientId : "",
};
}
/** 返回刷新后仍需展示的 Codex 权限请求。 */
get codexPendingApprovals() {
return [...this.pendingApprovals.values()];
}
/** 跟踪需要跨页面重连恢复的 Codex 权限请求。 */
trackCodexEvent(type: string, payload: Record<string, unknown>) {
const requestId = String(payload.requestId || "");
if (type === "codex_approval" && requestId) this.pendingApprovals.set(requestId, payload);
if (type === "codex_approval_resolved" && requestId) this.pendingApprovals.delete(requestId);
if (type === "agent_error") this.pendingApprovals.clear();
}
/** 更新并广播 Codex 运行状态。 */
setCodexState(patch: Partial<CodexState>) {
const next = { ...this.codexState, ...patch };
const threadChanged = next.threadId !== this.codexState.threadId;
const turnChanged = Boolean(this.codexState.turnId && next.turnId && next.turnId !== this.codexState.turnId);
const nextTurnStarted = !this.codexState.busy && next.busy;
if (threadChanged || turnChanged || nextTurnStarted) {
this.codexReplayEvents.clear();
this.codexReplayActiveItems.clear();
}
if (!next.busy) {
if (this.boundClientId && !this.clients.has(this.boundClientId)) this.boundClientId = "";
}
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);
}
/** 权威历史已覆盖指定 turn 后,清理其断线重放事件。 */
acknowledgeCodexHistory(threadId: string, turnIds: string[]) {
const acknowledged = new Set(turnIds.filter(Boolean));
if (!threadId || !acknowledged.size) return;
this.codexReplayEvents.forEach((event, key) => {
const eventThreadId = String(event.payload.threadId || event.payload.thread_id || "");
const eventTurnId = String(event.payload.turnId || event.payload.turn_id || "");
if (eventThreadId === threadId && acknowledged.has(eventTurnId)) {
this.codexReplayEvents.delete(key);
this.codexReplayActiveItems.delete(key);
}
});
}
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
openEvents(url: URL, res: ServerResponse, activeThreadId = "") {
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, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId) this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
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);
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 || !this.clients.has(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> = {}) {
const data: Record<string, unknown> = { ...payload, threadId };
const replayKey = codexReplayKey(type, data);
const eventTurnId = String(data.turnId || data.turn_id || "");
const currentScope = threadId === this.codexState.threadId && (!this.codexState.turnId || !eventTurnId || eventTurnId === this.codexState.turnId);
if (this.codexState.busy && currentScope && replayKey) {
const item = recordValue(data.item);
const eventType = String(data.type || "");
if (type === "agent_event" && item.id && (eventType === "item.started" || eventType === "item.updated")) this.codexReplayActiveItems.add(replayKey);
if (type === "agent_event" && item.id && eventType === "item.completed") this.codexReplayActiveItems.delete(replayKey);
if (type === "agent_event" && (eventType === "turn.completed" || eventType === "error")) this.clearReplayActiveTurn(threadId, eventTurnId);
const replayData = this.replaySnapshot(replayKey, data);
this.codexReplayEvents.set(replayKey, { type, payload: { ...replayData, replayed: true } });
while (this.codexReplayEvents.size > 240) {
const evictable = [...this.codexReplayEvents.keys()].find((key) => !this.codexReplayActiveItems.has(key));
if (!evictable) break;
this.codexReplayEvents.delete(evictable);
}
}
this.emitAll(type, data);
}
/** 为断线重连保存完整的最新文本快照,实时连接仍只接收增量。 */
private replaySnapshot(replayKey: string, data: Record<string, unknown>) {
if (data.type !== "item.updated" && data.type !== "item.completed") return data;
const item = recordValue(data.item);
if (!item.id) return data;
const previous = recordValue(recordValue(this.codexReplayEvents.get(replayKey)?.payload).item);
const delta = String(item.delta || "");
if (!delta) return data;
const previousText = String(previous.text || "");
const { delta: _delta, ...snapshotItem } = item;
return { ...data, item: { ...previous, ...snapshotItem, text: `${previousText}${delta}` } };
}
private clearReplayActiveTurn(threadId: string, turnId: string) {
const prefix = `item:${turnId}:`;
this.codexReplayActiveItems.forEach((key) => {
if (key.startsWith(prefix)) this.codexReplayActiveItems.delete(key);
});
if (!turnId) return;
this.codexReplayActiveItems.forEach((key) => {
const event = this.codexReplayEvents.get(key);
const eventThreadId = String(event?.payload.threadId || event?.payload.thread_id || "");
const eventTurnId = String(event?.payload.turnId || event?.payload.turn_id || "");
if (eventThreadId === threadId && eventTurnId === turnId) this.codexReplayActiveItems.delete(key);
});
}
/** 校验工具参数并将调用分派到当前目标网页。 */
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)) });
});
}
}
/** 为运行中 turn 的可重放事件生成稳定键。 */
function codexReplayKey(type: string, payload: Record<string, unknown>) {
const turnId = String(payload.turnId || payload.turn_id || "");
if (type === "chat_message") {
const message = recordValue(payload.message);
const clientMessageId = String(message.clientMessageId || "");
if (clientMessageId) return `chat:${clientMessageId}`;
const messageId = String(message.itemId || message.id || "");
return messageId ? `chat:${turnId}:${messageId}` : "";
}
if (type === "agent_error") return `error:${turnId}`;
if (type !== "agent_event") return "";
const item = recordValue(payload.item);
if (item.id) return `item:${turnId}:${String(item.id)}`;
const eventType = String(payload.type || "");
if (eventType === "plan.updated") return `plan:${turnId}`;
if (eventType === "usage.updated") return `usage:${turnId}`;
if (eventType === "turn.completed" || eventType === "error") return `${eventType}:${turnId}`;
return "";
}
function recordValue(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
/** 向 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 = { name?: string; type?: string; dataUrl?: string };
+21 -4
View File
@@ -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。若当前不在画布页,画布工具会报错,需先用 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 };
-143
View File
@@ -1,143 +0,0 @@
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, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import type { AgentAttachment } from "./types.js";
export function startHttpServer() {
const config = loadConfig(true);
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
config.url = `http://127.0.0.1:${port}`;
saveConfig(config);
const session = new CanvasSession();
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "30mb" }));
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" });
if (req.method === "OPTIONS") return void res.json({});
next();
});
app.get("/health", (_req, res) => res.json(session.health()));
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
app.use((req, res, next) => {
if (validToken(req, requestUrl(req, config), config.token)) return next();
res.status(401).json({ ok: false, error: "invalid token" });
});
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
app.post("/canvas/state", (req, res) => {
session.updateState(req.body, String(req.query.clientId || "") || undefined);
res.json({ ok: true });
});
app.post("/canvas/result", (req, res) => {
session.resolveResult(req.body);
res.json({ ok: true });
});
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);
res.json({ ok: true, workspace });
});
app.get("/agent/codex/threads", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
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) => {
const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath);
const activeThreadId = String((thread as Record<string, unknown>).id || "");
updateSiteWorkspace(config, { activeThreadId });
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
}));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
}));
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateSiteWorkspace(config, { activeThreadId: threadId });
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
}));
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
res.json({ ok: true });
}));
app.post("/agent/codex/turn", route(async (req, res) => {
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureSiteWorkspace(config);
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath);
threadId = String((thread as Record<string, unknown>).id || "");
updateSiteWorkspace(config, { activeThreadId: threadId });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
updateSiteWorkspace(config, { activeThreadId: threadId });
}
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
res.json({ ok: true, threadId });
}));
app.post("/agent/codex/interrupt", (_req, res) => {
const ok = interruptCodexTurn();
res.json({ ok });
});
app.post("/agent/claude/turn", (req, res) => {
runClaudeTurn(withAgentPrompt(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.listen(port, "127.0.0.1", () => {
console.log("Infinite Canvas Agent");
console.log(`Local URL: ${config.url}`);
console.log(`Connect token: ${config.token}`);
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");
});
}
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
}
function routeParam(value: string | string[]) {
return Array.isArray(value) ? value[0] || "" : value;
}
function requestUrl(req: Request, config: CanvasAgentConfig) {
return new URL(req.originalUrl || req.url || "/", config.url);
}
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
const origin = req.headers.origin;
res.setHeader("Access-Control-Allow-Origin", origin || "*");
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Private-Network", "true");
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
config.origins ||= [];
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
config.origins.push(origin);
saveConfig(config);
}
res.setHeader("Vary", "Origin");
return config.origins.includes(origin);
}
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));
}
+2 -2
View File
@@ -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();
+381
View File
@@ -0,0 +1,381 @@
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 { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
import { logger } from "../utils/logger.js";
import { checkVersions } from "../version-check.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;
config.url = `http://127.0.0.1:${port}`;
saveConfig(config);
const session = new CanvasSession();
/** 将 Agent 事件广播到所属线程或全部网页。 */
const emit = (type: string, payload: unknown) => {
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
const data = {
...value,
...(threadId ? { threadId, thread_id: threadId } : {}),
...(turnId ? { turnId, turn_id: turnId } : {}),
...(sourceClientId ? { sourceClientId } : {}),
};
session.trackCodexEvent(type, data);
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
/** 保存并广播当前站点工作空间的活跃线程。 */
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
if (!session.codexBusy && session.codexThreadId !== activeThreadId) session.setCodexState({ threadId: activeThreadId, turnId: "" });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
return workspace;
};
let draftThreadStart: ReturnType<typeof startCodexThread> | null = null;
const prepareDraftThread = (clientId: string, permission: AgentPermissionMode) => {
if (draftThreadStart) return draftThreadStart;
const workspace = ensureSiteWorkspace(config);
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
const start = startCodexThread(emit, workspace.workspacePath, permission);
draftThreadStart = start;
void start.then((thread) => {
if (draftThreadStart !== start) return;
draftThreadStart = null;
const threadId = String((thread as Record<string, unknown>).id || "");
if (threadId && !ensureSiteWorkspace(config).activeThreadId) setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId });
}).catch((error) => {
if (draftThreadStart === start) draftThreadStart = null;
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: error instanceof Error ? error.message : String(error) });
});
return start;
};
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" });
if (req.method === "OPTIONS") return void res.json({});
next();
});
app.get("/health", (_req, res) => res.json(session.health()));
app.get("/config", (_req, res) => res.json({ ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, url: config.url, hasToken: true }));
app.use((req, res, next) => {
if (validToken(req, requestUrl(req, config), config.token)) return next();
res.status(401).json({ ok: false, error: "invalid token" });
});
app.get("/events", (req, res) => {
session.openEvents(requestUrl(req, config), res, ensureSiteWorkspace(config).activeThreadId || "");
});
app.post("/canvas/state", (req, res) => {
session.updateState(req.body, String(req.query.clientId || "") || undefined);
res.json({ ok: true });
});
app.post("/canvas/activate", (req, res) => {
session.activateClient(String(req.query.clientId || ""));
res.json({ ok: true });
});
app.post("/canvas/result", (req, res) => {
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
res.status(ok ? 200 : 409).json({ ok });
});
app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
const data = attachment.dataUrl.split(",", 2)[1];
if (!data) throw new Error("图片附件内容无效");
res.setHeader("Cache-Control", "no-store");
res.type(attachment.type).send(Buffer.from(data, "base64"));
}));
app.post("/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);
res.json({ ok: true, workspace });
});
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
app.get("/agent/codex/threads", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
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", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
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, sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
}));
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
const clientId = String(req.body?.clientId || "");
const workspace = setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId });
void prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
res.json({ ok: true, workspace });
}));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
}));
app.post("/agent/codex/history/ack", (req, res) => {
const threadId = String(req.body?.threadId || "");
const turnIds = Array.isArray(req.body?.turnIds) ? req.body.turnIds.map(String) : [];
session.acknowledgeCodexHistory(threadId, turnIds);
res.json({ ok: true });
});
app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const nextWorkspace = setActiveThread(threadId, { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, ...result });
}));
app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true });
}));
app.post("/agent/codex/turn", codexMutation(async (req, res) => {
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureSiteWorkspace(config);
const prompt = String(req.body?.prompt || "");
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
const clientId = String(req.body?.clientId || "");
if (!clientId || !session.hasClient(clientId)) return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
const requestedThreadId = String(req.body?.threadId || "");
const activeThreadId = workspace.activeThreadId || "";
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
const model = String(req.body?.model || "") || undefined;
const effort = reasoningEffort(req.body?.effort);
const messageId = String(req.body?.messageId || Date.now());
const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
let threadId = activeThreadId;
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
session.bindClient(clientId);
session.setCodexState({ busy: true, threadId, turnId: "" });
try {
let turnId = "";
if (!threadId) {
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
threadId = String((thread as Record<string, unknown>).id || "");
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
session.emitThread("chat_message", threadId, {
sourceClientId: clientId,
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
});
let chatTurnId = "";
/** 将包装层日志和兜底错误固定广播到当前 turn。 */
const lifecycleEmit = (type: string, payload: unknown) => {
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
const eventThreadId = String(value.threadId || value.thread_id || threadId);
const eventTurnId = String(value.turnId || value.turn_id || turnId);
const sourceClientId = String(value.sourceClientId || clientId);
session.emitThread(type, eventThreadId, {
...value,
threadId: eventThreadId,
thread_id: eventThreadId,
...(eventTurnId ? { turnId: eventTurnId, turn_id: eventTurnId } : {}),
...(sourceClientId ? { sourceClientId } : {}),
});
};
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), lifecycleEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
permissionMode: permissionMode(req.body?.permissionMode),
model,
effort,
appEmit: emit,
onStart: () => session.bindClient(clientId),
onThread: (actualThreadId) => {
const threadChanged = actualThreadId !== threadId;
if (actualThreadId !== threadId) {
threadId = actualThreadId;
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
if (threadChanged) {
session.emitThread("chat_message", threadId, {
sourceClientId: clientId,
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
});
}
},
onTurn: (actualTurnId) => {
turnId = actualTurnId;
if (chatTurnId !== turnId) {
chatTurnId = turnId;
session.emitThread("chat_message", threadId, {
turnId,
sourceClientId: clientId,
message: { id: `${threadId}:${turnId}:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId, role: "user", text: messageText },
});
}
logger.info("Codex turn started", { threadId, turnId, model: model || "default", reasoningEffort: effort || "default" });
session.setCodexState({ busy: true, threadId, turnId });
},
onFinish: () => {
logger.info("Codex turn finished", { threadId, turnId });
session.clearTurnAttachments(clientId);
if (clientId) session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId });
},
});
res.json({ ok: true, threadId });
} catch (error) {
session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId: "" });
throw error;
}
}));
/** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
function codexMutation(handler: (req: Request, res: Response) => unknown | Promise<unknown>) {
return route(async (req, res) => {
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, error: "Codex 正在运行或正在切换会话,请稍后重试" });
try {
return await handler(req, res);
} finally {
session.endCodexMutation();
}
});
}
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(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) => {
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");
checkVersions();
console.log(`Local URL: ${config.url}`);
console.log(`Connect token: ${config.token}`);
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 reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
}
/** 使用当前操作系统的文件管理器定位本地文件。 */
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 || "*");
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Private-Network", "true");
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
config.origins ||= [];
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
config.origins.push(origin);
saveConfig(config);
}
res.setHeader("Vary", "Origin");
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");
return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
}
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
/** 将日期格式化为适合文件名使用的本地日期字符串。 */
export function formatDateForFilename(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+75
View File
@@ -0,0 +1,75 @@
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;
/** 普通模式输出 Info 以上日志,Debug 模式额外输出 Debug 并写入文件。 */
constructor() {
const line = format.printf(({level, message, timestamp, details}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
const output = format.combine(format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}), line);
if (this.enabled) fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
this.logger = winston.createLogger({
level: this.enabled ? "debug" : "info",
transports: [
new transports.Console({format: output}),
...(this.enabled ? [new transports.File({filename: this.filePath, format: output})] : []),
],
});
}
/** 输出 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();
+11
View File
@@ -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);
}
+65
View File
@@ -0,0 +1,65 @@
import { execFile, execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { promisify } from "node:util";
import { VERSION } from "./config.js";
import { logger } from "./utils/logger.js";
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const CODEX_VERSION = String((require("@openai/codex/package.json") as { version: string }).version);
/** 输出当前版本,并在后台检查 npm 最新版本。 */
export function checkVersions() {
const localCodexVersion = commandVersion("codex");
logger.info("Canvas Agent version", { version: VERSION });
logger.info("Bundled Codex version", { version: CODEX_VERSION });
logger.info("Local Codex version", { version: localCodexVersion || "not found" });
if (!localCodexVersion) {
logger.warn("Local Codex was not found. Install the latest version with: npm install -g @openai/codex@latest");
} else if (localCodexVersion !== CODEX_VERSION) {
logger.warn(`Bundled Codex ${CODEX_VERSION} does not match local Codex ${localCodexVersion}. Keep both current with: npm install -g @openai/codex@latest && npx -y @basketikun/canvas-agent@latest`);
}
void checkLatestVersions(localCodexVersion);
}
/** 查询 npm,提醒升级不再维护的旧版本。 */
async function checkLatestVersions(localCodexVersion: string) {
try {
const [latestAgent, latestCodex] = await Promise.all([
npmVersion("@basketikun/canvas-agent"),
npmVersion("@openai/codex"),
]);
if (isOlder(VERSION, latestAgent)) logger.warn(`Update available: Canvas Agent ${VERSION} -> ${latestAgent}. Run: npx -y @basketikun/canvas-agent@latest`);
if (isOlder(CODEX_VERSION, latestCodex)) logger.warn(`Update available: bundled Codex ${CODEX_VERSION} -> ${latestCodex}. Upgrade Canvas Agent with: npx -y @basketikun/canvas-agent@latest`);
if (localCodexVersion && isOlder(localCodexVersion, latestCodex)) logger.warn(`Update available: local Codex ${localCodexVersion} -> ${latestCodex}. Run: npm install -g @openai/codex@latest`);
} catch {
logger.warn("Unable to check the latest npm versions; startup will continue.");
}
}
/** 读取本机命令输出中的语义版本号。 */
function commandVersion(command: string) {
try {
return execFileSync(command, ["--version"], { encoding: "utf8", timeout: 5_000 }).match(/\d+\.\d+\.\d+/)?.[0] || "";
} catch {
return "";
}
}
/** 读取 npm 包的最新版本。 */
async function npmVersion(name: string) {
const command = process.platform === "win32" ? "npm.cmd" : "npm";
const { stdout } = await execFileAsync(command, ["view", name, "version"], { encoding: "utf8", timeout: 10_000 });
return stdout.trim();
}
/** 比较仅包含数字段的稳定版语义版本。 */
function isOlder(current: string, latest: string) {
const left = current.split(".").map(Number);
const right = latest.split(".").map(Number);
for (let index = 0; index < Math.max(left.length, right.length); index++) {
if ((left[index] || 0) !== (right[index] || 0)) return (left[index] || 0) < (right[index] || 0);
}
return false;
}
+2 -1
View File
@@ -10,5 +10,6 @@
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
@@ -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`。
@@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库
| 地址 | 状态 |
| --- | --- |
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
+53 -49
View File
@@ -5,52 +5,56 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 左侧画布面板「资产」Tab:搜索框旁「添加」按钮可上传图片/视频文件(支持多选)加入资产库;每张资产卡片悬停显示移除按钮,二次确认后删除;点击卡片仍插入到画布;需验证添加图片/视频、非图片视频文件的提示、移除确认与插入行为
- 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度
- 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互
- 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确
- 顶栏菜单:新增「导出当前画布」,导出当前画布为压缩包(与项目列表页单个导出格式一致);需验证导出文件名与内容
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 `{{input}}` 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互
- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载
- 画布节点插件系统:每个插件为 `plugins/canvas/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装
- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开
- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失
- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作
- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效
- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出
- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览
- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确
- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关
- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态
- 画布 AgentCodex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话
- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求
- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转
- 前端构建:修复 TypeScript 严格检查下的构建报错
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退
- 环境变量:移除过期的后端 `.env.example` 示例文件
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型
- 画布节点缩放稳定性:选中任意节点并反复拖动四角缩放时,节点工具条应在缩放期间隐藏、松开后恢复,页面不应再出现 `Maximum update depth exceeded`
- Agent MCP 初始化状态:首次进入空白对话或点击「新对话」后,无需先发送消息,对话区应立即显示 Codex 会话准备,并同时列出 `codex_apps`、`infinite-canvas`、`node_repl` 等全部 MCP 服务各自的启动、就绪或失败状态;初始化期间可输入并保留草稿,但发送按钮、上传和回车提交应禁用,全部完成后显示服务数量、恢复发送并复用同一个预热线程;右侧日志应记录同样的真实状态,`starting` 与 `ready` 归入信息且就绪显示绿色成功图标,只有 `failed` 归入错误、`cancelled` 归入警告,模型开始处理后不应残留初始化文案
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;后台应立即创建线程并预热 MCP,多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话或重复创建线程
- 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 后应输出 Canvas Agent、内置 Codex 和本机 Codex 版本,实际运行的内置 Codex 应为 0.146.0;内置与本机版本不一致、未安装本机 Codex,或 npm 存在更新版本时应显示对应升级提醒,npm 检查失败不应阻止服务启动;Agent 应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务
- Canvas Agent Debug:普通启动应通过公共 logger 输出 Info、Warn 和 Error 日志;使用 `npx -y @basketikun/canvas-agent --debug` 启动后还应输出 Debug 日志并显示日志文件路径,终端统一采用“`YYYY-MM-DD HH:mm:ss` 级别 消息 详情”的纯文本单行格式,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;日志中不应出现连接 token 或图片 Data URL 原文
- Canvas Agent Codex 日志:Codex app-server 输出带颜色或样式控制符及 UTC 时间的 stderr 时,本地日志和网页诊断日志应只显示一份 `YYYY-MM-DD HH:mm:ss` 本地时间和干净文本,不应出现重复 ISO 时间或 ``、`` 等 ANSI 转义内容
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON
- Agent 排查日志:右侧「日志」中的排查视图应显示连接摘要,并可按错误、警告和信息筛选;向下查看日志时仅列表内部滚动,标题、连接状态、筛选数量和操作按钮保持固定;连续相同事件应折叠计数,单条日志可展开查看完整详情;原始 JSON、复制全部、复制最近错误和清空功能应继续可用,浅色与深色主题下均应清晰
- Agent 排查日志顺序与跟随:日志应按时间从旧到新排列,进入日志或切换筛选时定位到最新事件;停留底部时自动跟随新日志,展开最后一条后应继续停留在完整详情底部,向上浏览后暂停跟随并显示与对话区视觉高度一致的居中圆形向下箭头,新日志数量只在悬浮提示中显示;列表底部不应出现额外留白,点击按钮后回到底部并恢复跟随
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败
- Agent 对话实时与历史一致性:运行包含思考、命令及画布工具的较长任务,在执行中刷新发起页面或打开第二个页面时,已出现的用户消息、思考过程、工具卡和回复前缀均应保留并继续更新;同一思考或工具只显示一张卡片,任务结束及再次刷新后内容、顺序和状态应一致。切换页面焦点不能改变本轮画布工具目标,发起页面断开后不得把图片或写操作转交其他画布,使用相同页面身份重连后才能继续处理
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,多行代码块和文本流程图应保留原始换行并允许横向滚动,完成事件不应再用「已完成分析」覆盖已经收到的摘要;同一轮连续命令应合并为显示数量的无边框折叠行,折叠时不显示命令预览,单条展开后直接显示详情,多条展开后可逐条查看工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;提交决定后卡片应保持禁用等待,只有 Codex 确认处理后才移除;等待审批时刷新页面,未处理的审批卡应自动恢复且不能让任务永久卡住;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话,并在后台创建新线程、预热 MCP;需要继续旧对话时可在「历史」中主动选择恢复
- 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 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效
- 画布组装提示词:输入或连接超长提示词后,浮层正文高度不应继续撑大,内容应可在正文区域内滚动查看和编辑,标题及关闭按钮保持可见
- 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布
- 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空
- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制
- 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框
- 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产
- 提示词详情弹窗:封面和参考图应固定显示在上方,复制及加入资产操作栏固定在底部,只有中间的标签、描述及提示词内容区域可以滚动;弹窗宽高应受视口限制且不超出屏幕
- 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;非运行状态关闭当前页面后应回退到最近聚焦且仍连接的页面,运行中关闭发起页面则不得把操作转交其他画布,只有同一页面身份重连后才能继续;其他页面回传同一请求结果应被拒绝
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示与日志完全相同尺寸、位置和样式的居中圆形向下箭头,新消息不强制打断阅读,对话底部不应出现额外留白,点击按钮后平滑回到底部并继续跟随新消息。
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
- Agent 命令记录:命令执行中应显示运行状态;完成后,同一轮相邻命令应合并为「已执行 N 条命令」折叠行且默认不显示原始命令,单条展开后直接显示完整详情,多条展开后可分别查看命令、输出和退出状态;对话结束或刷新后分组与记录不应消失。
-18
View File
@@ -32,7 +32,6 @@
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"three": "^0.180.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12",
},
@@ -42,7 +41,6 @@
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/three": "^0.180.0",
"@vitejs/plugin-react": "^5.2.0",
"prettier": "^3.8.3",
"typescript": "^5",
@@ -159,8 +157,6 @@
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
@@ -613,8 +609,6 @@
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -713,20 +707,14 @@
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
@@ -741,8 +729,6 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"@webgpu/types": ["@webgpu/types@0.1.71", "", {}, "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -1343,8 +1329,6 @@
"mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="],
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -1707,8 +1691,6 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+54
View File
@@ -0,0 +1,54 @@
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; 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 fetchAgentJson(endpoint, token, `/canvas/result?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 acknowledgeCodexHistory(endpoint: string, token: string, threadId: string, turnIds: string[]) {
await fetchAgentJson(endpoint, token, "/agent/codex/history/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId, turnIds }) });
}
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,230 @@
import { useRef, type ReactNode } from "react";
import { Button, Dropdown, Tooltip } from "antd";
import { ArrowUp, Check, ChevronUp, Cpu, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { canvasThemes } from "@/lib/canvas-theme";
import { isPlainEnterKey } from "@/lib/keyboard-event";
import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } 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,
models,
model,
reasoningEffort,
onModelChange,
onReasoningEffortChange,
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;
models?: AgentModel[];
model?: string;
reasoningEffort?: AgentReasoningEffort | "";
onModelChange?: (model: string) => void;
onReasoningEffortChange?: (effort: AgentReasoningEffort) => 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();
if (!canSubmit) return;
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={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}
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : 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 AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
const current = models.find((item) => item.model === model) || models[0];
return (
<div className="flex min-w-0 items-center gap-1">
<Select value={model} onValueChange={onModelChange}>
<SelectTrigger className="h-9 min-w-0 max-w-36 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" title={current.displayName || current.model} aria-label="选择 Codex 模型">
<Cpu className="size-3.5 shrink-0 opacity-70" />
<span className="min-w-0 flex-1 truncate text-left">{current.displayName || current.model}</span>
</SelectTrigger>
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] w-64 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
{models.map((item) => <SelectItem key={item.model} value={item.model}>{item.displayName || item.model}</SelectItem>)}
</SelectContent>
</Select>
<Select value={reasoningEffort} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}>
<SelectTrigger className="h-9 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" aria-label="选择推理强度">
<span>{effortLabels[reasoningEffort]}</span>
</SelectTrigger>
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)}
</SelectContent>
</Select>
</div>
);
}
const effortLabels: Record<AgentReasoningEffort, string> = {
minimal: "最低",
low: "轻度",
medium: "中",
high: "高",
xhigh: "极高",
max: "最高",
ultra: "Ultra",
};
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,548 @@
import { useEffect, useId, 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;
const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } 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={streamdownAnimation} 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" disabled={Boolean(approval.deciding)} loading={approval.deciding === "decline"} onClick={() => onDecision("decline")}></Button>
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "accept"} onClick={() => onDecision("accept")}></Button>
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "acceptForSession"} 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 <AgentCommandGroup items={[{ id: title, text, detail }]} theme={theme} />;
const state = toolCardState(title, text, detail);
const view = userDetail(detail);
const showText = title !== "读取画布" || text !== "已读取当前画布内容";
const className = "group min-w-0 rounded-xl border px-3 py-2.5 text-left";
const style = { borderColor: theme.node.stroke, background: "transparent", color: theme.node.text };
const content = (
<>
<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}
</>
);
if (!view) return <div className={className} style={style}>{content}</div>;
return (
<details className={className} style={style}>
<summary className="list-none cursor-pointer">{content}</summary>
<div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div>
</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={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
</div>
</details>
);
}
type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">;
export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const states = items.map((item) => commandViewState(item.detail));
const running = states.some((state) => state.running);
const failed = states.filter((state) => state.failed).length;
const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail)));
const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted;
const label = running
? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令"
: `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`;
const header = (
<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">{label}</span>
{expandable ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
</div>
);
if (!expandable) return <div className="min-w-0 py-1 text-left">{header}</div>;
return (
<details className="group min-w-0 text-left">
<summary className="cursor-pointer list-none py-1">{header}</summary>
{items.length === 1
? <AgentSingleCommand item={items[0]} theme={theme} />
: <div className="ml-6 mt-1">{items.map((item, index) => <AgentCommandEntry key={item.id} item={item} index={index} theme={theme} />)}</div>
}
</details>
);
}
function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const view = userDetail(item.detail);
return (
<div className="ml-6 pb-1">
{item.text ? <div className="mt-1.5 whitespace-pre-wrap break-all font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{item.text}</div> : null}
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
</div>
);
}
function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const [open, setOpen] = useState(false);
const detailId = useId();
const view = userDetail(item.detail);
const state = commandViewState(item.detail);
const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成";
const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a";
const content = (
<>
<span className="w-4 shrink-0 text-center text-[10px] tabular-nums opacity-50" style={{ color: theme.node.muted }}>{index + 1}</span>
<code className="min-w-0 flex-1 truncate text-[11px] leading-5" style={{ color: theme.node.text }} title={item.text}>{item.text || "命令"}</code>
<span className="shrink-0" style={{ color }} title={status} aria-label={status}>
{state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
</span>
{view ? <ChevronRight className={`size-3.5 shrink-0 transition-transform ${open ? "rotate-90" : ""}`} style={{ color: theme.node.muted }} /> : null}
</>
);
return (
<div className={index ? "border-t" : ""} style={{ borderColor: theme.node.stroke }}>
{view
? <button type="button" className="flex w-full min-w-0 items-center gap-2 py-2 text-left" aria-expanded={open} aria-controls={detailId} onClick={() => setOpen((value) => !value)}>{content}</button>
: <div className="flex min-w-0 items-center gap-2 py-2 text-left">{content}</div>}
{view && open ? <div id={detailId} className="pb-2 pl-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
</div>
);
}
function commandViewState(detail: unknown) {
const status = String(objectField(detail, "status") || "").toLowerCase();
return {
running: ["inprogress", "in_progress", "running", "started", "pending"].includes(status),
failed: ["failed", "error"].includes(status),
};
}
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, detail, status = "running", mcpStatuses = [], activityKey, theme }: { text: string; detail?: string; status?: "running" | "ready" | "error"; mcpStatuses?: Array<{ name: string; status: "running" | "ready" | "error"; detail: 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 }}>
{status === "running" ? <LoaderCircle className="size-3.5 shrink-0 animate-spin" /> : status === "ready" ? <CheckCircle2 className="size-3.5 shrink-0 text-emerald-600" /> : <XCircle className="size-3.5 shrink-0 text-red-600" />}
<span className="min-w-0">{text}</span>
{status === "running" && elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
</div>
{detail ? <div className="ml-5.5 mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>{detail}</div> : null}
{mcpStatuses.length ? (
<div className="ml-5.5 mt-3 space-y-2">
{mcpStatuses.map((item) => (
<div key={item.name} className="flex min-w-0 items-start gap-2 text-xs leading-5" style={{ color: theme.node.muted }}>
{item.status === "running" ? <LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin" /> : item.status === "ready" ? <CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-emerald-600" /> : <XCircle className="mt-0.5 size-3.5 shrink-0 text-red-600" />}
<div className="min-w-0">
<div className="font-medium" style={{ color: theme.node.text }}>{item.name}</div>
<div className="opacity-65">{item.detail}</div>
</div>
</div>
))}
</div>
) : null}
{status === "running" && 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;
}
+183
View File
@@ -0,0 +1,183 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "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, AgentCommandGroup, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
const SCROLL_BOTTOM_THRESHOLD = 48;
const historyMessageStyle = { contentVisibility: "auto", containIntrinsicSize: "0 80px" } as const;
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 bootstrapStatus = useAgentStore((state) => state.bootstrapStatus);
const mcpStartupStatuses = useAgentStore((state) => state.mcpStartupStatuses);
const timeline = useMemo(() => groupTimelineMessages(messages), [messages]);
const listRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followMessagesRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const streaming = messages.some((message) => message.streamId);
const working = bootstrapStatus || 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]);
useEffect(() => {
const content = contentRef.current;
if (!content) return;
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
});
observer.observe(content);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [scrollToBottom, updateScrollState]);
return (
<div className="relative min-h-0 flex-1">
<div ref={listRef} className="thin-scrollbar h-full select-text overflow-y-auto" onScroll={updateScrollState}>
<div ref={contentRef} className="space-y-4 px-4 pt-4">
{timeline.map((entry) => entry.type === "commands"
? <AgentCommandGroupRow key={entry.id} items={entry.items} theme={theme} />
: <AgentChatMessageRow key={entry.item.id} item={entry.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 || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
</div>
</div>
{showScrollToBottom ? (
<AgentScrollToBottom theme={theme} title="查看最新消息" onClick={() => scrollToBottom()} />
) : 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={item.streamId ? undefined : historyMessageStyle}>
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
</div>
);
});
const AgentCommandGroupRow = memo(function AgentCommandGroupRow({ items, theme }: { items: AgentChatItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div style={items.some((item) => item.streamId) ? undefined : historyMessageStyle}>
<AgentCommandGroup items={items} theme={theme} />
</div>
);
});
type AgentTimelineEntry = { type: "message"; item: AgentChatItem } | { type: "commands"; id: string; items: AgentChatItem[] };
function groupTimelineMessages(messages: AgentChatItem[]) {
const timeline: AgentTimelineEntry[] = [];
let commands: AgentChatItem[] = [];
let commandScope = "";
const flushCommands = () => {
if (!commands.length) return;
timeline.push({ type: "commands", id: `commands:${commands[0].id}`, items: commands });
commands = [];
commandScope = "";
};
messages.forEach((item) => {
if (isPlanMessage(item)) return;
if (isCommandMessage(item)) {
const scope = item.threadId && item.turnId ? `${item.threadId}\0${item.turnId}` : item.id;
if (commands.length && scope !== commandScope) flushCommands();
commands.push(item);
commandScope = scope;
return;
}
flushCommands();
timeline.push({ type: "message", item });
});
flushCommands();
return timeline;
}
function isCommandMessage(item: AgentChatItem) {
return item.role === "tool" && item.detail && typeof item.detail === "object" && (item.detail as { kind?: unknown }).kind === "command";
}
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,617 @@
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 const REASONING_PLACEHOLDER = "正在分析任务…";
export type AgentEventPayload = {
agent?: string;
type?: string;
threadId?: string;
thread_id?: string;
turnId?: string;
turn_id?: string;
sourceClientId?: string;
replayed?: boolean;
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 === "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"));
const failed = Boolean(item.error?.message) || item.success === false || ["failed", "error"].includes(status);
const itemStatus = failed ? "failed" : status;
if (item.type === "reasoning") {
const text = readableText(item.summary);
if (completed && !text) return null;
return { role: "tool", title: "思考摘要", text: text || activityPlaceholder(item.type), detail: { kind: "reasoning", status: itemStatus } };
}
if (item.type === "plan") {
const text = stringText(item.text);
if (completed && !text && !item.error?.message) return null;
return { role: "tool", title: "执行计划", text: item.error?.message || text || activityPlaceholder(item.type), detail: { kind: "plan", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
}
if (item.type === "command_execution") {
const command = stringText(item.command);
const text = command || (completed ? failed ? "命令执行失败" : "命令已完成" : activityPlaceholder(item.type));
return { role: "tool", title: "执行命令", text, detail: commandActivityDetail(item, itemStatus) };
}
if (item.type === "file_change") {
const files = activityFiles(item.changes);
return { role: "tool", title: "修改文件", text: item.error?.message || fileActivitySummary(files, completed), detail: { kind: "file", status: itemStatus, files, ...(item.error?.message ? { output: item.error.message } : {}) } };
}
if (item.type === "web_search") {
return { role: "tool", title: "搜索资料", text: item.error?.message || webSearchSummary(item), detail: { kind: "search", status: itemStatus, rows: webSearchDetailRows(item), ...(item.error?.message ? { output: item.error.message } : {}) } };
}
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: item.error?.message || stringText(item.path) || (completed ? "已查看图片" : "正在查看图片"), detail: { kind: "image", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
if (item.type === "image_generation") {
return { role: "tool", title: "内置生图", text: item.error?.message || (completed ? failed ? "图片生成失败" : "图片生成完成" : "正在生成图片…"), detail: { kind: "image", status: itemStatus, savedPath: item.savedPath, ...(item.error?.message ? { output: item.error.message } : {}) } };
}
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: item.error?.message || (completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…"), detail: { kind: "context", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
if (isMcpToolItem(item)) {
const name = String(item.tool || "");
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}`, detail: toolDetail(item, itemStatus) };
}
if (item.type === "dynamic_tool_call") {
const name = String(item.tool || "");
const title = toolName(name);
return {
role: "tool",
title,
text: completed ? item.error?.message || readableText(item.contentItems) : `正在${toolAction(name)}`,
detail: toolDetail(item, itemStatus),
};
}
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: item.error?.message || (completed ? failed ? "协作任务失败" : "已完成协作任务" : "正在协作处理任务…"), detail: { kind: "tool", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
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 REASONING_PLACEHOLDER;
}
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] : []));
const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status;
return { kind: "command", status: commandStatus, 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");
if (!value || typeof value !== "object") return "";
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 registerLiveAgentTurn(
event: { replayed?: boolean; threadId?: string; thread_id?: string; turnId?: string; turn_id?: string },
authoritativeTurns: ReadonlySet<string>,
liveTurns: Set<string>,
) {
const threadId = event.threadId || event.thread_id || "";
const turnId = event.turnId || event.turn_id || "";
const key = threadId && turnId ? `${threadId}\0${turnId}` : "";
if (event.replayed && key && authoritativeTurns.has(key)) return false;
if (key) liveTurns.add(key);
return true;
}
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}` : "工具操作";
}
function siteToolSummary(name: string, result: unknown, input: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`;
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 "";
}
function isMcpToolItem(item?: AgentEventItem): item is AgentEventItem & { type: "mcp_tool_call" } {
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) {
input = parseToolArguments(input);
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, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
return [];
}
export function toolSummary(item?: AgentEventItem) {
const result = parseToolResult(item?.result);
const name = String(item?.tool || "");
if (name === "site_navigate" || isSiteTool(name)) return siteToolSummary(name, result, parseToolArguments(item?.arguments));
const nodeField = objectField(result, "nodes");
const connectionField = objectField(result, "connections");
const nodes = Array.isArray(nodeField) ? nodeField : [];
const connections = Array.isArray(connectionField) ? connectionField : [];
if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
if (name === "canvas_get_selection") return "已读取当前选中内容";
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 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";
}
function parseToolArguments(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return {};
}
}
export function agentMessageId(threadId: string, turnId: string, itemId: string) {
return `${threadId}:${turnId}:${itemId}`;
}
export function scopeChatItem(item: AgentChatItem, threadId: string, turnId: string) {
const scopeTurnId = turnId || "pending";
const prefix = `${threadId || "local"}:${scopeTurnId}:`;
const sourceItemId = item.itemId || (item.id.startsWith(prefix) ? item.id.slice(prefix.length) : item.id);
const itemId = item.role === "user" ? "synthetic:user" : sourceItemId;
return { ...item, id: agentMessageId(threadId || "local", scopeTurnId, itemId), itemId, threadId, turnId };
}
export function bindPendingTurnMessages(messages: AgentChatItem[], threadId: string, turnId: string) {
const index = messages.findLastIndex((item) => item.role === "user" && item.threadId === threadId && !item.turnId);
if (index < 0) return messages;
return messages.map((item, itemIndex) => itemIndex === index ? scopeChatItem(item, threadId, turnId) : item);
}
export function upsertAgentMessage(messages: AgentChatItem[], item: AgentChatItem) {
const index = messages.findIndex((current) => current.id === item.id);
if (index < 0) return [...messages, item];
const current = messages[index];
const next = { ...current, ...item, attachments: item.attachments || current.attachments, historyText: item.historyText || current.historyText };
return messages.map((message, itemIndex) => itemIndex === index ? next : message);
}
export function mergeAgentMessages(snapshot: AgentChatItem[], current: AgentChatItem[], threadId: string, liveTurnKeys: ReadonlySet<string>) {
let messages = [...snapshot];
current.filter((item) => item.threadId === threadId).forEach((item) => {
const live = Boolean(item.turnId && liveTurnKeys.has(`${threadId}\0${item.turnId}`));
const index = messages.findIndex((message) => message.id === item.id);
if (index < 0) {
if (!item.turnId || live) messages = upsertAgentMessage(messages, item);
return;
}
const history = messages[index];
const next = live
? { ...history, ...item, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText }
: { ...history, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText };
messages = messages.map((message, itemIndex) => itemIndex === index ? next : message);
});
return messages;
}
export function normalizeHistoryMessages(messages: AgentChatItem[]) {
return messages
.filter((item) => (normalizeText(item.text) || item.role === "tool") && item.itemId && item.threadId && item.turnId)
.map(({ streamId: _streamId, ...item }) => scopeChatItem({ ...item, text: normalizeText(item.text) } as AgentChatItem, item.threadId!, item.turnId!));
}
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;
let index = item.clientMessageId
? currentUsers.findIndex((current) => current.clientMessageId === item.clientMessageId && current.threadId === item.threadId)
: -1;
if (index < 0 && item.turnId) index = currentUsers.findIndex((current) => current.turnId === item.turnId && current.threadId === item.threadId);
if (index < 0) {
const candidates = currentUsers
.map((current, candidateIndex) => ({ current, candidateIndex }))
.filter(({ current }) => current.threadId === item.threadId && (current.text === item.text || current.historyText === item.text));
if (new Set(candidates.map(({ current }) => current.clientMessageId || current.id)).size === 1) index = candidates[0]?.candidateIndex ?? -1;
}
if (index < 0) return item;
const current = currentUsers.splice(index, 1)[0];
return { ...item, text: current.text, historyText: current.historyText, attachments: current.attachments };
})
.reverse();
}
export function reasoningActivityText(items: Record<string, string>, fallback = "") {
const summaries = Object.values(items).map((item) => item.trim()).filter(isReasoningSummary);
return summaries.join("\n\n") || fallback || REASONING_PLACEHOLDER;
}
export function isReasoningSummary(value = "") {
const text = value.trim();
return Boolean(text && text !== REASONING_PLACEHOLDER && text !== "已完成分析");
}
export function mergeStreamText(prefix: string, incoming: string) {
if (!prefix || incoming.startsWith(prefix)) return incoming || prefix;
if (!incoming || prefix.startsWith(incoming)) return prefix;
return incoming.length >= prefix.length ? incoming : prefix;
}
@@ -0,0 +1,133 @@
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 canResume = connected && !loading && !busy;
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={canResume ? 0 : -1}
aria-disabled={!canResume}
className={`${canResume ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/10" : "cursor-default opacity-60"} rounded-lg border px-2.5 py-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20`}
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
onClick={() => {
if (canResume) onResumeThread(thread.id);
}}
onKeyDown={(event) => {
if (!canResume || (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();
}
+434
View File
@@ -0,0 +1,434 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } 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";
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
type LogFilter = "all" | "error" | "warning" | "info";
type DisplayLog = AgentEventLog & { count: number; detail: string; displayText: string; level: Exclude<LogFilter, "all">; signature: string; success: boolean };
const SCROLL_BOTTOM_THRESHOLD = 48;
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 [filter, setFilter] = useState<LogFilter>("all");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const followLogsRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [newLogCount, setNewLogCount] = useState(0);
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
const displayLogs = useMemo(() => prepareLogs(logs), [logs]);
const counts = useMemo(
() => ({
all: displayLogs.reduce((sum, item) => sum + item.count, 0),
error: displayLogs.filter((item) => item.level === "error").reduce((sum, item) => sum + item.count, 0),
warning: displayLogs.filter((item) => item.level === "warning").reduce((sum, item) => sum + item.count, 0),
info: displayLogs.filter((item) => item.level === "info").reduce((sum, item) => sum + item.count, 0),
}),
[displayLogs],
);
const visibleLogs = filter === "all" ? displayLogs : displayLogs.filter((item) => item.level === filter);
const visibleLogCount = visibleLogs.reduce((sum, item) => sum + item.count, 0);
const previousVisibleCountRef = useRef(visibleLogCount);
const lastError = [...logs].reverse().find((item) => logLevel(item) === "error");
const updateScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
followLogsRef.current = atBottom;
setShowScrollToBottom(!atBottom);
if (atBottom) setNewLogCount(0);
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const list = listRef.current;
if (!list) return;
followLogsRef.current = true;
list.scrollTo({ top: list.scrollHeight, behavior });
setShowScrollToBottom(false);
setNewLogCount(0);
}, []);
const handleLastLogToggle = useCallback((open: boolean) => {
if (!open) {
requestAnimationFrame(updateScrollState);
return;
}
if (!followLogsRef.current) return;
requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom("auto")));
}, [scrollToBottom, updateScrollState]);
useEffect(() => {
if (mode !== "text") return;
const frame = requestAnimationFrame(() => scrollToBottom("auto"));
return () => cancelAnimationFrame(frame);
}, [filter, mode, scrollToBottom]);
useEffect(() => {
const previousCount = previousVisibleCountRef.current;
const addedCount = Math.max(0, visibleLogCount - previousCount);
previousVisibleCountRef.current = visibleLogCount;
if (mode !== "text") return;
if (visibleLogCount < previousCount) setNewLogCount(0);
const frame = requestAnimationFrame(() => {
if (followLogsRef.current) scrollToBottom("auto");
else {
if (addedCount) setNewLogCount((count) => count + addedCount);
updateScrollState();
}
});
return () => cancelAnimationFrame(frame);
}, [mode, scrollToBottom, updateScrollState, visibleLogCount]);
const copy = async (value = content, tip = "日志已复制") => {
if (await copyToClipboard(value)) {
onCopied(tip);
return;
}
textareaRef.current?.focus();
textareaRef.current?.select();
onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制");
};
const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用";
return (
<div className="min-h-0 flex-1 overflow-hidden px-4 py-3">
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="text-base font-semibold leading-6"></div>
<Segmented
size="small"
value={mode}
onChange={(value) => setMode(value as "text" | "json")}
options={[
{ label: "排查日志", value: "text" },
{ label: "原始 JSON", value: "json" },
]}
/>
</div>
<div className="border-y py-2.5" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-2.5">
<span className={`mt-1.5 size-2 shrink-0 rounded-full ${context.connected ? "bg-emerald-500" : context.enabled ? "bg-amber-500" : "bg-current opacity-30"}`} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="font-medium">{connectionLabel}</span>
<span className="truncate" style={{ color: theme.node.muted }}>
{context.activity}
</span>
</div>
<div className="mt-0.5 truncate font-mono text-[11px] leading-4" style={{ color: theme.node.faint }} title={context.endpoint}>
{context.endpoint}
</div>
</div>
<div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}>
<div>{context.messages} </div>
<div>{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}</div>
</div>
</div>
</div>
{mode === "text" ? (
<>
<div className="flex items-center justify-between gap-2">
<Segmented
size="small"
value={filter}
onChange={(value) => setFilter(value as LogFilter)}
options={[
{ label: `全部 ${counts.all}`, value: "all" },
{ label: `错误 ${counts.error}`, value: "error" },
{ label: `警告 ${counts.warning}`, value: "warning" },
{ label: `信息 ${counts.info}`, value: "info" },
]}
/>
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
</div>
<div className="relative min-h-0 flex-1">
<div ref={listRef} tabIndex={0} aria-label="排查日志列表" className="thin-scrollbar h-full overflow-y-auto border-y focus-visible:outline-none" style={{ borderColor: theme.node.stroke }} onScroll={updateScrollState}>
{visibleLogs.map((item, index) => (
<LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} />
))}
{!visibleLogs.length ? (
<div className="px-3 py-10 text-center text-sm" style={{ color: theme.node.muted }}>
{logs.length ? "当前筛选下没有日志" : "暂无事件日志"}
</div>
) : null}
</div>
{showScrollToBottom ? (
<AgentScrollToBottom
theme={theme}
title={newLogCount ? `${newLogCount} 条新日志` : "查看最新日志"}
ariaLabel={newLogCount ? `${newLogCount} 条新日志,查看最新日志` : "查看最新日志"}
className="!bottom-52"
onClick={() => scrollToBottom()}
/>
) : null}
</div>
</>
) : (
<>
<div className="flex items-center justify-between gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>
· {logs.length}
</span>
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
</div>
<textarea
ref={textareaRef}
readOnly
value={content}
className="thin-scrollbar min-h-0 flex-1 resize-none rounded-md 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>
);
}
function LogActions({ logs, lastError, context, onClear, onCopy }: { logs: AgentEventLog[]; lastError?: AgentEventLog; context: AgentLogContext; onClear: () => void; onCopy: (value?: string, tip?: string) => void }) {
return (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip title="复制全部日志">
<Button type="text" size="small" shape="circle" aria-label="复制全部日志" icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
</Tooltip>
<Tooltip title="复制最近错误">
<Button type="text" size="small" shape="circle" aria-label="复制最近错误" disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), "最近错误已复制")} />
</Tooltip>
<Tooltip title="清空日志">
<Button danger type="text" size="small" shape="circle" aria-label="清空日志" disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} />
</Tooltip>
</div>
);
}
function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onToggle?: (open: boolean) => void }) {
const tone = item.level === "error" ? "text-red-600 dark:text-red-400" : item.level === "warning" ? "text-amber-600 dark:text-amber-400" : item.success ? "text-emerald-600 dark:text-emerald-400" : "";
const Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot;
return (
<details className="group border-b last:border-b-0" style={{ borderColor: theme.node.stroke }} onToggle={(event) => onToggle?.(event.currentTarget.open)}>
<summary className="cursor-pointer list-none px-1 py-2.5 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current/20 dark:hover:bg-white/10 [&::-webkit-details-marker]:hidden">
<div className="flex items-start gap-2.5">
<Icon className={`mt-0.5 size-4 shrink-0 ${tone}`} style={tone ? undefined : { color: theme.node.muted }} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[10px] leading-5" style={{ color: theme.node.faint }}>
{item.time}
</span>
<span className="truncate text-sm font-medium leading-5">{item.title}</span>
{item.count > 1 ? (
<span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}>
{item.count}
</span>
) : null}
</div>
{item.displayText !== item.title ? (
<div className="line-clamp-2 whitespace-pre-wrap break-words text-xs leading-5" style={{ color: theme.node.muted }}>
{item.displayText}
</div>
) : null}
</div>
<ChevronDown className="mt-1 size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.faint }} />
</div>
</summary>
<div className="pb-3 pl-[34px] pr-2">
<div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}>
</div>
<pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2.5 font-mono text-[11px] leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}>
{item.detail}
</pre>
</div>
</details>
);
}
function prepareLogs(logs: AgentEventLog[]) {
return logs.flatMap(expandLog).reduce<DisplayLog[]>((result, item) => {
const previous = result.at(-1);
if (previous && previous.signature === item.signature) {
previous.count += 1;
previous.time = item.time;
previous.detail = item.detail;
return result;
}
result.push(item);
return result;
}, []);
}
function expandLog(item: AgentEventLog): DisplayLog[] {
const entries = parseJsonEntries(item.raw ?? item.text);
return (entries.length ? entries : [undefined]).map((entry, index) => {
const displayText = compactLogText(entry === undefined ? item.text : logSummary(entry)) || item.title;
const level = logLevel(item, entry);
const title = entry === undefined ? item.title : logTitle(item.title, entry);
return {
...item,
id: entries.length > 1 ? `${item.id}-${index}` : item.id,
time: logTime(item.time, entry),
title,
text: displayText,
raw: entry ?? item.raw,
count: 1,
detail: entry === undefined ? stripAnsi(safeString(item.raw ?? item.text)) || item.text : safeJson(entry),
displayText,
level,
signature: `${level}\n${title}\n${logSignature(displayText)}`,
success: level === "info" && /完成|成功|已连接|已就绪|收到回复/.test(`${title}\n${displayText}`),
};
});
}
function logLevel(item: AgentEventLog, entry?: unknown): DisplayLog["level"] {
const entries = entry === undefined ? parseJsonEntries(item.raw ?? item.text) : [entry];
const structured = entries.find((value) => value && typeof value === "object" && !Array.isArray(value)) as Record<string, unknown> | undefined;
if (structured?.type === "mcp.startup") {
if (structured.status === "failed") return "error";
if (structured.status === "cancelled") return "warning";
return "info";
}
const declared = entries.map(declaredLogLevel).filter(Boolean);
if (declared.includes("error")) return "error";
if (declared.includes("warning")) return "warning";
if (declared.includes("info")) return "info";
const text = `${item.title}\n${item.text}\n${safeString(item.raw)}`;
if (/错误|失败|异常|中断|断开|拒绝|\berror\b|\bfailed\b|\bfatal\b|exception/i.test(text)) return "error";
if (/警告|重试|未找到|不可用|\bwarn(?:ing)?\b|deprecated|missing|unavailable/i.test(text)) return "warning";
return "info";
}
function declaredLogLevel(value: unknown): DisplayLog["level"] | "" {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const level = String((value as Record<string, unknown>).level || "").toLowerCase();
if (["error", "fatal"].includes(level)) return "error";
if (["warn", "warning"].includes(level)) return "warning";
if (["info", "debug", "trace"].includes(level)) return "info";
return "";
}
function logTitle(fallback: string, value: unknown) {
if (fallback !== "日志" || !value || typeof value !== "object" || Array.isArray(value)) return fallback;
const target = String((value as Record<string, unknown>).target || "").toLowerCase();
if (target.includes("skill")) return "技能加载";
if (target.includes("plugin")) return "插件";
if (target.includes("mcp") || target.includes("rmcp")) return "MCP";
if (target.includes("shell")) return "终端";
if (target.includes("state_db")) return "会话存储";
return "Codex";
}
function logTime(fallback: string, value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
const timestamp = (value as Record<string, unknown>).timestamp;
if (typeof timestamp !== "string") return fallback;
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? fallback : date.toLocaleTimeString();
}
function parseJsonEntries(value: unknown): unknown[] {
if (value && typeof value === "object") return [value];
if (typeof value !== "string") return [];
const text = stripAnsi(value).trim();
if (!text) return [];
try {
return [JSON.parse(text)];
} catch {
const entries: unknown[] = [];
let start = -1;
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (start < 0) {
if (character !== "{" && character !== "[") continue;
start = index;
depth = 1;
continue;
}
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = false;
continue;
}
if (character === '"') quoted = true;
else if (character === "{" || character === "[") depth += 1;
else if (character === "}" || character === "]") depth -= 1;
if (depth !== 0) continue;
try {
entries.push(JSON.parse(text.slice(start, index + 1)));
} catch {
// Ignore non-JSON fragments and continue scanning the stderr chunk.
}
start = -1;
}
return entries;
}
}
function logSummary(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(logSummary).filter(Boolean).slice(0, 3).join(" · ");
if (!value || typeof value !== "object") return String(value ?? "");
const record = value as Record<string, unknown>;
const fields = record.fields && typeof record.fields === "object" && !Array.isArray(record.fields) ? (record.fields as Record<string, unknown>) : null;
if (fields) {
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
if (fields[key] === undefined) continue;
const summary = logSummary(fields[key]);
if (summary) return summary;
}
}
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
if (record[key] === undefined) continue;
const summary = logSummary(record[key]);
if (summary) return summary;
}
return [record.method, record.type, record.tool, record.path, record.url].filter((item) => typeof item === "string" && item).join(" · ") || safeJson(value);
}
function compactLogText(value: string) {
return stripAnsi(value).replace(/\s+/g, " ").trim();
}
function logSignature(value: string) {
return value
.replace(/[a-z]:\\[^\r\n]*?skill\.md/gi, "<SKILL.md>")
.replace(/file:\/\/\/[^\s"']+/gi, "<路径>")
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, "<ID>");
}
function stripAnsi(value: string) {
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
}
function safeString(value: unknown) {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
return safeJson(value);
}
function safeJson(value: unknown) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
@@ -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>
);
}
+4 -29
View File
@@ -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;
@@ -50,37 +44,18 @@ export function AgentPanel() {
initial={{ width: 0, opacity: 0 }}
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
style={{ overflow: "clip", pointerEvents: panelOpen && !panelClosing ? undefined : "none" }}
>
<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,32 @@
import { Button, Tooltip } from "antd";
import { ChevronDown } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
export function AgentScrollToBottom({
theme,
title,
ariaLabel = title,
className = "",
onClick,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
title: string;
ariaLabel?: string;
className?: string;
onClick: () => void;
}) {
return (
<Tooltip title={title} placement="top">
<Button
type="text"
shape="circle"
aria-label={ariaLabel}
className={`!absolute bottom-6 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5 ${className}`}
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
icon={<ChevronDown className="size-4" />}
onClick={onClick}
/>
</Tooltip>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,319 +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={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
{isUser ? (
<div className="whitespace-pre-wrap break-words text-left">{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">{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;
}
@@ -126,7 +126,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
className="thin-scrollbar min-h-28 max-h-72 w-full overflow-y-auto overscroll-contain whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
style={{ color: theme.node.text }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
@@ -3,12 +3,13 @@ import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings
import { Button, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type CanvasConfigNodePanelProps = {
@@ -95,7 +96,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</button>
</div>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_148px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
@@ -103,7 +104,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
) : (
<CanvasTextSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
)}
</div>
<Button
@@ -143,17 +146,10 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model,
model: resolveModelForCapability(globalConfig, node.metadata?.model, mode),
reasoningEffort: node.metadata?.reasoningEffort || globalConfig.reasoningEffort || defaultConfig.reasoningEffort,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react";
import { Button, Modal, Segmented, Tooltip } from "antd";
import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = {
@@ -17,16 +18,32 @@ type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
const ratioOptions = [
{ label: "自由", value: "free" },
{ label: "固定", value: "fixed" },
{ label: "原图", value: "original" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false);
const [ratioPreset, setRatioPreset] = useState("free");
const [fixedRatio, setFixedRatio] = useState<number | null>(null);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const viewport = useImageEditorViewport(image, open);
const boxRef = viewport.stageRef;
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
useEffect(() => {
if (open) setCrop(defaultCrop);
if (open) {
setCrop(defaultCrop);
setRatioPreset("free");
setFixedRatio(null);
}
}, [dataUrl, open]);
useEffect(() => {
@@ -34,44 +51,78 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
dragAbortRef.current?.abort();
const controller = new AbortController();
dragAbortRef.current = controller;
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", resolveRatio(ratioPreset, image, fixedRatio), box));
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
const stop = () => controller.abort();
document.addEventListener("pointermove", move, { signal: controller.signal });
document.addEventListener("pointerup", stop, { signal: controller.signal });
document.addEventListener("pointercancel", stop, { signal: controller.signal });
};
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-4">
<div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))}
<div
ref={viewport.viewportRef}
{...viewport.panHandlers}
className={`relative h-[min(62vh,620px)] min-h-[340px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
>
<div className="relative" style={viewport.contentStyle}>
<div ref={boxRef} className="absolute isolate overflow-hidden rounded-lg bg-black select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
<div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="block h-full w-full object-contain opacity-90" draggable={false} />
</div>
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button
key={handle}
type="button"
className="absolute size-3 rounded-full border border-black bg-white"
style={handleStyle(handle)}
onPointerDown={(event) => startDrag("resize", event, handle)}
aria-label="调整裁剪框"
/>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
<span className="ml-2 text-xs opacity-55"> · +</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
@@ -82,9 +133,20 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</span>
) : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
<Segmented
size="small"
options={ratioOptions}
value={ratioPreset}
onChange={(value) => {
const preset = String(value);
setRatioPreset(preset);
const currentRatio = image ? (crop.width * image.width) / Math.max(1, crop.height * image.height) : null;
const nextFixedRatio = preset === "fixed" ? currentRatio : null;
setFixedRatio(nextFixedRatio);
const ratio = resolveRatio(preset, image, nextFixedRatio);
if (ratio && image) setCrop((current) => fitCropToRatio(current, ratio, image));
}}
/>
</div>
<div className="flex items-center justify-end gap-2">
@@ -116,7 +178,7 @@ function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImag
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
}
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect {
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, aspectRatio: number | null, box: DOMRect): CanvasImageCropRect {
let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy;
@@ -128,20 +190,68 @@ function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: R
next.y = crop.y + dy;
next.height = crop.height - dy;
}
if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width;
next.height = size / box.height;
if (aspectRatio) {
const normalizedRatio = aspectRatio * (box.height / box.width);
const horizontalOnly = (handle.includes("e") || handle.includes("w")) && !handle.includes("n") && !handle.includes("s");
const useWidth = horizontalOnly || (handle.length > 1 && Math.abs(dx * box.width) >= Math.abs(dy * box.height));
if (useWidth) next.height = next.width / normalizedRatio;
else next.width = next.height * normalizedRatio;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
if (aspectRatio) {
const normalizedRatio = aspectRatio * (box.height / box.width);
const scaleDown = Math.min(1, 1 / Math.max(next.width, 0.001), 1 / Math.max(next.height, 0.001));
next.width *= scaleDown;
next.height *= scaleDown;
if (next.width < minSize || next.height < minSize) {
const minimumScale = Math.max(minSize / Math.max(next.width, 0.001), minSize / Math.max(next.height, 0.001));
next.width *= minimumScale;
next.height *= minimumScale;
}
next.width = Math.min(next.width, next.height * normalizedRatio);
next.height = next.width / normalizedRatio;
} else {
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
}
next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height);
return next;
}
function resolveRatio(preset: string, image: { width: number; height: number } | null, fixedRatio: number | null) {
if (preset === "free" || !image) return null;
if (preset === "fixed") return fixedRatio;
if (preset === "original") return image.width / image.height;
const [width, height] = preset.split(":").map(Number);
return width > 0 && height > 0 ? width / height : null;
}
function fitCropToRatio(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect {
const normalizedRatio = ratio * (image.height / image.width);
let width = crop.width;
let height = width / normalizedRatio;
if (height > crop.height) {
height = crop.height;
width = height * normalizedRatio;
}
if (width > 1) {
width = 1;
height = width / normalizedRatio;
}
if (height > 1) {
height = 1;
width = height * normalizedRatio;
}
return {
x: clamp(crop.x + (crop.width - width) / 2, 0, 1 - width),
y: clamp(crop.y + (crop.height - height) / 2, 0, 1 - height),
width,
height,
};
}
function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
}
@@ -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} />
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom";
import { Button, Input, Modal, Slider, Tooltip } from "antd";
import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageMaskEditPayload = {
prompt: string;
@@ -10,20 +12,29 @@ export type CanvasImageMaskEditPayload = {
};
type DrawMode = "paint" | "erase";
type Point = { x: number; y: number };
type MaskStroke = { mode: DrawMode; size: number; points: Point[] };
type BrushPreview = { x: number; y: number; size: number; adjusting: boolean };
const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)";
const maskBorderColor = "rgba(255, 255, 255, .72)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null });
const brushAdjustRef = useRef<{ active: boolean; pointerId: number; startX: number; startSize: number; previewX: number; previewY: number } | null>(null);
const historyRef = useRef<MaskStroke[]>([]);
const redoRef = useRef<MaskStroke[]>([]);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint");
const [error, setError] = useState("");
const [historySize, setHistorySize] = useState(0);
const [redoSize, setRedoSize] = useState(0);
const [brushPreview, setBrushPreview] = useState<BrushPreview | null>(null);
const viewport = useImageEditorViewport(image, open);
useEffect(() => {
if (!open) return;
@@ -31,6 +42,13 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
setHistorySize(0);
setRedoSize(0);
setBrushPreview(null);
historyRef.current = [];
redoRef.current = [];
brushAdjustRef.current = null;
drawingRef.current = { active: false, stroke: null };
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
@@ -42,53 +60,144 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d");
if (!maskCanvas || !context) return;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
if (!drawingRef.current.last) {
drawMaskStroke(context, point, point, brushSize);
} else {
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
}
renderMaskPreview(maskCanvas, previewCanvasRef.current);
drawingRef.current.last = point;
if (mode === "paint") {
const context = maskCanvas?.getContext("2d", { willReadFrequently: true });
const previewContext = previewCanvasRef.current?.getContext("2d");
const stroke = drawingRef.current.stroke;
if (!maskCanvas || !context || !previewContext || !stroke) return;
configureStrokeContext(context, stroke);
configurePreviewStrokeContext(previewContext, stroke);
const last = stroke.points.at(-1);
drawMaskStroke(context, last || point, point, stroke.size);
drawMaskStroke(previewContext, last || point, point, stroke.size);
stroke.points.push(point);
if (stroke.mode === "paint") {
setError("");
}
};
const updateBrushPreview = (event: ReactPointerEvent<HTMLCanvasElement>, size = brushSize, adjusting = false) => {
setBrushPreview({
x: event.clientX,
y: event.clientY,
size,
adjusting,
});
};
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if ((event.button === 0 || event.button === 2) && event.altKey) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
brushAdjustRef.current = {
active: true,
pointerId: event.pointerId,
startX: event.clientX,
startSize: brushSize,
previewX: event.clientX,
previewY: event.clientY,
};
updateBrushPreview(event, brushSize, true);
return;
}
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
drawingRef.current = { active: true, last: null };
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
updateBrushPreview(event);
drawingRef.current = { active: true, stroke: { mode, size: brushSize, points: [] } };
draw(event);
};
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const brushAdjust = brushAdjustRef.current;
if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) {
event.preventDefault();
event.stopPropagation();
const nextSize = clampBrushSize(brushAdjust.startSize + event.clientX - brushAdjust.startX);
setBrushSize(nextSize);
setBrushPreview({
x: brushAdjust.previewX,
y: brushAdjust.previewY,
size: nextSize,
adjusting: true,
});
return;
}
updateBrushPreview(event);
if (!drawingRef.current.active) return;
event.preventDefault();
draw(event);
};
const stopDraw = () => {
drawingRef.current = { active: false, last: null };
const maskCanvas = maskCanvasRef.current;
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
const stopDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const brushAdjust = brushAdjustRef.current;
if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) {
brushAdjustRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
updateBrushPreview(event, brushSize);
return;
}
const stroke = drawingRef.current.stroke;
drawingRef.current = { active: false, stroke: null };
if (stroke?.points.length) {
historyRef.current.push(stroke);
setHistorySize(historyRef.current.length);
redoRef.current = [];
setRedoSize(0);
}
};
const undoMask = useCallback(() => {
if (drawingRef.current.active || !historyRef.current.length) return;
const stroke = historyRef.current.pop();
if (stroke) redoRef.current.push(stroke);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current);
setError("");
}, []);
const redoMask = useCallback(() => {
if (drawingRef.current.active || !redoRef.current.length) return;
const stroke = redoRef.current.pop();
if (stroke) historyRef.current.push(stroke);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current);
setError("");
}, []);
const resetMask = () => {
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
setError("");
};
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
const key = event.key.toLowerCase();
const modifier = (event.metaKey || event.ctrlKey) && !event.altKey;
const isUndo = modifier && !event.shiftKey && key === "z";
const isRedo = modifier && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y"));
if (!isUndo && !isRedo) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isRedo) redoMask();
else undoMask();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [open, redoMask, undoMask]);
const submit = () => {
const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current;
@@ -99,33 +208,58 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
/>
</>
) : null}
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]" data-canvas-no-zoom>
<div
ref={viewport.viewportRef}
{...viewport.panHandlers}
className={`relative h-[min(68vh,720px)] min-h-[360px] rounded-xl border border-black/10 bg-transparent dark:border-white/10 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
>
<div className="relative" style={viewport.contentStyle}>
<div ref={viewport.stageRef} className="absolute isolate overflow-hidden rounded-lg bg-transparent select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full bg-transparent object-contain" draggable={false} />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-none touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
onPointerEnter={(event) => updateBrushPreview(event)}
onPointerLeave={() => {
if (!drawingRef.current.active && !brushAdjustRef.current?.active) setBrushPreview(null);
}}
onContextMenu={(event) => event.preventDefault()}
/>
</div>
</>
) : null}
</div>
</div>
</div>
{brushPreview
? createPortal(
<div
className={`pointer-events-none fixed z-[1100] rounded-full border-2 ${brushPreview.adjusting ? "border-[#fbbf24] bg-black/10" : "border-white/90 bg-black/5"} shadow-[0_0_0_1px_rgba(0,0,0,.8)]`}
style={{ left: brushPreview.x, top: brushPreview.y, width: Math.max(4, brushPreview.size * viewport.imageScale), aspectRatio: 1, transform: "translate(-50%, -50%)" }}
>
{brushPreview.adjusting ? <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded bg-black/75 px-1.5 py-0.5 text-xs font-semibold text-white">{brushSize}px</span> : null}
</div>,
document.body,
)
: null}
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
<div className="mt-2 text-xs leading-5 opacity-55"> · + · Alt+/ · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </div>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -137,6 +271,26 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10">
<Tooltip title="撤回局部涂抹 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} />
</Tooltip>
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} />
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span>
@@ -187,8 +341,12 @@ function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: nu
};
}
function clampBrushSize(value: number) {
return Math.min(160, Math.max(8, Math.round(value / 2) * 2));
}
function clearCanvas(canvas: HTMLCanvasElement | null) {
const context = canvas?.getContext("2d");
const context = canvas?.getContext("2d", { willReadFrequently: true });
if (!canvas || !context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
}
@@ -206,8 +364,43 @@ function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y:
context.stroke();
}
function configureStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) {
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = stroke.size;
context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
}
function configurePreviewStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) {
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = stroke.size;
context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = maskFillColor;
context.fillStyle = maskFillColor;
}
function replayMask(strokes: MaskStroke[], maskCanvas: HTMLCanvasElement | null, previewCanvas: HTMLCanvasElement | null) {
const context = maskCanvas?.getContext("2d", { willReadFrequently: true });
const previewContext = previewCanvas?.getContext("2d");
if (!maskCanvas || !context || !previewCanvas || !previewContext) return;
context.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
previewContext.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
for (const stroke of strokes) {
configureStrokeContext(context, stroke);
configurePreviewStrokeContext(previewContext, stroke);
stroke.points.forEach((point, index) => {
const previous = stroke.points[index - 1] || point;
drawMaskStroke(context, previous, point, stroke.size);
drawMaskStroke(previewContext, previous, point, stroke.size);
});
}
}
function canvasHasPaint(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return false;
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < data.length; index += 4) {
@@ -216,54 +409,13 @@ function canvasHasPaint(canvas: HTMLCanvasElement) {
return false;
}
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
const context = previewCanvas?.getContext("2d");
if (!previewCanvas || !context) return;
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
context.fillStyle = maskFillColor;
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
context.globalCompositeOperation = "destination-in";
context.drawImage(maskCanvas, 0, 0);
context.globalCompositeOperation = "source-over";
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
}
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return;
const { width, height } = maskCanvas;
const data = maskContext.getImageData(0, 0, width, height).data;
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
const dash = step * 8;
const gap = step * 5;
const period = dash + gap;
context.save();
context.fillStyle = maskBorderColor;
context.shadowColor = "rgba(0, 0, 0, .24)";
context.shadowBlur = step * 1.5;
for (let y = step; y < height - step; y += step) {
for (let x = step; x < width - step; x += step) {
const offset = (y * width + x) * 4 + 3;
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
if ((x + y) % period > dash) continue;
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
}
}
context.restore();
}
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
}
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
const canvas = document.createElement("canvas");
canvas.width = selectionCanvas.width;
canvas.height = selectionCanvas.height;
const context = canvas.getContext("2d");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return selectionCanvas.toDataURL("image/png");
const selectionContext = selectionCanvas.getContext("2d");
const selectionContext = selectionCanvas.getContext("2d", { willReadFrequently: true });
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height);
if (!selectionContext) return canvas.toDataURL("image/png");
@@ -3,7 +3,7 @@ import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
@@ -11,6 +11,7 @@ import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasPromptChipInput } from "./canvas-prompt-chip-input";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasTextSettingsPopover } from "./canvas-text-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -37,11 +38,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
// 仅在切换到其它节点时恢复对应提示词;同一节点生成完成后继续保留当前输入。
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]);
setPrompt(node.metadata?.prompt || "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.id]);
const updatePrompt = (value: string) => {
setPrompt(value);
@@ -52,11 +55,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const text = prompt.trim();
if (!text || isRunning) return;
onGenerate(node.id, mode, text);
setPrompt("");
};
return (
<div
data-canvas-no-zoom
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
@@ -99,7 +102,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
</>
) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasTextSettingsPopover config={config} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
</>
)}
</div>
<Button
@@ -132,17 +138,10 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model,
model: resolveModelForCapability(globalConfig, node.metadata?.model, mode),
reasoningEffort: node.metadata?.reasoningEffort || globalConfig.reasoningEffort || defaultConfig.reasoningEffort,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal, Tooltip } from "antd";
import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
export type CanvasImageSplitParams = ImageSplitParams;
@@ -15,7 +16,13 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [active, setActive] = useState<ActiveLine>(null);
const previewRef = useRef<HTMLDivElement>(null);
const historyRef = useRef<CanvasImageSplitParams[]>([]);
const redoRef = useRef<CanvasImageSplitParams[]>([]);
const dragAbortRef = useRef<AbortController | null>(null);
const [historySize, setHistorySize] = useState(0);
const [redoSize, setRedoSize] = useState(0);
const viewport = useImageEditorViewport(image, open);
const previewRef = viewport.stageRef;
const horizontalLines = params.horizontalLines || [];
const verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1;
@@ -28,6 +35,10 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
setParams(defaultParams);
setActive(null);
setImage(null);
historyRef.current = [];
redoRef.current = [];
setHistorySize(0);
setRedoSize(0);
}, [dataUrl, open]);
useEffect(() => {
@@ -35,20 +46,28 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!open) dragAbortRef.current?.abort();
return () => dragAbortRef.current?.abort();
}, [open]);
const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]);
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
};
const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns };
});
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const spot = findLineSpot(params[key] || []);
const lines = [...(params[key] || []), spot].sort((a, b) => a - b);
setActive({ axis, index: lines.indexOf(spot) });
setParams({ ...params, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : params.rows, columns: axis === "vertical" ? lines.length + 1 : params.columns });
};
const deleteLine = () => {
if (!active) return;
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = (current[key] || []).filter((_, index) => index !== active.index);
@@ -58,16 +77,19 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
};
const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => {
event.preventDefault();
event.stopPropagation();
setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect();
if (!box) return;
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
dragAbortRef.current?.abort();
const controller = new AbortController();
dragAbortRef.current = controller;
const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
const stop = () => controller.abort();
window.addEventListener("pointermove", move, { signal: controller.signal });
window.addEventListener("pointerup", stop, { signal: controller.signal });
window.addEventListener("pointercancel", stop, { signal: controller.signal });
};
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => {
@@ -78,28 +100,93 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
});
};
const resetLines = () => {
pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize);
setActive(null);
setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) }));
};
const undoSplit = useCallback(() => {
const previous = historyRef.current.pop();
if (!previous) return;
redoRef.current.push(cloneSplitParams(params));
setParams(previous);
setActive(null);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
}, [params]);
const redoSplit = useCallback(() => {
const next = redoRef.current.pop();
if (!next) return;
historyRef.current.push(cloneSplitParams(params));
setParams(next);
setActive(null);
setHistorySize(historyRef.current.length);
setRedoSize(redoRef.current.length);
}, [params]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
const key = event.key.toLowerCase();
const isUndo = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && key === "z";
const isRedo = (event.metaKey || event.ctrlKey) && !event.altKey && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y"));
const isDelete = event.key === "Delete" || event.key === "Backspace";
if (!isUndo && !isRedo && !isDelete) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isDelete) deleteLine();
else if (isRedo) redoSplit();
else undoSplit();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [active, open, params, redoSplit, undoSplit]);
const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns };
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-5" data-canvas-no-zoom>
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p>
<p className="mt-2 text-xs leading-5 opacity-55"> · + · Delete 线 · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
<div
ref={viewport.viewportRef}
{...viewport.panHandlers}
className={`relative isolate h-[340px] min-h-[300px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
>
<div className="relative" style={viewport.contentStyle}>
<div ref={previewRef} className="absolute isolate overflow-hidden rounded-lg bg-black [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
<div className="absolute left-0 top-0 [backface-visibility:hidden]" style={viewport.mediaStyle}>
<img src={dataUrl} alt="" className="block h-full w-full object-contain" draggable={false} />
</div>
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<div className="flex items-center gap-1">
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} />
</Tooltip>
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} />
</Tooltip>
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
</Tooltip>
</div>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
@@ -107,10 +194,18 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>线</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>线</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>线</Button>
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
线
</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
线
</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
线
</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
线
</Button>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
@@ -184,3 +279,19 @@ function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
}
function cloneSplitParams(params: CanvasImageSplitParams) {
return {
...params,
horizontalLines: [...(params.horizontalLines || [])],
verticalLines: [...(params.verticalLines || [])],
};
}
function pushHistory(historyRef: { current: CanvasImageSplitParams[] }, redoRef: { current: CanvasImageSplitParams[] }, params: CanvasImageSplitParams, setHistorySize: (size: number) => void, setRedoSize: (size: number) => void) {
historyRef.current.push(cloneSplitParams(params));
if (historyRef.current.length > 50) historyRef.current.shift();
redoRef.current = [];
setHistorySize(historyRef.current.length);
setRedoSize(0);
}
+9 -2
View File
@@ -44,7 +44,9 @@ type CanvasNodeProps = {
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResizeStart: (nodeId: string) => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onResizeEnd: (nodeId: string) => void;
onContentChange: (nodeId: string, content: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void;
@@ -105,7 +107,9 @@ export const CanvasNode = React.memo(function CanvasNode({
onHoverStart,
onHoverEnd,
onConnectStart,
onResizeStart,
onResize,
onResizeEnd,
onContentChange,
onTitleChange,
onToggleBatch,
@@ -261,11 +265,13 @@ export const CanvasNode = React.memo(function CanvasNode({
resizeRef.current.isResizing = false;
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
}, [handleResizeMove]);
onResizeEnd(data.id);
}, [data.id, handleResizeMove, onResizeEnd]);
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
event.stopPropagation();
event.preventDefault();
onResizeStart(data.id);
resizeRef.current = {
isResizing: true,
corner,
@@ -286,8 +292,9 @@ export const CanvasNode = React.memo(function CanvasNode({
return () => {
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
if (resizeRef.current.isResizing) onResizeEnd(data.id);
};
}, [handleResizeMove, handleResizeUp]);
}, [data.id, handleResizeMove, handleResizeUp, onResizeEnd]);
return (
<div
+195 -33
View File
@@ -1,21 +1,20 @@
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Empty, Input, Popconfirm, Select, Tag } from "antd";
import { Check, ChevronRight, Download, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { memo, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import {
CANVAS_SIDE_PANEL_MAX_WIDTH,
CANVAS_SIDE_PANEL_MIN_WIDTH,
CANVAS_SIDE_PANEL_MOTION_MS,
useCanvasSidePanelStore,
} from "@/stores/use-canvas-side-panel-store";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -24,12 +23,13 @@ import type { InsertAssetPayload } from "./asset-picker-modal";
const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000;
const PANEL_EASE = [0.22, 1, 0.36, 1] as const;
type PanelTab = "canvas" | "assets";
type PanelTab = "canvas" | "assets" | "prompts";
type Props = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
onFocusNode: (nodeId: string) => void;
onPreviewNode: (nodeId: string) => void;
onInsertAsset: (payload: InsertAssetPayload) => void;
};
@@ -49,7 +49,7 @@ const STATUS_COLOR: Record<string, string> = {
idle: "transparent",
};
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
@@ -100,8 +100,17 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertA
<div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
<TabButton label="提示词库" active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">
{tab === "canvas" ? (
<CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} onPreviewNode={onPreviewNode} theme={theme} />
) : tab === "assets" ? (
<CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />
) : (
<CanvasPromptsTab onInsert={onInsertAsset} theme={theme} />
)}
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
</motion.aside>
</motion.div>
@@ -136,7 +145,7 @@ function nodePreviewText(node: CanvasNodeData) {
return getNodeDefinition(node.type)?.title || node.type;
}
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
@@ -208,23 +217,26 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
const isChecked = checked.has(node.id);
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))}
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button>
<div key={node.id} className={cn("group flex w-full items-center rounded-lg transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} style={active ? { background: theme.toolbar.activeBg } : undefined}>
<button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : "定位到节点"}>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button>
{selectMode || !isImage ? null : (
<div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5">
<button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="放大预览" title="放大预览">
<Eye className="size-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
@@ -278,7 +290,7 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload {
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
}
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
@@ -363,7 +375,11 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
const isCollapsed = collapsed[group.kind];
return (
<div key={group.kind}>
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<button
type="button"
onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))}
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100"
>
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" />
<span>{group.label}</span>
@@ -386,7 +402,7 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
</div>
</div>
);
}
});
function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
return (
@@ -423,3 +439,149 @@ function AssetCover({ asset }: { asset: Asset }) {
}
return <img src={asset.coverUrl || asset.data.dataUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
}
// ---------------------------------------------------------------------------
// 提示词库 Tab —— 按来源折叠分组,展开时按需加载,点击复制 / 插入文本节点
// ---------------------------------------------------------------------------
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const sources = usePromptSourceStore((state) => state.sources);
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
const [keyword, setKeyword] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [detail, setDetail] = useState<Prompt | null>(null);
const copyPrompt = async (prompt: string) => {
try {
await navigator.clipboard.writeText(prompt);
message.success("已复制提示词");
} catch {
message.error("复制失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2.5 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="space-y-1">
{enabledSources.length ? enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
</div>
);
});
function PromptSourceGroup({
sourceId,
sourceName,
keyword,
open,
theme,
onToggle,
onInsert,
onView,
}: {
sourceId: string;
sourceName: string;
keyword: string;
open: boolean;
theme: CanvasTheme;
onToggle: () => void;
onInsert: (payload: InsertAssetPayload) => void;
onView: (prompt: Prompt) => void;
}) {
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
const showResults = open || !!keyword.trim();
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
const filtered = useMemo(() => {
const items = query.data || [];
const q = keyword.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => [item.title, item.prompt, ...item.tags].join(" ").toLowerCase().includes(q));
}, [query.data, keyword]);
const insertPrompt = (item: Prompt) => onInsert({ kind: "text", content: item.prompt, title: item.title });
return (
<div>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
<BookOpen className="size-3.5" />
<span className="min-w-0 flex-1 truncate">{sourceName}</span>
{showResults && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
</button>
{showResults ? (
<div className="px-1 pb-2 pt-1">
{query.isLoading ? (
<div className="flex justify-center py-6">
<Spin size="small" />
</div>
) : query.isError ? (
<button type="button" onClick={() => void query.refetch()} className="block w-full py-4 text-center text-xs text-red-500 opacity-80 transition hover:opacity-100">
,
</button>
) : filtered.length ? (
<div className="space-y-1.5">
{filtered.map((item) => (
<PromptRow key={item.id} item={item} theme={theme} onInsert={() => insertPrompt(item)} onView={() => onView(item)} />
))}
</div>
) : (
<div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "该来源暂无提示词"}</div>
)}
</div>
) : null}
</div>
);
}
function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) {
return (
<div className="group relative flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-black/5 dark:hover:bg-white/5">
{item.coverUrl ? (
<img src={item.coverUrl} alt="" className="size-10 shrink-0 rounded-md object-cover" loading="lazy" />
) : (
<span className="grid size-10 shrink-0 place-items-center rounded-md" style={{ background: theme.node.panel }}>
<FileText className="size-4 opacity-50" />
</span>
)}
<button type="button" onClick={onView} className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-medium leading-snug">{item.title}</div>
<div className="mt-0.5 truncate text-xs leading-snug opacity-50">{item.prompt}</div>
</button>
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="查看详情" title="查看详情">
<Eye className="size-3.5" />
</button>
<button
type="button"
onClick={onInsert}
className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
style={{ color: theme.toolbar.activeText }}
aria-label="插入画布"
title="插入画布"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
type CanvasTextSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node) || buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate"> · {reasoningEffortLabel(config.reasoningEffort)}</span>
</Button>
</span>
{panel}
</>
);
}
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, onConfigChange }: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasTextSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap } : { top: buttonRect.bottom + gap }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
color: theme.node.text,
} as const;
return createPortal(
<div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<TextSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} />
</div>,
document.body,
);
}
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
type ImageSize = { width: number; height: number };
const minZoom = 1;
const maxZoom = 4;
const zoomStep = 1.2;
const viewportPadding = 16;
export function useImageEditorViewport(image: ImageSize | null, open: boolean) {
const viewportNodeRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const panRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
const zoomAnchorRef = useRef<{ zoom: number; ratioX: number; ratioY: number; viewportX: number; viewportY: number } | null>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const [viewportSize, setViewportSize] = useState<ImageSize>({ width: 0, height: 0 });
const [zoom, setZoom] = useState(minZoom);
const [isPanning, setIsPanning] = useState(false);
const [spacePressed, setSpacePressed] = useState(false);
const spacePressedRef = useRef(false);
const viewportRef = useCallback((node: HTMLDivElement | null) => {
viewportNodeRef.current = node;
setViewportElement(node);
}, []);
useEffect(() => {
if (!open) return;
zoomAnchorRef.current = null;
setZoom(minZoom);
}, [open, image?.width, image?.height]);
useEffect(() => {
if (!open) return;
const releaseSpace = () => {
spacePressedRef.current = false;
setSpacePressed(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space" || event.repeat) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input,textarea,[contenteditable='true']")) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (document.activeElement instanceof HTMLElement && document.activeElement.matches("button,a,[role='button']")) document.activeElement.blur();
spacePressedRef.current = true;
setSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code !== "Space" || !spacePressedRef.current) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
releaseSpace();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", releaseSpace);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", releaseSpace);
spacePressedRef.current = false;
};
}, [open]);
useEffect(() => {
if (!open || !viewportElement) return;
const updateSize = () => {
const width = viewportElement.clientWidth;
const height = viewportElement.clientHeight;
setViewportSize((current) => (current.width === width && current.height === height ? current : { width, height }));
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(viewportElement);
return () => observer.disconnect();
}, [open, viewportElement]);
const baseSize = fitImage(image, viewportSize);
const stageSize = { width: baseSize.width * zoom, height: baseSize.height * zoom };
const contentSize = {
width: Math.max(viewportSize.width, stageSize.width),
height: Math.max(viewportSize.height, stageSize.height),
};
const stageOffset = {
left: Math.max(0, Math.round((contentSize.width - stageSize.width) / 2)),
top: Math.max(0, Math.round((contentSize.height - stageSize.height) / 2)),
};
useLayoutEffect(() => {
const viewport = viewportNodeRef.current;
const anchor = zoomAnchorRef.current;
if (!viewport || !anchor || Math.abs(anchor.zoom - zoom) > 0.001) return;
const nextWidth = baseSize.width * zoom;
const nextHeight = baseSize.height * zoom;
const nextLeft = Math.max(0, (Math.max(viewport.clientWidth, nextWidth) - nextWidth) / 2);
const nextTop = Math.max(0, (Math.max(viewport.clientHeight, nextHeight) - nextHeight) / 2);
viewport.scrollLeft = nextLeft + anchor.ratioX * nextWidth - anchor.viewportX;
viewport.scrollTop = nextTop + anchor.ratioY * nextHeight - anchor.viewportY;
zoomAnchorRef.current = null;
}, [baseSize.height, baseSize.width, zoom]);
const setZoomAround = useCallback(
(nextZoom: number, clientX?: number, clientY?: number) => {
const viewport = viewportNodeRef.current;
const stage = stageRef.current;
if (!viewport || !stage || !baseSize.width || !baseSize.height) return;
const boundedZoom = clamp(nextZoom, minZoom, maxZoom);
if (Math.abs(boundedZoom - zoom) < 0.001) return;
const viewportRect = viewport.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const pointerX = clientX ?? viewportRect.left + viewportRect.width / 2;
const pointerY = clientY ?? viewportRect.top + viewportRect.height / 2;
const ratioX = clamp((pointerX - stageRect.left) / Math.max(1, stageRect.width), 0, 1);
const ratioY = clamp((pointerY - stageRect.top) / Math.max(1, stageRect.height), 0, 1);
const viewportX = pointerX - viewportRect.left;
const viewportY = pointerY - viewportRect.top;
zoomAnchorRef.current = { zoom: boundedZoom, ratioX, ratioY, viewportX, viewportY };
setZoom(boundedZoom);
},
[baseSize.height, baseSize.width, zoom],
);
useEffect(() => {
if (!open || !viewportElement) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
setZoomAround(event.deltaY < 0 ? zoom * zoomStep : zoom / zoomStep, event.clientX, event.clientY);
};
viewportElement.addEventListener("wheel", handleWheel, { passive: false });
return () => viewportElement.removeEventListener("wheel", handleWheel);
}, [open, setZoomAround, viewportElement, zoom]);
const startPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 1 && !(event.button === 0 && spacePressedRef.current)) return;
event.preventDefault();
event.stopPropagation();
const viewport = event.currentTarget;
panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, scrollLeft: viewport.scrollLeft, scrollTop: viewport.scrollTop };
viewport.setPointerCapture(event.pointerId);
setIsPanning(true);
}, []);
const movePan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
if (!pan || event.pointerId !== pan.pointerId) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollLeft = pan.scrollLeft - (event.clientX - pan.x);
event.currentTarget.scrollTop = pan.scrollTop - (event.clientY - pan.y);
}, []);
const stopPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
if (!pan || event.pointerId !== pan.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
panRef.current = null;
setIsPanning(false);
}, []);
const preventAuxClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button !== 1) return;
event.preventDefault();
event.stopPropagation();
}, []);
return {
viewportRef,
stageRef,
zoom,
isPanning,
spacePressed,
scrollClassName: zoom > minZoom + 0.001 ? "overflow-scroll" : "overflow-hidden",
panHandlers: {
onPointerDownCapture: startPan,
onPointerMoveCapture: movePan,
onPointerUpCapture: stopPan,
onPointerCancelCapture: stopPan,
onAuxClick: preventAuxClick,
},
canZoomIn: zoom < maxZoom,
canZoomOut: zoom > minZoom,
imageScale: image ? stageSize.width / image.width : 0,
zoomIn: () => setZoomAround(zoom * zoomStep),
zoomOut: () => setZoomAround(zoom / zoomStep),
resetZoom: () => setZoomAround(minZoom),
contentStyle: { width: contentSize.width, height: contentSize.height } satisfies CSSProperties,
stageStyle: {
left: stageOffset.left,
top: stageOffset.top,
width: stageSize.width,
height: stageSize.height,
} satisfies CSSProperties,
mediaStyle: {
width: baseSize.width,
height: baseSize.height,
transform: `translateZ(0) scale(${zoom})`,
transformOrigin: "top left",
} satisfies CSSProperties,
};
}
function fitImage(image: ImageSize | null, viewport: ImageSize): ImageSize {
if (!image || !viewport.width || !viewport.height) return { width: 0, height: 0 };
const availableWidth = Math.max(1, viewport.width - viewportPadding * 2);
const availableHeight = Math.max(1, viewport.height - viewportPadding * 2);
const scale = Math.min(availableWidth / image.width, availableHeight / image.height, 1);
return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+36 -3
View File
@@ -1,9 +1,11 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { exportAppConfig, importAppConfig } from "@/services/config-file";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
@@ -50,6 +52,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const configInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [editingChannelId, setEditingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false);
@@ -79,6 +82,17 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
clearPromptContinue();
};
const loadConfigFile = async (file: File) => {
try {
await importAppConfig(file);
message.success("配置与用户偏好已导入");
} catch (error) {
message.error(error instanceof Error ? error.message : "配置文件读取失败");
} finally {
if (configInputRef.current) configInputRef.current.value = "";
}
};
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => {
@@ -152,6 +166,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800">
<div className="text-xs text-stone-500">JSON API Key WebDAV </div>
<div className="flex gap-2">
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
</Button>
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
</Button>
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as ConfigTabKey)}
@@ -240,6 +266,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Form>
),
},
{
key: "prompt-sources",
label: "提示词来源",
children: <ConfigPromptSources />,
},
{
key: "webdav",
label: "WebDAV",
@@ -351,7 +382,9 @@ function normalizeImageCount(value: string) {
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
if (apiFormat === "gemini") return "Gemini";
if (apiFormat === "ark") return "火山方舟";
return "OpenAI";
}
function formatWebdavTime(value: string) {
@@ -9,6 +9,7 @@ import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: "火山方舟", value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import { App } from "antd";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
@@ -11,6 +12,8 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const config = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
usePromptSourceScheduler();
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
@@ -0,0 +1,155 @@
import { App, Button, Select, Switch, Tag } from "antd";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
import { PromptSourceContentModal } from "./prompt-source-content-modal";
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
export function ConfigPromptSources() {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const sources = usePromptSourceStore((state) => state.sources);
const schedule = usePromptSourceStore((state) => state.schedule);
const addSource = usePromptSourceStore((state) => state.addSource);
const saveSource = usePromptSourceStore((state) => state.saveSource);
const removeSource = usePromptSourceStore((state) => state.removeSource);
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
const statusQuery = useQuery({ queryKey: STATUS_QUERY_KEY, queryFn: fetchPromptSourceStatuses });
const [editingSource, setEditingSource] = useState<PromptSource | null>(null);
const [viewingId, setViewingId] = useState("");
const [refreshingId, setRefreshingId] = useState("");
const [refreshingAll, setRefreshingAll] = useState(false);
const viewingSource = sources.find((item) => item.id === viewingId) || null;
const invalidatePrompts = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }),
]);
};
const handleSave = (source: PromptSource) => {
saveSource(source);
void invalidatePrompts();
};
const handleDelete = (source: PromptSource) => {
modal.confirm({
title: `删除「${source.name}」?`,
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
removeSource(source.id);
await invalidatePrompts();
},
});
};
const handleRefreshOne = async (source: PromptSource) => {
setRefreshingId(source.id);
try {
const result = await refreshSource(source.id);
await invalidatePrompts();
message.success(`${source.name}」已更新 ${result.count}`);
} catch (error) {
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存");
} finally {
setRefreshingId("");
}
};
const handleRefreshAll = async () => {
setRefreshingAll(true);
try {
const result = await refreshAllSources();
updateSchedule("lastFetchedAt", new Date().toISOString());
await invalidatePrompts();
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "更新失败");
} finally {
setRefreshingAll(false);
}
};
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
</Button>
</div>
<div className="space-y-2">
{sources.map((source) => {
const status = statusQuery.data?.[source.id];
return (
<div key={source.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<Switch size="small" checked={source.enabled} onChange={(checked) => { toggleSource(source.id, checked); void invalidatePrompts(); }} />
<div className="min-w-[220px] flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{source.name}</span>
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]"></Tag> : null}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
{source.homepage || source.url}
</a>
<span className="tabular-nums">{status?.count ?? 0} </span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}></Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]"></Tag> : <Tag className="m-0 text-[10px]"></Tag>}
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
</div>
</div>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
</Button>
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}></Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}></Button> : null}
</div>
</div>
);
})}
</div>
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold"></div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-stone-500"></span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
</Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span>
</div>
<div className="mt-2 text-xs text-stone-400"></div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
</div>
);
}
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
@@ -0,0 +1,130 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchSourcePrompts, refreshSource, type Prompt } from "@/services/api/prompts";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp();
const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null);
const copyText = useCopyText();
const addAsset = useAssetStore((state) => state.addAsset);
const load = useCallback(
async (force: boolean) => {
if (!source) return;
setLoading(true);
try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败");
} finally {
setLoading(false);
}
},
[source, message],
);
useEffect(() => {
if (source) void load(false);
else setItems([]);
}, [source, load]);
const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产");
};
return (
<>
<Modal
open={Boolean(source)}
onCancel={onClose}
width={980}
footer={null}
title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div>
<div className="text-base font-semibold">{source?.name || ""} · </div>
<div className="mt-0.5 text-xs font-normal text-stone-500"> {items.length} </div>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
</Button>
</div>
}
>
<Table<Prompt>
rowKey="id"
size="small"
loading={loading}
dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }}
columns={[
{
title: "封面",
dataIndex: "coverUrl",
width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
},
{
title: "标题",
dataIndex: "title",
render: (title: string, item) => (
<div className="min-w-0">
<div className="truncate font-medium">{title}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-stone-500">{item.prompt}</div>
</div>
),
},
{
title: "标签",
dataIndex: "tags",
width: 200,
render: (tags: string[]) => (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
),
},
{
title: "操作",
width: 210,
render: (_, item) => (
<Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}>
</Button>
<Button size="small" type="text" onClick={() => setDetail(item)}>
</Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
</Button>
</Space>
),
},
]}
/>
</Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} />
</>
);
}
async function refreshSourceItems(sourceId: string) {
await refreshSource(sourceId);
return fetchSourcePrompts(sourceId);
}
@@ -0,0 +1,86 @@
import { App, Button, Drawer, Input, Space, Switch } from "antd";
import { useEffect, useState } from "react";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const { message } = App.useApp();
const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => {
if (open && source) setDraft(source);
}, [open, source]);
if (!draft) return null;
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
const save = () => {
const name = draft.name.trim();
const url = draft.url.trim();
if (!name) return message.warning("请输入来源名称");
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
onClose();
};
return (
<Drawer
open={open}
width={560}
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="space-y-5">
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
</label>
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
<span className="text-sm font-medium"></span>
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
</div>
<div>
<div className="mb-2 text-sm font-medium">JSON </div>
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
{
"id": "product-photo-1",
"title": "白底商品图",
"prompt": "生成专业白底商品摄影图",
"description": "",
"coverUrl": "",
"referenceImageUrls": [],
"tags": ["商品", "摄影"]
}
]`}</pre>
</div>
</div>
</Drawer>
);
}
function isHttpUrl(value: string) {
try {
return ["http:", "https:"].includes(new URL(value).protocol);
} catch {
return false;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { Copy } from "lucide-react";
import { Copy, FileText } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
@@ -28,7 +28,7 @@ export function PromptCard({
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
{item.coverUrl ? <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" loading="lazy" /> : <span className="grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-8" /></span>}
</button>
}
>
@@ -38,7 +38,7 @@ export function PromptCard({
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
@@ -6,9 +6,14 @@ import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword);
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => clearTimeout(timer);
}, [keyword]);
const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
queryKey: ["prompts", debouncedKeyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword: debouncedKeyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
enabled,
@@ -0,0 +1,60 @@
import { type ReactNode } from "react";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { type CanvasTheme } from "@/lib/canvas-theme";
import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
const reasoningEffortOptions: Array<{ value: ReasoningEffort; label: string }> = [
{ value: "auto", label: "自动" },
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "xhigh", label: "极高" },
];
type TextSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
theme: CanvasTheme;
className?: string;
};
export function TextSettingsPanel({ config, onConfigChange, theme, className = "space-y-4" }: TextSettingsPanelProps) {
return (
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<div className="text-lg font-semibold"></div>
<div className="space-y-2.5">
<div className="text-sm font-medium" style={{ color: theme.node.muted }}>
</div>
<div className="grid grid-cols-5 gap-2">
{reasoningEffortOptions.map((item) => (
<OptionPill key={item.value} selected={config.reasoningEffort === item.value} theme={theme} onClick={() => onConfigChange("reasoningEffort", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</div>
</div>
</ImageSettingsTheme>
);
}
export function reasoningEffortLabel(value: ReasoningEffort) {
return reasoningEffortOptions.find((item) => item.value === value)?.label || value;
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>
{children}
</button>
);
}
+8 -13
View File
@@ -2,9 +2,9 @@ import { type ReactNode } from "react";
import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { modelOptionName, type AiConfig } from "@/stores/use-config-store";
import { type AiConfig } from "@/stores/use-config-store";
const resolutionOptions = [
{ value: "720", label: "720p" },
@@ -105,8 +105,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
}
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
const model = modelOptionName(config.model || config.videoModel);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const resolution = normalizeSeedanceResolution(config.vquality);
const ratio = normalizeSeedanceRatio(config.size);
const duration = normalizeSeedanceDuration(config.videoSeconds);
const generateAudio = boolConfig(config.videoGenerateAudio, true);
@@ -118,16 +117,12 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
{showTitle ? <div className="text-lg font-semibold"></div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
{seedanceResolutionOptions.map((item) => {
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
return (
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
);
})}
{seedanceResolutionOptions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
))}
</div>
{isSeedanceFastModel(model) ? <div className="text-[11px] leading-4 opacity-55">fast 1080p使 720p</div> : null}
</SettingGroup>
<SettingGroup title="比例" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-2.5">
@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { refreshDueSources } from "@/services/api/prompts";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
const CHECK_INTERVAL_MS = 60_000;
/** Periodically update only the sources whose last successful refresh is due. */
export function usePromptSourceScheduler() {
const queryClient = useQueryClient();
const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes);
useEffect(() => {
if (!intervalMinutes) return;
let running = false;
const tick = async () => {
if (running) return;
const { updateSchedule } = usePromptSourceStore.getState();
running = true;
try {
const result = await refreshDueSources(intervalMinutes * 60_000);
if (!result.results.length) return;
updateSchedule("lastFetchedAt", new Date().toISOString());
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
queryClient.invalidateQueries({ queryKey: ["prompt-source-statuses"] }),
]);
} catch {
// 单个来源的错误已写入来源状态,下一个检查周期会继续尝试。
} finally {
running = false;
}
};
void tick();
const timer = window.setInterval(() => void tick(), CHECK_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [intervalMinutes, queryClient]);
}
+63 -5
View File
@@ -4,6 +4,7 @@ import { fetchPrompts } from "@/services/api/prompts";
import { uploadImage } from "@/services/image-storage";
import { imageAspectOptions, imageQualityOptions } from "@/components/image-settings-panel";
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
@@ -14,6 +15,7 @@ import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
export const SITE_TOOL_NAMES = [
"canvas_list_projects",
"generation_get_status",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
@@ -31,6 +33,7 @@ export function isSiteTool(name: string): name is SiteToolName {
export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
canvas_list_projects: "画布列表",
generation_get_status: "生成任务状态",
workbench_image_get_config: "生图配置",
workbench_image_generate: "生图工作台生成",
workbench_video_get_config: "视频配置",
@@ -41,11 +44,16 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
};
type SiteToolInput = Record<string, unknown>;
type SiteToolContext = { canvasSnapshot?: CanvasAgentSnapshot | null };
type GenerationStatus = "idle" | "queued" | "running" | "succeeded" | "failed";
type GenerationStatusItem = { id: string; source: "canvas" | "image" | "video"; status: GenerationStatus; kind?: string; title?: string; prompt?: string; projectId?: string; createdAt?: string; updatedAt?: string; successCount?: number; failCount?: number; error?: string };
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction): Promise<unknown> {
export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navigate: NavigateFunction, context: SiteToolContext = {}): Promise<unknown> {
switch (name) {
case "canvas_list_projects":
return listCanvasProjects(input);
case "generation_get_status":
return getGenerationStatus(input, context.canvasSnapshot);
case "workbench_image_get_config":
return getImageConfig();
case "workbench_image_generate":
@@ -65,6 +73,56 @@ export async function runSiteTool(name: SiteToolName, input: SiteToolInput, navi
}
}
function getGenerationStatus(input: SiteToolInput, canvasSnapshot?: CanvasAgentSnapshot | null) {
const scope = input.scope === "canvas" || input.scope === "image" || input.scope === "video" ? input.scope : "all";
const taskId = typeof input.taskId === "string" ? input.taskId : "";
const nodeIds = new Set(Array.isArray(input.nodeIds) ? input.nodeIds.filter((id): id is string => typeof id === "string") : []);
const limit = Math.max(1, Math.min(100, Math.floor(Number(input.limit)) || 20));
const tasks: GenerationStatusItem[] = [];
const includeCanvas = (scope === "all" || scope === "canvas") && (!taskId || nodeIds.size > 0);
const includeWorkbench = !nodeIds.size || Boolean(taskId);
if (includeCanvas && canvasSnapshot) {
canvasSnapshot.nodes.forEach((node) => {
const status = normalizeCanvasGenerationStatus(node.metadata?.status);
if (!status || (nodeIds.size && !nodeIds.has(node.id))) return;
const metadata = node.metadata || {};
if (!nodeIds.size && node.type !== "config" && status !== "running" && status !== "failed" && !metadata.generationMode && !metadata.generationType && !metadata.model) return;
tasks.push({ id: node.id, source: "canvas", status, kind: metadata.generationMode || node.type, title: node.title, prompt: compactPrompt(metadata.prompt || metadata.composerContent), projectId: canvasSnapshot.projectId, error: metadata.errorDetails });
});
}
if (includeWorkbench) {
useWorkbenchAgentStore.getState().tasks.forEach((task) => {
if ((scope === "image" || scope === "video") && task.kind !== scope) return;
if (scope === "canvas" || (taskId && task.id !== taskId)) return;
tasks.push({ ...task, source: task.kind, prompt: compactPrompt(task.prompt) });
});
}
tasks.sort((a, b) => generationStatusOrder(a.status) - generationStatusOrder(b.status) || (b.updatedAt || "").localeCompare(a.updatedAt || ""));
const summary: Record<GenerationStatus, number> = { idle: 0, queued: 0, running: 0, succeeded: 0, failed: 0 };
tasks.forEach((task) => (summary[task.status] += 1));
return { total: tasks.length, summary, tasks: tasks.slice(0, limit) };
}
function generationStatusOrder(status: GenerationStatus) {
return status === "running" ? 0 : status === "queued" ? 1 : 2;
}
function normalizeCanvasGenerationStatus(status: unknown): GenerationStatus | null {
if (status === "idle") return "idle";
if (status === "loading") return "running";
if (status === "success") return "succeeded";
if (status === "error") return "failed";
return null;
}
function compactPrompt(prompt: unknown) {
const value = typeof prompt === "string" ? prompt.trim() : "";
return value ? `${value.slice(0, 200)}${value.length > 200 ? "..." : ""}` : undefined;
}
function listCanvasProjects(input: SiteToolInput) {
const { projects, hydrated } = useCanvasStore.getState();
if (!hydrated) throw new Error("画布还在加载中,请稍后重试");
@@ -118,8 +176,8 @@ function runImageWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false;
navigate("/image");
useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, applied, note: run ? "已跳转生图工作台并触发生成,结果请稍后在工作台查看" : "已跳转生图工作台并填入参数,未触发生成" };
const taskId = useWorkbenchAgentStore.getState().dispatchImage({ prompt, run });
return { ok: true, navigated: "/image", prompt, run, taskId, applied, note: run ? "已跳转生图工作台并触发生成,可用 generation_get_status 查询任务" : "已跳转生图工作台并填入参数,未触发生成" };
}
function getVideoConfig() {
@@ -173,8 +231,8 @@ function runVideoWorkbench(input: SiteToolInput, navigate: NavigateFunction) {
const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
const run = input.run !== false;
navigate("/video");
useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, applied, note: run ? "已跳转视频创作台并触发生成,结果请稍后在工作台查看" : "已跳转视频创作台并填入参数,未触发生成" };
const taskId = useWorkbenchAgentStore.getState().dispatchVideo({ prompt, run });
return { ok: true, navigated: "/video", prompt, run, taskId, applied, note: run ? "已跳转视频创作台并触发生成,可用 generation_get_status 查询任务" : "已跳转视频创作台并填入参数,未触发生成" };
}
async function searchPrompts(input: SiteToolInput) {
@@ -1,4 +1,4 @@
import { defaultConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, resolveModelForCapability, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
@@ -90,10 +90,10 @@ export function getInputSummary(inputs: NodeGenerationInput[]) {
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
model: resolveModelForCapability(config, node?.metadata?.model, mode),
reasoningEffort: node?.metadata?.reasoningEffort || config.reasoningEffort || defaultConfig.reasoningEffort,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
+9 -22
View File
@@ -1,4 +1,4 @@
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -7,9 +7,10 @@ export const SEEDANCE_REFERENCE_LIMITS = {
videos: 3,
audios: 3,
imageMaxBytes: 30 * 1024 * 1024,
videoMaxBytes: 50 * 1024 * 1024,
videoMaxBytes: 200 * 1024 * 1024,
audioMaxBytes: 15 * 1024 * 1024,
};
export const SEEDANCE_VIDEO_MIME_TYPES = ["video/mp4", "video/quicktime"];
export const seedanceResolutionOptions = [
{ value: "480p", label: "480p" },
@@ -56,28 +57,13 @@ const seedancePixels = {
},
} as const;
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) {
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "apiFormat">) {
const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl);
return requestConfig.apiFormat === "ark";
}
export function isSeedanceVideoModel(model: string) {
const value = model.toLowerCase();
return value.includes("seedance") || value.includes("doubao-seedance");
}
export function isSeedanceFastModel(model: string) {
const value = model.toLowerCase();
return isSeedanceVideoModel(value) && value.includes("fast");
}
export function isArkPlanBaseUrl(baseUrl: string) {
return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3");
}
export function normalizeSeedanceResolution(value: string, model = "") {
export function normalizeSeedanceResolution(value: string) {
const normalized = normalizeResolutionToken(value);
if (isSeedanceFastModel(model) && normalized === "1080p") return "720p";
return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
}
@@ -149,7 +135,8 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
for (let index = 0; index < videos.length; index += 1) {
const video = videos[index];
const label = seedanceReferenceLabel("video", index);
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`;
if (!SEEDANCE_VIDEO_MIME_TYPES.includes(video.type)) return `${label} 仅支持 mp4/mov 格式`;
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 200MB,请压缩后再上传`;
if (video.durationMs) {
if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
totalDurationMs += video.durationMs;
@@ -159,7 +146,7 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
const ratio = video.width / video.height;
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`;
const pixels = video.width * video.height;
if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`;
if (pixels < 640 * 640 || pixels > 3326 * 2494) return `${label} 像素需要在 409600-8295044 之间`;
}
}
if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒";
+138 -76
View File
@@ -241,6 +241,7 @@ function InfiniteCanvasPage() {
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
const [isNodeDragging, setIsNodeDragging] = useState(false);
const [isNodeResizing, setIsNodeResizing] = useState(false);
const [dropTargetGroupId, setDropTargetGroupId] = useState<string | null>(null);
const nodesRef = useRef(nodes);
@@ -1376,11 +1377,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();
@@ -1461,6 +1464,9 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node)));
}, []);
const handleNodeResizeStart = useCallback(() => setIsNodeResizing(true), []);
const handleNodeResizeEnd = useCallback(() => setIsNodeResizing(false), []);
const toggleNodeFreeResize = useCallback((nodeId: string) => {
setNodes((prev) =>
prev.map((node) => {
@@ -1847,13 +1853,31 @@ function InfiniteCanvasPage() {
const handleImageInputChange = useCallback(
async (event: ReactChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
const files = Array.from(event.target.files || []).filter(
(f) => f.type.startsWith("image/") || f.type.startsWith("video/") || isAudioFile(f),
);
if (!files.length) {
uploadTargetRef.current = null;
event.target.value = "";
return;
}
const target = uploadTargetRef.current;
const basePosition =
target?.position ||
screenToCanvas(
(containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2,
(containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2,
);
const STAGGER = 40; // 多文件时的偏移间距
// 如果有替换目标节点,第一个文件替换它,其余在附近新建
if (target?.nodeId) {
if (isAudioFile(file)) {
const audio = await uploadMediaFile(file, "audio");
const [first, ...rest] = files;
// 第一个文件:替换目标节点
if (isAudioFile(first)) {
const audio = await uploadMediaFile(first, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
setNodes((prev) =>
prev.map((node) =>
@@ -1861,7 +1885,7 @@ function InfiniteCanvasPage() {
? {
...node,
type: CanvasNodeType.Audio,
title: file.name,
title: first.name,
position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 },
width: spec.width,
height: spec.height,
@@ -1872,12 +1896,8 @@ function InfiniteCanvasPage() {
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
uploadTargetRef.current = null;
event.target.value = "";
return;
}
if (file.type.startsWith("video/")) {
const video = await uploadMediaFile(file, "video");
} else if (first.type.startsWith("video/")) {
const video = await uploadMediaFile(first, "video");
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) =>
prev.map((node) =>
@@ -1885,7 +1905,7 @@ function InfiniteCanvasPage() {
? {
...node,
type: CanvasNodeType.Video,
title: file.name,
title: first.name,
position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 },
width: nextSize.width,
height: nextSize.height,
@@ -1896,50 +1916,69 @@ function InfiniteCanvasPage() {
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
uploadTargetRef.current = null;
event.target.value = "";
return;
} else {
const image = await uploadImage(first);
const s = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? {
...node,
type: CanvasNodeType.Image,
title: first.name,
width: s.width,
height: s.height,
metadata: {
...node.metadata,
...imageMetadata(image),
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
}
: node,
),
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
}
// 剩余文件:在目标节点附近新建
for (let i = 0; i < rest.length; i++) {
const offsetPos = { x: basePosition.x + (i + 1) * STAGGER, y: basePosition.y + (i + 1) * STAGGER };
const f = rest[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, offsetPos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, offsetPos);
} else {
void createImageFileNode(f, offsetPos);
}
}
const image = await uploadImage(file);
const size = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? {
...node,
type: CanvasNodeType.Image,
title: file.name,
width: size.width,
height: size.height,
metadata: {
...node.metadata,
...imageMetadata(image),
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
}
: node,
),
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
} else {
const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
// 无替换目标:所有文件在画布中心附近新建
for (let i = 0; i < files.length; i++) {
const offsetPos = { x: basePosition.x + i * STAGGER, y: basePosition.y + i * STAGGER };
const f = files[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, offsetPos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, offsetPos);
} else {
void createImageFileNode(f, offsetPos);
}
}
}
uploadTargetRef.current = null;
@@ -1951,11 +1990,24 @@ function InfiniteCanvasPage() {
const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!file) return;
const files = Array.from(event.dataTransfer.files).filter(
(item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item),
);
if (!files.length) return;
const pos = screenToCanvas(event.clientX, event.clientY);
void (isAudioFile(file) ? createAudioFileNode(file, pos) : file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos));
const basePos = screenToCanvas(event.clientX, event.clientY);
const STAGGER = 40;
for (let i = 0; i < files.length; i++) {
const pos = { x: basePos.x + i * STAGGER, y: basePos.y + i * STAGGER };
const f = files[i];
if (isAudioFile(f)) {
void createAudioFileNode(f, pos);
} else if (f.type.startsWith("video/")) {
void createVideoFileNode(f, pos);
} else {
void createImageFileNode(f, pos);
}
}
},
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas],
);
@@ -2041,14 +2093,13 @@ function InfiniteCanvasPage() {
return;
}
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
finishGenerationRequest(nodeId, runController);
setRunningNodeId(null);
return;
}
let pendingChildIds: string[] = [];
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(node.type === CanvasNodeType.Config ? {} : { prompt }), status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
if (mode === "image") {
@@ -2112,7 +2163,7 @@ function InfiniteCanvasPage() {
? isConfigNode
? {
...node,
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined },
}
: isEmptyImageNode
? {
@@ -2151,6 +2202,7 @@ function InfiniteCanvasPage() {
if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller);
let hasSuccess = false;
let hasFailure = false;
let firstError = "";
await Promise.all(
targetIds.map(async (targetId) => {
try {
@@ -2189,6 +2241,7 @@ function InfiniteCanvasPage() {
} catch (error) {
if (isGenerationCanceled(error)) return false;
const errorDetails = error instanceof Error ? error.message : "生成失败";
if (!firstError) firstError = errorDetails;
hasFailure = true;
setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
} finally {
@@ -2202,14 +2255,16 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
return;
}
if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
if (hasFailure) {
message.error(hasSuccess ? "部分图片生成失败" : firstError || "生成失败");
}
setNodes((prev) =>
prev.map((node) =>
node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } }
: node.id === nodeId && isEmptyImageNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === rootId && !hasSuccess
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } }
: node.id === rootId && !hasSuccess && !targetIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } }
: node,
),
@@ -2334,9 +2389,9 @@ function InfiniteCanvasPage() {
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort },
}));
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
}
@@ -2370,7 +2425,12 @@ function InfiniteCanvasPage() {
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } }
: node.id === nodeId && !editingTextNode
? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } }
? {
...node,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Generated Text",
metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort, status: NODE_STATUS_SUCCESS },
}
: node,
),
);
@@ -2591,11 +2651,11 @@ function InfiniteCanvasPage() {
);
const insertAssistantText = useCallback(
(text: string) => {
(text: string, title?: string) => {
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const node = {
...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }),
title: text.slice(0, 32) || "Assistant Text",
title: title || text.slice(0, 32) || "Assistant Text",
};
setNodes((prev) => [...prev, node]);
@@ -2608,7 +2668,7 @@ function InfiniteCanvasPage() {
const handleAssetInsert = useCallback(
(payload: InsertAssetPayload) => {
if (payload.kind === "text") {
insertAssistantText(payload.content);
insertAssistantText(payload.content, payload.title);
} else if (payload.kind === "video") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
@@ -2706,7 +2766,7 @@ function InfiniteCanvasPage() {
return (
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onPreviewNode={setPreviewNodeId} onInsertAsset={handleAssetInsert} />
<section className="relative min-w-0 flex-1 overflow-hidden">
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
@@ -2815,7 +2875,9 @@ function InfiniteCanvasPage() {
onHoverStart={handleNodeHoverStart}
onHoverEnd={handleNodeHoverEnd}
onConnectStart={handleConnectStart}
onResizeStart={handleNodeResizeStart}
onResize={handleNodeResize}
onResizeEnd={handleNodeResizeEnd}
onContentChange={handleNodeContentChange}
onTitleChange={handleNodeTitleChange}
onToggleBatch={toggleBatchExpanded}
@@ -2854,7 +2916,7 @@ function InfiniteCanvasPage() {
</InfiniteCanvas>
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
node={isNodeDragging || isNodeResizing || nodeImageSettingsOpen ? null : toolbarNode}
viewport={viewport}
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
onKeep={keepNodeToolbar}
@@ -2928,7 +2990,7 @@ function InfiniteCanvasPage() {
/>
) : null}
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
+46 -5
View File
@@ -69,6 +69,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function ImagePage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -89,10 +90,13 @@ export default function ImagePage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [isReferenceDragActive, setIsReferenceDragActive] = useState(false);
const [autoRunToken, setAutoRunToken] = useState(0);
const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand);
const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.imageModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim());
@@ -141,22 +145,30 @@ export default function ImagePage() {
};
const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const text = prompt.trim();
if (!text) {
message.error("请输入生图提示词");
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "请输入生图提示词" });
return;
}
if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置");
openConfigDialog(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图配置不完整" });
return;
}
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "生图参数无效" });
return;
}
setElapsedMs(0);
setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null);
setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" })));
const batchStartedAt = performance.now();
@@ -169,6 +181,8 @@ export default function ImagePage() {
const successCount = successImages.length;
const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
const error = failed?.reason instanceof Error ? failed.reason.message : failCount ? "生成失败" : undefined;
if (agentTaskId) updateAgentTask(agentTaskId, { status: successCount ? "succeeded" : "failed", successCount, failCount, error: successCount ? undefined : error });
try {
const logImages = await Promise.all(
@@ -202,8 +216,15 @@ export default function ImagePage() {
processedCommandRef.current = imageCommand.nonce;
clearImageCommand();
if (typeof imageCommand.prompt === "string") setPrompt(imageCommand.prompt);
if (imageCommand.run && !running) setAutoRunToken((value) => value + 1);
}, [imageCommand, clearImageCommand, running]);
if (imageCommand.run && running) {
if (imageCommand.taskId) updateAgentTask(imageCommand.taskId, { status: "failed", error: "生图工作台已有任务正在运行" });
return;
}
if (imageCommand.run) {
agentTaskIdRef.current = imageCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [imageCommand, clearImageCommand, running, updateAgentTask]);
useEffect(() => {
if (!autoRunToken) return;
@@ -408,7 +429,27 @@ export default function ImagePage() {
</div>
</div>
<div
className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"
className={`hover-scrollbar hover-scrollbar-hint relative flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${isReferenceDragActive ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => {
event.preventDefault();
dragDepthRef.current += 1;
if (event.dataTransfer.types.includes("Files")) setIsReferenceDragActive(true);
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (!dragDepthRef.current) setIsReferenceDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
dragDepthRef.current = 0;
setIsReferenceDragActive(false);
void addReferences(event.dataTransfer.files);
}}
onWheel={(event) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault();
@@ -430,7 +471,7 @@ export default function ImagePage() {
</button>
</div>
))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"></div> : null}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{isReferenceDragActive ? "松开即可添加参考图" : "暂无参考图,可将图片拖到这里"}</div> : null}
</div>
</div>
@@ -1,46 +1,44 @@
import { Copy, FolderPlus } from "lucide-react";
import { Copy, FileText, FolderPlus } from "lucide-react";
import { Button, Modal, Space, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return (
<>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
{prompt ? (
<>
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-3">
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
</div>
<div className="min-w-0">
<div className="flex flex-wrap gap-1.5">
{prompt.tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
{formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}
</div>
<Space wrap className="mt-5">
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space>
</div>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}>
{prompt ? (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 space-y-3 pb-4">
{prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="h-48 w-full rounded-lg object-cover sm:h-56" /> : <div className="grid h-48 w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600 sm:h-56"><FileText className="size-9" /></div>}
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-6 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto border-y border-stone-200 py-4 pr-2 dark:border-stone-800">
<div className="flex flex-wrap gap-1.5">
{prompt.tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
</>
) : null}
</Modal>
</>
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
{prompt.preview ? <pre className="mt-4 whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
</div>
<div className="shrink-0 pt-4">
<Space wrap>
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space>
</div>
</div>
) : null}
</Modal>
);
}
+35 -76
View File
@@ -1,5 +1,5 @@
import { FolderPlus, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { type ReactNode, type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } from "antd";
import { PromptCard } from "@/components/prompts/prompt-card";
@@ -21,9 +21,7 @@ export default function PromptsPage() {
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}, [message, query.error, query.isError]);
const toggleTag = (tag: string) => {
@@ -38,89 +36,50 @@ export default function PromptsPage() {
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
void query.fetchNextPage();
}
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
onScroll={handleListScroll}
>
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
<div className="mx-auto max-w-7xl">
<div className="text-center">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</div>
{query.isLoading ? (
<div className="flex h-60 items-center justify-center">
<Spin />
</div>
) : null}
{!query.isLoading ? (
<>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
</div>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategoryOptions.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
onChange={() => toggleTag(tag)}
>
{tag}
</Tag.CheckableTag>
))}
</div>
<div className="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
<aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800">
<PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
<div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500"></div>
<div className="flex flex-wrap gap-1.5">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag}</Tag.CheckableTag>;
})}
</div>
</div>
</>
) : null}
</aside>
<section className="min-w-0">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" /></div> : null}
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</section>
</div>
</div>
{!query.isLoading ? (
<div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{promptItems.map((item) => (
<PromptCard
key={item.id}
item={item}
onOpen={() => setSelectedPrompt(item)}
onCopy={() => copyText(item.prompt, "提示词已复制")}
extraAction={
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
</Button>
}
/>
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
</div>
</div>
) : null}
</main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</div>
);
}
function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) {
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option}</Tag.CheckableTag>)}</div></div>;
}
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
}
+88 -22
View File
@@ -1,5 +1,5 @@
import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type DragEvent } from "react";
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
import localforage from "localforage";
import { nanoid } from "nanoid";
@@ -11,7 +11,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, formatDuration } from "@/lib/image-utils";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS, SEEDANCE_VIDEO_MIME_TYPES } from "@/lib/seedance-video";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video";
@@ -71,6 +71,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function VideoPage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const activeLogIdsRef = useRef<Set<string>>(new Set());
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
@@ -94,10 +95,13 @@ export default function VideoPage() {
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [referenceDragTarget, setReferenceDragTarget] = useState<"image" | "video" | "audio" | null>(null);
const [autoRunToken, setAutoRunToken] = useState(0);
const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand);
const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand);
const updateAgentTask = useWorkbenchAgentStore((state) => state.updateTask);
const processedCommandRef = useRef(0);
const agentTaskIdRef = useRef<string | undefined>(undefined);
const model = effectiveConfig.videoModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim());
@@ -114,13 +118,13 @@ export default function VideoPage() {
const addReferences = async (files?: FileList | null) => {
const selectedFiles = Array.from(files || []);
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && !isSupportedAudioFile(file));
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频");
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
const videoFiles = selectedFiles.filter((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图");
if (selectedFiles.some((file) => file.type.startsWith("video/") && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 50MB 的参考视频");
if (selectedFiles.some((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 200MB 的参考视频");
if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频");
const nextReferences = await Promise.all(
imageFiles.map(async (file) => {
@@ -149,6 +153,25 @@ export default function VideoPage() {
setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios));
};
const handleReferenceDragEnter = (event: DragEvent<HTMLDivElement>, target: "image" | "video" | "audio") => {
event.preventDefault();
dragDepthRef.current += 1;
if (event.dataTransfer.types.includes("Files")) setReferenceDragTarget(target);
};
const handleReferenceDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (!dragDepthRef.current) setReferenceDragTarget(null);
};
const handleReferenceDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
dragDepthRef.current = 0;
setReferenceDragTarget(null);
void addReferences(event.dataTransfer.files);
};
const addReferencesFromClipboard = async () => {
try {
const items = await navigator.clipboard.read();
@@ -170,10 +193,16 @@ export default function VideoPage() {
}
};
const generate = async () => {
const agentTaskId = agentTaskIdRef.current;
agentTaskIdRef.current = undefined;
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
if (!snapshot) {
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", error: "视频生成参数无效" });
return;
}
setElapsedMs(0);
setRunning(true);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "running", error: undefined });
setPreviewLog(null);
setResults([{ id: nanoid(), status: "pending" }]);
const batchStartedAt = performance.now();
@@ -181,11 +210,12 @@ export default function VideoPage() {
try {
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
await saveLog(log);
void pollGenerationLog(log, snapshot.config);
await saveLog(log, false);
void pollGenerationLog(log, snapshot.config, agentTaskId);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage);
setRunning(false);
@@ -198,8 +228,15 @@ export default function VideoPage() {
processedCommandRef.current = videoCommand.nonce;
clearVideoCommand();
if (typeof videoCommand.prompt === "string") setPrompt(videoCommand.prompt);
if (videoCommand.run && !running) setAutoRunToken((value) => value + 1);
}, [videoCommand, clearVideoCommand, running]);
if (videoCommand.run && running) {
if (videoCommand.taskId) updateAgentTask(videoCommand.taskId, { status: "failed", error: "视频工作台已有任务正在运行" });
return;
}
if (videoCommand.run) {
agentTaskIdRef.current = videoCommand.taskId;
setAutoRunToken((value) => value + 1);
}
}, [videoCommand, clearVideoCommand, running, updateAgentTask]);
useEffect(() => {
if (!autoRunToken) return;
@@ -276,7 +313,7 @@ export default function VideoPage() {
.filter((log) => selectedLogIds.includes(log.id))
.map((log) => log.video?.storageKey)
.filter((key): key is string => Boolean(key));
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(() => refreshLogs());
if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null);
setResults([]);
@@ -285,15 +322,15 @@ export default function VideoPage() {
setDeleteConfirmOpen(false);
};
const saveLog = async (log: GenerationLog) => {
const saveLog = async (log: GenerationLog, resumePending = true) => {
await logStore.setItem(log.id, serializeLog(log));
await refreshLogs();
await refreshLogs(resumePending);
};
const refreshLogs = async () => {
const refreshLogs = async (resumePending = true) => {
const nextLogs = await readStoredLogs();
setLogs(nextLogs);
resumePendingLogs(nextLogs);
if (resumePending) resumePendingLogs(nextLogs);
return nextLogs;
};
@@ -303,7 +340,7 @@ export default function VideoPage() {
}
};
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => {
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig, agentTaskId?: string) => {
if (!log.task || activeLogIdsRef.current.has(log.id)) return;
activeLogIdsRef.current.add(log.id);
setRunning(true);
@@ -326,6 +363,7 @@ export default function VideoPage() {
mimeType: stored.mimeType,
};
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "succeeded", successCount: 1, failCount: 0, error: undefined });
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
message.success("视频已生成");
return;
@@ -337,6 +375,7 @@ export default function VideoPage() {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: log.id, status: "failed", error: errorMessage }]);
if (agentTaskId) updateAgentTask(agentTaskId, { status: "failed", successCount: 0, failCount: 1, error: errorMessage });
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
message.error(errorMessage);
} finally {
@@ -413,7 +452,16 @@ export default function VideoPage() {
</Button>
</div>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
<div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "image" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "image")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{references.map((item, index) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
@@ -424,7 +472,7 @@ export default function VideoPage() {
</button>
</div>
))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 9 </div> : null}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "image" ? "松开即可上传参考资产" : "暂无参考图,可拖入文件,最多 9 张"}</div> : null}
</div>
</div>
@@ -435,7 +483,16 @@ export default function VideoPage() {
</Button>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
<div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "video" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "video")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{videoReferences.map((item, index) => (
<div key={item.id} className="group relative h-20 w-32 shrink-0 overflow-hidden rounded-md border border-stone-200 bg-black dark:border-stone-800">
<video src={item.url} className="size-full object-cover" muted preload="metadata" />
@@ -446,7 +503,7 @@ export default function VideoPage() {
</button>
</div>
))}
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 3 </div> : null}
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">{referenceDragTarget === "video" ? "松开即可上传参考资产" : "暂无参考视频,可拖入文件,最多 3 个"}</div> : null}
</div>
</div>
@@ -457,7 +514,16 @@ export default function VideoPage() {
</Button>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
<div
className={`hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed p-2 pb-3 overscroll-x-contain transition-colors ${referenceDragTarget === "audio" ? "border-stone-900 bg-stone-100/80 dark:border-stone-100 dark:bg-stone-900/80" : "border-stone-300 dark:border-stone-700"}`}
onDragEnter={(event) => handleReferenceDragEnter(event, "audio")}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleReferenceDragLeave}
onDrop={handleReferenceDrop}
>
{audioReferences.map((item, index) => (
<div key={item.id} className="group relative flex h-20 w-48 shrink-0 flex-col justify-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2 dark:border-stone-800 dark:bg-stone-900">
<div className="flex min-w-0 items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
@@ -472,7 +538,7 @@ export default function VideoPage() {
</button>
</div>
))}
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500"> 3 mp3/wav 15MB </div> : null}
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">{referenceDragTarget === "audio" ? "松开即可上传参考资产" : "暂无参考音频,可拖入文件,最多 3 个,mp3/wav,单个 15MB 内"}</div> : null}
</div>
</div>
+132
View File
@@ -0,0 +1,132 @@
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; threadId?: string; turnId?: string };
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
const mutations = new Map<string, Promise<void>>();
const indexKey = (threadId: string) => `thread:${threadId}`;
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
const pendingKey = (messageId: string) => `pending:${messageId}`;
const threadMutationKey = (threadId: string) => `thread:${threadId}`;
const pendingMutationKey = (messageId: string) => `pending:${messageId}`;
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
if (!message.attachments?.length) return;
if (!threadId) return savePendingAgentUserMessage(message);
await saveThreadAgentUserMessage(threadId, message);
}
/** Persist attachments before a turn is accepted. The record is moved to a thread after the server assigns one. */
export async function savePendingAgentUserMessage(message: StoredAgentUserMessage) {
if (!message.id || !message.attachments?.length) return;
await mutateScopes([pendingMutationKey(message.id)], async () => {
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
await store.setItem(pendingKey(message.id), { ...message, threadId: undefined, turnId: undefined, attachments });
});
}
export async function deletePendingAgentUserMessage(messageId: string) {
if (!messageId) return;
await mutateScopes([pendingMutationKey(messageId)], () => store.removeItem(pendingKey(messageId)));
}
export async function readAgentUserMessages(threadId: string) {
await mutations.get(threadMutationKey(threadId))?.catch(() => undefined);
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));
}
/** Bind a pending message to the server thread, preserving an already-known turn id. */
export async function bindPendingAgentUserMessage(threadId: string, messageId: string, turnId = "") {
if (!threadId || !messageId) return;
await mutateScopes([pendingMutationKey(messageId), threadMutationKey(threadId)], async () => {
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
const key = messageKey(threadId, messageId);
const existing = await store.getItem<StoredAgentUserMessage>(key);
if (!pending && !existing) return;
const message = mergeStoredMessage(existing, pending, threadId, turnId);
await putThreadMessage(threadId, key, message);
if (pending) await store.removeItem(pendingKey(messageId));
});
}
export async function bindAgentUserMessageTurn(threadId: string, messageId: string, turnId: string) {
await bindPendingAgentUserMessage(threadId, messageId, turnId);
}
export async function moveAgentUserMessage(fromThreadId: string, toThreadId: string, messageId: string) {
if (!toThreadId || !messageId || fromThreadId === toThreadId) return bindPendingAgentUserMessage(toThreadId, messageId);
const scopes = [pendingMutationKey(messageId), threadMutationKey(toThreadId), ...(fromThreadId ? [threadMutationKey(fromThreadId)] : [])];
await mutateScopes(scopes, async () => {
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
const fromKey = fromThreadId ? messageKey(fromThreadId, messageId) : "";
const from = fromKey ? await store.getItem<StoredAgentUserMessage>(fromKey) : null;
const toKey = messageKey(toThreadId, messageId);
const existing = await store.getItem<StoredAgentUserMessage>(toKey);
const source = pending || from;
if (!source && !existing) return;
await putThreadMessage(toThreadId, toKey, mergeStoredMessage(existing, source, toThreadId));
if (pending) await store.removeItem(pendingKey(messageId));
if (from && fromThreadId) await removeThreadMessage(fromThreadId, fromKey, messageId);
});
}
export async function deleteAgentThreadMessages(threadIds: string[]) {
await mutateScopes(threadIds.map(threadMutationKey), async () => {
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 saveThreadAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
await mutateScopes([threadMutationKey(threadId)], async () => {
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
await putThreadMessage(threadId, messageKey(threadId, message.id), { ...message, threadId, attachments });
});
}
async function putThreadMessage(threadId: string, key: string, message: StoredAgentUserMessage) {
await store.setItem(key, { ...message, threadId });
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
}
function mergeStoredMessage(existing: StoredAgentUserMessage | null, source: StoredAgentUserMessage | null | undefined, threadId: string, turnId = "") {
const message = { ...(source || {}), ...(existing || {}) } as StoredAgentUserMessage;
if (!message.attachments?.length && source?.attachments?.length) message.attachments = source.attachments;
if (!message.text && source?.text) message.text = source.text;
if (!message.historyText && source?.historyText) message.historyText = source.historyText;
return { ...message, threadId, ...(turnId ? { turnId } : message.turnId ? { turnId: message.turnId } : {}) };
}
async function removeThreadMessage(threadId: string, key: string, messageId: string) {
await store.removeItem(key);
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
const remaining = ids.filter((id) => id !== messageId);
if (remaining.length) await store.setItem(indexKey(threadId), remaining);
else await store.removeItem(indexKey(threadId));
}
async function mutateScopes(scopes: string[], mutation: () => Promise<void>) {
const ids = [...new Set(scopes.filter(Boolean))].sort();
const operation = Promise.all(ids.map((id) => mutations.get(id)?.catch(() => undefined))).then(mutation);
ids.forEach((id) => mutations.set(id, operation));
try {
await operation;
} finally {
ids.forEach((id) => {
if (mutations.get(id) === operation) mutations.delete(id);
});
}
}
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 };
}
+40 -4
View File
@@ -102,17 +102,53 @@ async function assertAudioBlob(blob: Blob) {
if (payload.error?.message) throw new Error(payload.error.message);
}
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || statusMessage(error.response?.status, fallback);
const apiMsg = readApiErrorMessage(responseData);
if (apiMsg) return apiMsg;
const statusMsg = statusMessage(error.response?.status, fallback);
if (statusMsg) return statusMsg;
return error.message || fallback;
}
return error instanceof Error ? error.message : fallback;
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback;
if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
if (status === 503) return "服务繁忙(503),请稍后重试";
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
}
+92 -8
View File
@@ -244,33 +244,84 @@ function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
// 支持 data / images / results 三种返回字段(兼容不同 API)
const imageList = payload.data
|| (payload as Record<string, unknown>).images as Array<Record<string, unknown>> | undefined
|| (payload as Record<string, unknown>).results as Array<Record<string, unknown>> | undefined
|| [];
const images =
payload.data
?.map(resolveImageDataUrl)
imageList
.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
.map((dataUrl) => ({ id: nanoid(), dataUrl }));
if (images.length === 0) {
throw new Error("接口没有返回图片");
// 尝试检查是否有返回了但格式不被识别的数据
const rawKeys = Object.keys(payload).filter((k) => k !== "code" && k !== "msg" && k !== "error");
throw new Error(rawKeys.length > 0
? `接口返回了未知格式的数据(字段:${rawKeys.join("、")}),请检查模型或接口兼容性`
: "接口没有返回图片,请检查提示词是否触发安全审核或模型是否支持该操作");
}
return images;
}
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
// 可能是 JSON 字符串(如 error.message 被序列化)或纯文本错误
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
// 如果 JSON 解析后得到 "{}" 这种空对象,返回原始字符串
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
// 检查是否是 HTML 错误页面
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error 可能是字符串或含 message 的对象
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || readStatusError(error.response?.status, fallback);
// 优先从响应体提取业务错误
const apiMsg = readApiErrorMessage(responseData);
if (apiMsg) return apiMsg;
// 响应体无法提取时用 HTTP 状态推断
const statusMsg = readStatusError(error.response?.status, fallback);
if (statusMsg) return statusMsg;
// 最后用 axios 自身的错误文本
return error.message || fallback;
}
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? error.message : fallback;
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback;
if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
if (status === 503) return "服务繁忙(503),请稍后重试";
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
}
function withSystemPrompt(config: AiConfig, prompt: string) {
@@ -750,6 +801,38 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.apiFormat === "ark") {
if (mask) throw new Error("蒙版编辑暂不支持该模型,请使用其他渠道");
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(requestConfig, "/images/generations"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
n,
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
image: refs,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
...(background ? { background } : {}),
},
{
headers: aiHeaders(requestConfig, "application/json"),
signal: options?.signal,
},
);
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
@@ -810,6 +893,7 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
...(requestConfig.reasoningEffort === "auto" ? {} : { reasoning: { effort: requestConfig.reasoningEffort } }),
}, onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
+10 -3
View File
@@ -106,7 +106,7 @@ function createPoll(signal?: AbortSignal) {
/**
* Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES):
* prompt / images / messages / params
* model / baseUrl / apiKey / systemPrompt
* model / baseUrl / apiKey / systemPrompt / reasoningEffort
* http / request / poll / sleep / signal / onDelta
* The script must `return` the result; each caller normalizes it to its capability's shape.
*/
@@ -124,6 +124,7 @@ export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<
"baseUrl",
"apiKey",
"systemPrompt",
"reasoningEffort",
"http",
"request",
"poll",
@@ -142,6 +143,7 @@ export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<
config.baseUrl,
config.apiKey,
config.systemPrompt || "",
config.reasoningEffort,
http,
request,
poll,
@@ -169,6 +171,7 @@ export const PLUGIN_VARIABLES: PluginVariable[] = [
{ name: "baseUrl", type: "string", desc: "渠道接口地址(原样,未拼 /v1)" },
{ name: "apiKey", type: "string", desc: "渠道 API Key,请求头里自己带上" },
{ name: "systemPrompt", type: "string", desc: "系统提示词原文" },
{ name: "reasoningEffort", type: '"auto" | "low" | "medium" | "high" | "xhigh"', desc: "文本推理强度;auto 表示由脚本决定是否传递", capabilities: ["text"] },
{ name: "http", type: "object", desc: "便捷请求:http.post(path, body, {headers,params,responseType})、http.get(path, opts)、http.url(path);默认带 Authorization: Bearer apiKey,可用 headers 覆盖;path 相对时按 baseUrl 拼 /v1" },
{ name: "request", type: "function", desc: "原始请求 request({ method, url, headers, params, data, responseType }),不加任何默认头,鉴权头自己写;url 相对时按 baseUrl 拼接(不加 /v1" },
{ name: "poll", type: "function", desc: "轮询 poll(request, extract, {intervalMs,timeoutMs})extract 返回真值即结束" },
@@ -321,12 +324,16 @@ return { data: audio.data };`,
text: [
{
label: "OpenAI 规范",
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model
script: `// 文本对话(OpenAI Responses 接口)。可用:messages([{role,content}])、systemPrompt、model、reasoningEffort
const data = await request({
method: "post",
url: \`\${baseUrl}/v1/responses\`,
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${apiKey}\` },
data: { model, input: messages },
data: {
model,
input: messages,
...(reasoningEffort === "auto" ? {} : { reasoning: { effort: reasoningEffort } }),
},
});
const text = data.output_text
|| (data.output || []).flatMap((o) => o.content || []).map((c) => c.text || "").join("")
@@ -0,0 +1,37 @@
import { nanoid } from "nanoid";
export type PromptSource = {
id: string;
name: string;
url: string;
homepage: string;
enabled: boolean;
builtIn: boolean;
};
export const PROMPT_REGISTRY_HOMEPAGE = "https://github.com/yukkcat/image-prompts";
const PROMPT_REGISTRY_SOURCE_BASE = "https://raw.githubusercontent.com/yukkcat/image-prompts/main/dist/sources";
export function createPromptSource(source?: Partial<PromptSource>): PromptSource {
return {
id: source?.id?.trim() || nanoid(),
name: source?.name?.trim() || "新来源",
url: source?.url?.trim() || "",
homepage: source?.homepage?.trim() || "",
enabled: source?.enabled ?? true,
builtIn: source?.builtIn ?? false,
};
}
export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [
registrySource("banana-prompt-quicker", "Banana Prompt Quicker", "https://glidea.github.io/banana-prompt-quicker/"),
registrySource("davidwu-gpt-image2-prompts", "DavidWu GPT Image 2", "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts"),
registrySource("awesome-gpt-image", "Awesome GPT Image", "https://github.com/ZeroLu/awesome-gpt-image"),
registrySource("awesome-gpt4o-image-prompts", "Awesome GPT-4o", "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts"),
registrySource("youmind-gpt-image-2", "YouMind GPT Image 2", "https://github.com/YouMind-OpenLab/awesome-gpt-image-2"),
registrySource("youmind-nano-banana-pro", "YouMind Nano Banana Pro", "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts"),
];
function registrySource(id: string, name: string, homepage: string): PromptSource {
return { id, name, url: `${PROMPT_REGISTRY_SOURCE_BASE}/${id}.json`, homepage, enabled: true, builtIn: true };
}
@@ -0,0 +1,118 @@
import type { PromptSource } from "./prompt-source-presets";
export type RawPrompt = {
id: string;
title: string;
prompt: string;
description: string;
coverUrl: string;
referenceImageUrls: string[];
tags: string[];
preview: string;
createdAt: string;
updatedAt: string;
author?: string;
sourceUrl?: string;
imageMode?: string;
imageModel?: string;
imageSize?: string;
imageCount?: number;
};
type RunOptions = { signal?: AbortSignal };
async function fetchSource(source: PromptSource, options?: RunOptions) {
const response = await fetch(source.url, { cache: "no-store", signal: options?.signal });
if (!response.ok) throw new Error(`请求失败(${response.status}`);
return response.json();
}
export async function runPromptSource(source: PromptSource, options?: RunOptions): Promise<RawPrompt[]> {
if (!source.url.trim()) throw new Error("JSON URL 不能为空");
let data: unknown;
try {
data = await fetchSource(source, options);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new Error(`${source.name}」拉取失败:${error instanceof Error ? error.message : String(error)}`);
}
const items = parseJsonSource(data, source);
if (source.builtIn && !items.length) throw new Error(`${source.name}」未解析到有效提示词`);
return items;
}
function parseJsonSource(data: unknown, source: PromptSource) {
if (!Array.isArray(data)) throw new Error(`${source.name}」格式错误:根节点必须是数组`);
return normalizeItems(data, source);
}
function normalizeItems(values: unknown[], source: PromptSource) {
const seen = new Set<string>();
const items: RawPrompt[] = [];
values.forEach((value, index) => {
const record = asRecord(value);
const title = stringValue(record.title).trim();
const prompt = stringValue(record.prompt).trim();
if (!title || !prompt) return;
const id = stringValue(record.id).trim() || `${source.id}-${leftPad(index + 1)}`;
if (seen.has(id)) return;
seen.add(id);
const referenceImageUrls = stringArray(record.referenceImageUrls).map((url) => absoluteUrl(source.url, url));
const coverUrl = absoluteUrl(source.url, stringValue(record.coverUrl)) || referenceImageUrls[0] || "";
items.push({
id,
title,
prompt,
description: stringValue(record.description),
coverUrl,
referenceImageUrls,
tags: stringArray(record.tags),
preview: stringValue(record.preview),
createdAt: stringValue(record.createdAt),
updatedAt: stringValue(record.updatedAt),
author: stringValue(record.author),
sourceUrl: absoluteUrl(source.url, stringValue(record.sourceUrl)),
imageMode: optionalString(record.imageMode),
imageModel: optionalString(record.imageModel),
imageSize: optionalString(record.imageSize),
imageCount: optionalNumber(record.imageCount),
});
});
return items;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown) {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.map(stringValue).map((item) => item.trim()).filter(Boolean) : [];
}
function optionalString(value: unknown) {
const result = stringValue(value).trim();
return result || undefined;
}
function optionalNumber(value: unknown) {
const result = Number(value);
return Number.isFinite(result) && result > 0 ? result : undefined;
}
function absoluteUrl(baseUrl: string, path: string) {
if (!path) return "";
try {
return new URL(path, baseUrl).toString();
} catch {
return path;
}
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
+174 -193
View File
@@ -1,22 +1,13 @@
import localforage from "localforage";
export type Prompt = {
id: string;
title: string;
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "./prompt-source-presets";
type PromptCategory = {
export type Prompt = RawPrompt & {
sourceId: string;
category: string;
githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
};
export const ALL_PROMPTS_OPTION = "全部";
@@ -28,65 +19,187 @@ export type PromptListResponse = {
total: number;
};
const awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main";
const awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
const youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main";
const youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main";
const davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
export type PromptSourceStatus = {
sourceId: string;
count: number;
lastSuccessAt: string;
lastError: string;
};
export type PromptSourceRefreshResult = PromptSourceStatus & {
sourceName: string;
success: boolean;
};
export type PromptSourceRefreshSummary = {
results: PromptSourceRefreshResult[];
total: number;
successCount: number;
failureCount: number;
};
type SourceCache = PromptSourceStatus & {
items: Prompt[];
fetchedAt: number;
signature: string;
};
const cacheTtlMs = 1000 * 60 * 60;
const promptCacheKey = "third-party-prompts";
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
const loadingSources = new Map<string, Promise<PromptSourceRefreshResult>>();
const categories: PromptCategory[] = [
{ category: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", build: buildAwesomeGptImagePrompts },
{ category: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", build: buildAwesomeGpt4oImagePrompts },
{ category: "youmind-gpt-image-2", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", build: () => buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2") },
{ category: "youmind-nano-banana-pro", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", build: () => buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") },
{ category: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", build: buildDavidWuGptImage2Prompts },
];
let loadingPrompts: Promise<Prompt[]> | null = null;
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
};
function enabledSources() {
return usePromptSourceStore.getState().sources.filter((source) => source.enabled);
}
async function getPrompts() {
const cached = await promptCacheStore.getItem<{ items?: Prompt[]; fetchedAt?: number }>(promptCacheKey);
if (cached?.items?.length && cached.fetchedAt && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.items;
if (loadingPrompts) return loadingPrompts;
loadingPrompts = loadPrompts().finally(() => {
loadingPrompts = null;
});
return loadingPrompts;
function cacheKey(sourceId: string) {
return `prompt-source:${sourceId}`;
}
async function loadPrompts() {
function sourceSignature(source: PromptSource) {
const value = `${source.name}\n${source.url}\n${source.homepage}`;
let hash = 0;
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) | 0;
return `${value.length}:${hash}`;
}
function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
return items.map((item) => ({
...item,
description: item.description || "",
referenceImageUrls: Array.isArray(item.referenceImageUrls) ? item.referenceImageUrls : [],
sourceId: source.id,
category: source.name,
githubUrl: item.sourceUrl || source.homepage,
}));
}
async function readSourceCache(sourceId: string) {
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
}
async function refreshSourceRecord(source: PromptSource): Promise<PromptSourceRefreshResult> {
const previous = await readSourceCache(source.id);
try {
const items = withSourceMeta(source, await runPromptSource(source));
const lastSuccessAt = new Date().toISOString();
const cache: SourceCache = { sourceId: source.id, items, count: items.length, fetchedAt: Date.now(), lastSuccessAt, lastError: "", signature: sourceSignature(source) };
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: items.length, lastSuccessAt, lastError: "", success: true };
} catch (error) {
const lastError = error instanceof Error ? error.message : String(error);
const cache: SourceCache = {
sourceId: source.id,
items: previous?.items || [],
count: previous?.items?.length || 0,
fetchedAt: previous?.fetchedAt || 0,
lastSuccessAt: previous?.lastSuccessAt || "",
lastError,
signature: previous?.signature || sourceSignature(source),
};
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: cache.count, lastSuccessAt: cache.lastSuccessAt, lastError, success: false };
}
}
function getOrStartRefresh(source: PromptSource) {
const current = loadingSources.get(source.id);
if (current) return current;
const loading = refreshSourceRecord(source).finally(() => loadingSources.delete(source.id));
loadingSources.set(source.id, loading);
return loading;
}
async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
const cached = await readSourceCache(source.id);
if (cached) {
const stale = cached.signature !== sourceSignature(source) || Date.now() - cached.fetchedAt >= cacheTtlMs;
if (stale) void getOrStartRefresh(source).catch(() => undefined);
return withSourceMeta(source, cached.items);
}
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return (await readSourceCache(source.id))?.items || [];
}
async function getAllPrompts(): Promise<Prompt[]> {
const settled = await Promise.all(
categories.map(async (category) => {
enabledSources().map(async (source) => {
try {
const items = await category.build();
return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl }));
return await getSourcePrompts(source);
} catch {
return [];
}
}),
);
const items = settled.flat();
await promptCacheStore.setItem(promptCacheKey, { items, fetchedAt: Date.now() });
return items;
return settled.flat();
}
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getAllPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
const categories = enabledSources().map((source) => source.name);
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories,
total: filtered.length,
};
}
export async function fetchSourcePrompts(sourceId: string): Promise<Prompt[]> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
return getSourcePrompts(source);
}
export async function refreshSource(sourceId: string): Promise<PromptSourceRefreshResult> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return result;
}
export async function refreshAllSources(): Promise<PromptSourceRefreshSummary> {
const results = await Promise.all(enabledSources().map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function refreshDueSources(maxAgeMs: number): Promise<PromptSourceRefreshSummary> {
const sources = await Promise.all(
enabledSources().map(async (source) => {
const cached = await readSourceCache(source.id);
const lastSuccess = cached?.lastSuccessAt ? new Date(cached.lastSuccessAt).getTime() : 0;
return !lastSuccess || Boolean(cached?.lastError) || Date.now() - lastSuccess >= maxAgeMs || cached?.signature !== sourceSignature(source) ? source : null;
}),
);
const results = await Promise.all(sources.filter((source): source is PromptSource => Boolean(source)).map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function fetchPromptSourceStatuses(): Promise<Record<string, PromptSourceStatus>> {
const entries = await Promise.all(
usePromptSourceStore.getState().sources.map(async (source) => {
const cache = await readSourceCache(source.id);
return [source.id, { sourceId: source.id, count: cache?.items?.length || 0, lastSuccessAt: cache?.lastSuccessAt || "", lastError: cache?.lastError || "" }] as const;
}),
);
return Object.fromEntries(entries);
}
function summarizeRefresh(results: PromptSourceRefreshResult[]): PromptSourceRefreshSummary {
return {
results,
total: results.reduce((total, item) => total + item.count, 0),
successCount: results.filter((item) => item.success).length,
failureCount: results.filter((item) => !item.success).length,
};
}
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
@@ -94,148 +207,16 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st
if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
return [item.title, item.prompt, item.description, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
});
}
async function buildAwesomeGptImagePrompts() {
const markdown = await fetchText(awesomeGptImageRawBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const section of splitBeforeHeading(markdown, "## ")) {
const tags = tagsFromHeading(firstMatch(section, /^##\s+(.+)$/m));
for (const block of splitBeforeHeading(section, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).replace(/\[([^\]]+)]\([^)]+\)/g, "$1").trim();
const prompt = firstMatch(block, /\*\*:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGptImageRawBase, block);
items.push(defaultPrompt(`awesome-gpt-image-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", tags, markdownPreview(images)));
}
}
return items;
}
async function buildAwesomeGpt4oImagePrompts() {
const markdown = await fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \*\*\*\*\s*`(.*?)`/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGpt4oImagePromptsBase, block);
items.push(defaultPrompt(`awesome-gpt4o-image-prompts-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", ["gpt4o"], markdownPreview(images)));
}
return items;
}
async function buildYouMindPrompts(baseUrl: string, idPrefix: string, modelTag: string) {
const markdown = await fetchText(baseUrl, "README_zh.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+No\.\s*\d+:\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(baseUrl, block);
items.push(defaultPrompt(`${idPrefix}-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", youMindTags(title, modelTag), markdownPreview(images)));
}
return items;
}
async function buildDavidWuGptImage2Prompts() {
const data = await fetchJson<Array<{ id?: number; title_en?: string; title_cn?: string; category?: string; category_cn?: string; prompt?: string; note?: string; author?: string; source?: string; needs_ref?: boolean; image?: string }>>(davidWuGptImage2RawBase, "prompts.json");
return data
.map((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return null;
const image = absoluteImage(davidWuGptImage2RawBase, item.image || "");
const preview = [item.title_en, item.note, image ? `![](${image})` : ""].filter(Boolean).join("\n\n");
return defaultPrompt(`davidwu-gpt-image2-prompts-${leftPad(item.id || index + 1)}`, title, prompt, image, davidWuTags(item), preview);
})
.filter((item): item is Omit<Prompt, "category" | "githubUrl"> => Boolean(item));
}
function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit<Prompt, "category" | "githubUrl"> {
return { id, title, coverUrl, prompt, tags, preview, createdAt: "", updatedAt: "" };
}
async function fetchText(baseUrl: string, file: string) {
const response = await fetch(`${baseUrl}/${file}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${file} 拉取失败`);
return response.text();
}
async function fetchJson<T>(baseUrl: string, file: string) {
return JSON.parse(await fetchText(baseUrl, file)) as T;
}
function splitBeforeHeading(markdown: string, prefix: string) {
const blocks: string[] = [];
let current: string[] = [];
for (const line of markdown.split("\n")) {
if (line.startsWith(prefix) && current.length) {
blocks.push(current.join("\n"));
current = [];
}
current.push(line);
}
blocks.push(current.join("\n"));
return blocks;
}
function firstMatch(value: string, pattern: RegExp) {
return pattern.exec(value)?.[1] || "";
}
function extractMarkdownImages(baseUrl: string, markdown: string) {
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteImage(baseUrl, match[1])).filter(Boolean);
}
function absoluteImage(baseUrl: string, image: string) {
if (!image) return "";
if (/^https?:\/\//i.test(image)) return image;
return `${baseUrl}/${image.replace(/^\.?\//, "")}`;
}
function tagsFromCategory(category: string) {
return splitTags(category.replace(/\s+Cases$/i, ""), /\s*(?:&|and)\s*/);
}
function tagsFromHeading(heading: string) {
return splitTags(heading.replace(/[^\p{L}\p{N}/& ]/gu, ""), /\s*(?:\/|&||)\s*/);
}
function youMindTags(title: string, modelTag: string) {
const [, prefix] = title.match(/^(.+?) - /) || [];
return [modelTag, ...tagsFromHeading(prefix || "")];
}
function davidWuTags(item: { category_cn?: string; category?: string; author?: string; source?: string; needs_ref?: boolean }) {
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\//);
if (item.needs_ref) tags.push("需要参考图");
return tags;
}
function splitTags(value: string, pattern: RegExp) {
return value
.split(pattern)
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
}
function markdownPreview(images: string[]) {
return images.filter(Boolean).map((image) => `![](${image})`).join("\n\n");
}
function collectTags(items: Prompt[]) {
return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean)));
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all";
return value && value !== ALL_PROMPTS_OPTION && value !== "all";
}
export function formatPromptDate(value: string) {
+19 -4
View File
@@ -180,7 +180,7 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
model: modelOptionName(model),
content,
ratio: normalizeSeedanceRatio(config.size),
resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)),
resolution: normalizeSeedanceResolution(config.vquality),
duration: normalizeSeedanceDuration(config.videoSeconds),
generate_audio: boolConfig(config.videoGenerateAudio, true),
watermark: boolConfig(config.videoWatermark, false),
@@ -339,14 +339,29 @@ function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
try {
return readApiErrorMessage(JSON.parse(value)) || value;
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...`;
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: { message?: unknown } };
return readApiErrorMessage(payload.msg) || readApiErrorMessage(payload.message) || readApiErrorMessage(payload.error?.message);
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error 可能是字符串或含 message 的对象
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) {
+36
View File
@@ -0,0 +1,36 @@
import { saveAs } from "file-saver";
import { useConfigStore, type AiConfig, type WebdavSyncConfig } from "@/stores/use-config-store";
import { usePromptSourceStore, type PromptSourceSchedule } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
type AppConfigFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
config: AiConfig;
webdav: WebdavSyncConfig;
promptSources: {
sources: PromptSource[];
schedule: PromptSourceSchedule;
};
};
export function exportAppConfig() {
const { config, webdav } = useConfigStore.getState();
const { sources, schedule } = usePromptSourceStore.getState();
const data: AppConfigFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), config, webdav, promptSources: { sources, schedule } };
saveAs(new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }), "infinite-canvas-config.json");
}
export async function importAppConfig(file: File) {
let data: AppConfigFile;
try {
data = JSON.parse(await file.text()) as AppConfigFile;
} catch {
throw new Error("配置文件格式不正确");
}
if (data.app !== "infinite-canvas" || data.version !== 1 || !data.config || !data.webdav || !data.promptSources) throw new Error("配置文件格式不正确");
useConfigStore.setState({ config: data.config, webdav: data.webdav });
usePromptSourceStore.setState(data.promptSources);
}
+39 -7
View File
@@ -3,12 +3,26 @@ import { create } from "zustand";
import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
export type AgentAttachment = { id: string; name: string; type: string; size: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string; activityItems?: Record<string, 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 AgentReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
export type AgentModel = {
id: string;
model: string;
displayName: string;
defaultReasoningEffort: AgentReasoningEffort;
supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>;
isDefault?: boolean;
};
export type AgentApprovalDecision = "accept" | "acceptForSession" | "decline";
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; deciding?: AgentApprovalDecision };
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 AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" };
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
const CONNECT_TIMEOUT_MS = 6000;
@@ -31,16 +45,25 @@ type AgentStore = {
sending: boolean;
waiting: boolean;
messages: AgentChatItem[];
tokenUsage: AgentTokenUsage | null;
eventLogs: AgentEventLog[];
threads: AgentThreadSummary[];
activeThreadId: string;
activeTurnId: string;
workspacePath: string;
loadingThreads: boolean;
activeTab: AgentPanelTab;
confirmTools: boolean;
permissionMode: AgentPermissionMode;
models: AgentModel[];
model: string;
reasoningEffort: AgentReasoningEffort | "";
activity: string;
bootstrapStatus: AgentBootstrapStatus | null;
mcpStartupStatuses: Record<string, AgentBootstrapStatus>;
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,23 +94,32 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
sending: false,
waiting: false,
messages: [],
tokenUsage: null,
eventLogs: [],
threads: [],
activeThreadId: "",
activeTurnId: "",
workspacePath: "",
loadingThreads: false,
activeTab: "setup",
confirmTools: true,
confirmTools: false,
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
models: [],
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
activity: "就绪",
bootstrapStatus: null,
mcpStartupStatuses: {},
connectError: "",
pendingTool: null,
pendingApprovals: [],
setAgentState: (patch) => set(patch),
openPanel: () => set({ panelOpen: true, panelMounted: true, panelClosing: false }),
closePanel: () => {
if (!get().panelMounted || get().panelClosing) return;
set({ panelOpen: false, panelClosing: true });
setTimeout(() => {
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
if (get().panelClosing) set({ panelClosing: false });
}, CANVAS_AGENT_PANEL_MOTION_MS);
},
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
@@ -105,7 +137,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 = {}) => {
@@ -113,9 +145,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
agentSource = null;
if (connectTimer) clearTimeout(connectTimer);
connectTimer = null;
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", ...patch });
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", bootstrapStatus: null, mcpStartupStatuses: {}, ...patch });
},
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })),
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
clearEventLogs: () => set({ eventLogs: [] }),
}));
+19 -4
View File
@@ -3,8 +3,9 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
export type ApiCallFormat = "openai" | "gemini";
export type ApiCallFormat = "openai" | "gemini" | "ark";
export type ModelCapability = "image" | "video" | "text" | "audio";
export type ReasoningEffort = "auto" | "low" | "medium" | "high" | "xhigh";
export type ChannelModel = {
name: string;
@@ -41,6 +42,7 @@ export type AiConfig = {
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
reasoningEffort: ReasoningEffort;
models: string[];
quality: string;
size: string;
@@ -56,12 +58,13 @@ export type WebdavSyncConfig = {
directory: string;
lastSyncedAt: string;
};
export type ConfigTabKey = "channels" | "preferences" | "webdav";
export type ConfigTabKey = "channels" | "preferences" | "prompt-sources" | "webdav";
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
const CHANNEL_MODEL_SEPARATOR = "::";
const OPENAI_BASE_URL = "https://api.openai.com";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
const ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
export const defaultConfig: AiConfig = {
channelMode: "local",
@@ -97,6 +100,7 @@ export const defaultConfig: AiConfig = {
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
reasoningEffort: "auto",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
@@ -157,6 +161,14 @@ export function modelMatchesCapability(config: AiConfig, value: string, capabili
return modelCapabilityOf(config, value) === capability;
}
export function resolveModelForCapability(config: AiConfig, currentModel: string | undefined, capability: ModelCapability) {
const defaultModel = capability === "image" ? config.imageModel : capability === "video" ? config.videoModel : capability === "audio" ? config.audioModel : config.textModel;
const fallbackModel = capability === "image" ? defaultConfig.imageModel : capability === "video" ? defaultConfig.videoModel : capability === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
if (currentModel && modelMatchesCapability(config, currentModel, capability)) return currentModel;
if (defaultModel && modelMatchesCapability(config, defaultModel, capability)) return defaultModel;
return fallbackModel;
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return config.channels.flatMap((channel) => channel.models.filter((model) => model.capability === capability).map((model) => encodeChannelModel(channel.id, model.name)));
@@ -227,6 +239,7 @@ export const useConfigStore = create<ConfigStore>()(
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
reasoningEffort: config.reasoningEffort || "auto",
videoSeconds: config.videoSeconds || "6",
vquality: config.vquality || "720",
videoGenerateAudio: config.videoGenerateAudio || "true",
@@ -356,11 +369,13 @@ function normalizeChannels(config: AiConfig) {
}
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL;
if (apiFormat === "gemini") return GEMINI_BASE_URL;
if (apiFormat === "ark") return ARK_BASE_URL;
return OPENAI_BASE_URL;
}
function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
return apiFormat === "gemini" ? "gemini" : "openai";
return apiFormat === "gemini" || apiFormat === "ark" ? apiFormat : "openai";
}
function uniqueModelOptions(models: string[]) {
+65
View File
@@ -0,0 +1,65 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_PROMPT_SOURCES, createPromptSource, type PromptSource } from "@/services/api/prompt-source-presets";
export type PromptSourceSchedule = {
intervalMinutes: number;
lastFetchedAt: string;
};
const PROMPT_SOURCE_STORE_KEY = "infinite-canvas:prompt_source_store_v2";
const defaultSchedule: PromptSourceSchedule = {
intervalMinutes: 30,
lastFetchedAt: "",
};
export const PROMPT_SOURCE_INTERVAL_OPTIONS = [
{ label: "关闭定时", value: 0 },
{ label: "每 30 分钟", value: 30 },
{ label: "每 1 小时", value: 60 },
{ label: "每 6 小时", value: 360 },
{ label: "每 24 小时", value: 1440 },
];
type PromptSourceStore = {
sources: PromptSource[];
schedule: PromptSourceSchedule;
addSource: () => PromptSource;
saveSource: (source: PromptSource) => void;
removeSource: (id: string) => void;
toggleSource: (id: string, enabled: boolean) => void;
updateSchedule: <K extends keyof PromptSourceSchedule>(key: K, value: PromptSourceSchedule[K]) => void;
};
export const usePromptSourceStore = create<PromptSourceStore>()(
persist(
(set) => ({
sources: DEFAULT_PROMPT_SOURCES,
schedule: defaultSchedule,
addSource: () => createPromptSource(),
saveSource: (source) =>
set((state) => ({
sources: state.sources.some((item) => item.id === source.id)
? state.sources.map((item) => (item.id === source.id && !item.builtIn ? createPromptSource(source) : item))
: [...state.sources, createPromptSource(source)],
})),
removeSource: (id) => set((state) => ({ sources: state.sources.filter((item) => item.id !== id || item.builtIn) })),
toggleSource: (id, enabled) => set((state) => ({ sources: state.sources.map((item) => (item.id === id ? { ...item, enabled } : item)) })),
updateSchedule: (key, value) => set((state) => ({ schedule: { ...state.schedule, [key]: value } })),
}),
{
name: PROMPT_SOURCE_STORE_KEY,
partialize: (state) => ({ sources: state.sources, schedule: state.schedule }),
merge: (persisted, current) => {
const persistedState = (persisted || {}) as Partial<PromptSourceStore>;
const savedSources = Array.isArray(persistedState.sources) ? persistedState.sources : [];
const enabledById = new Map(savedSources.map((source) => [source.id, source.enabled]));
const builtIn = DEFAULT_PROMPT_SOURCES.map((source) => ({ ...source, enabled: enabledById.get(source.id) ?? source.enabled }));
const custom = savedSources.filter((source) => !source.builtIn).map((source) => createPromptSource(source));
return { ...current, sources: [...builtIn, ...custom], schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) } };
},
},
),
);
+36 -4
View File
@@ -6,15 +6,30 @@ import { create } from "zustand";
export type WorkbenchCommand = {
nonce: number;
taskId?: string;
prompt?: string;
run: boolean;
};
export type WorkbenchGenerationTask = {
id: string;
kind: "image" | "video";
status: "queued" | "running" | "succeeded" | "failed";
prompt?: string;
createdAt: string;
updatedAt: string;
successCount?: number;
failCount?: number;
error?: string;
};
type WorkbenchAgentStore = {
imageCommand: WorkbenchCommand | null;
videoCommand: WorkbenchCommand | null;
dispatchImage: (command: Omit<WorkbenchCommand, "nonce">) => void;
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce">) => void;
tasks: WorkbenchGenerationTask[];
dispatchImage: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
dispatchVideo: (command: Omit<WorkbenchCommand, "nonce" | "taskId">) => string | undefined;
updateTask: (id: string, patch: Partial<Pick<WorkbenchGenerationTask, "status" | "successCount" | "failCount" | "error">>) => void;
clearImageCommand: () => void;
clearVideoCommand: () => void;
};
@@ -25,8 +40,25 @@ const nextNonce = () => (nonce += 1);
export const useWorkbenchAgentStore = create<WorkbenchAgentStore>((set) => ({
imageCommand: null,
videoCommand: null,
dispatchImage: (command) => set({ imageCommand: { ...command, nonce: nextNonce() } }),
dispatchVideo: (command) => set({ videoCommand: { ...command, nonce: nextNonce() } }),
tasks: [],
dispatchImage: (command) => {
const commandNonce = nextNonce();
const task = command.run ? createTask("image", commandNonce, command.prompt) : undefined;
set((state) => ({ imageCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
return task?.id;
},
dispatchVideo: (command) => {
const commandNonce = nextNonce();
const task = command.run ? createTask("video", commandNonce, command.prompt) : undefined;
set((state) => ({ videoCommand: { ...command, nonce: commandNonce, taskId: task?.id }, tasks: task ? [task, ...state.tasks].slice(0, 30) : state.tasks }));
return task?.id;
},
updateTask: (id, patch) => set((state) => ({ tasks: state.tasks.map((task) => (task.id === id ? { ...task, ...patch, updatedAt: new Date().toISOString() } : task)) })),
clearImageCommand: () => set({ imageCommand: null }),
clearVideoCommand: () => set({ videoCommand: null }),
}));
function createTask(kind: "image" | "video", commandNonce: number, prompt?: string): WorkbenchGenerationTask {
const now = new Date().toISOString();
return { id: `${kind}-${commandNonce}`, kind, status: "queued", prompt, createdAt: now, updatedAt: now };
}

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