mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(agent): enhance local Agent connection handling with silent mode and update UI instructions
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ConfigPage() {
|
||||
<div className="mx-auto max-w-6xl px-6 py-6">
|
||||
<div className="mb-5">
|
||||
<h1 className="text-xl font-semibold text-stone-950 dark:text-stone-100">配置与用户偏好</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">渠道聚合、模型选择、同步偏好和 Codex 连接配置</p>
|
||||
<p className="mt-1 text-sm text-stone-500">渠道聚合、模型选择和同步偏好</p>
|
||||
</div>
|
||||
<AppConfigPanel />
|
||||
</div>
|
||||
|
||||
@@ -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<Omit<AgentStore, "setAgentState" | "connectAgent" | "disconnectAgent" | "addMessage" | "addEventLog" | "clearEventLogs" | "openPanel" | "closePanel" | "togglePanel" | "setCanvasContext">>) => void;
|
||||
addMessage: (item: AgentChatItem) => void;
|
||||
addEventLog: (item: AgentEventLog) => void;
|
||||
@@ -64,6 +65,7 @@ export const useAgentStore = create<AgentStore>((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<AgentStore>((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] })),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isImeComposing, isPlainEnterKey } from "../src/lib/keyboard-event";
|
||||
|
||||
type KeyboardEventLike = Parameters<typeof isPlainEnterKey>[0];
|
||||
|
||||
function enterEvent(overrides: Partial<KeyboardEventLike> = {}): 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");
|
||||
Reference in New Issue
Block a user