Files
UHD_Media_Tool/apps/web/app/components/agent/agent-subagent-tree.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

136 lines
5.9 KiB
TypeScript

import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`);
return parts.join(" · ");
}
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
function budgetText(b: Record<string, number | undefined>) {
const parts: string[] = [];
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
return parts.length ? parts.join(" · ") : "沿用设置";
}
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
const kids = byParent.get(node.id) ?? [];
const [open, setOpen] = useState(true);
return (
<li>
<div className="flex min-w-0 items-start gap-1.5 rounded-lg px-1.5 py-1 hover:bg-foreground/[0.04]">
{kids.length ? (
<button
type="button"
aria-expanded={open}
aria-label={open ? "收起子会话" : "展开子会话"}
onClick={() => setOpen(!open)}
className="mt-0.5 cursor-pointer rounded-tight text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight className={cn("size-4 transition-transform duration-micro", open && "rotate-90")} />
</button>
) : (
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<Link
to={href(node.id)}
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{splitContextGoal(node.goal).text}
</Link>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
<span className="text-meta text-muted-foreground tabular-nums">
{usageLine({
toolCalls: node.toolCallCount,
totalTokens: node.usage.totalTokens,
costUsd: node.usage.costUsd,
})}
</span>
</div>
{node.spawnReason ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
) : null}
</div>
</div>
{kids.length && open ? (
<ul className={cn("ml-3 border-l border-border/70 pl-2", depth > 2 && "ml-1")}>
{kids.map((k) => (
<Node key={k.id} node={k} byParent={byParent} depth={depth + 1} />
))}
</ul>
) : null}
</li>
);
}
/** 子会话树 + 父会话链接;两者都没有时返回 null */
export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
const { run, parent, children = [], familyUsage } = snapshot;
if (!parent && children.length === 0) return null;
const byParent = new Map<string, AgentRunNode[]>();
for (const c of children) {
const k = c.parentRunId ?? "";
byParent.set(k, [...(byParent.get(k) ?? []), c]);
}
const direct = byParent.get(run.id) ?? [];
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
return (
<section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
{parent ? (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<CornerLeftUp aria-hidden className="size-3.5" />
父会话
</p>
<Link
to={href(parent.id)}
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{splitContextGoal(parent.goal).text}
</Link>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
</p>
</div>
) : null}
{direct.length ? (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5" />
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
</p>
<ul className="space-y-0.5">
{direct.map((c) => (
<Node key={c.id} node={c} byParent={byParent} depth={1} />
))}
</ul>
</div>
) : null}
</section>
);
}
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
return <span className="ml-auto tabular-nums">会话树合计 {usageLine(usage)}</span>;
}