mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 16:14:22 +08:00
fix(agent): unify live and historical conversation state
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
|
||||
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
|
||||
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||
try {
|
||||
@@ -19,13 +19,17 @@ export async function activateAgentClient(endpoint: string, token: string, clien
|
||||
}
|
||||
|
||||
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) });
|
||||
await fetchAgentJson(endpoint, token, `/canvas/result?clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") {
|
||||
await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) });
|
||||
}
|
||||
|
||||
export async function acknowledgeCodexHistory(endpoint: string, token: string, threadId: string, turnIds: string[]) {
|
||||
await fetchAgentJson(endpoint, token, "/agent/codex/history/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId, turnIds }) });
|
||||
}
|
||||
|
||||
export async function revealAgentLocalFile(endpoint: string, token: string, path: string) {
|
||||
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { App, Button, Image, Modal } from "antd";
|
||||
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
||||
import { Streamdown, type LinkSafetyModalProps } from "streamdown";
|
||||
@@ -182,9 +182,9 @@ export function AgentApprovalCard({ approval, theme, onDecision }: { approval: A
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<Button danger type="text" className="!h-8" onClick={() => onDecision("decline")}>拒绝</Button>
|
||||
<Button type="text" className="!h-8" onClick={() => onDecision("accept")}>允许一次</Button>
|
||||
<Button type="text" className="!h-8" style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
||||
<Button danger type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "decline"} onClick={() => onDecision("decline")}>拒绝</Button>
|
||||
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "accept"} onClick={() => onDecision("accept")}>允许一次</Button>
|
||||
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "acceptForSession"} style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -195,26 +195,32 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
||||
if (plan) return <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
||||
const kind = String(objectField(detail, "kind") || "");
|
||||
if (kind === "reasoning") return <AgentReasoningSummary text={text} detail={detail} theme={theme} />;
|
||||
if (kind === "command") return <AgentCommandSummary text={text} detail={detail} theme={theme} />;
|
||||
if (kind === "command") return <AgentCommandGroup items={[{ id: title, text, detail }]} theme={theme} />;
|
||||
const state = toolCardState(title, text, detail);
|
||||
const view = userDetail(detail);
|
||||
const showText = title !== "读取画布" || text !== "已读取当前画布内容";
|
||||
return (
|
||||
<details className="group min-w-0 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
|
||||
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
||||
<span className="min-w-0 truncate font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
||||
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
const className = "group min-w-0 rounded-xl border px-3 py-2.5 text-left";
|
||||
const style = { borderColor: theme.node.stroke, background: "transparent", color: theme.node.text };
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
|
||||
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
||||
<span className="min-w-0 truncate font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
||||
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
</div>
|
||||
{showText ? (
|
||||
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
{showText ? (
|
||||
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
) : null}
|
||||
</summary>
|
||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (!view) return <div className={className} style={style}>{content}</div>;
|
||||
return (
|
||||
<details className={className} style={style}>
|
||||
<summary className="list-none cursor-pointer">{content}</summary>
|
||||
<div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -238,27 +244,81 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCommandSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const view = userDetail(detail);
|
||||
const status = String(objectField(detail, "status") || "");
|
||||
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
|
||||
const failed = ["failed", "error"].includes(status);
|
||||
const color = failed ? "#dc2626" : running ? "#d97706" : theme.node.muted;
|
||||
type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">;
|
||||
|
||||
export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const states = items.map((item) => commandViewState(item.detail));
|
||||
const running = states.some((state) => state.running);
|
||||
const failed = states.filter((state) => state.failed).length;
|
||||
const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail)));
|
||||
const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted;
|
||||
const label = running
|
||||
? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令"
|
||||
: `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`;
|
||||
const header = (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
|
||||
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
|
||||
<span className="font-medium">{label}</span>
|
||||
{expandable ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
|
||||
</div>
|
||||
);
|
||||
if (!expandable) return <div className="min-w-0 py-1 text-left">{header}</div>;
|
||||
return (
|
||||
<details className="group min-w-0 text-left">
|
||||
<summary className={`list-none py-1 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
|
||||
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
|
||||
<span className="font-medium">{failed ? "命令执行失败" : running ? "正在执行命令" : "已执行 1 条命令"}</span>
|
||||
{view ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate pl-6 font-mono text-[12px] leading-5" style={{ color: failed ? color : theme.node.muted }} title={text}>{text}</div>
|
||||
</summary>
|
||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
<summary className="cursor-pointer list-none py-1">{header}</summary>
|
||||
{items.length === 1
|
||||
? <AgentSingleCommand item={items[0]} theme={theme} />
|
||||
: <div className="ml-6 mt-1">{items.map((item, index) => <AgentCommandEntry key={item.id} item={item} index={index} theme={theme} />)}</div>
|
||||
}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const view = userDetail(item.detail);
|
||||
return (
|
||||
<div className="ml-6 pb-1">
|
||||
{item.text ? <div className="mt-1.5 whitespace-pre-wrap break-all font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{item.text}</div> : null}
|
||||
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailId = useId();
|
||||
const view = userDetail(item.detail);
|
||||
const state = commandViewState(item.detail);
|
||||
const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成";
|
||||
const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a";
|
||||
const content = (
|
||||
<>
|
||||
<span className="w-4 shrink-0 text-center text-[10px] tabular-nums opacity-50" style={{ color: theme.node.muted }}>{index + 1}</span>
|
||||
<code className="min-w-0 flex-1 truncate text-[11px] leading-5" style={{ color: theme.node.text }} title={item.text}>{item.text || "命令"}</code>
|
||||
<span className="shrink-0" style={{ color }} title={status} aria-label={status}>
|
||||
{state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
|
||||
</span>
|
||||
{view ? <ChevronRight className={`size-3.5 shrink-0 transition-transform ${open ? "rotate-90" : ""}`} style={{ color: theme.node.muted }} /> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className={index ? "border-t" : ""} style={{ borderColor: theme.node.stroke }}>
|
||||
{view
|
||||
? <button type="button" className="flex w-full min-w-0 items-center gap-2 py-2 text-left" aria-expanded={open} aria-controls={detailId} onClick={() => setOpen((value) => !value)}>{content}</button>
|
||||
: <div className="flex min-w-0 items-center gap-2 py-2 text-left">{content}</div>}
|
||||
{view && open ? <div id={detailId} className="pb-2 pl-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function commandViewState(detail: unknown) {
|
||||
const status = String(objectField(detail, "status") || "").toLowerCase();
|
||||
return {
|
||||
running: ["inprogress", "in_progress", "running", "started", "pending"].includes(status),
|
||||
failed: ["failed", "error"].includes(status),
|
||||
};
|
||||
}
|
||||
|
||||
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const completed = plan.tasks.filter((item) => item.status === "completed").length;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "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 AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||
import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
||||
import { AgentApprovalCard, AgentChatMessage, AgentCommandGroup, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
||||
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
||||
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
|
||||
|
||||
@@ -31,7 +31,9 @@ export function AgentChatTimeline({
|
||||
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
||||
}) {
|
||||
const messages = useAgentStore((state) => state.messages);
|
||||
const timeline = useMemo(() => groupTimelineMessages(messages), [messages]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
@@ -54,23 +56,39 @@ export function AgentChatTimeline({
|
||||
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
let frame = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [scrollToBottom, updateScrollState]);
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pt-4" onScroll={updateScrollState}>
|
||||
{messages.map((item) => (
|
||||
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} />
|
||||
))}
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||
theme={theme}
|
||||
onReject={onRejectTool}
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
<div ref={listRef} className="thin-scrollbar h-full select-text overflow-y-auto" onScroll={updateScrollState}>
|
||||
<div ref={contentRef} className="space-y-4 px-4 pt-4">
|
||||
{timeline.map((entry) => entry.type === "commands"
|
||||
? <AgentCommandGroupRow key={entry.id} items={entry.items} theme={theme} />
|
||||
: <AgentChatMessageRow key={entry.item.id} item={entry.item} theme={theme} />)}
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||
theme={theme}
|
||||
onReject={onRejectTool}
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<AgentScrollToBottom theme={theme} title="查看最新消息" onClick={() => scrollToBottom()} />
|
||||
@@ -97,6 +115,46 @@ const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: {
|
||||
);
|
||||
});
|
||||
|
||||
const AgentCommandGroupRow = memo(function AgentCommandGroupRow({ items, theme }: { items: AgentChatItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div style={items.some((item) => item.streamId) ? undefined : historyMessageStyle}>
|
||||
<AgentCommandGroup items={items} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type AgentTimelineEntry = { type: "message"; item: AgentChatItem } | { type: "commands"; id: string; items: AgentChatItem[] };
|
||||
|
||||
function groupTimelineMessages(messages: AgentChatItem[]) {
|
||||
const timeline: AgentTimelineEntry[] = [];
|
||||
let commands: AgentChatItem[] = [];
|
||||
let commandScope = "";
|
||||
const flushCommands = () => {
|
||||
if (!commands.length) return;
|
||||
timeline.push({ type: "commands", id: `commands:${commands[0].id}`, items: commands });
|
||||
commands = [];
|
||||
commandScope = "";
|
||||
};
|
||||
messages.forEach((item) => {
|
||||
if (isPlanMessage(item)) return;
|
||||
if (isCommandMessage(item)) {
|
||||
const scope = item.threadId && item.turnId ? `${item.threadId}\0${item.turnId}` : item.id;
|
||||
if (commands.length && scope !== commandScope) flushCommands();
|
||||
commands.push(item);
|
||||
commandScope = scope;
|
||||
return;
|
||||
}
|
||||
flushCommands();
|
||||
timeline.push({ type: "message", item });
|
||||
});
|
||||
flushCommands();
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function isCommandMessage(item: AgentChatItem) {
|
||||
return item.role === "tool" && item.detail && typeof item.detail === "object" && (item.detail as { kind?: unknown }).kind === "command";
|
||||
}
|
||||
|
||||
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 }}>
|
||||
|
||||
@@ -3,13 +3,17 @@ import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas
|
||||
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 const REASONING_PLACEHOLDER = "正在分析任务…";
|
||||
|
||||
export type AgentEventPayload = {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
threadId?: string;
|
||||
thread_id?: string;
|
||||
turnId?: string;
|
||||
turn_id?: string;
|
||||
sourceClientId?: string;
|
||||
replayed?: boolean;
|
||||
item?: AgentEventItem;
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
@@ -60,7 +64,6 @@ export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentCha
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -70,45 +73,50 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
|
||||
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"));
|
||||
const failed = Boolean(item.error?.message) || item.success === false || ["failed", "error"].includes(status);
|
||||
const itemStatus = failed ? "failed" : status;
|
||||
if (item.type === "reasoning") {
|
||||
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
|
||||
const text = readableText(item.summary);
|
||||
if (completed && !text) return null;
|
||||
return { role: "tool", title: "思考摘要", text: text || activityPlaceholder(item.type), detail: { kind: "reasoning", status: itemStatus } };
|
||||
}
|
||||
if (item.type === "plan") {
|
||||
const text = stringText(item.text) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
|
||||
const text = stringText(item.text);
|
||||
if (completed && !text && !item.error?.message) return null;
|
||||
return { role: "tool", title: "执行计划", text: item.error?.message || text || activityPlaceholder(item.type), detail: { kind: "plan", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
}
|
||||
if (item.type === "command_execution") {
|
||||
const command = stringText(item.command) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
|
||||
const command = stringText(item.command);
|
||||
const text = command || (completed ? failed ? "命令执行失败" : "命令已完成" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "执行命令", text, detail: commandActivityDetail(item, itemStatus) };
|
||||
}
|
||||
if (item.type === "file_change") {
|
||||
const files = activityFiles(item.changes);
|
||||
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
|
||||
return { role: "tool", title: "修改文件", text: item.error?.message || fileActivitySummary(files, completed), detail: { kind: "file", status: itemStatus, files, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
}
|
||||
if (item.type === "web_search") {
|
||||
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
|
||||
return { role: "tool", title: "搜索资料", text: item.error?.message || webSearchSummary(item), detail: { kind: "search", status: itemStatus, rows: webSearchDetailRows(item), ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
}
|
||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
|
||||
if (item.type === "image_generation") return { role: "tool", title: "内置生图", text: completed ? "图片生成完成" : "正在生成图片…", 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 || ""))) {
|
||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: item.error?.message || stringText(item.path) || (completed ? "已查看图片" : "正在查看图片"), detail: { kind: "image", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
if (item.type === "image_generation") {
|
||||
return { role: "tool", title: "内置生图", text: item.error?.message || (completed ? failed ? "图片生成失败" : "图片生成完成" : "正在生成图片…"), detail: { kind: "image", status: itemStatus, savedPath: item.savedPath, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
}
|
||||
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: item.error?.message || (completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…"), detail: { kind: "context", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
if (isMcpToolItem(item)) {
|
||||
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) };
|
||||
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}…`, detail: toolDetail(item, itemStatus) };
|
||||
}
|
||||
if (item.type === "dynamic_tool_call") {
|
||||
const name = String(item.tool || "");
|
||||
const title = toolName(name);
|
||||
const failed = Boolean(item.error) || item.success === false || ["failed", "error"].includes(status);
|
||||
const currentStatus = failed ? "failed" : status;
|
||||
return {
|
||||
role: "tool",
|
||||
title,
|
||||
text: completed ? item.error?.message || readableText(item.contentItems) || `${title}${failed ? "失败" : "完成"}` : `正在${toolAction(name)}…`,
|
||||
detail: toolDetail(item, currentStatus),
|
||||
text: completed ? item.error?.message || readableText(item.contentItems) : `正在${toolAction(name)}…`,
|
||||
detail: toolDetail(item, itemStatus),
|
||||
};
|
||||
}
|
||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
|
||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: item.error?.message || (completed ? failed ? "协作任务失败" : "已完成协作任务" : "正在协作处理任务…"), detail: { kind: "tool", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -147,7 +155,7 @@ export function activityDeltaFallback(item: AgentEventItem, delta: string): Agen
|
||||
export function activityPlaceholder(type?: string) {
|
||||
if (type === "plan") return "正在整理执行步骤…";
|
||||
if (type === "command_execution") return "正在执行命令…";
|
||||
return "正在分析任务…";
|
||||
return REASONING_PLACEHOLDER;
|
||||
}
|
||||
|
||||
export function activityKind(type?: string) {
|
||||
@@ -163,7 +171,8 @@ export function activityDetail(value: unknown, kind: string, status: string): Ag
|
||||
|
||||
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) };
|
||||
const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status;
|
||||
return { kind: "command", status: commandStatus, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
|
||||
}
|
||||
|
||||
function activityFiles(value: unknown) {
|
||||
@@ -184,7 +193,7 @@ 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")) || "相关内容"}”`;
|
||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "内容"}”`;
|
||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||
}
|
||||
|
||||
@@ -196,6 +205,7 @@ function webSearchDetailRows(item: AgentEventItem) {
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
if (!value || typeof value !== "object") return "";
|
||||
return readableText(objectField(value, "text"));
|
||||
}
|
||||
|
||||
@@ -227,6 +237,19 @@ export function isCurrentThreadEvent(event: { threadId?: string; thread_id?: str
|
||||
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
|
||||
}
|
||||
|
||||
export function registerLiveAgentTurn(
|
||||
event: { replayed?: boolean; threadId?: string; thread_id?: string; turnId?: string; turn_id?: string },
|
||||
authoritativeTurns: ReadonlySet<string>,
|
||||
liveTurns: Set<string>,
|
||||
) {
|
||||
const threadId = event.threadId || event.thread_id || "";
|
||||
const turnId = event.turnId || event.turn_id || "";
|
||||
const key = threadId && turnId ? `${threadId}\0${turnId}` : "";
|
||||
if (event.replayed && key && authoritativeTurns.has(key)) return false;
|
||||
if (key) liveTurns.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
const head = [
|
||||
"Infinite Canvas Agent 诊断",
|
||||
@@ -325,26 +348,23 @@ export function toolName(name: string) {
|
||||
return name ? `调用工具:${name}` : "工具操作";
|
||||
}
|
||||
|
||||
export function siteToolSummary(name: string, result: unknown) {
|
||||
function siteToolSummary(name: string, result: unknown, input: unknown) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`;
|
||||
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 === "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 "已完成";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isReadTool(name: string) {
|
||||
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
|
||||
}
|
||||
|
||||
function isMcpToolItem(item?: AgentEventItem) {
|
||||
function isMcpToolItem(item?: AgentEventItem): item is AgentEventItem & { type: "mcp_tool_call" } {
|
||||
return item?.type === "mcp_tool_call";
|
||||
}
|
||||
|
||||
@@ -358,23 +378,26 @@ export function toolCallDetail(name: string, input: unknown, status: string, err
|
||||
}
|
||||
|
||||
function toolInputRows(name: string, input: unknown) {
|
||||
input = parseToolArguments(input);
|
||||
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] : []));
|
||||
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toolSummary(item?: AgentEventItem) {
|
||||
const result = parseToolResult(item?.result);
|
||||
const name = String(item?.tool || "");
|
||||
if (name === "site_navigate" || isSiteTool(name)) return siteToolSummary(name, result, parseToolArguments(item?.arguments));
|
||||
const nodeField = objectField(result, "nodes");
|
||||
const connectionField = objectField(result, "connections");
|
||||
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
||||
const connections = Array.isArray(connectionField) ? connectionField : [];
|
||||
if (item?.tool === "canvas_get_state" && (Array.isArray(nodeField) || Array.isArray(connectionField))) return canvasContentSummary(nodes, connections.length);
|
||||
if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`;
|
||||
return "工具调用完成";
|
||||
if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
|
||||
if (name === "canvas_get_selection") return "已读取当前选中内容";
|
||||
return "";
|
||||
}
|
||||
|
||||
function canvasContentSummary(nodes: unknown[], connections: number) {
|
||||
@@ -479,17 +502,6 @@ function numberField(value: unknown, key: string) {
|
||||
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 ? "请处理上传的图片附件。" : "");
|
||||
}
|
||||
@@ -506,15 +518,63 @@ export function isCanvasWriteTool(name: string) {
|
||||
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
|
||||
}
|
||||
|
||||
function parseToolArguments(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function agentMessageId(threadId: string, turnId: string, itemId: string) {
|
||||
return `${threadId}:${turnId}:${itemId}`;
|
||||
}
|
||||
|
||||
export function scopeChatItem(item: AgentChatItem, threadId: string, turnId: string) {
|
||||
const scopeTurnId = turnId || "pending";
|
||||
const prefix = `${threadId || "local"}:${scopeTurnId}:`;
|
||||
const sourceItemId = item.itemId || (item.id.startsWith(prefix) ? item.id.slice(prefix.length) : item.id);
|
||||
const itemId = item.role === "user" ? "synthetic:user" : sourceItemId;
|
||||
return { ...item, id: agentMessageId(threadId || "local", scopeTurnId, itemId), itemId, threadId, turnId };
|
||||
}
|
||||
|
||||
export function bindPendingTurnMessages(messages: AgentChatItem[], threadId: string, turnId: string) {
|
||||
const index = messages.findLastIndex((item) => item.role === "user" && item.threadId === threadId && !item.turnId);
|
||||
if (index < 0) return messages;
|
||||
return messages.map((item, itemIndex) => itemIndex === index ? scopeChatItem(item, threadId, turnId) : item);
|
||||
}
|
||||
|
||||
export function upsertAgentMessage(messages: AgentChatItem[], item: AgentChatItem) {
|
||||
const index = messages.findIndex((current) => current.id === item.id);
|
||||
if (index < 0) return [...messages, item];
|
||||
const current = messages[index];
|
||||
const next = { ...current, ...item, attachments: item.attachments || current.attachments, historyText: item.historyText || current.historyText };
|
||||
return messages.map((message, itemIndex) => itemIndex === index ? next : message);
|
||||
}
|
||||
|
||||
export function mergeAgentMessages(snapshot: AgentChatItem[], current: AgentChatItem[], threadId: string, liveTurnKeys: ReadonlySet<string>) {
|
||||
let messages = [...snapshot];
|
||||
current.filter((item) => item.threadId === threadId).forEach((item) => {
|
||||
const live = Boolean(item.turnId && liveTurnKeys.has(`${threadId}\0${item.turnId}`));
|
||||
const index = messages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
if (!item.turnId || live) messages = upsertAgentMessage(messages, item);
|
||||
return;
|
||||
}
|
||||
const history = messages[index];
|
||||
const next = live
|
||||
? { ...history, ...item, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText }
|
||||
: { ...history, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText };
|
||||
messages = messages.map((message, itemIndex) => itemIndex === index ? next : message);
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
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);
|
||||
.filter((item) => (normalizeText(item.text) || item.role === "tool") && item.itemId && item.threadId && item.turnId)
|
||||
.map(({ streamId: _streamId, ...item }) => scopeChatItem({ ...item, text: normalizeText(item.text) } as AgentChatItem, item.threadId!, item.turnId!));
|
||||
}
|
||||
|
||||
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||
@@ -523,22 +583,35 @@ export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessag
|
||||
.reverse()
|
||||
.map((item) => {
|
||||
if (item.role !== "user") return item;
|
||||
const index = currentUsers.findIndex((current) => current.text === item.text || current.historyText === item.text);
|
||||
let index = item.clientMessageId
|
||||
? currentUsers.findIndex((current) => current.clientMessageId === item.clientMessageId && current.threadId === item.threadId)
|
||||
: -1;
|
||||
if (index < 0 && item.turnId) index = currentUsers.findIndex((current) => current.turnId === item.turnId && current.threadId === item.threadId);
|
||||
if (index < 0) {
|
||||
const candidates = currentUsers
|
||||
.map((current, candidateIndex) => ({ current, candidateIndex }))
|
||||
.filter(({ current }) => current.threadId === item.threadId && (current.text === item.text || current.historyText === item.text));
|
||||
if (new Set(candidates.map(({ current }) => current.clientMessageId || current.id)).size === 1) index = candidates[0]?.candidateIndex ?? -1;
|
||||
}
|
||||
if (index < 0) return item;
|
||||
const current = currentUsers.splice(index, 1)[0];
|
||||
return { ...item, id: current.id, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
||||
return { ...item, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
export function mergeHistoryMessages(historyMessages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||
if (!currentMessages.length) return historyMessages;
|
||||
const remaining = [...historyMessages];
|
||||
const messages = currentMessages.map((current) => {
|
||||
const index = remaining.findIndex((history) => history.id === current.id || ((current.role === "user" || current.role === "assistant") && history.role === current.role && history.text === current.text));
|
||||
if (index < 0) return current;
|
||||
const history = remaining.splice(index, 1)[0];
|
||||
return { ...current, ...history, id: current.id, attachments: current.attachments || history.attachments };
|
||||
});
|
||||
return [...messages, ...remaining];
|
||||
export function reasoningActivityText(items: Record<string, string>, fallback = "") {
|
||||
const summaries = Object.values(items).map((item) => item.trim()).filter(isReasoningSummary);
|
||||
return summaries.join("\n\n") || fallback || REASONING_PLACEHOLDER;
|
||||
}
|
||||
|
||||
export function isReasoningSummary(value = "") {
|
||||
const text = value.trim();
|
||||
return Boolean(text && text !== REASONING_PLACEHOLDER && text !== "已完成分析");
|
||||
}
|
||||
|
||||
export function mergeStreamText(prefix: string, incoming: string) {
|
||||
if (!prefix || incoming.startsWith(prefix)) return incoming || prefix;
|
||||
if (!incoming || prefix.startsWith(incoming)) return prefix;
|
||||
return incoming.length >= prefix.length ? incoming : prefix;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AgentHistoryView({
|
||||
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 canResume = connected && !loading && !busy;
|
||||
const toggleThread = (threadId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -76,12 +77,15 @@ export function AgentHistoryView({
|
||||
<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"
|
||||
tabIndex={canResume ? 0 : -1}
|
||||
aria-disabled={!canResume}
|
||||
className={`${canResume ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/10" : "cursor-default opacity-60"} rounded-lg border px-2.5 py-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20`}
|
||||
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||
onClick={() => onResumeThread(thread.id)}
|
||||
onClick={() => {
|
||||
if (canResume) onResumeThread(thread.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (!canResume || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
onResumeThread(thread.id);
|
||||
}}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AgentPanel() {
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
|
||||
style={{ overflow: "clip", pointerEvents: panelOpen && !panelClosing ? undefined : "none" }}
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -157,7 +157,6 @@ 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);
|
||||
@@ -353,11 +352,6 @@ 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();
|
||||
|
||||
@@ -3,33 +3,127 @@ import localforage from "localforage";
|
||||
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store";
|
||||
|
||||
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string };
|
||||
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string; threadId?: string; turnId?: string };
|
||||
|
||||
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
|
||||
const mutations = new Map<string, Promise<void>>();
|
||||
const indexKey = (threadId: string) => `thread:${threadId}`;
|
||||
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
|
||||
const pendingKey = (messageId: string) => `pending:${messageId}`;
|
||||
const threadMutationKey = (threadId: string) => `thread:${threadId}`;
|
||||
const pendingMutationKey = (messageId: string) => `pending:${messageId}`;
|
||||
|
||||
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||
if (!message.attachments?.length) return;
|
||||
const attachments = await Promise.all((message.attachments || []).map(createThumbnail));
|
||||
await store.setItem(messageKey(threadId, message.id), { ...message, attachments });
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
||||
if (!threadId) return savePendingAgentUserMessage(message);
|
||||
await saveThreadAgentUserMessage(threadId, message);
|
||||
}
|
||||
|
||||
/** Persist attachments before a turn is accepted. The record is moved to a thread after the server assigns one. */
|
||||
export async function savePendingAgentUserMessage(message: StoredAgentUserMessage) {
|
||||
if (!message.id || !message.attachments?.length) return;
|
||||
await mutateScopes([pendingMutationKey(message.id)], async () => {
|
||||
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
|
||||
await store.setItem(pendingKey(message.id), { ...message, threadId: undefined, turnId: undefined, attachments });
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePendingAgentUserMessage(messageId: string) {
|
||||
if (!messageId) return;
|
||||
await mutateScopes([pendingMutationKey(messageId)], () => store.removeItem(pendingKey(messageId)));
|
||||
}
|
||||
|
||||
export async function readAgentUserMessages(threadId: string) {
|
||||
await mutations.get(threadMutationKey(threadId))?.catch(() => undefined);
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
return (await Promise.all(ids.map((id) => store.getItem<StoredAgentUserMessage>(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item));
|
||||
}
|
||||
|
||||
/** Bind a pending message to the server thread, preserving an already-known turn id. */
|
||||
export async function bindPendingAgentUserMessage(threadId: string, messageId: string, turnId = "") {
|
||||
if (!threadId || !messageId) return;
|
||||
await mutateScopes([pendingMutationKey(messageId), threadMutationKey(threadId)], async () => {
|
||||
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
|
||||
const key = messageKey(threadId, messageId);
|
||||
const existing = await store.getItem<StoredAgentUserMessage>(key);
|
||||
if (!pending && !existing) return;
|
||||
const message = mergeStoredMessage(existing, pending, threadId, turnId);
|
||||
await putThreadMessage(threadId, key, message);
|
||||
if (pending) await store.removeItem(pendingKey(messageId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function bindAgentUserMessageTurn(threadId: string, messageId: string, turnId: string) {
|
||||
await bindPendingAgentUserMessage(threadId, messageId, turnId);
|
||||
}
|
||||
|
||||
export async function moveAgentUserMessage(fromThreadId: string, toThreadId: string, messageId: string) {
|
||||
if (!toThreadId || !messageId || fromThreadId === toThreadId) return bindPendingAgentUserMessage(toThreadId, messageId);
|
||||
const scopes = [pendingMutationKey(messageId), threadMutationKey(toThreadId), ...(fromThreadId ? [threadMutationKey(fromThreadId)] : [])];
|
||||
await mutateScopes(scopes, async () => {
|
||||
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
|
||||
const fromKey = fromThreadId ? messageKey(fromThreadId, messageId) : "";
|
||||
const from = fromKey ? await store.getItem<StoredAgentUserMessage>(fromKey) : null;
|
||||
const toKey = messageKey(toThreadId, messageId);
|
||||
const existing = await store.getItem<StoredAgentUserMessage>(toKey);
|
||||
const source = pending || from;
|
||||
if (!source && !existing) return;
|
||||
await putThreadMessage(toThreadId, toKey, mergeStoredMessage(existing, source, toThreadId));
|
||||
if (pending) await store.removeItem(pendingKey(messageId));
|
||||
if (from && fromThreadId) await removeThreadMessage(fromThreadId, fromKey, messageId);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentThreadMessages(threadIds: string[]) {
|
||||
await Promise.all(
|
||||
threadIds.map(async (threadId) => {
|
||||
await mutateScopes(threadIds.map(threadMutationKey), async () => {
|
||||
await Promise.all(threadIds.map(async (threadId) => {
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id))));
|
||||
await store.removeItem(indexKey(threadId));
|
||||
}),
|
||||
);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function saveThreadAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||
await mutateScopes([threadMutationKey(threadId)], async () => {
|
||||
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
|
||||
await putThreadMessage(threadId, messageKey(threadId, message.id), { ...message, threadId, attachments });
|
||||
});
|
||||
}
|
||||
|
||||
async function putThreadMessage(threadId: string, key: string, message: StoredAgentUserMessage) {
|
||||
await store.setItem(key, { ...message, threadId });
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
||||
}
|
||||
|
||||
function mergeStoredMessage(existing: StoredAgentUserMessage | null, source: StoredAgentUserMessage | null | undefined, threadId: string, turnId = "") {
|
||||
const message = { ...(source || {}), ...(existing || {}) } as StoredAgentUserMessage;
|
||||
if (!message.attachments?.length && source?.attachments?.length) message.attachments = source.attachments;
|
||||
if (!message.text && source?.text) message.text = source.text;
|
||||
if (!message.historyText && source?.historyText) message.historyText = source.historyText;
|
||||
return { ...message, threadId, ...(turnId ? { turnId } : message.turnId ? { turnId: message.turnId } : {}) };
|
||||
}
|
||||
|
||||
async function removeThreadMessage(threadId: string, key: string, messageId: string) {
|
||||
await store.removeItem(key);
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
const remaining = ids.filter((id) => id !== messageId);
|
||||
if (remaining.length) await store.setItem(indexKey(threadId), remaining);
|
||||
else await store.removeItem(indexKey(threadId));
|
||||
}
|
||||
|
||||
async function mutateScopes(scopes: string[], mutation: () => Promise<void>) {
|
||||
const ids = [...new Set(scopes.filter(Boolean))].sort();
|
||||
const operation = Promise.all(ids.map((id) => mutations.get(id)?.catch(() => undefined))).then(mutation);
|
||||
ids.forEach((id) => mutations.set(id, operation));
|
||||
try {
|
||||
await operation;
|
||||
} finally {
|
||||
ids.forEach((id) => {
|
||||
if (mutations.get(id) === operation) mutations.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createThumbnail(attachment: AgentAttachment): Promise<AgentAttachment> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-age
|
||||
|
||||
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
|
||||
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string; activityItems?: Record<string, string> };
|
||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
||||
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||
@@ -17,7 +17,8 @@ export type AgentModel = {
|
||||
supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown };
|
||||
export type AgentApprovalDecision = "accept" | "acceptForSession" | "decline";
|
||||
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown; deciding?: AgentApprovalDecision };
|
||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
export type AgentTokenUsage = { input: number; cached: number; output: number };
|
||||
@@ -47,6 +48,7 @@ type AgentStore = {
|
||||
eventLogs: AgentEventLog[];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
activeTurnId: string;
|
||||
workspacePath: string;
|
||||
loadingThreads: boolean;
|
||||
activeTab: AgentPanelTab;
|
||||
@@ -93,6 +95,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
eventLogs: [],
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
activeTurnId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
activeTab: "setup",
|
||||
@@ -111,7 +114,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
if (!get().panelMounted || get().panelClosing) return;
|
||||
set({ panelOpen: false, panelClosing: true });
|
||||
setTimeout(() => {
|
||||
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
|
||||
if (get().panelClosing) set({ panelClosing: false });
|
||||
}, CANVAS_AGENT_PANEL_MOTION_MS);
|
||||
},
|
||||
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
|
||||
@@ -139,7 +142,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
connectTimer = null;
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", ...patch });
|
||||
},
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })),
|
||||
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
||||
clearEventLogs: () => set({ eventLogs: [] }),
|
||||
}));
|
||||
|
||||
@@ -523,6 +523,10 @@
|
||||
line-height: 1.15rem;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-body"] code > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-streamdown [data-streamdown="code-block-actions"] {
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user