mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-05 03:53:49 +08:00
672 lines
25 KiB
TypeScript
672 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
CheckIcon,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CrosshairIcon,
|
|
Flag,
|
|
MessageSquare,
|
|
PaperclipIcon,
|
|
ShieldAlertIcon,
|
|
Terminal,
|
|
UserIcon,
|
|
Wrench,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { api } from "@/lib/api";
|
|
import { Markdown } from "@/components/markdown";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { Activity } from "@/lib/types";
|
|
|
|
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
|
|
const workerColors = [
|
|
"bg-sky-600",
|
|
"bg-violet-600",
|
|
"bg-teal-600",
|
|
"bg-pink-600",
|
|
"bg-orange-600",
|
|
];
|
|
function workerColor(name: string): string {
|
|
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
|
|
if (name === "mainagent") return "bg-primary";
|
|
const m = /#(\d+)/.exec(name);
|
|
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
|
|
return workerColors[Math.max(0, i)];
|
|
}
|
|
|
|
const chip = (worker: string) =>
|
|
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
|
|
|
|
// useInView latches true when the ref'd element first comes within `rootMargin` of
|
|
// the enclosing scroll viewport. Blocks that always show their full body (user
|
|
// bubbles, answers) use it to defer fetching that body until they're about to be
|
|
// seen — so opening a long thread doesn't fire a detail request for every off-
|
|
// screen step. Observes the ScrollArea viewport (falls back to eager load when
|
|
// IntersectionObserver is unavailable, e.g. SSR).
|
|
function useInView(rootMargin = "400px"): [React.RefObject<HTMLDivElement | null>, boolean] {
|
|
const ref = React.useRef<HTMLDivElement | null>(null);
|
|
const [inView, setInView] = React.useState(false);
|
|
React.useEffect(() => {
|
|
if (inView) return; // latch: once seen, stop observing
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
if (typeof IntersectionObserver === "undefined") {
|
|
setInView(true);
|
|
return;
|
|
}
|
|
const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
|
|
const ob = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((e) => e.isIntersecting)) setInView(true);
|
|
},
|
|
{ root, rootMargin },
|
|
);
|
|
ob.observe(el);
|
|
return () => ob.disconnect();
|
|
}, [inView, rootMargin]);
|
|
return [ref, inView];
|
|
}
|
|
|
|
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
|
|
// a run of consecutive conversational steps (text/thinking/result) from the same
|
|
// agent is one "message".
|
|
type Group =
|
|
| { type: "user"; key: number; step: Activity; intent?: boolean }
|
|
| { type: "answer"; key: number; step: Activity }
|
|
| { type: "round"; key: number; label: string }
|
|
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
|
|
| { type: "msg"; key: number; worker: string; steps: Activity[] }
|
|
| { type: "intercept"; key: number; step: Activity };
|
|
|
|
function groupSteps(steps: Activity[], chat: boolean): Group[] {
|
|
const out: Group[] = [];
|
|
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
|
|
for (const s of steps) {
|
|
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
|
|
if (s.kind === "round") {
|
|
out.push({ type: "round", key: s.seq, label: s.summary || "新一轮" }); // planner round boundary
|
|
continue;
|
|
}
|
|
if (s.kind === "intercept_request") {
|
|
out.push({ type: "intercept", key: s.seq, step: s });
|
|
continue;
|
|
}
|
|
if (s.kind === "user" || s.kind === "intent") {
|
|
// human turn OR the LLM-generated intent leading a worker session — both are
|
|
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
|
|
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
|
|
continue;
|
|
}
|
|
// In a chat (main agent) the assistant's text/result IS the answer — render it
|
|
// full (markdown), never collapsed. thinking still folds into a compact block.
|
|
if (s.kind === "result" || (chat && s.kind === "text")) {
|
|
out.push({ type: "answer", key: s.seq, step: s });
|
|
continue;
|
|
}
|
|
if (s.kind === "tool_use") {
|
|
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
|
|
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
|
|
out.push(g);
|
|
continue;
|
|
}
|
|
if (s.kind === "tool_result") {
|
|
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
|
|
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
|
|
if (g && !g.result) g.result = s;
|
|
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
|
|
continue;
|
|
}
|
|
const last = out[out.length - 1];
|
|
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
|
|
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const kindLabel = (k: string) => (k === "thinking" ? "推理" : k === "result" ? "总结" : "说明");
|
|
|
|
function ActivityTime({ ts }: { ts: string }) {
|
|
const date = new Date(ts);
|
|
if (!ts || Number.isNaN(date.getTime())) return null;
|
|
return (
|
|
<time
|
|
dateTime={date.toISOString()}
|
|
title={date.toLocaleString("zh-CN")}
|
|
className="text-[10px] text-muted-foreground tabular-nums"
|
|
suppressHydrationWarning
|
|
>
|
|
{date.toLocaleString("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
})}
|
|
</time>
|
|
);
|
|
}
|
|
|
|
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
|
|
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
|
|
// the command itself instead of JSON; other tools fall back to the raw text.
|
|
function toolInputText(tool: string, raw: string): string {
|
|
if (tool !== "Bash") return raw;
|
|
// full input (expanded detail): parse and pull the command out.
|
|
try {
|
|
const o = JSON.parse(raw);
|
|
if (o && typeof o.command === "string") return o.command;
|
|
} catch {
|
|
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
|
|
// JSON tail is cut off and JSON.parse fails — fall through and extract the
|
|
// "command" field by hand, tolerating the missing closing quote.
|
|
}
|
|
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
|
|
if (m) {
|
|
try {
|
|
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
|
|
return JSON.parse('"' + m[1] + '"');
|
|
} catch {
|
|
// truncated mid-escape — unescape the common sequences best-effort.
|
|
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) =>
|
|
c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c,
|
|
);
|
|
}
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
// InterceptCard renders an inline intercept_request approval card. The pending_id
|
|
// is extracted from the summary (format: "工具 X 请求审批 (#N)") so buttons are
|
|
// available immediately without waiting for the detail load.
|
|
function InterceptCard({
|
|
step,
|
|
getDetail,
|
|
}: {
|
|
step: Activity;
|
|
getDetail: (seq: number) => Promise<string>;
|
|
}) {
|
|
// extract pending_id from summary: "工具 Bash 请求审批 (#42)"
|
|
const pendingId = React.useMemo(() => {
|
|
const m = /\(#(\d+)\)/.exec(step.summary);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
}, [step.summary]);
|
|
|
|
const toolName = React.useMemo(() => {
|
|
const m = /工具\s+(\S+)\s+请求/.exec(step.summary);
|
|
return m ? m[1] : step.summary;
|
|
}, [step.summary]);
|
|
|
|
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
|
|
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
|
|
const [deciding, setDeciding] = React.useState(false);
|
|
|
|
// Load persisted detail JSON + check the real current status from the backend
|
|
// so that a page refresh shows the already-decided state instead of re-offering buttons.
|
|
React.useEffect(() => {
|
|
let live = true;
|
|
getDetail(step.seq)
|
|
.then((raw) => {
|
|
if (!live || !raw) return;
|
|
try { setDetail(JSON.parse(raw)); } catch { /* ignore */ }
|
|
})
|
|
.catch(() => {/* ignore */});
|
|
return () => { live = false; };
|
|
}, [step.seq, getDetail]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pendingId) return;
|
|
let live = true;
|
|
api.interceptGetOne(pendingId)
|
|
.then((p) => {
|
|
if (!live) return;
|
|
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
|
|
})
|
|
.catch(() => {/* ignore */});
|
|
return () => { live = false; };
|
|
}, [pendingId]);
|
|
|
|
async function decide(decision: "allowed" | "denied") {
|
|
if (step.inherited || !pendingId || deciding) return;
|
|
setDeciding(true);
|
|
try {
|
|
await api.interceptDecide(pendingId, decision);
|
|
setDecided(decision);
|
|
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
|
} catch (e) {
|
|
toast.error((e as Error).message);
|
|
} finally {
|
|
setDeciding(false);
|
|
}
|
|
}
|
|
|
|
const inputStr = detail?.input
|
|
? JSON.stringify(detail.input).slice(0, 200)
|
|
: null;
|
|
|
|
return (
|
|
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
<div className="min-w-0 space-y-0.5">
|
|
<div className="flex items-center gap-1.5 font-medium">
|
|
<span className="text-amber-700 dark:text-amber-400">审批请求</span>
|
|
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
|
|
{toolName}
|
|
</code>
|
|
{pendingId && (
|
|
<span className="text-muted-foreground">#{pendingId}</span>
|
|
)}
|
|
</div>
|
|
{inputStr && (
|
|
<p className="font-mono text-muted-foreground truncate">{inputStr}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{step.inherited ? (
|
|
<Badge variant="outline">历史记录 · 只读</Badge>
|
|
) : decided ? (
|
|
<span className={
|
|
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
|
|
(decided === "allowed"
|
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
|
|
: decided === "timeout"
|
|
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
|
|
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
|
|
}>
|
|
{decided === "allowed" ? "已允许" : decided === "timeout" ? "已超时" : "已拒绝"}
|
|
</span>
|
|
) : (
|
|
<div className="flex shrink-0 gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={deciding || !pendingId}
|
|
onClick={() => decide("allowed")}
|
|
>
|
|
<CheckIcon className="h-3 w-3" />
|
|
允许
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={deciding || !pendingId}
|
|
onClick={() => decide("denied")}
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
拒绝
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ToolBlock renders ONE tool call: the command and its result bound into a single
|
|
// row (collapsed shows the command + a status/result preview; expand shows the
|
|
// full input AND output together). Lazy-loads both details on expand.
|
|
function ToolBlock({
|
|
group,
|
|
getDetail,
|
|
showWorker,
|
|
}: {
|
|
group: Extract<Group, { type: "tool" }>;
|
|
getDetail: (seq: number) => Promise<string>;
|
|
showWorker?: boolean;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [detail, setDetail] = React.useState<string | null>(null);
|
|
// what we last loaded, keyed by the underlying step seqs. When the tool result
|
|
// arrives after we expanded mid-run (command only), this key changes and the
|
|
// effect below re-fetches — so the output shows up instead of being cached out.
|
|
const loadedKey = React.useRef<string | null>(null);
|
|
const { use, result } = group;
|
|
const toolName = use?.tool || result?.tool || "工具";
|
|
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
|
|
const running = !result;
|
|
const ok = !!result && !result.is_error;
|
|
const statusTone = running
|
|
? "text-muted-foreground"
|
|
: ok
|
|
? "text-emerald-600 dark:text-emerald-400"
|
|
: "text-red-600 dark:text-red-400";
|
|
const rawCmd =
|
|
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? "";
|
|
const cmd = toolInputText(toolName, rawCmd);
|
|
// status only — the full result lives behind the expand (【输出】), not previewed inline
|
|
const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败";
|
|
|
|
// key over the seqs we'd load; changes when the result (or command) arrives.
|
|
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
|
|
React.useEffect(() => {
|
|
if (!open || loadedKey.current === detailKey) return;
|
|
let live = true;
|
|
const segs: { label: string; seq: number }[] = [];
|
|
if (use) segs.push({ label: "命令", seq: use.seq });
|
|
if (result) segs.push({ label: "输出" + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
|
|
Promise.all(
|
|
segs.map((x) =>
|
|
getDetail(x.seq)
|
|
.then((d) => d || "(空)")
|
|
.catch(() => "(加载失败)"),
|
|
),
|
|
).then((parts) => {
|
|
if (!live) return;
|
|
setDetail(
|
|
segs
|
|
.map((x, i) => `【${x.label}】\n${x.label === "命令" ? toolInputText(toolName, parts[i]) : parts[i]}`)
|
|
.join("\n\n"),
|
|
);
|
|
loadedKey.current = detailKey;
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [open, detailKey, use, result, getDetail]);
|
|
|
|
function toggle() {
|
|
setOpen((o) => !o);
|
|
}
|
|
|
|
return (
|
|
<div className="text-xs">
|
|
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
|
|
<span className="mt-0.5 text-muted-foreground">
|
|
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
</span>
|
|
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
|
|
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
|
|
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
|
|
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
|
|
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
|
|
</button>
|
|
{open && (
|
|
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
|
|
{detail ?? "加载中…"}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
|
|
// result fragments into one block) so the conversation reads as messages, not rows.
|
|
function MessageBlock({
|
|
group,
|
|
getDetail,
|
|
showWorker,
|
|
}: {
|
|
group: Extract<Group, { type: "msg" }>;
|
|
getDetail: (seq: number) => Promise<string>;
|
|
showWorker?: boolean;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [detail, setDetail] = React.useState<string | null>(null);
|
|
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
|
|
// after an early expand re-fetch instead of being cached out.
|
|
const loadedKey = React.useRef<string | null>(null);
|
|
const speak = group.steps.filter((s) => s.kind !== "thinking");
|
|
const hasThinking = group.steps.some((s) => s.kind === "thinking");
|
|
const isError = group.steps.some((s) => s.is_error);
|
|
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
|
|
const Icon = isError ? Flag : MessageSquare;
|
|
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
|
|
|
|
const detailKey = group.steps.map((s) => s.seq).join(",");
|
|
React.useEffect(() => {
|
|
if (!open || loadedKey.current === detailKey) return;
|
|
let live = true;
|
|
Promise.all(
|
|
group.steps.map((s) =>
|
|
getDetail(s.seq)
|
|
.then((d) => d || s.summary)
|
|
.catch(() => s.summary),
|
|
),
|
|
).then((parts) => {
|
|
if (!live) return;
|
|
setDetail(group.steps.map((s, i) => `【${kindLabel(s.kind)}】\n${parts[i]}`).join("\n\n"));
|
|
loadedKey.current = detailKey;
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [open, detailKey, group.steps, getDetail]);
|
|
|
|
function toggle() {
|
|
setOpen((o) => !o);
|
|
}
|
|
|
|
return (
|
|
<div className="text-xs">
|
|
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
|
|
<span className="mt-0.5 text-muted-foreground">
|
|
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
</span>
|
|
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
|
|
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
|
|
<span className={"min-w-0 flex-1 truncate " + tone}>
|
|
{body}
|
|
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· 含推理</span>}
|
|
</span>
|
|
</button>
|
|
{open && (
|
|
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
|
|
{detail ?? "加载中…"}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
|
|
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
|
|
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
|
|
// summary is a truncated first line, so the full message is pulled from the detail
|
|
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
|
|
// fmtBytes renders a human file size for attachment chips.
|
|
function fmtBytes(n: number): string {
|
|
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
|
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
|
|
return `${n} B`;
|
|
}
|
|
|
|
type MsgAttachment = { name: string; path: string; size: number };
|
|
|
|
// parseUserBody splits a user turn's body into text + attachments. The backend stores
|
|
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
|
|
// parse defensively and fall back to treating the whole body as text.
|
|
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
|
|
if (body.startsWith("{")) {
|
|
try {
|
|
const p = JSON.parse(body);
|
|
if (p && Array.isArray(p.attachments)) {
|
|
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
|
|
}
|
|
} catch {
|
|
/* plain text that merely starts with "{" */
|
|
}
|
|
}
|
|
return { text: body, attachments: [] };
|
|
}
|
|
|
|
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
|
|
const Icon = intent ? CrosshairIcon : UserIcon;
|
|
const [ref, inView] = useInView();
|
|
// Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll.
|
|
const inline = step.detail && step.detail.length > 0 ? step.detail : null;
|
|
const [full, setFull] = React.useState<string | null>(inline);
|
|
React.useEffect(() => {
|
|
if (inline) return; // already have the body (optimistic echo)
|
|
if (!inView) return; // fetch the full message only when the bubble nears view
|
|
let live = true;
|
|
getDetail(step.seq)
|
|
.then((d) => {
|
|
if (live) setFull(d || step.summary);
|
|
})
|
|
.catch(() => {
|
|
if (live) setFull(step.summary);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [inView, step.seq, getDetail, step.summary, inline]);
|
|
const { text, attachments } = parseUserBody(full ?? step.summary);
|
|
return (
|
|
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
|
|
<div className="flex max-w-[85%] flex-col items-end gap-1.5">
|
|
{attachments.length > 0 && (
|
|
<div className="flex flex-wrap justify-end gap-1.5">
|
|
{attachments.map((a) => (
|
|
<div
|
|
key={a.path}
|
|
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
|
|
title={a.path}
|
|
>
|
|
<PaperclipIcon className="size-3 shrink-0 text-primary" />
|
|
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
|
|
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{text && (
|
|
<div className="whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
|
{text}
|
|
</div>
|
|
)}
|
|
<ActivityTime ts={step.ts} />
|
|
</div>
|
|
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
|
<Icon className="size-3.5 text-primary" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
|
|
// collapsed. The summary is a truncated first line, so the full text is pulled
|
|
// from the detail and shown inline.
|
|
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
|
|
const [ref, inView] = useInView();
|
|
const [full, setFull] = React.useState<string | null>(null);
|
|
React.useEffect(() => {
|
|
if (!inView) return; // fetch the full answer only when it nears view
|
|
let live = true;
|
|
getDetail(step.seq)
|
|
.then((d) => {
|
|
if (live) setFull(d || step.summary);
|
|
})
|
|
.catch(() => {
|
|
if (live) setFull(step.summary);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [inView, step.seq, getDetail, step.summary]);
|
|
return (
|
|
<div ref={ref} className="mb-2 mt-1 flex min-w-0 flex-col gap-1">
|
|
<div
|
|
className={
|
|
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
|
|
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
|
|
}
|
|
>
|
|
{step.is_error ? (
|
|
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
|
|
) : (
|
|
<Markdown text={full ?? step.summary} />
|
|
)}
|
|
</div>
|
|
<ActivityTime ts={step.ts} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ExecView renders an agent execution replay (planner / worker / main agent) in
|
|
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
|
|
// and (for the main agent) the human turns. Worker lane chips show only when the
|
|
// view actually mixes agents.
|
|
function ExecView({
|
|
activity,
|
|
taskId,
|
|
chat,
|
|
fetchDetail,
|
|
}: {
|
|
activity: Activity[];
|
|
taskId?: string;
|
|
chat?: boolean;
|
|
fetchDetail?: (seq: number) => Promise<string>;
|
|
}) {
|
|
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
|
|
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
|
|
// its own (conversation-scoped) fetcher instead.
|
|
const getDetail = React.useCallback(
|
|
(seq: number) =>
|
|
fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""),
|
|
[fetchDetail, taskId],
|
|
);
|
|
return (
|
|
<div className="flex flex-col">
|
|
{groupSteps(activity, !!chat).map((g) =>
|
|
g.type === "round" ? (
|
|
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
|
|
<span className="h-px flex-1 bg-border" />
|
|
{g.label}
|
|
<span className="h-px flex-1 bg-border" />
|
|
</div>
|
|
) : g.type === "user" ? (
|
|
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
|
|
) : g.type === "answer" ? (
|
|
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
|
|
) : g.type === "tool" ? (
|
|
<ToolBlock key={"t" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
|
|
) : g.type === "intercept" ? (
|
|
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
|
|
) : (
|
|
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
|
|
),
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Transcript renders a session's activity as a compact grouped execution replay:
|
|
// human turns, thinking, tool calls (command+result paired), and messages — for
|
|
// the main agent (interactive) and worker/planner (read-only) alike.
|
|
export function Transcript({
|
|
activity,
|
|
live,
|
|
taskId,
|
|
chat,
|
|
fetchDetail,
|
|
}: {
|
|
activity: Activity[];
|
|
live?: boolean;
|
|
taskId?: string;
|
|
chat?: boolean;
|
|
fetchDetail?: (seq: number) => Promise<string>;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} />
|
|
{live && (
|
|
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
|
|
<span className="flex gap-1">
|
|
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
|
|
</span>
|
|
实时流式中…
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|