mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 04:44:28 +08:00
feat(agent): update agent connection behavior to start with a blank conversation and allow user-initiated session restoration
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
|
||||
+ [修复] Agent 在模型繁忙等任务失败时会立即结束等待状态,并在对话和历史中展示中文错误原因与重试建议。
|
||||
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||
+ [调整] 进入画布并连接 Agent 时默认开启空白新对话,不再自动加载上一次会话,历史对话改为由用户主动选择恢复。
|
||||
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
|
||||
+ [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
||||
- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。
|
||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
|
||||
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
|
||||
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
|
||||
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||
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 }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function activateAgentClient(endpoint: string, token: string, clientId: string) {
|
||||
try {
|
||||
await fetch(`${endpoint}/canvas/activate?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
|
||||
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
|
||||
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function discoverAgentConfig(endpoint: string) {
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/config`);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as AgentConfigResponse;
|
||||
return data.ok ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, ImagePlus, LoaderCircle, Square, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
disabled,
|
||||
sending,
|
||||
placeholder,
|
||||
theme,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
left,
|
||||
}: {
|
||||
prompt: string;
|
||||
attachments?: AgentChatAttachment[];
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
placeholder: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
return (
|
||||
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
{onRemoveAttachment ? (
|
||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
void onAddFiles(images);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isPlainEnterKey(event)) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{onAddFiles ? (
|
||||
<>
|
||||
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
|
||||
void onAddFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{sending && onStop ? (
|
||||
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
|
||||
) : (
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-120
@@ -1,26 +1,25 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, Brain, CheckCircle2, ChevronDown, Circle, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button } from "antd";
|
||||
import { Brain, CheckCircle2, ChevronDown, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, TerminalSquare, UserRound, Wrench, XCircle } from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type CanvasAgentChatMessage = {
|
||||
export type AgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type AgentChatMessageItem = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system" | "tool" | "error";
|
||||
title?: string;
|
||||
text: string;
|
||||
meta?: string;
|
||||
detail?: unknown;
|
||||
attachments?: CanvasAgentChatAttachment[];
|
||||
attachments?: AgentChatAttachment[];
|
||||
/** Present while the message is actively streaming; cleared on completion. */
|
||||
streamId?: string;
|
||||
};
|
||||
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: AgentChatMessageItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||
@@ -184,117 +183,6 @@ function waitingTime(seconds: number) {
|
||||
return `已等待 ${minutes} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
disabled,
|
||||
sending,
|
||||
placeholder,
|
||||
theme,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
left,
|
||||
}: {
|
||||
prompt: string;
|
||||
attachments?: CanvasAgentChatAttachment[];
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
placeholder: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
left?: ReactNode;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
return (
|
||||
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
{onRemoveAttachment ? (
|
||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
if (!onAddFiles) return;
|
||||
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
void onAddFiles(images);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isPlainEnterKey(event)) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{onAddFiles ? (
|
||||
<>
|
||||
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
|
||||
void onAddFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{sending && onStop ? (
|
||||
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
|
||||
) : (
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
|
||||
return (
|
||||
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
|
||||
{items.map((item) => (
|
||||
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
|
||||
{item.icon}
|
||||
{item.label}{item.count ? ` ${item.count}` : ""}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PlanTask = { step: string; status: string };
|
||||
type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string };
|
||||
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
@@ -351,7 +239,7 @@ function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (type
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) {
|
||||
function AgentMessageAttachments({ attachments }: { attachments: AgentChatAttachment[] }) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{attachments.map((item) => (
|
||||
@@ -0,0 +1,131 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { useAgentStore, type AgentChatItem, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
|
||||
import { AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
||||
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
|
||||
export function AgentChatTimeline({
|
||||
theme,
|
||||
pendingTool,
|
||||
sending,
|
||||
waiting,
|
||||
onRejectTool,
|
||||
onApproveTool,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
sending: boolean;
|
||||
waiting: boolean;
|
||||
onRejectTool: () => void;
|
||||
onApproveTool: () => void;
|
||||
}) {
|
||||
const messages = useAgentStore((state) => state.messages);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||
followMessagesRef.current = atBottom;
|
||||
setShowScrollToBottom(!atBottom);
|
||||
}, []);
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
followMessagesRef.current = true;
|
||||
list.scrollTo({ top: list.scrollHeight, behavior });
|
||||
setShowScrollToBottom(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} className="thin-scrollbar h-full space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
||||
{messages.map((item) => (
|
||||
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
|
||||
))}
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||
theme={theme}
|
||||
onReject={onRejectTool}
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Tooltip title="滚动到底部" placement="left">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
aria-label="滚动到底部"
|
||||
className="!absolute bottom-3 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5"
|
||||
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||
icon={<ChevronDown className="size-4" />}
|
||||
onClick={() => scrollToBottom()}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; busy: boolean }) {
|
||||
const plan = useAgentStore((state) => busy ? currentPlanMessage(state.messages) : latestPlanMessage(state.messages));
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<div className="shrink-0 px-4 pt-2">
|
||||
<AgentToolCard key={plan.id} title={plan.title || "任务进度"} text={plan.text} detail={plan.detail} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null }) {
|
||||
return (
|
||||
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
|
||||
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} user={user} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
|
||||
<span className="opacity-70">最新调用</span>
|
||||
<UsageNumber label="输入" value={usage.input} color={theme.node.text} />
|
||||
<UsageNumber label="缓存" value={usage.cached} color={theme.node.text} />
|
||||
<UsageNumber label="输出" value={usage.output} color={theme.node.text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageNumber({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const spring = useSpring(value, { stiffness: 110, damping: 24, mass: 0.7 });
|
||||
const text = useTransform(spring, (current) => Math.round(current).toLocaleString());
|
||||
useEffect(() => spring.set(value), [spring, value]);
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1" aria-label={`${label} ${value.toLocaleString()}`}>
|
||||
<span>{label}</span>
|
||||
<motion.span aria-hidden className="font-medium" style={{ color }}>
|
||||
{text}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Fragment } from "react";
|
||||
import { App, Button, Input, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, KeyRound, Link2, PlugZap } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
const AGENT_CONNECT_STEPS = [
|
||||
{ title: "方式一:在 Codex 中使用插件", text: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。" },
|
||||
{ title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" },
|
||||
];
|
||||
const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas";
|
||||
const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
|
||||
|
||||
export 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 statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
const copyCommand = (command: string) => {
|
||||
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">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
按使用场景选择一种连接方式。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{AGENT_CONNECT_STEPS.map((step, index) => {
|
||||
const command = "command" in step ? step.command : "";
|
||||
return (
|
||||
<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 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">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
默认自动读取 Local URL 和 Connect token,失败时再手动填写。
|
||||
</div>
|
||||
</div>
|
||||
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
|
||||
{enabled ? "断开" : "连接"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<Link2 className="size-3.5" />
|
||||
本地地址
|
||||
<span className="font-normal opacity-70">Local URL</span>
|
||||
</span>
|
||||
<Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="例如 http://127.0.0.1:17371" />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
|
||||
<KeyRound className="size-3.5" />
|
||||
连接 Token
|
||||
<span className="font-normal opacity-70">Connect token</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"
|
||||
/>
|
||||
</label>
|
||||
{connectError ? (
|
||||
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
||||
{connectError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { isSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||
|
||||
export type AgentEventPayload = {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
threadId?: string;
|
||||
thread_id?: string;
|
||||
turn_id?: string;
|
||||
item?: AgentEventItem;
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
status?: string;
|
||||
explanation?: unknown;
|
||||
plan?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
duration_ms?: number;
|
||||
};
|
||||
export type AgentEventItem = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
text?: unknown;
|
||||
delta?: unknown;
|
||||
message?: unknown;
|
||||
server?: string;
|
||||
tool?: string;
|
||||
status?: string;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
aggregatedOutput?: unknown;
|
||||
exitCode?: unknown;
|
||||
durationMs?: unknown;
|
||||
changes?: unknown;
|
||||
summary?: unknown;
|
||||
query?: unknown;
|
||||
action?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
export type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }>; tasks?: Array<{ step: string; status: string }>; explanation?: string };
|
||||
|
||||
export type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
|
||||
|
||||
export function agentMessageToChatMessage(item: AgentChatItem) {
|
||||
return { ...item, meta: item.role === "user" || item.role === "assistant" ? undefined : item.meta, attachments: item.attachments?.map(agentAttachmentToChatAttachment) };
|
||||
}
|
||||
|
||||
export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentChatAttachment {
|
||||
return { id: item.id, name: item.name, url: item.dataUrl || item.url };
|
||||
}
|
||||
|
||||
export function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const item = event.item;
|
||||
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
|
||||
if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const item = event.item;
|
||||
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
|
||||
const completed = event.type === "item.completed";
|
||||
const status = String(item.status || (completed ? "completed" : "inProgress"));
|
||||
if (item.type === "reasoning") {
|
||||
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
|
||||
}
|
||||
if (item.type === "plan") {
|
||||
const text = stringText(item.text) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
|
||||
}
|
||||
if (item.type === "command_execution") {
|
||||
const command = stringText(item.command) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
|
||||
}
|
||||
if (item.type === "file_change") {
|
||||
const files = activityFiles(item.changes);
|
||||
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
|
||||
}
|
||||
if (item.type === "web_search") {
|
||||
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
|
||||
}
|
||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
|
||||
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…", detail: { kind: "context", status } };
|
||||
if (isMcpToolItem(item) && isReadTool(String(item.tool || ""))) {
|
||||
const name = String(item.tool || "");
|
||||
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}…`, detail: toolDetail(item, item.error ? "failed" : status) };
|
||||
}
|
||||
if (item.type === "dynamic_tool_call") return { role: "tool", title: "使用工具", text: completed ? "已完成工具操作" : "正在执行工具操作…", detail: { kind: "tool", status } };
|
||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatAgentPlan(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const tasks = planTasks(event.plan);
|
||||
if (!tasks.length) return null;
|
||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||
return {
|
||||
role: "tool",
|
||||
title: "任务进度",
|
||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||
detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) },
|
||||
};
|
||||
}
|
||||
|
||||
function planTasks(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((item) => {
|
||||
const step = stringText(objectField(item, "step")).trim();
|
||||
return step ? [{ step, status: stringText(objectField(item, "status")) || "pending" }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function turnPlanStatus(detail: unknown, turnStatus?: string) {
|
||||
const tasks = planTasks(objectField(detail, "tasks"));
|
||||
if (turnStatus === "failed") return "failed";
|
||||
if (turnStatus === "interrupted") return "interrupted";
|
||||
if (tasks.length && tasks.every((item) => item.status === "completed")) return "completed";
|
||||
return turnStatus === "completed" ? "finished" : "inProgress";
|
||||
}
|
||||
|
||||
export function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
|
||||
if (item.type === "command_execution") return { id: item.id || randomId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
|
||||
return { id: item.id || randomId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
|
||||
}
|
||||
|
||||
export function activityPlaceholder(type?: string) {
|
||||
if (type === "plan") return "正在整理执行步骤…";
|
||||
if (type === "command_execution") return "正在执行命令…";
|
||||
return "正在分析任务…";
|
||||
}
|
||||
|
||||
export function activityKind(type?: string) {
|
||||
if (type === "command_execution") return "command";
|
||||
if (type === "plan") return "plan";
|
||||
return "reasoning";
|
||||
}
|
||||
|
||||
export function activityDetail(value: unknown, kind: string, status: string): AgentUserDetail {
|
||||
const current = value && typeof value === "object" ? (value as Partial<AgentUserDetail>) : {};
|
||||
return { kind, status, rows: current.rows, output: current.output, files: current.files, tasks: current.tasks, explanation: current.explanation };
|
||||
}
|
||||
|
||||
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
||||
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
|
||||
return { kind: "command", status, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
|
||||
}
|
||||
|
||||
function activityFiles(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((change) => {
|
||||
const path = stringText(objectField(change, "path"));
|
||||
return path ? [{ path, action: changeAction(objectField(change, "kind")) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) {
|
||||
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…";
|
||||
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`;
|
||||
const names = files.slice(0, 3).map((file) => file.path).join("、");
|
||||
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`;
|
||||
}
|
||||
|
||||
function webSearchSummary(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
const type = stringText(objectField(action, "type"));
|
||||
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}”`;
|
||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||
}
|
||||
|
||||
function webSearchDetailRows(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
return [detailRow("关键词", item.query || objectField(action, "query")), detailRow("网页", objectField(action, "url"))].flatMap((row) => (row ? [row] : []));
|
||||
}
|
||||
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
return readableText(objectField(value, "text"));
|
||||
}
|
||||
|
||||
function detailRow(label: string, value: unknown) {
|
||||
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||
}
|
||||
|
||||
function durationDetailRow(value: unknown) {
|
||||
const duration = Number(value || 0);
|
||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||
}
|
||||
|
||||
function changeAction(value: unknown) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
export function parseEventData<T>(event: Event) {
|
||||
try {
|
||||
return JSON.parse((event as MessageEvent).data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentThreadEvent(event: { threadId?: string; thread_id?: string }) {
|
||||
const threadId = event.threadId || event.thread_id || "";
|
||||
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
|
||||
}
|
||||
|
||||
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
const head = [
|
||||
"Infinite Canvas Agent 诊断",
|
||||
`地址:${context.endpoint}`,
|
||||
`连接:${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"} · 状态:${context.activity}`,
|
||||
`消息:${context.messages} · 工具:${context.pendingTool ? toolName(context.pendingTool) : "无"}`,
|
||||
].join("\n");
|
||||
const body = logs.map((item) => `${item.time} ${item.title}${item.text && item.text !== item.title ? ` · ${item.text}` : ""}`).join("\n");
|
||||
return [head, body || "暂无事件日志"].join("\n\n");
|
||||
}
|
||||
|
||||
export function formatLogJson(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
return JSON.stringify({ context, logs: logs.map(({ time, title, text, raw }) => ({ time, title, text, raw })) }, null, 2);
|
||||
}
|
||||
|
||||
export function formatAgentEventLog(event: AgentEventPayload) {
|
||||
const item = event.item;
|
||||
if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) };
|
||||
if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) };
|
||||
if (event.type === "plan.updated") {
|
||||
const tasks = planTasks(event.plan);
|
||||
return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length} 项` };
|
||||
}
|
||||
if (event.type === "turn.completed" && event.status === "failed") return { title: "处理失败", text: agentErrorView(event.error?.message).text };
|
||||
if (event.type === "turn.completed") return { title: event.status === "interrupted" ? "处理已停止" : "处理完成", text: turnSummary(event) };
|
||||
if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: agentErrorView(event.message || event.error?.message).text };
|
||||
if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) };
|
||||
if (event.type === "item.completed" && isMcpToolItem(item)) return { title: item.error ? "工具失败" : "工具完成", text: `${toolName(String(item?.tool || ""))}${item.error?.message ? ` · ${item.error.message}` : ""}` };
|
||||
if (event.type === "item.completed" && item?.type === "agent_message") return { title: "收到回复", text: compactText(stringText(item.text)) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function turnSummary(event: AgentEventPayload) {
|
||||
return event.duration_ms ? `${(event.duration_ms / 1000).toFixed(1)} 秒` : "完成";
|
||||
}
|
||||
|
||||
export function agentErrorView(value: unknown) {
|
||||
const text = normalizeText(value);
|
||||
if (/selected model is at capacity/i.test(text)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" };
|
||||
return { title: "任务失败", text: text || "Codex 未能完成本次任务,请稍后重试。" };
|
||||
}
|
||||
|
||||
export function eventUsage(event: AgentEventPayload): AgentTokenUsage {
|
||||
return {
|
||||
input: numberField(event.usage, "input_tokens"),
|
||||
cached: numberField(event.usage, "cached_input_tokens"),
|
||||
output: numberField(event.usage, "output_tokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function shortId(value?: string) {
|
||||
return value ? value.slice(0, 8) : "";
|
||||
}
|
||||
|
||||
export function compactText(value: string, maxLength = 120) {
|
||||
const text = value.replace(/\s+/g, " ").trim();
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
}
|
||||
|
||||
export function isConnectionErrorMessage(item: AgentChatItem) {
|
||||
return item.role === "error" && /连接失败|无法连接本地 Agent|本地 Agent 连接失败/.test(item.text);
|
||||
}
|
||||
|
||||
export function toolName(name: string) {
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_node") return "创建节点";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_config_node") return "创建生成配置";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
if (name === "site_navigate") return "打开页面";
|
||||
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
|
||||
return "工具操作";
|
||||
}
|
||||
|
||||
export function siteToolSummary(name: string, result: unknown) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||
if (name === "assets_add") return "已加入我的资产";
|
||||
if (name === "generation_get_status") {
|
||||
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
|
||||
return `共 ${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
|
||||
}
|
||||
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
||||
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||
return "已完成";
|
||||
}
|
||||
|
||||
export function isReadTool(name: string) {
|
||||
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
|
||||
}
|
||||
|
||||
function isMcpToolItem(item?: AgentEventItem) {
|
||||
return item?.type === "mcp_tool_call";
|
||||
}
|
||||
|
||||
export function toolDetail(item: AgentEventItem | undefined, status: string): AgentUserDetail {
|
||||
const name = String(item?.tool || "");
|
||||
return { kind: "tool", status, rows: toolInputRows(name, item?.arguments), ...(item?.error?.message ? { output: item.error.message } : {}) };
|
||||
}
|
||||
|
||||
export function toolCallDetail(name: string, input: unknown, status: string, error = ""): AgentUserDetail {
|
||||
return { kind: "tool", status, rows: toolInputRows(name, input), ...(error ? { output: error } : {}) };
|
||||
}
|
||||
|
||||
function toolInputRows(name: string, input: unknown) {
|
||||
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "nodes")) ? (objectField(input, "nodes") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toolSummary(item?: AgentEventItem) {
|
||||
const result = parseToolResult(item?.result);
|
||||
const nodeField = objectField(result, "nodes");
|
||||
const connectionField = objectField(result, "connections");
|
||||
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
||||
const connections = Array.isArray(connectionField) ? connectionField : [];
|
||||
if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`;
|
||||
return "工具调用完成";
|
||||
}
|
||||
|
||||
export function toolAction(name: string) {
|
||||
const label = toolName(name);
|
||||
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label;
|
||||
return `执行${label}`;
|
||||
}
|
||||
|
||||
export function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
return path;
|
||||
}
|
||||
|
||||
export function workingActivity(item?: AgentChatItem) {
|
||||
const status = String(objectField(item?.detail, "status") || "");
|
||||
const output = stringText(objectField(item?.detail, "output"));
|
||||
const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`;
|
||||
if (item?.role !== "tool") return { key, text: "正在思考..." };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
|
||||
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
|
||||
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` };
|
||||
}
|
||||
|
||||
export function currentPlanMessage(messages: AgentChatItem[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (isPlanMessage(message)) return message;
|
||||
if (message.role === "user") return;
|
||||
}
|
||||
}
|
||||
|
||||
export function latestPlanMessage(messages: AgentChatItem[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (isPlanMessage(messages[index])) return messages[index];
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlanMessage(message: AgentChatItem) {
|
||||
return message.role === "tool" && objectField(message.detail, "kind") === "todo";
|
||||
}
|
||||
|
||||
function parseToolResult(result: unknown) {
|
||||
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")
|
||||
: "";
|
||||
try {
|
||||
return text ? JSON.parse(text) : result;
|
||||
} catch {
|
||||
return text || result;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value == null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function stringText(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function objectField(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
function numberField(value: unknown, key: string) {
|
||||
const field = objectField(value, key);
|
||||
return typeof field === "number" ? field : 0;
|
||||
}
|
||||
|
||||
export function mergeAgentText(prev: string, next: string) {
|
||||
if (!next || prev === next || prev.endsWith(next)) return prev;
|
||||
if (next.startsWith(prev)) return next;
|
||||
for (let size = Math.min(prev.length, next.length); size > 0; size--) {
|
||||
if (prev.endsWith(next.slice(0, size))) return `${prev}${next.slice(size)}`;
|
||||
}
|
||||
const half = Math.floor(prev.length / 2);
|
||||
if (prev.length > 12 && next.length > 12 && prev.slice(half) === next.slice(0, prev.length - half)) return prev;
|
||||
return `${prev}${next}`;
|
||||
}
|
||||
|
||||
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
|
||||
return text || (attachments.length ? "请处理上传的图片附件。" : "");
|
||||
}
|
||||
|
||||
export function attachmentPayloadBytes(attachments: AgentAttachment[]) {
|
||||
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
|
||||
}
|
||||
|
||||
export function isCanvasWriteTool(name: string) {
|
||||
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
|
||||
}
|
||||
|
||||
export function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
||||
return messages
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || `history-${index}`,
|
||||
text: normalizeText(item.text),
|
||||
streamId: undefined,
|
||||
}))
|
||||
.filter((item) => item.text);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Checkbox } from "antd";
|
||||
import { FolderOpen, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
|
||||
export function AgentHistoryView({
|
||||
theme,
|
||||
threads,
|
||||
activeThreadId,
|
||||
workspacePath,
|
||||
loading,
|
||||
busy,
|
||||
connected,
|
||||
onRefresh,
|
||||
onNewThread,
|
||||
onResumeThread,
|
||||
onDeleteThreads,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
workspacePath: string;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
connected: boolean;
|
||||
onRefresh: () => void;
|
||||
onNewThread: () => void;
|
||||
onResumeThread: (threadId: string) => void;
|
||||
onDeleteThreads: (threadIds: string[]) => void;
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
|
||||
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
|
||||
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
|
||||
const toggleThread = (threadId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(threadId) ? next.delete(threadId) : next.add(threadId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0">工作空间</span>
|
||||
<span className="min-w-0 truncate" title={workspacePath}>
|
||||
{workspacePath || "默认画布目录"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: theme.node.muted }}>
|
||||
{threads.length ? <Checkbox checked={allSelected} indeterminate={Boolean(selectedThreads.length) && !allSelected} disabled={loading || busy} onChange={() => setSelectedIds(allSelected ? new Set() : new Set(threads.map((thread) => thread.id)))} /> : null}
|
||||
<span>{selectedThreads.length ? `已选 ${selectedThreads.length} 条` : threads.length ? `${threads.length} 条历史` : connected ? "暂无历史" : "未连接"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedThreads.length ? (
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={loading || busy} onClick={() => onDeleteThreads(selectedThreads.map((thread) => thread.id))}>
|
||||
删除 {selectedThreads.length} 条
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} disabled={!connected || loading} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} disabled={!connected || loading || busy} onClick={onNewThread}>
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{threads.map((thread) => {
|
||||
const active = thread.id === activeThreadId;
|
||||
return (
|
||||
<div
|
||||
key={thread.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer rounded-lg border px-2.5 py-2 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20 dark:hover:bg-white/10"
|
||||
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||
onClick={() => onResumeThread(thread.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onResumeThread(thread.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(thread.id)}
|
||||
disabled={loading || busy}
|
||||
aria-label={`选择${thread.name || thread.preview || "未命名对话"}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onChange={() => toggleThread(thread.id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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}
|
||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] opacity-55">{formatThreadTime(thread.updatedAt || thread.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!threads.length ? (
|
||||
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||
{connected ? "当前工作空间还没有对话记录" : "连接本地 Agent 后显示历史记录"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadTime(value?: number) {
|
||||
if (!value) return "";
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Segmented } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentEventLog } from "@/stores/use-agent-store";
|
||||
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
||||
|
||||
export 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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||
const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`));
|
||||
const copy = async (value = content, tip = "日志已复制") => {
|
||||
if (await copyToClipboard(value)) {
|
||||
onCopied(tip);
|
||||
return;
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
onCopyBlocked("已选中日志,请手动复制");
|
||||
};
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="flex min-h-full flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||
</div>
|
||||
<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" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
{logs.length} 条
|
||||
</span>
|
||||
<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>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
readOnly
|
||||
value={content}
|
||||
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
|
||||
return (
|
||||
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
|
||||
{items.map((item) => (
|
||||
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
|
||||
{item.icon}
|
||||
{item.label}{item.count ? ` ${item.count}` : ""}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Bot, PanelRightClose } from "lucide-react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { LocalAgentPanel } from "./local-agent-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -80,7 +80,7 @@ export function AgentPanel() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<CanvasLocalAgentPanel embedded />
|
||||
<LocalAgentPanel embedded />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button } from "antd";
|
||||
import { History, MessageSquare, PlugZap, Plus, Terminal } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import {summarizeCanvasAgentOps, type CanvasAgentOp, CanvasAgentSnapshot} from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
|
||||
import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postState, postToolResult } from "./agent-api";
|
||||
import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat";
|
||||
import { AgentChatComposer } from "./agent-chat-composer";
|
||||
import { AgentConnectView } from "./agent-connect-view";
|
||||
import {
|
||||
activityDeltaFallback,
|
||||
activityDetail,
|
||||
activityKind,
|
||||
activityPlaceholder,
|
||||
agentAttachmentToChatAttachment,
|
||||
agentErrorView,
|
||||
attachmentPayloadBytes,
|
||||
compactText,
|
||||
eventUsage,
|
||||
formatAgentActivity,
|
||||
formatAgentEvent,
|
||||
formatAgentEventLog,
|
||||
formatAgentPlan,
|
||||
formatBytes,
|
||||
isCanvasWriteTool,
|
||||
isConnectionErrorMessage,
|
||||
isCurrentThreadEvent,
|
||||
mergeAgentText,
|
||||
normalizeHistoryMessages,
|
||||
normalizeText,
|
||||
parseEventData,
|
||||
promptWithAttachments,
|
||||
routeName,
|
||||
siteToolSummary,
|
||||
stringText,
|
||||
toolCallDetail,
|
||||
toolName,
|
||||
turnPlanStatus,
|
||||
type AgentEventItem,
|
||||
type AgentEventPayload,
|
||||
} from "./agent-event-formatters";
|
||||
import { AgentHistoryView } from "./agent-history-view";
|
||||
import { AgentLogView } from "./agent-log-view";
|
||||
import { AgentPanelTabs } from "./agent-panel-tabs";
|
||||
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
|
||||
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
|
||||
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
|
||||
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean };
|
||||
type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem };
|
||||
|
||||
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, 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,
|
||||
tokenUsage: state.tokenUsage,
|
||||
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 messageCount = useAgentStore((state) => state.messages.length);
|
||||
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
|
||||
const confirmToolsRef = useRef(confirmTools);
|
||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||
const autoConnectRef = useRef(false);
|
||||
const connectedRef = useRef(false);
|
||||
const errorLoggedRef = useRef(false);
|
||||
const attachmentUrlsRef = useRef(new Set<string>());
|
||||
const clientIdRef = useRef(randomId());
|
||||
const loadThreadsSequenceRef = useRef(0);
|
||||
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
|
||||
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
|
||||
const loadThreads = useCallback(async (skipHistory = false) => {
|
||||
if (!connectedRef.current && !useAgentStore.getState().connected) return;
|
||||
const sequence = ++loadThreadsSequenceRef.current;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const currentThreadId = useAgentStore.getState().activeThreadId;
|
||||
const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null;
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
|
||||
let nextMessages: AgentChatItem[] = [];
|
||||
if (currentThreadId && !skipHistory) {
|
||||
let thread = await currentThreadRequest;
|
||||
thread ||= await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`);
|
||||
nextMessages = normalizeHistoryMessages(thread.messages || []);
|
||||
}
|
||||
if (sequence !== loadThreadsSequenceRef.current) return;
|
||||
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", ...(skipHistory ? {} : { messages: nextMessages }) });
|
||||
} catch (error) {
|
||||
addEventLog("读取历史失败", error);
|
||||
} finally {
|
||||
if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false });
|
||||
}
|
||||
}, [endpoint, setAgentState, token]);
|
||||
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
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(() => {
|
||||
confirmToolsRef.current = confirmTools;
|
||||
}, [confirmTools]);
|
||||
useEffect(() => {
|
||||
pendingToolRef.current = pendingTool;
|
||||
}, [pendingTool]);
|
||||
useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !token.trim()) return;
|
||||
localStorage.setItem("canvas-agent-url", endpoint);
|
||||
localStorage.setItem("canvas-agent-token", token);
|
||||
const clientId = clientIdRef.current;
|
||||
let eventQueue = Promise.resolve();
|
||||
const enqueueEvent = (task: () => void | Promise<void>) => {
|
||||
eventQueue = eventQueue.then(task).catch((error) => addEventLog("同步会话失败", error));
|
||||
};
|
||||
const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
|
||||
source.addEventListener("hello", (event) => {
|
||||
const busy = Boolean(parseEventData<AgentHelloEvent>(event)?.codex?.busy);
|
||||
errorLoggedRef.current = false;
|
||||
connectedRef.current = true;
|
||||
setAgentState({ connected: true, activity: busy ? "Codex 正在运行" : "已连接", waiting: busy, sending: false, 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);
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
|
||||
});
|
||||
source.addEventListener("codex_state", (event) => {
|
||||
const data = parseEventData<AgentCodexState>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(async () => {
|
||||
const busy = Boolean(data.busy);
|
||||
setAgentState({ activity: busy ? "Codex 正在运行" : "完成", waiting: busy, ...(busy ? {} : { sending: false }) });
|
||||
if (!busy) await loadThreads();
|
||||
});
|
||||
});
|
||||
source.addEventListener("tool_call", (event) => {
|
||||
const data = parseEventData<AgentPendingToolCall>(event);
|
||||
if (data) void handleToolCall(endpoint, token, data);
|
||||
});
|
||||
source.addEventListener("agent_event", (event) => {
|
||||
const data = parseEventData<AgentEventPayload>(event);
|
||||
if (data) enqueueEvent(() => {
|
||||
if (isCurrentThreadEvent(data)) handleAgentEvent(data);
|
||||
});
|
||||
});
|
||||
source.addEventListener("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(async () => {
|
||||
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
|
||||
const current = useAgentStore.getState();
|
||||
const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.activeThreadId === nextThreadId);
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null });
|
||||
await loadThreads(Boolean(data.emptyThread));
|
||||
});
|
||||
});
|
||||
source.addEventListener("chat_message", (event) => {
|
||||
const data = parseEventData<AgentChatEvent>(event);
|
||||
if (!data?.message) return;
|
||||
enqueueEvent(() => {
|
||||
if (!isCurrentThreadEvent(data)) return;
|
||||
addMessage(data.message!);
|
||||
});
|
||||
});
|
||||
source.addEventListener("agent_log", (event) => {
|
||||
const text = parseEventData<{ text?: unknown }>(event)?.text;
|
||||
addEventLog("日志", text, text);
|
||||
});
|
||||
source.addEventListener("agent_error", (event) => {
|
||||
const data = parseEventData<AgentEventPayload>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(() => {
|
||||
if (!isCurrentThreadEvent(data)) return;
|
||||
showAgentError(data.message, data.turn_id);
|
||||
});
|
||||
});
|
||||
source.onerror = () => {
|
||||
const wasConnected = connectedRef.current;
|
||||
const silent = useAgentStore.getState().silentConnect && !wasConnected;
|
||||
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
|
||||
if (!errorLoggedRef.current || wasConnected) {
|
||||
addEventLog(wasConnected ? "连接断开" : "连接失败", text);
|
||||
if (!headless && !silent) message.error(text);
|
||||
}
|
||||
errorLoggedRef.current = true;
|
||||
connectedRef.current = false;
|
||||
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false });
|
||||
if (!wasConnected) {
|
||||
source.close();
|
||||
setAgentState({ enabled: false });
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
source.close();
|
||||
connectedRef.current = false;
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
};
|
||||
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) void loadThreads();
|
||||
}, [connected, loadThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
|
||||
const activateVisible = () => {
|
||||
if (document.visibilityState === "visible") activate();
|
||||
};
|
||||
window.addEventListener("focus", activate);
|
||||
document.addEventListener("visibilitychange", activateVisible);
|
||||
return () => {
|
||||
window.removeEventListener("focus", activate);
|
||||
document.removeEventListener("visibilitychange", activateVisible);
|
||||
};
|
||||
}, [connected, endpoint, token]);
|
||||
const sendPrompt = async () => {
|
||||
const text = prompt.trim();
|
||||
const files = attachments;
|
||||
const requestPrompt = promptWithAttachments(text, files);
|
||||
if (!connected || !requestPrompt || sending || waiting) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
return;
|
||||
}
|
||||
setAgentState({ activity: "发送中", sending: true });
|
||||
const messageId = createId();
|
||||
let threadId = useAgentStore.getState().activeThreadId;
|
||||
try {
|
||||
if (!threadId) {
|
||||
const created = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
threadId = created.thread?.id || created.workspace?.activeThreadId || "";
|
||||
if (!threadId) throw new Error("新建对话失败");
|
||||
setAgentState({ activeThreadId: threadId, messages: [], tokenUsage: null });
|
||||
}
|
||||
addMessage({ id: messageId, role: "user", text: text || "发送了图片", attachments: files });
|
||||
addEventLog("发送任务", `${compactText(text) || "仅附件"}${files.length ? ` · 附件 ${files.length}` : ""}`);
|
||||
const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: requestPrompt,
|
||||
messageText: text || `发送了 ${files.length} 张图片`,
|
||||
messageId,
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
|
||||
}),
|
||||
});
|
||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||
files.forEach((item) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
attachmentUrlsRef.current.delete(item.url);
|
||||
});
|
||||
setAgentState({ prompt: "", attachments: [], sending: false, waiting: true, activity: "Codex 正在运行" });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : "发送失败";
|
||||
const busy = text.includes("Codex 正在运行");
|
||||
setAgentState({ activity: busy ? "Codex 正在运行" : "发送失败" });
|
||||
addMessage({ role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
|
||||
addEventLog("发送失败", error);
|
||||
} finally {
|
||||
setAgentState({ sending: false });
|
||||
}
|
||||
};
|
||||
|
||||
const stopTurn = async () => {
|
||||
if (!connected || (!sending && !waiting)) return;
|
||||
setAgentState({ activity: "停止中" });
|
||||
try {
|
||||
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId: useAgentStore.getState().activeThreadId || undefined }) });
|
||||
addEventLog("停止任务", "已发送停止请求");
|
||||
} catch {
|
||||
setAgentState({ activity: "停止失败" });
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachments = async (files: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||
const prev = useAgentStore.getState().attachments;
|
||||
try {
|
||||
const next = await Promise.all(
|
||||
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||
const dataUrl = await readDataUrl(file);
|
||||
const meta = await readImageMeta(dataUrl);
|
||||
const url = URL.createObjectURL(file);
|
||||
attachmentUrlsRef.current.add(url);
|
||||
return { id: createId(), name: file.name, type: file.type, size: file.size, width: meta.width, height: meta.height, url, dataUrl };
|
||||
}),
|
||||
);
|
||||
const merged = [...prev, ...next];
|
||||
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
next.forEach((item) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
attachmentUrlsRef.current.delete(item.url);
|
||||
});
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件最多约 30MB。" });
|
||||
return;
|
||||
}
|
||||
if (next.length) setAgentState({ attachments: merged });
|
||||
} catch (error) {
|
||||
addMessage({ role: "error", title: "图片读取失败", text: error instanceof Error ? error.message : "图片读取失败" });
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const removed = attachments.find((item) => item.id === id);
|
||||
if (removed) {
|
||||
URL.revokeObjectURL(removed.url);
|
||||
attachmentUrlsRef.current.delete(removed.url);
|
||||
}
|
||||
setAgentState({ attachments: attachments.filter((item) => item.id !== id) });
|
||||
};
|
||||
|
||||
const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
|
||||
if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) {
|
||||
if (pendingToolRef.current) {
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
|
||||
return;
|
||||
}
|
||||
pendingToolRef.current = payload;
|
||||
setAgentState({ pendingTool: payload });
|
||||
addEventLog("等待确认", payload, payload);
|
||||
return;
|
||||
}
|
||||
await runToolCall(endpoint, token, payload);
|
||||
};
|
||||
|
||||
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
|
||||
if (isSiteTool(payload.name)) {
|
||||
try {
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: siteToolSummary(payload.name, result), detail: toolCallDetail(payload.name, payload.input, "completed") });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "工具执行失败";
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
let result: unknown;
|
||||
let appliedOps = input.ops || [];
|
||||
if (payload.name === "site_navigate") {
|
||||
const path = input.path || "/";
|
||||
navigate(path);
|
||||
result = { ok: true, path };
|
||||
} else if (payload.name === "canvas_apply_ops") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = context.applyOps(appliedOps);
|
||||
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else if (payload.name === "canvas_create_attachment_nodes") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes);
|
||||
result = context.applyOps(appliedOps);
|
||||
await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else {
|
||||
const snapshot = canvasContextRef.current?.snapshot;
|
||||
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = snapshot;
|
||||
}
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({
|
||||
role: "tool",
|
||||
title: toolName(payload.name),
|
||||
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "已完成画布操作" : payload.name === "site_navigate" ? `已打开${routeName(input.path || "/")}` : "已完成",
|
||||
detail: toolCallDetail(payload.name, input, "completed"),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
};
|
||||
|
||||
const rejectPendingTool = async () => {
|
||||
if (!pendingTool) return;
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
|
||||
addMessage({ role: "tool", title: toolName(pendingTool.name), text: "用户已取消本次操作", detail: toolCallDetail(pendingTool.name, pendingTool.input, "declined") });
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ pendingTool: null });
|
||||
};
|
||||
|
||||
const approvePendingTool = async () => {
|
||||
if (!pendingTool) return;
|
||||
const tool = pendingTool;
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ pendingTool: null });
|
||||
await runToolCall(endpoint, token, tool);
|
||||
};
|
||||
|
||||
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
if (enabled) {
|
||||
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
|
||||
return;
|
||||
}
|
||||
const urlToken = searchParams.get("agentToken") || "";
|
||||
const urlEndpoint = searchParams.get("agentUrl") || "";
|
||||
const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL);
|
||||
const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, "");
|
||||
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
|
||||
if (!nextEndpoint) {
|
||||
const text = "请填写本地 Agent 地址";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!nextToken) {
|
||||
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(nextEndpoint);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
|
||||
} catch {
|
||||
const text = "本地 Agent 地址格式不正确";
|
||||
if (!silent) {
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
errorLoggedRef.current = false;
|
||||
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false });
|
||||
}, [confirmTools, setAgentState, urlAgentAutoConnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
|
||||
autoConnectRef.current = true;
|
||||
void toggleAgentConnection({ silent: true });
|
||||
}, [autoConnect, connected, enabled]);
|
||||
|
||||
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
setAgentState({
|
||||
messages: [],
|
||||
tokenUsage: null,
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
waiting: false,
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
...patch,
|
||||
});
|
||||
pendingToolRef.current = null;
|
||||
}
|
||||
|
||||
const startNewThread = async () => {
|
||||
if (!connected || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], tokenUsage: null, activeTab: "chat", activity: "新对话" });
|
||||
} catch (error) {
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
} finally {
|
||||
setAgentState({ loadingThreads: false });
|
||||
}
|
||||
};
|
||||
|
||||
const resumeThread = async (threadId: string) => {
|
||||
if (!connected || !threadId || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), tokenUsage: null, activeTab: "chat", activity: "已恢复会话" });
|
||||
} catch (error) {
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
} finally {
|
||||
setAgentState({ loadingThreads: false });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteThreads = async (threadIds: string[]) => {
|
||||
if (!connected || !threadIds.length || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
await Promise.all(threadIds.map((threadId) => fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) })));
|
||||
const current = useAgentStore.getState();
|
||||
const deleted = new Set(threadIds);
|
||||
setAgentState({
|
||||
threads: current.threads.filter((thread) => !deleted.has(thread.id)),
|
||||
activeThreadId: deleted.has(current.activeThreadId) ? "" : current.activeThreadId,
|
||||
messages: deleted.has(current.activeThreadId) ? [] : current.messages,
|
||||
tokenUsage: deleted.has(current.activeThreadId) ? null : current.tokenUsage,
|
||||
});
|
||||
message.success(`已删除 ${threadIds.length} 条记录`);
|
||||
} catch (error) {
|
||||
addEventLog("删除对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "删除对话失败");
|
||||
} finally {
|
||||
setAgentState({ loadingThreads: false });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteThreads = (threadIds: string[]) => {
|
||||
modal.confirm({
|
||||
title: `删除 ${threadIds.length} 条对话记录`,
|
||||
content: "删除后无法恢复,确定继续吗?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteThreads(threadIds),
|
||||
});
|
||||
};
|
||||
|
||||
const addMessage = (item: Omit<AgentChatItem, "id"> & { id?: string }) => {
|
||||
const text = normalizeText(item.text);
|
||||
if (!text && !item.attachments?.length) return;
|
||||
const next = { ...item, id: item.id || `${Date.now()}-${Math.random()}`, text } as AgentChatItem;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
if (currentMessages.some((message) => message.id === next.id)) return;
|
||||
if (next.streamId) {
|
||||
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
||||
if (index >= 0) {
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const last = currentMessages.at(-1);
|
||||
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
|
||||
const merged = mergeAgentText(last.text, next.text);
|
||||
if (merged === last.text) return;
|
||||
setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
|
||||
return;
|
||||
}
|
||||
pushMessage(next);
|
||||
};
|
||||
|
||||
const addEventLog = (title: string, text: unknown, raw?: unknown) => {
|
||||
const value = normalizeText(text) || title;
|
||||
const last = useAgentStore.getState().eventLogs.at(-1);
|
||||
if (last?.title === title && last.text === value) return;
|
||||
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: value, raw });
|
||||
};
|
||||
|
||||
const upsertActivityMessage = (item: AgentChatItem) => {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
pushMessage(item);
|
||||
return;
|
||||
}
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...item } : message)) });
|
||||
};
|
||||
|
||||
const appendActivityDelta = (item: AgentEventItem) => {
|
||||
if (!item.id) return;
|
||||
const delta = stringText(item.delta);
|
||||
if (!delta) return;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
if (!delta.trim()) return;
|
||||
upsertActivityMessage(activityDeltaFallback(item, delta));
|
||||
return;
|
||||
}
|
||||
const current = currentMessages[index];
|
||||
if (item.type === "command_execution") {
|
||||
const detail = activityDetail(current.detail, "command", "inProgress");
|
||||
detail.output = `${detail.output || ""}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
return;
|
||||
}
|
||||
const placeholder = activityPlaceholder(item.type);
|
||||
if (!delta.trim() && current.text === placeholder) return;
|
||||
const text = current.text === placeholder ? delta : `${current.text}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) });
|
||||
};
|
||||
|
||||
const finishPlanActivity = (turnId: string, status?: string) => {
|
||||
const id = `plan-${turnId}`;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === id);
|
||||
if (index < 0) return;
|
||||
const current = currentMessages[index];
|
||||
const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, status));
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
};
|
||||
|
||||
const showAgentError = (value: unknown, turnId?: string) => {
|
||||
const error = agentErrorView(value);
|
||||
const item = { id: turnId ? `error-${turnId}` : createId(), role: "error" as const, title: error.title, text: error.text };
|
||||
upsertActivityMessage(item);
|
||||
setAgentState({ activity: "处理失败", waiting: false, sending: false });
|
||||
addEventLog("处理失败", error.text, value);
|
||||
};
|
||||
|
||||
const handleAgentEvent = (event: AgentEventPayload) => {
|
||||
if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) });
|
||||
const log = formatAgentEventLog(event);
|
||||
if (log) addEventLog(log.title, log.text);
|
||||
if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id });
|
||||
if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) {
|
||||
appendStreamDelta(event.item.id, stringText(event.item.delta));
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.updated" && event.item) {
|
||||
appendActivityDelta(event.item);
|
||||
return;
|
||||
}
|
||||
if (event.type === "plan.updated" && event.turn_id) {
|
||||
const plan = formatAgentPlan(event);
|
||||
if (plan) upsertActivityMessage({ ...plan, id: `plan-${event.turn_id}` });
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
|
||||
if (index >= 0) {
|
||||
const text = stringText(event.item.text);
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: text || message.text, streamId: undefined } : message)) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const activity = formatAgentActivity(event);
|
||||
if (activity && event.item?.id) {
|
||||
upsertActivityMessage({ ...activity, id: event.item.id });
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.completed") {
|
||||
if (event.turn_id) finishPlanActivity(event.turn_id, event.status);
|
||||
setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
|
||||
if (event.status === "failed") showAgentError(event.error?.message, event.turn_id);
|
||||
}
|
||||
const item = formatAgentEvent(event);
|
||||
if (item) addMessage(item);
|
||||
};
|
||||
|
||||
const appendStreamDelta = (streamId: string, delta: string) => {
|
||||
if (!delta) return;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.streamId === streamId);
|
||||
if (index < 0) {
|
||||
pushMessage({ id: `stream-${streamId}`, role: "assistant", title: "Codex", text: delta, streamId });
|
||||
return;
|
||||
}
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: `${message.text}${delta}` } : message)) });
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<AgentPanelTabs
|
||||
value={activeTab}
|
||||
theme={theme}
|
||||
items={[
|
||||
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
|
||||
{ value: "chat", label: "对话", icon: <MessageSquare className="size-3.5" /> },
|
||||
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
|
||||
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
|
||||
]}
|
||||
onChange={(activeTab) => {
|
||||
setAgentState({ activeTab });
|
||||
if (activeTab === "history") void loadThreads();
|
||||
}}
|
||||
right={
|
||||
<>
|
||||
<Button size="small" type="text" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
新对话
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{activeTab === "setup" ? (
|
||||
<AgentConnectView
|
||||
theme={theme}
|
||||
url={url}
|
||||
token={token}
|
||||
enabled={enabled}
|
||||
connected={connected}
|
||||
activity={activity}
|
||||
connectError={connectError}
|
||||
onUrlChange={(url) => setAgentState({ url, connectError: "" })}
|
||||
onTokenChange={(token) => setAgentState({ token, connectError: "" })}
|
||||
onToggleEnabled={toggleAgentConnection}
|
||||
/>
|
||||
) : activeTab === "history" ? (
|
||||
<AgentHistoryView
|
||||
theme={theme}
|
||||
threads={threads}
|
||||
activeThreadId={activeThreadId}
|
||||
workspacePath={workspacePath}
|
||||
loading={loadingThreads}
|
||||
busy={sending || waiting}
|
||||
connected={connected}
|
||||
onRefresh={() => void loadThreads()}
|
||||
onNewThread={() => void startNewThread()}
|
||||
onResumeThread={(threadId) => void resumeThread(threadId)}
|
||||
onDeleteThreads={confirmDeleteThreads}
|
||||
/>
|
||||
) : activeTab === "log" ? (
|
||||
<AgentLogView
|
||||
logs={eventLogs}
|
||||
theme={theme}
|
||||
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messageCount, pendingTool: pendingTool?.name }}
|
||||
onClear={clearEventLogs}
|
||||
onCopied={(text) => message.success(text)}
|
||||
onCopyBlocked={(text) => message.warning(text)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AgentChatTimeline theme={theme} pendingTool={pendingTool} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} />
|
||||
<AgentTaskProgress theme={theme} busy={sending || waiting} />
|
||||
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected}
|
||||
sending={sending || waiting}
|
||||
placeholder="询问 Codex,或让它操作网站/画布"
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
onStop={stopTurn}
|
||||
onAddFiles={addAttachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
left={
|
||||
attachments.length ? (
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (headless) return null;
|
||||
return embedded ? content : null;
|
||||
}
|
||||
|
||||
async function attachmentNodeOps(endpoint: string, token: string, clientId: string, value: unknown): Promise<CanvasAgentOp[]> {
|
||||
const nodes = Array.isArray(value) ? value : [];
|
||||
if (!nodes.length) throw new Error("没有可添加的图片附件");
|
||||
return await Promise.all(
|
||||
nodes.map(async (value) => {
|
||||
const item = value as { id?: unknown; attachmentId?: unknown; title?: unknown; position?: unknown };
|
||||
const id = String(item.id || "");
|
||||
const attachmentId = String(item.attachmentId || "");
|
||||
if (!id || !attachmentId) throw new Error("图片附件节点参数无效");
|
||||
const res = await fetch(`${endpoint}/agent/attachments/${encodeURIComponent(attachmentId)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(body?.error || "读取图片附件失败");
|
||||
}
|
||||
const image = await uploadImage(await res.blob());
|
||||
const size = fitNodeSize(image.width, image.height);
|
||||
const position = item.position && typeof item.position === "object" ? (item.position as { x?: unknown; y?: unknown }) : {};
|
||||
return {
|
||||
type: "add_node" as const,
|
||||
id,
|
||||
nodeType: "image" as const,
|
||||
title: String(item.title || "参考图"),
|
||||
position: { x: Number(position.x) || 0, y: Number(position.y) || 0 },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: imageMetadata(image),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return randomId();
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function readDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -157,6 +157,7 @@ function InfiniteCanvasPage() {
|
||||
const agentPanelOpen = useAgentStore((state) => state.panelOpen);
|
||||
const toggleAgentPanel = useAgentStore((state) => state.togglePanel);
|
||||
const openAgentPanel = useAgentStore((state) => state.openPanel);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
|
||||
@@ -352,6 +353,11 @@ function InfiniteCanvasPage() {
|
||||
void restore();
|
||||
}, [hydrated, navigate, openProject, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, pendingTool: null });
|
||||
}, [projectId, projectLoaded, setAgentState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
|
||||
if (!searchParams.has("agentUrl")) openAgentPanel();
|
||||
|
||||
@@ -108,7 +108,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
}
|
||||
localStorage.setItem("canvas-agent-url", endpoint);
|
||||
localStorage.setItem("canvas-agent-token", token);
|
||||
// 只设 enabled=true,由 CanvasLocalAgentPanel 的 useEffect 统一负责开 SSE
|
||||
// 只设 enabled=true,由 LocalAgentPanel 的 useEffect 统一负责开 SSE
|
||||
set({ url: endpoint, token, enabled: true, silentConnect: silent, activity: "连接中", connectError: "" });
|
||||
},
|
||||
disconnectAgent: (patch = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user