import type { AgentRunStatus } from "@umt/contracts"; import { Bot, CheckCircle2, ChevronRight, CircleDashed, CircleX, Layers, LoaderCircle, ShieldAlert, User, Wrench, } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { type AgentRunView, type GoalReport, isRunActive, REPORT_STATE, RUN_STATUS_LABEL, RUN_STATUS_TONE, } from "~/api/agent.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { cn } from "~/lib/utils.ts"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; const BUDGET_LABEL: Record = { tool_calls: "工具调用次数", duration: "运行时长", tokens: "token", cost: "费用", }; const COMPACT_REASON: Record = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" }; 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`; } /** 摘要块:长文本在块内滚动,不撑坏布局 */ function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) { return (

{label}

        {text}
      
); } const TOOL_STATUS: Record< ToolCallView["status"], { label: string; tone: "success" | "destructive" | "warning" | "neutral" } > = { running: { label: "执行中", tone: "neutral" }, succeeded: { label: "成功", tone: "success" }, failed: { label: "失败", tone: "destructive" }, denied: { label: "已拒绝", tone: "destructive" }, }; /** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */ export function ToolCallCard({ call }: { call: ToolCallView }) { const [open, setOpen] = useState(false); const st = TOOL_STATUS[call.status]; if (call.status === "denied") { return (
服务端拒绝调用 {call.capabilityId}

{call.reason ?? "未授权"}

授权校验在服务端完成;Agent 没有绕过的途径。

); } const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2; return (
{!open && call.outputSummary ? (

{call.outputSummary}

) : null} {open ? (
{call.inputSummary ? : null} {call.outputSummary ? ( ) : call.status === "running" ? (

等待结果…

) : null}
) : null}
); } function StreamBlock({ item }: { item: StreamItem }) { const [open, setOpen] = useState(false); switch (item.kind) { case "text": return (

{item.text}

); case "thinking": return (
{open ? (

{item.text}

) : null}
); case "tool": return (
); case "compaction": return (
上下文压缩({COMPACT_REASON[item.reason] ?? item.reason}) {item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"}
); case "end": if (item.ok) return null; return (

{item.reason === "budget" ? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}` : item.reason === "aborted" ? "已中止" : "运行失败"}

{item.message}

); } } function Meter({ label, used, max, format, }: { label: string; used: number | undefined; max?: number; format: (n: number) => string; }) { const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null; return (
{label} {used === undefined ? "未报告" : format(used)} {max ? / {format(max)} : null}
{pct !== null ? (
= 90 ? "bg-warning" : "bg-foreground/60")} style={{ width: `${pct}%` }} /> ) : null}
); } /** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); const durationMs = u.durationMs ?? (run.startedAt ? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime() : undefined); return (
String(n)} /> (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))} /> `${n}s`} /> `$${n.toFixed(3)}`} />
); } /** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ 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 => i.kind === "text") .map((i) => i.text) .join(""); const said = report?.modelText ?? (tail || run.summary || ""); const calls = live.items .filter((i): i is Extract => i.kind === "tool") .map((i) => i.call); const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length; const state = report ? REPORT_STATE[report.state] : null; return (

模型说的

{said || "(没有文字汇报)"}

仅供参考,不作为完成依据。

系统核实的结果

{state ? {state.label} : null} 运行{RUN_STATUS_LABEL[run.status]} 工具成功 {count("succeeded")} {count("failed") ? 失败 {count("failed")} : null} {count("denied") ? 被拒绝 {count("denied")} : null}
{report ? ( <>

{report.summary}

{( [ ["已提交", report.submitted], ["已入库", report.imported], ["进行中", report.inProgress], ["失败", report.failed], ] as const ).map(([k, v]) => (
{v}
{k}
))}
    {report.downloads.map((d) => (
  • 下载 · {d.name ?? d.downloadTaskId.slice(0, 8)} · {d.status}
  • ))} {report.organization.map((o) => (
  • 整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)} · {o.status}
  • ))}
) : (

服务端汇报尚未生成;以任务页、整理记录的实际状态为准。

)} {run.error ? (

{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}

) : null}
); } /** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */ /** 末尾标记离视口底部不超过这么多像素算"停在底部" */ const FOLLOW_SLACK_PX = 160; export function AgentConversation({ run, live, userMessages = [], compact = false, }: { run: AgentRunView; live: AgentStreamState; userMessages?: { at: string; text: string; pending?: boolean }[]; compact?: boolean; }) { const endRef = useRef(null); const active = isRunActive(run.status) && !live.finished; const count = live.items.length; // 有新内容时跟随到底,但只在用户停在底部附近时跟随:往上翻(去点"中止"、看早先的工具卡)后不再把视口拽回去, // 翻回底部附近自动恢复。捕获阶段监听 scroll,侧栏抽屉里的滚动容器也算。 const stick = useRef(true); useEffect(() => { const onScroll = () => { const el = endRef.current; if (el) stick.current = el.getBoundingClientRect().top - window.innerHeight < FOLLOW_SLACK_PX; }; document.addEventListener("scroll", onScroll, { capture: true, passive: true }); return () => document.removeEventListener("scroll", onScroll, { capture: true }); }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底 useEffect(() => { if (active && stick.current) endRef.current?.scrollIntoView({ block: "nearest" }); }, [count, active]); // 追加的用户消息按时间插入 type Row = { key: string; at: string; node: ReactNode }; const rows: Row[] = live.items.map((it) => ({ key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node: , })); for (const m of userMessages) rows.push({ key: `u${m.at}`, at: m.at, node: , }); rows.sort((a, b) => a.at.localeCompare(b.at)); return (
{rows.map((r) => (
{r.node}
))} {active ? (
{run.status === "waiting" ? "等待中…" : "正在处理…"}
) : null} {live.error ?

事件读取失败:{live.error}(稍后自动重试)

: null}
); } function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) { return (
{mode === "plan_only" ?

只要方案

: null}

{text}

{note ?

{note}

: null}
); } export function RunStatusBadge({ status }: { status: AgentRunStatus }) { return ( {isRunActive(status) ? : null} {RUN_STATUS_LABEL[status]} ); }