Files
UHD_Media_Tool/apps/web/app/components/agent/agent-conversation.tsx
T
rootandClaude Opus 5.5 7103bc8f7c feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay,
  candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search,
  unified download entry; origin view placeholder (M2)
- sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history
- tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans,
  cache volume thresholds and cleanup, business timeline (observation → plan → download →
  organization → agent/engine)
- organize: record list/detail with file-level results, manual organize with dryRun preview
- agent: session list/page and layout drawer with context prefix; streaming events,
  tool cards, rejection alerts, compaction, budget meters, model text vs server report;
  mock switch (?mock=1) until W2-D endpoints land

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:52:32 +08:00

450 lines
17 KiB
TypeScript

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<string, string> = {
tool_calls: "工具调用次数",
duration: "运行时长",
tokens: "token",
cost: "费用",
};
const COMPACT_REASON: Record<string, string> = { 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 (
<div className="min-w-0">
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
<pre
className={cn(
"max-h-48 overflow-auto rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 font-mono text-meta break-all whitespace-pre-wrap text-foreground",
tone === "destructive" && "border-destructive/30 text-destructive",
)}
>
{text}
</pre>
</div>
);
}
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 (
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-destructive">
<ShieldAlert className="h-4 w-4 shrink-0" />
<span>服务端拒绝调用</span>
<code className="font-mono text-meta break-all">{call.capabilityId}</code>
</div>
<p className="mt-1 text-caption break-words text-destructive">{call.reason ?? "未授权"}</p>
<p className="mt-1 text-meta text-muted-foreground">授权校验在服务端完成;Agent 没有绕过的途径。</p>
</div>
);
}
const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2;
return (
<div className="min-w-0 rounded-xl border border-border/70">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate font-mono text-caption text-foreground">{call.capabilityId}</code>
{call.recovery ? <StatusBadge>恢复补记</StatusBadge> : null}
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{durationText(call.startedAt, call.endedAt) ?? ""}
</span>
<StatusBadge tone={st.tone}>
<Icon className={cn("h-3 w-3", call.status === "running" && "animate-spin")} />
{st.label}
</StatusBadge>
</button>
{!open && call.outputSummary ? (
<p className="truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{call.outputSummary}</p>
) : null}
{open ? (
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
{call.inputSummary ? <Summary label="输入(已脱敏)" text={call.inputSummary} /> : null}
{call.outputSummary ? (
<Summary
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
text={call.outputSummary}
tone={call.status === "failed" ? "destructive" : undefined}
/>
) : call.status === "running" ? (
<p className="text-meta text-muted-foreground">等待结果…</p>
) : null}
</div>
) : null}
</div>
);
}
function StreamBlock({ item }: { item: StreamItem }) {
const [open, setOpen] = useState(false);
switch (item.kind) {
case "text":
return (
<div className="flex gap-2.5">
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
</span>
<p className="min-w-0 flex-1 text-sm leading-relaxed break-words whitespace-pre-wrap text-foreground">
{item.text}
</p>
</div>
);
case "thinking":
return (
<div className="pl-8">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
<ChevronRight className={cn("h-3 w-3 transition-transform duration-base", open && "rotate-90")} />
思考过程
</button>
{open ? (
<p className="mt-1 max-h-48 overflow-auto text-caption break-words whitespace-pre-wrap text-muted-foreground">
{item.text}
</p>
) : null}
</div>
);
case "tool":
return (
<div className="pl-8">
<ToolCallCard call={item.call} />
</div>
);
case "compaction":
return (
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
<Layers className="h-3.5 w-3.5 shrink-0" />
<span>
上下文压缩({COMPACT_REASON[item.reason] ?? item.reason})
{item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"}
</span>
</div>
);
case "end":
if (item.ok) return null;
return (
<div
role="alert"
className={cn(
"ml-8 rounded-xl border px-3 py-2.5 text-caption",
item.reason === "budget"
? "border-warning/40 bg-warning/10 text-warning"
: item.reason === "aborted"
? "border-border bg-foreground/[0.04] text-muted-foreground"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
<p className="font-medium">
{item.reason === "budget"
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}`
: item.reason === "aborted"
? "已中止"
: "运行失败"}
</p>
<p className="mt-0.5 break-words">{item.message}</p>
</div>
);
}
}
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 (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2 text-meta">
<span className="text-muted-foreground">{label}</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]">
{pct !== null ? (
<div
className={cn("h-full rounded-full", pct >= 90 ? "bg-warning" : "bg-foreground/60")}
style={{ width: `${pct}%` }}
/>
) : null}
</div>
</div>
);
}
/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */
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 (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Meter label="工具调用" used={toolCalls} max={run.budget.maxToolCalls} format={(n) => String(n)} />
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n}s`}
/>
<Meter label="费用(估算)" used={u.costUsd} max={run.budget.maxCostUsd} format={(n) => `$${n.toFixed(3)}`} />
</div>
);
}
/** 最终汇报:模型说的 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<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 count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length;
const state = report ? REPORT_STATE[report.state] : null;
return (
<div className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-xl border border-border/70 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 className="h-3.5 w-3.5" />
模型说的
</p>
<p className="max-h-60 overflow-auto text-sm break-words whitespace-pre-wrap text-foreground">
{said || "(没有文字汇报)"}
</p>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<CheckCircle2 className="h-3.5 w-3.5" />
系统核实的结果
</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}
{count("denied") ? <StatusBadge tone="destructive">被拒绝 {count("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="rounded-lg border border-border/60 px-1 py-1.5">
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
<dt className="text-meta text-muted-foreground">{k}</dt>
</div>
))}
</dl>
<ul className="mt-2 space-y-1 text-caption">
{report.downloads.map((d) => (
<li key={d.downloadTaskId} className="min-w-0 truncate">
<Link
to={`/console/tasks/${d.downloadTaskId}`}
className="text-foreground underline-offset-4 hover:underline"
>
下载 · {d.name ?? d.downloadTaskId.slice(0, 8)}
</Link>
<span className="text-muted-foreground"> · {d.status}</span>
</li>
))}
{report.organization.map((o) => (
<li key={o.recordId} className="min-w-0 truncate">
<Link
to={`/console/organize/${o.recordId}`}
className="text-foreground underline-offset-4 hover:underline"
>
整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)}
</Link>
<span className="text-muted-foreground"> · {o.status}</span>
</li>
))}
</ul>
</>
) : (
<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>
</div>
);
}
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
export function AgentConversation({
run,
live,
userMessages = [],
compact = false,
}: {
run: AgentRunView;
live: AgentStreamState;
userMessages?: { at: string; text: string; pending?: boolean }[];
compact?: boolean;
}) {
const endRef = useRef<HTMLDivElement>(null);
const active = isRunActive(run.status) && !live.finished;
const count = live.items.length;
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
useEffect(() => {
if (active) 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: <StreamBlock item={it} />,
}));
for (const m of userMessages)
rows.push({
key: `u${m.at}`,
at: m.at,
node: <UserBubble text={m.text} note={m.pending ? "等待 Agent 读取" : undefined} />,
});
rows.sort((a, b) => a.at.localeCompare(b.at));
return (
<div className={cn("space-y-3", compact && "text-sm")}>
<UserBubble text={run.goal} mode={run.mode ?? (run.scope.mode as string | undefined)} />
{rows.map((r) => (
<div key={r.key}>{r.node}</div>
))}
{active ? (
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5 animate-spin" />
{run.status === "waiting" ? "等待中…" : "正在处理…"}
</div>
) : null}
{live.error ? <p className="pl-8 text-meta text-destructive">事件读取失败:{live.error}(稍后自动重试)</p> : null}
<div ref={endRef} />
</div>
);
}
function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) {
return (
<div className="flex justify-end gap-2.5">
<div className="max-w-[85%] min-w-0 rounded-2xl border border-border/70 bg-foreground/[0.05] px-3 py-2">
{mode === "plan_only" ? <p className="mb-0.5 text-meta text-muted-foreground">只要方案</p> : null}
<p className="text-sm break-words whitespace-pre-wrap text-foreground">{text}</p>
{note ? <p className="mt-0.5 text-meta text-muted-foreground">{note}</p> : null}
</div>
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
<User className="h-3.5 w-3.5" />
</span>
</div>
);
}
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
return (
<StatusBadge tone={RUN_STATUS_TONE[status]}>
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin" /> : null}
{RUN_STATUS_LABEL[status]}
</StatusBadge>
);
}