- 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]>
450 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|