From 3080d6da10985b99fa5f3c3246a0a70dbf897c84 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Sun, 5 Jul 2026 23:40:54 +0800 Subject: [PATCH] feat: enhance Codex agent connection handling with automatic thread recovery and improved error logging --- canvas-agent/src/agents.ts | 36 +++++++++++---- canvas-agent/src/canvas-session.ts | 5 +- docs/content/docs/progress/pending-test.mdx | 3 ++ .../canvas/canvas-local-agent-panel.tsx | 18 +++++--- .../components/layout/app-config-modal.tsx | 29 ++++++++---- web/src/components/layout/app-top-nav.tsx | 35 +++++++++++++- web/src/pages/canvas/project.tsx | 12 ++--- .../stores/canvas/use-canvas-agent-store.ts | 46 ++++++++++++++++++- web/src/stores/use-config-store.ts | 7 ++- 9 files changed, 154 insertions(+), 37 deletions(-) diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index be0e18f..41f1cdf 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -35,8 +35,16 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age try { files = await writeAttachmentFiles(attachments); codexApp ||= await CodexAppClient.start(emit); - const threadId = await ensureCodexThread(codexApp, options); - await codexApp.startTurn(threadId, prompt, files); + let threadId = await ensureCodexThread(codexApp, options, emit); + try { + await codexApp.startTurn(threadId, prompt, files); + } catch (error) { + if (!isRecoverableThreadError(error)) throw error; + emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); + codexThreadId = ""; + threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit); + await codexApp.startTurn(threadId, prompt, files); + } } catch (error) { emit("agent_error", { message: errorMessage(error) }); } finally { @@ -96,14 +104,20 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) { pipeJsonLines(child, emit, "claude"); } -async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) { +async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) { if (options.threadId) { - const result = await app.readThread(options.threadId, false); - assertThreadWorkspace(field(result, "thread") || {}, options.cwd); - const thread = await app.resumeThread(options.threadId, options.cwd); - assertThreadWorkspace(thread, options.cwd); - codexThreadId = String(field(thread, "id") || options.threadId); - return codexThreadId; + if (options.threadId === codexThreadId) return codexThreadId; + try { + const result = await app.readThread(options.threadId, false); + assertThreadWorkspace(field(result, "thread") || {}, options.cwd); + const thread = await app.resumeThread(options.threadId, options.cwd); + assertThreadWorkspace(thread, options.cwd); + codexThreadId = String(field(thread, "id") || options.threadId); + return codexThreadId; + } catch (error) { + if (!isRecoverableThreadError(error)) throw error; + emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); + } } if (!codexThreadId) { const thread = await app.startThread(options.cwd); @@ -112,6 +126,10 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) return codexThreadId; } +function isRecoverableThreadError(error: unknown) { + return /thread not loaded|no rollout found/i.test(errorMessage(error)); +} + class CodexAppClient { private nextId = 1; private buffer = ""; diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts index 754faf6..5147a82 100644 --- a/canvas-agent/src/canvas-session.ts +++ b/canvas-agent/src/canvas-session.ts @@ -18,13 +18,14 @@ export class CanvasSession { openEvents(url: URL, res: ServerResponse) { const clientId = url.searchParams.get("clientId") || crypto.randomUUID(); + const statusOnly = url.searchParams.get("role") === "status"; res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); - this.clients.set(clientId, res); + if (!statusOnly) this.clients.set(clientId, res); sendEvent(res, "hello", { ok: true, clientId }); const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000); res.on("close", () => { clearInterval(timer); - this.clients.delete(clientId); + if (!statusOnly) this.clients.delete(clientId); if (this.canvasState?.clientId === clientId) this.canvasState = null; }); } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8f28372..935b32a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -10,6 +10,9 @@ description: 当前版本已实现但仍需人工验证的变更项 - Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。 - 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。 - 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。 +- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。 +- 画布 Agent:Codex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。 +- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。 - 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。 - 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。 - Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 508c505..1dfdaf5 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -64,14 +64,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`); - const current = useCanvasAgentStore.getState(); + const nextThreadId = data.workspace?.activeThreadId || ""; setAgentState({ threads: data.data || [], - workspacePath: data.workspace?.workspacePath || current.workspacePath, - activeThreadId: data.workspace?.activeThreadId || current.activeThreadId, + workspacePath: data.workspace?.workspacePath || "", + activeThreadId: nextThreadId, + messages: [], }); - const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId; - if (nextThreadId && !current.messages.length) { + if (nextThreadId) { const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`); setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) }); } @@ -152,13 +152,17 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde return () => { source.close(); connectedRef.current = false; - setAgentState({ connected: false }); }; }, [enabled, endpoint, loadThreads, message, setAgentState, token]); useEffect(() => { if (connected) void loadThreads(); - }, [connected, loadThreads, snapshot.projectId]); + }, [connected, loadThreads]); + + useEffect(() => { + clearAgentSession({ activity: connected ? "切换画布" : activity }); + if (connected) void loadThreads(); + }, [snapshot.projectId]); useEffect(() => { if (!connected) return; diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index e1bce17..36892b7 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -1,6 +1,6 @@ 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 { useState } from "react"; +import { useEffect, useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { fetchChannelModels } from "@/services/api/image"; @@ -8,7 +8,7 @@ import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; -import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ModelCapability, type ModelChannel } from "@/stores/use-config-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 = { capability: ModelCapability; @@ -47,7 +47,7 @@ const webdavDomainLabels: Record = { }; const codexSetupSteps = [ { title: "安装 Codex 插件", text: "先在 Codex App 安装 Infinite Canvas 插件,插件会注册 MCP 并尝试启动本地 Canvas Agent。" }, - { title: "打开画布 Agent", text: "回到画布右侧 Agent 面板,网页会自动读取 Local URL 和 Connect token。" }, + { title: "连接本地 Agent", text: "在本页填入 Local URL 和 Connect token 后点击连接。" }, { title: "手动启动备用", text: "如果插件没有自动启动本地服务,在终端运行下面命令。", command: "npx -y @basketikun/canvas-agent" }, ]; @@ -61,9 +61,9 @@ function createWebdavDomainProgress(): Record(initialTab); const [loadingChannelId, setLoadingChannelId] = useState(""); const [testingWebdav, setTestingWebdav] = useState(false); const [syncingWebdav, setSyncingWebdav] = useState(false); @@ -81,10 +81,14 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo const agentConnected = useCanvasAgentStore((state) => state.connected); const agentEnabled = useCanvasAgentStore((state) => state.enabled); const agentActivity = useCanvasAgentStore((state) => state.activity); + const agentConnectError = useCanvasAgentStore((state) => state.connectError); const agentConfirmTools = useCanvasAgentStore((state) => state.confirmTools); const setAgentState = useCanvasAgentStore((state) => state.setAgentState); + const connectAgent = useCanvasAgentStore((state) => state.connectAgent); + const disconnectAgent = useCanvasAgentStore((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]); const saveConfig = (nextConfig: AiConfig) => { (Object.keys(nextConfig) as Array).forEach((key) => updateConfig(key, nextConfig[key])); @@ -218,11 +222,13 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo }; const updateAgentConfig = (patch: { url?: string; token?: string }) => { - setAgentState(patch); + 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 ( <>
用于画布 Agent 连接本机 Codex 插件启动的 Canvas Agent。
-
{agentConnected ? agentActivity || "已连接" : agentEnabled ? "连接中" : "未连接"}
+
{agentConnectError ? "连接失败" : agentConnected ? agentActivity || "已连接" : agentEnabled ? "连接中" : "未连接"}
{codexSetupSteps.map((step, index) => ( @@ -450,6 +456,12 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo } value={agentToken} placeholder="自动发现,或手动填入 Connect token" onChange={(event) => updateAgentConfig({ token: event.target.value })} />
+ {agentConnectError ?
{agentConnectError}
: null} +
+ +
@@ -479,6 +491,7 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo export function AppConfigModal() { const isConfigOpen = useConfigStore((state) => state.isConfigOpen); + const configTab = useConfigStore((state) => state.configTab); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); return ( - + ); } diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index ea3cf7a..be2195c 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -1,4 +1,5 @@ import { Menu } from "lucide-react"; +import { Button, Tooltip } from "antd"; import { Link, useLocation } from "react-router-dom"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; @@ -6,15 +7,28 @@ import { AppConfigModal } from "@/components/layout/app-config-modal"; import { MobileNavDrawer } from "@/components/layout/mobile-nav-drawer"; import { UserStatusActions } from "@/components/layout/user-status-actions"; import { cn } from "@/lib/utils"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; +import { useConfigStore } from "@/stores/use-config-store"; export function AppTopNav() { const { pathname } = useLocation(); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const autoConnectRef = useRef(false); + const agentToken = useCanvasAgentStore((state) => state.token); + const agentEnabled = useCanvasAgentStore((state) => state.enabled); + const agentConnected = useCanvasAgentStore((state) => state.connected); + const connectAgent = useCanvasAgentStore((state) => state.connectAgent); const hideHeader = /^\/canvas\/[^/]+/.test(pathname); const slug = pathname.split("/").filter(Boolean)[0]; const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; + useEffect(() => { + if (autoConnectRef.current || agentEnabled || agentConnected || !agentToken.trim()) return; + autoConnectRef.current = true; + connectAgent(); + }, [agentConnected, agentEnabled, agentToken, connectAgent]); + return ( <> {!hideHeader ? ( @@ -66,6 +80,7 @@ export function AppTopNav() {
+
@@ -77,3 +92,21 @@ export function AppTopNav() { ); } + +function CodexStatusButton() { + const connected = useCanvasAgentStore((state) => state.connected); + const enabled = useCanvasAgentStore((state) => state.enabled); + const activity = useCanvasAgentStore((state) => state.activity); + const connectError = useCanvasAgentStore((state) => state.connectError); + const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + const color = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : "currentColor"; + const title = connectError || (connected ? activity || "Codex 已连接" : enabled ? "Codex 连接中" : "Codex 未连接"); + return ( + + + + ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 8df1e0a..9a3d172 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2502,7 +2502,7 @@ function InfiniteCanvasPage() { onUndo={undoCanvas} onRedo={redoCanvas} agentOpen={assistantOpen} - compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined} + compactAgentStatus={{ connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity }} onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())} /> @@ -2915,10 +2915,10 @@ function CanvasTopBar({ )} +
- {compactAgentStatus ? : null} setShortcutsOpen(true)} @@ -2968,18 +2968,18 @@ 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 label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接"; const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted; return ( ); } diff --git a/web/src/stores/canvas/use-canvas-agent-store.ts b/web/src/stores/canvas/use-canvas-agent-store.ts index 54bba90..2c0e6cb 100644 --- a/web/src/stores/canvas/use-canvas-agent-store.ts +++ b/web/src/stores/canvas/use-canvas-agent-store.ts @@ -10,6 +10,10 @@ export type AgentPendingToolCall = { requestId: string; name: string; input?: { export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number }; export type AgentPanelTab = "chat" | "setup" | "history" | "log"; +const CONNECT_TIMEOUT_MS = 6000; +let agentSource: EventSource | null = null; +let connectTimer: ReturnType | null = null; + type CanvasAgentStore = { width: number; url: string; @@ -31,13 +35,15 @@ type CanvasAgentStore = { activity: string; connectError: string; pendingTool: AgentPendingToolCall | null; - setAgentState: (patch: Partial>) => void; + setAgentState: (patch: Partial>) => void; + connectAgent: () => void; + disconnectAgent: (patch?: Partial>) => void; addMessage: (item: AgentChatItem) => void; addEventLog: (item: AgentEventLog) => void; clearEventLogs: () => void; }; -export const useCanvasAgentStore = create((set) => ({ +export const useCanvasAgentStore = create((set, get) => ({ width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440, url: typeof window === "undefined" ? "http://127.0.0.1:17371" : localStorage.getItem("canvas-agent-url") || "http://127.0.0.1:17371", token: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-token") || "", @@ -59,6 +65,42 @@ export const useCanvasAgentStore = create((set) => ({ connectError: "", pendingTool: null, setAgentState: (patch) => set(patch), + connectAgent: () => { + const endpoint = get().url.trim().replace(/\/$/, ""); + const token = get().token.trim(); + if (!endpoint || !token) return set({ connectError: "请填写 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 格式不正确" }); + } + get().disconnectAgent({ url: endpoint, token, enabled: true, activity: "连接中", connectError: "" }); + localStorage.setItem("canvas-agent-url", endpoint); + localStorage.setItem("canvas-agent-token", token); + const clientId = typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID(); + const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}&role=status`); + agentSource = source; + connectTimer = setTimeout(() => { + if (agentSource !== source) return; + get().disconnectAgent({ activity: "连接超时", connectError: "连接超时:请确认 Canvas Agent 正在运行,并使用当前启动输出的 Connect token" }); + }, CONNECT_TIMEOUT_MS); + source.addEventListener("hello", () => { + if (connectTimer) clearTimeout(connectTimer); + connectTimer = null; + set({ enabled: true, connected: true, activity: "已连接", connectError: "" }); + }); + source.onerror = () => { + if (agentSource === source) get().disconnectAgent({ activity: "连接失败", connectError: "连接失败:请检查 Local URL、Connect token 或已绑定的网页 Origin" }); + }; + }, + disconnectAgent: (patch = {}) => { + agentSource?.close(); + agentSource = null; + if (connectTimer) clearTimeout(connectTimer); + connectTimer = null; + set({ enabled: false, connected: false, activity: "离线", ...patch }); + }, addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })), addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })), clearEventLogs: () => set({ eventLogs: [] }), diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 29e691a..c93c3f1 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -52,6 +52,7 @@ export type WebdavSyncConfig = { directory: string; lastSyncedAt: string; }; +export type ConfigTabKey = "channels" | "models" | "preferences" | "webdav" | "codex"; export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; export type ModelCapability = "image" | "video" | "text" | "audio"; @@ -111,11 +112,12 @@ type ConfigStore = { config: AiConfig; webdav: WebdavSyncConfig; isConfigOpen: boolean; + configTab: ConfigTabKey; shouldPromptContinue: boolean; updateConfig: (key: K, value: AiConfig[K]) => void; updateWebdavConfig: (key: K, value: WebdavSyncConfig[K]) => void; isAiConfigReady: (config: AiConfig, model: string) => boolean; - openConfigDialog: (shouldPromptContinue?: boolean) => void; + openConfigDialog: (shouldPromptContinue?: boolean, tab?: ConfigTabKey) => void; setConfigDialogOpen: (isOpen: boolean) => void; clearPromptContinue: () => void; }; @@ -171,6 +173,7 @@ export const useConfigStore = create()( config: defaultConfig, webdav: defaultWebdavSyncConfig, isConfigOpen: false, + configTab: "channels", shouldPromptContinue: false, updateConfig: (key, value) => set((state) => ({ @@ -187,7 +190,7 @@ export const useConfigStore = create()( }, })), isAiConfigReady: (config, model) => isAiConfigReady(config, model), - openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }), + openConfigDialog: (shouldPromptContinue = false, configTab = "channels") => set({ isConfigOpen: true, shouldPromptContinue, configTab }), setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }), clearPromptContinue: () => set({ shouldPromptContinue: false }), }),