- Trim descriptions, empty states, confirm buttons and toasts to facts - Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游 - Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk) - Shorter, concrete goal templates for built-in Agent checks - Human-readable nouns instead of table names in instance CRUD errors - Audit labels for pi_upstream / schedule / automation_grant actions - Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权 Co-Authored-By: Claude Opus 5.5 <[email protected]>
842 lines
32 KiB
TypeScript
842 lines
32 KiB
TypeScript
import {
|
|
type AgentRunStatus,
|
|
type Approval,
|
|
CAPABILITY_CATALOG,
|
|
type CapabilityDescriptor,
|
|
type KnownCapabilityId,
|
|
type RiskLevel,
|
|
} from "@umt/contracts";
|
|
import {
|
|
Bot,
|
|
Brain,
|
|
ChevronDown,
|
|
CircleSlash,
|
|
Download,
|
|
FolderInput,
|
|
Layers,
|
|
LoaderCircle,
|
|
ShieldCheck,
|
|
User,
|
|
} from "lucide-react";
|
|
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
type AgentRunView,
|
|
type GoalReport,
|
|
type InvocationAudit,
|
|
isRunActive,
|
|
REPORT_STATE,
|
|
RUN_STATUS_LABEL,
|
|
RUN_STATUS_TONE,
|
|
useDecideApproval,
|
|
useIsAdminSession,
|
|
} from "~/api/agent.ts";
|
|
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
|
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
|
|
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
|
import { FileDiff } from "~/components/agentui/file-diff.tsx";
|
|
import {
|
|
Message,
|
|
MessageAvatar,
|
|
MessageContent,
|
|
MessageFooter,
|
|
MessageHeader,
|
|
MessageMarker,
|
|
MessageTyping,
|
|
} from "~/components/agentui/message.tsx";
|
|
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
|
|
import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.tsx";
|
|
import { type TodoItem, TodoList } from "~/components/agentui/todo-list.tsx";
|
|
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
|
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
|
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
|
|
|
// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/,
|
|
// 改造自 github.com/ashish200729/agentui,MIT)。服务端事件形状见 packages/contracts/src/agent-events.ts。
|
|
|
|
export const BUDGET_LABEL: Record<string, string> = {
|
|
tool_calls: "工具调用次数",
|
|
duration: "运行时长",
|
|
tokens: "token",
|
|
cost: "费用",
|
|
};
|
|
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
|
|
const COMPACT_STATUS: Record<string, string> = {
|
|
completed: "已完成,业务进度保留",
|
|
declined: "未压缩",
|
|
aborted: "已中止",
|
|
failed: "失败",
|
|
};
|
|
|
|
// ── 能力元数据(scope / 风险 / 类别来自 contracts 的 CAPABILITY_CATALOG) ──
|
|
|
|
export function capabilityMeta(id: string): CapabilityDescriptor | null {
|
|
return (CAPABILITY_CATALOG as Record<string, CapabilityDescriptor>)[id as KnownCapabilityId] ?? null;
|
|
}
|
|
|
|
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
|
|
low: { label: "低风险", tone: "neutral" },
|
|
medium: { label: "中风险", tone: "warning" },
|
|
high: { label: "高风险", tone: "destructive" },
|
|
};
|
|
const KIND_LABEL: Record<string, string> = {
|
|
read: "只读",
|
|
preview: "预览",
|
|
propose: "提案",
|
|
approve: "审批",
|
|
execute: "执行",
|
|
};
|
|
|
|
export function RiskBadge({ level }: { level: RiskLevel }) {
|
|
const r = RISK[level];
|
|
return <StatusBadge tone={r.tone}>{r.label}</StatusBadge>;
|
|
}
|
|
|
|
function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) {
|
|
const m = capabilityMeta(id);
|
|
return (
|
|
<>
|
|
{m ? (
|
|
<>
|
|
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
|
|
<StatusBadge>scope · {m.scope}</StatusBadge>
|
|
<RiskBadge level={m.riskLevel} />
|
|
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
|
|
</>
|
|
) : (
|
|
<StatusBadge>未登记能力</StatusBadge>
|
|
)}
|
|
{extra}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
const TOOL_STATUS: Record<Exclude<ToolCallView["status"], "denied">, ToolResultStatus> = {
|
|
running: "running",
|
|
succeeded: "success",
|
|
failed: "error",
|
|
};
|
|
|
|
/**
|
|
* 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden,
|
|
* 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。两者在界面上都按"服务端拒绝"醒目展示。
|
|
*/
|
|
export function authDenial(call: ToolCallView): string | null {
|
|
if (call.status !== "failed" || !call.outputSummary) return null;
|
|
try {
|
|
const e = (JSON.parse(call.outputSummary) as { error?: { code?: string; message?: string } }).error;
|
|
if (e && (e.code === "unauthorized" || e.code === "forbidden")) return e.message ?? e.code;
|
|
} catch {
|
|
const m = /"code":"(unauthorized|forbidden)"(?:,"message":"([^"]*)")?/.exec(call.outputSummary);
|
|
if (m) return m[2] ?? m[1] ?? "未授权";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 被服务端拒绝(白名单拦截或授权校验失败) */
|
|
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
|
|
|
|
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
|
export function ToolCallCard({
|
|
call,
|
|
mode,
|
|
audit,
|
|
}: {
|
|
call: ToolCallView;
|
|
mode?: string;
|
|
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
|
|
audit?: InvocationAudit;
|
|
}) {
|
|
const meta = capabilityMeta(call.capabilityId);
|
|
const authReason = authDenial(call);
|
|
if (call.status === "denied" || authReason) {
|
|
const writeKind = meta && !["read", "preview", "propose"].includes(meta.kind);
|
|
return (
|
|
<ToolApproval
|
|
status="rejected"
|
|
tool={call.capabilityId}
|
|
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
|
|
badges={
|
|
<CapabilityBadges
|
|
id={call.capabilityId}
|
|
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
|
|
/>
|
|
}
|
|
reason={authReason ?? call.reason ?? "未授权"}
|
|
explanation={
|
|
authReason
|
|
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
|
|
: mode === "plan_only" && writeKind
|
|
? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
|
|
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
|
}
|
|
parameters={[
|
|
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
|
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
|
|
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
|
|
]}
|
|
/>
|
|
);
|
|
}
|
|
const status = TOOL_STATUS[call.status as Exclude<ToolCallView["status"], "denied">];
|
|
return (
|
|
<ToolResult
|
|
status={status}
|
|
tool={call.capabilityId}
|
|
title={meta?.title ?? call.capabilityId}
|
|
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
|
|
badges={
|
|
<CapabilityBadges
|
|
id={call.capabilityId}
|
|
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
|
|
/>
|
|
}
|
|
summary={call.outputSummary ?? undefined}
|
|
>
|
|
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
|
|
{call.outputSummary ? (
|
|
<ToolResultOutput
|
|
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
|
|
tone={call.status === "failed" ? "destructive" : undefined}
|
|
>
|
|
{call.outputSummary}
|
|
</ToolResultOutput>
|
|
) : call.status === "running" ? (
|
|
<p className="text-meta text-muted-foreground">等待结果…</p>
|
|
) : null}
|
|
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
|
<dt className="text-muted-foreground">调用 ID</dt>
|
|
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
|
|
<dt className="text-muted-foreground">幂等键</dt>
|
|
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
|
{audit?.idempotencyKey ??
|
|
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
|
|
</dd>
|
|
{audit?.grantId || audit?.approvalId ? (
|
|
<>
|
|
<dt className="text-muted-foreground">授权依据</dt>
|
|
<dd className="min-w-0 font-mono break-all text-foreground/80">
|
|
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
|
|
</dd>
|
|
</>
|
|
) : null}
|
|
</dl>
|
|
</ToolResult>
|
|
);
|
|
}
|
|
|
|
/** 思考块:thinking_delta 累积,默认收起;流式中显示"思考中"与光标 */
|
|
function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
|
const id = useId();
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div className="w-full min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-expanded={open}
|
|
aria-controls={id}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 py-0.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Brain aria-hidden className={cn("size-3.5", streaming && "animate-pulse motion-reduce:animate-none")} />
|
|
{streaming ? "思考中…" : "思考过程"}
|
|
<span className="tabular-nums">· {text.length} 字</span>
|
|
<ChevronDown aria-hidden className={cn("size-3", CHEVRON_CLASS, open && "rotate-180")} />
|
|
</button>
|
|
<Disclosure id={id} open={open}>
|
|
<p className="mt-1 max-h-64 overflow-auto border-l-2 border-border pl-3 text-caption leading-5 break-words whitespace-pre-wrap text-muted-foreground">
|
|
{text}
|
|
{streaming ? <StreamingCursor /> : null}
|
|
</p>
|
|
</Disclosure>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssistantRow({
|
|
children,
|
|
animateIn,
|
|
avatar = true,
|
|
}: {
|
|
children: ReactNode;
|
|
animateIn?: boolean;
|
|
avatar?: boolean;
|
|
}) {
|
|
return (
|
|
<Message from="assistant" animateIn={animateIn}>
|
|
<MessageAvatar placeholder={!avatar}>
|
|
<Bot />
|
|
</MessageAvatar>
|
|
<MessageContent>{children}</MessageContent>
|
|
</Message>
|
|
);
|
|
}
|
|
|
|
function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
|
|
const budget = item.reason === "budget";
|
|
const aborted = item.reason === "aborted";
|
|
return (
|
|
<Message from="system" role="alert">
|
|
<MessageAvatar>
|
|
<CircleSlash />
|
|
</MessageAvatar>
|
|
<MessageContent>
|
|
<MessageBubble variant={budget ? "warning" : aborted ? "outline" : "danger"}>
|
|
<MessageBubbleContent className={cn(aborted && "text-muted-foreground")}>
|
|
<p className="font-medium">
|
|
{budget
|
|
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止`
|
|
: aborted
|
|
? "已停止"
|
|
: "运行失败"}
|
|
</p>
|
|
{item.message ? <p className="mt-0.5 text-caption break-words">{item.message}</p> : null}
|
|
{budget ? (
|
|
<p className="mt-1 text-meta text-muted-foreground">
|
|
已完成的外部动作不会撤销。调大预算后追加消息可继续运行。
|
|
</p>
|
|
) : null}
|
|
</MessageBubbleContent>
|
|
</MessageBubble>
|
|
</MessageContent>
|
|
</Message>
|
|
);
|
|
}
|
|
|
|
export function UserMessage({
|
|
text,
|
|
at,
|
|
mode,
|
|
note,
|
|
animateIn,
|
|
}: {
|
|
text: string;
|
|
at?: string;
|
|
mode?: string;
|
|
note?: string;
|
|
animateIn?: boolean;
|
|
}) {
|
|
return (
|
|
<Message from="user" animateIn={animateIn}>
|
|
<MessageAvatar>
|
|
<User />
|
|
</MessageAvatar>
|
|
<MessageContent>
|
|
{at || mode ? (
|
|
<MessageHeader>
|
|
{mode ? <span>{mode === "plan_only" ? "只要方案" : "授权内执行"}</span> : null}
|
|
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
|
|
</MessageHeader>
|
|
) : null}
|
|
<MessageBubble>
|
|
<MessageBubbleContent className="whitespace-pre-wrap">{text}</MessageBubbleContent>
|
|
</MessageBubble>
|
|
{note ? <MessageFooter>{note}</MessageFooter> : null}
|
|
</MessageContent>
|
|
</Message>
|
|
);
|
|
}
|
|
|
|
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<string, InvocationAudit>();
|
|
if (!audit?.length) return out;
|
|
const byCap = new Map<string, InvocationAudit[]>();
|
|
for (const a of audit) byCap.set(a.capabilityId, [...(byCap.get(a.capabilityId) ?? []), a]);
|
|
const used = new Map<string, number>();
|
|
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 mode = run.mode ?? (run.scope.mode as string | undefined);
|
|
// 首次载入(回放)的条目不播入场动画,之后新到的才播
|
|
const baseline = useRef<number | null>(null);
|
|
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
|
|
const fresh = (i: number) => baseline.current !== null && i >= baseline.current;
|
|
const calls = live.items
|
|
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
|
.map((i) => i.call);
|
|
const auditByCall = pairAudit(calls, audit);
|
|
const lastIndex = live.items.length - 1;
|
|
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
|
|
|
|
type Row = { key: string; at: string; node: ReactNode };
|
|
const rows: Row[] = live.items.map((it, idx) => {
|
|
const streaming = active && idx === lastIndex;
|
|
const anim = fresh(idx);
|
|
let node: ReactNode = null;
|
|
switch (it.kind) {
|
|
case "text":
|
|
node = (
|
|
<AssistantRow animateIn={anim}>
|
|
<StreamingResponse
|
|
status={streaming ? "streaming" : "complete"}
|
|
copyText={it.text}
|
|
announce={false}
|
|
showActions={!active && idx === lastText}
|
|
>
|
|
{it.text}
|
|
</StreamingResponse>
|
|
</AssistantRow>
|
|
);
|
|
break;
|
|
case "thinking":
|
|
node = (
|
|
<AssistantRow animateIn={anim} avatar={false}>
|
|
<ThinkingBlock text={it.text} streaming={streaming} />
|
|
</AssistantRow>
|
|
);
|
|
break;
|
|
case "tool":
|
|
node = (
|
|
<AssistantRow animateIn={anim} avatar={false}>
|
|
<ToolCallCard call={it.call} mode={mode} audit={auditByCall.get(it.call.toolCallId)} />
|
|
</AssistantRow>
|
|
);
|
|
break;
|
|
case "compaction":
|
|
node = (
|
|
<MessageMarker>
|
|
<Layers aria-hidden className="size-3.5 shrink-0" />
|
|
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
|
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
|
|
</MessageMarker>
|
|
);
|
|
break;
|
|
case "end":
|
|
node = it.ok ? null : <EndNotice item={it} />;
|
|
break;
|
|
}
|
|
return { 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: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
|
|
});
|
|
rows.sort((a, b) => a.at.localeCompare(b.at));
|
|
|
|
return (
|
|
<div className="space-y-4 px-1 py-2">
|
|
<UserMessage text={run.goal} at={run.createdAt} mode={mode} />
|
|
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
|
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
|
<AssistantRow avatar={false}>
|
|
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
|
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
|
{run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"}
|
|
</span>
|
|
</AssistantRow>
|
|
) : null}
|
|
{after}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── plan_only:调查步骤清单 ──
|
|
|
|
export function PlanSteps({ live, active }: { live: AgentStreamState; active: boolean }) {
|
|
const items: TodoItem[] = live.items
|
|
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
|
.map(({ call }) => ({
|
|
id: call.toolCallId,
|
|
title: capabilityMeta(call.capabilityId)?.title ?? call.capabilityId,
|
|
detail: <span className="font-mono">{call.capabilityId}</span>,
|
|
status:
|
|
call.status === "running"
|
|
? "in-progress"
|
|
: call.status === "succeeded"
|
|
? "completed"
|
|
: isDenied(call)
|
|
? "cancelled"
|
|
: "error",
|
|
}));
|
|
items.push({
|
|
id: "report",
|
|
title: "整理方案并汇报",
|
|
status: active ? (items.some((i) => i.status === "in-progress") ? "pending" : "in-progress") : "completed",
|
|
});
|
|
return <TodoList title="调查步骤(只要方案)" items={items} defaultOpen={active} collapseOnComplete />;
|
|
}
|
|
|
|
// ── 预算用量条 ──
|
|
|
|
function Meter({
|
|
label,
|
|
used,
|
|
max,
|
|
format,
|
|
exceeded,
|
|
}: {
|
|
label: string;
|
|
used: number | undefined;
|
|
max?: number;
|
|
format: (n: number) => string;
|
|
exceeded?: boolean;
|
|
}) {
|
|
const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null;
|
|
const over = exceeded || (pct !== null && pct >= 100);
|
|
const near = !over && pct !== null && pct >= 80;
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className="flex items-baseline justify-between gap-2 text-meta">
|
|
<span className={cn("text-muted-foreground", over && "font-medium text-destructive")}>
|
|
{label}
|
|
{over ? " · 已超限" : near ? " · 接近上限" : ""}
|
|
</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]" aria-hidden>
|
|
{pct !== null ? (
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
|
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
|
|
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 end = [...live.items].reverse().find((i) => i.kind === "end");
|
|
const exceeded = end?.kind === "end" && end.reason === "budget" ? end.budget : null;
|
|
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)}
|
|
exceeded={exceeded === "tool_calls"}
|
|
/>
|
|
<Meter
|
|
label="token"
|
|
used={u.totalTokens}
|
|
max={run.budget.maxTokens}
|
|
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
|
exceeded={exceeded === "tokens"}
|
|
/>
|
|
<Meter
|
|
label="时长"
|
|
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
|
max={run.budget.maxDurationSec}
|
|
format={(n) => `${n}s`}
|
|
exceeded={exceeded === "duration"}
|
|
/>
|
|
<Meter
|
|
label="费用(估算)"
|
|
used={u.costUsd}
|
|
max={run.budget.maxCostUsd}
|
|
format={(n) => `$${n.toFixed(3)}`}
|
|
exceeded={exceeded === "cost"}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 审批卡片 ──
|
|
|
|
const PLAN_CAPABILITY: Record<string, KnownCapabilityId> = {
|
|
acquisition_plan: "download.submit",
|
|
organize_plan: "organize.execute",
|
|
policy_revision: "policy.apply",
|
|
};
|
|
const PLAN_LABEL: Record<string, string> = {
|
|
acquisition_plan: "下载计划",
|
|
organize_plan: "整理计划",
|
|
policy_revision: "策略版本",
|
|
};
|
|
|
|
export function ApprovalItem({ approval: a }: { approval: Approval }) {
|
|
const admin = useIsAdminSession();
|
|
const decide = useDecideApproval();
|
|
const cap = PLAN_CAPABILITY[a.planType];
|
|
const risk = cap ? capabilityMeta(cap)?.riskLevel : undefined;
|
|
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 (
|
|
<ApprovalCard
|
|
status={status}
|
|
title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`}
|
|
description={a.reason ?? "计划变化后,这份审批自动失效。"}
|
|
badges={
|
|
<>
|
|
{risk ? <RiskBadge level={risk} /> : null}
|
|
{cap ? <StatusBadge>执行能力 · {cap}</StatusBadge> : null}
|
|
</>
|
|
}
|
|
onApprove={() => act("approved")}
|
|
onReject={() => act("rejected")}
|
|
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
|
|
result={
|
|
a.decidedAt
|
|
? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}`
|
|
: undefined
|
|
}
|
|
>
|
|
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
|
<dt className="text-muted-foreground">计划</dt>
|
|
<dd className="min-w-0 font-mono break-all text-foreground/85">
|
|
{a.planType} · {a.planId}
|
|
</dd>
|
|
<dt className="text-muted-foreground">payload hash</dt>
|
|
<dd className="min-w-0 font-mono break-all text-foreground/85">{a.payloadHash}</dd>
|
|
<dt className="text-muted-foreground">版本</dt>
|
|
<dd className="text-foreground/85 tabular-nums">r{a.expectedRevision}</dd>
|
|
<dt className="text-muted-foreground">到期</dt>
|
|
<dd className={cn("text-foreground/85", expired && "text-destructive")}>
|
|
{a.expiresAt ? formatDateTime(a.expiresAt) : "不过期"}
|
|
{expired ? "(已过期)" : ""}
|
|
</dd>
|
|
</dl>
|
|
</ApprovalCard>
|
|
);
|
|
}
|
|
|
|
// ── 最终汇报:模型说的 vs 系统核实的 ──
|
|
|
|
const PLAN_STATUS_TONE = (s: string): StatusTone =>
|
|
["succeeded", "completed", "submitted", "approved"].includes(s)
|
|
? "success"
|
|
: ["failed", "rejected", "error", "cancelled"].includes(s)
|
|
? "destructive"
|
|
: "warning";
|
|
|
|
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
|
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 denied = calls.filter(isDenied).length;
|
|
const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s && !isDenied(c)).length;
|
|
const state = report ? REPORT_STATE[report.state] : null;
|
|
|
|
const sources: CitationItem[] = report
|
|
? [
|
|
...report.downloads.map((d) => ({
|
|
id: `d${d.downloadTaskId}`,
|
|
icon: <Download />,
|
|
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
|
|
domain: "下载任务",
|
|
meta: <StatusBadge tone={PLAN_STATUS_TONE(d.status)}>{d.status}</StatusBadge>,
|
|
to: `/console/tasks/${d.downloadTaskId}`,
|
|
})),
|
|
...report.organization.map((o) => ({
|
|
id: `o${o.recordId}`,
|
|
icon: <FolderInput />,
|
|
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
|
|
domain: "整理记录",
|
|
meta: <StatusBadge tone={PLAN_STATUS_TONE(o.status)}>{o.status}</StatusBadge>,
|
|
to: `/console/organize/${o.recordId}`,
|
|
})),
|
|
]
|
|
: [];
|
|
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
|
|
|
return (
|
|
<section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
|
|
<div className="min-w-0 rounded-xl border border-dashed border-border 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 aria-hidden className="size-3.5" />
|
|
模型说的(未经核查)
|
|
</p>
|
|
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
|
|
{said || "(没有文字汇报)"}
|
|
</StreamingResponse>
|
|
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
|
</div>
|
|
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
|
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
|
{report ? (
|
|
<ShieldCheck aria-hidden className="size-3.5" />
|
|
) : (
|
|
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
系统核实的结果
|
|
</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}
|
|
{denied ? <StatusBadge tone="destructive">被拒绝 {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="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
|
|
<dt className="text-meta text-muted-foreground">{k}</dt>
|
|
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{report.plans.length ? (
|
|
<ul className="mt-2 space-y-1 text-meta">
|
|
{report.plans.map((p) => (
|
|
<li key={p.planId} className="flex min-w-0 items-center gap-2">
|
|
<span className="text-muted-foreground">下载计划</span>
|
|
<code className="min-w-0 truncate font-mono text-foreground/80">{p.planId.slice(0, 8)}</code>
|
|
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{sources.length ? <CitationList citations={sources} className="mt-2" /> : null}
|
|
{imported.length ? (
|
|
<FileDiff
|
|
className="mt-2"
|
|
file="入库目标"
|
|
defaultOpen={false}
|
|
lines={imported.map((o) => ({
|
|
id: o.recordId,
|
|
type: o.status === "succeeded" ? ("added" as const) : ("context" as const),
|
|
content: o.targetPath,
|
|
note: o.status,
|
|
}))}
|
|
/>
|
|
) : null}
|
|
<p className="mt-2 text-meta text-muted-foreground">
|
|
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
|
|
)。"已提交"指已进入下载器,整理成功才算入库。
|
|
</p>
|
|
</>
|
|
) : (
|
|
<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>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
|
|
return (
|
|
<StatusBadge tone={RUN_STATUS_TONE[status]}>
|
|
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin motion-reduce:animate-none" /> : null}
|
|
{RUN_STATUS_LABEL[status]}
|
|
</StatusBadge>
|
|
);
|
|
}
|
|
|
|
/** 滚动时保持最新一次状态播报(给读屏用的礼貌播报,流式正文本身不逐字播报) */
|
|
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 (
|
|
<span className="sr-only" role="status" aria-live="polite">
|
|
{msg}
|
|
</span>
|
|
);
|
|
}
|