diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index a95e5771..d95597bc 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -19,6 +19,7 @@ import { SiteHealthResult, SiteRefreshLoginAccepted, SiteRefreshLoginInput, + SiteRequestStats, SiteSnapshot, SiteUserDataPoint, SiteUserDataQuery, @@ -259,6 +260,49 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { 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 站点中心:删除、健康检测、刷新登录、用户数据、凭证 ── route( diff --git a/apps/server/src/modules/sources-identity/runtime.ts b/apps/server/src/modules/sources-identity/runtime.ts index 40d6d8f6..2e66eed9 100644 --- a/apps/server/src/modules/sources-identity/runtime.ts +++ b/apps/server/src/modules/sources-identity/runtime.ts @@ -14,7 +14,7 @@ import { type TmdbSharedCache, } from "@umt/adapters"; 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 type { ServerContext } from "../types.ts"; @@ -170,7 +170,7 @@ export class SourcesRuntime { if (hit && hit.sig === sig) return hit.adapter; const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit); // 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:);进程内先串行排队 - const limiter = + const inner = this.overrides.sharedRateLimit === false ? createSiteRateLimiter(lo) : createSharedSiteRateLimiter({ @@ -183,6 +183,7 @@ export class SourcesRuntime { "站点共享限流不可用,本次退回进程内限流", ), }); + const limiter = countingLimiter(inner, () => this.countSiteRequest(site.id)); const cfg = (site.config ?? {}) as { apiBaseUrl?: string }; const baseUrl = 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; } + /** + * 站点请求计数(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 { + 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 { + return siteAccessFor(getSiteDefinition(siteKey) ?? null, await this.siteDefaults()); + } + /** 已建立的站点限流器(测试与诊断用) */ limiterOf(siteId: string): SiteRateLimiter | null { return this.adapters.get(siteId)?.limiter ?? null; @@ -260,6 +296,63 @@ function sameUrl(a: string, b: string): boolean { 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 }; /** diff --git a/apps/server/src/modules/sources-identity/scheduler.ts b/apps/server/src/modules/sources-identity/scheduler.ts index 29ef66b8..a7335501 100644 --- a/apps/server/src/modules/sources-identity/scheduler.ts +++ b/apps/server/src/modules/sources-identity/scheduler.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; +import { getSiteDefinition } from "@umt/adapters"; 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"; // worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、 @@ -67,24 +68,32 @@ export async function tickFeeds(rt: SourcesRuntime): Promise { export async function tickSnapshots(rt: SourcesRuntime): Promise { const { ctx } = rt; const now = rt.now(); - // 站点未设周期时用全局默认(settings.sites.defaultSnapshotIntervalSec) - const fallback = (await rt.siteDefaults()).defaultSnapshotIntervalSec; - const sites = await sql<{ id: string; snapshot_interval_sec: number }>` - select id, coalesce(snapshot_interval_sec, ${fallback}::int) as snapshot_interval_sec from site - where enabled and snapshot_enabled and deleted_at is null - and (last_snapshot_at is null - or last_snapshot_at + make_interval(secs => coalesce(snapshot_interval_sec, ${fallback}::int)) <= ${now})`.execute( - ctx.db, - ); - for (const s of sites.rows) { - const slot = Math.floor(now.getTime() / 1000 / s.snapshot_interval_sec); + // 站点未设周期时用站点定义与全局默认里更长的一个(settings.sites.defaultSnapshotIntervalSec / umt.defaults) + const defaults = await rt.siteDefaults(); + const rows = await ctx.db + .selectFrom("site") + .select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"]) + .where("enabled", "=", true) + .where("snapshot_enabled", "=", true) + .where("deleted_at", "is", null) + .execute(); + const due: Array<{ id: string; interval: number }> = []; + 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( "snapshotSite", { siteId: s.id, kind: "periodic" }, { idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() }, ); } - return sites.rows.length; + return due.length; } /** diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index 52aecb8e..5cb2aaa2 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -889,7 +889,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis return rows.length; } -/** sites.healthCheck:启用站点逐个检测(同步,走站点限流) */ +/** sites.healthCheck:启用站点逐个检测(同步,走站点限流);按站点访问默认的间隔跳过刚检测过的站点 */ export async function tickSiteHealth(rt: SourcesRuntime): Promise { const sites = await rt.ctx.db .selectFrom("site") @@ -899,7 +899,11 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise { .where("adapter", "!=", "generic_rss") .execute(); let n = 0; + const now = rt.now().getTime(); 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 { await checkSiteHealth(rt, s); n++; diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index 42854bce..ab3c5046 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -119,7 +119,11 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr return { status: "skipped", reason: (e as Error).message, ...none }; } 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 .insertInto("site_snapshot") .values({ @@ -211,9 +215,14 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr if (!rel) return "not_found"; const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); 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; 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) { if (e instanceof SiteError) { 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 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; } + +/** + * 详情页缺的字段沿用列表快照已有的值:有些站点(如 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 })._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 ?? []), + }; +} diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index b89e3e8e..c26a7c88 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -9,7 +9,7 @@ import { type SiteRow, sql } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; import { ApiException } from "../../../http/errors.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"; // 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查, @@ -20,13 +20,15 @@ const LIVE_FAIL_BACKOFF_MS = 60_000; interface LiveState { inflight: Map>; failedUntil: Map; + /** 按站点串行的实时搜索(站点访问默认 searchConcurrency = 1 时) */ + siteChain: Map>; } const liveStates = new WeakMap(); function liveState(rt: SourcesRuntime): LiveState { let s = liveStates.get(rt); if (!s) { - s = { inflight: new Map(), failedUntil: new Map() }; + s = { inflight: new Map(), failedUntil: new Map(), siteChain: new Map() }; liveStates.set(rt, s); } return s; @@ -44,14 +46,22 @@ export async function liveSearch( if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false }; const running = st.inflight.get(key); 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 () => { + // 同站不同关键词的实时搜索排队执行(不并发打站点) + if (prev) await prev.catch(() => {}); 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; if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error }); else st.failedUntil.delete(key); 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); + if (searchConcurrency <= 1) st.siteChain.set(site.id, p); return { ...(await p), coalesced: false }; } diff --git a/apps/server/test/sources-identity/api.test.ts b/apps/server/test/sources-identity/api.test.ts index 50dbb7b9..ddb7e262 100644 --- a/apps/server/test/sources-identity/api.test.ts +++ b/apps/server/test/sources-identity/api.test.ts @@ -175,6 +175,15 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => { .where("key", "=", `site:${siteId}`) .executeTakeFirst(); 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 () => { @@ -219,6 +228,13 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => { expect(d.body.data.refreshError).toBeNull(); expect(d.body.data.volatile.recheckBeforeDownload).toBe(true); 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); const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`)); expect(d2.status).toBe(200); diff --git a/apps/server/test/sources-identity/detail-merge.test.ts b/apps/server/test/sources-identity/detail-merge.test.ts new file mode 100644 index 00000000..75732aca --- /dev/null +++ b/apps/server/test/sources-identity/detail-merge.test.ts @@ -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: "电视剧" } }); + }); +}); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index ed991cea..93b758a0 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -13,7 +13,6 @@ import { type AgentRunEvent, type AgentRunMessage, type AgentRunStatus, - type AgentTrigger, Approval, type ApprovalStatus, AuditEvent, @@ -120,38 +119,21 @@ function useAgentMockGate(): boolean { export const agentKeys = { all: ["agent"] as const, - runs: (status?: AgentRunStatus, trigger?: AgentTrigger) => - ["agent", "list", status ?? "all", trigger ?? "any"] as const, + runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const, run: (id: string) => ["agent", "detail", id] as const, }; const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"]; export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s); -/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */ -export const TRIGGER_LABEL: Record = { - user: "手动", - schedule: "巡检", - event: "事件", - subagent: "子会话", -}; - -/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */ -export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) { +export function useAgentRuns(status?: AgentRunStatus) { const mock = useAgentMockGate(); return useInfiniteQuery({ - queryKey: [...agentKeys.runs(status, trigger), mock], + queryKey: [...agentKeys.runs(status), mock], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => { - if (mock) - return { - 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, - }); + if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null }; + return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal }); }, getNextPageParam: (last) => last.nextCursor ?? undefined, refetchInterval: (q) => diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 4a6db6ea..e448799c 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -12,6 +12,7 @@ import { SiteHealthResult, SiteListItem, SiteRefreshLoginAccepted, + SiteRequestStats, SiteSnapshot, type SiteUpdate, SiteUserDataPoint, @@ -33,6 +34,7 @@ export const siteKeys = { one: (id: string) => ["sites", "one", id] as const, accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, + requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const, definitions: () => ["sites", "definitions"] as const, userData: (siteId: string, accountId?: string, days = 30) => ["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() { const qc = useQueryClient(); diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index a9863c31..c58ea56e 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -51,7 +51,6 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami import { ToolApproval } from "~/components/agentui/tool-approval.tsx"; import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.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 { cn, formatDateTime } from "~/lib/utils.ts"; 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; -/** - * 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段; - * 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。 - */ -export function toolSummary(call: Pick): 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; - 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/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */ export function ToolCallCard({ call, @@ -231,7 +198,7 @@ export function ToolCallCard({ extra={call.recovery ? 恢复补记 : null} /> } - summary={toolSummary(call) ?? undefined} + summary={call.outputSummary ?? undefined} > {call.inputSummary ? {call.inputSummary} : null} {call.outputSummary ? ( @@ -417,112 +384,7 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde 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 | 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 ( -
- - -
- {entries.map((e) => ( -
{e.node}
- ))} -
-
-
- ); -} - -/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */ +/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */ export function AgentConversation({ run, live, @@ -551,28 +413,16 @@ export function AgentConversation({ const lastIndex = live.items.length - 1; const lastText = live.items.map((i) => i.kind).lastIndexOf("text"); - const entries: ConvEntry[] = []; - live.items.forEach((it, idx) => { + type Row = { key: string; at: string; node: ReactNode }; + const rows: Row[] = live.items.map((it, idx) => { const streaming = active && idx === lastIndex; const anim = fresh(idx); - const wrap = (node: ReactNode) => - anim ? ( -
- {node} -
- ) : ( - node - ); + let node: ReactNode = null; switch (it.kind) { case "text": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap( + node = ( + {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {streaming ? it.text : } - , - ), - }); + + + ); break; case "thinking": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: !streaming, - label: "思考", - node: wrap(), - }); + node = ( + + + + ); break; - case "tool": { - const denied = isDenied(it.call); - entries.push({ - key: it.key, - at: it.call.startedAt, - 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(), - }); + case "tool": + node = ( + + + + ); break; - } case "compaction": - entries.push({ - key: it.key, - at: it.at, - role: "system", - foldable: false, - node: ( - - - 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} - {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} - - ), - }); + node = ( + + + 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} + {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} + + ); break; case "options": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap(), - }); + node = ( + + + + ); break; case "approval": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap(), - }); + node = ( + + + + ); break; case "end": - if (!it.ok) - entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: }); + node = it.ok ? null : ; break; } + return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node }; }); for (const m of userMessages) - entries.push({ + rows.push({ key: `u${m.at}`, at: m.at, - role: "user", - foldable: false, node: , }); - entries.sort((a, b) => a.at.localeCompare(b.at)); - const blocks = groupConversation(entries); - const tail = live.items[lastIndex]?.kind; - const typing = - active && tail !== "text" && tail !== "thinking" ? ( - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} - - ) : null; - const lastBlock = blocks[blocks.length - 1]; - const typingInTurn = typing && lastBlock?.kind === "turn"; + rows.sort((a, b) => a.at.localeCompare(b.at)); return ( -
+
- {blocks.map((b) => - b.kind === "single" ? ( -
{b.entry.node}
- ) : ( - - - - - - - Agent - - - {b.parts.map((p) => - p.kind === "fold" ? ( - - ) : ( -
- {p.entry.node} -
- ), - )} - {typingInTurn && b === lastBlock ? typing : null} -
-
- ), - )} - {typing && !typingInTurn ? {typing} : null} + {rows.map((r) => (r.node ?
{r.node}
: null))} + {active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? ( + + + + {run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` + : run.status === "queued" + ? "排队中…" + : "正在处理…"} + + + ) : null} {after}
); @@ -753,16 +552,7 @@ function Meter({ } /** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */ -export function BudgetBar({ - run, - live, - stacked = false, -}: { - run: AgentRunView; - live: AgentStreamState; - /** 侧栏里单列排 */ - stacked?: boolean; -}) { +export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); 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() : undefined); return ( -
+
? "destructive" : "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 ( -
-
- {children} -
- -
- ); -} - /** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ export function FinalReport({ run, @@ -965,17 +719,15 @@ export function FinalReport({ const imported = report?.organization.filter((o) => o.targetPath) ?? []; return ( -
+

模型说的(未经核查)

- - - {said ? : "(没有文字汇报)"} - - + + {said ? : "(没有文字汇报)"} +

仅供参考,不作为完成依据。

diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index a846a22b..f7f00e50 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -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 { Link, useLocation } from "react-router"; 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 { Button } from "~/components/ui/button.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { cn } from "~/lib/utils.ts"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; -import { AgentSessionList } from "./agent-session-list.tsx"; import { type AgentContext, - type AgentDrawerTab, agentShortcutLabel, contextFromPath, contextPrefix, - lastAgentDrawerTab, minimizeAgentDrawer, openAgentDrawer, - openAgentHistoryRun, restoreAgentDrawer, setAgentDrawerOpen, setAgentDrawerRun, - setAgentDrawerTab, - showAgentRunInDrawer, splitContextGoal, useAgentDrawer, } from "./agent-store.ts"; -import { AgentRunOpenContext } from "./agent-subagent-tree.tsx"; import { quickGoalsFor } from "./quick-goals.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 ( -
- {TABS.map((t) => ( - - ))} -
- ); -} - -/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */ -function HistoryRunView({ runId }: { runId: string }) { - const run = useAgentRun(runId); - const mock = agentMockEnabled(); - return ( -
-
- -

- {run.data ? splitContextGoal(run.data.run.goal).text : ""} -

- -
- - - -
- ); -} - /** * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; - * 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。 * 运行中可最小化到右下角小窗,流式输出继续。 * 挂载:在 console layout 里渲染一次 ;顶栏放 。 * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 */ export function AgentDrawer() { - const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer(); + const { open, minimized, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); const prefix = contextPrefix(context); const { pathname } = useLocation(); const content = useRef(null); - const history = tab === "history"; return ( <> @@ -158,21 +87,20 @@ export function AgentDrawer() { if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); }} > - + Agent - + {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"} ({agentShortcutLabel()} 开关,Esc 关闭) - {context && !history ? : null} - + {context ? : null} - {runId && !history ? ( + {runId ? ( ) : null} -
- {history ? ( - historyRunId ? ( - - ) : ( - - ) - ) : runId ? ( - - - +
+ {runId ? ( + ) : ( )}
- {history ? null : ( -
- {runId ? ( - <> - - - - ) : ( - - )} -
- )} + + + ) : ( + + )} +
{minimized && runId ? : null} @@ -328,9 +237,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) { aria-label="打开 Agent" title={`Agent(${agentShortcutLabel()})`} className={className} - onClick={() => - minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()) - } + onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))} > diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index 9e44ea12..f1858ee5 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -1,16 +1,6 @@ import type { AgentRunEvent } from "@umt/contracts"; import { describe, expect, it } from "vitest"; -import { - authDenial, - type ConvEntry, - groupConversation, - isDenied, - isLongReport, - pairAudit, - runPlanIds, - toolSummary, -} from "./agent-conversation.tsx"; -import { matchesHistory, rangeStart } from "./agent-session-list.tsx"; +import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx"; import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.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 }); }); }); - -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(); - }); -}); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index b181ab28..acd83a7f 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -32,18 +32,15 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] { })); } -/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */ +/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */ export function RunPlan({ plan, active, compact = false, - fill = false, }: { plan: { steps: AgentPlanStep[]; revision: number } | null; active: boolean; compact?: boolean; - /** 侧栏 / 展开的概况条里:不限高、不自动收起 */ - fill?: boolean; }) { if (!plan?.steps.length) return null; const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length; @@ -82,8 +79,8 @@ export function RunPlan({ title="步骤" items={planTodoItems(plan.steps)} defaultOpen - collapseOnComplete={!fill && !active} - maxHeightClassName={fill ? "max-h-none" : "max-h-56"} + collapseOnComplete={!active} + maxHeightClassName="max-h-56" /> ); } diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index fe820833..be3d0ee7 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,8 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { type AgentRunStatus, EventType } from "@umt/contracts"; -import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react"; +import { EventType } from "@umt/contracts"; +import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react"; import { useId, useRef, useState } from "react"; -import { useNavigate } from "react-router"; import { toast } from "sonner"; import { type AgentSessionOptions, @@ -10,16 +9,12 @@ import { agentMockEnabled, isProposeFirst, isRunActive, - type RunSnapshot, - TRIGGER_LABEL, useAbortAgentRun, useAgentRun, useAppendAgentMessage, - useCreateAgentRun, useRunApprovals, useRunInvocationAudit, } from "~/api/agent.ts"; -import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { MessageScroller } from "~/components/agentui/message-scroller.tsx"; import { PromptInput } from "~/components/agentui/prompt-input.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; @@ -42,7 +37,9 @@ import { } from "./agent-conversation.tsx"; import { RunPlan } from "./agent-interaction.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 }) { @@ -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 ( -
-
- - {isProposeFirst(run) ? 先看方案再执行 : null} - {agentMockEnabled() ? ( - - - mock 数据 - - ) : null} -
-

- {TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)} - {run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""} -

-
- ); -} - -/** - * 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。 - * 不 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 ( -
- - {steps.length ? ( -
-
-
- ) : null} - -
- - - -
-

预算与用量

- -
-
-
-
- ); -} - -/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */ -function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) { - const create = useCreateAgentRun(); - const [proposeFirst, setProposeFirst] = useState(false); - return ( -
- - -
- ); -} - -/** - * 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。 - * compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。 - */ +/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */ export function AgentRunPanel({ runId, compact = false, className, - onCreated, }: { runId: string; compact?: boolean; className?: string; - /** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ - onCreated?: (runId: string) => void; }) { const run = useAgentRun(runId); const active = isRunActive(run.data?.run.status); const live = useAgentStream(runId, active || !run.data); const abort = useAbortAgentRun(); const append = useAppendAgentMessage(); - const navigate = useNavigate(); const [msg, setMsg] = useState(""); const [confirmStop, setConfirmStop] = useState(false); const sseStatus = useSseStatus(); @@ -332,14 +190,13 @@ export function AgentRunPanel({ const { run: r, report, messages } = run.data; const finished = !isRunActive(r.status) || live.finished; const streaming = active && !live.finished; - const shownStatus: AgentRunStatus = + const proposeFirst = isProposeFirst(r); + const shownStatus = // 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示 (live.finished && (r.status === "queued" || r.status === "running")) || (r.status === "queued" && live.events.length > 0) ? "running" : r.status; - const ended = !isRunActive(r.status); - const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`)); const send = (text: string) => append.mutate( @@ -353,121 +210,100 @@ export function AgentRunPanel({ }, ); - const column = "mx-auto w-full max-w-[88ch]"; - return ( -
+
-
- - - {streaming && sseStatus === "reconnecting" ? ( -
- - 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 -
+
+ + {proposeFirst ? 先看方案再执行 : null} + + {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)} + {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""} + + {agentMockEnabled() ? ( + + + mock 数据 + ) : null} - {live.error ? ( -
- 事件读取失败:{live.error} - -
- ) : null} - - - ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} - after={ - <> - {approvals.items - .filter((a) => !inline.has(a.id)) - .map((a) => ( - - ))} - {finished ? : null} - - } - /> - - -
- setConfirmStop(true)} - stopLabel={abort.isPending ? "停止中…" : "停止"} - minRows={1} - maxRows={6} - maxLength={8000} - aria-label={ended ? "追加消息续跑" : "追加消息"} - placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"} - submitLabel={ended ? "续跑" : "发送"} - toolbar={ - ended ? ( - - ) : isProposeFirst(r) ? ( - 先看方案再执行 - ) : null - } - /> -

- {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") - ? `${r.summary}:在会话里的卡片上处理,会话随即继续。` - : ended - ? "续跑沿用本会话的上下文与预算。" - : "追加的消息在本轮结束后读取。"}{" "} - Enter 发送,Shift+Enter 换行。 -

-
- {compact ? null : ( -
+ + + + + + {streaming && sseStatus === "reconnecting" ? ( +
- - - -
-

预算与用量

- -
- - )} + + 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 +
+ ) : null} + {live.error ? ( +
+ 事件读取失败:{live.error} + +
+ ) : null} + + + ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} + after={ + <> + {approvals.items + .filter((a) => !inline.has(a.id)) + .map((a) => ( + + ))} + {finished ? : null} + + } + /> + + +
+ setConfirmStop(true)} + stopLabel={abort.isPending ? "停止中…" : "停止"} + minRows={1} + maxRows={6} + maxLength={8000} + aria-label="追加消息" + placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"} + /> +

+ {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") + ? `${r.summary}:在上面的卡片里处理,会话随即继续。` + : "追加的消息在本轮结束后读取。"}{" "} + Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。 +

+
({ 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 天" }, +const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [ + { value: "", label: "全部" }, + { value: "running", label: RUN_STATUS_LABEL.running }, + { value: "waiting", label: RUN_STATUS_LABEL.waiting }, + { value: "succeeded", label: RUN_STATUS_LABEL.succeeded }, + { value: "failed", label: RUN_STATUS_LABEL.failed }, + { value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded }, + { value: "cancelled", label: RUN_STATUS_LABEL.cancelled }, ]; -/** 时间范围的起点(毫秒);不限为 null */ -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, - f: Pick, - 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) { +function usageText(r: AgentRunView) { const parts = [`${r.toolCallCount} 次调用`]; if (r.usage.totalTokens !== undefined) parts.push( @@ -69,232 +31,104 @@ export function usageText(r: Pick) { 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 ( - - ); -} - -/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ -const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 }; - -function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) { - const goal = splitContextGoal(r.goal); - return ( - <> -
-

- {goal.text} -

- {children} -
-
- - {TRIGGER_LABEL[r.trigger]} - {r.scope.proposeFirst === true ? · 先看方案 : null} - -
-

- {goal.context ? `${goal.context.split(" · ")[0]} · ` : ""} - {usageText(r)} -

- - ); -} - -/** - * 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。 - * - hrefFor:会话页左栏,点一条跳到该会话页。 - * - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。 - */ +/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */ export function AgentSessionList({ activeId, - hrefFor, - onOpen, - remember = false, + status, + onStatusChange, className, }: { activeId?: string; - hrefFor?: (id: string) => string; - onOpen?: (id: string) => void; - remember?: boolean; + status: "" | AgentRunStatus; + onStatusChange: (s: "" | AgentRunStatus) => void; className?: string; }) { - const [f, setF] = useState(remember ? drawerMemory.filters : EMPTY_FILTERS); - const set = (patch: Partial) => - setF((prev) => { - 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(null); - const sentinel = useRef(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]); + const runs = useAgentRuns(status || undefined); + const mock = agentMockEnabled(); + const rows = runs.data?.pages.flatMap((p) => p.items) ?? []; return ( diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index d4b1826f..7a10eb2b 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -16,82 +16,16 @@ export type AgentContext = { goal?: string; }; -/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */ -export type AgentDrawerTab = "current" | "history"; +type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null }; -type State = { - 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; +let state: State = { open: false, minimized: false, context: null, runId: null }; const listeners = new Set<() => void>(); const emit = () => { for (const l of listeners) l(); }; -/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */ -export function lastAgentDrawerTab(): AgentDrawerTab { - 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"); +export function openAgentDrawer(context: AgentContext | null = null) { + state = { open: true, minimized: false, context, runId: null }; emit(); } @@ -106,9 +40,9 @@ export function minimizeAgentDrawer() { emit(); } -/** 从小窗恢复抽屉(小窗里的运行在「当前」) */ +/** 从小窗恢复抽屉 */ export function restoreAgentDrawer() { - state = { ...state, open: true, minimized: false, tab: "current" }; + state = { ...state, open: true, minimized: false }; emit(); } @@ -132,7 +66,7 @@ export function isAgentShortcut( export function toggleAgentDrawer(pathname: string) { if (state.open) setAgentDrawerOpen(false); else if (state.minimized && state.runId) restoreAgentDrawer(); - else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()); + else openAgentDrawer(contextFromPath(pathname)); } /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ diff --git a/apps/web/app/components/agent/agent-subagent-tree.tsx b/apps/web/app/components/agent/agent-subagent-tree.tsx index a22ce036..48dd03b1 100644 --- a/apps/web/app/components/agent/agent-subagent-tree.tsx +++ b/apps/web/app/components/agent/agent-subagent-tree.tsx @@ -1,6 +1,6 @@ import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts"; 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 { agentMockEnabled, type RunSnapshot } from "~/api/agent.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" : ""}`; -/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */ -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 ( - - ); - return ( - - {children} - - ); -} - function budgetText(b: Record) { const parts: string[] = []; if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`); @@ -70,7 +49,12 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map )}
- {splitContextGoal(node.goal).text} + + {splitContextGoal(node.goal).text} +
@@ -98,7 +82,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map(); @@ -109,14 +93,19 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c const direct = byParent.get(run.id) ?? []; const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length; return ( -
+
{parent ? (

父会话

- {splitContextGoal(parent.goal).text} + + {splitContextGoal(parent.goal).text} +

第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "} {budgetText(run.budget as Record)} @@ -126,7 +115,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c ) : null} {direct.length ? (

-

+

子会话 {children.length} 个{familyUsage ? : null}

diff --git a/apps/web/app/components/agent/mock.ts b/apps/web/app/components/agent/mock.ts index 08cb2cbd..3aeae6c2 100644 --- a/apps/web/app/components/agent/mock.ts +++ b/apps/web/app/components/agent/mock.ts @@ -245,15 +245,7 @@ function seed() { const now = Date.now(); add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1); add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2); - 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" }; + const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); failed.steps = [ { delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } }, { diff --git a/apps/web/app/components/agentui/tool-result.tsx b/apps/web/app/components/agentui/tool-result.tsx index 1aed2aea..360226b8 100644 --- a/apps/web/app/components/agentui/tool-result.tsx +++ b/apps/web/app/components/agentui/tool-result.tsx @@ -2,8 +2,7 @@ // Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md // 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉; // 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果); -// AGUI-2:收起态压成一行(能力名 · 状态 · 耗时 · 摘要),能力 ID 与徽标移进展开区; -// 默认一律收起(运行中也只显示一行状态);用户点开的卡片完成后保持展开(collapseOnComplete 可改)。 +// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。 import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { cn } from "~/lib/utils.ts"; @@ -62,8 +61,8 @@ export function ToolResult({ badges, summary, icon, - defaultOpen = false, - collapseOnComplete = false, + defaultOpen = status === "running", + collapseOnComplete = true, className, }: ToolResultProps) { const id = useId(); @@ -71,6 +70,7 @@ export function ToolResult({ const prev = useRef(status); const setOpen = useCallback((v: boolean) => setOpenState(v), []); useEffect(() => { + if (prev.current !== "running" && status === "running") setOpen(true); if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false); prev.current = status; }, [collapseOnComplete, setOpen, status]); @@ -88,31 +88,28 @@ export function ToolResult({ aria-expanded={open} aria-controls={`${id}-c`} 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" > {icon ?? } - {/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */} - {title} + + {title} + {tool} + {meta ? {meta} : null} + {st.label} - {meta ? {meta} : null} - - {!open && summary ? summary : null} - - + + {badges ?
{badges}
: null} + {!open && summary ? ( +

{summary}

+ ) : null} -
-
- {tool} - {badges} -
- {children} -
+
{children}
); diff --git a/apps/web/app/components/settings/sites.tsx b/apps/web/app/components/settings/sites.tsx index 5d39258d..1ce896f8 100644 --- a/apps/web/app/components/settings/sites.tsx +++ b/apps/web/app/components/settings/sites.tsx @@ -27,6 +27,24 @@ const DEFAULTS_CARD: CardSpec = { kind: "number", 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", label: "做种列表页数上限", @@ -54,6 +72,9 @@ const DEFAULTS_CARD: CardSpec = { "sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency), "sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)), "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.proxyUrl": s.sites.proxyUrl ?? "", }), @@ -61,6 +82,8 @@ const DEFAULTS_CARD: CardSpec = { const min = num(v["sites.defaultSnapshotIntervalSec"]); const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]); const concurrency = num(v["sites.defaultRateLimit.concurrency"]); + const healthH = num(v["sites.defaultHealthCheckIntervalSec"]); + const detailH = num(v["sites.defaultDetailCacheSec"]); return { sites: { defaultRateLimit: { @@ -70,6 +93,9 @@ const DEFAULTS_CARD: CardSpec = { }, defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60), 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, proxyUrl: str(v["sites.proxyUrl"]) || null, }, diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index 02d74f9d..94e8f9c7 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,5 +1,5 @@ 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 { ErrorState, LoadingState } from "~/components/console/empty-state.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 u = dAcc?.userData ?? null; const history = useSiteUserData(site.id, dAcc?.id, 30); + const stats = useSiteRequestStats(site.id); const st = ACCOUNT_OVERALL[overallStatus(site)]; const h = healthLine(site.lastHealth, site.lastHealthAt); @@ -104,6 +105,17 @@ export function SiteOverview({ site }: { site: SiteListItem }) { : "未开启", }, { label: "限流", value: rateLimitText(site.rateLimit) }, + { + label: "近 24 小时请求", + value: stats.data ? ( + + {stats.data.last24h} 次 + {accessSummary(stats.data)} + + ) : ( + "—" + ), + }, { label: "代理", value: site.proxy ? "使用" : "不使用" }, ]} /> diff --git a/apps/web/app/components/workbench/release-fields.ts b/apps/web/app/components/workbench/release-fields.ts index 08e7e845..6cbfd7c7 100644 --- a/apps/web/app/components/workbench/release-fields.ts +++ b/apps/web/app/components/workbench/release-fields.ts @@ -41,6 +41,97 @@ export function promotionLabel(r: Pick) => r.promotion.kind === "free" || r.promotion.kind === "2xfree"; +/** 完成数(站点 raw._site.snatched);没有时 null */ +export function snatchedOf(r: Pick): 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): SpecTag[] { + const p = (r.parsed ?? {}) as ParsedLike; + const out: SpecTag[] = []; + const seen = new Set(); + 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 后" */ export function relTime(iso: string | null | undefined, now = Date.now()): string { if (!iso) return "—"; diff --git a/apps/web/app/components/workbench/release-list.tsx b/apps/web/app/components/workbench/release-list.tsx index d2b43a64..809a0334 100644 --- a/apps/web/app/components/workbench/release-list.tsx +++ b/apps/web/app/components/workbench/release-list.tsx @@ -1,21 +1,34 @@ 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 { StatusBadge } from "~/components/console/status-badge.tsx"; 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 }) { const promo = promotionLabel(r); const hr = hrOf(r); + const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null; return ( <> {promo ? ( - - {promo.label} - {promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""} - + + + {promo.label} + {promo.expired ? "(已过期)" : left ? ` · ${left}` : ""} + + ) : null} {hr === "yes" ? H&R : null} {hr === "unknown" ? H&R 未知 : null} @@ -23,6 +36,40 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) { ); } +/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */ +export function SpecTags({ r }: { r: Pick }) { + const tags = releaseSpecTags(r); + if (!tags.length) return null; + return ( + <> + {tags.map((t) => ( + + {t.label} + + ))} + + ); +} + +/** 没有关联作品时的说明(明确写出,不猜) */ +export function unresolvedLabel(release: Pick): string { + return release.resolutionStatus === "ambiguous" + ? "识别有歧义" + : release.resolutionStatus === "unidentifiable" + ? "无法识别" + : release.resolutionStatus === "rejected" + ? "已排除" + : "未识别到本地作品"; +} + /** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */ export function InventoryOverlay({ release, @@ -33,17 +80,7 @@ export function InventoryOverlay({ entry: InventoryEntry | undefined; loading: boolean; }) { - if (!release.entity) { - const label = - release.resolutionStatus === "ambiguous" - ? "识别有歧义" - : release.resolutionStatus === "unidentifiable" - ? "无法识别" - : release.resolutionStatus === "rejected" - ? "已排除" - : "未识别到本地作品"; - return {label}; - } + if (!release.entity) return {unresolvedLabel(release)}; const flags = inventoryFlags(entry, release.resolutionStatus, release.id); return ( @@ -68,6 +105,11 @@ export function InventoryOverlay({ ); } +/** + * 资源列表行,两层: + * 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。 + * 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。 + */ export function ReleaseRow({ r, entry, @@ -86,6 +128,11 @@ export function ReleaseRow({ }) { const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore; 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 (
  • ); } + +/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */ +function InventoryLine({ + release, + entry, + loading, +}: { + release: WorkbenchRelease; + entry: InventoryEntry | undefined; + loading: boolean; +}) { + if (!release.entity && release.resolutionStatus !== "ambiguous") return null; + return ( +
    + +
    + ); +} diff --git a/apps/web/app/routes/console/agent.$runId.tsx b/apps/web/app/routes/console/agent.$runId.tsx index e918f9f3..031dd589 100644 --- a/apps/web/app/routes/console/agent.$runId.tsx +++ b/apps/web/app/routes/console/agent.$runId.tsx @@ -1,44 +1,35 @@ +import type { AgentRunStatus } from "@umt/contracts"; import { ArrowLeft } from "lucide-react"; +import { useState } from "react"; import { Link, useParams } from "react-router"; import { agentMockEnabled, useAgentRun } from "~/api/agent.ts"; import { AgentRunPanel } from "~/components/agent/agent-run-panel.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 { Button } from "~/components/ui/button.tsx"; -// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上; -// 更窄时换成会话列顶部的可折叠概况条)。 export default function AgentRunPage() { const { runId = "" } = useParams(); const run = useAgentRun(runId); const mock = agentMockEnabled(); - const suffix = mock ? "?mock=1" : ""; + const [status, setStatus] = useState<"" | AgentRunStatus>(""); return ( <> - {splitContextGoal(run.data.run.goal).text} -
    - ) : ( - "加载中…" - ) - } + description={run.data ? {run.data.run.goal} : "加载中…"} actions={ } /> -
    -