- 「未经核查」只在最终汇报左栏标题保留一处,删「仅供参考」与数据来源说明; 右栏改叫「实际结果」,去掉「运行已完成」「工具成功 N」徽章,计数只列非零项, 状态值用中文标签,不再显示计划 / 记录短 ID - 工具卡片只标会改数据的动作和中高风险;能力 ID、scope、幂等、耗时、调用 ID、 schema 入口收进技术信息;拒绝说明改成一句「要你批准」 - 用量只显示费用与时长,工具调用数、token、模型、会话 ID 进技术信息(超限时照常显示) - 会话标题 shortGoalTitle:去上下文前缀、UUID、JSON 参数,能力 ID 换中文名,取第一句,40 字截断 - 入口去口号:删「交代目标,边做边汇报」与抽屉里的重复快捷键说明 - 对话组件结构不动 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1138 lines
43 KiB
TypeScript
1138 lines
43 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 { DOWNLOAD_STATUS_LABEL, PLAN_STATUS_LABEL } from "~/api/downloads.ts";
|
||
import { RECORD_STATUS_LABEL } from "~/api/records.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 { 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 { 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 (
|
||
<>
|
||
{/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */}
|
||
{m ? (
|
||
<>
|
||
{m.kind !== "read" ? <StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge> : null}
|
||
{m.riskLevel !== "low" ? <RiskBadge level={m.riskLevel} /> : null}
|
||
</>
|
||
) : null}
|
||
{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;
|
||
|
||
/**
|
||
* 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段;
|
||
* 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。
|
||
*/
|
||
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
|
||
const raw = call.outputSummary?.trim();
|
||
if (!raw) return call.status === "running" ? "等待结果…" : null;
|
||
const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t);
|
||
let v: unknown;
|
||
try {
|
||
v = JSON.parse(raw);
|
||
} catch {
|
||
return clip(raw.replace(/\s+/g, " "));
|
||
}
|
||
if (Array.isArray(v)) return `${v.length} 条`;
|
||
if (v && typeof v === "object") {
|
||
const o = v as Record<string, unknown>;
|
||
const err = o.error as { message?: unknown; code?: unknown } | undefined;
|
||
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
|
||
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
|
||
for (const k of ["items", "results", "releases", "candidates"])
|
||
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`;
|
||
const simple = Object.entries(o)
|
||
.filter(([, x]) => ["string", "number", "boolean"].includes(typeof x))
|
||
.slice(0, 3)
|
||
.map(([k, x]) => `${k}: ${String(x)}`);
|
||
if (simple.length) return clip(simple.join(" · "));
|
||
return `${Object.keys(o).length} 个字段`;
|
||
}
|
||
return clip(String(v));
|
||
}
|
||
|
||
/** 工具调用卡片:脱敏输入/输出折叠、耗时、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 ?? "这个操作"}`}
|
||
badges={
|
||
<CapabilityBadges
|
||
id={call.capabilityId}
|
||
extra={<StatusBadge tone="destructive">{authReason ? "需要你批准" : "本次没有开放"}</StatusBadge>}
|
||
/>
|
||
}
|
||
reason={authReason ?? call.reason ?? "未授权"}
|
||
explanation={
|
||
authReason
|
||
? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。"
|
||
: "这次会话没有开放这个操作,或已用完调用次数。"
|
||
}
|
||
parameters={[
|
||
{ id: "cap", label: "能力", value: call.capabilityId },
|
||
{ 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={meta ? null : call.capabilityId}
|
||
title={meta?.title ?? call.capabilityId}
|
||
badges={
|
||
<CapabilityBadges
|
||
id={call.capabilityId}
|
||
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
|
||
/>
|
||
}
|
||
summary={toolSummary(call) ?? 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}
|
||
<TechInfo>
|
||
<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/80">
|
||
{call.capabilityId}
|
||
{meta
|
||
? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}`
|
||
: ""}
|
||
</dd>
|
||
{durationText(call.startedAt, call.endedAt) ? (
|
||
<>
|
||
<dt className="text-muted-foreground">耗时</dt>
|
||
<dd className="min-w-0 text-foreground/80">{durationText(call.startedAt, call.endedAt)}</dd>
|
||
</>
|
||
) : null}
|
||
<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} />
|
||
</TechInfo>
|
||
</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;
|
||
}
|
||
|
||
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头;
|
||
// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ──
|
||
|
||
export type ConvEntry = {
|
||
key: string;
|
||
at: string;
|
||
role: "user" | "assistant" | "system";
|
||
/** 可并入"活动"折叠(已结束的思考 / 工具调用) */
|
||
foldable: boolean;
|
||
/** 折叠摘要里的名字(能力名 / "思考") */
|
||
label?: string;
|
||
failed?: boolean;
|
||
node: ReactNode;
|
||
};
|
||
export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] };
|
||
export type ConvBlock =
|
||
| { kind: "single"; entry: ConvEntry }
|
||
| { kind: "turn"; key: string; at: string; parts: ConvPart[] };
|
||
|
||
function foldRuns(parts: ConvPart[], min: number): ConvPart[] {
|
||
const out: ConvPart[] = [];
|
||
let run: ConvEntry[] = [];
|
||
const flush = () => {
|
||
if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run });
|
||
else for (const e of run) out.push({ kind: "item", entry: e });
|
||
run = [];
|
||
};
|
||
for (const p of parts) {
|
||
if (p.kind === "item" && p.entry.foldable) run.push(p.entry);
|
||
else {
|
||
flush();
|
||
out.push(p);
|
||
}
|
||
}
|
||
flush();
|
||
return out;
|
||
}
|
||
|
||
/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */
|
||
export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] {
|
||
const out: ConvBlock[] = [];
|
||
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
|
||
for (const e of entries) {
|
||
if (e.role !== "assistant") {
|
||
turn = null;
|
||
out.push({ kind: "single", entry: e });
|
||
continue;
|
||
}
|
||
if (!turn) {
|
||
turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] };
|
||
out.push(turn);
|
||
}
|
||
turn.parts.push({ kind: "item", entry: e });
|
||
}
|
||
for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold);
|
||
return out;
|
||
}
|
||
|
||
function clockText(iso: string) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
const sameDay = new Date().toDateString() === d.toDateString();
|
||
return sameDay
|
||
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
|
||
: formatDateTime(iso);
|
||
}
|
||
|
||
/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */
|
||
function ActivityFold({ entries }: { entries: ConvEntry[] }) {
|
||
const id = useId();
|
||
const [open, setOpen] = useState(false);
|
||
const tools = entries.filter((e) => e.label !== "思考").length;
|
||
const thoughts = entries.length - tools;
|
||
const failed = entries.filter((e) => e.failed).length;
|
||
const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))];
|
||
return (
|
||
<div className="w-full min-w-0 rounded-xl border border-border/60 bg-foreground/[0.015]">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||
<span className="shrink-0 text-foreground/85 tabular-nums">
|
||
{tools ? `${tools} 次工具调用` : ""}
|
||
{tools && thoughts ? "," : ""}
|
||
{thoughts ? `思考 ${thoughts} 段` : ""}
|
||
</span>
|
||
{failed ? <span className="shrink-0 text-destructive tabular-nums">失败 {failed}</span> : null}
|
||
<span className="min-w-0 flex-1 truncate">{names.slice(0, 4).join(" → ")}</span>
|
||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||
</button>
|
||
<Disclosure id={id} open={open}>
|
||
<div className="flex flex-col gap-2 border-t border-border/50 p-2">
|
||
{entries.map((e) => (
|
||
<div key={e.key}>{e.node}</div>
|
||
))}
|
||
</div>
|
||
</Disclosure>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */
|
||
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");
|
||
|
||
const entries: ConvEntry[] = [];
|
||
live.items.forEach((it, idx) => {
|
||
const streaming = active && idx === lastIndex;
|
||
const anim = fresh(idx);
|
||
const wrap = (node: ReactNode) =>
|
||
anim ? (
|
||
<div className="animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none">
|
||
{node}
|
||
</div>
|
||
) : (
|
||
node
|
||
);
|
||
switch (it.kind) {
|
||
case "text":
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.at,
|
||
role: "assistant",
|
||
foldable: false,
|
||
node: wrap(
|
||
<StreamingResponse
|
||
className="max-w-[72ch]"
|
||
status={streaming ? "streaming" : "complete"}
|
||
copyText={it.text}
|
||
announce={false}
|
||
showActions={!active && idx === lastText}
|
||
>
|
||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||
{streaming ? it.text : <Markdown text={it.text} />}
|
||
</StreamingResponse>,
|
||
),
|
||
});
|
||
break;
|
||
case "thinking":
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.at,
|
||
role: "assistant",
|
||
foldable: !streaming,
|
||
label: "思考",
|
||
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
|
||
});
|
||
break;
|
||
case "tool": {
|
||
const denied = isDenied(it.call);
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.call.startedAt,
|
||
role: "assistant",
|
||
foldable: !denied && it.call.status !== "running" && idx !== lastIndex,
|
||
label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId,
|
||
failed: it.call.status === "failed",
|
||
node: wrap(<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />),
|
||
});
|
||
break;
|
||
}
|
||
case "compaction":
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.at,
|
||
role: "system",
|
||
foldable: false,
|
||
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":
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.at,
|
||
role: "assistant",
|
||
foldable: false,
|
||
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
|
||
});
|
||
break;
|
||
case "approval":
|
||
entries.push({
|
||
key: it.key,
|
||
at: it.at,
|
||
role: "assistant",
|
||
foldable: false,
|
||
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
|
||
});
|
||
break;
|
||
case "end":
|
||
if (!it.ok)
|
||
entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: <EndNotice item={it} /> });
|
||
break;
|
||
}
|
||
});
|
||
for (const m of userMessages)
|
||
entries.push({
|
||
key: `u${m.at}`,
|
||
at: m.at,
|
||
role: "user",
|
||
foldable: false,
|
||
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待智能体读取" : undefined} animateIn />,
|
||
});
|
||
entries.sort((a, b) => a.at.localeCompare(b.at));
|
||
const blocks = groupConversation(entries);
|
||
const tail = live.items[lastIndex]?.kind;
|
||
const typing =
|
||
active && tail !== "text" && tail !== "thinking" ? (
|
||
<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;
|
||
const lastBlock = blocks[blocks.length - 1];
|
||
const typingInTurn = typing && lastBlock?.kind === "turn";
|
||
|
||
return (
|
||
<div className="space-y-5 px-1 py-2">
|
||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||
{blocks.map((b) =>
|
||
b.kind === "single" ? (
|
||
<div key={b.entry.key}>{b.entry.node}</div>
|
||
) : (
|
||
<Message key={b.key} from="assistant" aria-label="智能体回合">
|
||
<MessageAvatar>
|
||
<Bot />
|
||
</MessageAvatar>
|
||
<MessageContent className="gap-2">
|
||
<MessageHeader>
|
||
<span className="font-medium text-foreground/80">智能体</span>
|
||
<time dateTime={b.at}>{clockText(b.at)}</time>
|
||
</MessageHeader>
|
||
{b.parts.map((p) =>
|
||
p.kind === "fold" ? (
|
||
<ActivityFold key={p.key} entries={p.entries} />
|
||
) : (
|
||
<div key={p.entry.key} className="w-full min-w-0">
|
||
{p.entry.node}
|
||
</div>
|
||
),
|
||
)}
|
||
{typingInTurn && b === lastBlock ? typing : null}
|
||
</MessageContent>
|
||
</Message>
|
||
),
|
||
)}
|
||
{typing && !typingInTurn ? <AssistantRow avatar>{typing}</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,
|
||
stacked = false,
|
||
}: {
|
||
run: AgentRunView;
|
||
live: AgentStreamState;
|
||
/** 侧栏里单列排 */
|
||
stacked?: boolean;
|
||
}) {
|
||
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 gap-2">
|
||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||
<Meter
|
||
label="费用(估算)"
|
||
used={u.costUsd}
|
||
max={run.budget.maxCostUsd}
|
||
format={(n) => formatUsd(n)}
|
||
exceeded={exceeded === "cost"}
|
||
/>
|
||
<Meter
|
||
label="时长"
|
||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||
max={run.budget.maxDurationSec}
|
||
format={(n) => `${n} 秒`}
|
||
exceeded={exceeded === "duration"}
|
||
/>
|
||
</div>
|
||
{/* 调用次数与 token 超限时直接显示,否则收进技术信息 */}
|
||
{exceeded === "tool_calls" || exceeded === "tokens" ? (
|
||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
|
||
<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"}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<TechInfo
|
||
items={[
|
||
{
|
||
label: "工具调用",
|
||
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
|
||
},
|
||
{
|
||
label: "token",
|
||
value:
|
||
u.totalTokens === undefined
|
||
? null
|
||
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
|
||
},
|
||
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
|
||
{ label: "会话 ID", value: run.id, wide: true },
|
||
]}
|
||
/>
|
||
)}
|
||
</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";
|
||
|
||
/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */
|
||
export function isLongReport(text: string): boolean {
|
||
return text.length > 600 || text.split("\n").length > 12;
|
||
}
|
||
|
||
/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */
|
||
function LongText({ long, children }: { long: boolean; children: ReactNode }) {
|
||
const id = useId();
|
||
const [open, setOpen] = useState(false);
|
||
if (!long) return <>{children}</>;
|
||
return (
|
||
<div>
|
||
<div
|
||
id={id}
|
||
className={cn(
|
||
"relative",
|
||
!open && "max-h-56 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]",
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
className="mt-1"
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||
{open ? "收起" : "展开全文"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
||
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 ?? "下载任务",
|
||
domain: "下载任务",
|
||
meta: (
|
||
<StatusBadge tone={PLAN_STATUS_TONE(d.status)}>
|
||
{DOWNLOAD_STATUS_LABEL[d.status as keyof typeof DOWNLOAD_STATUS_LABEL] ?? d.status}
|
||
</StatusBadge>
|
||
),
|
||
to: `/console/downloads/${d.downloadTaskId}`,
|
||
})),
|
||
...report.organization.map((o) => ({
|
||
id: `o${o.recordId}`,
|
||
icon: <FolderInput />,
|
||
title: o.targetPath ?? "整理记录",
|
||
domain: "整理记录",
|
||
meta: (
|
||
<StatusBadge tone={PLAN_STATUS_TONE(o.status)}>
|
||
{RECORD_STATUS_LABEL[o.status as keyof typeof RECORD_STATUS_LABEL] ?? o.status}
|
||
</StatusBadge>
|
||
),
|
||
to: `/console/organize/${o.recordId}`,
|
||
})),
|
||
]
|
||
: [];
|
||
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
||
|
||
return (
|
||
<section aria-label="最终汇报" className="grid gap-3 @2xl: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>
|
||
<LongText long={isLongReport(said)}>
|
||
<StreamingResponse status="complete" copyText={said || undefined}>
|
||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||
</StreamingResponse>
|
||
</LongText>
|
||
</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>
|
||
{state || count("failed") || denied ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||
{count("failed") ? <StatusBadge tone="destructive">{count("failed")} 步失败</StatusBadge> : null}
|
||
{denied ? <StatusBadge tone="destructive">{denied} 步未执行</StatusBadge> : null}
|
||
</div>
|
||
) : null}
|
||
{report ? (
|
||
<>
|
||
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
|
||
<dl className="mt-2 flex flex-wrap gap-2 text-center">
|
||
{(
|
||
[
|
||
["已提交", report.submitted],
|
||
["已入库", report.imported],
|
||
["进行中", report.inProgress],
|
||
["失败", report.failed],
|
||
] as const
|
||
)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([k, v]) => (
|
||
<div
|
||
key={k}
|
||
className="flex min-w-16 flex-col-reverse rounded-lg border border-border/60 px-2 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>
|
||
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>
|
||
{PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? 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">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
|
||
)}
|
||
{run.error ? (
|
||
<>
|
||
<p className="mt-2 text-caption break-words text-destructive">
|
||
{humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"}
|
||
</p>
|
||
<TechInfo className="mt-1" items={[{ label: "原始错误", value: JSON.stringify(run.error), wide: true }]} />
|
||
</>
|
||
) : 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>
|
||
);
|
||
}
|