- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
|
||
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react";
|
||
import { useId, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
|
||
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
|
||
import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
|
||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||
import type { ApprovalView, OptionsView } from "./use-agent-stream.ts";
|
||
|
||
// 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。
|
||
// 事件形状见 packages/contracts/src/agent-events.ts;服务端逻辑见 apps/server/src/modules/agent/interaction.ts。
|
||
|
||
const STEP_STATUS: Record<AgentPlanStep["status"], TodoItemStatus> = {
|
||
pending: "pending",
|
||
running: "in-progress",
|
||
done: "completed",
|
||
skipped: "skipped",
|
||
failed: "error",
|
||
};
|
||
|
||
export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
||
return steps.map((s) => ({
|
||
id: s.id,
|
||
title: s.title,
|
||
status: STEP_STATUS[s.status],
|
||
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}),
|
||
}));
|
||
}
|
||
|
||
/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */
|
||
export function RunPlan({
|
||
plan,
|
||
active,
|
||
compact = false,
|
||
fill = false,
|
||
}: {
|
||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||
active: boolean;
|
||
compact?: boolean;
|
||
/** 侧栏 / 展开的概况条里:不限高、不自动收起 */
|
||
fill?: boolean;
|
||
}) {
|
||
if (!plan?.steps.length) return null;
|
||
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||
const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending");
|
||
const failed = plan.steps.some((s) => s.status === "failed");
|
||
if (compact) {
|
||
const pct = Math.round((done / plan.steps.length) * 100);
|
||
return (
|
||
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-2 text-meta">
|
||
<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}/{plan.steps.length}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1.5 h-1 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: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
return (
|
||
<TodoList
|
||
title="步骤"
|
||
items={planTodoItems(plan.steps)}
|
||
defaultOpen
|
||
collapseOnComplete={!fill && !active}
|
||
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── 方案选择 ──
|
||
|
||
const NONE = "__none__";
|
||
|
||
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */
|
||
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
|
||
const name = useId();
|
||
const choose = useChooseOption();
|
||
const [picked, setPicked] = useState<string>(view.recommendedId ?? "");
|
||
const [reason, setReason] = useState("");
|
||
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
|
||
const resolved = view.resolved;
|
||
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
|
||
const closed = !!resolved || !!sent || !waiting;
|
||
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
|
||
const submit = () => {
|
||
const optionId = picked === NONE ? null : picked;
|
||
if (!picked) return;
|
||
if (optionId === null && !reason.trim()) {
|
||
toast.error("写一句不要的原因,智能体按它调整");
|
||
return;
|
||
}
|
||
choose.mutate(
|
||
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
|
||
{
|
||
onSuccess: () => {
|
||
setSent({ optionId, reason: reason.trim() });
|
||
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
|
||
},
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
},
|
||
);
|
||
};
|
||
const outcome = resolved
|
||
? resolved.outcome === "chosen"
|
||
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}`
|
||
: resolved.outcome === "stop"
|
||
? "超时未选,按设置停止"
|
||
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
|
||
: sent
|
||
? sent.optionId
|
||
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
|
||
: `都不要:${sent.reason},等待智能体调整`
|
||
: !waiting
|
||
? "会话已不在等待这个选择"
|
||
: null;
|
||
|
||
return (
|
||
<section
|
||
aria-label="选择方案"
|
||
className={cn(
|
||
"w-full min-w-0 rounded-xl border p-3",
|
||
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
|
||
)}
|
||
>
|
||
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
|
||
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||
<span className="min-w-0 break-words">{view.question}</span>
|
||
</p>
|
||
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
|
||
<legend className="sr-only">{view.question}</legend>
|
||
{view.options.map((o) => {
|
||
const selected = closed ? chosenId === o.id : picked === o.id;
|
||
return (
|
||
<label
|
||
key={o.id}
|
||
className={cn(
|
||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||
selected && "border-foreground/40 bg-foreground/[0.04]",
|
||
closed && !selected && "opacity-60",
|
||
closed && "cursor-default",
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name={name}
|
||
value={o.id}
|
||
checked={selected}
|
||
onChange={() => setPicked(o.id)}
|
||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-caption font-medium text-foreground">{o.title}</span>
|
||
{view.recommendedId === o.id ? (
|
||
<StatusBadge>
|
||
<Star className="size-3" />
|
||
推荐
|
||
</StatusBadge>
|
||
) : null}
|
||
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
|
||
</span>
|
||
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
|
||
{o.risks.length ? (
|
||
<ul className="mt-1 space-y-0.5 text-meta text-warning">
|
||
{o.risks.map((r) => (
|
||
<li key={r} className="break-words">
|
||
风险:{r}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
{closed ? null : (
|
||
<label
|
||
className={cn(
|
||
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
||
picked === NONE && "border-foreground/40",
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name={name}
|
||
value={NONE}
|
||
checked={picked === NONE}
|
||
onChange={() => setPicked(NONE)}
|
||
className="mt-1 size-3.5 shrink-0 accent-foreground"
|
||
/>
|
||
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
|
||
</label>
|
||
)}
|
||
</fieldset>
|
||
{!closed && picked === NONE ? (
|
||
<Textarea
|
||
className="mt-2 min-h-16 text-caption"
|
||
value={reason}
|
||
maxLength={1000}
|
||
aria-label="不要的原因"
|
||
placeholder="例如:体积太大,只要 WEB-DL"
|
||
onChange={(e) => setReason(e.target.value)}
|
||
/>
|
||
) : null}
|
||
{closed ? (
|
||
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
|
||
) : (
|
||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
|
||
{picked === NONE ? "退回" : "选定"}
|
||
</Button>
|
||
<span className="text-meta text-muted-foreground">
|
||
{formatDateTime(view.expiresAt)} 前没选,
|
||
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
|
||
</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── 步骤处的审批卡 ──
|
||
|
||
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
|
||
low: { label: "低风险", tone: "neutral" },
|
||
medium: { label: "中风险", tone: "warning" },
|
||
high: { label: "高风险", tone: "destructive" },
|
||
};
|
||
const PLAN_LABEL: Record<string, string> = {
|
||
acquisition_plan: "下载计划",
|
||
organize_plan: "整理计划",
|
||
policy_revision: "策略版本",
|
||
};
|
||
const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
|
||
approved: "approved",
|
||
rejected: "rejected",
|
||
revoked: "revoked",
|
||
expired: "expired",
|
||
invalid: "expired",
|
||
consumed: "consumed",
|
||
};
|
||
|
||
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
|
||
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
|
||
const admin = useIsAdminSession();
|
||
const live = useApproval(view.approvalId);
|
||
const decide = useDecideApproval();
|
||
const [reason, setReason] = useState("");
|
||
const a = live.data;
|
||
const expired = new Date(view.expiresAt).getTime() < Date.now();
|
||
const serverStatus = a?.status ?? (view.resolved ? view.resolved.status : "pending");
|
||
const status: ApprovalCardStatus = decide.isPending
|
||
? "submitting"
|
||
: serverStatus === "pending" && expired
|
||
? "expired"
|
||
: (RESOLVED_STATUS[serverStatus] ?? (serverStatus as ApprovalCardStatus));
|
||
const cap = CAPABILITY_CATALOG[view.capabilityId as KnownCapabilityId];
|
||
const act = (s: "approved" | "rejected") =>
|
||
decide.mutate(
|
||
{
|
||
id: view.approvalId,
|
||
status: s,
|
||
payloadHash: a?.payloadHash ?? view.payloadHash,
|
||
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
|
||
},
|
||
{
|
||
onSuccess: () => toast.success(s === "approved" ? "已批准,智能体继续执行" : "已拒绝,智能体按理由调整"),
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
},
|
||
);
|
||
const decidedReason = a?.reason && a.status === "rejected" ? a.reason : view.resolved?.reason;
|
||
return (
|
||
<ApprovalCard
|
||
status={status}
|
||
title={`批准「${cap?.title ?? view.capabilityId}」?`}
|
||
description={view.summary}
|
||
badges={
|
||
<>
|
||
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
|
||
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
|
||
</>
|
||
}
|
||
onApprove={() => act("approved")}
|
||
onReject={() => act("rejected")}
|
||
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
|
||
result={
|
||
status === "rejected" && decidedReason
|
||
? `已拒绝:${decidedReason}`
|
||
: a?.decidedAt
|
||
? `${status === "approved" || status === "consumed" ? "批准" : "处理"}于 ${formatDateTime(a.decidedAt)}`
|
||
: undefined
|
||
}
|
||
>
|
||
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
|
||
<dt className="text-muted-foreground">计划</dt>
|
||
<dd className="min-w-0 font-mono break-all text-foreground/85">
|
||
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
|
||
</dd>
|
||
<dt className="text-muted-foreground">payload hash</dt>
|
||
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
|
||
<dt className="text-muted-foreground">版本</dt>
|
||
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
|
||
<dt className="text-muted-foreground">到期</dt>
|
||
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
|
||
{formatDateTime(view.expiresAt)}
|
||
</dd>
|
||
</dl>
|
||
{status === "pending" && admin ? (
|
||
<Textarea
|
||
className="mt-2 min-h-14 text-caption"
|
||
value={reason}
|
||
maxLength={2000}
|
||
aria-label="拒绝理由"
|
||
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
|
||
onChange={(e) => setReason(e.target.value)}
|
||
/>
|
||
) : null}
|
||
</ApprovalCard>
|
||
);
|
||
}
|