Files
UHD_Media_Tool/apps/web/app/components/agent/agent-conversation.tsx
T

874 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type AgentRunStatus,
type Approval,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
} from "@umt/contracts";
import {
Ban,
Brain,
CalendarCog,
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 { consolePath } from "~/components/console/nav.ts";
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.activity
.flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : []))
.map((d) => (
<DraftCheckCard key={d.jobId} {...d} />
))}
{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>
);
}
/** 起草巡检(schedule.draft_check)成功的输出:巡检 ID 与名称(输出摘要可能被截断,按字段取) */
export function draftCheckOf(call: ToolCallView): { jobId: string; name: string }[] {
if (call.capabilityId !== "schedule.draft_check" || call.status === "running" || call.status === "failed") return [];
if (isDenied(call) || !call.outputSummary) return [];
const jobId = /"jobId"\s*:\s*"([\w.-]+)"/.exec(call.outputSummary)?.[1];
const name = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.outputSummary)?.[1];
return jobId ? [{ jobId, name: name ?? "巡检" }] : [];
}
/** 对话里起草好的巡检:未启用,去计划页检查后启用 */
function DraftCheckCard({ jobId, name }: { jobId: string; name: string }) {
return (
<div className="flex w-full max-w-[72ch] flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 px-3 py-2.5">
<div className="min-w-0">
<p className="flex items-center gap-2 text-caption font-medium text-foreground">
<CalendarCog aria-hidden className="size-4 shrink-0 text-muted-foreground" />
巡检草稿「{name}」<StatusBadge>未启用</StatusBadge>
</p>
<p className="mt-0.5 text-meta text-muted-foreground">在计划页检查目标、间隔和能力后启用。</p>
</div>
<Button asChild size="sm" variant="outline">
<Link to={`${consolePath("schedules")}?job=${encodeURIComponent(jobId)}`}>检查并启用</Link>
</Button>
</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>
);
}