feat(plugin): add Codex App plugin support and local agent integration

This commit is contained in:
HouYunFei
2026-06-23 17:35:43 +08:00
parent 8cbe00e348
commit bd0ad0aebf
18 changed files with 518 additions and 28 deletions
+20
View File
@@ -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"
}
]
}
+1
View File
@@ -3,6 +3,7 @@
## Unreleased
+ [新增] 渠道兼容Gemini格式。
+ [新增] 新增Codex App插件支持。
## v0.4.0 - 2026-06-16
+3 -1
View File
@@ -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)
## 赞助支持
+23 -1
View File
@@ -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
@@ -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=<Local URL>&agentToken=<Connect token>
```
`mode=new` 会让网页自动创建具体画布并连接本地 Agent,不需要手动点击“新建画布”。
+1
View File
@@ -4,6 +4,7 @@
"defaultOpen": true,
"pages": [
"quick-start",
"codex-app-plugin",
"features",
"render",
"docker",
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"infinite-canvas": {
"command": "npx",
"args": ["-y", "@basketikun/canvas-agent", "mcp"],
"startup_timeout_sec": 20,
"tool_timeout_sec": 90
}
}
}
+111
View File
@@ -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=<Local URL>&agentToken=<Connect token>`。不要通过页面点击来新建画布;`mode=new` 会让网页自动创建具体画布并连接本地 Agent。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -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。
- 写入画布的操作会由网页侧边栏做二次确认,按当前工具结果继续推进即可。
## 风格
- 页面文案和画布节点内容默认使用中文。
- 生成节点、配置节点和提示词节点要保持结构清晰,方便用户继续编辑。
- 批量创建节点时注意给节点留出间距,不要堆叠在同一个位置。
- 图片、视频、音频等媒体节点默认保留原始比例;只有用户明确要求自由变形时才改变比例。
- 生成流程尽量少而清楚,优先让用户一眼能看懂节点关系。
@@ -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=<Local URL>&agentToken=<Connect token>`
- 最近画布:`<画布网页地址>/canvas?mode=recent&agentUrl=<Local URL>&agentToken=<Connect token>`
- 自己选择:`<画布网页地址>/canvas?mode=choose&agentUrl=<Local URL>&agentToken=<Connect token>`
默认打开新建本地画布;只有用户明确要求线上地址、最近画布或自己选择时,才改用对应模式。
## 工作流
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=<Local URL>&agentToken=<Connect token>`
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。
@@ -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<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(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<CanvasAgentMode>("online");
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(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() {
</Modal>
<AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
{codexCompactAgent && !assistantMounted ? <CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={Boolean(agentUndoSnapshot)} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} /> : null}
</section>
{assistantMounted ? (
<CanvasAssistantPanel
@@ -2791,6 +2810,7 @@ function InfiniteCanvasPage() {
onPasteImage={pasteAssistantImage}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoConnectLocal={codexAutoConnect}
closing={assistantClosing}
onCollapse={closeAgent}
/>
@@ -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({
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
<UserStatusActions
variant="canvas"
onOpenShortcuts={() => 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 (
<button
type="button"
className="flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium transition hover:opacity-85"
style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
onClick={onClick}
title="打开本地 Codex 面板"
>
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[180px] truncate">{label}</span>
</button>
);
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
@@ -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
@@ -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<HTMLDivElement>(null);
@@ -50,11 +53,13 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
const onApplyOpsRef = useRef(onApplyOps);
const autoConnectRef = useRef(false);
const connectedRef = useRef(false);
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
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<typeof setAgentState>[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
<div>
<div className="text-base font-semibold leading-6"> Agent</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
127.0.0.1 token Codex
Codex Agent
</div>
</div>
<div className="space-y-2">
@@ -657,7 +678,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</span>
</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
Local URL Connect token
Local URL Connect token
</div>
</div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
@@ -679,7 +700,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
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" />
<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" }}>
@@ -971,6 +992,17 @@ async function fetchAgentJson<T>(endpoint: string, token: string, path: string,
return data;
}
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;
}
}
function normalizeHistoryMessages(messages: AgentChatItem[]) {
return messages
.map((item, index) => ({
@@ -1,7 +1,7 @@
"use client";
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
@@ -10,6 +10,7 @@ import { exportCanvasProjects } from "../utils/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
const searchParams = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
@@ -21,7 +22,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`);
const open = () => router.push(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
stopEditing();
+16 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { App, Button } from "antd";
import { Download, FileUp, Plus } from "lucide-react";
@@ -18,7 +18,9 @@ import { exportCanvasProjects } from "./utils/canvas-export";
export default function CanvasPage() {
const { message } = App.useApp();
const router = useRouter();
const searchParams = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
const autoOpenRef = useRef(false);
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const createProject = useCanvasStore((state) => state.createProject);
@@ -26,8 +28,11 @@ export default function CanvasPage() {
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const mode = searchParams.get("mode");
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const agentQuery = agentMode ? `?${searchParams.toString()}` : "";
const enterProject = (id: string) => {
router.push(`/canvas/${id}`);
router.push(`/canvas/${id}${agentQuery}`);
};
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
const importCanvas = async (file?: File) => {
@@ -56,6 +61,14 @@ export default function CanvasPage() {
}
};
useEffect(() => {
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent")) return;
autoOpenRef.current = true;
enterProject(mode === "new" ? createProject(`无限画布 ${projects.length + 1}`) : projects[0]?.id || createProject(`无限画布 ${projects.length + 1}`));
}, [createProject, hydrated, mode, projects]);
if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">...</main>;
return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">