diff --git a/CHANGELOG.md b/CHANGELOG.md index 2894e4e..4d65287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ + [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。 + [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。 + [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。 ++ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。 + [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。 + [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。 + [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 9a745d5..79412b0 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { App, Button, Input, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; @@ -103,7 +103,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb source.addEventListener("hello", () => { errorLoggedRef.current = false; connectedRef.current = true; - setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); + setAgentState({ connected: true, activity: "已连接", connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) }); if (!headless) message.success("本地 Agent 已连接"); void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null); }); @@ -131,14 +131,15 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb }); source.onerror = () => { const wasConnected = connectedRef.current; + const silent = useAgentStore.getState().silentConnect && !wasConnected; const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token"; if (!errorLoggedRef.current || wasConnected) { addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text }); - if (!headless) message.error(text); + if (!headless && !silent) message.error(text); } errorLoggedRef.current = true; connectedRef.current = false; - clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text }); + clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false }); if (!wasConnected) { source.close(); setAgentState({ enabled: false }); @@ -327,7 +328,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (connected) void postState(endpoint, token, clientIdRef.current, restored); }; - const toggleAgentConnection = async () => { + const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => { if (enabled) { clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); return; @@ -339,14 +340,18 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const nextToken = (urlToken || token.trim() || discovered?.token || "").trim(); if (!nextEndpoint) { const text = "请填写本地 Agent 地址"; - setAgentState({ connectError: text }); - if (!headless) message.warning(text); + if (!silent) { + setAgentState({ connectError: text }); + if (!headless) message.warning(text); + } return; } if (!nextToken) { const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent"; - setAgentState({ connectError: text }); - if (!headless) message.warning(text); + if (!silent) { + setAgentState({ connectError: text }); + if (!headless) message.warning(text); + } return; } try { @@ -354,12 +359,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); } catch { const text = "本地 Agent 地址格式不正确"; - setAgentState({ connectError: text }); - if (!headless) message.warning(text); + if (!silent) { + setAgentState({ connectError: text }); + if (!headless) message.warning(text); + } return; } errorLoggedRef.current = false; - setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" }); + setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" }); }; useEffect(() => { @@ -369,7 +376,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb useEffect(() => { if (!autoConnect || autoConnectRef.current || enabled || connected) return; autoConnectRef.current = true; - void toggleAgentConnection(); + void toggleAgentConnection({ silent: true }); }, [autoConnect, connected, enabled]); function clearAgentSession(patch: Parameters[0] = {}) { @@ -630,6 +637,26 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con copyToClipboard(command); message.success("命令已复制"); }; + const codexPluginReminder = ( +
+
Codex 插件提醒
+
只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。
+
+ {[ + ["移除插件", AGENT_PLUGIN_REMOVE_COMMAND], + ["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND], + ].map(([label, command]) => ( +
+ {label} + {command} + +
+ ))} +
+
+ ); return (
@@ -640,43 +667,27 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
- {AGENT_CONNECT_STEPS.map((step) => { + {AGENT_CONNECT_STEPS.map((step, index) => { const command = "command" in step ? step.command : ""; return ( -
-
{step.title}
-
{step.text}
- {command ? ( -
- {command} - -
- ) : null} -
+ +
+
{step.title}
+
{step.text}
+ {command ? ( +
+ {command} + +
+ ) : null} +
+ {index === 0 ? codexPluginReminder : null} +
); })}
- -
-
Codex 插件提醒
-
只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。
-
- {[ - ["移除插件", AGENT_PLUGIN_REMOVE_COMMAND], - ["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND], - ].map(([label, command]) => ( -
- {label} - {command} - -
- ))} -
-
diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index 5d0b028..b52ab5f 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -1,5 +1,5 @@ -import { App, Button, Form, Input, Modal, Progress, Select, Switch, Tabs } from "antd"; -import { CircleAlert, Cloud, KeyRound, Link2, Plus, RefreshCw, ShieldCheck, Trash2, Wifi } from "lucide-react"; +import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; +import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react"; import { useEffect, useState } from "react"; import { ModelPicker } from "@/components/model-picker"; @@ -7,7 +7,6 @@ import { fetchChannelModels } from "@/services/api/image"; 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"; -import { useAgentStore } from "@/stores/use-agent-store"; import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; type ModelGroup = { @@ -45,12 +44,6 @@ const webdavDomainLabels: Record = { "image-workbench": "生图工作台", "video-workbench": "视频创作台", }; -const codexSetupSteps = [ - { title: "方式一:在 Codex 中使用插件", text: "先在 Codex App 安装 Infinite Canvas 插件,再通过插件启动画布,插件会自动启动本地 Canvas Agent 并带上连接信息。" }, - { title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" }, -]; -const codexPluginRemoveCommand = "codex plugin remove infinite-canvas"; -const codexMcpRemoveCommand = "codex mcp remove infinite-canvas"; function createWebdavDomainProgress(): Record { return webdavDomainKeys.reduce( @@ -77,16 +70,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); - const agentUrl = useAgentStore((state) => state.url); - const agentToken = useAgentStore((state) => state.token); - const agentConnected = useAgentStore((state) => state.connected); - const agentEnabled = useAgentStore((state) => state.enabled); - const agentActivity = useAgentStore((state) => state.activity); - const agentConnectError = useAgentStore((state) => state.connectError); - const agentConfirmTools = useAgentStore((state) => state.confirmTools); - const setAgentState = useAgentStore((state) => state.setAgentState); - const connectAgent = useAgentStore((state) => state.connectAgent); - const disconnectAgent = useAgentStore((state) => state.disconnectAgent); const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model })); const webdavReady = Boolean(webdav.url.trim()); useEffect(() => setActiveTab(initialTab), [initialTab]); @@ -222,14 +205,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" } }; - const updateAgentConfig = (patch: { url?: string; token?: string }) => { - setAgentState({ ...patch, connectError: "" }); - if (patch.url !== undefined) localStorage.setItem("canvas-agent-url", patch.url.trim().replace(/\/$/, "")); - if (patch.token !== undefined) localStorage.setItem("canvas-agent-token", patch.token); - }; - - const toggleAgentConnection = () => (agentEnabled ? disconnectAgent({ connectError: "" }) : connectAgent()); - return ( <> ), }, - { - key: "codex", - label: "Codex", - children: ( -
-
-
-
-
- - 连接本地 Codex -
-
用于画布 Agent 连接本机 Codex 插件启动的 Canvas Agent。
-
-
{agentConnectError ? "连接失败" : agentConnected ? agentActivity || "已连接" : agentEnabled ? "连接中" : "未连接"}
-
-
- {codexSetupSteps.map((step, index) => ( -
-
连接方式 {index + 1}
-
{step.title}
-
{step.text}
- {step.command ? {step.command} : null} -
- ))} -
- -
-
Codex 插件提醒
-
只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。
- 移除插件:{codexPluginRemoveCommand} - 移除手动 MCP:{codexMcpRemoveCommand} -
-
- - } value={agentUrl} placeholder="http://127.0.0.1:17371" onChange={(event) => updateAgentConfig({ url: event.target.value })} /> - - - } value={agentToken} placeholder="自动发现,或手动填入 Connect token" onChange={(event) => updateAgentConfig({ token: event.target.value })} /> - -
- {agentConnectError ?
{agentConnectError}
: null} -
- -
-
-
- -
-
执行画布操作前确认
-
关闭后,本地 Codex 可直接执行画布工具调用。不再需要人工确认
-
-
- setAgentState({ confirmTools })} /> -
-
-
- ), - }, ]} /> {showDoneButton ? ( diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index a47d0ce..279f49f 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -9,7 +9,6 @@ import { UserStatusActions } from "@/components/layout/user-status-actions"; import { cn } from "@/lib/utils"; import { useEffect, useRef, useState } from "react"; import { useAgentStore } from "@/stores/use-agent-store"; -import { useConfigStore } from "@/stores/use-config-store"; export function AppTopNav() { const { pathname } = useLocation(); @@ -28,7 +27,7 @@ export function AppTopNav() { useEffect(() => { if (autoConnectRef.current || agentEnabled || agentConnected || !agentToken.trim()) return; autoConnectRef.current = true; - connectAgent(); + connectAgent({ silent: true }); }, [agentConnected, agentEnabled, agentToken, connectAgent]); return ( @@ -82,7 +81,6 @@ export function AppTopNav() {
- - - ); -} diff --git a/web/src/pages/config/index.tsx b/web/src/pages/config/index.tsx index f8f2678..fe80faf 100644 --- a/web/src/pages/config/index.tsx +++ b/web/src/pages/config/index.tsx @@ -6,7 +6,7 @@ export default function ConfigPage() {

配置与用户偏好

-

渠道聚合、模型选择、同步偏好和 Codex 连接配置

+

渠道聚合、模型选择和同步偏好

diff --git a/web/src/stores/use-agent-store.ts b/web/src/stores/use-agent-store.ts index 9f4b0f6..993cf96 100644 --- a/web/src/stores/use-agent-store.ts +++ b/web/src/stores/use-agent-store.ts @@ -25,6 +25,7 @@ type AgentStore = { token: string; connected: boolean; enabled: boolean; + silentConnect: boolean; prompt: string; attachments: AgentAttachment[]; sending: boolean; @@ -45,7 +46,7 @@ type AgentStore = { closePanel: () => void; togglePanel: () => void; setCanvasContext: (context: AgentCanvasContext | null) => void; - connectAgent: () => void; + connectAgent: (options?: { silent?: boolean }) => void; disconnectAgent: (patch?: Partial>) => void; addMessage: (item: AgentChatItem) => void; addEventLog: (item: AgentEventLog) => void; @@ -64,6 +65,7 @@ export const useAgentStore = create((set, get) => ({ token: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-token") || "", connected: false, enabled: false, + silentConnect: false, prompt: "", attachments: [], sending: false, @@ -90,27 +92,28 @@ export const useAgentStore = create((set, get) => ({ }, togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()), setCanvasContext: (canvasContext) => set({ canvasContext }), - connectAgent: () => { + connectAgent: (options) => { + const silent = options?.silent ?? false; const endpoint = get().url.trim().replace(/\/$/, ""); const token = get().token.trim(); - if (!endpoint || !token) return set({ connectError: "请填写 Local URL 和 Connect token" }); + if (!endpoint || !token) return set({ connectError: silent ? "" : "请填写 Local URL 和 Connect token" }); try { const parsed = new URL(endpoint); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(); } catch { - return set({ connectError: "Local URL 格式不正确" }); + return set({ connectError: silent ? "" : "Local URL 格式不正确" }); } localStorage.setItem("canvas-agent-url", endpoint); localStorage.setItem("canvas-agent-token", token); // 只设 enabled=true,由 CanvasLocalAgentPanel 的 useEffect 统一负责开 SSE - set({ url: endpoint, token, enabled: true, activity: "连接中", connectError: "" }); + set({ url: endpoint, token, enabled: true, silentConnect: silent, activity: "连接中", connectError: "" }); }, disconnectAgent: (patch = {}) => { agentSource?.close(); agentSource = null; if (connectTimer) clearTimeout(connectTimer); connectTimer = null; - set({ enabled: false, connected: false, activity: "离线", ...patch }); + set({ enabled: false, connected: false, silentConnect: false, activity: "离线", ...patch }); }, addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })), addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })), diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index c93c3f1..b54cf37 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -52,7 +52,7 @@ export type WebdavSyncConfig = { directory: string; lastSyncedAt: string; }; -export type ConfigTabKey = "channels" | "models" | "preferences" | "webdav" | "codex"; +export type ConfigTabKey = "channels" | "models" | "preferences" | "webdav"; export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; export type ModelCapability = "image" | "video" | "text" | "audio"; diff --git a/web/tests/ime-keyboard.test.ts b/web/tests/ime-keyboard.test.ts deleted file mode 100644 index 331eed6..0000000 --- a/web/tests/ime-keyboard.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import assert from "node:assert/strict"; - -import { isImeComposing, isPlainEnterKey } from "../src/lib/keyboard-event"; - -type KeyboardEventLike = Parameters[0]; - -function enterEvent(overrides: Partial = {}): KeyboardEventLike { - return { - key: "Enter", - shiftKey: false, - ctrlKey: false, - metaKey: false, - ...overrides, - }; -} - -assert.equal(isPlainEnterKey(enterEvent()), true, "plain Enter submits"); -assert.equal(isPlainEnterKey(enterEvent({ shiftKey: true })), false, "Shift+Enter does not submit"); -assert.equal(isPlainEnterKey(enterEvent({ ctrlKey: true })), false, "Ctrl+Enter does not submit"); -assert.equal(isPlainEnterKey(enterEvent({ metaKey: true })), false, "Meta+Enter does not submit"); -assert.equal(isPlainEnterKey(enterEvent({ nativeEvent: { isComposing: true } })), false, "IME composition Enter does not submit"); -assert.equal(isPlainEnterKey(enterEvent({ nativeEvent: { keyCode: 229 } })), false, "legacy IME Enter does not submit"); -assert.equal(isPlainEnterKey(enterEvent({ key: "a" })), false, "non-Enter keys do not submit"); - -assert.equal(isImeComposing(enterEvent({ isComposing: true })), true, "direct composition flag is detected"); -assert.equal(isImeComposing(enterEvent({ nativeEvent: { isComposing: true } })), true, "native composition flag is detected"); -assert.equal(isImeComposing(enterEvent({ keyCode: 229 })), true, "direct legacy keyCode composition is detected"); -assert.equal(isImeComposing(enterEvent({ nativeEvent: { keyCode: 229 } })), true, "native legacy keyCode composition is detected"); -assert.equal(isImeComposing(enterEvent()), false, "plain Enter is not composition"); - -console.log("ime keyboard tests passed");