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
View File
@@ -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">
+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>
);
}
+1 -1
View File
@@ -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>
+9 -6
View File
@@ -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] })),
+1 -1
View File
@@ -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";
-31
View File
@@ -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");