786 lines
31 KiB
TypeScript
786 lines
31 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,
|
||
capabilityTitle,
|
||
deniedReason,
|
||
durationLabel,
|
||
isDenied,
|
||
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 summary = denied ? deniedReason(call).text : toolSummary(call);
|
||
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,
|
||
}: {
|
||
activity: ActivityItem[];
|
||
running: boolean;
|
||
auditByCall?: Map<string, InvocationAudit>;
|
||
}) {
|
||
const id = useId();
|
||
const [open, setOpen] = useState(false);
|
||
const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : []));
|
||
const current = running ? calls.find((c) => c.status === "running") : undefined;
|
||
const done = activitySummary(calls.filter((c) => c.status !== "running"));
|
||
const denied = calls.filter(isDenied).length;
|
||
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
|
||
const span = activitySpan(activity);
|
||
const thinking = activity.some((a) => a.kind === "thinking");
|
||
const head = current
|
||
? `${done ? `${done},` : ""}${runningText(current)}…`
|
||
: done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明");
|
||
return (
|
||
<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}
|
||
{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"
|
||
>
|
||
{a.text.trim()}
|
||
</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");
|
||
const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting;
|
||
const typing = showTyping ? (
|
||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||
{run.status === "waiting"
|
||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||
: run.status === "queued"
|
||
? "排队中…"
|
||
: "正在处理…"}
|
||
</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) =>
|
||
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) => {
|
||
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} />
|
||
) : 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>
|
||
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
|
||
function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
|
||
const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
|
||
return (
|
||
<TechInfo
|
||
title="正文原文"
|
||
className="mt-1"
|
||
items={[
|
||
{
|
||
label: "换掉的标识",
|
||
value: (
|
||
<ul className="space-y-0.5">
|
||
{uniq.map((r) => (
|
||
<li key={r.from}>
|
||
{r.from} → {r.to || "(省略)"}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
),
|
||
wide: true,
|
||
},
|
||
]}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── 实际结果 ──
|
||
|
||
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
|
||
export function resultText(report: GoalReport | null): string {
|
||
if (!report) return "汇总中…";
|
||
const r = report;
|
||
switch (r.state) {
|
||
case "no_actions":
|
||
return "没有提交下载或整理";
|
||
case "waiting":
|
||
return r.imported
|
||
? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行`
|
||
: `${r.submitted || r.inProgress} 个下载已提交,还没入库`;
|
||
case "completed":
|
||
return `已入库 ${r.imported} 个`;
|
||
case "partial":
|
||
return [
|
||
r.imported ? `已入库 ${r.imported} 个` : "",
|
||
r.inProgress ? `${r.inProgress} 个还在进行` : "",
|
||
r.failed ? `${r.failed} 个失败` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(",");
|
||
case "failed":
|
||
return r.failed ? `${r.failed} 个没有成功` : "没有达成目标";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。
|
||
* 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。
|
||
*/
|
||
export function ResultLine({
|
||
run,
|
||
report,
|
||
live,
|
||
subagents = 0,
|
||
}: {
|
||
run: AgentRunView;
|
||
report: GoalReport | null;
|
||
live: AgentStreamState;
|
||
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
|
||
subagents?: number;
|
||
}) {
|
||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||
const durationMs =
|
||
u.durationMs ??
|
||
(run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null);
|
||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||
const links = report
|
||
? [
|
||
...report.downloads.map((d) => ({
|
||
id: d.downloadTaskId,
|
||
label: d.name ?? "下载任务",
|
||
to: `/console/downloads/${d.downloadTaskId}`,
|
||
})),
|
||
...report.organization.map((o) => ({
|
||
id: o.recordId,
|
||
label: o.targetPath?.split("/").pop() || "整理记录",
|
||
to: `/console/organize/${o.recordId}`,
|
||
})),
|
||
]
|
||
: [];
|
||
const bad = report?.state === "failed" || report?.state === "partial";
|
||
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
|
||
return (
|
||
<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={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
|
||
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
|
||
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
|
||
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
|
||
</p>
|
||
{links.length ? (
|
||
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-0.5 text-meta">
|
||
{links.slice(0, 4).map((l) => (
|
||
<li key={l.id} className="min-w-0 max-w-full">
|
||
<Link to={l.to} className="block truncate text-foreground/85 underline-offset-2 hover:underline">
|
||
{l.label}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
{links.length > 4 ? <li className="text-muted-foreground">等 {links.length} 项</li> : null}
|
||
</ul>
|
||
) : null}
|
||
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
|
||
<TechInfo
|
||
items={[
|
||
{
|
||
label: "说明",
|
||
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
|
||
wide: true,
|
||
mono: false,
|
||
},
|
||
{ 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: report?.summary ?? null, 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>
|
||
);
|
||
}
|