Files
UHD_Media_Tool/apps/web/app/components/agent/agent-run-panel.tsx
T
rootandClaude Opus 5.5 6fcf82afb0 feat(web): 智能体对话重做(AGUI-3)
- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」),
  展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。
- agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡,
  回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。
- 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠;
  「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。
- 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止,
  / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。
- 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。
- 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。
- 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。
- 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 22:41:34 +08:00

440 lines
16 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 { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts";
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
import { useId, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type AgentSessionOptions,
agentKeys,
agentMockEnabled,
isRunActive,
type RunSnapshot,
TRIGGER_LABEL,
useAbortAgentRun,
useAgentRun,
useAppendAgentMessage,
useCreateAgentRun,
useRunApprovals,
useRunInvocationAudit,
} from "~/api/agent.ts";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.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 { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
ApprovalItem,
ResultLine,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import {
NEW_RUN_COMMANDS,
PROPOSE_FIRST,
PROPOSE_FIRST_MESSAGE,
parseCommand,
RERUN,
RUN_COMMANDS,
} from "./slash-commands.ts";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */
export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null {
const { command, rest } = parseCommand(text, NEW_RUN_COMMANDS);
if (!rest) return null;
return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
}
/**
* 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框
* (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。
*/
export function GoalComposer({
onSubmit,
pending,
context,
onClearContext,
defaultGoal = "",
suggestions,
}: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
/** 上下文标签(对象名称) */
context?: string | null;
onClearContext?: () => void;
defaultGoal?: string;
/** 快捷目标:输入框为空时显示 */
suggestions?: string[];
}) {
const [goal, setGoal] = useState(defaultGoal);
const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => {
setGoal(text);
requestAnimationFrame(() => {
const ta = box.current?.querySelector("textarea");
if (!ta) return;
ta.focus();
ta.setSelectionRange(text.length, text.length);
});
};
const submit = (text: string) => {
const g = goalFromInput(text);
if (!g) {
toast.error("在指令后面写上目标");
return;
}
onSubmit(g.goal, g.opts);
};
return (
<div ref={box} className="flex flex-col gap-3">
<PromptInput
value={goal}
onValueChange={setGoal}
onSubmit={submit}
pending={pending}
minRows={2}
maxRows={10}
maxLength={4000}
aria-label="目标"
placeholder="例如:把沙丘:第二部的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"}
commands={NEW_RUN_COMMANDS}
context={context}
onClearContext={onClearContext}
/>
{suggestions?.length && !goal.trim() ? (
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
{suggestions.slice(0, 3).map((s) => (
<li key={s}>
<button
type="button"
onClick={() => fill(s)}
className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{s.endsWith(" ") || s.endsWith(":") ? `${s.trimEnd()}…` : s}
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。
* 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。
*/
export function RunOverviewBar({
snapshot,
status,
live,
active,
className,
}: {
snapshot: RunSnapshot;
status: AgentRunStatus;
live: AgentStreamState;
active: boolean;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const r = snapshot.run;
const steps = live.plan?.steps ?? [];
const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length;
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
const failed = steps.find((s) => s.status === "failed");
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
if (!steps.length && !family && !isRunActive(status)) return null;
const u = { ...r.usage, ...(live.usage ?? {}) };
return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
{isRunActive(status) ? <RunStatusBadge status={status} /> : null}
{steps.length ? (
<>
<ListChecks
aria-hidden
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
</span>
</>
) : (
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{family ? `${snapshot.children?.length ?? 0} 个子会话` : ""}
</span>
)}
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button>
{steps.length ? (
<div className="mx-3 mb-1.5 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
failed ? "bg-destructive" : "bg-foreground/60",
)}
style={{ width: `${Math.round((done / steps.length) * 100)}%` }}
/>
</div>
) : null}
<Disclosure id={id} open={open}>
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
<RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<p className="text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
</p>
<TechInfo
items={[
{ label: "模型", value: r.model ? `${r.provider ? `${r.provider}/` : ""}${r.model}` : null },
{
label: "费用",
value: u.costUsd
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
: null,
},
{ label: "token", value: u.totalTokens !== undefined ? u.totalTokens.toLocaleString("zh-CN") : null },
{
label: "工具调用",
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
},
{ label: "会话 ID", value: r.id, wide: true },
]}
/>
</div>
</Disclosure>
</section>
);
}
/**
* 一次运行:顶部概况条 → 会话流(统一最大宽度)→ 底部输入区(只有输入框和发送 / 停止)。
* 输入区的 `/重跑` 用同一个目标新开;`/先给方案` 让智能体先给方案。compact 用于抽屉。
*/
export function AgentRunPanel({
runId,
compact = false,
className,
onCreated,
}: {
runId: string;
compact?: boolean;
className?: string;
/** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void;
}) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const create = useCreateAgentRun();
const navigate = useNavigate();
const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false);
const sseStatus = useSseStatus();
const qc = useQueryClient();
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上还在等的其它审批
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
const related = new Set([
runId,
...(run.data?.children ?? []).map((c) => c.id),
...(run.data?.parent ? [run.data.parent.id] : []),
]);
useSse(
[EventType.agentRunStatus, EventType.agentToolCall],
(e) => {
const p = e.payload as { runId?: string; capabilityId?: string } | null;
if (!p?.runId) return;
const hit =
e.type === EventType.agentRunStatus
? related.has(p.runId)
: p.runId === runId && p.capabilityId === "agent.spawn";
if (hit) void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
},
!agentMockEnabled(),
);
if (run.isPending) return <LoadingState rows={4} />;
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished;
const streaming = active && !live.finished;
const shownStatus: AgentRunStatus =
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
(live.finished && (r.status === "queued" || r.status === "running")) ||
(r.status === "queued" && live.events.length > 0)
? "running"
: r.status;
const ended = !isRunActive(r.status);
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
const send = (text: string) => {
const { command, rest } = parseCommand(text, RUN_COMMANDS);
if (command === RERUN.name) {
create.mutate(
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
{
onSuccess: (n) => {
setMsg("");
created(n.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
);
return;
}
const body = command === PROPOSE_FIRST.name ? `${PROPOSE_FIRST_MESSAGE}${rest ? `\n${rest}` : ""}` : text;
append.mutate(
{ id: r.id, text: body },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
};
const column = "mx-auto w-full max-w-[88ch]";
return (
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
{agentMockEnabled() && !compact ? (
<div className={column}>
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
演示数据
</StatusBadge>
</div>
) : null}
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
live={live}
active={streaming || r.status === "waiting"}
className={column}
/>
{streaming && sseStatus === "reconnecting" ? (
<div
role="status"
className={cn(
column,
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
)}
>
<WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。</span>
</div>
) : null}
{live.error ? (
<div
role="alert"
className={cn(
column,
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
)}
>
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
<Button size="xs" variant="outline" onClick={live.retry}>
<RefreshCw />
重试
</Button>
</div>
) : null}
<MessageScroller
className={cn("min-h-48 flex-1", compact && "min-h-40")}
viewportClassName="pr-1"
contentClassName={column}
busy={streaming}
label="智能体会话记录"
>
<AgentConversation
run={r}
live={live}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items
.filter((a) => !inline.has(a.id) && a.status === "pending")
.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <ResultLine run={r} report={report} live={live} /> : null}
</>
}
/>
</MessageScroller>
<div className={cn(column, "shrink-0")}>
<PromptInput
value={msg}
onValueChange={setMsg}
onSubmit={send}
pending={append.isPending || create.isPending}
loading={streaming}
onStop={() => setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1}
maxRows={6}
maxLength={8000}
aria-label="发消息"
placeholder={ended ? "接着说,输入 / 看指令" : "补充或纠正"}
commands={RUN_COMMANDS}
/>
</div>
<ConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
title="停止这次运行?"
description="后续步骤不再执行,已完成的动作(如已提交的下载)不会撤销。"
confirmLabel="停止运行"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("已请求停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
</div>
);
}