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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:18:15 +08:00
1 parent 20b63db254
commit 901b01d1a1
43 files changed
+1123 -1216

No files matched your search

@@ -19,6 +19,7 @@ import {
SiteHealthResult, SiteHealthResult,
SiteRefreshLoginAccepted, SiteRefreshLoginAccepted,
SiteRefreshLoginInput, SiteRefreshLoginInput,
SiteRequestStats,
SiteSnapshot, SiteSnapshot,
SiteUserDataPoint, SiteUserDataPoint,
SiteUserDataQuery, SiteUserDataQuery,
@@ -259,6 +260,49 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
return { items: toApiList(p.items), nextCursor: p.nextCursor }; return { items: toApiList(p.items), nextCursor: p.nextCursor };
}, },
); );
route(
app,
{
method: "get",
path: "/sites/{id}/request-stats",
scope: "read",
summary: "站点请求统计:最近 24 小时对该站发出的请求次数(按小时)与生效的访问默认",
tags: T_SITES,
params: IdParam,
response: SiteRequestStats,
},
async ({ params }) => {
const site = await db()
.selectFrom("site")
.select(["id", "key", "snapshot_interval_sec"])
.where("id", "=", params.id)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const now = rt().now().getTime();
const cur = Math.floor(now / 3_600_000) * 3_600_000;
const since = new Date(cur - 23 * 3_600_000);
const rows = await db()
.selectFrom("site_request_count")
.select(["hour", "count"])
.where("site_id", "=", site.id)
.where("hour", ">=", since)
.execute();
const byHour = new Map(rows.map((r) => [r.hour.getTime(), r.count]));
const hourly = Array.from({ length: 24 }, (_, i) => {
const t = since.getTime() + i * 3_600_000;
return { hour: new Date(t).toISOString(), count: byHour.get(t) ?? 0 };
});
const access = await rt().siteAccess(site.key);
return {
siteId: site.id,
last24h: hourly.reduce((a, h) => a + h.count, 0),
hourly,
snapshotIntervalSec: site.snapshot_interval_sec ?? access.snapshotIntervalSec,
access,
};
},
);
// ── W9-A 站点中心:删除、健康检测、刷新登录、用户数据、凭证 ── // ── W9-A 站点中心:删除、健康检测、刷新登录、用户数据、凭证 ──
route( route(
@@ -14,7 +14,7 @@ import {
type TmdbSharedCache, type TmdbSharedCache,
} from "@umt/adapters"; } from "@umt/adapters";
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts"; import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
import { pgTokenBucketFor, type SiteRow } from "@umt/db"; import { pgTokenBucketFor, type SiteRow, sql } from "@umt/db";
import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts"; import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts";
import type { ServerContext } from "../types.ts"; import type { ServerContext } from "../types.ts";
@@ -170,7 +170,7 @@ export class SourcesRuntime {
if (hit && hit.sig === sig) return hit.adapter; if (hit && hit.sig === sig) return hit.adapter;
const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit); const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit);
// 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队 // 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队
const limiter = const inner =
this.overrides.sharedRateLimit === false this.overrides.sharedRateLimit === false
? createSiteRateLimiter(lo) ? createSiteRateLimiter(lo)
: createSharedSiteRateLimiter({ : createSharedSiteRateLimiter({
@@ -183,6 +183,7 @@ export class SourcesRuntime {
"站点共享限流不可用,本次退回进程内限流", "站点共享限流不可用,本次退回进程内限流",
), ),
}); });
const limiter = countingLimiter(inner, () => this.countSiteRequest(site.id));
const cfg = (site.config ?? {}) as { apiBaseUrl?: string }; const cfg = (site.config ?? {}) as { apiBaseUrl?: string };
const baseUrl = const baseUrl =
def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url; def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url;
@@ -197,6 +198,41 @@ export class SourcesRuntime {
return adapter; return adapter;
} }
/**
* 站点请求计数(site_request_count,按小时聚合):每拿到一个令牌记一次。写失败只记日志,不影响请求;
* 顺手删 7 天前的行(每进程每小时最多一次)。
*/
private lastCountPrune = 0;
countSiteRequest(siteId: string): void {
const hour = new Date(Math.floor(this.now().getTime() / 3_600_000) * 3_600_000);
const db = this.ctx.db;
void sql`insert into site_request_count (site_id, hour, count) values (${siteId}, ${hour}, 1)
on conflict (site_id, hour) do update set count = site_request_count.count + 1`
.execute(db)
.then(async () => {
if (Date.now() - this.lastCountPrune < 3_600_000) return;
this.lastCountPrune = Date.now();
await sql`delete from site_request_count where hour < ${new Date(hour.getTime() - 7 * 86_400_000)}`.execute(db);
})
.catch((err) =>
this.ctx.logger.warn({ siteId, err: { message: (err as Error).message } }, "站点请求计数写入失败"),
);
}
/** 最近 hours 小时(含当前小时)该站点的请求次数 */
async siteRequestCount(siteId: string, hours = 24): Promise<number> {
const hour = Math.floor(this.now().getTime() / 3_600_000) * 3_600_000;
const since = new Date(hour - (hours - 1) * 3_600_000);
const r = await sql<{ n: number }>`select coalesce(sum(count), 0)::int as n from site_request_count
where site_id = ${siteId} and hour >= ${since}`.execute(this.ctx.db);
return Number(r.rows[0]?.n ?? 0);
}
/** 站点访问默认:站点定义 umt.defaults 与 settings.sites 取更保守的一方 */
async siteAccess(siteKey: string): Promise<SiteAccess> {
return siteAccessFor(getSiteDefinition(siteKey) ?? null, await this.siteDefaults());
}
/** 已建立的站点限流器(测试与诊断用) */ /** 已建立的站点限流器(测试与诊断用) */
limiterOf(siteId: string): SiteRateLimiter | null { limiterOf(siteId: string): SiteRateLimiter | null {
return this.adapters.get(siteId)?.limiter ?? null; return this.adapters.get(siteId)?.limiter ?? null;
@@ -260,6 +296,63 @@ function sameUrl(a: string, b: string): boolean {
return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
} }
/** 站点访问默认(已合并定义与设置) */
export interface SiteAccess {
/** 站点行没设快照周期时用 */
snapshotIntervalSec: number;
snapshotMaxPages: number;
healthCheckIntervalSec: number;
detailCacheSec: number;
searchConcurrency: number;
}
type DefinitionDefaults = {
snapshotIntervalSec?: number;
snapshotMaxPages?: number;
healthCheckIntervalSec?: number;
detailCacheSec?: number;
searchConcurrency?: number;
};
/** 周期 / 缓存取较长,页数 / 并发取较小(定义值是按站点实测给的保守下限) */
export function siteAccessFor(def: SiteDefinition | null, s: SitesSettings): SiteAccess {
const d = ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults ?? {}) as DefinitionDefaults;
const max = (a: number | undefined, b: number) => (a === undefined ? b : Math.max(a, b));
const min = (a: number | undefined, b: number) => (a === undefined ? b : Math.min(a, b));
return {
snapshotIntervalSec: max(d.snapshotIntervalSec, s.defaultSnapshotIntervalSec),
snapshotMaxPages: min(d.snapshotMaxPages, s.defaultSnapshotMaxPages),
healthCheckIntervalSec: max(d.healthCheckIntervalSec, s.defaultHealthCheckIntervalSec),
detailCacheSec: max(d.detailCacheSec, s.defaultDetailCacheSec),
searchConcurrency: d.searchConcurrency ?? 1,
};
}
/** 同站实时搜索并发(只看站点定义,同步取值;缺省 1) */
export function siteSearchConcurrency(siteKey: string): number {
const def = getSiteDefinition(siteKey);
return ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults?.searchConcurrency ?? 1) as number;
}
/** 包一层限流器:每发放一个令牌回调一次(计数) */
function countingLimiter(inner: SiteRateLimiter, onGrant: () => void): SiteRateLimiter {
return {
get stats() {
return inner.stats;
},
async acquire(signal) {
await inner.acquire(signal);
onGrant();
},
tryAcquire() {
const ok = inner.tryAcquire();
if (ok) onGrant();
return ok;
},
penalize: (ms) => inner.penalize(ms),
};
}
type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number }; type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
/** /**
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { getSiteDefinition } from "@umt/adapters";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { SourcesRuntime } from "./runtime.ts"; import { type SourcesRuntime, siteAccessFor } from "./runtime.ts";
import { archiveStalePool } from "./service/identity-decisions.ts"; import { archiveStalePool } from "./service/identity-decisions.ts";
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、 // worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
@@ -67,24 +68,32 @@ export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
export async function tickSnapshots(rt: SourcesRuntime): Promise<number> { export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt; const { ctx } = rt;
const now = rt.now(); const now = rt.now();
// 站点未设周期时用全局默认(settings.sites.defaultSnapshotIntervalSec) // 站点未设周期时用站点定义与全局默认里更长的一个(settings.sites.defaultSnapshotIntervalSec / umt.defaults)
const fallback = (await rt.siteDefaults()).defaultSnapshotIntervalSec; const defaults = await rt.siteDefaults();
const sites = await sql<{ id: string; snapshot_interval_sec: number }>` const rows = await ctx.db
select id, coalesce(snapshot_interval_sec, ${fallback}::int) as snapshot_interval_sec from site .selectFrom("site")
where enabled and snapshot_enabled and deleted_at is null .select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"])
and (last_snapshot_at is null .where("enabled", "=", true)
or last_snapshot_at + make_interval(secs => coalesce(snapshot_interval_sec, ${fallback}::int)) <= ${now})`.execute( .where("snapshot_enabled", "=", true)
ctx.db, .where("deleted_at", "is", null)
); .execute();
for (const s of sites.rows) { const due: Array<{ id: string; interval: number }> = [];
const slot = Math.floor(now.getTime() / 1000 / s.snapshot_interval_sec); for (const r of rows) {
const interval =
r.snapshot_interval_sec ?? siteAccessFor(getSiteDefinition(r.key) ?? null, defaults).snapshotIntervalSec;
if (!r.last_snapshot_at || r.last_snapshot_at.getTime() + interval * 1000 <= now.getTime()) {
due.push({ id: r.id, interval });
}
}
for (const s of due) {
const slot = Math.floor(now.getTime() / 1000 / s.interval);
await ctx.engine.enqueue( await ctx.engine.enqueue(
"snapshotSite", "snapshotSite",
{ siteId: s.id, kind: "periodic" }, { siteId: s.id, kind: "periodic" },
{ idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() }, { idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() },
); );
} }
return sites.rows.length; return due.length;
} }
/** /**
@@ -889,7 +889,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis
return rows.length; return rows.length;
} }
/** sites.healthCheck:启用站点逐个检测(同步,走站点限流) */ /** sites.healthCheck:启用站点逐个检测(同步,走站点限流);按站点访问默认的间隔跳过刚检测过的站点 */
export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> { export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
const sites = await rt.ctx.db const sites = await rt.ctx.db
.selectFrom("site") .selectFrom("site")
@@ -899,7 +899,11 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
.where("adapter", "!=", "generic_rss") .where("adapter", "!=", "generic_rss")
.execute(); .execute();
let n = 0; let n = 0;
const now = rt.now().getTime();
for (const s of sites) { for (const s of sites) {
// 站点访问默认的最短间隔内检测过(手动或定时)就跳过;留 2 分钟余量,免得和定时任务周期错开一轮
const { healthCheckIntervalSec } = await rt.siteAccess(s.key);
if (s.last_health_at && now - s.last_health_at.getTime() < healthCheckIntervalSec * 1000 - 120_000) continue;
try { try {
await checkSiteHealth(rt, s); await checkSiteHealth(rt, s);
n++; n++;
@@ -119,7 +119,11 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
return { status: "skipped", reason: (e as Error).message, ...none }; return { status: "skipped", reason: (e as Error).message, ...none };
} }
const cfgPages = Number((site.config as { snapshotPages?: number })?.snapshotPages) || 1; const cfgPages = Number((site.config as { snapshotPages?: number })?.snapshotPages) || 1;
const maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages)); let maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages));
// 定时 / 手动快照受站点访问默认的页数上限约束(补漏有自己的页数预算,实时补查只抓 1 页)
if (input.kind === "periodic" || input.kind === "manual") {
maxPages = Math.min(maxPages, (await rt.siteAccess(site.key)).snapshotMaxPages);
}
const snap = await db const snap = await db
.insertInto("site_snapshot") .insertInto("site_snapshot")
.values({ .values({
@@ -211,9 +215,14 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
if (!rel) return "not_found"; if (!rel) return "not_found";
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
if (!site?.enabled) return "site_disabled"; if (!site?.enabled) return "site_disabled";
// 详情缓存:站点访问默认的缓存期内抓过就不再请求站点(列表快照会持续更新做种 / 促销)
const { detailCacheSec } = await rt.siteAccess(site.key);
if (rel.detail_fetched_at && rt.now().getTime() - rel.detail_fetched_at.getTime() < detailCacheSec * 1000) {
return null;
}
let detail: SiteRelease; let detail: SiteRelease;
try { try {
detail = await rt.siteAdapter(site).detail(rel.site_release_id); detail = mergeDetailOverList(await rt.siteAdapter(site).detail(rel.site_release_id), rel);
} catch (e) { } catch (e) {
if (e instanceof SiteError) { if (e instanceof SiteError) {
if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e)); if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e));
@@ -226,7 +235,47 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
} }
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
await upsertSiteRelease(rt, trx, site, detail); await upsertSiteRelease(rt, trx, site, detail);
await trx.updateTable("source_release").set({ detail_fetched_at: rt.now() }).where("id", "=", srId).execute(); const keepPromotion = detail.promotion.type === "unknown";
await trx
.updateTable("source_release")
.set({
detail_fetched_at: rt.now(),
// 详情页没解析出促销时保留列表快照的促销,不用"未知"覆盖
...(keepPromotion
? { promotion: jsonb(rel.promotion ?? {}), promotion_expires_at: rel.promotion_expires_at }
: {}),
})
.where("id", "=", srId)
.execute();
}); });
return null; return null;
} }
/**
* 详情页缺的字段沿用列表快照已有的值:有些站点(如 HHClub 的 div 详情页)详情里没有做种 / 完成数 / 分类名,
* upsert 时 seeders / leechers 与 raw._site 会被整体覆盖,不能用 null 冲掉列表数据。
*/
export function mergeDetailOverList(
detail: SiteRelease,
rel: {
seeders: number | null;
leechers: number | null;
category_raw: string | null;
tags_raw: string[] | null;
raw: unknown;
},
): SiteRelease {
const prev = ((rel.raw ?? {}) as { _site?: Record<string, unknown> })._site ?? {};
return {
...detail,
seeders: detail.seeders ?? rel.seeders,
leechers: detail.leechers ?? rel.leechers,
snatched: detail.snatched ?? (typeof prev.snatched === "number" ? prev.snatched : null),
category: {
id: detail.category.id ?? rel.category_raw,
name: detail.category.name ?? (typeof prev.categoryName === "string" ? prev.categoryName : null),
},
hr: detail.hr === "unknown" && typeof prev.hr === "boolean" ? prev.hr : detail.hr,
tags: detail.tags.length ? detail.tags : (rel.tags_raw ?? []),
};
}
@@ -9,7 +9,7 @@ import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain"; import { normalizeTitle } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts"; import { decodeCursor, page, toApi } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts"; import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts"; import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts";
// 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查, // 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查,
@@ -20,13 +20,15 @@ const LIVE_FAIL_BACKOFF_MS = 60_000;
interface LiveState { interface LiveState {
inflight: Map<string, Promise<{ error: string | null }>>; inflight: Map<string, Promise<{ error: string | null }>>;
failedUntil: Map<string, { until: number; error: string }>; failedUntil: Map<string, { until: number; error: string }>;
/** 按站点串行的实时搜索(站点访问默认 searchConcurrency = 1 时) */
siteChain: Map<string, Promise<unknown>>;
} }
const liveStates = new WeakMap<SourcesRuntime, LiveState>(); const liveStates = new WeakMap<SourcesRuntime, LiveState>();
function liveState(rt: SourcesRuntime): LiveState { function liveState(rt: SourcesRuntime): LiveState {
let s = liveStates.get(rt); let s = liveStates.get(rt);
if (!s) { if (!s) {
s = { inflight: new Map(), failedUntil: new Map() }; s = { inflight: new Map(), failedUntil: new Map(), siteChain: new Map() };
liveStates.set(rt, s); liveStates.set(rt, s);
} }
return s; return s;
@@ -44,14 +46,22 @@ export async function liveSearch(
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false }; if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
const running = st.inflight.get(key); const running = st.inflight.get(key);
if (running) return { ...(await running), coalesced: true }; if (running) return { ...(await running), coalesced: true };
const searchConcurrency = siteSearchConcurrency(site.key);
const prev = searchConcurrency <= 1 ? st.siteChain.get(site.id) : undefined;
const p = (async () => { const p = (async () => {
// 同站不同关键词的实时搜索排队执行(不并发打站点)
if (prev) await prev.catch(() => {});
const r = await snapshotSite(rt, { siteId: site.id, kind: "live_search", query: q, maxPages: 1 }); const r = await snapshotSite(rt, { siteId: site.id, kind: "live_search", query: q, maxPages: 1 });
const error = r.status === "failed" || r.status === "skipped" ? (r.error ?? r.reason ?? "failed") : null; const error = r.status === "failed" || r.status === "skipped" ? (r.error ?? r.reason ?? "failed") : null;
if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error }); if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error });
else st.failedUntil.delete(key); else st.failedUntil.delete(key);
return { error }; return { error };
})().finally(() => st.inflight.delete(key)); })().finally(() => {
st.inflight.delete(key);
if (st.siteChain.get(site.id) === p) st.siteChain.delete(site.id);
});
st.inflight.set(key, p); st.inflight.set(key, p);
if (searchConcurrency <= 1) st.siteChain.set(site.id, p);
return { ...(await p), coalesced: false }; return { ...(await p), coalesced: false };
} }
@@ -175,6 +175,15 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
.where("key", "=", `site:${siteId}`) .where("key", "=", `site:${siteId}`)
.executeTakeFirst(); .executeTakeFirst();
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 }); expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
await new Promise((r) => setTimeout(r, 50));
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
expect(stats.status).toBe(200);
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
expect(stats.body.data.hourly).toHaveLength(24);
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
}); });
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => { it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
@@ -219,6 +228,13 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
expect(d.body.data.refreshError).toBeNull(); expect(d.body.data.refreshError).toBeNull();
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true); expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull(); expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
const before = mock.requests.length;
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(cached.body.data.refreshError).toBeNull();
expect(mock.requests.length).toBe(before);
// 缓存过期后才重新抓
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
mock.setLoggedIn(false); mock.setLoggedIn(false);
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`)); const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(d2.status).toBe(200); expect(d2.status).toBe(200);
@@ -0,0 +1,48 @@
import type { SiteRelease } from "@umt/adapters";
import { describe, expect, it } from "vitest";
import { mergeDetailOverList } from "../../src/modules/sources-identity/service/snapshot.ts";
// HHClub 2026-09-29 实测:div 详情页没有做种 / 完成数 / 分类名,刷新详情后这些列被 null 冲掉。
describe("详情刷新沿用列表字段", () => {
const detail: SiteRelease = {
siteId: "hhanclub",
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
category: { id: "412", name: null },
sizeBytes: 194385019,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: "unknown",
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001",
downloadUrl: "https://hhanclub.net/download.php?id=91001",
tags: [],
raw: {},
observedAt: "2026-09-29T06:19:28.000Z",
};
const rel = {
seeders: 1,
leechers: 58,
category_raw: "412",
tags_raw: ["官方"],
raw: { _site: { hr: false, snatched: 0, categoryName: "短剧" } },
};
it("详情缺的做种 / 下载 / 完成 / 分类名 / H&R / 标签取列表快照值", () => {
expect(mergeDetailOverList(detail, rel)).toMatchObject({
seeders: 1,
leechers: 58,
snatched: 0,
category: { id: "412", name: "短剧" },
hr: false,
tags: ["官方"],
});
});
it("详情有值时以详情为准", () => {
const m = mergeDetailOverList({ ...detail, seeders: 9, hr: true, category: { id: "402", name: "电视剧" } }, rel);
expect(m).toMatchObject({ seeders: 9, hr: true, category: { id: "402", name: "电视剧" } });
});
});
+5 -23
View File
@@ -13,7 +13,6 @@ import {
type AgentRunEvent, type AgentRunEvent,
type AgentRunMessage, type AgentRunMessage,
type AgentRunStatus, type AgentRunStatus,
type AgentTrigger,
Approval, Approval,
type ApprovalStatus, type ApprovalStatus,
AuditEvent, AuditEvent,
@@ -120,38 +119,21 @@ function useAgentMockGate(): boolean {
export const agentKeys = { export const agentKeys = {
all: ["agent"] as const, all: ["agent"] as const,
runs: (status?: AgentRunStatus, trigger?: AgentTrigger) => runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
["agent", "list", status ?? "all", trigger ?? "any"] as const,
run: (id: string) => ["agent", "detail", id] as const, run: (id: string) => ["agent", "detail", id] as const,
}; };
const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"]; const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s); export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */ export function useAgentRuns(status?: AgentRunStatus) {
export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
user: "手动",
schedule: "巡检",
event: "事件",
subagent: "子会话",
};
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) {
const mock = useAgentMockGate(); const mock = useAgentMockGate();
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: [...agentKeys.runs(status, trigger), mock], queryKey: [...agentKeys.runs(status), mock],
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) => { queryFn: async ({ pageParam, signal }) => {
if (mock) if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null };
return { return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal });
items: MOCK_RUNS().filter((r) => (!status || r.status === status) && (!trigger || r.trigger === trigger)),
nextCursor: null,
};
return apiParsed(RunPage, AGENT_BASE, {
params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }),
signal,
});
}, },
getNextPageParam: (last) => last.nextCursor ?? undefined, getNextPageParam: (last) => last.nextCursor ?? undefined,
refetchInterval: (q) => refetchInterval: (q) =>
+24
View File
@@ -12,6 +12,7 @@ import {
SiteHealthResult, SiteHealthResult,
SiteListItem, SiteListItem,
SiteRefreshLoginAccepted, SiteRefreshLoginAccepted,
SiteRequestStats,
SiteSnapshot, SiteSnapshot,
type SiteUpdate, type SiteUpdate,
SiteUserDataPoint, SiteUserDataPoint,
@@ -33,6 +34,7 @@ export const siteKeys = {
one: (id: string) => ["sites", "one", id] as const, one: (id: string) => ["sites", "one", id] as const,
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const,
definitions: () => ["sites", "definitions"] as const, definitions: () => ["sites", "definitions"] as const,
userData: (siteId: string, accountId?: string, days = 30) => userData: (siteId: string, accountId?: string, days = 30) =>
["sites", "user-data", siteId, accountId ?? "first", days] as const, ["sites", "user-data", siteId, accountId ?? "first", days] as const,
@@ -88,6 +90,28 @@ export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
}); });
} }
/** 最近 24 小时对该站的请求次数与生效的访问默认(每分钟刷新) */
export function useSiteRequestStats(siteId: string | undefined) {
return useQuery({
queryKey: siteKeys.requestStats(siteId ?? ""),
enabled: !!siteId,
refetchInterval: 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(SiteRequestStats, "/api/v1/sites/:id/request-stats", { pathParams: { id: siteId ?? "" }, signal }),
});
}
/** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */
export function accessSummary(s: SiteRequestStats): string {
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
const parts = [
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
];
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
return parts.join(" · ");
}
/** 启用 / 停用站点 */ /** 启用 / 停用站点 */
export function useToggleSite() { export function useToggleSite() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -51,7 +51,6 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
import { ToolApproval } from "~/components/agentui/tool-approval.tsx"; import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx"; import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts"; import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
@@ -149,38 +148,6 @@ export function authDenial(call: ToolCallView): string | null {
/** 被服务端拒绝(白名单拦截或授权校验失败) */ /** 被服务端拒绝(白名单拦截或授权校验失败) */
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null; export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
/**
* 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段;
* 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。
*/
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
const raw = call.outputSummary?.trim();
if (!raw) return call.status === "running" ? "等待结果…" : null;
const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t);
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
return clip(raw.replace(/\s+/g, " "));
}
if (Array.isArray(v)) return `${v.length} 条`;
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const err = o.error as { message?: unknown; code?: unknown } | undefined;
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
for (const k of ["items", "results", "releases", "candidates"])
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`;
const simple = Object.entries(o)
.filter(([, x]) => ["string", "number", "boolean"].includes(typeof x))
.slice(0, 3)
.map(([k, x]) => `${k}: ${String(x)}`);
if (simple.length) return clip(simple.join(" · "));
return `${Object.keys(o).length} 个字段`;
}
return clip(String(v));
}
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */ /** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
export function ToolCallCard({ export function ToolCallCard({
call, call,
@@ -231,7 +198,7 @@ export function ToolCallCard({
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null} extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
/> />
} }
summary={toolSummary(call) ?? undefined} summary={call.outputSummary ?? undefined}
> >
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null} {call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
{call.outputSummary ? ( {call.outputSummary ? (
@@ -417,112 +384,7 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde
return out; return out;
} }
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头; /** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */
// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ──
export type ConvEntry = {
key: string;
at: string;
role: "user" | "assistant" | "system";
/** 可并入"活动"折叠(已结束的思考 / 工具调用) */
foldable: boolean;
/** 折叠摘要里的名字(能力名 / "思考") */
label?: string;
failed?: boolean;
node: ReactNode;
};
export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] };
export type ConvBlock =
| { kind: "single"; entry: ConvEntry }
| { kind: "turn"; key: string; at: string; parts: ConvPart[] };
function foldRuns(parts: ConvPart[], min: number): ConvPart[] {
const out: ConvPart[] = [];
let run: ConvEntry[] = [];
const flush = () => {
if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run });
else for (const e of run) out.push({ kind: "item", entry: e });
run = [];
};
for (const p of parts) {
if (p.kind === "item" && p.entry.foldable) run.push(p.entry);
else {
flush();
out.push(p);
}
}
flush();
return out;
}
/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */
export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] {
const out: ConvBlock[] = [];
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
for (const e of entries) {
if (e.role !== "assistant") {
turn = null;
out.push({ kind: "single", entry: e });
continue;
}
if (!turn) {
turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] };
out.push(turn);
}
turn.parts.push({ kind: "item", entry: e });
}
for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold);
return out;
}
function clockText(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const sameDay = new Date().toDateString() === d.toDateString();
return sameDay
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
: formatDateTime(iso);
}
/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */
function ActivityFold({ entries }: { entries: ConvEntry[] }) {
const id = useId();
const [open, setOpen] = useState(false);
const tools = entries.filter((e) => e.label !== "思考").length;
const thoughts = entries.length - tools;
const failed = entries.filter((e) => e.failed).length;
const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))];
return (
<div className="w-full min-w-0 rounded-xl border border-border/60 bg-foreground/[0.015]">
<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.5 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Layers aria-hidden className="size-3.5 shrink-0" />
<span className="shrink-0 text-foreground/85 tabular-nums">
{tools ? `${tools} 次工具调用` : ""}
{tools && thoughts ? "," : ""}
{thoughts ? `思考 ${thoughts} 段` : ""}
</span>
{failed ? <span className="shrink-0 text-destructive tabular-nums">失败 {failed}</span> : null}
<span className="min-w-0 flex-1 truncate">{names.slice(0, 4).join(" → ")}</span>
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<div className="flex flex-col gap-2 border-t border-border/50 p-2">
{entries.map((e) => (
<div key={e.key}>{e.node}</div>
))}
</div>
</Disclosure>
</div>
);
}
/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */
export function AgentConversation({ export function AgentConversation({
run, run,
live, live,
@@ -551,28 +413,16 @@ export function AgentConversation({
const lastIndex = live.items.length - 1; const lastIndex = live.items.length - 1;
const lastText = live.items.map((i) => i.kind).lastIndexOf("text"); const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
const entries: ConvEntry[] = []; type Row = { key: string; at: string; node: ReactNode };
live.items.forEach((it, idx) => { const rows: Row[] = live.items.map((it, idx) => {
const streaming = active && idx === lastIndex; const streaming = active && idx === lastIndex;
const anim = fresh(idx); const anim = fresh(idx);
const wrap = (node: ReactNode) => let node: ReactNode = null;
anim ? (
<div className="animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none">
{node}
</div>
) : (
node
);
switch (it.kind) { switch (it.kind) {
case "text": case "text":
entries.push({ node = (
key: it.key, <AssistantRow animateIn={anim}>
at: it.at,
role: "assistant",
foldable: false,
node: wrap(
<StreamingResponse <StreamingResponse
className="max-w-[72ch]"
status={streaming ? "streaming" : "complete"} status={streaming ? "streaming" : "complete"}
copyText={it.text} copyText={it.text}
announce={false} announce={false}
@@ -580,85 +430,67 @@ export function AgentConversation({
> >
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{streaming ? it.text : <Markdown text={it.text} />} {streaming ? it.text : <Markdown text={it.text} />}
</StreamingResponse>, </StreamingResponse>
), </AssistantRow>
}); );
break; break;
case "thinking": case "thinking":
entries.push({ node = (
key: it.key, <AssistantRow animateIn={anim} avatar={false}>
at: it.at, <ThinkingBlock text={it.text} streaming={streaming} />
role: "assistant", </AssistantRow>
foldable: !streaming, );
label: "思考",
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
});
break; break;
case "tool": { case "tool":
const denied = isDenied(it.call); node = (
entries.push({ <AssistantRow animateIn={anim} avatar={false}>
key: it.key, <ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
at: it.call.startedAt, </AssistantRow>
role: "assistant", );
foldable: !denied && it.call.status !== "running" && idx !== lastIndex,
label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId,
failed: it.call.status === "failed",
node: wrap(<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />),
});
break; break;
}
case "compaction": case "compaction":
entries.push({ node = (
key: it.key,
at: it.at,
role: "system",
foldable: false,
node: (
<MessageMarker> <MessageMarker>
<Layers aria-hidden className="size-3.5 shrink-0" /> <Layers aria-hidden className="size-3.5 shrink-0" />
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
</MessageMarker> </MessageMarker>
), );
});
break; break;
case "options": case "options":
entries.push({ node = (
key: it.key, <AssistantRow animateIn={anim} avatar={false}>
at: it.at, <OptionsCard runId={run.id} view={it.view} waiting={waiting} />
role: "assistant", </AssistantRow>
foldable: false, );
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
});
break; break;
case "approval": case "approval":
entries.push({ node = (
key: it.key, <AssistantRow animateIn={anim} avatar={false}>
at: it.at, <InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
role: "assistant", </AssistantRow>
foldable: false, );
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
});
break; break;
case "end": case "end":
if (!it.ok) node = it.ok ? null : <EndNotice item={it} />;
entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: <EndNotice item={it} /> });
break; break;
} }
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
}); });
for (const m of userMessages) for (const m of userMessages)
entries.push({ rows.push({
key: `u${m.at}`, key: `u${m.at}`,
at: m.at, at: m.at,
role: "user",
foldable: false,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />, node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
}); });
entries.sort((a, b) => a.at.localeCompare(b.at)); rows.sort((a, b) => a.at.localeCompare(b.at));
const blocks = groupConversation(entries);
const tail = live.items[lastIndex]?.kind; return (
const typing = <div className="space-y-4 px-1 py-2">
active && tail !== "text" && tail !== "thinking" ? ( <UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
<AssistantRow avatar={false}>
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground"> <span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} /> <MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting" {run.status === "waiting"
@@ -667,41 +499,8 @@ export function AgentConversation({
? "排队中…" ? "排队中…"
: "正在处理…"} : "正在处理…"}
</span> </span>
) : null; </AssistantRow>
const lastBlock = blocks[blocks.length - 1]; ) : null}
const typingInTurn = typing && lastBlock?.kind === "turn";
return (
<div className="space-y-5 px-1 py-2">
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
{blocks.map((b) =>
b.kind === "single" ? (
<div key={b.entry.key}>{b.entry.node}</div>
) : (
<Message key={b.key} from="assistant" aria-label="Agent 回合">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent className="gap-2">
<MessageHeader>
<span className="font-medium text-foreground/80">Agent</span>
<time dateTime={b.at}>{clockText(b.at)}</time>
</MessageHeader>
{b.parts.map((p) =>
p.kind === "fold" ? (
<ActivityFold key={p.key} entries={p.entries} />
) : (
<div key={p.entry.key} className="w-full min-w-0">
{p.entry.node}
</div>
),
)}
{typingInTurn && b === lastBlock ? typing : null}
</MessageContent>
</Message>
),
)}
{typing && !typingInTurn ? <AssistantRow avatar>{typing}</AssistantRow> : null}
{after} {after}
</div> </div>
); );
@@ -753,16 +552,7 @@ function Meter({
} }
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */ /** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
export function BudgetBar({ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
run,
live,
stacked = false,
}: {
run: AgentRunView;
live: AgentStreamState;
/** 侧栏里单列排 */
stacked?: boolean;
}) {
const u = { ...run.usage, ...(live.usage ?? {}) }; const u = { ...run.usage, ...(live.usage ?? {}) };
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const end = [...live.items].reverse().find((i) => i.kind === "end"); const end = [...live.items].reverse().find((i) => i.kind === "end");
@@ -773,7 +563,7 @@ export function BudgetBar({
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime() ? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined); : undefined);
return ( return (
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}> <div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Meter <Meter
label="工具调用" label="工具调用"
used={toolCalls} used={toolCalls}
@@ -882,42 +672,6 @@ const PLAN_STATUS_TONE = (s: string): StatusTone =>
? "destructive" ? "destructive"
: "warning"; : "warning";
/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */
export function isLongReport(text: string): boolean {
return text.length > 600 || text.split("\n").length > 12;
}
/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */
function LongText({ long, children }: { long: boolean; children: ReactNode }) {
const id = useId();
const [open, setOpen] = useState(false);
if (!long) return <>{children}</>;
return (
<div>
<div
id={id}
className={cn(
"relative",
!open && "max-h-56 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]",
)}
>
{children}
</div>
<Button
variant="ghost"
size="xs"
className="mt-1"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
{open ? "收起" : "展开全文"}
</Button>
</div>
);
}
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ /** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
export function FinalReport({ export function FinalReport({
run, run,
@@ -965,17 +719,15 @@ export function FinalReport({
const imported = report?.organization.filter((o) => o.targetPath) ?? []; const imported = report?.organization.filter((o) => o.targetPath) ?? [];
return ( return (
<section aria-label="最终汇报" className="grid gap-3 @2xl:grid-cols-2"> <section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-xl border border-dashed border-border p-3"> <div className="min-w-0 rounded-xl border border-dashed border-border p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase"> <p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<Bot aria-hidden className="size-3.5" /> <Bot aria-hidden className="size-3.5" />
模型说的(未经核查) 模型说的(未经核查)
</p> </p>
<LongText long={isLongReport(said)}> <StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
<StreamingResponse status="complete" copyText={said || undefined}>
{said ? <Markdown text={said} /> : "(没有文字汇报)"} {said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse> </StreamingResponse>
</LongText>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p> <p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div> </div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3"> <div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
+18 -111
View File
@@ -1,35 +1,27 @@
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react"; import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList } from "./agent-session-list.tsx";
import { import {
type AgentContext, type AgentContext,
type AgentDrawerTab,
agentShortcutLabel, agentShortcutLabel,
contextFromPath, contextFromPath,
contextPrefix, contextPrefix,
lastAgentDrawerTab,
minimizeAgentDrawer, minimizeAgentDrawer,
openAgentDrawer, openAgentDrawer,
openAgentHistoryRun,
restoreAgentDrawer, restoreAgentDrawer,
setAgentDrawerOpen, setAgentDrawerOpen,
setAgentDrawerRun, setAgentDrawerRun,
setAgentDrawerTab,
showAgentRunInDrawer,
splitContextGoal, splitContextGoal,
useAgentDrawer, useAgentDrawer,
} from "./agent-store.ts"; } from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
import { quickGoalsFor } from "./quick-goals.ts"; import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts"; import { useAgentStream } from "./use-agent-stream.ts";
@@ -63,81 +55,18 @@ function ContextChips({ context }: { context: AgentContext }) {
); );
} }
const TABS: { value: AgentDrawerTab; label: string }[] = [
{ value: "current", label: "当前" },
{ value: "history", label: "历史" },
];
function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
return (
<div
role="tablist"
aria-label="Agent 视图"
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
>
{TABS.map((t) => (
<button
key={t.value}
type="button"
role="tab"
id={`agent-tab-${t.value}`}
aria-selected={tab === t.value}
aria-controls="agent-tabpanel"
onClick={() => setAgentDrawerTab(t.value)}
className={cn(
"h-7 cursor-pointer rounded-full border border-transparent px-3 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
tab === t.value && "pill-selected text-foreground",
)}
>
{t.label}
</button>
))}
</div>
);
}
/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */
function HistoryRunView({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const mock = agentMockEnabled();
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 items-center gap-1">
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}>
<ArrowLeft />
返回列表
</Button>
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
</p>
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在页面打开
</Link>
</Button>
</div>
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
</div>
);
}
/** /**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。
* 运行中可最小化到右下角小窗,流式输出继续。 * 运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。 * 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
*/ */
export function AgentDrawer() { export function AgentDrawer() {
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer(); const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun(); const create = useCreateAgentRun();
const prefix = contextPrefix(context); const prefix = contextPrefix(context);
const { pathname } = useLocation(); const { pathname } = useLocation();
const content = useRef<HTMLDivElement>(null); const content = useRef<HTMLDivElement>(null);
const history = tab === "history";
return ( return (
<> <>
@@ -158,21 +87,20 @@ export function AgentDrawer() {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}} }}
> >
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24"> <SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead"> <SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
Agent Agent
</SheetTitle> </SheetTitle>
<SheetDescription className={cn("break-words", history && "sr-only")}> <SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"} {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline"> <span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭) ({agentShortcutLabel()} 开关,Esc 关闭)
</span> </span>
</SheetDescription> </SheetDescription>
{context && !history ? <ContextChips context={context} /> : null} {context ? <ContextChips context={context} /> : null}
<DrawerTabs tab={tab} />
</SheetHeader> </SheetHeader>
{runId && !history ? ( {runId ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -184,27 +112,9 @@ export function AgentDrawer() {
<Minus /> <Minus />
</Button> </Button>
) : null} ) : null}
<div <div className="flex min-h-0 flex-1 flex-col px-4 py-3">
id="agent-tabpanel" {runId ? (
role="tabpanel" <AgentRunPanel runId={runId} compact className="flex-1" />
aria-labelledby={`agent-tab-${tab}`}
className="flex min-h-0 flex-1 flex-col px-4 py-3"
>
{history ? (
historyRunId ? (
<HistoryRunView runId={historyRunId} />
) : (
<AgentSessionList
remember
activeId={runId ?? undefined}
onOpen={openAgentHistoryRun}
className="flex-1"
/>
)
) : runId ? (
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
<AgentRunPanel runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : ( ) : (
<GoalComposer <GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`} key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
@@ -225,29 +135,28 @@ export function AgentDrawer() {
/> />
)} )}
</div> </div>
{history ? null : ( <div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-2.5">
{runId ? ( {runId ? (
<> <>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}> <Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus /> <Plus />
新目标 新目标
</Button> </Button>
<Button asChild variant="ghost" size="sm" className="text-muted-foreground"> <Button asChild variant="outline" size="sm">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}> <Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink /> <ExternalLink />
在页面打开 在会话页打开
</Link> </Link>
</Button> </Button>
</> </>
) : ( ) : (
<Button variant="ghost" size="sm" onClick={() => setAgentDrawerTab("history")}> <Button asChild variant="ghost" size="sm">
<History /> <Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
历史会话 全部会话
</Link>
</Button> </Button>
)} )}
</div> </div>
)}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null} {minimized && runId ? <AgentMiniDock runId={runId} /> : null}
@@ -328,9 +237,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
aria-label="打开 Agent" aria-label="打开 Agent"
title={`Agent(${agentShortcutLabel()})`} title={`Agent(${agentShortcutLabel()})`}
className={className} className={className}
onClick={() => onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
}
> >
<Bot /> <Bot />
</Button> </Button>
@@ -1,16 +1,6 @@
import type { AgentRunEvent } from "@umt/contracts"; import type { AgentRunEvent } from "@umt/contracts";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
authDenial,
type ConvEntry,
groupConversation,
isDenied,
isLongReport,
pairAudit,
runPlanIds,
toolSummary,
} from "./agent-conversation.tsx";
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts"; import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
@@ -121,67 +111,3 @@ describe("事件折叠", () => {
expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true }); expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true });
}); });
}); });
describe("会话排版(AGUI-2)", () => {
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({
key,
at: key,
role,
foldable,
label: foldable ? "查本地库存" : undefined,
node: null,
});
it("同一助手回合归为一组,用户 / 系统条目打断回合", () => {
const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]);
expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]);
const first = blocks[0];
expect(first?.kind === "turn" && first.parts.length).toBe(2);
});
it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => {
const [t] = groupConversation([
e("1", "assistant", true),
e("2", "assistant", true),
e("3", "assistant", true),
e("4", "assistant"),
e("5", "assistant", true),
e("6", "assistant", true),
]);
expect(t?.kind).toBe("turn");
if (t?.kind !== "turn") return;
expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([
"fold3",
"4",
"5",
"6",
]);
});
it("工具卡一行摘要", () => {
expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…");
expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果");
expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时");
expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe(
"found: true · count: 2",
);
expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时");
});
it("长汇报判定", () => {
expect(isLongReport("短")).toBe(false);
expect(isLongReport("x".repeat(601))).toBe(true);
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
});
});
describe("会话历史筛选", () => {
const now = new Date("2026-09-29T12:00:00Z").getTime();
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
it("搜索匹配目标或 ID 前缀", () => {
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true);
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true);
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false);
});
it("时间范围按创建时间", () => {
expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true);
expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false);
expect(rangeStart("", now)).toBeNull();
});
});
@@ -32,18 +32,15 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
})); }));
} }
/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */ /** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
export function RunPlan({ export function RunPlan({
plan, plan,
active, active,
compact = false, compact = false,
fill = false,
}: { }: {
plan: { steps: AgentPlanStep[]; revision: number } | null; plan: { steps: AgentPlanStep[]; revision: number } | null;
active: boolean; active: boolean;
compact?: boolean; compact?: boolean;
/** 侧栏 / 展开的概况条里:不限高、不自动收起 */
fill?: boolean;
}) { }) {
if (!plan?.steps.length) return null; if (!plan?.steps.length) return null;
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length; const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
@@ -82,8 +79,8 @@ export function RunPlan({
title="步骤" title="步骤"
items={planTodoItems(plan.steps)} items={planTodoItems(plan.steps)}
defaultOpen defaultOpen
collapseOnComplete={!fill && !active} collapseOnComplete={!active}
maxHeightClassName={fill ? "max-h-none" : "max-h-56"} maxHeightClassName="max-h-56"
/> />
); );
} }
+39 -203
View File
@@ -1,8 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts"; import { EventType } from "@umt/contracts";
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react"; import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
import { useId, useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type AgentSessionOptions, type AgentSessionOptions,
@@ -10,16 +9,12 @@ import {
agentMockEnabled, agentMockEnabled,
isProposeFirst, isProposeFirst,
isRunActive, isRunActive,
type RunSnapshot,
TRIGGER_LABEL,
useAbortAgentRun, useAbortAgentRun,
useAgentRun, useAgentRun,
useAppendAgentMessage, useAppendAgentMessage,
useCreateAgentRun,
useRunApprovals, useRunApprovals,
useRunInvocationAudit, useRunInvocationAudit,
} from "~/api/agent.ts"; } from "~/api/agent.ts";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { MessageScroller } from "~/components/agentui/message-scroller.tsx"; import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
import { PromptInput } from "~/components/agentui/prompt-input.tsx"; import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
@@ -42,7 +37,9 @@ import {
} from "./agent-conversation.tsx"; } from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx"; import { RunPlan } from "./agent-interaction.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx"; import { SubagentTree } from "./agent-subagent-tree.tsx";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts"; import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */ /** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -143,160 +140,21 @@ export function GoalComposer({
); );
} }
function shortTokens(n: number | undefined) { /** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
}
/** 状态 + 来源 + 时间 + 模型 */
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={status} />
{isProposeFirst(run) ? <StatusBadge>先看方案再执行</StatusBadge> : null}
{agentMockEnabled() ? (
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
mock 数据
</StatusBadge>
) : null}
</div>
<p className="text-meta break-words text-muted-foreground">
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
</p>
</div>
);
}
/**
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
* 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。
*/
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.some((s) => s.status === "failed");
const u = { ...r.usage, ...(live.usage ?? {}) };
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
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-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<RunStatusBadge status={status} />
{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>
</>
) : null}
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""}
</span>
<span className="hidden shrink-0 text-muted-foreground tabular-nums sm:inline">{usage}</span>
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button>
{steps.length ? (
<div className="mx-3 mb-2 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">
<RunMeta run={r} status={status} />
<RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<div>
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<BudgetBar run={r} live={live} />
</div>
</div>
</Disclosure>
</section>
);
}
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
const create = useCreateAgentRun();
const [proposeFirst, setProposeFirst] = useState(false);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
size="xs"
variant="outline"
disabled={create.isPending}
onClick={() =>
create.mutate(
{ goal, proposeFirst },
{ onSuccess: (run) => onCreated(run.id), onError: (e) => toast.error(errorMessage(e)) },
)
}
>
<RotateCcw />
{create.isPending ? "新建中…" : "用同目标新建"}
</Button>
<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />
</div>
);
}
/**
* 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。
* compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。
*/
export function AgentRunPanel({ export function AgentRunPanel({
runId, runId,
compact = false, compact = false,
className, className,
onCreated,
}: { }: {
runId: string; runId: string;
compact?: boolean; compact?: boolean;
className?: string; className?: string;
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void;
}) { }) {
const run = useAgentRun(runId); const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status); const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data); const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun(); const abort = useAbortAgentRun();
const append = useAppendAgentMessage(); const append = useAppendAgentMessage();
const navigate = useNavigate();
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false); const [confirmStop, setConfirmStop] = useState(false);
const sseStatus = useSseStatus(); const sseStatus = useSseStatus();
@@ -332,14 +190,13 @@ export function AgentRunPanel({
const { run: r, report, messages } = run.data; const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished; const finished = !isRunActive(r.status) || live.finished;
const streaming = active && !live.finished; const streaming = active && !live.finished;
const shownStatus: AgentRunStatus = const proposeFirst = isProposeFirst(r);
const shownStatus =
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示 // 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
(live.finished && (r.status === "queued" || r.status === "running")) || (live.finished && (r.status === "queued" || r.status === "running")) ||
(r.status === "queued" && live.events.length > 0) (r.status === "queued" && live.events.length > 0)
? "running" ? "running"
: r.status; : r.status;
const ended = !isRunActive(r.status);
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
const send = (text: string) => const send = (text: string) =>
append.mutate( append.mutate(
@@ -353,27 +210,36 @@ export function AgentRunPanel({
}, },
); );
const column = "mx-auto w-full max-w-[88ch]";
return ( return (
<div className={cn("flex min-h-0 gap-4", className)}> <div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} /> <RunAnnouncer status={shownStatus} finished={live.finished} />
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3"> <div className="flex flex-wrap items-center gap-2">
<RunOverviewBar <RunStatusBadge status={shownStatus} />
snapshot={run.data} {proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
status={shownStatus} <span className="min-w-0 text-meta text-muted-foreground">
live={live} {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
active={streaming || r.status === "waiting"} {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
className={cn(column, !compact && "lg:hidden")} </span>
/> {agentMockEnabled() ? (
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
mock 数据
</StatusBadge>
) : null}
</div>
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
<BudgetBar run={r} live={live} />
</div>
<SubagentTree snapshot={run.data} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
{streaming && sseStatus === "reconnecting" ? ( {streaming && sseStatus === "reconnecting" ? (
<div <div
role="status" role="status"
className={cn( className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
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" /> <WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span> <span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
@@ -382,10 +248,7 @@ export function AgentRunPanel({
{live.error ? ( {live.error ? (
<div <div
role="alert" role="alert"
className={cn( className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"
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> <span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
<Button size="xs" variant="outline" onClick={live.retry}> <Button size="xs" variant="outline" onClick={live.retry}>
@@ -396,9 +259,8 @@ export function AgentRunPanel({
) : null} ) : null}
<MessageScroller <MessageScroller
className={cn("min-h-48 flex-1", compact && "min-h-40")} className={cn("min-h-64 flex-1", compact && "min-h-48")}
viewportClassName="pr-1" viewportClassName="pr-1"
contentClassName={column}
busy={streaming} busy={streaming}
label="Agent 会话记录" label="Agent 会话记录"
> >
@@ -420,7 +282,7 @@ export function AgentRunPanel({
/> />
</MessageScroller> </MessageScroller>
<div className={cn(column, "shrink-0 space-y-1")}> <div className="space-y-1.5">
<PromptInput <PromptInput
value={msg} value={msg}
onValueChange={setMsg} onValueChange={setMsg}
@@ -432,42 +294,16 @@ export function AgentRunPanel({
minRows={1} minRows={1}
maxRows={6} maxRows={6}
maxLength={8000} maxLength={8000}
aria-label={ended ? "追加消息续跑" : "追加消息"} aria-label="追加消息"
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"} placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
submitLabel={ended ? "续跑" : "发送"}
toolbar={
ended ? (
<NewFromGoal goal={r.goal} onCreated={created} />
) : isProposeFirst(r) ? (
<span className="px-1 text-meta text-muted-foreground">先看方案再执行</span>
) : null
}
/> />
<p className="px-1 text-meta text-muted-foreground"> <p className="px-1 text-meta text-muted-foreground">
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
? `${r.summary}:在会话里的卡片上处理,会话随即继续。` ? `${r.summary}:在上面的卡片里处理,会话随即继续。`
: ended
? "续跑沿用本会话的上下文与预算。"
: "追加的消息在本轮结束后读取。"}{" "} : "追加的消息在本轮结束后读取。"}{" "}
Enter 发送,Shift+Enter 换行。 Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
</p> </p>
</div> </div>
</div>
{compact ? null : (
<aside
aria-label="会话概况"
className="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-[var(--shell-border)] pl-4 lg:flex"
>
<RunMeta run={r} status={shownStatus} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
<SubagentTree snapshot={run.data} className="p-2.5" />
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<BudgetBar run={r} live={live} stacked />
</section>
</aside>
)}
<ConfirmDialog <ConfirmDialog
open={confirmStop} open={confirmStop}
@@ -1,63 +1,25 @@
import type { AgentRunStatus, AgentTrigger } from "@umt/contracts"; import type { AgentRunStatus } from "@umt/contracts";
import { Bot, ChevronRight } from "lucide-react"; import { Bot } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts"; import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts"; import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx"; import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts"; import { splitContextGoal } from "./agent-store.ts";
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页), const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。 { value: "", label: "全部" },
{ value: "running", label: RUN_STATUS_LABEL.running },
export type TimeRange = "" | "today" | "7d" | "30d"; { value: "waiting", label: RUN_STATUS_LABEL.waiting },
export type HistoryFilters = { q: string; status: "" | AgentRunStatus; trigger: "" | AgentTrigger; range: TimeRange }; { value: "succeeded", label: RUN_STATUS_LABEL.succeeded },
export const EMPTY_FILTERS: HistoryFilters = { q: "", status: "", trigger: "", range: "" }; { value: "failed", label: RUN_STATUS_LABEL.failed },
{ value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded },
const STATUS_OPTIONS = ( { value: "cancelled", label: RUN_STATUS_LABEL.cancelled },
["running", "waiting", "succeeded", "failed", "budget_exceeded", "cancelled"] as AgentRunStatus[]
).map((s) => ({ value: s, label: RUN_STATUS_LABEL[s] }));
const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({
value: t,
label: TRIGGER_LABEL[t],
}));
const RANGE_OPTIONS = [
{ value: "today", label: "今天" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
]; ];
/** 时间范围的起点(毫秒);不限为 null */ function usageText(r: AgentRunView) {
export function rangeStart(range: TimeRange, now = Date.now()): number | null {
if (range === "today") {
const d = new Date(now);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
if (range === "7d") return now - 7 * 86_400_000;
if (range === "30d") return now - 30 * 86_400_000;
return null;
}
/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */
export function matchesHistory(
run: Pick<AgentRunView, "id" | "goal" | "createdAt">,
f: Pick<HistoryFilters, "q" | "range">,
now = Date.now(),
): boolean {
const start = rangeStart(f.range, now);
if (start !== null && new Date(run.createdAt).getTime() < start) return false;
const q = f.q.trim().toLowerCase();
if (!q) return true;
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
}
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
const parts = [`${r.toolCallCount} 次调用`]; const parts = [`${r.toolCallCount} 次调用`];
if (r.usage.totalTokens !== undefined) if (r.usage.totalTokens !== undefined)
parts.push( parts.push(
@@ -69,232 +31,104 @@ export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
return parts.join(" · "); return parts.join(" · ");
} }
const ALL = "__all__"; /** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */
/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */
function MiniSelect({
label,
allLabel,
value,
onChange,
options,
}: {
label: string;
allLabel: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
<SelectTrigger
size="sm"
aria-label={label}
className={cn("h-7 min-w-0 flex-1 gap-1 px-2 text-meta", !value && "text-muted-foreground")}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={ALL}>{allLabel}</SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 };
function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) {
const goal = splitContextGoal(r.goal);
return (
<>
<div className="flex min-w-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
{goal.text}
</p>
{children}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
<RunStatusBadge status={r.status} />
<span className="text-meta text-muted-foreground">{TRIGGER_LABEL[r.trigger]}</span>
{r.scope.proposeFirst === true ? <span className="text-meta text-muted-foreground">· 先看方案</span> : null}
<time
dateTime={r.createdAt}
className={cn("ml-auto text-meta whitespace-nowrap text-muted-foreground", current && "text-foreground/80")}
>
{relTime(r.createdAt)}
</time>
</div>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
{usageText(r)}
</p>
</>
);
}
/**
* 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。
* - hrefFor:会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。
*/
export function AgentSessionList({ export function AgentSessionList({
activeId, activeId,
hrefFor, status,
onOpen, onStatusChange,
remember = false,
className, className,
}: { }: {
activeId?: string; activeId?: string;
hrefFor?: (id: string) => string; status: "" | AgentRunStatus;
onOpen?: (id: string) => void; onStatusChange: (s: "" | AgentRunStatus) => void;
remember?: boolean;
className?: string; className?: string;
}) { }) {
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS); const runs = useAgentRuns(status || undefined);
const set = (patch: Partial<HistoryFilters>) => const mock = agentMockEnabled();
setF((prev) => { const rows = runs.data?.pages.flatMap((p) => p.items) ?? [];
const next = { ...prev, ...patch };
if (remember) drawerMemory.filters = next;
return next;
});
const runs = useAgentRuns(f.status || undefined, f.trigger || undefined);
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const now = Date.now();
const rows = loaded.filter((r) => matchesHistory(r, f, now));
const start = rangeStart(f.range, now);
const oldest = loaded.at(-1);
// 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中
const canLoadMore = !!runs.hasNextPage && !(start !== null && oldest && new Date(oldest.createdAt).getTime() < start);
const filtered = !!(f.q.trim() || f.status || f.trigger || f.range);
const scroller = useRef<HTMLDivElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
const restored = useRef(!remember);
useLayoutEffect(() => {
if (restored.current || !rows.length || !scroller.current) return;
restored.current = true;
scroller.current.scrollTop = drawerMemory.scroll;
});
const { fetchNextPage, isFetchingNextPage } = runs;
useEffect(() => {
const el = sentinel.current;
if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
(es) => {
if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
},
{ root: scroller.current, rootMargin: "120px" },
);
io.observe(el);
return () => io.disconnect();
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
return ( return (
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}> <nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5"> <fieldset aria-label="按状态筛选" className="scroll-bleed mb-2 flex flex-wrap gap-1">
<Input {FILTERS.map((f) => (
type="search" <button
value={f.q} key={f.value || "all"}
onChange={(e) => set({ q: e.target.value })} type="button"
placeholder="搜索目标" aria-pressed={status === f.value}
aria-label="搜索会话目标" onClick={() => onStatusChange(f.value)}
className="h-8 md:text-caption" className={cn(
/> "h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
<div className="flex flex-wrap gap-1.5"> status === f.value && "pill-selected text-foreground",
<MiniSelect )}
label="按状态筛选"
allLabel="全部状态"
value={f.status}
onChange={(v) => set({ status: v as HistoryFilters["status"] })}
options={STATUS_OPTIONS}
/>
<MiniSelect
label="按来源筛选"
allLabel="全部来源"
value={f.trigger}
onChange={(v) => set({ trigger: v as HistoryFilters["trigger"] })}
options={TRIGGER_OPTIONS}
/>
<MiniSelect
label="按时间筛选"
allLabel="不限时间"
value={f.range}
onChange={(v) => set({ range: v as TimeRange })}
options={RANGE_OPTIONS}
/>
</div>
</div>
<div
ref={scroller}
onScroll={(e) => {
if (remember) drawerMemory.scroll = e.currentTarget.scrollTop;
}}
className="min-h-0 flex-1 overflow-y-auto"
> >
{f.label}
</button>
))}
</fieldset>
<div className="min-h-0 flex-1 overflow-y-auto">
{runs.isPending ? ( {runs.isPending ? (
<LoadingState rows={4} /> <LoadingState rows={4} />
) : runs.error ? ( ) : runs.error ? (
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} /> <ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
) : rows.length === 0 && !canLoadMore ? ( ) : rows.length === 0 ? (
<EmptyState plain icon={Bot} title={filtered ? "没有符合条件的会话" : "还没有会话,交代一个目标开始"} /> <EmptyState
plain
icon={Bot}
title={status ? "没有这个状态的会话" : "还没有会话"}
description={status ? undefined : "交代一个目标开始。任意页面都能从右下角打开 Agent。"}
/>
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-1">
{rows.map((r) => { {rows.map((r) => {
const current = r.id === activeId; const current = r.id === activeId;
const cls = cn(
"block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
current && "pill-selected",
);
return ( return (
<li key={r.id}> <li key={r.id}>
{onOpen ? (
<button type="button" className={cn(cls, "group")} onClick={() => onOpen(r.id)}>
<RunRow r={r} current={current}>
<ChevronRight
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-micro group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</RunRow>
</button>
) : (
<Link <Link
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`} to={`/console/agent/${r.id}${mock ? "?mock=1" : ""}`}
aria-current={current ? "page" : undefined} aria-current={current ? "page" : undefined}
className={cls} className={cn(
> "block min-w-0 rounded-xl border border-transparent px-3 py-2 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
<RunRow r={r} current={current}> current && "pill-selected",
{null}
</RunRow>
</Link>
)} )}
>
<p className="line-clamp-2 text-caption break-words text-foreground">
{splitContextGoal(r.goal).text}
</p>
{splitContextGoal(r.goal).context ? (
<p className="mt-0.5 truncate font-mono text-label text-muted-foreground">
上下文 · {splitContextGoal(r.goal).context?.split(" · ")[0]}
</p>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={r.status} />
{r.scope.proposeFirst === true ? (
<span className="text-meta text-muted-foreground">先看方案</span>
) : null}
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
<time
dateTime={r.createdAt}
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
>
{relTime(r.createdAt)}
</time>
</div>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{usageText(r)}</p>
</Link>
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
{canLoadMore ? ( {runs.hasNextPage ? (
<div ref={sentinel} className="pt-2">
{filtered && rows.length === 0 ? (
<p className="px-1 pb-1 text-meta text-muted-foreground">
已加载的 {loaded.length} 条里没有匹配,继续往前找。
</p>
) : null}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full" className="mt-2 w-full"
disabled={isFetchingNextPage} disabled={runs.isFetchingNextPage}
onClick={() => void fetchNextPage()} onClick={() => void runs.fetchNextPage()}
> >
{isFetchingNextPage ? "加载中…" : "加载更早的会话"} {runs.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button> </Button>
</div>
) : null} ) : null}
</div> </div>
</nav> </nav>
+7 -73
View File
@@ -16,82 +16,16 @@ export type AgentContext = {
goal?: string; goal?: string;
}; };
/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */ type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null };
export type AgentDrawerTab = "current" | "history";
type State = { let state: State = { open: false, minimized: false, context: null, runId: null };
open: boolean;
minimized: boolean;
context: AgentContext | null;
runId: string | null;
tab: AgentDrawerTab;
/** 历史 Tab 里打开的会话(null = 列表) */
historyRunId: string | null;
};
const TAB_KEY = "umt-agent-drawer-tab";
function readTab(): AgentDrawerTab {
try {
return globalThis.localStorage?.getItem(TAB_KEY) === "history" ? "history" : "current";
} catch {
return "current";
}
}
function writeTab(tab: AgentDrawerTab) {
try {
globalThis.localStorage?.setItem(TAB_KEY, tab);
} catch {
// 存储不可用时只在本页记住
}
}
let state: State = { open: false, minimized: false, context: null, runId: null, tab: "current", historyRunId: null };
let tabLoaded = false;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const emit = () => { const emit = () => {
for (const l of listeners) l(); for (const l of listeners) l();
}; };
/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */ export function openAgentDrawer(context: AgentContext | null = null) {
export function lastAgentDrawerTab(): AgentDrawerTab { state = { open: true, minimized: false, context, runId: null };
if (!tabLoaded) {
tabLoaded = true;
state = { ...state, tab: readTab() };
}
return state.tab;
}
/**
* 打开抽屉。页面内按钮带着对象打开时落在「当前」开始新目标;
* 快捷键 / 悬浮按钮不指定 tab,回到上次停留的 Tab。
*/
export function openAgentDrawer(context: AgentContext | null = null, tab: AgentDrawerTab = "current") {
tabLoaded = true;
state = { ...state, open: true, minimized: false, context, runId: null, tab };
if (tab === "current") writeTab(tab);
emit();
}
export function setAgentDrawerTab(tab: AgentDrawerTab) {
tabLoaded = true;
state = { ...state, tab };
writeTab(tab);
emit();
}
/** 历史 Tab:在抽屉内打开一条会话(null 回到列表) */
export function openAgentHistoryRun(runId: string | null) {
state = { ...state, tab: "history", historyRunId: runId };
emit();
}
/** 把一条会话放到「当前」(同目标新建后、从历史里继续时) */
export function showAgentRunInDrawer(runId: string) {
tabLoaded = true;
state = { ...state, open: true, minimized: false, runId, tab: "current" };
writeTab("current");
emit(); emit();
} }
@@ -106,9 +40,9 @@ export function minimizeAgentDrawer() {
emit(); emit();
} }
/** 从小窗恢复抽屉(小窗里的运行在「当前」) */ /** 从小窗恢复抽屉 */
export function restoreAgentDrawer() { export function restoreAgentDrawer() {
state = { ...state, open: true, minimized: false, tab: "current" }; state = { ...state, open: true, minimized: false };
emit(); emit();
} }
@@ -132,7 +66,7 @@ export function isAgentShortcut(
export function toggleAgentDrawer(pathname: string) { export function toggleAgentDrawer(pathname: string) {
if (state.open) setAgentDrawerOpen(false); if (state.open) setAgentDrawerOpen(false);
else if (state.minimized && state.runId) restoreAgentDrawer(); else if (state.minimized && state.runId) restoreAgentDrawer();
else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()); else openAgentDrawer(contextFromPath(pathname));
} }
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
@@ -1,6 +1,6 @@
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts"; import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react"; import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { createContext, type ReactNode, useContext, useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts"; import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
@@ -20,27 +20,6 @@ export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`; const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */
export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null);
const LINK_CLASS =
"line-clamp-2 cursor-pointer rounded-tight text-left text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring";
function RunLink({ id, children }: { id: string; children: ReactNode }) {
const open = useContext(AgentRunOpenContext);
if (open)
return (
<button type="button" onClick={() => open(id)} className={LINK_CLASS}>
{children}
</button>
);
return (
<Link to={href(id)} className={LINK_CLASS}>
{children}
</Link>
);
}
function budgetText(b: Record<string, number | undefined>) { function budgetText(b: Record<string, number | undefined>) {
const parts: string[] = []; const parts: string[] = [];
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`); if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
@@ -70,7 +49,12 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
<span className="mt-0.5 size-4 shrink-0" aria-hidden /> <span className="mt-0.5 size-4 shrink-0" aria-hidden />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink> <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"> <div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} /> <RunStatusBadge status={node.status} />
<span className="text-meta text-muted-foreground tabular-nums"> <span className="text-meta text-muted-foreground tabular-nums">
@@ -98,7 +82,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
} }
/** 子会话树 + 父会话链接;两者都没有时返回 null */ /** 子会话树 + 父会话链接;两者都没有时返回 null */
export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; className?: string }) { export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
const { run, parent, children = [], familyUsage } = snapshot; const { run, parent, children = [], familyUsage } = snapshot;
if (!parent && children.length === 0) return null; if (!parent && children.length === 0) return null;
const byParent = new Map<string, AgentRunNode[]>(); const byParent = new Map<string, AgentRunNode[]>();
@@ -109,14 +93,19 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
const direct = byParent.get(run.id) ?? []; const direct = byParent.get(run.id) ?? [];
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length; const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
return ( return (
<section aria-label="子会话" className={cn("space-y-2 rounded-xl border border-border/70 p-3", className)}> <section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
{parent ? ( {parent ? (
<div className="space-y-1"> <div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground"> <p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<CornerLeftUp aria-hidden className="size-3.5" /> <CornerLeftUp aria-hidden className="size-3.5" />
父会话 父会话
</p> </p>
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink> <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"> <p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "} 第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)} {budgetText(run.budget as Record<string, number | undefined>)}
@@ -126,7 +115,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
) : null} ) : null}
{direct.length ? ( {direct.length ? (
<div className="space-y-1"> <div className="space-y-1">
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground"> <p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5" /> <GitBranch aria-hidden className="size-3.5" />
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null} 子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
</p> </p>
+1 -9
View File
@@ -245,15 +245,7 @@ function seed() {
const now = Date.now(); const now = Date.now();
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1); add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2); add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
failed.run = { ...failed.run, trigger: "schedule" };
const evt = add(
"新资源到达:看看 奥本海默 的 REMUX 是否值得替换现有版本",
{ proposeFirst: false },
now - 3 * 86_400_000,
4,
);
evt.run = { ...evt.run, trigger: "event" };
failed.steps = [ failed.steps = [
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } }, { delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
{ {
+16 -19
View File
@@ -2,8 +2,7 @@
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md // Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉; // 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果); // 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
// AGUI-2:收起态压成一行(能力名 · 状态 · 耗时 · 摘要),能力 ID 与徽标移进展开区; // 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
// 默认一律收起(运行中也只显示一行状态);用户点开的卡片完成后保持展开(collapseOnComplete 可改)。
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react"; import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
@@ -62,8 +61,8 @@ export function ToolResult({
badges, badges,
summary, summary,
icon, icon,
defaultOpen = false, defaultOpen = status === "running",
collapseOnComplete = false, collapseOnComplete = true,
className, className,
}: ToolResultProps) { }: ToolResultProps) {
const id = useId(); const id = useId();
@@ -71,6 +70,7 @@ export function ToolResult({
const prev = useRef(status); const prev = useRef(status);
const setOpen = useCallback((v: boolean) => setOpenState(v), []); const setOpen = useCallback((v: boolean) => setOpenState(v), []);
useEffect(() => { useEffect(() => {
if (prev.current !== "running" && status === "running") setOpen(true);
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false); if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
prev.current = status; prev.current = status;
}, [collapseOnComplete, setOpen, status]); }, [collapseOnComplete, setOpen, status]);
@@ -88,31 +88,28 @@ export function ToolResult({
aria-expanded={open} aria-expanded={open}
aria-controls={`${id}-c`} aria-controls={`${id}-c`}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="group flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring" className="group flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
> >
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground"> <span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
{icon ?? <KindIcon kind={kind} />} {icon ?? <KindIcon kind={kind} />}
</span> </span>
{/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */} <span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="max-w-[45%] shrink-0 truncate text-caption font-medium text-foreground/90">{title}</span> <span className="min-w-0 truncate text-caption font-medium text-foreground/90">{title}</span>
<span className="min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
</span>
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}> <span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
<StatusIcon status={status} /> <StatusIcon status={status} />
{st.label} {st.label}
</span> </span>
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null} <ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{!open && summary ? summary : null}
</span>
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button> </button>
{badges ? <div className="flex flex-wrap items-center gap-1 px-3 pb-2 pl-9">{badges}</div> : null}
{!open && summary ? (
<p className="-mt-1 truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{summary}</p>
) : null}
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}> <Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
<div className="space-y-2 border-t border-border/60 px-3 py-2.5"> <div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
<div className="flex flex-wrap items-center gap-1">
<span className="mr-1 min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
{badges}
</div>
{children}
</div>
</Disclosure> </Disclosure>
</div> </div>
); );
@@ -27,6 +27,24 @@ const DEFAULTS_CARD: CardSpec = {
kind: "number", kind: "number",
hint: "站点开了定时快照但没设周期时使用;5–10080", hint: "站点开了定时快照但没设周期时使用;5–10080",
}, },
{
name: "sites.defaultSnapshotMaxPages",
label: "快照页数",
kind: "number",
hint: "定时和手动快照最多抓几页列表;站点定义更少时按站点;1–20",
},
{
name: "sites.defaultHealthCheckIntervalSec",
label: "健康检测间隔(小时)",
kind: "number",
hint: "同一站点两次定时检测至少隔这么久;站点定义更长时按站点",
},
{
name: "sites.defaultDetailCacheSec",
label: "详情缓存(小时)",
kind: "number",
hint: "期内不重复抓同一条资源详情;0 为不缓存",
},
{ {
name: "sites.userDataMaxSeedingPages", name: "sites.userDataMaxSeedingPages",
label: "做种列表页数上限", label: "做种列表页数上限",
@@ -54,6 +72,9 @@ const DEFAULTS_CARD: CardSpec = {
"sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency), "sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency),
"sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)), "sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)),
"sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages), "sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages),
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
"sites.userAgent": s.sites.userAgent, "sites.userAgent": s.sites.userAgent,
"sites.proxyUrl": s.sites.proxyUrl ?? "", "sites.proxyUrl": s.sites.proxyUrl ?? "",
}), }),
@@ -61,6 +82,8 @@ const DEFAULTS_CARD: CardSpec = {
const min = num(v["sites.defaultSnapshotIntervalSec"]); const min = num(v["sites.defaultSnapshotIntervalSec"]);
const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]); const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]);
const concurrency = num(v["sites.defaultRateLimit.concurrency"]); const concurrency = num(v["sites.defaultRateLimit.concurrency"]);
const healthH = num(v["sites.defaultHealthCheckIntervalSec"]);
const detailH = num(v["sites.defaultDetailCacheSec"]);
return { return {
sites: { sites: {
defaultRateLimit: { defaultRateLimit: {
@@ -70,6 +93,9 @@ const DEFAULTS_CARD: CardSpec = {
}, },
defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60), defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60),
userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]), userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]),
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
userAgent: str(v["sites.userAgent"]) || undefined, userAgent: str(v["sites.userAgent"]) || undefined,
proxyUrl: str(v["sites.proxyUrl"]) || null, proxyUrl: str(v["sites.proxyUrl"]) || null,
}, },
@@ -1,5 +1,5 @@
import type { SiteListItem } from "@umt/contracts"; import type { SiteListItem } from "@umt/contracts";
import { useSiteUserData } from "~/api/sites.ts"; import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx"; import { KvList } from "~/components/console/kv-list.tsx";
@@ -26,6 +26,7 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
const acc = primaryAccount(site.accounts); const acc = primaryAccount(site.accounts);
const u = dAcc?.userData ?? null; const u = dAcc?.userData ?? null;
const history = useSiteUserData(site.id, dAcc?.id, 30); const history = useSiteUserData(site.id, dAcc?.id, 30);
const stats = useSiteRequestStats(site.id);
const st = ACCOUNT_OVERALL[overallStatus(site)]; const st = ACCOUNT_OVERALL[overallStatus(site)];
const h = healthLine(site.lastHealth, site.lastHealthAt); const h = healthLine(site.lastHealth, site.lastHealthAt);
@@ -104,6 +105,17 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
: "未开启", : "未开启",
}, },
{ label: "限流", value: rateLimitText(site.rateLimit) }, { label: "限流", value: rateLimitText(site.rateLimit) },
{
label: "近 24 小时请求",
value: stats.data ? (
<span>
{stats.data.last24h} 次
<span className="block text-meta text-muted-foreground">{accessSummary(stats.data)}</span>
</span>
) : (
"—"
),
},
{ label: "代理", value: site.proxy ? "使用" : "不使用" }, { label: "代理", value: site.proxy ? "使用" : "不使用" },
]} ]}
/> />
@@ -41,6 +41,97 @@ export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionEx
export const isFree = (r: Pick<SourceRelease, "promotion">) => export const isFree = (r: Pick<SourceRelease, "promotion">) =>
r.promotion.kind === "free" || r.promotion.kind === "2xfree"; r.promotion.kind === "free" || r.promotion.kind === "2xfree";
/** 完成数(站点 raw._site.snatched);没有时 null */
export function snatchedOf(r: Pick<SourceRelease, "raw">): number | null {
const v = siteExtra(r).snatched;
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** 促销剩余时间:"剩 3 小时";没有截止时间或已过期返回 null */
export function remainingLabel(iso: string | null | undefined, now = Date.now()): string | null {
if (!iso) return null;
const left = new Date(iso).getTime() - now;
if (!Number.isFinite(left) || left <= 0) return null;
if (left < 3_600_000) return `剩 ${Math.max(1, Math.round(left / 60_000))} 分钟`;
if (left < 2 * 86_400_000) return `剩 ${Math.round(left / 3_600_000)} 小时`;
return `剩 ${Math.round(left / 86_400_000)} 天`;
}
/** 绝对时间(本地时区,精确到分钟),用在悬停提示 */
export function absTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
type ParsedLike = {
resolution?: string;
source?: string;
videoCodec?: string;
hdr?: string[];
releaseGroup?: { name?: string };
subtitles?: string[];
season?: number;
seasonEnd?: number;
seasonInferred?: boolean;
episodes?: { start: number; end: number };
totalEpisodes?: number;
isSeasonPack?: boolean;
};
export type SpecTagKind = "episode" | "resolution" | "source" | "codec" | "hdr" | "group" | "lang" | "site";
export interface SpecTag {
key: string;
label: string;
kind: SpecTagKind;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/**
* 资源规格标签:季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字,再加站点自带标签(tagsRaw)。
* 都来自标题解析结果与标题、副标题原文,不猜;同名去重。
*/
export function releaseSpecTags(r: Pick<SourceRelease, "parsed" | "title" | "subtitle" | "tagsRaw">): SpecTag[] {
const p = (r.parsed ?? {}) as ParsedLike;
const out: SpecTag[] = [];
const seen = new Set<string>();
const add = (kind: SpecTagKind, label: string | undefined | null) => {
const l = label?.trim();
if (!l || seen.has(l.toLowerCase())) return;
seen.add(l.toLowerCase());
out.push({ key: `${kind}:${l}`, label: l, kind });
};
// 季号是推定的(标题里没写)就不显示季,只显示集
const season =
p.season !== undefined && !p.seasonInferred
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
: "";
const episode = p.episodes
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
: "";
const total = !episode && p.totalEpisodes ? `全 ${p.totalEpisodes} 集` : "";
add("episode", [`${season}${episode}`, total].filter(Boolean).join(" "));
add("resolution", p.resolution === "2160p" ? "4K" : p.resolution);
add("source", p.source);
add("codec", p.videoCodec);
for (const h of p.hdr ?? []) add("hdr", h);
add("group", p.releaseGroup?.name);
const text = `${r.title} ${r.subtitle ?? ""}`;
if (/国语|國語|国配|普通话|中配/.test(text)) add("lang", "国语");
if (/粤语|粵語/.test(text)) add("lang", "粤语");
if (
/中字|中文字幕|简中|繁中|简繁|簡繁|双语字幕|雙語字幕|内嵌中|内封中/.test(text) ||
(p.subtitles ?? []).some((l) => l.startsWith("zh"))
) {
add("lang", "中字");
}
for (const t of r.tagsRaw ?? []) add("site", t);
return out;
}
/** 相对时间(中文);未来时间显示"x 后" */ /** 相对时间(中文);未来时间显示"x 后" */
export function relTime(iso: string | null | undefined, now = Date.now()): string { export function relTime(iso: string | null | undefined, now = Date.now()): string {
if (!iso) return "—"; if (!iso) return "—";
@@ -1,21 +1,34 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts"; import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { ArrowDown, ArrowUp, Clock } from "lucide-react"; import { ArrowDown, ArrowUp, Check } from "lucide-react";
import { inventoryFlags } from "~/api/inventory.ts"; import { inventoryFlags } from "~/api/inventory.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts"; import {
absTime,
categoryLabel,
hrOf,
ORIGIN_LABEL,
promotionLabel,
releaseSpecTags,
relTime,
remainingLabel,
snatchedOf,
} from "./release-fields.ts";
/** 促销 / H&R 徽章(列表与详情共用) */ /** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) { export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
const promo = promotionLabel(r); const promo = promotionLabel(r);
const hr = hrOf(r); const hr = hrOf(r);
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
return ( return (
<> <>
{promo ? ( {promo ? (
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}> <span title={r.promotionExpiresAt ? `促销截止 ${absTime(r.promotionExpiresAt)}` : undefined}>
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
{promo.label} {promo.label}
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""} {promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
</StatusBadge> </StatusBadge>
</span>
) : null} ) : null}
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null} {hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null} {hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
@@ -23,6 +36,40 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
); );
} }
/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */
export function SpecTags({ r }: { r: Pick<WorkbenchRelease, "parsed" | "title" | "subtitle" | "tagsRaw"> }) {
const tags = releaseSpecTags(r);
if (!tags.length) return null;
return (
<>
{tags.map((t) => (
<span
key={t.key}
className={cn(
"inline-flex items-center rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
t.kind === "resolution" || t.kind === "hdr"
? "border-border text-foreground"
: "border-border/70 text-muted-foreground",
)}
>
{t.label}
</span>
))}
</>
);
}
/** 没有关联作品时的说明(明确写出,不猜) */
export function unresolvedLabel(release: Pick<WorkbenchRelease, "resolutionStatus">): string {
return release.resolutionStatus === "ambiguous"
? "识别有歧义"
: release.resolutionStatus === "unidentifiable"
? "无法识别"
: release.resolutionStatus === "rejected"
? "已排除"
: "未识别到本地作品";
}
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */ /** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
export function InventoryOverlay({ export function InventoryOverlay({
release, release,
@@ -33,17 +80,7 @@ export function InventoryOverlay({
entry: InventoryEntry | undefined; entry: InventoryEntry | undefined;
loading: boolean; loading: boolean;
}) { }) {
if (!release.entity) { if (!release.entity) return <span className="text-meta text-muted-foreground">{unresolvedLabel(release)}</span>;
const label =
release.resolutionStatus === "ambiguous"
? "识别有歧义"
: release.resolutionStatus === "unidentifiable"
? "无法识别"
: release.resolutionStatus === "rejected"
? "已排除"
: "未识别到本地作品";
return <span className="text-meta text-muted-foreground">{label}</span>;
}
const flags = inventoryFlags(entry, release.resolutionStatus, release.id); const flags = inventoryFlags(entry, release.resolutionStatus, release.id);
return ( return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5"> <span className="flex min-w-0 flex-wrap items-center gap-1.5">
@@ -68,6 +105,11 @@ export function InventoryOverlay({
); );
} }
/**
* 资源列表行,两层:
* 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。
* 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。
*/
export function ReleaseRow({ export function ReleaseRow({
r, r,
entry, entry,
@@ -86,6 +128,11 @@ export function ReleaseRow({
}) { }) {
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore; const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
const cat = categoryLabel(r); const cat = categoryLabel(r);
const snatched = snatchedOf(r);
const timeTitle = [
r.sourcePublishedAt ? `上传 ${absTime(r.sourcePublishedAt)}` : "上传时间未知",
`${ORIGIN_LABEL[r.origin]}数据 ${relTime(r.dataUpdatedAt)}确认`,
].join("\n");
return ( return (
<li> <li>
<button <button
@@ -93,37 +140,68 @@ export function ReleaseRow({
onClick={onOpen} onClick={onOpen}
aria-current={selected ? "true" : undefined} aria-current={selected ? "true" : undefined}
className={cn( className={cn(
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", "group block w-full min-w-0 cursor-pointer px-4 py-2.5 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
selected && "bg-foreground/[0.05]", selected && "bg-foreground/[0.05]",
)} )}
> >
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span> <span
<span className="flex flex-wrap gap-1"> className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere] group-hover:underline group-hover:underline-offset-2"
title={r.title}
>
{r.title}
</span>
<SpecTags r={r} />
<ReleaseBadges r={r} /> <ReleaseBadges r={r} />
</span>
</div> </div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null} <div className="mt-1 flex min-w-0 flex-col gap-1 text-meta text-muted-foreground sm:flex-row sm:items-center sm:gap-4">
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums"> <span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
{cat ? <span>{cat}</span> : null} {r.subtitle ?? ""}
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span> </span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载"> <span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
<ArrowUp className="h-3 w-3" /> <span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
{r.seeders ?? "?"} {cat ?? "分类未知"}
<ArrowDown className="ml-1 h-3 w-3" /> </span>
<span className="sm:w-16 sm:text-right">
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
</span>
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
<ArrowUp className="h-3 w-3" aria-label="做种" />
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
{r.leechers ?? "?"} {r.leechers ?? "?"}
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
{snatched ?? "?"}
</span> </span>
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span> <span className="sm:w-20 sm:text-right" title={timeTitle}>
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}> {relTime(r.sourcePublishedAt)}
<Clock className="h-3 w-3" />
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
</span> </span>
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
) : null}
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null} {stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
</span>
</div> </div>
<div className="mt-1.5"> <InventoryLine release={r} entry={entry} loading={inventoryLoading} />
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
</div>
</button> </button>
</li> </li>
); );
} }
/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */
function InventoryLine({
release,
entry,
loading,
}: {
release: WorkbenchRelease;
entry: InventoryEntry | undefined;
loading: boolean;
}) {
if (!release.entity && release.resolutionStatus !== "ambiguous") return null;
return (
<div className="mt-1.5 min-w-0">
<InventoryOverlay release={release} entry={entry} loading={loading} />
</div>
);
}
+8 -17
View File
@@ -1,44 +1,35 @@
import type { AgentRunStatus } from "@umt/contracts";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts"; import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx"; import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { splitContextGoal } from "~/components/agent/agent-store.ts";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上;
// 更窄时换成会话列顶部的可折叠概况条)。
export default function AgentRunPage() { export default function AgentRunPage() {
const { runId = "" } = useParams(); const { runId = "" } = useParams();
const run = useAgentRun(runId); const run = useAgentRun(runId);
const mock = agentMockEnabled(); const mock = agentMockEnabled();
const suffix = mock ? "?mock=1" : ""; const [status, setStatus] = useState<"" | AgentRunStatus>("");
return ( return (
<> <>
<PageHeader <PageHeader
title="Agent 会话" title="Agent 会话"
description={ description={run.data ? <span className="line-clamp-2 break-words">{run.data.run.goal}</span> : "加载中…"}
run.data ? (
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
{splitContextGoal(run.data.run.goal).text}
</span>
) : (
"加载中…"
)
}
actions={ actions={
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link to={`/console/agent${suffix}`}> <Link to={`/console/agent${mock ? "?mock=1" : ""}`}>
<ArrowLeft /> <ArrowLeft />
全部会话 全部会话
</Link> </Link>
</Button> </Button>
} }
/> />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-13rem)] xl:overflow-hidden"> <aside className="pg-slab hidden rounded-xl p-3 lg:block lg:h-[calc(100dvh-13rem)] lg:overflow-hidden">
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" /> <AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
</aside> </aside>
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]"> <div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
<AgentRunPanel key={runId} runId={runId} className="flex-1" /> <AgentRunPanel key={runId} runId={runId} className="flex-1" />
+15 -7
View File
@@ -1,4 +1,6 @@
import type { AgentRunStatus } from "@umt/contracts";
import { FlaskConical, ShieldCheck } from "lucide-react"; import { FlaskConical, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts"; import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
@@ -9,6 +11,7 @@ import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
export default function AgentPage() { export default function AgentPage() {
const [status, setStatus] = useState<"" | AgentRunStatus>("");
const create = useCreateAgentRun(); const create = useCreateAgentRun();
const navigate = useNavigate(); const navigate = useNavigate();
const mock = agentMockEnabled(); const mock = agentMockEnabled();
@@ -28,11 +31,11 @@ export default function AgentPage() {
</Button> </Button>
</div> </div>
) : null} ) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden"> <div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:max-h-[calc(100dvh-12rem)] lg:overflow-hidden">
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" /> <AgentSessionList status={status} onStatusChange={setStatus} className="h-full" />
</div> </div>
<div className="order-1 mx-auto w-full max-w-[88ch] space-y-3 lg:order-2"> <div className="order-1 space-y-4 lg:order-2">
<div className="pg-slab rounded-xl p-4"> <div className="pg-slab rounded-xl p-4">
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2> <h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
<GoalComposer <GoalComposer
@@ -48,10 +51,15 @@ export default function AgentPage() {
} }
/> />
</div> </div>
<p className="flex items-center gap-2 px-1 text-meta text-muted-foreground"> <div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
<ShieldCheck aria-hidden className="size-3.5 shrink-0" /> <ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
每次工具调用都经服务端授权校验,汇报以系统核实的结果为准。 <div className="space-y-1">
<p>
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
</p> </p>
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
</div>
</div>
</div> </div>
</div> </div>
</> </>
+13 -3
View File
@@ -17,7 +17,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts"; import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts"; import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx";
@@ -157,6 +157,7 @@ export default function SiteWorkbenchPage() {
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null; const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
const selected = all.find((r) => r.id === openId) ?? null; const selected = all.find((r) => r.id === openId) ?? null;
const freeCount = all.filter(isFree).length; const freeCount = all.filter(isFree).length;
const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined);
const reset = () => { const reset = () => {
setQInput(""); setQInput("");
@@ -292,10 +293,14 @@ export default function SiteWorkbenchPage() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!s?.enabled || snap.isPending} disabled={!s?.enabled || snap.isPending}
title="手动快照(受站点限流)" title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() => onClick={() =>
snap.mutate( snap.mutate(
{ id: siteId, maxPages: 2 }, { id: siteId },
{ {
onSuccess: () => toast.success("快照已排队"), onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
@@ -389,6 +394,11 @@ export default function SiteWorkbenchPage() {
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""} 已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span> </span>
{freeCount ? <span>其中免费 {freeCount}</span> : null} {freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null} {releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div> </div>
<ul className="divide-y divide-border/60"> <ul className="divide-y divide-border/60">
-30
View File
@@ -250,36 +250,6 @@ async function main() {
} }
} }
// ── 交互:Agent 抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)──
{
const label = "Agent 抽屉 → 历史 → 抽屉内打开会话";
problems = [];
const t0 = Date.now();
try {
await page.goto("about:blank");
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
const launcher = page.locator("[data-agent-launcher]:visible").last();
await launcher.click();
await page.getByRole("tab", { name: "历史" }).click();
const list = page.getByRole("navigation", { name: "Agent 会话列表" });
await list.getByRole("button").first().click();
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 });
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
await page.getByRole("button", { name: "返回列表" }).click();
await list.waitFor({ timeout: 5_000 });
await page.keyboard.press("Escape");
await launcher.click();
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected");
if (tab !== "true") problems.push("再次打开没有停在「历史」");
if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {});
await page.keyboard.press("Escape");
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });
} catch (err) {
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
}
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
}
// ── 旧链接重定向 ── // ── 旧链接重定向 ──
for (const r of REDIRECT_ROUTES) { for (const r of REDIRECT_ROUTES) {
let from = r.from; let from = r.from;
+12 -3
View File
@@ -61,7 +61,7 @@
| key | 名称 | 默认 | 行为 | | key | 名称 | 默认 | 行为 |
|---|---|---|---| |---|---|---|---|
| `sites.user_data` | 站点数据刷新 | 每 6 小时,一个周期后首跑 | 启用站点里 `status=active` 的非 passkey 账号逐个入队 `refreshSiteAccount{mode:"userData"}`;会话失效的账密账号自动重登一次;只读对账模式跳过 | | `sites.user_data` | 站点数据刷新 | 每 6 小时,一个周期后首跑 | 启用站点里 `status=active` 的非 passkey 账号逐个入队 `refreshSiteAccount{mode:"userData"}`;会话失效的账密账号自动重登一次;只读对账模式跳过 |
| `sites.health_check` | 站点健康检测 | 每小时,一个周期后首跑 | 启用站点(非 generic_rss)逐个同步检测;可在定时任务页关闭 | | `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(非 generic_rss)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 |
注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。 注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。
@@ -74,14 +74,23 @@
| 字段 | 缺省 | 说明 | | 字段 | 缺省 | 说明 |
|---|---|---| |---|---|---|
| `defaultRateLimit` | `{requestsPerMinute: 20, concurrency: 1}` | 站点 `rate_limit` 为空时使用;不会比定义里的 `umt.rateLimit` 更激进(取两者较慢的间隔、较小的并发) | | `defaultRateLimit` | `{requestsPerMinute: 20, concurrency: 2}` | 站点 `rate_limit` 为空时使用;不会比定义里的 `umt.rateLimit` 更激进(取两者较慢的间隔、较小的并发) |
| `defaultSnapshotIntervalSec` | 3600 | 站点开了定时快照但未设周期时使用 | | `defaultSnapshotIntervalSec` | 3600 | 站点开了定时快照但未设周期时使用(与定义 `umt.defaults.snapshotIntervalSec` 取较长) |
| `defaultSnapshotMaxPages` | 1 | 定时 / 手动快照最多抓几页(与定义取较小);补漏有自己的页数预算,实时补查固定 1 页 |
| `defaultHealthCheckIntervalSec` | 21600 | 定时健康检测的最短间隔(与定义取较长) |
| `defaultDetailCacheSec` | 86400 | 详情缓存:期内 `GET /sites/{id}/releases/{rid}?refresh=true` 与 `release.inspect` 不再请求站点(与定义取较长);0 = 不缓存 |
| `userAgent` | 固定桌面 Chrome UA | 账号未设 UA 时用于登录与用户数据 | | `userAgent` | 固定桌面 Chrome UA | 账号未设 UA 时用于登录与用户数据 |
| `proxyUrl` | null | 站点 `proxy=true` 时,适配器、登录、用户数据请求都经此代理(`Deno.createHttpClient`) | | `proxyUrl` | null | 站点 `proxy=true` 时,适配器、登录、用户数据请求都经此代理(`Deno.createHttpClient`) |
| `userDataMaxSeedingPages` | 10 | 用户数据做种分页上限 | | `userDataMaxSeedingPages` | 10 | 用户数据做种分页上限 |
运行时每 30 s 最多重读一次设置;设置变化会让站点适配器按新签名重建。 运行时每 30 s 最多重读一次设置;设置变化会让站点适配器按新签名重建。
## 访问默认与请求统计
站点定义可带 `umt.defaults`(`snapshotIntervalSec / snapshotMaxPages / healthCheckIntervalSec / userDataIntervalSec / searchConcurrency / detailCacheSec`),是按站点实测给的保守下限;运行时 `siteAccessFor(def, settings.sites)` 合并:周期、缓存取较长,页数取较小;`searchConcurrency`(缺省 1)让同站不同关键词的实时补查排队执行。目前只有憨憨配置了(快照 60 分钟 1 页、健康检测 6 小时、用户数据 6 小时、搜索并发 1、详情缓存 24 小时、限流 3 秒 1 个令牌突发 2)。
每从站点限流器拿到一个令牌(列表、搜索、详情、下载、登录、用户数据、原站视图导航)记一次请求,写 `site_request_count`(迁移 `0046`,按小时聚合,多进程共用,保留 7 天)。`GET /sites/{id}/request-stats`(read)返回最近 24 小时总数、按小时明细、快照实际周期与合并后的访问默认;站点页概览「近 24 小时请求」与资源页列表头显示。计数只在升级后开始累计。
## Agent 能力(W10-pre2) ## Agent 能力(W10-pre2)
处理器在 `apps/server/src/modules/sources-identity/capabilities-sites.ts`,调用上面同一组 service。能力 ID 只允许小写 + 下划线(`CapabilityId` 正则),所以规格里的 `site.healthCheck` / `site.refreshLogin` / `site.userData` 落为下划线形式。 处理器在 `apps/server/src/modules/sources-identity/capabilities-sites.ts`,调用上面同一组 service。能力 ID 只允许小写 + 下划线(`CapabilityId` 正则),所以规格里的 `site.healthCheck` / `site.refreshLogin` / `site.userData` 落为下划线形式。
+21 -2
View File
@@ -88,14 +88,33 @@ PT 站点适配器,按 MoviePilot v3(提交 e6820a8)迁移:通用配置驱动 sp
| 状态 | 站点 | | 状态 | 站点 |
| --- | --- | | --- | --- |
| 启用(主力,仍须真实账号核验) | audiences 观众、hhanclub 憨憨、chdbits 彩虹岛、mteam 馒头 | | 启用,**已实测 2026-09-29**(真实账号只读核对) | hhanclub 憨憨 |
| 启用(主力,仍须真实账号核验) | audiences 观众、chdbits 彩虹岛、mteam 馒头 |
| `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg | | `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg |
未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。 未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。
### 待真实站点核验(W1-3c 新增) ### 待真实站点核验(W1-3c 新增)
1. HHClub:`div.torrent-table-sub-info` 卡片结构、`?cat[]=` 分类链接(若 URL 编码为 `cat%5B%5D=` 则 `\d+` 会取错,需改 querystring 过滤)、`promotion-tag-*` 新旧类名、`div.torrent-info-hr`、详情页 Tailwind 选择器、分页 0 起。 1. HHClub(2026-09-29 已实测,见下节):仍未见到的只有促销(`promotion-tag-*`)与 H&R(`div.torrent-info-hr`)命中的实例——当次第 1 页 100 条全部无促销、无 H&R,类名与 Jackett 现行定义一致,暂不改。
### HHClub 实测记录(2026-09-29)
真实账号只读核对,对站点共发 1 次详情请求(列表、用户数据、健康检测用添加站点时产品自己抓的结果;下载令牌只核对列表里的下载链接形态,没有下载)。
| 项 | 结果 |
| --- | --- |
| 列表 `div.torrent-table-sub-info` 卡片、分类 `?cat[]=` 链接、大小 / 做种 / 下载 / 完成 | 正确,全为数字;分类 401–405/412 原始 ID 与名称都在 |
| 标题 | **有误,已修**:标题链接文本末尾带新种标记「[新]」(100 条里 58 条;详情页标题没有),`title` 加 `replace` 过滤 |
| 副标题 | 正确,纯文本,无 HTML 残片 |
| 发布时间 | 正确:`span[title]` 绝对时间按 `+08:00` 转 UTC,相对时间只进 `date_elapsed` |
| 详情页 | 标题 / 副标题取到;没有做种、完成、分类名、基本信息行,**刷新详情会用 null 冲掉列表值,已修**(服务端合并,见 `mergeDetailOverList`) |
| 用户数据(`NexusHhanclub`) | 正确;等级「贵宾」之前 `vip` 为 null,**已加 `umt.vipLevels`** |
| 健康检测 / 登录标志 `a[href='logout.php']` | 正确 |
| 下载链接 | 列表给 `download.php?id=…&passkey=…`,入库时 passkey 脱敏 |
| 标签(官方 / 中字 / 国语等) | 列表没有可用的标签选择器;界面按标题解析与副标题原文生成规格标签 |
访问默认(`umt.defaults`,与 `settings.sites` 取更保守的一方):快照每 60 分钟只抓第 1 页、健康检测 6 小时、用户数据 6 小时、实时搜索并发 1、详情缓存 24 小时;限流 3 秒 1 个令牌、突发 2。fixture:`@umt/testkit` 的 `HHANCLUB_LIST_PAGE_2026`(脱敏最小片段)。
2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。 2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。
3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。 3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。
4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。 4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。
@@ -1,4 +1,10 @@
import { CHDBITS_LIST_PAGE, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE } from "@umt/testkit"; import {
CHDBITS_LIST_PAGE,
CHDBITS_LOGIN_PAGE,
HHANCLUB_DETAIL_PAGE,
HHANCLUB_LIST_PAGE,
HHANCLUB_LIST_PAGE_2026,
} from "@umt/testkit";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getSiteDefinition, listSiteDefinitions } from "../registry.ts"; import { getSiteDefinition, listSiteDefinitions } from "../registry.ts";
import { parseDetailPage } from "../spider/detail.ts"; import { parseDetailPage } from "../spider/detail.ts";
@@ -102,6 +108,44 @@ describe("HHClub 定义(div 卡片模板)", () => {
}); });
}); });
it("2026-09-29 实测结构:去掉新种标记「[新]」,短剧 412、完成数 0、无促销、时间按 +08:00、下载链接 passkey 脱敏", () => {
const [a, b] = parse(HHANCLUB_LIST_PAGE_2026);
expect(a).toMatchObject({
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
subtitle: "示例短剧 | 全30集 | 1080p",
category: { id: "412", name: "短剧" },
sizeBytes: Math.round(185.38 * 1024 ** 2),
seeders: 1,
leechers: 58,
snatched: 0,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: false,
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001&hit=1",
});
expect(a!.downloadUrl).not.toContain("0000000000000000");
expect(b).toMatchObject({
title: "Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
category: { id: "402", name: "电视剧" },
snatched: 187,
uploadedAt: "2026-09-29T04:03:28.000Z",
});
});
it("站点访问默认:快照只抓 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1;3 秒 1 个令牌、突发 2", () => {
expect(hhanclubDefinition.umt.defaults).toEqual({
snapshotIntervalSec: 3600,
snapshotMaxPages: 1,
healthCheckIntervalSec: 21600,
userDataIntervalSec: 21600,
searchConcurrency: 1,
detailCacheSec: 86400,
});
expect(hhanclubDefinition.umt.rateLimit).toEqual({ minIntervalMs: 3000, burst: 2 });
expect(hhanclubDefinition.umt.vipLevels).toContain("贵宾");
});
it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => { it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => {
expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe( expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe(
"https://hhanclub.net/torrents.php?page=0", "https://hhanclub.net/torrents.php?page=0",
@@ -86,7 +86,17 @@
"attribute": "title" "attribute": "title"
}, },
"title": { "title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}" "text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
"filters": [
{
"name": "replace",
"args": ["[新]", ""]
},
{
"name": "replace",
"args": ["(新)", ""]
}
]
}, },
"details": { "details": {
"selector": "div.torrent-title a[href*='details.php']", "selector": "div.torrent-title a[href*='details.php']",
@@ -175,13 +185,13 @@
} }
}, },
"umt": { "umt": {
"version": "[email protected]", "version": "[email protected]+umt.1",
"kind": "nexusphp", "kind": "nexusphp",
"displayName": "憨憨", "displayName": "憨憨",
"timezone": "+08:00", "timezone": "+08:00",
"rateLimit": { "rateLimit": {
"minIntervalMs": 3000, "minIntervalMs": 3000,
"burst": 1 "burst": 2
}, },
"capabilities": { "capabilities": {
"list": true, "list": true,
@@ -249,6 +259,15 @@
"douban": "a[href*='douban.com']::attr(href)", "douban": "a[href*='douban.com']::attr(href)",
"fileList": "td.rowfollow > ul > li::text" "fileList": "td.rowfollow > ul > li::text"
} }
},
"vipLevels": ["贵宾", "VIP"],
"defaults": {
"snapshotIntervalSec": 3600,
"snapshotMaxPages": 1,
"healthCheckIntervalSec": 21600,
"userDataIntervalSec": 21600,
"searchConcurrency": 1,
"detailCacheSec": 86400
} }
} }
} }
@@ -74,6 +74,14 @@ const CapabilitiesSchema = z.strictObject({
download: z.boolean(), download: z.boolean(),
rss: z.boolean(), rss: z.boolean(),
}); });
const AccessDefaultsSchema = z.strictObject({
snapshotIntervalSec: z.number().int().min(300).optional(),
snapshotMaxPages: z.number().int().min(1).max(10).optional(),
healthCheckIntervalSec: z.number().int().min(300).optional(),
userDataIntervalSec: z.number().int().min(300).optional(),
searchConcurrency: z.number().int().min(1).max(4).optional(),
detailCacheSec: z.number().int().min(0).optional(),
});
const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) }); const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) });
const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移"); const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移");
const PairSchema = z.tuple([z.string(), z.string()]); const PairSchema = z.tuple([z.string(), z.string()]);
@@ -87,6 +95,8 @@ const UmtNexusSchema = z.strictObject({
authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(), authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(),
timezone: TimezoneSchema, timezone: TimezoneSchema,
rateLimit: RateLimitSchema, rateLimit: RateLimitSchema,
/** 站点级访问默认(与 settings.sites 取更保守的一方) */
defaults: AccessDefaultsSchema.optional(),
capabilities: CapabilitiesSchema, capabilities: CapabilitiesSchema,
categoryNames: z.record(z.string(), z.string()).optional(), categoryNames: z.record(z.string(), z.string()).optional(),
loginSuccess: z.string().optional(), loginSuccess: z.string().optional(),
@@ -121,6 +121,21 @@ export interface IndexerDefinition {
umt: UmtSiteExtension; umt: UmtSiteExtension;
} }
export interface SiteAccessDefaults {
/** 定时快照周期(秒) */
snapshotIntervalSec?: number;
/** 快照(定时 / 手动)最多抓几页列表 */
snapshotMaxPages?: number;
/** 健康检测最短间隔(秒) */
healthCheckIntervalSec?: number;
/** 用户数据刷新间隔(秒;定时任务 sites.user_data 的周期,仅作说明与校验) */
userDataIntervalSec?: number;
/** 同站实时搜索并发 */
searchConcurrency?: number;
/** 详情页结果缓存(秒):期内不重复抓详情 */
detailCacheSec?: number;
}
export interface UmtSiteExtension { export interface UmtSiteExtension {
/** 定义版本,入 site_snapshot */ /** 定义版本,入 site_snapshot */
version: string; version: string;
@@ -151,6 +166,11 @@ export interface UmtSiteExtension {
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */ /** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
timezone: string; timezone: string;
rateLimit: { minIntervalMs: number; burst: number }; rateLimit: { minIntervalMs: number; burst: number };
/**
* 站点级访问默认(本项目扩展):按站点实测给出的保守下限。运行时与 settings.sites 取更保守的一方
* (周期 / 缓存取较长,页数 / 并发取较小);站点行自己设了快照周期时以站点行为准。
*/
defaults?: SiteAccessDefaults;
capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean }; capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean };
/** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */ /** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */
categoryNames?: Record<string, string>; categoryNames?: Record<string, string>;
+8
View File
@@ -472,6 +472,14 @@ export const Api = {
query: CursorQuery, query: CursorQuery,
response: cursorPage(sources.SiteSnapshot), response: cursorPage(sources.SiteSnapshot),
}), }),
requestStats: ep({
method: "get",
path: "/sites/{id}/request-stats",
scope: "read",
summary: "站点请求统计:最近 24 小时对该站发出的请求次数(按小时)与生效的访问默认",
params: IdParam,
response: sources.SiteRequestStats,
}),
credentials: ep({ credentials: ep({
method: "put", method: "put",
path: "/sites/{id}/credentials", path: "/sites/{id}/credentials",
+21 -1
View File
@@ -237,6 +237,20 @@ export const SitesSettings = z.object({
proxyUrl: z.url().nullable(), proxyUrl: z.url().nullable(),
/** 用户数据做种分页上限 */ /** 用户数据做种分页上限 */
userDataMaxSeedingPages: z.number().int().min(1).max(50), userDataMaxSeedingPages: z.number().int().min(1).max(50),
/** 快照(定时 / 手动)最多抓几页列表;站点定义给了更小的值时取更小的 */
defaultSnapshotMaxPages: z.number().int().min(1).max(20),
/** 同一站点两次定时健康检测的最短间隔(秒);站点定义给了更长的取更长的 */
defaultHealthCheckIntervalSec: z
.number()
.int()
.min(900)
.max(7 * 24 * 3600),
/** 详情页结果缓存(秒):期内不重复抓同一条详情;0 = 不缓存 */
defaultDetailCacheSec: z
.number()
.int()
.min(0)
.max(7 * 24 * 3600),
}); });
export type SitesSettings = z.infer<typeof SitesSettings>; export type SitesSettings = z.infer<typeof SitesSettings>;
@@ -459,11 +473,14 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
}, },
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null }, metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null },
sites: { sites: {
defaultRateLimit: { requestsPerMinute: 20, concurrency: 1 }, defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 },
defaultSnapshotIntervalSec: 3600, defaultSnapshotIntervalSec: 3600,
userAgent: DEFAULT_SITE_USER_AGENT, userAgent: DEFAULT_SITE_USER_AGENT,
proxyUrl: null, proxyUrl: null,
userDataMaxSeedingPages: 10, userDataMaxSeedingPages: 10,
defaultSnapshotMaxPages: 1,
defaultHealthCheckIntervalSec: 21600,
defaultDetailCacheSec: 86400,
}, },
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false }, naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
identity: { eagerTmdbLookup: false, poolArchiveDays: 30 }, identity: { eagerTmdbLookup: false, poolArchiveDays: 30 },
@@ -655,6 +672,9 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"sites.userAgent", "sites.userAgent",
"sites.proxyUrl", "sites.proxyUrl",
"sites.userDataMaxSeedingPages", "sites.userDataMaxSeedingPages",
"sites.defaultSnapshotMaxPages",
"sites.defaultHealthCheckIntervalSec",
"sites.defaultDetailCacheSec",
"search.defaultSiteMode", "search.defaultSiteMode",
"search.siteIds", "search.siteIds",
], ],
+22
View File
@@ -523,6 +523,28 @@ export const SiteSnapshot = EntityBase.extend({
}); });
export type SiteSnapshot = z.infer<typeof SiteSnapshot>; export type SiteSnapshot = z.infer<typeof SiteSnapshot>;
/** 站点访问默认(站点定义 umt.defaults 与 settings.sites 合并后,取更保守的一方) */
export const SiteAccessDefaults = z.object({
snapshotIntervalSec: z.number().int(),
snapshotMaxPages: z.number().int(),
healthCheckIntervalSec: z.number().int(),
detailCacheSec: z.number().int(),
searchConcurrency: z.number().int(),
});
export type SiteAccessDefaults = z.infer<typeof SiteAccessDefaults>;
/** 站点请求统计:每从站点限流器拿到一个令牌记一次(列表 / 搜索 / 详情 / 下载 / 登录 / 用户数据 / 原站视图) */
export const SiteRequestStats = z.object({
siteId: z.string(),
last24h: z.number().int(),
/** 最近 24 个整点小时(旧 → 新),没有请求的小时计 0 */
hourly: z.array(z.object({ hour: z.string(), count: z.number().int() })),
/** 快照实际周期(站点行设置优先) */
snapshotIntervalSec: z.number().int(),
access: SiteAccessDefaults,
});
export type SiteRequestStats = z.infer<typeof SiteRequestStats>;
/** 站点注册表条目(随产品发布的适配器定义)+ 是否已在本系统登记 */ /** 站点注册表条目(随产品发布的适配器定义)+ 是否已在本系统登记 */
export const SiteDefinitionView = z.object({ export const SiteDefinitionView = z.object({
key: z.string(), key: z.string(),
+2
View File
@@ -138,6 +138,8 @@ const INTERNAL_TABLES = [
// C2-A 跨进程限流桶(0041)与 PG 缓存(0042):服务端内部,缓存经 /cache/* 的专用 DTO 暴露 // C2-A 跨进程限流桶(0041)与 PG 缓存(0042):服务端内部,缓存经 /cache/* 的专用 DTO 暴露
"RateLimitBucket", "RateLimitBucket",
"KvCache", "KvCache",
// 站点请求计数(0046):服务端内部,经 GET /sites/{id}/request-stats 的专用 DTO 暴露
"SiteRequestCount",
// W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露 // W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露
"ProcessHeartbeat", "ProcessHeartbeat",
"ProcessHeartbeatSample", "ProcessHeartbeatSample",
@@ -0,0 +1,25 @@
import type { Kysely } from "kysely";
import { dropTables, execDdl } from "../src/migration-helpers.ts";
// 站点请求计数:每次从站点限流器拿到令牌(列表 / 搜索 / 详情 / 下载 / 登录 / 用户数据 / 原站视图)记一次,
// 按小时聚合,api / worker 多进程共用。站点页展示"最近 24 小时请求次数";保留 7 天,写入时顺手清理。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create table site_request_count (
site_id uuid not null references site(id) on delete cascade,
hour timestamptz not null,
count integer not null default 0 check (count >= 0),
primary key (site_id, hour)
);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["site_request_count"]));
}
+11 -1
View File
@@ -788,7 +788,6 @@ export interface IdentityCandidateTable {
status: Generated<IdentityCandidateStatus>; status: Generated<IdentityCandidateStatus>;
reason: IdentityCandidateReason; reason: IdentityCandidateReason;
query: Generated<Json>; query: Generated<Json>;
normalized_title: string | null;
candidates: Generated<Json>; candidates: Generated<Json>;
evidence_version: string; evidence_version: string;
attempts: Generated<number>; attempts: Generated<number>;
@@ -800,6 +799,7 @@ export interface IdentityCandidateTable {
resolved_at: Timestamptz | null; resolved_at: Timestamptz | null;
created_at: GeneratedTimestamptz; created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
normalized_title: string | null;
} }
export interface IdentityRedirectTable { export interface IdentityRedirectTable {
@@ -1385,6 +1385,12 @@ export interface SiteAccountTable {
user_data_at: Timestamptz | null; user_data_at: Timestamptz | null;
} }
export interface SiteRequestCountTable {
site_id: string;
hour: Timestamptz;
count: Generated<number>;
}
export interface SiteSnapshotTable { export interface SiteSnapshotTable {
id: Generated<string>; id: Generated<string>;
site_id: string; site_id: string;
@@ -1691,6 +1697,7 @@ export interface DB {
settings: SettingsTable; settings: SettingsTable;
site: SiteTable; site: SiteTable;
site_account: SiteAccountTable; site_account: SiteAccountTable;
site_request_count: SiteRequestCountTable;
site_snapshot: SiteSnapshotTable; site_snapshot: SiteSnapshotTable;
site_user_data: SiteUserDataTable; site_user_data: SiteUserDataTable;
slot_deferral: SlotDeferralTable; slot_deferral: SlotDeferralTable;
@@ -1903,6 +1910,9 @@ export type SiteUpdate = Updateable<SiteTable>;
export type SiteAccountRow = Selectable<SiteAccountTable>; export type SiteAccountRow = Selectable<SiteAccountTable>;
export type NewSiteAccount = Insertable<SiteAccountTable>; export type NewSiteAccount = Insertable<SiteAccountTable>;
export type SiteAccountUpdate = Updateable<SiteAccountTable>; export type SiteAccountUpdate = Updateable<SiteAccountTable>;
export type SiteRequestCountRow = Selectable<SiteRequestCountTable>;
export type NewSiteRequestCount = Insertable<SiteRequestCountTable>;
export type SiteRequestCountUpdate = Updateable<SiteRequestCountTable>;
export type SiteSnapshotRow = Selectable<SiteSnapshotTable>; export type SiteSnapshotRow = Selectable<SiteSnapshotTable>;
export type NewSiteSnapshot = Insertable<SiteSnapshotTable>; export type NewSiteSnapshot = Insertable<SiteSnapshotTable>;
export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>; export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>;
+4
View File
@@ -119,6 +119,8 @@ const EXPECTED_TABLES = [
"rate_limit_bucket", "rate_limit_bucket",
// C2-A PG 短期缓存(0042,UNLOGGED) // C2-A PG 短期缓存(0042,UNLOGGED)
"kv_cache", "kv_cache",
// 站点请求计数(0046,按小时聚合)
"site_request_count",
].sort(); ].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */ /** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
@@ -147,6 +149,8 @@ const NON_ENTITY_TABLES = [
"rate_limit_bucket", "rate_limit_bucket",
// 缓存:(region, key) 主键 // 缓存:(region, key) 主键
"kv_cache", "kv_cache",
// 站点请求计数:(site_id, hour) 主键
"site_request_count",
]; ];
describe.skipIf(skip)("schema migrations (real PG)", () => { describe.skipIf(skip)("schema migrations (real PG)", () => {
@@ -45,3 +45,38 @@ export const HHANCLUB_DETAIL_PAGE = `<!DOCTYPE html><html><head><title>HHCLUB ::
<div><a href="download.php?id=90002">下载种子</a></div> <div><a href="download.php?id=90002">下载种子</a></div>
<div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div> <div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div>
</div></body></html>`; </div></body></html>`;
/**
* 2026-09-29 真实账号核对后补的最小脱敏片段(名称、ID、数值均已替换;不含 passkey 与用户信息)。
* 核对到的真实行为:列表标题链接文本末尾带新种标记「[新]」(详情页标题没有);短剧分类 412;
* 新种完成数为 0;无促销时没有 promotion-tag 元素;上传时间在 span[title] 里,文本是相对时间。
* 下载链接带 passkey(这里用占位值)。
*/
export const HHANCLUB_LIST_PAGE_2026 = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
<div id="user-info-panel"><a href="userdetails.php?id=7" class="VIP_Name">bob</a> <a href="logout.php">退出</a></div>
<div class="torrent-table">
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=412"><img src="pic/cattrans.gif" alt="短剧"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91001&amp;hit=1">示例短剧 1080p WEB-DL H265 AAC-HHWEB[新]</a></div>
<div class="torrent-info-text-small_name">示例短剧 | 全30集 | 1080p</div>
<div class="torrent-manage"><a href="download.php?id=91001&amp;passkey=0000000000000000">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-29 14:07:03">5分钟</span></div>
<div class="torrent-info-text-size">185.38 MB</div>
<div class="torrent-info-text-seeders">1</div>
<div class="torrent-info-text-leechers">58</div>
<div class="torrent-info-text-finished">0</div>
</div>
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=402"><img src="pic/cattrans.gif" alt="电视剧"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91002&amp;hit=1">Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB</a></div>
<div class="torrent-info-text-small_name">示例剧集 | 第12集 | 4K | 类型: 奇幻 [内嵌中字]</div>
<div class="torrent-manage"><a href="download.php?id=91002&amp;passkey=0000000000000000">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-29 12:03:28">2时8分钟</span></div>
<div class="torrent-info-text-size">1.46 GB</div>
<div class="torrent-info-text-seeders">131</div>
<div class="torrent-info-text-leechers">5</div>
<div class="torrent-info-text-finished">187</div>
</div>
</div>
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=1">101-200</a></div>
</body></html>`;
@@ -1,5 +1,5 @@
{ {
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。", "$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。",
"umt": { "umt": {
"categoryNames": { "categoryNames": {
"401": "电影", "401": "电影",
@@ -12,6 +12,37 @@
"410": "音乐", "410": "音乐",
"411": "MV", "411": "MV",
"412": "短剧" "412": "短剧"
},
"version": "[email protected]+umt.1",
"rateLimit": {
"minIntervalMs": 3000,
"burst": 2
},
"vipLevels": ["贵宾", "VIP"],
"defaults": {
"snapshotIntervalSec": 3600,
"snapshotMaxPages": 1,
"healthCheckIntervalSec": 21600,
"userDataIntervalSec": 21600,
"searchConcurrency": 1,
"detailCacheSec": 86400
}
},
"torrents": {
"fields": {
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
"filters": [
{
"name": "replace",
"args": ["[新]", ""]
},
{
"name": "replace",
"args": ["(新)", ""]
}
]
}
} }
} }
} }