Files
UHD_Media_Tool/apps/web/app/components/agent/agent-conversation.tsx
T
2026-09-29 01:58:28 +08:00

836 lines
32 KiB
TypeScript

import {
type AgentRunStatus,
type Approval,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
type RiskLevel,
} from "@umt/contracts";
import {
Bot,
Brain,
ChevronDown,
CircleSlash,
Download,
FolderInput,
Layers,
LoaderCircle,
ShieldCheck,
User,
} from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { toast } from "sonner";
import {
type AgentRunView,
type GoalReport,
type InvocationAudit,
isProposeFirst,
isRunActive,
REPORT_STATE,
RUN_STATUS_LABEL,
RUN_STATUS_TONE,
useDecideApproval,
useIsAdminSession,
} from "~/api/agent.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { FileDiff } from "~/components/agentui/file-diff.tsx";
import { Markdown } from "~/components/agentui/markdown.tsx";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
MessageMarker,
MessageTyping,
} from "~/components/agentui/message.tsx";
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.tsx";
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { ToolCallIo } from "./tool-call-card-io.tsx";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/,
// 改造自 github.com/ashish200729/agentui,MIT)。服务端事件形状见 packages/contracts/src/agent-events.ts。
export const BUDGET_LABEL: Record<string, string> = {
tool_calls: "工具调用次数",
duration: "运行时长",
tokens: "token",
cost: "费用",
};
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
const COMPACT_STATUS: Record<string, string> = {
completed: "已完成,业务进度保留",
declined: "未压缩",
aborted: "已中止",
failed: "失败",
};
// ── 能力元数据(scope / 风险 / 类别来自 contracts 的 CAPABILITY_CATALOG) ──
export function capabilityMeta(id: string): CapabilityDescriptor | null {
return (CAPABILITY_CATALOG as Record<string, CapabilityDescriptor>)[id as KnownCapabilityId] ?? null;
}
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const KIND_LABEL: Record<string, string> = {
read: "只读",
preview: "预览",
propose: "提案",
approve: "审批",
execute: "执行",
};
export function RiskBadge({ level }: { level: RiskLevel }) {
const r = RISK[level];
return <StatusBadge tone={r.tone}>{r.label}</StatusBadge>;
}
function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) {
const m = capabilityMeta(id);
return (
<>
{m ? (
<>
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
<StatusBadge>scope · {m.scope}</StatusBadge>
<RiskBadge level={m.riskLevel} />
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
</>
) : (
<StatusBadge>未登记能力</StatusBadge>
)}
{extra}
</>
);
}
function durationText(a: string, b: string | null) {
if (!b) return null;
const ms = new Date(b).getTime() - new Date(a).getTime();
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
}
const TOOL_STATUS: Record<Exclude<ToolCallView["status"], "denied">, ToolResultStatus> = {
running: "running",
succeeded: "success",
failed: "error",
};
/**
* 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden,
* 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。两者在界面上都按"服务端拒绝"醒目展示。
*/
export function authDenial(call: ToolCallView): string | null {
if (call.status !== "failed" || !call.outputSummary) return null;
try {
const e = (JSON.parse(call.outputSummary) as { error?: { code?: string; message?: string } }).error;
if (e && (e.code === "unauthorized" || e.code === "forbidden")) return e.message ?? e.code;
} catch {
const m = /"code":"(unauthorized|forbidden)"(?:,"message":"([^"]*)")?/.exec(call.outputSummary);
if (m) return m[2] ?? m[1] ?? "未授权";
}
return null;
}
/** 被服务端拒绝(白名单拦截或授权校验失败) */
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
export function ToolCallCard({
call,
audit,
}: {
call: ToolCallView;
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
audit?: InvocationAudit;
}) {
const meta = capabilityMeta(call.capabilityId);
const authReason = authDenial(call);
if (call.status === "denied" || authReason) {
return (
<ToolApproval
status="rejected"
tool={call.capabilityId}
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
badges={
<CapabilityBadges
id={call.capabilityId}
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
/>
}
reason={authReason ?? call.reason ?? "未授权"}
explanation={
authReason
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
}
parameters={[
{ id: "call", label: "调用 ID", value: call.toolCallId },
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
]}
/>
);
}
const status = TOOL_STATUS[call.status as Exclude<ToolCallView["status"], "denied">];
return (
<ToolResult
status={status}
tool={call.capabilityId}
title={meta?.title ?? call.capabilityId}
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
badges={
<CapabilityBadges
id={call.capabilityId}
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
/>
}
summary={call.outputSummary ?? undefined}
>
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
{call.outputSummary ? (
<ToolResultOutput
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
tone={call.status === "failed" ? "destructive" : undefined}
>
{call.outputSummary}
</ToolResultOutput>
) : call.status === "running" ? (
<p className="text-meta text-muted-foreground">等待结果…</p>
) : null}
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">调用 ID</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
<dt className="text-muted-foreground">授权依据</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
</dd>
</>
) : null}
</dl>
<ToolCallIo call={call} />
</ToolResult>
);
}
/** 思考块:thinking_delta 累积,默认收起;流式中显示"思考中"与光标 */
function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<div className="w-full min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={id}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 py-0.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Brain aria-hidden className={cn("size-3.5", streaming && "animate-pulse motion-reduce:animate-none")} />
{streaming ? "思考中…" : "思考过程"}
<span className="tabular-nums">· {text.length} 字</span>
<ChevronDown aria-hidden className={cn("size-3", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<p className="mt-1 max-h-64 overflow-auto border-l-2 border-border pl-3 text-caption leading-5 break-words whitespace-pre-wrap text-muted-foreground">
{text}
{streaming ? <StreamingCursor /> : null}
</p>
</Disclosure>
</div>
);
}
function AssistantRow({
children,
animateIn,
avatar = true,
}: {
children: ReactNode;
animateIn?: boolean;
avatar?: boolean;
}) {
return (
<Message from="assistant" animateIn={animateIn}>
<MessageAvatar placeholder={!avatar}>
<Bot />
</MessageAvatar>
<MessageContent>{children}</MessageContent>
</Message>
);
}
function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
const budget = item.reason === "budget";
const aborted = item.reason === "aborted";
return (
<Message from="system" role="alert">
<MessageAvatar>
<CircleSlash />
</MessageAvatar>
<MessageContent>
<MessageBubble variant={budget ? "warning" : aborted ? "outline" : "danger"}>
<MessageBubbleContent className={cn(aborted && "text-muted-foreground")}>
<p className="font-medium">
{budget
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止`
: aborted
? "已停止"
: "运行失败"}
</p>
{item.message ? <p className="mt-0.5 text-caption break-words">{item.message}</p> : null}
{budget ? (
<p className="mt-1 text-meta text-muted-foreground">
已完成的外部动作不会撤销。调大预算后追加消息可继续运行。
</p>
) : null}
</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
);
}
export function UserMessage({
text,
at,
tag,
note,
animateIn,
}: {
text: string;
at?: string;
/** 会话选项标记(如"先看方案再执行") */
tag?: string;
note?: string;
animateIn?: boolean;
}) {
return (
<Message from="user" animateIn={animateIn}>
<MessageAvatar>
<User />
</MessageAvatar>
<MessageContent>
{at || tag ? (
<MessageHeader>
{tag ? <span>{tag}</span> : null}
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
</MessageHeader>
) : null}
<MessageBubble>
<MessageBubbleContent className="whitespace-pre-wrap">{text}</MessageBubbleContent>
</MessageBubble>
{note ? <MessageFooter>{note}</MessageFooter> : null}
</MessageContent>
</Message>
);
}
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
const PLAN_IN_INPUT = new RegExp(`"planId"\\s*:\\s*"(${UUID})"`);
const PLAN_IN_OUTPUT = new RegExp(`"plan"\\s*:\\s*\\{\\s*"id"\\s*:\\s*"(${UUID})"`);
/** 本次运行触达的计划 ID:服务端汇报里的计划 + 下载计划/提交/整理执行的输入输出里出现的计划(摘要可能截断,只取开头能匹配到的) */
export function runPlanIds(items: StreamItem[], report: GoalReport | null): string[] {
const ids = new Set(report?.plans.map((p) => p.planId) ?? []);
for (const it of items) {
if (it.kind !== "tool") continue;
const c = it.call;
if (["download.submit", "organize.execute"].includes(c.capabilityId)) {
const m = c.inputSummary ? PLAN_IN_INPUT.exec(c.inputSummary) : null;
if (m?.[1]) ids.add(m[1]);
}
if (c.capabilityId === "download.plan" && c.outputSummary) {
const m = PLAN_IN_OUTPUT.exec(c.outputSummary);
if (m?.[1]) ids.add(m[1]);
}
}
return [...ids];
}
/** 把工具调用按能力 ID 顺序与审计记录配对(审计没有 toolCallId,按同能力的先后对齐) */
export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | undefined) {
const out = new Map<string, InvocationAudit>();
if (!audit?.length) return out;
const byCap = new Map<string, InvocationAudit[]>();
for (const a of audit) byCap.set(a.capabilityId, [...(byCap.get(a.capabilityId) ?? []), a]);
const used = new Map<string, number>();
for (const c of [...calls].sort((a, b) => a.startedAt.localeCompare(b.startedAt))) {
const list = byCap.get(c.capabilityId);
const i = used.get(c.capabilityId) ?? 0;
if (list?.[i]) out.set(c.toolCallId, list[i]);
used.set(c.capabilityId, i + 1);
}
return out;
}
/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */
export function AgentConversation({
run,
live,
userMessages = [],
audit,
after,
}: {
run: AgentRunView;
live: AgentStreamState;
userMessages?: { at: string; text: string; pending?: boolean }[];
audit?: InvocationAudit[];
/** 流末尾追加的内容(审批卡片、最终汇报) */
after?: ReactNode;
}) {
const active = isRunActive(run.status) && !live.finished;
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
const stepTitle = (id: string | null) => (id ? (live.plan?.steps.find((s) => s.id === id)?.title ?? null) : null);
// 首次载入(回放)的条目不播入场动画,之后新到的才播
const baseline = useRef<number | null>(null);
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
const fresh = (i: number) => baseline.current !== null && i >= baseline.current;
const calls = live.items
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
.map((i) => i.call);
const auditByCall = pairAudit(calls, audit);
const lastIndex = live.items.length - 1;
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
type Row = { key: string; at: string; node: ReactNode };
const rows: Row[] = live.items.map((it, idx) => {
const streaming = active && idx === lastIndex;
const anim = fresh(idx);
let node: ReactNode = null;
switch (it.kind) {
case "text":
node = (
<AssistantRow animateIn={anim}>
<StreamingResponse
status={streaming ? "streaming" : "complete"}
copyText={it.text}
announce={false}
showActions={!active && idx === lastText}
>
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{streaming ? it.text : <Markdown text={it.text} />}
</StreamingResponse>
</AssistantRow>
);
break;
case "thinking":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<ThinkingBlock text={it.text} streaming={streaming} />
</AssistantRow>
);
break;
case "tool":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
</AssistantRow>
);
break;
case "compaction":
node = (
<MessageMarker>
<Layers aria-hidden className="size-3.5 shrink-0" />
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
</MessageMarker>
);
break;
case "options":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
</AssistantRow>
);
break;
case "approval":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
</AssistantRow>
);
break;
case "end":
node = it.ok ? null : <EndNotice item={it} />;
break;
}
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
});
for (const m of userMessages)
rows.push({
key: `u${m.at}`,
at: m.at,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
});
rows.sort((a, b) => a.at.localeCompare(b.at));
return (
<div className="space-y-4 px-1 py-2">
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
<AssistantRow avatar={false}>
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
</span>
</AssistantRow>
) : null}
{after}
</div>
);
}
// ── 预算用量条 ──
function Meter({
label,
used,
max,
format,
exceeded,
}: {
label: string;
used: number | undefined;
max?: number;
format: (n: number) => string;
exceeded?: boolean;
}) {
const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null;
const over = exceeded || (pct !== null && pct >= 100);
const near = !over && pct !== null && pct >= 80;
return (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2 text-meta">
<span className={cn("text-muted-foreground", over && "font-medium text-destructive")}>
{label}
{over ? " · 已超限" : near ? " · 接近上限" : ""}
</span>
<span className="truncate text-foreground tabular-nums">
{used === undefined ? "未报告" : format(used)}
{max ? <span className="text-muted-foreground"> / {format(max)}</span> : null}
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
{pct !== null ? (
<div
className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
) : null}
</div>
</div>
);
}
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
const u = { ...run.usage, ...(live.usage ?? {}) };
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const end = [...live.items].reverse().find((i) => i.kind === "end");
const exceeded = end?.kind === "end" && end.reason === "budget" ? end.budget : null;
const durationMs =
u.durationMs ??
(run.startedAt
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined);
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Meter
label="工具调用"
used={toolCalls}
max={run.budget.maxToolCalls}
format={(n) => String(n)}
exceeded={exceeded === "tool_calls"}
/>
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
exceeded={exceeded === "tokens"}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n}s`}
exceeded={exceeded === "duration"}
/>
<Meter
label="费用(估算)"
used={u.costUsd}
max={run.budget.maxCostUsd}
format={(n) => `$${n.toFixed(3)}`}
exceeded={exceeded === "cost"}
/>
</div>
);
}
// ── 审批卡片 ──
const PLAN_CAPABILITY: Record<string, KnownCapabilityId> = {
acquisition_plan: "download.submit",
organize_plan: "organize.execute",
policy_revision: "policy.apply",
};
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
organize_plan: "整理计划",
policy_revision: "策略版本",
};
export function ApprovalItem({ approval: a }: { approval: Approval }) {
const admin = useIsAdminSession();
const decide = useDecideApproval();
const cap = PLAN_CAPABILITY[a.planType];
const risk = cap ? capabilityMeta(cap)?.riskLevel : undefined;
const expired = a.status === "pending" && a.expiresAt !== null && new Date(a.expiresAt).getTime() < Date.now();
const status: ApprovalCardStatus = decide.isPending ? "submitting" : expired ? "expired" : a.status;
const act = (s: "approved" | "rejected") =>
decide.mutate(
{ id: a.id, status: s, payloadHash: a.payloadHash },
{
onSuccess: () => toast.success(s === "approved" ? "已批准" : "已拒绝"),
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<ApprovalCard
status={status}
title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`}
description={a.reason ?? "计划变化后,这份审批自动失效。"}
badges={
<>
{risk ? <RiskBadge level={risk} /> : null}
{cap ? <StatusBadge>执行能力 · {cap}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={
a.decidedAt
? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}`
: undefined
}
>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">计划</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">
{a.planType} · {a.planId}
</dd>
<dt className="text-muted-foreground">payload hash</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{a.payloadHash}</dd>
<dt className="text-muted-foreground">版本</dt>
<dd className="text-foreground/85 tabular-nums">r{a.expectedRevision}</dd>
<dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && "text-destructive")}>
{a.expiresAt ? formatDateTime(a.expiresAt) : "不过期"}
{expired ? "(已过期)" : ""}
</dd>
</dl>
</ApprovalCard>
);
}
// ── 最终汇报:模型说的 vs 系统核实的 ──
const PLAN_STATUS_TONE = (s: string): StatusTone =>
["succeeded", "completed", "submitted", "approved"].includes(s)
? "success"
: ["failed", "rejected", "error", "cancelled"].includes(s)
? "destructive"
: "warning";
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
export function FinalReport({
run,
report,
live,
}: {
run: AgentRunView;
report: GoalReport | null;
live: AgentStreamState;
}) {
const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool");
const tail = live.items
.slice(lastTool + 1)
.filter((i): i is Extract<StreamItem, { kind: "text" }> => i.kind === "text")
.map((i) => i.text)
.join("");
const said = report?.modelText ?? (tail || run.summary || "");
const calls = live.items
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
.map((i) => i.call);
const denied = calls.filter(isDenied).length;
const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s && !isDenied(c)).length;
const state = report ? REPORT_STATE[report.state] : null;
const sources: CitationItem[] = report
? [
...report.downloads.map((d) => ({
id: `d${d.downloadTaskId}`,
icon: <Download />,
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
domain: "下载任务",
meta: <StatusBadge tone={PLAN_STATUS_TONE(d.status)}>{d.status}</StatusBadge>,
to: `/console/downloads/${d.downloadTaskId}`,
})),
...report.organization.map((o) => ({
id: `o${o.recordId}`,
icon: <FolderInput />,
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
domain: "整理记录",
meta: <StatusBadge tone={PLAN_STATUS_TONE(o.status)}>{o.status}</StatusBadge>,
to: `/console/organize/${o.recordId}`,
})),
]
: [];
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
return (
<section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-xl border border-dashed border-border p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<Bot aria-hidden className="size-3.5" />
模型说的(未经核查)
</p>
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{report ? (
<ShieldCheck aria-hidden className="size-3.5" />
) : (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
)}
系统核实的结果
</p>
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
{denied ? <StatusBadge tone="destructive">被拒绝 {denied}</StatusBadge> : null}
</div>
{report ? (
<>
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
{(
[
["已提交", report.submitted],
["已入库", report.imported],
["进行中", report.inProgress],
["失败", report.failed],
] as const
).map(([k, v]) => (
<div key={k} className="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
<dt className="text-meta text-muted-foreground">{k}</dt>
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
</div>
))}
</dl>
{report.plans.length ? (
<ul className="mt-2 space-y-1 text-meta">
{report.plans.map((p) => (
<li key={p.planId} className="flex min-w-0 items-center gap-2">
<span className="text-muted-foreground">下载计划</span>
<code className="min-w-0 truncate font-mono text-foreground/80">{p.planId.slice(0, 8)}</code>
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
</li>
))}
</ul>
) : null}
{sources.length ? <CitationList citations={sources} className="mt-2" /> : null}
{imported.length ? (
<FileDiff
className="mt-2"
file="入库目标"
defaultOpen={false}
lines={imported.map((o) => ({
id: o.recordId,
type: o.status === "succeeded" ? ("added" as const) : ("context" as const),
content: o.targetPath,
note: o.status,
}))}
/>
) : null}
<p className="mt-2 text-meta text-muted-foreground">
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
)。「已提交」指已进入下载器,整理成功才算入库。
</p>
</>
) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
)}
{run.error ? (
<p className="mt-2 text-caption break-words text-destructive">
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
</p>
) : null}
</div>
</section>
);
}
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
return (
<StatusBadge tone={RUN_STATUS_TONE[status]}>
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin motion-reduce:animate-none" /> : null}
{RUN_STATUS_LABEL[status]}
</StatusBadge>
);
}
/** 滚动时保持最新一次状态播报(给读屏用的礼貌播报,流式正文本身不逐字播报) */
export function RunAnnouncer({ status, finished }: { status: AgentRunStatus; finished: boolean }) {
const [msg, setMsg] = useState("");
const prev = useRef(status);
useEffect(() => {
if (prev.current !== status) setMsg(`运行${RUN_STATUS_LABEL[status]}`);
prev.current = status;
}, [status]);
useEffect(() => {
if (finished) setMsg("最终汇报已生成");
}, [finished]);
return (
<span className="sr-only" role="status" aria-live="polite">
{msg}
</span>
);
}