Files
UHD_Media_Tool/apps/web/app/components/agent/agent-interaction.tsx
T
rootandClaude Opus 5.5 901b01d1a1 fix(sites): HHClub parsing, resource list layout and request restraint
HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).

Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
  rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
  name / H&R with null because the HHClub div detail page lacks them;
  detail values now fall back to the list snapshot (mergeDetailOverList),
  and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
  HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.

Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
  (longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
  defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
  burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
  concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
  health checks skip sites checked within the interval; detail refresh is
  served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
  (site_request_count, migration 0046); GET /sites/{id}/request-stats;
  site overview and resource list show requests in the last 24 hours

Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.

Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).

Takes effect after the stack is rebuilt (migration 0046).

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

339 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 className="block max-w-48 truncate">{s.note}</span> } : {}),
}));
}
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
export function RunPlan({
plan,
active,
compact = false,
}: {
plan: { steps: AgentPlanStep[]; revision: number } | null;
active: boolean;
compact?: 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={!active}
maxHeightClassName="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>
);
}