feat(agent): enhance local Agent connection handling with silent mode and update UI instructions

This commit is contained in:
HouYunFei
2026-07-14 17:08:20 +08:00
parent 7e0288d461
commit a5824f509e
8 changed files with 71 additions and 193 deletions
@@ -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<typeof setAgentState>[0] = {}) {
@@ -630,6 +637,26 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
copyToClipboard(command);
message.success("命令已复制");
};
const codexPluginReminder = (
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}>Codex </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<div className="mt-2 grid gap-1.5">
{[
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
))}
</div>
</div>
);
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
@@ -640,43 +667,27 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</div>
</div>
<div className="space-y-2">
{AGENT_CONNECT_STEPS.map((step) => {
{AGENT_CONNECT_STEPS.map((step, index) => {
const command = "command" in step ? step.command : "";
return (
<div key={step.title} className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
<Fragment key={step.title}>
<div className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
{index === 0 ? codexPluginReminder : null}
</Fragment>
);
})}
</div>
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}>Codex </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<div className="mt-2 grid gap-1.5">
{[
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
+2 -88
View File
@@ -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<AppSyncDomainKey, string> = {
"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<AppSyncDomainKey, WebdavDomainProgress> {
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 (
<>
<Tabs
@@ -423,67 +398,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Form>
),
},
{
key: "codex",
label: "Codex",
children: (
<Form layout="vertical" requiredMark={false}>
<section className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4" />
Codex
</div>
<div className="mt-1 text-xs text-stone-500"> Agent Codex Canvas Agent</div>
</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-2">
{codexSetupSteps.map((step, index) => (
<div key={step.title} className="rounded-md border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs font-semibold text-stone-500"> {index + 1}</div>
<div className="mt-1 text-sm font-medium">{step.title}</div>
<div className="mt-1 text-xs leading-5 text-stone-500">{step.text}</div>
{step.command ? <code className="mt-2 block overflow-x-auto rounded bg-stone-100 px-2 py-1.5 text-[11px] text-stone-700 dark:bg-stone-900 dark:text-stone-200">{step.command}</code> : null}
</div>
))}
</div>
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200">
<div className="font-semibold">Codex </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<code className="mt-2 block overflow-x-auto rounded bg-white/70 px-2 py-1.5 text-[11px] text-amber-900 dark:bg-black/20 dark:text-amber-100">{codexPluginRemoveCommand}</code>
<code className="mt-1 block overflow-x-auto rounded bg-white/70 px-2 py-1.5 text-[11px] text-amber-900 dark:bg-black/20 dark:text-amber-100"> MCP{codexMcpRemoveCommand}</code>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Local URL" className="mb-4">
<Input prefix={<Link2 className="mr-1 size-4 text-stone-400" />} value={agentUrl} placeholder="http://127.0.0.1:17371" onChange={(event) => updateAgentConfig({ url: event.target.value })} />
</Form.Item>
<Form.Item label="Connect token" className="mb-4">
<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" />
<div>
<div className="text-sm font-medium"></div>
<div className="mt-0.5 text-xs text-stone-500"> Codex </div>
</div>
</div>
<Switch checked={agentConfirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
</div>
</section>
</Form>
),
},
]}
/>
{showDoneButton ? (
+1 -21
View File
@@ -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() {
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<CodexStatusButton />
<Tooltip title={panelOpen ? "收起 Agent" : "打开 Agent"}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 Agent" />
</Tooltip>
@@ -97,21 +95,3 @@ export function AppTopNav() {
</>
);
}
function CodexStatusButton() {
const connected = useAgentStore((state) => state.connected);
const enabled = useAgentStore((state) => state.enabled);
const activity = useAgentStore((state) => state.activity);
const connectError = useAgentStore((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>
);
}