diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c768b..e3984c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,15 @@ ## Unreleased ++ [新增] Agent 面板全站化:从画布页抽离为全局常驻右侧面板,开/关整体推动顶栏和内容(类似浏览器侧边栏扩展),顶栏和画布工具栏各有一个独立开关按钮。 ++ [新增] Agent 新增 `site_navigate` 工具,Codex 可直接控制网站跳转至首页、画布、生图、视频、提示词库、素材、配置等页面。 ++ [新增] Agent 添加停止对话按钮:对话运行中发送按钮变为红色停止按钮,点击后向后端发送 SIGINT 中断当前 Codex turn。 ++ [调整] Codex 会话改为单一站点级连续线程,不再按画布 ID 分隔,跨页面和跨画布保持同一对话上下文; ++ [调整] 移除仅前端调用 OpenAI responses 接口的在线画布助手死代码,Agent 统一走 MCP + 本地 Codex 链路。 + [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。 -+ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。 ++ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。 + [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。 - ## v0.6.0 - 2026-07-09 + [新增] 新增Codex App插件支持。 diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index 41f1cdf..209bbfa 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -30,6 +30,11 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: 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 { @@ -200,6 +205,16 @@ class CodexAppClient { 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 }); diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index 5147a82..afb1a28 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -49,6 +49,10 @@ export class CanvasSession { if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`); let tool: ToolName = name; let input = parseToolInput(tool, rawInput) as Record; + if (tool === "site_navigate") { + 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); diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts index 48d3a77..69e1f68 100644 --- a/canvas-agent/src/config.ts +++ b/canvas-agent/src/config.ts @@ -7,10 +7,10 @@ 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 网页画布。需要改动画布时优先使用已配置的 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。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; +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 打开画布。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。"; -export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; -export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record }; +export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] }; +export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig }; export function loadConfig(create = false): CanvasAgentConfig { try { @@ -27,30 +27,28 @@ export function saveConfig(config: CanvasAgentConfig) { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } -export function ensureCanvasWorkspace(config: CanvasAgentConfig, canvasId: string) { - const id = safeSegment(canvasId || "default"); - config.canvases ||= {}; - const current = config.canvases[id]; +export function ensureSiteWorkspace(config: CanvasAgentConfig) { + const current = config.workspace; if (current?.workspacePath) { - fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true }); - return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) }; + const workspacePath = resolveWorkspacePath(current.workspacePath); + fs.mkdirSync(workspacePath, { recursive: true }); + return { ...current, workspacePath }; } - const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id); - config.canvases[id] = { workspacePath }; + const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site"); + config.workspace = { workspacePath }; fs.mkdirSync(workspacePath, { recursive: true }); saveConfig(config); - return { canvasId: id, workspacePath }; + return { workspacePath }; } -export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial) { - const current = ensureCanvasWorkspace(config, canvasId); +export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial) { + const current = ensureSiteWorkspace(config); const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath; const next = { ...current, ...patch, workspacePath }; - config.canvases ||= {}; - config.canvases[current.canvasId] = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds }; + config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds }; fs.mkdirSync(workspacePath, { recursive: true }); saveConfig(config); - return { canvasId: current.canvasId, ...config.canvases[current.canvasId] }; + return config.workspace; } function resolveWorkspacePath(value: string) { @@ -59,10 +57,6 @@ function resolveWorkspacePath(value: string) { return path.resolve(value); } -function safeSegment(value: string) { - return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default"; -} - function readPackageVersion() { try { const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string }; diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index 49965b2..82354f7 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -1,8 +1,8 @@ import express, { type NextFunction, type Request, type Response } from "express"; -import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js"; +import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js"; import { CanvasSession } from "./canvas-session.js"; -import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.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() { @@ -38,56 +38,60 @@ export function startHttpServer() { 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 = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); + 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 = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); + 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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + 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).id || ""); - updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId }); + 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 = ensureCanvasWorkspace(config, String(req.query.canvasId || "")); + 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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + const workspace = ensureSiteWorkspace(config); const threadId = routeParam(req.params.threadId); const result = await resumeCodexThread(emit, threadId, workspace.workspacePath); - updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + 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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + const workspace = ensureSiteWorkspace(config); const threadId = routeParam(req.params.threadId); await archiveCodexThread(emit, threadId, workspace.workspacePath); - if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined }); + 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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || "")); + 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).id || ""); - updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + updateSiteWorkspace(config, { activeThreadId: threadId }); } else if (threadId !== workspace.activeThreadId) { await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath); - updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId }); + 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 }); diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index b60fd1c..9b8b02e 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -7,6 +7,7 @@ const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]); const generationModeSchema = z.enum(["text", "image", "video", "audio"]); export const toolNames = [ + "site_navigate", "canvas_get_state", "canvas_get_selection", "canvas_export_snapshot", @@ -77,6 +78,7 @@ const generationFlowSchema = z.object({ }); export const toolInputSchemas = { + site_navigate: z.object({ path: z.string() }), canvas_get_state: z.object({}).passthrough(), canvas_get_selection: z.object({}).passthrough(), canvas_export_snapshot: z.object({}).passthrough(), @@ -103,6 +105,7 @@ export const toolInputSchemas = { } satisfies Record; export const toolDescriptions: Record = { + site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。", canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。", canvas_get_selection: "读取当前网页画布选中的节点。", canvas_export_snapshot: "导出当前画布快照,用于理解布局。", diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 1a2f8f4..ddb81ce 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,11 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 全站 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 可查看该字段。 - 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。 - 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组。 diff --git a/web/src/components/agent/agent-panel.tsx b/web/src/components/agent/agent-panel.tsx new file mode 100644 index 0000000..b45e73f --- /dev/null +++ b/web/src/components/agent/agent-panel.tsx @@ -0,0 +1,87 @@ +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 { canvasThemes } from "@/lib/canvas-theme"; +import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store"; +import { useThemeStore } from "@/stores/use-theme-store"; + +const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; + +export function AgentPanel() { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const width = useAgentStore((state) => state.width); + const [resizing, setResizing] = useState(false); + 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) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = width; + let nextWidth = startWidth; + const onMove = (moveEvent: PointerEvent) => { + nextWidth = Math.min(760, Math.max(360, startWidth + startX - moveEvent.clientX)); + setAgentState({ width: nextWidth }); + }; + const onUp = () => { + localStorage.setItem("canvas-agent-panel-width", String(nextWidth)); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + setResizing(false); + }; + setResizing(true); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + if (!panelMounted) return null; + + return ( + + + - - - } - > -

将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。

- - - ); - - return ( - - - - - - - - - - ); -} - -function OnlineAgentLogView({ logs, theme, context, onClear }: { logs: OnlineAgentLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: OnlineAgentLogContext; onClear: () => void }) { - const [mode, setMode] = useState<"text" | "json">("text"); - const textareaRef = useRef(null); - const content = mode === "text" ? formatOnlineLogText(logs, context) : formatOnlineLogJson(logs, context); - const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${stringifyLog(item.data)}`)); - const copy = async (value = content) => { - if (await copyToClipboard(value)) return; - textareaRef.current?.focus(); - textareaRef.current?.select(); - }; - return ( -
-
- setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} /> -
- {logs.length} 条 - - - -
-
-