Files
UHD_Media_Tool/apps/web/app/components/agent/agent-conversation.tsx
T
rootandClaude Opus 5.5 28974d1e1a feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
  into the camofox profile before the first tab opens; password accounts
  without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
  accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
  every 10 min of activity, and from refresh-login when the stored cookie is
  invalid (verified before saving). user_info.cookieOrigin records
  source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
  under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
  logged in (logout link present), 5 cookies read back.

Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:25:07 +08:00

1084 lines
41 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,
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 { Button } from "~/components/ui/button.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;
/**
* 工具卡收起时那一行的摘要:错误取 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 ?? 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={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}
<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;
}
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头;
// 组内连续 ≥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 ? "已送达,等待 Agent 读取" : 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="Agent 回合">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent className="gap-2">
<MessageHeader>
<span className="font-medium text-foreground/80">Agent</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={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-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";
/** 长汇报:超过 ~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 ?? `下载任务 ${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 @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>
<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>
);
}