feat: enhance Codex agent connection handling with automatic thread recovery and improved error logging

This commit is contained in:
HouYunFei
2026-07-05 23:40:54 +08:00
parent 6113986637
commit 3080d6da10
9 changed files with 154 additions and 37 deletions
@@ -64,14 +64,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadsResponse>(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<AgentThreadResponse>(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;
+21 -8
View File
@@ -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<AppSyncDomainKey, string> = {
};
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<AppSyncDomainKey, WebdavDomainProg
);
}
export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: boolean }) {
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState("channels");
const [activeTab, setActiveTab] = useState<ConfigTabKey>(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<keyof AiConfig>).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 (
<>
<Tabs
@@ -430,7 +436,7 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo
</div>
<div className="mt-1 text-xs text-stone-500"> Agent Codex Canvas Agent</div>
</div>
<div className="text-xs text-stone-500">{agentConnected ? agentActivity || "已连接" : agentEnabled ? "连接中" : "未连接"}</div>
<div className={agentConnectError ? "text-xs text-red-600" : "text-xs text-stone-500"}>{agentConnectError ? "连接失败" : agentConnected ? agentActivity || "已连接" : agentEnabled ? "连接中" : "未连接"}</div>
</div>
<div className="mb-4 grid gap-2 md:grid-cols-3">
{codexSetupSteps.map((step, index) => (
@@ -450,6 +456,12 @@ export function AppConfigPanel({ showDoneButton = false }: { showDoneButton?: bo
<Input.Password prefix={<KeyRound className="mr-1 size-4 text-stone-400" />} value={agentToken} placeholder="自动发现,或手动填入 Connect token" onChange={(event) => updateAgentConfig({ token: event.target.value })} />
</Form.Item>
</div>
{agentConnectError ? <div className="mb-3 rounded-md border border-red-200 px-3 py-2 text-xs text-red-600 dark:border-red-900/60">{agentConnectError}</div> : null}
<div className="mb-3 flex justify-end">
<Button type={agentEnabled ? "default" : "primary"} icon={<Wifi className="size-4" />} onClick={toggleAgentConnection}>
{agentConnected ? "断开" : agentEnabled ? "取消连接" : "连接"}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="flex min-w-0 items-center gap-2">
<ShieldCheck className="size-4 text-stone-500" />
@@ -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 (
<Modal
@@ -495,7 +508,7 @@ export function AppConfigModal() {
styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 12 } }}
footer={null}
>
<AppConfigPanel showDoneButton />
<AppConfigPanel showDoneButton initialTab={configTab} />
</Modal>
);
}
+34 -1
View File
@@ -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() {
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<CodexStatusButton />
<UserStatusActions />
</div>
</div>
@@ -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 (
<Tooltip title={title}>
<Button type="text" shape="circle" className="relative !h-8 !w-8 !min-w-8" onClick={() => openConfigDialog(false, "codex")} aria-label="Codex 连接状态">
<span className="mx-auto block size-4" style={{ background: color, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />
<span className="absolute right-1 top-1 size-2 rounded-full border border-background" style={{ background: color }} />
</Button>
</Tooltip>
);
}