Files
UHD_Media_Tool/apps/web/app/components/agent/agent-interaction.tsx
T
rootandClaude Opus 5.5 cabd772243 fix(sites): normalize HHClub RSS items, complete detail page parsing (HH-2)
- RSS: split NexusPHP "[category]name[subtitle][size][uploader]" titles in the
  site adapter layer (category id/name/media type, clean title, subtitle,
  size); snapshot fields win on merge; lazily renormalize old RSS rows and
  re-emit release.observed; unknown list fields show an em dash
- Detail: HHClub section layout (douban info block as description,
  screenshots section, IMDb/TMDB from MediaInfo lines, upload time field,
  on-demand viewfilelist.php once); site CDN screenshots get a Referer
- Site request count now includes site-bound RSS fetches
- Origin view shows "自动化" when idle and "Agent" only after recent
  automation actions (lastAutomationAt); agent step titles no longer get
  squeezed to a sliver by long notes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 17:30:25 +08:00

342 lines
14 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 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("写一句不要的原因,Agent 按它调整");
return;
}
choose.mutate(
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
{
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
},
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},等待 Agent 继续`
: `都不要:${sent.reason},等待 Agent 调整`
: !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" ? "自动选推荐项" : "Agent 停止"}。
</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" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
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="拒绝时写理由或建议(可选),Agent 按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
</ApprovalCard>
);
}