import { type AgentRunStatus, type Approval, CAPABILITY_CATALOG, type CapabilityDescriptor, type KnownCapabilityId, } from "@umt/contracts"; import { Ban, Brain, Check, ChevronDown, ChevronRight, CircleAlert, CircleSlash, Layers, LoaderCircle, RotateCcw, } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { type AgentRunView, agentMockEnabled, type GoalReport, type InvocationAudit, isProposeFirst, isRunActive, RUN_STATUS_LABEL, RUN_STATUS_TONE, useApproveAndRetry, useDecideApproval, useIsAdminSession, } from "~/api/agent.ts"; import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx"; import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { Markdown } from "~/components/agentui/markdown.tsx"; import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx"; import { StreamingResponse } from "~/components/agentui/streaming-response.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { formatUsd } from "~/components/schedules/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { humanizeReason } from "~/lib/reasons.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; import { splitContextGoal } from "./agent-store.ts"; import { type ActivityItem, activitySpan, activitySummary, authDenial, buildConversation, capabilityTitle, deniedReason, durationLabel, isDenied, runningText, toolSummary, } from "./conversation-model.ts"; import { 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 summary = denied ? deniedReason(call).text : toolSummary(call); return (
  • ); } /** * 一段进度的一行摘要:「查了 1 个站点和媒体库 · 1 项没做 · 41 秒 ›」;运行中写「正在检测站点…」。 * 展开后每步一行;思考与工具之间的话也在里面。 */ export function StepSummary({ activity, running, auditByCall, }: { activity: ActivityItem[]; running: boolean; auditByCall?: Map; }) { const id = useId(); const [open, setOpen] = useState(false); const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])); const current = running ? calls.find((c) => c.status === "running") : undefined; const done = activitySummary(calls.filter((c) => c.status !== "running")); const denied = calls.filter(isDenied).length; const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length; const span = activitySpan(activity); const thinking = activity.some((a) => a.kind === "thinking"); const head = current ? `${done ? `${done},` : ""}${runningText(current)}…` : done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明"); return (
      {activity.map((a) => a.kind === "tool" ? ( ) : a.kind === "thinking" ? ( ) : (
    • {a.text.trim()}
    • ), )}
    ); } // ── 没做的事 ── /** 回合末尾一行:「没做:检测站点,需要你批准 [批准并重试]」;同一能力只写一次 */ 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, }: { 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 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 anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none"; const lastBlock = blocks.at(-1); const toolRunning = tail?.kind === "tool" && tail.call.status === "running"; // 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…" const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined; const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval"); const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting; const typing = showTyping ? ( {run.status === "waiting" ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` : run.status === "queued" ? "排队中…" : "正在处理…"} ) : null; return (
    {blocks.map((b) => b.kind === "user" ? ( ) : (
    {b.parts.map((p) => { const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key); switch (p.kind) { case "segment": { const streamingText = active && p.body.at(-1)?.key === lastKey; const running = active && p.activity.some((a) => a.kind === "tool" && a.call.status === "running"); const thinkingNow = active && p.activity.at(-1)?.key === lastKey && tail?.kind === "thinking"; return (
    {p.activity.length ? ( ) : null} {p.body.map((t) => { const streaming = streamingText && t.key === lastKey; return (
    {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {streaming ? 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}
    ); } // ── 实际结果 ── /** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ export function resultText(report: GoalReport | null): string { if (!report) return "汇总中…"; const r = report; switch (r.state) { case "no_actions": return "没有提交下载或整理"; case "waiting": return r.imported ? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行` : `${r.submitted || r.inProgress} 个下载已提交,还没入库`; case "completed": return `已入库 ${r.imported} 个`; case "partial": return [ r.imported ? `已入库 ${r.imported} 个` : "", r.inProgress ? `${r.inProgress} 个还在进行` : "", r.failed ? `${r.failed} 个失败` : "", ] .filter(Boolean) .join(","); case "failed": return r.failed ? `${r.failed} 个没有成功` : "没有达成目标"; } } /** * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。 * 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。 */ export function ResultLine({ run, report, live, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const durationMs = u.durationMs ?? (run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null); const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); const links = report ? [ ...report.downloads.map((d) => ({ id: d.downloadTaskId, label: d.name ?? "下载任务", to: `/console/downloads/${d.downloadTaskId}`, })), ...report.organization.map((o) => ({ id: o.recordId, label: o.targetPath?.split("/").pop() || "整理记录", to: `/console/organize/${o.recordId}`, })), ] : []; const bad = report?.state === "failed" || report?.state === "partial"; const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null; return (

    {report ? null : } 实际结果:{resultText(report)} {durationMs ? · {durationLabel(durationMs)} : null} {u.costUsd ? · 约 {formatUsd(u.costUsd)} : null}

    {links.length ? (
      {links.slice(0, 4).map((l) => (
    • {l.label}
    • ))} {links.length > 4 ?
    • 等 {links.length} 项
    • : null}
    ) : 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} ); }