mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat: enhance Codex agent connection handling with automatic thread recovery and improved error logging
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user