From bd0ad0aebf613a5e4cfb44491017a9915e390808 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 23 Jun 2026 17:35:43 +0800 Subject: [PATCH] feat(plugin): add Codex App plugin support and local agent integration --- .agents/plugins/marketplace.json | 20 +++ CHANGELOG.md | 1 + README.md | 4 +- canvas-agent/README.md | 24 ++- .../docs/overview/codex-app-plugin.mdx | 140 ++++++++++++++++++ docs/content/docs/overview/meta.json | 1 + docs/content/docs/progress/pending-test.mdx | 1 + .../infinite-canvas/.codex-plugin/plugin.json | 29 ++++ plugins/infinite-canvas/.mcp.json | 10 ++ plugins/infinite-canvas/README.md | 111 ++++++++++++++ plugins/infinite-canvas/assets/icon.png | Bin 0 -> 1049 bytes .../infinite-canvas/skills/canvas/SKILL.md | 27 ++++ .../skills/open-canvas/SKILL.md | 36 +++++ .../(user)/canvas/[id]/canvas-client-page.tsx | 44 +++++- .../components/canvas-assistant-panel.tsx | 4 +- .../components/canvas-local-agent-panel.tsx | 70 ++++++--- .../canvas/components/canvas-project-card.tsx | 5 +- web/src/app/(user)/canvas/page.tsx | 19 ++- 18 files changed, 518 insertions(+), 28 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 docs/content/docs/overview/codex-app-plugin.mdx create mode 100644 plugins/infinite-canvas/.codex-plugin/plugin.json create mode 100644 plugins/infinite-canvas/.mcp.json create mode 100644 plugins/infinite-canvas/README.md create mode 100644 plugins/infinite-canvas/assets/icon.png create mode 100644 plugins/infinite-canvas/skills/canvas/SKILL.md create mode 100644 plugins/infinite-canvas/skills/open-canvas/SKILL.md diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..98a0146 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "infinite-canvas-local", + "interface": { + "displayName": "Infinite Canvas" + }, + "plugins": [ + { + "name": "infinite-canvas", + "source": { + "source": "local", + "path": "./plugins/infinite-canvas" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e22814..3a9dd26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased + [新增] 渠道兼容Gemini格式。 ++ [新增] 新增Codex App插件支持。 ## v0.4.0 - 2026-06-16 diff --git a/README.md b/README.md index a6c632e..20d7ae4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ - 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。 - AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。 - 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。 -- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布。 +- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布; +- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。 - 提示词库:Next.js route 抓取多个 GitHub 开源项目,并缓存在运行实例内存中。 完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。 @@ -106,6 +107,7 @@ https://canvas.best?apiKey={key}&baseUrl={address} - [漏洞提交](SECURITY.md) - [待办事项](docs/content/docs/progress/todo.mdx) - [本地 Canvas Agent](canvas-agent/README.md) +- [Codex app 插件](plugins/infinite-canvas) ## 赞助支持 diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 0310426..c67dae1 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -1,6 +1,6 @@ # Infinite Canvas Agent -本地 Canvas Agent 用来连接线上画布网页和用户电脑上的 Codex / Claude Code。 +本地 Canvas Agent 用来连接画布网页和用户电脑上的 Codex / Claude Code。本地开发时优先连接 `http://localhost:3000`,不需要先使用线上站点。 ## 启动 @@ -26,6 +26,8 @@ Connect token: xxxxxx 在画布右上角点击 `Agent`,填入地址和 token 后连接。 +Codex app 插件会读取启动输出里的 Local URL 和 Connect token,并直接打开画布网页地址;Canvas Agent 不负责生成画布打开 URL。 + Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`。 ## 发布 @@ -38,6 +40,26 @@ Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接 如果希望 Codex 终端能直接操作画布,需要先把 Canvas Agent 注册成 Codex MCP。 +### Codex app 插件 + +仓库内提供了 Codex app 插件:`plugins/infinite-canvas`。在 Codex app 中添加本仓库的 marketplace 后,可以安装 `Infinite Canvas` 插件;插件会注册同一个 `infinite-canvas` MCP,并带上画布操作说明。 + +添加本地 marketplace 时建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败: + +```bash +cd /path/to/infinite-canvas +codex plugin marketplace add "$(pwd)" +codex plugin add infinite-canvas@infinite-canvas-local +``` + +插件默认通过 npm 启动 MCP: + +```bash +npx -y @basketikun/canvas-agent mcp +``` + +使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会优先启动本地画布和本地 Agent,读取 Local URL 和 Connect token,然后直接打开画布网页地址新建并连接画布。如果自动连接失败,再检查本地画布服务和 Canvas Agent 是否都已启动。 + Canvas Agent 启动后,给 Codex 添加 MCP: ```bash diff --git a/docs/content/docs/overview/codex-app-plugin.mdx b/docs/content/docs/overview/codex-app-plugin.mdx new file mode 100644 index 0000000..4235f99 --- /dev/null +++ b/docs/content/docs/overview/codex-app-plugin.mdx @@ -0,0 +1,140 @@ +--- +title: Codex App 插件 +description: 在 Codex App 中安装并使用 Infinite Canvas 插件 +--- + +# Codex App 插件 + +Infinite Canvas 提供了一个 Codex App 插件,用来把 Codex 和本地 Canvas Agent 连接起来。安装后,你可以直接在 Codex 中说“打开 Infinite Canvas”,让 Codex 打开画布、读取节点、创建节点并触发生成流程。 + +插件源码位于仓库的 `plugins/infinite-canvas/` 目录。 + +## 安装方式 + +### 让 Codex 自动安装 + +把下面这段发给 Codex: + +```text +请从 https://github.com/basketikun/infinite-canvas.git 安装 Infinite Canvas Codex 插件。 +请 clone 仓库到 ~/plugins/infinite-canvas,确认 plugins/infinite-canvas/.codex-plugin/plugin.json 存在, +把 plugins/infinite-canvas 加入 personal marketplace,先运行 codex plugin marketplace add ~, +再运行 codex plugin add infinite-canvas@personal。 +安装后请校验插件,并告诉我是否需要开启一个新对话来加载新技能和 MCP 工具。 +``` + +这种方式适合不想手动改 marketplace 配置的用户。Codex 会完成 clone、写入 marketplace、安装插件和基础校验。 + +### 手动安装 + +推荐把仓库 clone 到 Codex personal marketplace 默认会引用的位置: + +```bash +mkdir -p ~/plugins +git clone https://github.com/basketikun/infinite-canvas.git ~/plugins/infinite-canvas +``` + +确保 `~/.agents/plugins/marketplace.json` 中有 Infinite Canvas 条目。这里的 `path` 要指向仓库里的插件子目录: + +```json +{ + "name": "personal", + "interface": { + "displayName": "Personal" + }, + "plugins": [ + { + "name": "infinite-canvas", + "source": { + "source": "local", + "path": "./plugins/infinite-canvas/plugins/infinite-canvas" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +然后注册 personal marketplace 并安装插件: + +```bash +codex plugin marketplace add ~ +codex plugin add infinite-canvas@personal +``` + +安装完成后,建议新建一个 Codex 对话,让新的 skill 和 MCP 工具完整加载。 + +### 本仓库开发调试安装 + +如果你正在 Infinite Canvas 仓库中开发插件,可以直接使用仓库自带的 marketplace: + +```bash +cd /path/to/infinite-canvas +codex plugin marketplace add "$(pwd)" +codex plugin add infinite-canvas@infinite-canvas-local +``` + +仓库内的 `.agents/plugins/marketplace.json` 已经指向 `./plugins/infinite-canvas`,适合开发者本地调试。 + +## 使用插件 + +新建 Codex 对话后,可以直接说: + +```text +打开 Infinite Canvas 新建画布 并连接好Codex +``` + +插件会优先确认当前仓库的本地画布服务是否已经运行。如果端口被占用,它会检查监听进程是否属于当前项目,避免误连到其他项目。确认或启动画布后,插件会启动本地 Canvas Agent,并打开带连接参数的新建画布 URL。 + +画布打开后可以继续让 Codex 操作画布: + +```text +读取当前画布并总结节点结构 +根据选中节点创建一组生图提示词 +把这些节点整理成一个生成流程 +``` + +## 更新插件 + +普通业务代码变更通常不需要更新插件。只有改了下面这些内容时,才建议重新安装或刷新插件: + +- `plugins/infinite-canvas/.codex-plugin/plugin.json` +- `plugins/infinite-canvas/.mcp.json` +- `plugins/infinite-canvas/skills/` 下的技能说明 +- 插件暴露的 MCP、app 或入口脚本 + +重新安装后建议开启新对话,避免旧对话继续使用缓存中的技能和 MCP 工具。 + +## 手动排查 + +插件默认通过下面的 MCP 命令启动 Canvas Agent: + +```bash +npx -y @basketikun/canvas-agent mcp +``` + +如果需要手动排查,先启动画布: + +```bash +cd web +bun install +bun run dev +``` + +然后启动本地 Agent。端口不是 `3000` 时,把 `CANVAS_URL` 换成真实本地画布地址: + +```bash +CANVAS_URL=http://localhost:3000 npx -y @basketikun/canvas-agent +``` + +从 Agent 输出或 `http://127.0.0.1:17371/config` 读取本地地址和 token 后,打开: + +```text +<画布网页地址>/canvas?mode=new&agentUrl=&agentToken= +``` + +`mode=new` 会让网页自动创建具体画布并连接本地 Agent,不需要手动点击“新建画布”。 diff --git a/docs/content/docs/overview/meta.json b/docs/content/docs/overview/meta.json index b5e61c5..bbaf606 100644 --- a/docs/content/docs/overview/meta.json +++ b/docs/content/docs/overview/meta.json @@ -4,6 +4,7 @@ "defaultOpen": true, "pages": [ "quick-start", + "codex-app-plugin", "features", "render", "docker", diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index b992dfe..feeea32 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。 - 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。 - Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。 - 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。 diff --git a/plugins/infinite-canvas/.codex-plugin/plugin.json b/plugins/infinite-canvas/.codex-plugin/plugin.json new file mode 100644 index 0000000..078d071 --- /dev/null +++ b/plugins/infinite-canvas/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "infinite-canvas", + "version": "0.1.0", + "description": "Use Codex to read and operate Infinite Canvas through the local Canvas Agent MCP server.", + "author": { + "name": "Infinite Canvas" + }, + "repository": "https://github.com/basketikun/infinite-canvas", + "license": "AGPL-3.0", + "keywords": ["infinite-canvas", "canvas", "mcp", "agent"], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Infinite Canvas", + "shortDescription": "Use Codex to operate the current Infinite Canvas board.", + "longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Infinite Canvas board, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.", + "developerName": "Infinite Canvas", + "category": "Productivity", + "capabilities": ["Canvas", "MCP", "Creative Workflow"], + "defaultPrompt": [ + "打开 Infinite Canvas", + "读取当前画布并总结节点结构", + "根据选中节点创建一组生图提示词" + ], + "brandColor": "#2563EB", + "composerIcon": "./assets/icon.png", + "logo": "./assets/icon.png" + } +} diff --git a/plugins/infinite-canvas/.mcp.json b/plugins/infinite-canvas/.mcp.json new file mode 100644 index 0000000..23331ff --- /dev/null +++ b/plugins/infinite-canvas/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "infinite-canvas": { + "command": "npx", + "args": ["-y", "@basketikun/canvas-agent", "mcp"], + "startup_timeout_sec": 20, + "tool_timeout_sec": 90 + } + } +} diff --git a/plugins/infinite-canvas/README.md b/plugins/infinite-canvas/README.md new file mode 100644 index 0000000..0e491cb --- /dev/null +++ b/plugins/infinite-canvas/README.md @@ -0,0 +1,111 @@ +# Infinite Canvas Codex Plugin + +这个插件把 Infinite Canvas 的本地 Canvas Agent MCP 打包给 Codex app 使用,让 Codex 能打开本地画布、读取当前节点、创建内容并触发生成流程。 + +## 安装 + +### AI 自动安装 + +把下面这段发给 Codex: + +```text +请从 https://github.com/basketikun/infinite-canvas.git 安装 Infinite Canvas Codex 插件。 +请 clone 仓库到 ~/plugins/infinite-canvas,确认 plugins/infinite-canvas/.codex-plugin/plugin.json 存在, +把 plugins/infinite-canvas 加入 personal marketplace,先运行 codex plugin marketplace add ~, +再运行 codex plugin add infinite-canvas@personal。 +安装后请校验插件,并告诉我是否需要开启一个新对话来加载新技能和 MCP 工具。 +``` + +### 手动安装 + +推荐把仓库 clone 到 Codex personal marketplace 默认会引用的位置: + +```bash +mkdir -p ~/plugins +git clone https://github.com/basketikun/infinite-canvas.git ~/plugins/infinite-canvas +``` + +确保 `~/.agents/plugins/marketplace.json` 中有 Infinite Canvas 条目,注意 `path` 指向仓库里的插件子目录: + +```json +{ + "name": "personal", + "interface": { + "displayName": "Personal" + }, + "plugins": [ + { + "name": "infinite-canvas", + "source": { + "source": "local", + "path": "./plugins/infinite-canvas/plugins/infinite-canvas" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +然后注册 personal marketplace 并安装插件: + +```bash +codex plugin marketplace add ~ +codex plugin add infinite-canvas@personal +``` + +安装后建议开启一个新的 Codex 对话,让新的 skill 和 MCP 工具完整加载。 + +### 本仓库开发调试 + +如果你就在 Infinite Canvas 仓库中调试插件,可以直接添加仓库自带 marketplace。建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败: + +```bash +cd /path/to/infinite-canvas +codex plugin marketplace add "$(pwd)" +codex plugin add infinite-canvas@infinite-canvas-local +``` + +## 使用 + +1. 新建 Codex 线程后说“打开 Infinite Canvas”。 +2. 插件会确认当前仓库的本地画布服务是否已运行;端口被占用时会检查进程归属,不会把其他项目的 `3000` 当作 Infinite Canvas。 +3. 确认或启动后,插件会直接打开新建画布 URL,并自动尝试连接本地 Agent。 +4. 画布打开后,让 Codex 读取或操作当前画布。 + +常用提示: + +```text +打开 Infinite Canvas +读取当前画布并总结节点结构 +根据选中节点创建一组生图提示词 +``` + +## 工作机制 + +插件默认通过以下命令启动 MCP,并会在 MCP 启动时自动尝试拉起本地 Agent: + +```bash +npx -y @basketikun/canvas-agent mcp +``` + +## 手动排查 + +优先本地启动画布: + +```bash +cd web +bun install +bun run dev +``` + +然后启动本地 Agent。端口不是 `3000` 时,把 `CANVAS_URL` 换成真实本地画布地址: + +```bash +CANVAS_URL=http://localhost:3000 npx -y @basketikun/canvas-agent +``` + +手动排查时先从 Agent 输出或 `http://127.0.0.1:17371/config` 读取本地地址和 token,然后直接打开 `<画布网页地址>/canvas?mode=new&agentUrl=&agentToken=`。不要通过页面点击来新建画布;`mode=new` 会让网页自动创建具体画布并连接本地 Agent。 diff --git a/plugins/infinite-canvas/assets/icon.png b/plugins/infinite-canvas/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b1aa70d8ee2f3a0eec7e416ab83f5777a0f258ee GIT binary patch literal 1049 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blLz^-t6h(7*fIb zc7|<6sG~ru@;`;94xtSKvlA9iyA`#1X$04_rlzEYt#`HGuyA)LXm7ifmA`tf3%94T zriwzxf(QJsex8VqICitTeDa<>zc;h1m6w;LeVbF+*cutJVudU+oZ-)|_aZ81$_?GC z+QG@TR(B>m+7G0)WG3Azjm%)ku33DLS(EqCVe$JK8&sy+r#M#pc=R~uLxozab4m&!0@VU2QXwNn5YnKW}}Z+nfIck@u!CBi?1x46 zY5wQ0yWSqrxGP=AKIz`~E@itN7N3iP_U-JyG~MNfbm4N2{b4ni_w*Yl1^v8c|2?qk za>wmSsq>9LsvbM-`}3KZm%i$+m_tR5IbIu{pU=#BcQ>-|&+2DAJD6|I@3Nm5TB+O- zQRGy2yZpHOOI78(6ZUs5e7%3a`%{A4iA-U+`R{LN%q=WE+3|bxBYtbH?owUBrybPtJ^LgqLce`F~_~x)~?e#75D_pl-4!3&rZ^7yBRxEud#Q*PF z^D%OQcdy#Tk2lY_zwBB0nEUp%>1!6f{5LU}eLj2Emb=394;QLw$QCYNwp0D+yXiV= zXQkxRAl){SL3z)|B23>?ER z3g<)#zizzcTltOWUG$+vo4nob%onM@u=Ao_^|TdW6N}q>{oii4@ru{m!6S4%MQ%^J zzN^)b;PXo5Ei>lmOPR4AJFj~A*7mON&q`LNukWpS75RkkT^2CHX3H%#w+&xd@yAv_ zNG|S##^(CuPl{g++4QnXUy|ADflr>mdKI;Vst0Hqw` A%K!iX literal 0 HcmV?d00001 diff --git a/plugins/infinite-canvas/skills/canvas/SKILL.md b/plugins/infinite-canvas/skills/canvas/SKILL.md new file mode 100644 index 0000000..a254d55 --- /dev/null +++ b/plugins/infinite-canvas/skills/canvas/SKILL.md @@ -0,0 +1,27 @@ +--- +name: canvas +description: 操作 Infinite Canvas 当前网页画布,读取节点、选区、创建文本节点、创建生成流程、连接节点或触发生成。 +--- + +# Infinite Canvas + +你正在帮助用户操作 Infinite Canvas 网页画布。需要理解或改动画布时,优先使用已配置的 `infinite-canvas` MCP 工具;不要让用户手动复制 JSON、URL 或 token。 + +## 工作流 + +- 如果用户还没有打开或连接网页画布,使用 `open-canvas` 技能打开 Infinite Canvas,不要要求用户手动复制 URL 或 token。 +- 操作前先用 `canvas_get_state` 读取当前画布;如果用户明确提到选中内容、当前节点或“这个”,先用 `canvas_get_selection`。 +- 创建单个文本内容优先用 `canvas_create_text_node`。 +- 创建生成内容优先用 `canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`。 +- 需要把提示词、配置和生成节点串成流程时,使用 `canvas_create_generation_flow` 或项目已有的流程工具。 +- 需要批量增删改、移动、连接节点或设置视口时,使用 `canvas_apply_ops`。 +- 不要模拟鼠标点击,不要要求用户手动复制 JSON。 +- 写入画布的操作会由网页侧边栏做二次确认,按当前工具结果继续推进即可。 + +## 风格 + +- 页面文案和画布节点内容默认使用中文。 +- 生成节点、配置节点和提示词节点要保持结构清晰,方便用户继续编辑。 +- 批量创建节点时注意给节点留出间距,不要堆叠在同一个位置。 +- 图片、视频、音频等媒体节点默认保留原始比例;只有用户明确要求自由变形时才改变比例。 +- 生成流程尽量少而清楚,优先让用户一眼能看懂节点关系。 diff --git a/plugins/infinite-canvas/skills/open-canvas/SKILL.md b/plugins/infinite-canvas/skills/open-canvas/SKILL.md new file mode 100644 index 0000000..0769242 --- /dev/null +++ b/plugins/infinite-canvas/skills/open-canvas/SKILL.md @@ -0,0 +1,36 @@ +--- +name: open-canvas +description: 打开 Infinite Canvas 网页画布并自动连接本地 Canvas Agent。用户要求打开、启动、进入、使用 Infinite Canvas 或画布时使用。 +--- + +# Open Infinite Canvas + +当用户要求打开、启动、进入或使用 Infinite Canvas 时,不要把 URL 交给用户手动复制,不要通过浏览器点击“新建画布”。优先快速拉起本地画布和本地 Canvas Agent,然后直接打开带 `mode`、`agentUrl`、`agentToken` 的 URL,让网页自动创建或选择画布并连接 Agent。 + +## 默认打开方式 + +- 新建画布:`<画布网页地址>/canvas?mode=new&agentUrl=&agentToken=` +- 最近画布:`<画布网页地址>/canvas?mode=recent&agentUrl=&agentToken=` +- 自己选择:`<画布网页地址>/canvas?mode=choose&agentUrl=&agentToken=` + +默认打开新建本地画布;只有用户明确要求线上地址、最近画布或自己选择时,才改用对应模式。 + +## 工作流 + +1. 如果当前仓库是 Infinite Canvas 项目,优先使用当前仓库的 `web/` 前端。 +2. 先检查本地端口归属:如果 `3000`、`3001` 等端口已被占用,必须用 `lsof`/`ps` 或服务输出确认监听进程的工作目录属于当前仓库的 `web/`,不能只因为端口存在就当成本地画布。 +3. 如果已有当前仓库的 Next dev 服务,复用它并记录真实画布地址,例如 `http://localhost:3001`。 +4. 如果没有当前仓库的服务,启动本地画布开发服务,默认在 `web/` 下运行 `bun run dev`;若默认端口被其他项目占用,改用空闲端口启动,例如 `bunx next dev --webpack -H 0.0.0.0 -p <空闲端口>`。不要执行构建或测试。 +5. 启动本地 Canvas Agent,必须带上第 3/4 步得到的真实画布地址:`CANVAS_URL=<真实画布地址> npx -y @basketikun/canvas-agent`。如果 Agent 已经在运行,则读取 `~/.infinite-canvas/canvas-agent.json` 或 `/config` 获取 `Local URL` 和 token。 +6. 读取 Agent 输出或配置中的 `Local URL` 和 `Connect token`,不要让用户手动复制。 +7. 不走本地 Agent 的 `/open` 跳转;直接构造并打开最终 URL:`<真实画布地址>/canvas?mode=new&agentUrl=&agentToken=`。 +8. 画布网页会自动新建具体画布、打开本机 Agent 面板并连接本地 Agent;不要用浏览器点击新建画布。 +9. 打开后再使用 `canvas_get_state` 检查画布是否已经连接;如果尚未连接,等待片刻再检查,不要改用线上站点,除非用户明确要求。 + +## 用户只安装插件时 + +- 如果当前工作区不是 Infinite Canvas 源码仓库,优先提示用户先打开或启动 Infinite Canvas 网页,再连接本地 Agent。 +- 可以使用线上画布地址或用户给出的本地地址作为 `<画布网页地址>`,但仍要通过本地 Canvas Agent 获取 token 后再打开最终 URL。 +- 不要假设用户已经安装本仓库依赖;插件的 MCP 会通过 `npx -y @basketikun/canvas-agent mcp` 使用已发布的 Canvas Agent。 + +不要要求用户手动填写 URL、token 或复制 JSON。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 6317e70..b759777 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react"; import { saveAs } from "file-saver"; @@ -42,6 +42,8 @@ import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../compone import { CanvasToolbar } from "../components/canvas-toolbar"; import { AssetPickerModal, type InsertAssetPayload } from "../components/asset-picker-modal"; import { CanvasZoomControls } from "../components/canvas-zoom-controls"; +import { CanvasLocalAgentPanel } from "../components/canvas-local-agent-panel"; +import { useCanvasAgentStore } from "../stores/use-canvas-agent-store"; import { useCanvasStore } from "../stores/use-canvas-store"; import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references"; @@ -219,7 +221,11 @@ function InfiniteCanvasPage() { const { message, modal } = App.useApp(); const params = useParams<{ id: string }>(); const router = useRouter(); + const searchParams = useSearchParams(); const projectId = params.id; + const localAgentConnected = useCanvasAgentStore((state) => state.connected); + const localAgentActivity = useCanvasAgentStore((state) => state.activity); + const localAgentEnabled = useCanvasAgentStore((state) => state.enabled); const containerRef = useRef(null); const imageInputRef = useRef(null); const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); @@ -302,6 +308,8 @@ function InfiniteCanvasPage() { const [assistantClosing, setAssistantClosing] = useState(false); const [agentMode, setAgentMode] = useState("online"); const [agentUndoSnapshot, setAgentUndoSnapshot] = useState(null); + const codexAutoConnect = ["new", "recent", "choose"].includes(searchParams.get("mode") || ""); + const codexCompactAgent = codexAutoConnect && searchParams.has("agentUrl"); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); @@ -424,6 +432,15 @@ function InfiniteCanvasPage() { void restore(); }, [hydrated, openProject, projectId, router]); + useEffect(() => { + if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return; + if (searchParams.has("agentUrl")) { + setAgentMode("local"); + return; + } + openAgent("local"); + }, [projectLoaded, searchParams]); + useEffect(() => { if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return; const next = createHistoryEntry(); @@ -2494,6 +2511,7 @@ function InfiniteCanvasPage() { onUndo={undoCanvas} onRedo={redoCanvas} agentOpen={assistantOpen} + compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined} onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())} /> @@ -2775,6 +2793,7 @@ function InfiniteCanvasPage() { setAssetPickerOpen(false)} /> + {codexCompactAgent && !assistantMounted ? : null} {assistantMounted ? ( @@ -2817,6 +2837,7 @@ function CanvasTopBar({ onUndo, onRedo, agentOpen, + compactAgentStatus, onToggleAgent, }: { title: string; @@ -2836,6 +2857,7 @@ function CanvasTopBar({ onUndo: () => void; onRedo: () => void; agentOpen: boolean; + compactAgentStatus?: { connected: boolean; enabled: boolean; activity: string }; onToggleAgent: () => void; }) { const colorTheme = useThemeStore((state) => state.theme); @@ -2907,6 +2929,7 @@ function CanvasTopBar({
+ {compactAgentStatus ? : null} setShortcutsOpen(true)} @@ -2953,6 +2976,25 @@ function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) { ); } +function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) { + const colorTheme = useThemeStore((state) => state.theme); + const theme = canvasThemes[colorTheme]; + const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex"; + const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted; + return ( + + ); +} + function Shortcut({ keys, value }: { keys: string[]; value: string }) { return (
diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index ea929c6..e30b5f6 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -137,11 +137,12 @@ type CanvasAssistantPanelProps = { onPasteImage: (file: File) => void; agentMode: CanvasAgentMode; onAgentModeChange: (mode: CanvasAgentMode) => void; + autoConnectLocal?: boolean; closing: boolean; onCollapse: () => void; }; -export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, onUndoOps, onPasteImage, agentMode, onAgentModeChange, closing, onCollapse }: CanvasAssistantPanelProps) { +export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, onUndoOps, onPasteImage, agentMode, onAgentModeChange, autoConnectLocal, closing, onCollapse }: CanvasAssistantPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); const effectiveConfig = useEffectiveConfig(); @@ -660,6 +661,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, session canUndoOps={canUndoOps} onApplyOps={onApplyOps} onUndoOps={onUndoOps} + autoConnect={autoConnectLocal} /> ) : ( onlineContent diff --git a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx index 2bd39a8..0135ac0 100644 --- a/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-local-agent-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { useSearchParams } from "next/navigation"; import { App, Button, Input, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react"; @@ -16,11 +17,11 @@ import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCa const PANEL_MOTION_SECONDS = 0.5; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; +const DEFAULT_AGENT_URL = "http://127.0.0.1:17371"; const AGENT_CONNECT_STEPS = [ - { title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" }, - { title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" }, - { title: "3. 启动本地 Agent", text: "启动后终端会输出 Local URL 和 Connect token。", command: "canvas-agent" }, - { title: "4. 回到网页连接", text: "把终端输出的地址和 token 填到下面,点击连接。" }, + { title: "安装 Codex 插件", text: "在 Codex app 安装 Infinite Canvas 插件后,首次使用插件会自动启动本地 Agent。" }, + { title: "打开画布连接", text: "回到这里点击连接,网页会自动读取本机 Agent 配置。" }, + { title: "手动启动备用", text: "如果自动发现失败,再运行下面命令。", command: "npx -y @basketikun/canvas-agent" }, ]; type AgentEventPayload = { @@ -38,11 +39,13 @@ type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; +type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean }; -export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) { +export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, headless, autoConnect, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; headless?: boolean; autoConnect?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); const { message, modal } = App.useApp(); + const searchParams = useSearchParams(); const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore(); const [resizing, setResizing] = useState(false); const listRef = useRef(null); @@ -50,11 +53,13 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde const confirmToolsRef = useRef(confirmTools); const pendingToolRef = useRef(null); const onApplyOpsRef = useRef(onApplyOps); + const autoConnectRef = useRef(false); const connectedRef = useRef(false); const errorLoggedRef = useRef(false); const attachmentUrlsRef = useRef(new Set()); const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID()); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); + const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken"); const loadThreads = useCallback(async () => { const projectId = snapshotRef.current.projectId; if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return; @@ -106,7 +111,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde errorLoggedRef.current = false; connectedRef.current = true; setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); - message.success("本地 Agent 已连接"); + if (!headless) message.success("本地 Agent 已连接"); void postState(endpoint, token, clientId, snapshotRef.current); }); source.addEventListener("tool_call", (event) => { @@ -136,7 +141,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; if (!errorLoggedRef.current || wasConnected) { addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text }); - message.error(text); + if (!headless) message.error(text); } errorLoggedRef.current = true; connectedRef.current = false; @@ -288,36 +293,51 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde if (connected) void postState(endpoint, token, clientIdRef.current, restored); }; - const toggleAgentConnection = () => { + const toggleAgentConnection = async () => { if (enabled) { clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); return; } - if (!endpoint) { + const urlToken = searchParams.get("agentToken") || ""; + const urlEndpoint = searchParams.get("agentUrl") || ""; + const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL); + const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, ""); + const nextToken = (urlToken || token.trim() || discovered?.token || "").trim(); + if (!nextEndpoint) { const text = "请填写本地 Agent 地址"; setAgentState({ connectError: text }); - message.warning(text); + if (!headless) message.warning(text); return; } - if (!token.trim()) { - const text = "请填写 Agent token"; + if (!nextToken) { + const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent"; setAgentState({ connectError: text }); - message.warning(text); + if (!headless) message.warning(text); return; } try { - const parsed = new URL(endpoint); + const parsed = new URL(nextEndpoint); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); } catch { const text = "本地 Agent 地址格式不正确"; setAgentState({ connectError: text }); - message.warning(text); + if (!headless) message.warning(text); return; } errorLoggedRef.current = false; - setAgentState({ url: endpoint, token: token.trim(), enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" }); + setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" }); }; + useEffect(() => { + if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false }); + }, [confirmTools, setAgentState, urlAgentAutoConnect]); + + useEffect(() => { + if (!autoConnect || autoConnectRef.current || enabled || connected) return; + autoConnectRef.current = true; + void toggleAgentConnection(); + }, [autoConnect, connected, enabled]); + function clearAgentSession(patch: Parameters[0] = {}) { setAgentState({ messages: [], @@ -544,6 +564,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde ); + if (headless) return null; if (embedded) return content; return ( @@ -624,7 +645,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
连接本地 Agent
- 本地服务只监听 127.0.0.1,画布通过地址和 token 连接到你电脑上的 Codex。 + 安装 Codex 插件后,画布会优先自动连接本机 Agent。
@@ -657,7 +678,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
- 填入终端输出的 Local URL 和 Connect token。 + 默认自动读取 Local URL 和 Connect token,失败时再手动填写。