import { type AgentRunStatus, type Approval, CAPABILITY_CATALOG, type CapabilityDescriptor, type KnownCapabilityId, } from "@umt/contracts"; import { Ban, Brain, CalendarCog, 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 { consolePath } from "~/components/console/nav.ts"; 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, type ConvBlock, callCounts, callCountText, capabilityTitle, deniedReason, durationLabel, isDenied, lastReportText, 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 = { tool_calls: "工具调用次数", duration: "运行时长", tokens: "token", cost: "费用", }; const COMPACT_STATUS: Record = { completed: "已完成", declined: "没有压缩", aborted: "已中止", failed: "失败", }; export function capabilityMeta(id: string): CapabilityDescriptor | null { return (CAPABILITY_CATALOG as Record)[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 (
{label || tag ? (
{tag ? ( {tag} ) : null} {label ? ( {label} ) : null}
) : null}
{body}
{note ?

{note}

: null}
); } // ── 进度行 ── function ThinkingRow({ text }: { text: string }) { const id = useId(); const [open, setOpen] = useState(false); return (
  • {text}

  • ); } function StepIcon({ call }: { call: ToolCallView }) { if (call.status === "running") return ; if (isDenied(call)) return ; if (call.status === "failed") return ; return ; } /** 展开后的一步:「搜资源 → 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 raw = denied ? deniedReason(call).text : toolSummary(call); // 旧会话的错误文字里带能力 ID / 原因码:和最终汇报一样换成能力标题与人话 const summary = raw ? humanizeReport(raw).text : raw; return (
  • ); } /** * 一段进度的一行摘要:「查了 1 个站点和媒体库 · 1 项没做 · 41 秒 ›」;运行中写「正在检测站点…」。 * 展开后每步一行;思考与工具之间的话也在里面。 */ export function StepSummary({ activity, running, auditByCall, since, until, }: { activity: ActivityItem[]; running: boolean; auditByCall?: Map; /** 这一轮开始的时间(会话开始 / 用户消息);只给回合里的第一段 */ since?: string | null; /** 结论正文开始的时间 */ until?: string | null; }) { 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, { since, until }); const counted = running ? "" : callCountText(callCounts(calls), "调用"); const thinking = activity.some((a) => a.kind === "thinking"); const head = current ? `${done ? `${done},` : ""}${runningText(current)}…` : done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明"); return (
      {activity.map((a) => a.kind === "tool" ? ( ) : a.kind === "thinking" ? ( ) : (
    • {humanizeReport(a.text.trim()).text}
    • ), )}
    ); } // ── 没做的事 ── /** 回合末尾一行:「没做:检测站点,需要你批准 [批准并重试]」;同一能力只写一次 */ export function DeniedLines({ runId, calls }: { runId: string; calls: ToolCallView[] }) { const admin = useIsAdminSession() || agentMockEnabled(); const retry = useApproveAndRetry(); const [sent, setSent] = useState>(new Set()); const rows = [...new Map(calls.map((c) => [c.capabilityId, c] as const)).values()]; if (!rows.length) return null; return (
      {rows.map((c) => { const title = capabilityTitle(c.capabilityId); const r = deniedReason(c); const done = sent.has(c.capabilityId); return (
    • 没做:{title},{r.text} {r.approvable && admin ? ( ) : null}
    • ); })}
    ); } // ── 结束 / 压缩提示 ── function EndNotice({ item }: { item: Extract }) { const budget = item.reason === "budget"; const aborted = item.reason === "aborted"; return (

    {budget ? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的动作不会撤销;调大预算后发消息可以继续。` : aborted ? "已停止。" : `运行失败${item.message ? `:${humanizeReason(item.message) ?? item.message}` : ""}`}

    ); } // ── 审计配对与计划 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(); if (!audit?.length) return out; const byCap = new Map(); for (const a of audit) byCap.set(a.capabilityId, [...(byCap.get(a.capabilityId) ?? []), a]); const used = new Map(); 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(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"); // 本轮的实时状态(AG-4):「思考中 · 已调用 3 次工具、1 次 MCP」;本轮结束后计数并入各段进度行 const turnCalls = lastBlock?.kind === "turn" ? lastBlock.parts.flatMap((p) => p.kind === "segment" ? p.activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])) : [], ) : []; const turnCount = callCountText(callCounts(turnCalls)); const showTyping = active && !cardWaiting; const typingState = run.status === "waiting" ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}` : run.status === "queued" ? "排队中" : toolRunning ? "调用工具中" : tail?.kind === "text" ? "回答中" : "思考中"; const typing = showTyping ? ( {typingState} {turnCount ? ` · ${turnCount}` : "…"} ) : null; return (
    {blocks.map((b, bi) => b.kind === "user" ? ( ) : (
    {b.parts.map((p, pi) => { 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 (
    {p.activity.length ? ( ) : null} {p.activity .flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : [])) .map((d) => ( ))} {p.body.map((t) => { const streaming = streamingText && t.key === lastKey; // 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息 const human = streaming ? null : humanizeReport(t.text, names); return (
    {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {human ? : t.text}
    ); })}
    ); } case "options": return (
    ); case "approval": return (
    ); case "compaction": return ( 整理上下文 ·{" "} {p.item.done ? (COMPACT_STATUS[p.item.status ?? "completed"] ?? p.item.status) : "进行中…"} ); case "end": return p.item.ok ? null : ; default: return null; } })} {b.denied.length ? : null} {typing && b === lastBlock ? typing : null}
    ), )} {typing && lastBlock?.kind !== "turn" ?
    {typing}
    : null} {after}
    ); } /** 起草巡检(schedule.draft_check)成功的输出:巡检 ID 与名称(输出摘要可能被截断,按字段取) */ export function draftCheckOf(call: ToolCallView): { jobId: string; name: string }[] { if (call.capabilityId !== "schedule.draft_check" || call.status === "running" || call.status === "failed") return []; if (isDenied(call) || !call.outputSummary) return []; const jobId = /"jobId"\s*:\s*"([\w.-]+)"/.exec(call.outputSummary)?.[1]; const name = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.outputSummary)?.[1]; return jobId ? [{ jobId, name: name ?? "巡检" }] : []; } /** 对话里起草好的巡检:未启用,去计划页检查后启用 */ function DraftCheckCard({ jobId, name }: { jobId: string; name: string }) { return (

    巡检草稿「{name}」未启用

    到计划页检查目标、间隔和能力,确认后打开开关启用。

    ); } /** 回合开始的时间:前一条用户消息;第一轮是会话开始 */ function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null { const prev = blocks[index - 1]; if (prev?.kind === "user") return prev.at; return index === 0 ? run.startedAt : null; } // ── 实际结果 ── type ResultFact = { text: string; to?: string; tone?: "warning" }; /** * 实际结果一行:系统核实的事实(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本)。 * 没有动作时只有「没有提交下载或整理」;有动作时「提交了 2 个下载、完成 1 次整理」,只有一条记录的项直接链到它。 */ export function resultFacts(report: GoalReport | null): ResultFact[] { if (!report) return [{ text: "汇总中…" }]; const r = report; if (r.state === "no_actions") return [{ text: "没有提交下载或整理" }]; const downloads = Math.max(r.submitted, r.plans.length); const done = r.organization.filter((o) => o.status === "succeeded" || o.status === "partial"); const facts: ResultFact[] = []; if (downloads) { const only = r.downloads.length === 1 ? r.downloads[0] : undefined; facts.push({ text: `提交了 ${downloads} 个下载`, to: only ? `/console/downloads/${only.downloadTaskId}` : r.downloads.length ? "/console/downloads" : undefined, }); } if (done.length) { const only = done.length === 1 ? done[0] : undefined; facts.push({ text: `完成 ${done.length} 次整理`, to: only ? `/console/organize/${only.recordId}` : "/console/organize", }); } if (r.imported && r.imported !== done.length) facts.push({ text: `已整理 ${r.imported} 个` }); if (r.inProgress) facts.push({ text: `${r.inProgress} 个还在进行` }); if (r.failed) facts.push({ text: `${r.failed} 个没有成功`, tone: "warning" }); return facts.length ? facts : [{ text: "没有提交下载或整理" }]; } /** 模型汇报里写明的数字(只认「提交了 N 个」「整理了 N 个」这类明确说法;没写数字的不算) */ export function claimedCounts(text: string | null): { downloads?: number; organized?: number } { if (!text) return {}; const out: { downloads?: number; organized?: number } = {}; const dl = text.match(/(?:提交|添加|推送|开始下载)了?\s*(\d+)\s*(?:个|项|部|集)/); if (dl?.[1]) out.downloads = Number(dl[1]); const org = text.match(/整理了?\s*(\d+)\s*(?:个|次|项|部|集)/) ?? text.match(/(\d+)\s*(?:个|次|项)[^,。,.\n]{0,4}整理/); if (org?.[1]) out.organized = Number(org[1]); return out; } /** 汇报里的数字与系统记录不一致的地方;一致或汇报没写数字时为空 */ export function reportMismatch(report: GoalReport | null, text: string | null): string[] { if (!report) return []; const c = claimedCounts(text); const downloads = Math.max(report.submitted, report.plans.length); const organized = report.organization.filter((o) => o.status === "succeeded" || o.status === "partial").length; const out: string[] = []; if (c.downloads !== undefined && c.downloads !== downloads) out.push(`汇报说提交了 ${c.downloads} 个下载,系统记录是 ${downloads} 个`); if (c.organized !== undefined && c.organized !== organized) out.push(`汇报说整理了 ${c.organized} 个,系统记录完成 ${organized} 次`); return out; } /** * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒」或「提交了 2 个下载、完成 1 次整理 · 41 秒」。 * 数字与汇报不一致时行尾加「与汇报不一致」(悬停看说明,技术信息里也有);模型、token、会话 ID 进技术信息。 */ export function ResultLine({ run, report, live, subagents = 0, showCost = true, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */ subagents?: number; /** 上方统计行已显示费用时传 false,不重复 */ showCost?: boolean; }) { 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 facts = resultFacts(report); const last = lastReportText(live.items); const rawReport = last && humanizeReport(last).replaced.length ? last : null; const mismatch = reportMismatch(report, last ?? report?.modelText ?? null); const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null; return (

    {report ? null : } {facts.map((f, i) => ( {i ? "、" : ""} {f.to ? ( {f.text} ) : ( {f.text} )} ))} {subagents ? · 派出 {subagents} 个子会话 : null} {durationMs ? · {durationLabel(durationMs)} : null} {showCost && u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} {mismatch.length ? ( 与汇报不一致 ) : null}

    {err ?

    {err}

    : null}
    ); } // ── 本次运行计划上的其它审批(不在会话流里的) ── const PLAN_CAPABILITY: Record = { 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 ( act("approved")} onReject={() => act("rejected")} disabledReason={admin ? undefined : "只有管理员能批准或拒绝"} result={ a.decidedAt ? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}` : undefined } > ); } export function RunStatusBadge({ status }: { status: AgentRunStatus }) { return ( {isRunActive(status) ? : null} {RUN_STATUS_LABEL[status]} ); } /** 滚动时保持最新一次状态播报(给读屏用的礼貌播报,流式正文本身不逐字播报) */ 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 ( {msg} ); }