mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(canvas-local-agent-panel): optimize state management and rendering performance
This commit is contained in:
@@ -8,7 +8,8 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
|||||||
import { randomId } from "@/lib/utils";
|
import { randomId } from "@/lib/utils";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||||
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||||
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
|
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
|
||||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||||
@@ -46,9 +47,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
const { message, modal } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore();
|
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
|
||||||
|
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||||
|
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||||
|
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||||
|
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
width: state.width,
|
||||||
|
url: state.url,
|
||||||
|
token: state.token,
|
||||||
|
connected: state.connected,
|
||||||
|
enabled: state.enabled,
|
||||||
|
prompt: state.prompt,
|
||||||
|
attachments: state.attachments,
|
||||||
|
sending: state.sending,
|
||||||
|
waiting: state.waiting,
|
||||||
|
messages: state.messages,
|
||||||
|
eventLogs: state.eventLogs,
|
||||||
|
threads: state.threads,
|
||||||
|
activeThreadId: state.activeThreadId,
|
||||||
|
workspacePath: state.workspacePath,
|
||||||
|
loadingThreads: state.loadingThreads,
|
||||||
|
activeTab: state.activeTab,
|
||||||
|
confirmTools: state.confirmTools,
|
||||||
|
activity: state.activity,
|
||||||
|
connectError: state.connectError,
|
||||||
|
pendingTool: state.pendingTool,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||||
|
const pushMessage = useAgentStore((state) => state.addMessage);
|
||||||
|
const pushEventLog = useAgentStore((state) => state.addEventLog);
|
||||||
|
const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasContextRef = useRef(canvasContext);
|
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
|
||||||
const confirmToolsRef = useRef(confirmTools);
|
const confirmToolsRef = useRef(confirmTools);
|
||||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||||
const autoConnectRef = useRef(false);
|
const autoConnectRef = useRef(false);
|
||||||
@@ -81,9 +113,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
}
|
}
|
||||||
}, [endpoint, setAgentState, token]);
|
}, [endpoint, setAgentState, token]);
|
||||||
|
|
||||||
|
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
canvasContextRef.current = canvasContext;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
}, [canvasContext]);
|
const unsubscribe = useAgentStore.subscribe((state) => {
|
||||||
|
if (state.canvasContext === canvasContextRef.current) return;
|
||||||
|
canvasContextRef.current = state.canvasContext;
|
||||||
|
if (!useAgentStore.getState().connected) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [endpoint, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
confirmToolsRef.current = confirmTools;
|
confirmToolsRef.current = confirmTools;
|
||||||
}, [confirmTools]);
|
}, [confirmTools]);
|
||||||
@@ -156,12 +200,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
if (connected) void loadThreads();
|
if (connected) void loadThreads();
|
||||||
}, [connected, loadThreads]);
|
}, [connected, loadThreads]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connected) return;
|
|
||||||
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [canvasContext?.snapshot, connected, endpoint, token]);
|
|
||||||
|
|
||||||
const sendPrompt = async () => {
|
const sendPrompt = async () => {
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
const files = attachments;
|
const files = attachments;
|
||||||
@@ -175,7 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
|
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
|
||||||
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
|
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
|
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }),
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
|
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
|
||||||
const data = (await res.json()) as { threadId?: string };
|
const data = (await res.json()) as { threadId?: string };
|
||||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||||
@@ -211,12 +253,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||||
const prev = useAgentStore.getState().attachments;
|
const prev = useAgentStore.getState().attachments;
|
||||||
try {
|
try {
|
||||||
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
const next = await Promise.all(
|
||||||
const dataUrl = await readDataUrl(file);
|
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||||
const url = URL.createObjectURL(file);
|
const dataUrl = await readDataUrl(file);
|
||||||
attachmentUrlsRef.current.add(url);
|
const url = URL.createObjectURL(file);
|
||||||
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
|
attachmentUrlsRef.current.add(url);
|
||||||
}));
|
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
|
||||||
|
}),
|
||||||
|
);
|
||||||
const merged = [...prev, ...next];
|
const merged = [...prev, ...next];
|
||||||
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||||
next.forEach((item) => {
|
next.forEach((item) => {
|
||||||
@@ -295,7 +339,12 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||||
setAgentState({ activity: "工具完成", waiting: true });
|
setAgentState({ activity: "工具完成", waiting: true });
|
||||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||||
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
|
addMessage({
|
||||||
|
role: "tool",
|
||||||
|
title: `${toolName(payload.name)}完成`,
|
||||||
|
text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成",
|
||||||
|
detail: { requestId: payload.requestId, name: payload.name, input, result },
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||||
setAgentState({ activity: "工具失败", waiting: false });
|
setAgentState({ activity: "工具失败", waiting: false });
|
||||||
@@ -457,7 +506,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
if (next.streamId) {
|
if (next.streamId) {
|
||||||
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
setAgentState({ messages: currentMessages.map((message, i) => i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message) });
|
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,7 +603,15 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
{messages.map((item) => (
|
{messages.map((item) => (
|
||||||
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
|
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
|
||||||
))}
|
))}
|
||||||
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
|
{pendingTool ? (
|
||||||
|
<AgentPendingToolCard
|
||||||
|
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||||
|
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
|
||||||
|
theme={theme}
|
||||||
|
onReject={rejectPendingTool}
|
||||||
|
onApprove={approvePendingTool}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<AgentChatComposer
|
<AgentChatComposer
|
||||||
@@ -569,7 +626,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
onStop={stopTurn}
|
onStop={stopTurn}
|
||||||
onAddFiles={addAttachments}
|
onAddFiles={addAttachments}
|
||||||
onRemoveAttachment={removeAttachment}
|
onRemoveAttachment={removeAttachment}
|
||||||
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
|
left={
|
||||||
|
attachments.length ? (
|
||||||
|
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||||
|
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -580,7 +643,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
|||||||
return embedded ? content : null;
|
return embedded ? content : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
|
function AgentLogView({
|
||||||
|
logs,
|
||||||
|
theme,
|
||||||
|
context,
|
||||||
|
onClear,
|
||||||
|
onCopied,
|
||||||
|
onCopyBlocked,
|
||||||
|
}: {
|
||||||
|
logs: AgentEventLog[];
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
context: AgentLogContext;
|
||||||
|
onClear: () => void;
|
||||||
|
onCopied: (text: string) => void;
|
||||||
|
onCopyBlocked: (text: string) => void;
|
||||||
|
}) {
|
||||||
const [mode, setMode] = useState<"text" | "json">("text");
|
const [mode, setMode] = useState<"text" | "json">("text");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||||
@@ -601,12 +678,28 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
|
|||||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
value={mode}
|
||||||
|
onChange={(value) => setMode(value as "text" | "json")}
|
||||||
|
options={[
|
||||||
|
{ label: "排查日志", value: "text" },
|
||||||
|
{ label: "原始 JSON", value: "json" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
|
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>复制</Button>
|
{logs.length} 条
|
||||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>最近错误</Button>
|
</span>
|
||||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
|
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
|
||||||
|
最近错误
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -622,7 +715,29 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentConnectView({ theme, url, token, enabled, connected, activity, connectError, onUrlChange, onTokenChange, onToggleEnabled }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; url: string; token: string; enabled: boolean; connected: boolean; activity: string; connectError: string; onUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onToggleEnabled: () => void }) {
|
function AgentConnectView({
|
||||||
|
theme,
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
enabled,
|
||||||
|
connected,
|
||||||
|
activity,
|
||||||
|
connectError,
|
||||||
|
onUrlChange,
|
||||||
|
onTokenChange,
|
||||||
|
onToggleEnabled,
|
||||||
|
}: {
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
activity: string;
|
||||||
|
connectError: string;
|
||||||
|
onUrlChange: (value: string) => void;
|
||||||
|
onTokenChange: (value: string) => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
}) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||||
@@ -632,7 +747,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
};
|
};
|
||||||
const codexPluginReminder = (
|
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="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="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-1">只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。</div>
|
||||||
<div className="mt-2 grid gap-1.5">
|
<div className="mt-2 grid gap-1.5">
|
||||||
{[
|
{[
|
||||||
@@ -640,7 +757,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
|
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
|
||||||
].map(([label, 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 }}>
|
<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>
|
<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>
|
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||||
<Tooltip title="复制命令">
|
<Tooltip title="复制命令">
|
||||||
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
||||||
@@ -666,7 +785,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
<Fragment key={step.title}>
|
<Fragment key={step.title}>
|
||||||
<div className="rounded-lg px-3 py-2.5">
|
<div className="rounded-lg px-3 py-2.5">
|
||||||
<div className="text-sm font-medium leading-5">{step.title}</div>
|
<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>
|
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||||
|
{step.text}
|
||||||
|
</div>
|
||||||
{command ? (
|
{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 }}>
|
<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>
|
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||||
@@ -686,7 +807,10 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}>
|
<span
|
||||||
|
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4"
|
||||||
|
style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}
|
||||||
|
>
|
||||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||||
<span className="truncate">{statusText}</span>
|
<span className="truncate">{statusText}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -714,7 +838,13 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
连接 Token
|
连接 Token
|
||||||
<span className="font-normal opacity-70">Connect token</span>
|
<span className="font-normal opacity-70">Connect token</span>
|
||||||
</span>
|
</span>
|
||||||
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="自动发现,或手动填入 Connect token" />
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => onTokenChange(event.target.value)}
|
||||||
|
placeholder="自动发现,或手动填入 Connect token"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{connectError ? (
|
{connectError ? (
|
||||||
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
||||||
@@ -728,14 +858,38 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loading, connected, onRefresh, onNewThread, onResumeThread, onDeleteThread }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; threads: AgentThreadSummary[]; activeThreadId: string; workspacePath: string; loading: boolean; connected: boolean; onRefresh: () => void; onNewThread: () => void; onResumeThread: (threadId: string) => void; onDeleteThread: (thread: AgentThreadSummary) => void }) {
|
function AgentHistoryView({
|
||||||
|
theme,
|
||||||
|
threads,
|
||||||
|
activeThreadId,
|
||||||
|
workspacePath,
|
||||||
|
loading,
|
||||||
|
connected,
|
||||||
|
onRefresh,
|
||||||
|
onNewThread,
|
||||||
|
onResumeThread,
|
||||||
|
onDeleteThread,
|
||||||
|
}: {
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
threads: AgentThreadSummary[];
|
||||||
|
activeThreadId: string;
|
||||||
|
workspacePath: string;
|
||||||
|
loading: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onNewThread: () => void;
|
||||||
|
onResumeThread: (threadId: string) => void;
|
||||||
|
onDeleteThread: (thread: AgentThreadSummary) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||||
<FolderOpen className="size-3.5 shrink-0" />
|
<FolderOpen className="size-3.5 shrink-0" />
|
||||||
<span className="shrink-0">工作空间</span>
|
<span className="shrink-0">工作空间</span>
|
||||||
<span className="min-w-0 truncate" title={workspacePath}>{workspacePath || "默认画布目录"}</span>
|
<span className="min-w-0 truncate" title={workspacePath}>
|
||||||
|
{workspacePath || "默认画布目录"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-sm" style={{ color: theme.node.muted }}>
|
<div className="text-sm" style={{ color: theme.node.muted }}>
|
||||||
@@ -758,7 +912,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
{active ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
{active ? (
|
||||||
|
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
|
||||||
|
当前
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||||
@@ -789,7 +947,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
|||||||
|
|
||||||
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
|
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }),
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,10 +997,12 @@ function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
|||||||
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
|
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
|
||||||
`logs: ${logs.length}`,
|
`logs: ${logs.length}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const body = logs.map((item, index) => {
|
const body = logs
|
||||||
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
|
.map((item, index) => {
|
||||||
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
|
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
|
||||||
}).join("\n\n---\n\n");
|
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
|
||||||
|
})
|
||||||
|
.join("\n\n---\n\n");
|
||||||
return [head, body || "暂无事件日志"].join("\n\n");
|
return [head, body || "暂无事件日志"].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,7 +1122,12 @@ function toolSummary(item?: AgentEventItem) {
|
|||||||
|
|
||||||
function parseToolResult(result: unknown) {
|
function parseToolResult(result: unknown) {
|
||||||
const content = objectField(result, "content");
|
const content = objectField(result, "content");
|
||||||
const text = Array.isArray(content) ? content.map((item) => objectField(item, "text")).filter((item): item is string => typeof item === "string").join("\n") : "";
|
const text = Array.isArray(content)
|
||||||
|
? content
|
||||||
|
.map((item) => objectField(item, "text"))
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.join("\n")
|
||||||
|
: "";
|
||||||
try {
|
try {
|
||||||
return text ? JSON.parse(text) : result;
|
return text ? JSON.parse(text) : result;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user