Files
UHD_Media_Tool/apps/web/app/components/agent/agent-conversation.tsx
T
rootandClaude Fable 5.1 9ef2e8554c Merge branch 'fix8'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-30 00:10:57 +08:00

786 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type AgentRunStatus,
type Approval,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
} from "@umt/contracts";
import {
Ban,
Brain,
Check,
ChevronDown,
ChevronRight,
CircleAlert,
CircleSlash,
Layers,
LoaderCircle,
RotateCcw,
} from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type AgentRunView,
agentMockEnabled,
type GoalReport,
type InvocationAudit,
isProposeFirst,
isRunActive,
RUN_STATUS_LABEL,
RUN_STATUS_TONE,
useApproveAndRetry,
useDecideApproval,
useIsAdminSession,
} from "~/api/agent.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { Markdown } from "~/components/agentui/markdown.tsx";
import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx";
import { StreamingResponse } from "~/components/agentui/streaming-response.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { splitContextGoal } from "./agent-store.ts";
import {
type ActivityItem,
activitySpan,
activitySummary,
authDenial,
buildConversation,
capabilityTitle,
deniedReason,
durationLabel,
isDenied,
runningText,
toolSummary,
} from "./conversation-model.ts";
import { collectNames, humanizeReport } from "./report-humanize.ts";
import { ToolCallIo } from "./tool-call-card-io.tsx";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
// 智能体会话的展示层(AGUI-3):用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。
// 排版规则在 conversation-model.ts(纯函数);组件改造自 agentui(components/agentui/,MIT)。
// 服务端事件形状见 packages/contracts/src/agent-events.ts;旧会话的事件照样按这套规则渲染。
export { authDenial, isDenied, toolSummary };
export const BUDGET_LABEL: Record<string, string> = {
tool_calls: "工具调用次数",
duration: "运行时长",
tokens: "token",
cost: "费用",
};
const COMPACT_STATUS: Record<string, string> = {
completed: "已完成",
declined: "没有压缩",
aborted: "已中止",
failed: "失败",
};
export function capabilityMeta(id: string): CapabilityDescriptor | null {
return (CAPABILITY_CATALOG as Record<string, CapabilityDescriptor>)[id as KnownCapabilityId] ?? null;
}
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`;
}
// ── 用户消息 ──
/** 上下文标签:只显示对象名称(「沙丘:第二部」),ID 留在目标前缀里给模型看 */
export function contextLabel(context: string | null): string | null {
if (!context) return null;
const first = context.split(" · ")[0] ?? "";
// 前缀里第一段是界面给的名称(「作品:沙丘2」);没有名称时只剩 key=ID,不上屏
if (!first || first.includes("=")) return null;
return first.replace(/^(作品|资源|下载|整理记录|站点|任务|引擎任务)[::]\s*/, "");
}
export function UserMessage({
text,
note,
tag,
animateIn,
}: {
text: string;
note?: string;
/** 单次指令标记(如「先给方案」) */
tag?: string;
animateIn?: boolean;
}) {
const { context, text: body } = splitContextGoal(text);
const label = contextLabel(context);
return (
<div
className={cn(
"flex w-full min-w-0 flex-col items-end gap-1",
animateIn && "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none",
)}
>
{label || tag ? (
<div className="flex max-w-[85%] flex-wrap justify-end gap-1">
{tag ? (
<span className="rounded-full border border-border/70 px-2 py-0.5 text-label text-muted-foreground">
{tag}
</span>
) : null}
{label ? (
<span className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 text-label text-muted-foreground">
{label}
</span>
) : null}
</div>
) : null}
<div
data-slot="user-message"
className="max-w-[85%] rounded-2xl border border-border/60 bg-foreground/[0.05] px-3.5 py-2 text-sm leading-6 break-words whitespace-pre-wrap text-foreground"
>
{body}
</div>
{note ? <p className="px-1 text-label text-muted-foreground">{note}</p> : null}
</div>
);
}
// ── 进度行 ──
function ThinkingRow({ text }: { text: string }) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<li className="min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={id}
className="flex min-h-7 w-full cursor-pointer items-center gap-2 rounded-md px-1 text-left text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Brain aria-hidden className="size-3.5 shrink-0" />
思考 · {text.length} 字
<ChevronDown aria-hidden className={cn("size-3", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<p className="mt-1 mb-1 ml-6 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}
</p>
</Disclosure>
</li>
);
}
function StepIcon({ call }: { call: ToolCallView }) {
if (call.status === "running")
return <LoaderCircle aria-hidden className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />;
if (isDenied(call)) return <Ban aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />;
if (call.status === "failed") return <CircleAlert aria-hidden className="size-3.5 shrink-0 text-destructive" />;
return <Check aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />;
}
/** 展开后的一步:「搜资源 → 3 条」;点开看技术信息(能力 ID、调用 ID、幂等键、输入输出) */
function ToolRow({ call, audit }: { call: ToolCallView; audit?: InvocationAudit }) {
const id = useId();
const [open, setOpen] = useState(false);
const meta = capabilityMeta(call.capabilityId);
const denied = isDenied(call);
const summary = denied ? deniedReason(call).text : toolSummary(call);
return (
<li className="min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={id}
className="flex min-h-7 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 text-left text-meta outline-none hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<StepIcon call={call} />
<span className={cn("shrink-0 text-foreground/85", denied && "text-muted-foreground line-through")}>
{meta?.title ?? capabilityTitle(call.capabilityId)}
</span>
{summary ? (
<span
className={cn(
"min-w-0 flex-1 truncate text-muted-foreground",
call.status === "failed" && !denied && "text-destructive",
)}
>
→ {summary}
</span>
) : (
<span className="flex-1" />
)}
<span className="shrink-0 text-muted-foreground tabular-nums">
{durationText(call.startedAt, call.endedAt) ?? ""}
</span>
</button>
<Disclosure id={id} open={open}>
<div className="mt-1 mb-2 ml-6 grid gap-2 text-meta">
<TechInfo
title="技术信息"
items={[
{ label: "能力", value: call.capabilityId },
{ label: "调用 ID", value: call.toolCallId },
{ label: "幂等键", value: audit?.idempotencyKey ?? null },
{
label: "授权依据",
value: audit?.grantId
? `持续授权 ${audit.grantId.slice(0, 8)}`
: audit?.approvalId
? `单次审批 ${audit.approvalId.slice(0, 8)}`
: null,
},
{ label: "拒绝原因", value: denied ? (authDenial(call) ?? call.reason) : null, wide: true },
{ label: "恢复补记", value: call.recovery ? "是" : null },
]}
>
<ToolCallIo call={call} />
</TechInfo>
</div>
</Disclosure>
</li>
);
}
/**
* 一段进度的一行摘要:「查了 1 个站点和媒体库 · 1 项没做 · 41 秒 ›」;运行中写「正在检测站点…」。
* 展开后每步一行;思考与工具之间的话也在里面。
*/
export function StepSummary({
activity,
running,
auditByCall,
}: {
activity: ActivityItem[];
running: boolean;
auditByCall?: Map<string, InvocationAudit>;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : []));
const current = running ? calls.find((c) => c.status === "running") : undefined;
const done = activitySummary(calls.filter((c) => c.status !== "running"));
const denied = calls.filter(isDenied).length;
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
const span = activitySpan(activity);
const thinking = activity.some((a) => a.kind === "thinking");
const head = current
? `${done ? `${done},` : ""}${runningText(current)}…`
: done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明");
return (
<div className="w-full min-w-0" data-slot="step-summary">
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
className="-mx-1 flex min-h-8 w-[calc(100%+0.5rem)] min-w-0 cursor-pointer items-center gap-2 rounded-lg px-1 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{running ? (
<LoaderCircle aria-hidden className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
) : (
<Layers aria-hidden className="size-3.5 shrink-0" />
)}
<span className="min-w-0 truncate">{head}</span>
{denied ? <span className="shrink-0">· {denied} 项没做</span> : null}
{failed ? <span className="shrink-0 text-destructive">· {failed} 项失败</span> : null}
{span !== null && !running ? <span className="shrink-0 tabular-nums">· {durationLabel(span)}</span> : null}
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-90")} />
</button>
<Disclosure id={id} open={open}>
<ul className="mt-0.5 mb-1 flex flex-col border-l border-border/70 pl-2">
{activity.map((a) =>
a.kind === "tool" ? (
<ToolRow key={a.key} call={a.call} audit={auditByCall?.get(a.call.toolCallId)} />
) : a.kind === "thinking" ? (
<ThinkingRow key={a.key} text={a.text} />
) : (
<li
key={a.key}
className="line-clamp-3 px-1 py-1 text-meta break-words whitespace-pre-wrap text-muted-foreground"
>
{a.text.trim()}
</li>
),
)}
</ul>
</Disclosure>
</div>
);
}
// ── 没做的事 ──
/** 回合末尾一行:「没做:检测站点,需要你批准 [批准并重试]」;同一能力只写一次 */
export function DeniedLines({ runId, calls }: { runId: string; calls: ToolCallView[] }) {
const admin = useIsAdminSession() || agentMockEnabled();
const retry = useApproveAndRetry();
const [sent, setSent] = useState<Set<string>>(new Set());
const rows = [...new Map(calls.map((c) => [c.capabilityId, c] as const)).values()];
if (!rows.length) return null;
return (
<ul aria-label="没做的操作" className="flex w-full min-w-0 flex-col gap-1">
{rows.map((c) => {
const title = capabilityTitle(c.capabilityId);
const r = deniedReason(c);
const done = sent.has(c.capabilityId);
return (
<li key={c.toolCallId} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-caption">
<Ban aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 text-foreground/85">
没做:{title},{r.text}
</span>
{r.approvable && admin ? (
<Button
size="xs"
variant="outline"
disabled={retry.isPending || done}
onClick={() =>
retry.mutate(
{ runId, capabilityId: c.capabilityId, title },
{
onSuccess: () => {
setSent((s) => new Set(s).add(c.capabilityId));
toast.success("已批准,智能体重试这一步");
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<RotateCcw />
{done ? "已批准" : "批准并重试"}
</Button>
) : null}
</li>
);
})}
</ul>
);
}
// ── 结束 / 压缩提示 ──
function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
const budget = item.reason === "budget";
const aborted = item.reason === "aborted";
return (
<p
role="alert"
className={cn(
"flex w-full min-w-0 items-start gap-2 text-caption",
budget ? "text-warning" : aborted ? "text-muted-foreground" : "text-destructive",
)}
>
<CircleSlash aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{budget
? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的动作不会撤销;调大预算后发消息可以继续。`
: aborted
? "已停止。"
: `运行失败${item.message ? `:${humanizeReason(item.message) ?? item.message}` : ""}`}
</span>
</p>
);
}
// ── 审计配对与计划 ID ──
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,
knownNames,
}: {
run: AgentRunView;
live: AgentStreamState;
userMessages?: { at: string; text: string; pending?: boolean }[];
audit?: InvocationAudit[];
/** 流末尾追加的内容(其它审批卡、实际结果行) */
after?: ReactNode;
/** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */
knownNames?: [string, string][];
}) {
const active = isRunActive(run.status) && !live.finished;
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
// 首次载入(回放)的条目不播入场动画,之后新到的才播
const baseline = useRef<number | null>(null);
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
const freshKeys = new Set(
baseline.current === null ? [] : live.items.slice(baseline.current).map((i) => i.key as string),
);
const calls = live.items.flatMap((i) => (i.kind === "tool" ? [i.call] : []));
const auditByCall = pairAudit(calls, audit);
const lastKey = live.items.at(-1)?.key;
const tail = live.items.at(-1);
const blocks = buildConversation(live.items, userMessages);
const names = collectNames(live.items, knownNames);
const anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none";
const lastBlock = blocks.at(-1);
const toolRunning = tail?.kind === "tool" && tail.call.status === "running";
// 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…"
const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined;
const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval");
const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting;
const typing = showTyping ? (
<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>
) : null;
return (
<div className="flex flex-col gap-5 px-1 py-2">
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
{blocks.map((b) =>
b.kind === "user" ? (
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
) : (
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
{b.parts.map((p) => {
const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key);
switch (p.kind) {
case "segment": {
const streamingText = active && p.body.at(-1)?.key === lastKey;
const running = active && p.activity.some((a) => a.kind === "tool" && a.call.status === "running");
const thinkingNow = active && p.activity.at(-1)?.key === lastKey && tail?.kind === "thinking";
return (
<div key={p.key} className="flex w-full min-w-0 flex-col gap-1.5">
{p.activity.length ? (
<StepSummary activity={p.activity} running={running || thinkingNow} auditByCall={auditByCall} />
) : null}
{p.body.map((t) => {
const streaming = streamingText && t.key === lastKey;
// 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
const human = streaming ? null : humanizeReport(t.text, names);
return (
<div key={t.key} className={cn("w-full min-w-0", freshKeys.has(t.key) && anim)}>
<StreamingResponse
className="max-w-[72ch]"
status={streaming ? "streaming" : "complete"}
copyText={human?.text ?? t.text}
announce={false}
showActions={false}
>
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{human ? <Markdown text={human.text} /> : t.text}
</StreamingResponse>
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
</div>
);
})}
</div>
);
}
case "options":
return (
<div key={p.key} className={cn("w-full min-w-0", fresh && anim)}>
<OptionsCard runId={run.id} view={p.view} waiting={waiting} />
</div>
);
case "approval":
return (
<div key={p.key} className={cn("w-full min-w-0", fresh && anim)}>
<InlineApproval view={p.view} />
</div>
);
case "compaction":
return (
<MessageMarker key={p.key}>
<Layers aria-hidden className="size-3.5 shrink-0" />
整理上下文 ·{" "}
{p.item.done ? (COMPACT_STATUS[p.item.status ?? "completed"] ?? p.item.status) : "进行中…"}
</MessageMarker>
);
case "end":
return p.item.ok ? null : <EndNotice key={p.key} item={p.item} />;
default:
return null;
}
})}
{b.denied.length ? <DeniedLines runId={run.id} calls={b.denied} /> : null}
{typing && b === lastBlock ? typing : null}
</section>
),
)}
{typing && lastBlock?.kind !== "turn" ? <div>{typing}</div> : null}
{after}
</div>
);
}
/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
return (
<TechInfo
title="正文原文"
className="mt-1"
items={[
{
label: "换掉的标识",
value: (
<ul className="space-y-0.5">
{uniq.map((r) => (
<li key={r.from}>
{r.from} → {r.to || "(省略)"}
</li>
))}
</ul>
),
wide: true,
},
]}
/>
);
}
// ── 实际结果 ──
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
export function resultText(report: GoalReport | null): string {
if (!report) return "汇总中…";
const r = report;
switch (r.state) {
case "no_actions":
return "没有提交下载或整理";
case "waiting":
return r.imported
? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行`
: `${r.submitted || r.inProgress} 个下载已提交,还没入库`;
case "completed":
return `已入库 ${r.imported} 个`;
case "partial":
return [
r.imported ? `已入库 ${r.imported} 个` : "",
r.inProgress ? `${r.inProgress} 个还在进行` : "",
r.failed ? `${r.failed} 个失败` : "",
]
.filter(Boolean)
.join(",");
case "failed":
return r.failed ? `${r.failed} 个没有成功` : "没有达成目标";
}
}
/**
* 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。
* 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。
*/
export function ResultLine({
run,
report,
live,
subagents = 0,
}: {
run: AgentRunView;
report: GoalReport | null;
live: AgentStreamState;
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
subagents?: number;
}) {
const u = { ...run.usage, ...(live.usage ?? {}) };
const durationMs =
u.durationMs ??
(run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null);
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const links = report
? [
...report.downloads.map((d) => ({
id: d.downloadTaskId,
label: d.name ?? "下载任务",
to: `/console/downloads/${d.downloadTaskId}`,
})),
...report.organization.map((o) => ({
id: o.recordId,
label: o.targetPath?.split("/").pop() || "整理记录",
to: `/console/organize/${o.recordId}`,
})),
]
: [];
const bad = report?.state === "failed" || report?.state === "partial";
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
return (
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
<span className={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
</p>
{links.length ? (
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-0.5 text-meta">
{links.slice(0, 4).map((l) => (
<li key={l.id} className="min-w-0 max-w-full">
<Link to={l.to} className="block truncate text-foreground/85 underline-offset-2 hover:underline">
{l.label}
</Link>
</li>
))}
{links.length > 4 ? <li className="text-muted-foreground">等 {links.length} 项</li> : null}
</ul>
) : null}
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
<TechInfo
items={[
{
label: "说明",
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
wide: true,
mono: false,
},
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
{
label: "token",
value:
u.totalTokens === undefined
? null
: `${formatTokens(u.totalTokens)}${run.budget.maxTokens ? ` / ${formatTokens(run.budget.maxTokens)}` : ""}`,
},
{
label: "工具调用",
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
},
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
{ label: "汇总", value: report?.summary ?? null, wide: true, mono: false },
{ label: "会话 ID", value: run.id, wide: true },
{ label: "原始错误", value: run.error ? JSON.stringify(run.error) : null, wide: true },
]}
/>
</section>
);
}
// ── 本次运行计划上的其它审批(不在会话流里的) ──
const PLAN_CAPABILITY: Record<string, KnownCapabilityId> = {
acquisition_plan: "download.submit",
organize_plan: "organize.execute",
policy_revision: "policy.apply",
};
export function ApprovalItem({ approval: a }: { approval: Approval }) {
const admin = useIsAdminSession();
const decide = useDecideApproval();
const cap = PLAN_CAPABILITY[a.planType];
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={`批准「${cap ? capabilityTitle(cap) : a.planType}」?`}
description={a.reason ?? undefined}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={
a.decidedAt
? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}`
: undefined
}
>
<TechInfo
items={[
{ label: "计划", value: `${a.planType} · ${a.planId}`, wide: true },
{ label: "payload hash", value: a.payloadHash, wide: true },
{ label: "版本", value: `r${a.expectedRevision}` },
{ label: "到期", value: a.expiresAt ? formatDateTime(a.expiresAt) : "不过期" },
]}
/>
</ApprovalCard>
);
}
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>
);
}