840 lines
35 KiB
TypeScript
840 lines
35 KiB
TypeScript
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 { 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<string, string> = {
|
||
tool_calls: "工具调用次数",
|
||
duration: "运行时长",
|
||
tokens: "token",
|
||
cost: "费用",
|
||
};
|
||
const COMPACT_STATUS: Record<string, string> = {
|
||
completed: "已完成",
|
||
declined: "没有压缩",
|
||
aborted: "已中止",
|
||
failed: "失败",
|
||
};
|
||
|
||
export function capabilityMeta(id: string): CapabilityDescriptor | null {
|
||
return (CAPABILITY_CATALOG as Record<string, CapabilityDescriptor>)[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 (
|
||
<div
|
||
className={cn(
|
||
"flex w-full min-w-0 flex-col items-end gap-1",
|
||
animateIn && "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none",
|
||
)}
|
||
>
|
||
{label || tag ? (
|
||
<div className="flex max-w-[85%] flex-wrap justify-end gap-1">
|
||
{tag ? (
|
||
<span className="rounded-full border border-border/70 px-2 py-0.5 text-label text-muted-foreground">
|
||
{tag}
|
||
</span>
|
||
) : null}
|
||
{label ? (
|
||
<span className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 text-label text-muted-foreground">
|
||
{label}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
data-slot="user-message"
|
||
className="max-w-[85%] rounded-2xl border border-border/60 bg-foreground/[0.05] px-3.5 py-2 text-sm leading-6 break-words whitespace-pre-wrap text-foreground"
|
||
>
|
||
{body}
|
||
</div>
|
||
{note ? <p className="px-1 text-label text-muted-foreground">{note}</p> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 进度行 ──
|
||
|
||
function ThinkingRow({ text }: { text: string }) {
|
||
const id = useId();
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<li className="min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
className="flex min-h-7 w-full cursor-pointer items-center gap-2 rounded-md px-1 text-left text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<Brain aria-hidden className="size-3.5 shrink-0" />
|
||
思考 · {text.length} 字
|
||
<ChevronDown aria-hidden className={cn("size-3", CHEVRON_CLASS, open && "rotate-180")} />
|
||
</button>
|
||
<Disclosure id={id} open={open}>
|
||
<p className="mt-1 mb-1 ml-6 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}
|
||
</p>
|
||
</Disclosure>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function StepIcon({ call }: { call: ToolCallView }) {
|
||
if (call.status === "running")
|
||
return <LoaderCircle aria-hidden className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />;
|
||
if (isDenied(call)) return <Ban aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />;
|
||
if (call.status === "failed") return <CircleAlert aria-hidden className="size-3.5 shrink-0 text-destructive" />;
|
||
return <Check aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />;
|
||
}
|
||
|
||
/** 展开后的一步:「搜资源 → 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 (
|
||
<li className="min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
className="flex min-h-7 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 text-left text-meta outline-none hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<StepIcon call={call} />
|
||
<span className={cn("shrink-0 text-foreground/85", denied && "text-muted-foreground line-through")}>
|
||
{meta?.title ?? capabilityTitle(call.capabilityId)}
|
||
</span>
|
||
{summary ? (
|
||
<span
|
||
className={cn(
|
||
"min-w-0 flex-1 truncate text-muted-foreground",
|
||
call.status === "failed" && !denied && "text-destructive",
|
||
)}
|
||
>
|
||
→ {summary}
|
||
</span>
|
||
) : (
|
||
<span className="flex-1" />
|
||
)}
|
||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||
{durationText(call.startedAt, call.endedAt) ?? ""}
|
||
</span>
|
||
</button>
|
||
<Disclosure id={id} open={open}>
|
||
<div className="mt-1 mb-2 ml-6 grid gap-2 text-meta">
|
||
<TechInfo
|
||
title="技术信息"
|
||
items={[
|
||
{ label: "能力", value: call.capabilityId },
|
||
{ label: "调用 ID", value: call.toolCallId },
|
||
{ label: "幂等键", value: audit?.idempotencyKey ?? null },
|
||
{
|
||
label: "授权依据",
|
||
value: audit?.grantId
|
||
? `持续授权 ${audit.grantId.slice(0, 8)}`
|
||
: audit?.approvalId
|
||
? `单次审批 ${audit.approvalId.slice(0, 8)}`
|
||
: null,
|
||
},
|
||
{ label: "拒绝原因", value: denied ? (authDenial(call) ?? call.reason) : null, wide: true },
|
||
{ label: "恢复补记", value: call.recovery ? "是" : null },
|
||
]}
|
||
>
|
||
<ToolCallIo call={call} />
|
||
</TechInfo>
|
||
</div>
|
||
</Disclosure>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 一段进度的一行摘要:「查了 1 个站点和媒体库 · 1 项没做 · 41 秒 ›」;运行中写「正在检测站点…」。
|
||
* 展开后每步一行;思考与工具之间的话也在里面。
|
||
*/
|
||
export function StepSummary({
|
||
activity,
|
||
running,
|
||
auditByCall,
|
||
since,
|
||
until,
|
||
}: {
|
||
activity: ActivityItem[];
|
||
running: boolean;
|
||
auditByCall?: Map<string, InvocationAudit>;
|
||
/** 这一轮开始的时间(会话开始 / 用户消息);只给回合里的第一段 */
|
||
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 (
|
||
<div className="w-full min-w-0" data-slot="step-summary">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="-mx-1 flex min-h-8 w-[calc(100%+0.5rem)] min-w-0 cursor-pointer items-center gap-2 rounded-lg px-1 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{running ? (
|
||
<LoaderCircle aria-hidden className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
|
||
) : (
|
||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||
)}
|
||
<span className="min-w-0 truncate">{head}</span>
|
||
{denied ? <span className="shrink-0">· {denied} 项没做</span> : null}
|
||
{failed ? <span className="shrink-0 text-destructive">· {failed} 项失败</span> : null}
|
||
{counted ? <span className="shrink-0 tabular-nums">· {counted}</span> : null}
|
||
{span !== null && !running ? <span className="shrink-0 tabular-nums">· {durationLabel(span)}</span> : null}
|
||
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-90")} />
|
||
</button>
|
||
<Disclosure id={id} open={open}>
|
||
<ul className="mt-0.5 mb-1 flex flex-col border-l border-border/70 pl-2">
|
||
{activity.map((a) =>
|
||
a.kind === "tool" ? (
|
||
<ToolRow key={a.key} call={a.call} audit={auditByCall?.get(a.call.toolCallId)} />
|
||
) : a.kind === "thinking" ? (
|
||
<ThinkingRow key={a.key} text={a.text} />
|
||
) : (
|
||
<li
|
||
key={a.key}
|
||
className="line-clamp-3 px-1 py-1 text-meta break-words whitespace-pre-wrap text-muted-foreground"
|
||
>
|
||
{humanizeReport(a.text.trim()).text}
|
||
</li>
|
||
),
|
||
)}
|
||
</ul>
|
||
</Disclosure>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 没做的事 ──
|
||
|
||
/** 回合末尾一行:「没做:检测站点,需要你批准 [批准并重试]」;同一能力只写一次 */
|
||
export function DeniedLines({ runId, calls }: { runId: string; calls: ToolCallView[] }) {
|
||
const admin = useIsAdminSession() || agentMockEnabled();
|
||
const retry = useApproveAndRetry();
|
||
const [sent, setSent] = useState<Set<string>>(new Set());
|
||
const rows = [...new Map(calls.map((c) => [c.capabilityId, c] as const)).values()];
|
||
if (!rows.length) return null;
|
||
return (
|
||
<ul aria-label="没做的操作" className="flex w-full min-w-0 flex-col gap-1">
|
||
{rows.map((c) => {
|
||
const title = capabilityTitle(c.capabilityId);
|
||
const r = deniedReason(c);
|
||
const done = sent.has(c.capabilityId);
|
||
return (
|
||
<li key={c.toolCallId} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-caption">
|
||
<Ban aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
|
||
<span className="min-w-0 text-foreground/85">
|
||
没做:{title},{r.text}
|
||
</span>
|
||
{r.approvable && admin ? (
|
||
<Button
|
||
size="xs"
|
||
variant="outline"
|
||
disabled={retry.isPending || done}
|
||
onClick={() =>
|
||
retry.mutate(
|
||
{ runId, capabilityId: c.capabilityId, title },
|
||
{
|
||
onSuccess: () => {
|
||
setSent((s) => new Set(s).add(c.capabilityId));
|
||
toast.success("已批准,智能体重试这一步");
|
||
},
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
},
|
||
)
|
||
}
|
||
>
|
||
<RotateCcw />
|
||
{done ? "已批准" : "批准并重试"}
|
||
</Button>
|
||
) : null}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ── 结束 / 压缩提示 ──
|
||
|
||
function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
|
||
const budget = item.reason === "budget";
|
||
const aborted = item.reason === "aborted";
|
||
return (
|
||
<p
|
||
role="alert"
|
||
className={cn(
|
||
"flex w-full min-w-0 items-start gap-2 text-caption",
|
||
budget ? "text-warning" : aborted ? "text-muted-foreground" : "text-destructive",
|
||
)}
|
||
>
|
||
<CircleSlash aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||
<span className="min-w-0 break-words">
|
||
{budget
|
||
? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的动作不会撤销;调大预算后发消息可以继续。`
|
||
: aborted
|
||
? "已停止。"
|
||
: `运行失败${item.message ? `:${humanizeReason(item.message) ?? item.message}` : ""}`}
|
||
</span>
|
||
</p>
|
||
);
|
||
}
|
||
|
||
// ── 审计配对与计划 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<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,
|
||
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<number | null>(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 ? (
|
||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground" data-slot="turn-status">
|
||
<MessageTyping label={typingState} />
|
||
<span className="tabular-nums">
|
||
{typingState}
|
||
{turnCount ? ` · ${turnCount}` : "…"}
|
||
</span>
|
||
</span>
|
||
) : null;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5 px-1 py-2">
|
||
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
|
||
{blocks.map((b, bi) =>
|
||
b.kind === "user" ? (
|
||
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
|
||
) : (
|
||
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
|
||
{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 (
|
||
<div key={p.key} className="flex w-full min-w-0 flex-col gap-1.5">
|
||
{p.activity.length ? (
|
||
<StepSummary
|
||
activity={p.activity}
|
||
running={running || thinkingNow}
|
||
auditByCall={auditByCall}
|
||
since={pi === 0 ? turnSince(blocks, bi, run) : null}
|
||
until={p.body[0]?.at ?? null}
|
||
/>
|
||
) : null}
|
||
{p.body.map((t) => {
|
||
const streaming = streamingText && t.key === lastKey;
|
||
// 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
|
||
const human = streaming ? null : humanizeReport(t.text, names);
|
||
return (
|
||
<div key={t.key} className={cn("w-full min-w-0", freshKeys.has(t.key) && anim)}>
|
||
<StreamingResponse
|
||
className="max-w-[72ch]"
|
||
status={streaming ? "streaming" : "complete"}
|
||
copyText={human?.text ?? t.text}
|
||
announce={false}
|
||
showActions={false}
|
||
>
|
||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||
{human ? <Markdown text={human.text} /> : t.text}
|
||
</StreamingResponse>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
case "options":
|
||
return (
|
||
<div key={p.key} className={cn("w-full min-w-0", fresh && anim)}>
|
||
<OptionsCard runId={run.id} view={p.view} waiting={waiting} />
|
||
</div>
|
||
);
|
||
case "approval":
|
||
return (
|
||
<div key={p.key} className={cn("w-full min-w-0", fresh && anim)}>
|
||
<InlineApproval view={p.view} />
|
||
</div>
|
||
);
|
||
case "compaction":
|
||
return (
|
||
<MessageMarker key={p.key}>
|
||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||
整理上下文 ·{" "}
|
||
{p.item.done ? (COMPACT_STATUS[p.item.status ?? "completed"] ?? p.item.status) : "进行中…"}
|
||
</MessageMarker>
|
||
);
|
||
case "end":
|
||
return p.item.ok ? null : <EndNotice key={p.key} item={p.item} />;
|
||
default:
|
||
return null;
|
||
}
|
||
})}
|
||
{b.denied.length ? <DeniedLines runId={run.id} calls={b.denied} /> : null}
|
||
{typing && b === lastBlock ? typing : null}
|
||
</section>
|
||
),
|
||
)}
|
||
{typing && lastBlock?.kind !== "turn" ? <div>{typing}</div> : null}
|
||
{after}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 回合开始的时间:前一条用户消息;第一轮是会话开始 */
|
||
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 (
|
||
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
|
||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
|
||
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
|
||
<span className="min-w-0">
|
||
{facts.map((f, i) => (
|
||
<span key={f.text}>
|
||
{i ? "、" : ""}
|
||
{f.to ? (
|
||
<Link
|
||
to={f.to}
|
||
className={cn(
|
||
"text-foreground/85 underline-offset-2 hover:underline",
|
||
f.tone === "warning" && "text-warning",
|
||
)}
|
||
>
|
||
{f.text}
|
||
</Link>
|
||
) : (
|
||
<span className={cn("text-foreground/85", f.tone === "warning" && "text-warning")}>{f.text}</span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</span>
|
||
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
|
||
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
|
||
{showCost && u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
|
||
{mismatch.length ? (
|
||
<span
|
||
data-slot="report-mismatch"
|
||
title={mismatch.join("\n")}
|
||
className="rounded-tight border border-warning/40 px-1 text-warning"
|
||
>
|
||
与汇报不一致
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
|
||
<TechInfo
|
||
items={[
|
||
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
|
||
{
|
||
label: "token",
|
||
value:
|
||
u.totalTokens === undefined
|
||
? null
|
||
: `${formatTokens(u.totalTokens)}${run.budget.maxTokens ? ` / ${formatTokens(run.budget.maxTokens)}` : ""}`,
|
||
},
|
||
{
|
||
label: "工具调用",
|
||
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
|
||
},
|
||
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
|
||
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
|
||
{ label: "与汇报不一致", value: mismatch.length ? mismatch.join(";") : null, wide: true, mono: false },
|
||
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
|
||
{ label: "原文", value: rawReport, wide: true, mono: false },
|
||
{ label: "会话 ID", value: run.id, wide: true },
|
||
{ label: "原始错误", value: run.error ? JSON.stringify(run.error) : null, wide: true },
|
||
]}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── 本次运行计划上的其它审批(不在会话流里的) ──
|
||
|
||
const PLAN_CAPABILITY: Record<string, KnownCapabilityId> = {
|
||
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 (
|
||
<ApprovalCard
|
||
status={status}
|
||
title={`批准「${cap ? capabilityTitle(cap) : a.planType}」?`}
|
||
description={a.reason ?? undefined}
|
||
onApprove={() => act("approved")}
|
||
onReject={() => act("rejected")}
|
||
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
|
||
result={
|
||
a.decidedAt
|
||
? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}`
|
||
: undefined
|
||
}
|
||
>
|
||
<TechInfo
|
||
items={[
|
||
{ label: "计划", value: `${a.planType} · ${a.planId}`, wide: true },
|
||
{ label: "payload hash", value: a.payloadHash, wide: true },
|
||
{ label: "版本", value: `r${a.expectedRevision}` },
|
||
{ label: "到期", value: a.expiresAt ? formatDateTime(a.expiresAt) : "不过期" },
|
||
]}
|
||
/>
|
||
</ApprovalCard>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|