From 7b7fadbfb6098d596ea75adf93125588e9892e0a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:47:03 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(=E8=AF=86=E5=88=AB):=20=E9=9B=86?= =?UTF-8?q?=E6=95=B0=E8=AF=86=E5=88=AB=E5=88=86=E4=B8=A4=E5=B1=82,?= =?UTF-8?q?=E5=8F=AA=E5=AF=B9=E5=85=B3=E6=B3=A8=E4=B8=AD=E7=9A=84=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=8B=89=E5=85=83=E6=95=B0=E6=8D=AE=E3=80=81=E5=BC=80?= =?UTF-8?q?=E9=9B=86=E6=95=B0=E5=BE=85=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 isEntityWatched(service/watched.ts):有进行中订阅、用户搜索 / 手动识别 / 手动下载 / 人工确认身份、 正在下载或整理、已在媒体库的作品算关注中 - 自动新建作品后的 full 刷新只在关注范围内排(用户搜索 / 手动识别 / 接管照常);集数待确认事项只给关注中的作品开 - 进入关注范围(建订阅、用户搜索命中已有作品、开始下载)时对从没刷新过的作品补排刷新,刷新重算后再开事项 - 确定性的集数校验 / 换算对所有已绑定作品照做 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../routes/subscriptions.ts | 3 + .../services/downloads.ts | 4 + .../service/episode-resolution.ts | 4 + .../sources-identity/service/resolve.ts | 10 +- .../sources-identity/service/watched.ts | 70 ++++++++ .../episode-watch-scope.test.ts | 150 ++++++++++++++++++ docs/runbook/identity.md | 24 ++- 7 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/service/watched.ts create mode 100644 apps/server/test/sources-identity/episode-watch-scope.test.ts diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 4aed224c..9b5bcb49 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db"; import type { Context } from "hono"; import type { AuthEnv } from "../../../http/auth.ts"; import { ApiException } from "../../../http/errors.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import type { ModuleApp } from "../../types.ts"; import { ensureSubscriptionSlot } from "../services/acquisition.ts"; import type { AoDeps, Exec } from "../services/common.ts"; @@ -378,6 +379,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { return r; }); if (row.media_entity_id) { + // 建订阅 = 作品进入关注范围:从没刷新过元数据的补排 full 刷新(集数两层,见 sources-identity/service/watched.ts) + await enqueueWatchedRefresh(d.ctx, row.media_entity_id, "subscription"); await kickEvaluate(d, { subscriptionId: row.id }); // 新订阅:立即主动搜索一次(之后按周期);剧集先刷新元数据拿季总集数 await enqueueSubscriptionSearch(d, row.id, "created").catch((err) => diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index 854e5553..aa572fbf 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -35,6 +35,7 @@ import { import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts"; +import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import { errMessage, jsonb } from "../util.ts"; import { parsedOf } from "./acquisition.ts"; import { @@ -266,6 +267,9 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: .selectAll() .where("id", "=", plan.source_release_id) .executeTakeFirstOrThrow(); + // 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts) + const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id; + if (watchedEntity) await enqueueWatchedRefresh(d.ctx, watchedEntity, "download"); const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); const client = await d.ports.downloader(topo.inst); const tag = topo.inst.managed_tag; diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 72afde45..39ce0c87 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -25,6 +25,7 @@ import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; import { seasonOrigins } from "./episode-structure.ts"; +import { isEntityWatched } from "./watched.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: @@ -274,6 +275,9 @@ export async function settleEpisodeDecision( : null; await detachFromEpisodeDecisions(ctx, exec, sourceReleaseId, target); if (!target) return; + // 两层(2026-10-06):集数待确认是内部事项(供智能体调查,用户在订阅详情 / 作品页集数映射里看原因), + // 只给关注中的作品开;不在关注范围的作品资源照样待确认(不建集、订阅跳过),进入关注范围补刷新时重算再开 + if (!(await isEntityWatched(exec, entityId))) return; const existing = await exec .selectFrom("decision") diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index b7018efc..280a60cf 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -55,6 +55,7 @@ import { import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "./watched.ts"; // resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。 // auto 才建/关联正式作品;ambiguous / no_match / 查询失败进待识别池(候选只存 JSON),有界退避复查。 @@ -900,7 +901,14 @@ async function bindAuto( reason: resolution.reason, }; }); - if (created) await enqueueNewEntityRefresh(ctx, created); + // 两层(2026-10-06):自动新建的作品只在关注范围内才排刷新(用户搜索 / 手动识别 / 接管下载,或已被订阅等); + // RSS / 列表扫进来、没人关心的作品不拉 TMDB 季表,等它进入关注范围(建订阅、搜索、开始下载)时再补排 + const userAsked = lookup === "search" || lookup === "manual" || lookup === "takeover"; + if (created) { + if (userAsked || (await isEntityWatched(ctx.db, created))) await enqueueNewEntityRefresh(ctx, created); + } else if (userAsked && result.mediaEntityId) { + await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`); + } return result; } catch (e) { if (e instanceof IdentityConflictError && attempt < 2) continue; diff --git a/apps/server/src/modules/sources-identity/service/watched.ts b/apps/server/src/modules/sources-identity/service/watched.ts new file mode 100644 index 00000000..13cb7e67 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/watched.ts @@ -0,0 +1,70 @@ +import { sql } from "@umt/db"; +import type { ServerContext } from "../../types.ts"; +import type { Exec } from "./common.ts"; + +// 关注中的作品(2026-10-06,集数识别分两层): +// 确定性的集数校验 / 换算对所有已绑定作品的资源照做(无外部请求、无打扰); +// 有成本或会打扰的后续动作——拉 TMDB、自动新建作品后的元数据刷新、开集数待确认、派智能体——只对关注中的作品做。 +// +// 关注中 = 满足任一: +// 1. 有进行中的订阅(subscription.status in active / pending); +// 2. 用户明确表达过兴趣:手动下载过(acquisition_plan.evaluation.manual = true)、用户搜索 / 手动识别出的资源 +// (source_release.resolution_evidence.lookup in search / manual)、人工 / 智能体确认的外部身份(evidence_level = manual); +// 3. 正在下载或整理:未移除且没入库完的下载任务(直接、经资源或经下载计划关联),或排队 / 执行中的整理记录; +// 4. 已在媒体库:有在用(active)的已入库文件。 +// RSS / 站点列表扫进来、自动建档却没人关心的作品不算。 + +const OPEN_SUBSCRIPTION = ["active", "pending"]; +const DOWNLOAD_DONE = ["imported", "removed"]; + +export async function isEntityWatched(exec: Exec, entityId: string): Promise { + const r = await sql<{ watched: boolean }>` + select ( + exists (select 1 from subscription s where s.media_entity_id = ${entityId} and s.status = any(${OPEN_SUBSCRIPTION})) + or exists (select 1 from media_asset a where a.media_entity_id = ${entityId} and a.status = 'active') + or exists (select 1 from external_identity x + where x.media_entity_id = ${entityId} and x.status = 'accepted' and x.evidence_level = 'manual') + or exists (select 1 from source_release r + where r.media_entity_id = ${entityId} and r.resolution_evidence->>'lookup' in ('search', 'manual')) + or exists (select 1 from acquisition_plan p left join acquisition_slot sl on sl.id = p.slot_id + where (sl.media_entity_id = ${entityId} or p.evaluation->>'mediaEntityId' = ${entityId}::text) + and (p.evaluation->>'manual')::boolean is true) + or exists (select 1 from download_task t + left join source_release r on r.id = t.source_release_id + left join acquisition_plan p on p.id = t.acquisition_plan_id + left join acquisition_slot sl on sl.id = p.slot_id + where t.removed_at is null and t.status <> all(${DOWNLOAD_DONE}) + and ${entityId} in (t.media_entity_id, r.media_entity_id, sl.media_entity_id)) + or exists (select 1 from organization_record o + where o.media_entity_id = ${entityId} and o.status in ('queued', 'running')) + ) as watched`.execute(exec); + return r.rows[0]?.watched === true; +} + +/** + * 作品进入关注范围(建订阅、用户搜索、开始下载)时补排一次 full 元数据刷新:只对从没刷新过的作品排 + * (自动建档时因不在关注范围而没排的),刷新完成会重算待确认 / 滞后放行资源的集数(必要时开集数待确认)。 + * 入队失败只记日志;同一作品同一原因只排一次(幂等键)。 + */ +export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string, reason: string): Promise { + const e = await ctx.db + .selectFrom("media_entity") + .select(["id", "last_metadata_refresh_at", "status"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e || e.last_metadata_refresh_at || e.status === "merged") return false; + try { + await ctx.engine.enqueue( + "refreshMetadata", + { mediaEntityId: entityId, full: true }, + { idempotencyKey: `refreshMetadata:watched:${entityId}`, traceId: entityId }, + ); + return true; + } catch (err) { + ctx.logger.debug( + { entityId, reason, err: { message: err instanceof Error ? err.message : String(err) } }, + "进入关注范围的元数据刷新入队失败", + ); + return false; + } +} diff --git a/apps/server/test/sources-identity/episode-watch-scope.test.ts b/apps/server/test/sources-identity/episode-watch-scope.test.ts new file mode 100644 index 00000000..9b707613 --- /dev/null +++ b/apps/server/test/sources-identity/episode-watch-scope.test.ts @@ -0,0 +1,150 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 集数识别分两层(2026-10-06): +// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。 +// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。 + +const pg = await pgAvailable(); + +const season = (n: number, eps: number, start: string) => ({ + season_number: n, + air_date: start, + names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` }, + episodes: Array.from({ length: eps }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })), +}); + +// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认 +const SHOW: TmdbFixture = { + type: "tv", + id: 990301, + original_title: "Yoru no Kurage wa Oyogenai", + original_language: "ja", + origin_country: ["JP"], + date: "2024-04-06", + titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")], +}; + +describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] }); + h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + // 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围 + await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb) + on conflict (key) do update set value = excluded.value`.execute(h.db); + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const observe = async (title: string) => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId as string }; + }; + const method = async (id: string) => + ( + ( + await h.db + .selectFrom("source_release") + .select("episode_resolution") + .where("id", "=", id) + .executeTakeFirstOrThrow() + ).episode_resolution as { method: string } + ).method; + const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata"); + const episodeDecisions = () => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + + let entityId: string; + let r40: string; + + it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => { + const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + entityId = a.entityId; + expect(await method(a.id)).toBe("unverified"); + expect(await isEntityWatched(h.db, entityId)).toBe(false); + const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); + r40 = b.id; + expect(b.entityId).toBe(entityId); + expect(refreshJobs()).toEqual([]); + expect(await episodeDecisions()).toEqual([]); + // 季详情一次都没拉(识别只用搜索与详情) + expect(tmdb.callsTo(/\/season\//)).toHaveLength(0); + }); + + it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => { + // 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上 + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + expect(await method(r40)).toBe("unresolved"); + expect(await episodeDecisions()).toEqual([]); + await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute(); + }); + + it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => { + await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", status: "active" }) + .execute(); + expect(await isEntityWatched(h.db, entityId)).toBe(true); + expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true); + expect(refreshJobs()).toEqual([ + { + task: "refreshMetadata", + input: { mediaEntityId: entityId, full: true }, + key: `refreshMetadata:watched:${entityId}`, + }, + ]); + // 执行入队的刷新(同 refreshMetadata 任务处理器) + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + const ds = await episodeDecisions(); + expect(ds).toHaveLength(1); + expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]); + // 已刷新过的不再补排 + expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false); + }); + + it("用户搜索 / 手动识别出的新作品照常排刷新", async () => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" }) + .returningAll() + .executeTakeFirstOrThrow(); + const before = refreshJobs().length; + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true); + expect(refreshJobs().length).toBe(before + 1); + }); +}); diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 0028aeb7..777fe017 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -1,7 +1,8 @@ # 资源识别与待识别池运行手册(DEC-1) 新观察到的资源(RSS、站点列表快照、搜索结果)先进待识别池,只在有理由时才查 TMDB。只有和订阅或本地作品相关、 -又需要你决定的资源才进待处理。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警 +又需要你决定的资源才开内部事项(2026-10-06 起待处理中心取消:搜索 / RSS 扫进来的未识别资源静默留在「未识别」里,不提示, +要处理的在媒体库「未识别」里选候选或手动指定)。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警 (`app/chain/subscribe/match.py` `_prepare_match_torrents`:未识别的种子最多重试 3 次,之后跳过)。 需求依据:REQ §4.2–4.3(观察不等于事实)、§6.3(新作品收录 / 追更补缺 / 升级都由规则触发)、§7.5(待识别池可检索、 @@ -139,18 +140,27 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k ## 集数映射怎么用(集数解析批次 4) -资源绑定到剧集 / 综艺后,先按作品季表自动判定「媒体库该用哪一集」:原号对得上就用原号;元数据还没覆盖到(最新季未完结、总集数未知)先按原号放行;否则试季内 / 跨季绝对集号、按播出日期换算,唯一解才采用。都不行就是**集数待确认**:资源照常绑定作品,但不建集、订阅跳过、整理不按原号入库,并按「作品 + 来源季 + 发布组」开一条待处理。判定细节见 `docs/plan/episode-resolution.md`。 +资源绑定到剧集 / 综艺后,先按作品季表自动判定「媒体库该用哪一集」:原号对得上就用原号;元数据还没覆盖到(最新季未完结、总集数未知)先按原号放行;否则试季内 / 跨季绝对集号、按播出日期换算,唯一解才采用。都不行就是**集数待确认**:资源照常绑定作品,但不建集、订阅跳过、整理不按原号入库,并按「作品 + 来源季 + 发布组」开一条内部事项(只给关注中的作品开,见下面「两层」)。判定细节见 `docs/plan/episode-resolution.md`。 + +**两层(2026-10-06)**:确定性校验 / 换算对所有已绑定作品的资源照做(无外部请求、无打扰);有成本或打扰的后续动作——拉 TMDB、 +自动新建作品后的元数据刷新、开集数待确认事项、派智能体——只对**关注中**的作品做(`isEntityWatched`,`service/watched.ts`): +有进行中订阅(active / pending);用户明确搜索 / 手动识别过(资源 `resolution_evidence.lookup` 为 search / manual)、手动下载过 +(`acquisition_plan.evaluation.manual`)或人工 / 智能体确认过外部身份(evidence_level manual);正在下载(未移除、没入库完的下载任务) +或整理(排队 / 执行中的整理记录);已在媒体库(有 active 的已入库文件)。RSS / 列表扫进来自动建档、没人关心的作品不排刷新、不开事项。 +进入关注范围时补排一次 full 刷新(`enqueueWatchedRefresh`,幂等键 `refreshMetadata:watched:<作品>`,只对从没刷新过的作品): +建订阅、用户搜索 / 手动识别命中已有作品、开始下载(`submitDownload`);刷新会按作品重算待确认 / 滞后放行的资源,换算不了的这时才开事项。 +集数待确认不再是给用户逐条处理的条目:原因在订阅详情「集数映射」与作品页「集数映射」分区按组显示,建议规则 / 智能体建议在作品页一键确认。 **规则**在作品页「集数映射」分区管理(`GET/POST /media-entities/{id}/episode-mapping-rules`、`PATCH/DELETE /episode-mapping-rules/{id}`): - 作用域:作品必填,来源季、发布组可空(空 = 任意);越具体越优先。同作品 + 季 + 发布组(发布组忽略大小写)只能有一条启用的规则,冲突返回 409,先停用或修改旧的那条。 - 类型:**偏移表达式**(`EP-12`、`EP*2-1`,省略 EP 按 MP 约定补全,可指定目标季)、**对照表**(每行 `26=S02E01`,区间 `27-28=S02E02`,区间不能重叠)、**特别篇 / 花絮 / 忽略**。表达式解析不了、表格区间不合法直接拒绝。 -- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的待处理自动关闭。 -- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开待处理;手动停用的原因写「手动停用」。 +- 每次新建 / 修改 / 停用 / 启用 / 删除都在同一事务里按作品重算已绑定资源(人工锁定的不动),返回重算条数、变化条数、待确认转已解决条数;资源解决后对应的事项自动关闭。 +- 规则算出的集数对不上季表时自动停用(停用原因写当时的来源季集)并重新开事项;手动停用的原因写「手动停用」。 **模拟预览**(`POST /media-entities/{id}/episode-mapping/preview`,纯读):取作品下最近 100 条已绑定资源(按发布时间倒序,可按季 / 发布组过滤;季按来源季或规范季任一匹配),按原标题重解析后逐条给出原始季集 → 库里的当前结果 → 套用草稿规则后的结果、校验状态、是否有变化;另给季表摘要、按发布组的方式统计与待确认数、各组待确认样本推出的建议规则。草稿规则会替换同作用域的已有规则(修改时用 `replaceRuleId` 替换被改的那条)。作品页边改草稿边预览,点「应用」才写入。 -**待处理「集数待确认」**:卡片列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。 +**作品页「换算不了的资源」**(原待处理「集数待确认」卡片,2026-10-06 起显示在作品页集数映射分区):每组列出来源季集、原因、建议规则(同组待确认资源按发布时间对播出日期推出,全部样本成立且唯一才给)与它套用到同组资源的模拟结果。 - 确认:选项 `confirm_suggestion`,建议规则写成 expression 规则 → 事项按用户选择关闭 → 按作品重算(同一事务;仍对不上的资源另开新事项)。 - 修改:跳到作品页集数映射并预填草稿(`?mapSeason=&mapGroup=&mapExpr=&mapTarget=#episode-mapping`),在那里改完再应用。 @@ -160,7 +170,7 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k **整理向导**:识别结果里的季集只是按首个本地候选映射后的预填。向导只把用户改过的季集(行内输入、批量季号、集号偏移、弹窗「指定季号」)发给服务端,当作人工指定;没改过的由服务端按最终作品映射,所以改选作品后预填会清空、改由新作品映射。识别为集数待确认的行(`episodeUnresolved`)在向导里未决,填季集或排除后才能生成预览。 -**新建作品的季表**:自动识别新建的作品(和人工指定、整理接管一样)在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`);新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。 +**新建作品的季表**:自动识别新建的作品在关注范围内(用户搜索 / 手动识别 / 接管下载,或已被订阅等)才在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`),其余等进入关注范围时补排;新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。 **智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。 @@ -168,7 +178,7 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k - 三个能力:`episode_mapping.inspect`(只读:事项内资源、同组相邻资源各最多 50 条,季表每季最多 100 集含播出日期与集标题,现有规则含停用原因,确定性建议)→ `episode_mapping.simulate`(只读:复用模拟预览,汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认)→ `episode_mapping.apply_rule`(写)。 - `apply_rule` 服务端强制再模拟:样本有一条没解决、或会让当前已对上 / 已换算的资源变成待确认,直接拒绝不写库。通过才写规则:`created_by_type = agent`,样本 ≥ 3 且全部对得上记「多证据」(`multi_evidence`),否则「待复核」(`needs_review`);证据存理由、样本数、模拟摘要与会话 ID;随后按作品重算,资源都解决时事项自动关闭,系统据此核实恢复。 - 需要什么授权:后台自动调查的模式要是「授权内恢复」(`agent.autoInvestigateMode = recover`),且有一条覆盖 `episode_mapping.apply_rule` 的有效持续授权(action 写 `episode_mapping.apply_rule` 或 `*`;可用 `scope.mediaEntityIds / mediaKinds` 限定作品,按事项的作品核对;不随身份识别策略放开)。对话里没有授权时同样被拒,智能体只会说「需要你批准」。 -- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),待处理卡片显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 +- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),作品页集数映射里显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 - 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。 ## 剧集组(季集结构) From 414ea7c89049f5654fb9be574ef05f30eed22ad2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:47:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(=E6=99=BA=E8=83=BD=E4=BD=93):=20?= =?UTF-8?q?=E4=B8=8D=E8=AE=BE=E7=A1=AC=E6=80=A7=E9=A2=84=E7=AE=97=E4=B8=8A?= =?UTF-8?q?=E9=99=90,=E8=8A=B1=E8=B4=B9=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉每天次数、每天花费、单次花费、单次 token 四个上限的拦截;设置项登记为弃用,旧值兼容读取、不生效 - 保留单次工具调用次数与时长两个防跑飞限制,缺省调宽(120 次 / 1800 秒,后台调查 20 次) - 新增 GET /agent/usage:今天与最近 7 天按会话类型(对话 / 巡检 / 后台调查 / 整理识别 / 子会话)的次数、token 与费用 - 设置 agent.autoIdentifyOrganize(整理识别不到时叫智能体识别,缺省开)一并加在智能体设置里 - runner 里加整理识别会话的能力白名单与调查会话去重键解析(investigationDecisionId) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/agent/challenge.ts | 3 +- apps/server/src/modules/agent/routes.ts | 3 + apps/server/src/modules/agent/runner.ts | 45 ++++--- apps/server/src/modules/agent/subagents.ts | 6 +- apps/server/src/modules/agent/usage.ts | 44 +++++++ .../server/test/agent/spend-no-budget.test.ts | 110 ++++++++++++++++++ docs/runbook/agent.md | 31 ++++- packages/contracts/src/agent.ts | 17 +++ packages/contracts/src/api/review.ts | 7 ++ packages/contracts/src/settings.ts | 41 ++++--- 10 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/modules/agent/usage.ts create mode 100644 apps/server/test/agent/spend-no-budget.test.ts diff --git a/apps/server/src/modules/agent/challenge.ts b/apps/server/src/modules/agent/challenge.ts index 4143e38b..393856be 100644 --- a/apps/server/src/modules/agent/challenge.ts +++ b/apps/server/src/modules/agent/challenge.ts @@ -78,9 +78,8 @@ export function systemChallengeVision(env: () => AgentEnv, registry: CapabilityR success: { textAbsent: req.textAbsent ?? "Just a moment" }, maxSteps: Math.min(12, Math.max(1, req.maxSteps)), budget: { + // 不设 token / 费用上限(2026-10-06);步数与时长是防跑飞限制 maxDurationSec: Math.min(120, s.agent.maxDurationSec), - ...(s.agent.maxTokensPerRun ? { maxTokens: Math.max(1000, s.agent.maxTokensPerRun) } : {}), - ...(s.agent.maxCostUsdPerRun ? { maxCostUsd: s.agent.maxCostUsdPerRun } : {}), }, }; const hctx = { diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index e81ecdce..7db462b8 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -19,6 +19,7 @@ import { afterApprovalDecided, answerOptions } from "./interaction.ts"; import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts"; import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts"; import { descendants, descendantUsage } from "./subagents.ts"; +import { agentUsage } from "./usage.ts"; // Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。 @@ -119,6 +120,8 @@ export function registerAgentRoutes( const dispatch = new InternalDispatcher(app); // ── Agent 会话 ── + route(app, Api.agentRuns.usage, async () => agentUsage(db())); + route(app, Api.agentRuns.list, async ({ query }) => { let q = db().selectFrom("agent_run").selectAll(); if (query.status) q = q.where("status", "=", query.status); diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index f1e00c65..532f8f90 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -91,12 +91,35 @@ export const RECOVER_CAPABILITIES = [ "download.recheck", "episode_mapping.apply_rule", ] as const; +/** + * 整理没识别出作品时的智能体识别:查作品(TMDB / 媒体库 / 网页)+ 指定作品并重新整理。 + * 不能下载、删除、改别的事项;organize.assign_identity 只放行本事项(authorize.ts) + */ +export const ORGANIZE_IDENTIFY_CAPABILITIES = [ + "catalog.lookup", + "inventory.query", + "release.inspect", + "download.inspect", + "task.inspect", + "decision.list", + "web.search", + "web.fetch", + "organize.assign_identity", +] as const; +/** + * 后台调查会话的去重键 investigate:<事项 id>;用户在整理记录里再点一次「让智能体识别」时加 #<序号> 另起一个会话。 + * 返回事项 id,不是调查会话返回 null + */ +export function investigationDecisionId(dedupeKey: string | null | undefined): string | null { + return dedupeKey?.startsWith("investigate:") ? (dedupeKey.slice("investigate:".length).split("#")[0] ?? null) : null; +} /** 被视为"恢复动作"的能力(结果需系统核实) */ export const RECOVERY_ACTIONS = [ "task.retry", "download.recheck", "identity.resolve", "episode_mapping.apply_rule", + "organize.assign_identity", ] as const; export interface RunScope { @@ -162,8 +185,9 @@ export function agentSettings( upstream: RunUpstream, ): AgentSettings { const pi = s.pi; - const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined; - const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined; + // 设置里不再有 token / 费用上限(2026-10-06);只有调用方显式划拨(子会话、巡检预算)时才限制 + const maxTokens = budget.maxTokens; + const maxCost = budget.maxCostUsd; const up = upstream.row; return { provider: up.provider, @@ -387,20 +411,6 @@ async function lastOutcomeEvent(env: AgentEnv, runId: string) { return r ?? null; } -async function dailyBudgetBlock(env: AgentEnv, s: GlobalSettings, run: AgentRunRow): Promise { - if (run.started_at) return null; // 续跑不重复计数 - const day = new Date(); - day.setHours(0, 0, 0, 0); - const r = await sql<{ n: number; cost: number | null }>` - select count(*)::int as n, sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost - from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db); - const n = r.rows[0]?.n ?? 0; - const cost = r.rows[0]?.cost ?? 0; - if (n >= s.budget.agentMaxRunsPerDay) return `今日智能体运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`; - if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日智能体费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`; - return null; -} - /** * 驱动一次模型会话直到本轮结束(含追加消息)。整个函数在一个持久步骤里执行:worker 崩溃后新 worker 从头重跑本函数, * 打开同一 JSONL 会话,pendingOperations>0 时对账 + resume,已完成的工具调用不会重放。 @@ -416,9 +426,8 @@ export async function drive( const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow(); if (run.cancel_requested_at || signal.aborted) return { status: "cancelled", reason: "aborted", budget: null, message: "运行已被取消", finalText: "" }; + // 不设每日次数 / 费用上限(2026-10-06):花费在设置页与运行状态里可见,单次运行只受工具调用次数与时长约束 const s = await ctx.settings.all(); - const blocked = await dailyBudgetBlock(env, s, run); - if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" }; const scope = (run.scope ?? {}) as unknown as RunScope; // 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId;都没有直接失败 diff --git a/apps/server/src/modules/agent/subagents.ts b/apps/server/src/modules/agent/subagents.ts index 44b2ab2d..3c431306 100644 --- a/apps/server/src/modules/agent/subagents.ts +++ b/apps/server/src/modules/agent/subagents.ts @@ -471,9 +471,9 @@ function limitsOf(run: AgentRunRow, s: GlobalSettings): Limits { const b = (run.budget ?? {}) as AgentBudget; return { maxToolCalls: b.maxToolCalls ?? s.agent.maxToolCalls, - maxTokens: b.maxTokens ?? s.agent.maxTokensPerRun ?? null, - maxCostUsd: - b.maxCostUsd ?? (s.agent.maxCostUsdPerRun && s.agent.maxCostUsdPerRun > 0 ? s.agent.maxCostUsdPerRun : null), + // token / 费用只认本会话显式划拨的额度;设置里不再有全局上限(2026-10-06) + maxTokens: b.maxTokens ?? null, + maxCostUsd: b.maxCostUsd && b.maxCostUsd > 0 ? b.maxCostUsd : null, maxDurationSec: b.maxDurationSec ?? s.agent.maxDurationSec, }; } diff --git a/apps/server/src/modules/agent/usage.ts b/apps/server/src/modules/agent/usage.ts new file mode 100644 index 00000000..6ffb0b76 --- /dev/null +++ b/apps/server/src/modules/agent/usage.ts @@ -0,0 +1,44 @@ +import type { AgentSpend, AgentSpendBucket, AgentSpendKind } from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; + +// 智能体用量(2026-10-06):不设每日次数 / 费用上限,改为把花费摆出来。按 agent_run 的开始时间(没开始的按创建时间) +// 统计今天与最近 7 天,按会话类型分组;费用取 usage.costUsd(上游不报费用时为 0)。 + +/** 整理没识别出作品时智能体识别的会话发起者(见 investigate.ts) */ +export const ORGANIZE_IDENTIFY_ACTOR = "system:organize-identify"; + +const KIND_SQL = sql`case + when parent_run_id is not null then 'subagent' + when created_by = ${ORGANIZE_IDENTIFY_ACTOR} then 'organize_identify' + when trigger = 'schedule' then 'patrol' + when trigger = 'event' then 'investigate' + else 'chat' end`; + +const ORDER: AgentSpendKind[] = ["chat", "patrol", "investigate", "organize_identify", "subagent"]; + +async function bucket(db: Kysely, since: Date): Promise { + const r = await sql<{ kind: AgentSpendKind; runs: number; tokens: string | null; cost: number | null }>` + select ${KIND_SQL} as kind, count(*)::int as runs, + sum(coalesce((usage->>'totalTokens')::bigint, 0))::bigint as tokens, + sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost + from agent_run + where coalesce(started_at, created_at) >= ${since} + group by 1`.execute(db); + const byKind = r.rows + .map((x) => ({ kind: x.kind, runs: x.runs, tokens: Number(x.tokens ?? 0), costUsd: x.cost ?? 0 })) + .sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind)); + return { + runs: byKind.reduce((a, x) => a + x.runs, 0), + tokens: byKind.reduce((a, x) => a + x.tokens, 0), + costUsd: byKind.reduce((a, x) => a + x.costUsd, 0), + byKind, + }; +} + +export async function agentUsage(db: Kysely, now = new Date()): Promise { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + const week = new Date(today.getTime() - 6 * 86_400_000); + const [t, w] = await Promise.all([bucket(db, today), bucket(db, week)]); + return { today: t, last7d: w }; +} diff --git a/apps/server/test/agent/spend-no-budget.test.ts b/apps/server/test/agent/spend-no-budget.test.ts new file mode 100644 index 00000000..86318ba6 --- /dev/null +++ b/apps/server/test/agent/spend-no-budget.test.ts @@ -0,0 +1,110 @@ +import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings } from "@umt/contracts"; +import type { PiUpstreamRow } from "@umt/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createAgentModule } from "../../src/modules/agent/index.ts"; +import { agentSettings } from "../../src/modules/agent/runner.ts"; +import { ORGANIZE_IDENTIFY_ACTOR } from "../../src/modules/agent/usage.ts"; +import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; +import { DEV_KEY, json } from "../helpers.ts"; + +// 智能体不设硬性预算上限(2026-10-06):每天次数 / 每天花费 / 单次花费 / 单次 token 四个上限不再拦截(旧值兼容读取、不生效), +// 只保留单次工具调用次数与单次最长时长两个防跑飞限制(缺省调宽);花费改为可见:/agent/usage 按会话类型统计今天与最近 7 天。 + +const upstream = { row: { provider: "fake", default_model: null, thinking_level: "off" } as unknown as PiUpstreamRow }; + +describe("预算上限移除,防跑飞限制保留", () => { + it("旧设置里的 token / 费用上限不进运行参数;工具调用次数与时长照用", () => { + const legacy = { + ...DEFAULT_SETTINGS, + agent: { ...DEFAULT_SETTINGS.agent, maxTokensPerRun: 1000, maxCostUsdPerRun: 0.01 }, + budget: { ...DEFAULT_SETTINGS.budget, agentMaxRunsPerDay: 0, agentMaxCostUsdPerDay: 0 }, + } as GlobalSettings; + // biome-ignore lint/suspicious/noExplicitAny: 只取用到的上游字段 + const s = agentSettings(legacy, {}, true, upstream as any); + expect(s).not.toHaveProperty("costBudget"); + expect(s.maxToolCalls).toBe(DEFAULT_SETTINGS.agent.maxToolCalls); + expect(s.maxDurationMs).toBe(DEFAULT_SETTINGS.agent.maxDurationSec * 1000); + // 调用方显式划拨(子会话、巡检预算)仍然生效 + // biome-ignore lint/suspicious/noExplicitAny: 同上 + const sub = agentSettings(legacy, { maxToolCalls: 3, maxCostUsd: 0.5 }, true, upstream as any); + expect(sub.maxToolCalls).toBe(3); + expect(sub.costBudget).toEqual({ maxCostUsd: 0.5 }); + }); + + it("缺省调宽;四个上限登记为弃用字段", () => { + expect(DEFAULT_SETTINGS.agent.maxToolCalls).toBeGreaterThanOrEqual(100); + expect(DEFAULT_SETTINGS.agent.maxDurationSec).toBeGreaterThanOrEqual(1800); + expect(DEFAULT_SETTINGS.agent.investigateMaxToolCalls).toBeGreaterThanOrEqual(20); + expect(DEFAULT_SETTINGS.agent.autoIdentifyOrganize).toBe(true); + expect(DEFAULT_SETTINGS.budget).not.toHaveProperty("agentMaxRunsPerDay"); + expect(DEPRECATED_SETTINGS_FIELDS).toEqual( + expect.arrayContaining([ + "budget.agentMaxRunsPerDay", + "budget.agentMaxCostUsdPerDay", + "agent.maxTokensPerRun", + "agent.maxCostUsdPerRun", + ]), + ); + }); +}); + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("花费统计 /agent/usage(真实 PG)", () => { + let h: Harness; + beforeAll(async () => { + h = await createHarness({ extraModules: [createAgentModule()] }); + }, 120_000); + afterAll(async () => { + await h?.close(); + }); + + it("今天与最近 7 天按会话类型统计次数、token 与费用;超过 7 天的不算", async () => { + const day = 86_400_000; + const now = Date.now(); + const run = async ( + trigger: string, + at: number, + usage: { totalTokens: number; costUsd: number }, + extra: Record = {}, + ) => + ( + await h.db + .insertInto("agent_run") + .values({ + trigger, + goal: "g", + status: "succeeded", + mode: "execute", + usage: JSON.stringify(usage), + started_at: new Date(at), + created_at: new Date(at), + ...extra, + } as never) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const chat = await run("user", now, { totalTokens: 1000, costUsd: 0.1 }); + await run("subagent", now, { totalTokens: 500, costUsd: 0.05 }, { parent_run_id: chat }); + await run("event", now, { totalTokens: 200, costUsd: 0.02 }, { created_by: ORGANIZE_IDENTIFY_ACTOR }); + await run("event", now - 3 * day, { totalTokens: 300, costUsd: 0.03 }); + await run("schedule", now - 3 * day, { totalTokens: 400, costUsd: 0.04 }); + await run("user", now - 10 * day, { totalTokens: 9999, costUsd: 9 }); + + const res = await h.app.request("/api/v1/agent/usage", { headers: { authorization: `Bearer ${DEV_KEY}` } }); + expect(res.status).toBe(200); + const { today, last7d } = (await json(res)).data; + expect(today.runs).toBe(3); + expect(today.tokens).toBe(1700); + expect(today.costUsd).toBeCloseTo(0.17); + expect(today.byKind.map((k: { kind: string }) => k.kind)).toEqual(["chat", "organize_identify", "subagent"]); + expect(last7d.runs).toBe(5); + expect(last7d.costUsd).toBeCloseTo(0.24); + expect(last7d.byKind).toEqual( + expect.arrayContaining([ + { kind: "patrol", runs: 1, tokens: 400, costUsd: 0.04 }, + { kind: "investigate", runs: 1, tokens: 300, costUsd: 0.03 }, + ]), + ); + }); +}); diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index 09521cb1..d17bdc32 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -63,7 +63,7 @@ AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白 1. `drive` 步骤: - 取上游(provider / baseUrl / 模型 / 思考档位来自 `pi_upstream`,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。 - - 检查今日次数和费用预算(`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay`)。 + - 不设每日次数 / 费用上限(2026-10-06 起;`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay` 旧值兼容读取、不生效),花费在设置 → 智能体「用量与限制」与运行状态里可见(`GET /agent/usage`)。 - 打开 `AGENT_SESSION_DIR/.jsonl`。`pendingOperations>0` 说明上次崩溃:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。 2. `goal-settled` 步骤:只在会话正常结束、且不是后台只读调查(内部 `agent_run.mode=plan_only`)时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。 3. `finalize` 步骤:从业务表汇总计划、下载任务和整理记录,生成 `AgentGoalReport`(artifact kind=report),并据此决定终态: @@ -156,6 +156,20 @@ curl -X POST localhost:4380/api/v1/automation-grants -H "Authorization: Bearer < **有界尝试**:`evidence.autoRecover.attempt` 记录该根因的第几次自动恢复。同一去重键(如 `download_failed:`)下,历史上 `recoverAttempted=true` 的事项数达到 `agent.investigateMaxAttempts`(缺省 2)后,同根因的新事项不再唤醒模型,直接 `awaiting_user`,并写 `skipped=attempts_exhausted`。 +## 整理识别不到作品时叫智能体识别(2026-10-06) + +下载完整理时认不出作品(接管任务、文件名认不出、`metadata.resolve_organize` 也没认出来),`planImport` 照旧记 `identity_unknown` +并开内部事项 `organize_identity:<下载任务>`。`decision.created → resolveDecision → autoInvestigate` 认出这类事项后不看 `autoInvestigate`, +按 `agent.autoIdentifyOrganize`(缺省开,设置 → 智能体 → 自动调查)派一次识别会话(`identifyOrganize`): + +- 会话:`created_by = system:organize-identify`、`dedupe_key = investigate:<事项>`(同一事项只派一次),白名单 + `ORGANIZE_IDENTIFY_CAPABILITIES`(查 TMDB / 媒体库 / 网页 + `organize.assign_identity`),工具调用上限 `investigateMaxToolCalls`。 +- `organize.assign_identity {decisionId, mediaEntityId | tmdb, reasons}`:回填下载任务的作品 → 冻结手动整理计划 → 入队执行,事项关闭 + (与人工「指定作品」同一条路)。授权不走持续授权:只放行 dedupe 指向这条事项的识别会话与用户对话(`authorize.ts`)。 +- 结果按恢复动作核实(事项关闭 = 已指定并重新整理);没认出来事项转 `awaiting_user`,记录留在整理记录「需要处理」里。 +- 用户在整理记录里点「让智能体识别」(`POST /organization-records/{id}/agent-identify`)不看开关,入队 `resolveDecision {trigger: identify}`, + 另起一个会话(`investigate:<事项>#<时间戳>`)。开关关掉时只是不自动派。 + ## 上游与登录(W7-A) 实现:`apps/server/src/modules/pi/`(上游 CRUD、能力表、测试、服务端订阅登录、刷新任务)、`packages/agent/src/pi/{catalog,upstream,fake-upstream}.ts`、 @@ -238,19 +252,24 @@ OpenRouter 换到的是永久 key,不刷新。 "subagents": { "enabled": false, "maxConcurrent": 2, "maxDepth": 1 } // subagentsEnabled 是 enabled 的旧别名 }, "agent": { - "maxToolCalls": 40, "maxDurationSec": 900, // 单次运行预算 - "maxTokensPerRun": null, "maxCostUsdPerRun": null, - "autoInvestigate": false, "investigateMaxToolCalls": 8, + "maxToolCalls": 120, "maxDurationSec": 1800, // 单次运行的防跑飞限制(只保留这两个) + "autoIdentifyOrganize": true, // 整理识别不到作品时叫智能体识别(见下节) + "autoInvestigate": false, "investigateMaxToolCalls": 20, "autoInvestigateMode": "investigate", // 或 "recover"(授权内恢复,见上一节) "investigateMaxAttempts": 2, "recoverVerifySec": 600, "goalPollSec": 60, "goalWaitHours": 72, "optionsTimeoutSec": 1800, "optionsTimeoutAction": "recommended", // 方案选择超时:自动选推荐项或 "stop" "approvalTimeoutSec": 86400 // 会话内审批有效期 - }, - "budget": { "agentMaxRunsPerDay": 50, "agentMaxCostUsdPerDay": 5 } + } } ``` +**不设硬性预算(2026-10-06)**:每天次数(`budget.agentMaxRunsPerDay`)、每天花费(`budget.agentMaxCostUsdPerDay`)、单次花费(`agent.maxCostUsdPerRun`)、 +单次 token(`agent.maxTokensPerRun`)四个上限不再拦截,登记在 `DEPRECATED_SETTINGS_FIELDS`,设置表里的旧值照常读出、不生效。 +调用方显式划拨的额度(子会话预算、巡检的 `budget`)仍然生效。花费改为可见:`GET /agent/usage` 按会话类型 +(对话 / 巡检 / 后台调查 / 整理识别 / 子会话)给今天与最近 7 天的次数、token 与费用(取 `agent_run.usage.costUsd`,上游不报费用时为 0), +设置页「用量与限制」与运行状态页「智能体用量」都显示。 + 上下文预算裁剪模型的 contextWindow;思考档位取上游的 `thinkingLevel`,运行时夹到模型支持的档位。 - 会话目录由环境变量 `AGENT_SESSION_DIR` 指定,缺省为 `/data/agent-sessions`。api 进程和 worker 进程要能读到同一个目录,这是崩溃恢复的前提。 diff --git a/packages/contracts/src/agent.ts b/packages/contracts/src/agent.ts index 423578e2..14e5ce70 100644 --- a/packages/contracts/src/agent.ts +++ b/packages/contracts/src/agent.ts @@ -359,6 +359,23 @@ export type AgentRunChoiceInput = z.infer; export const AgentRunAbortInput = z.object({ reason: z.string().max(500).optional() }); export type AgentRunAbortInput = z.infer; +/** + * 智能体用量(2026-10-06 起不设硬性预算上限,花费改为可见):按会话类型统计次数、token 与费用。 + * chat = 用户对话;patrol = 智能体巡检;investigate = 待处理事项的后台调查 / 授权内恢复; + * organize_identify = 整理没识别出作品时的智能体识别;subagent = 子会话 + */ +export const AgentSpendKind = z.enum(["chat", "patrol", "investigate", "organize_identify", "subagent"]); +export type AgentSpendKind = z.infer; +export const AgentSpendBucket = z.object({ + runs: NonNegInt, + tokens: NonNegInt, + costUsd: z.number().min(0), + byKind: z.array(z.object({ kind: AgentSpendKind, runs: NonNegInt, tokens: NonNegInt, costUsd: z.number().min(0) })), +}); +export type AgentSpendBucket = z.infer; +export const AgentSpend = z.object({ today: AgentSpendBucket, last7d: AgentSpendBucket }); +export type AgentSpend = z.infer; + export const AgentRunListQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), diff --git a/packages/contracts/src/api/review.ts b/packages/contracts/src/api/review.ts index 4051eb0a..22e87f73 100644 --- a/packages/contracts/src/api/review.ts +++ b/packages/contracts/src/api/review.ts @@ -77,6 +77,13 @@ export const reviewApi = { ), /** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */ agentRuns: { + usage: ep({ + method: "get", + path: "/agent/usage", + scope: "read", + summary: "智能体用量:今天与最近 7 天按会话类型的次数、token 与费用", + response: agent.AgentSpend, + }), list: ep({ method: "get", path: "/agent/runs", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cadbf484..a604c1f8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -208,10 +208,12 @@ export const AgentRuntimeSettings = z.object({ .int() .min(10) .max(24 * 3600), - /** 单次运行:token 上限(null = 不限) */ - maxTokensPerRun: z.number().int().positive().nullable(), - /** 单次运行:费用上限(美元,null = 不限) */ - maxCostUsdPerRun: z.number().positive().nullable(), + /** 已弃用(2026-10-06 起不限 token,旧值兼容读取、不生效) */ + maxTokensPerRun: z.number().int().positive().nullable().optional(), + /** 已弃用(2026-10-06 起不限单次费用,旧值兼容读取、不生效) */ + maxCostUsdPerRun: z.number().positive().nullable().optional(), + /** 整理时没识别出作品:自动派智能体识别一次,认出来就按该作品重新整理(不需要持续授权) */ + autoIdentifyOrganize: z.boolean(), /** 后台自动调查的工具调用上限(比用户委托更小) */ investigateMaxToolCalls: z.number().int().min(1).max(100), /** 等待目标关联的下载/入库结束时的轮询间隔(秒) */ @@ -432,8 +434,10 @@ export const BudgetSettings = z.object({ maxNewTitlesPerDay: z.number().int().min(0), maxDownloadBytesPerDay: z.number().int().min(0), maxConcurrentDownloads: z.number().int().min(1).max(100), - agentMaxRunsPerDay: z.number().int().min(0), - agentMaxCostUsdPerDay: z.number().min(0), + /** 已弃用(2026-10-06 起智能体不设每日次数上限,旧值兼容读取、不生效) */ + agentMaxRunsPerDay: z.number().int().min(0).optional(), + /** 已弃用(2026-10-06 起智能体不设每日费用上限,旧值兼容读取、不生效) */ + agentMaxCostUsdPerDay: z.number().min(0).optional(), }); export type BudgetSettings = z.infer; @@ -667,19 +671,16 @@ export const DEFAULT_SETTINGS: GlobalSettings = { maxNewTitlesPerDay: 20, maxDownloadBytesPerDay: 500 * 1024 ** 3, maxConcurrentDownloads: 5, - agentMaxRunsPerDay: 50, - agentMaxCostUsdPerDay: 5, }, agent: { autoInvestigate: false, autoInvestigateMode: "investigate", investigateMaxAttempts: 2, recoverVerifySec: 600, - maxToolCalls: 40, - maxDurationSec: 900, - maxTokensPerRun: null, - maxCostUsdPerRun: null, - investigateMaxToolCalls: 8, + maxToolCalls: 120, + maxDurationSec: 1800, + autoIdentifyOrganize: true, + investigateMaxToolCalls: 20, goalPollSec: 60, goalWaitHours: 72, optionsTimeoutSec: 1800, @@ -858,6 +859,11 @@ export const DEPRECATED_SETTINGS_FIELDS = [ "cache.tmdbTtlHours", "cache.emptyResultTtlSec", "identity.poolArchiveDays", + // 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制 + "budget.agentMaxRunsPerDay", + "budget.agentMaxCostUsdPerDay", + "agent.maxTokensPerRun", + "agent.maxCostUsdPerRun", ] as const; // ── 设置页分区(docs/design/console-ia.md §3.4) ── @@ -1027,12 +1033,12 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ { id: "agent", label: "智能体", - description: "模型服务、运行预算、自动调查、网页搜索", + description: "模型服务、用量与限制、自动调查、网页搜索", legacyTabs: [{ tab: "pi" }], anchors: [ { id: "upstream", label: "模型服务" }, { id: "runtime", label: "上下文与子会话" }, - { id: "budget", label: "预算" }, + { id: "budget", label: "用量与限制" }, { id: "investigate", label: "自动调查" }, { id: "websearch", label: "网页搜索" }, { id: "advanced", label: "高级" }, @@ -1045,16 +1051,13 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ "pi.compaction", "pi.subagentsEnabled", "pi.subagents", - "budget.agentMaxRunsPerDay", - "budget.agentMaxCostUsdPerDay", "agent.autoInvestigate", + "agent.autoIdentifyOrganize", "agent.autoInvestigateMode", "agent.investigateMaxAttempts", "agent.recoverVerifySec", "agent.maxToolCalls", "agent.maxDurationSec", - "agent.maxTokensPerRun", - "agent.maxCostUsdPerRun", "agent.investigateMaxToolCalls", "agent.goalPollSec", "agent.goalWaitHours", From 1c1a9547038d1286cb244450dceebf84fd4058fa Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:47:18 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(=E6=95=B4=E7=90=86):=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=9C=A8=E6=95=B4=E7=90=86=E8=AE=B0=E5=BD=95=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E5=A4=84=E7=90=86,=E8=AF=86=E5=88=AB=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=87=AA=E5=8A=A8=E5=8F=AB=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整理记录「需要处理」:列表 attention 筛选与统计同口径(没被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略), 记录带 attention(原因类别、精简事项、能否让智能体识别);忽略端点记 attention_cleared_at(迁移 0078)并关闭关联事项 - 识别失败(organize_identity)按 agent.autoIdentifyOrganize 自动派一次识别会话;新能力 organize.assign_identity 指定作品后复用人工指定的钩子路径自动重新整理,只放行本事项的识别会话与用户对话;整理记录里可手动「让智能体识别」 - GET /attention:整理需要处理条数、等你批准的智能体操作、系统健康问题(铃铛 / 首页 / 运行状态共用) - 原因码文案不再指向待处理;命令面板「待处理」换成「需要处理的整理记录」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../acquisition-organize/capabilities.ts | 23 ++ .../acquisition-organize/routes/dashboard.ts | 55 ++++- .../routes/organization.ts | 82 +++++++ .../services/organize-records.ts | 103 +++++++- .../acquisition-organize/services/takeover.ts | 86 ++++++- apps/server/src/modules/agent/authorize.ts | 19 ++ apps/server/src/modules/agent/investigate.ts | 133 +++++++++- apps/server/src/modules/agent/service.ts | 14 +- apps/server/src/modules/agent/tasks.ts | 5 +- apps/server/src/modules/search/actions.ts | 7 +- .../organize-attention.test.ts | 227 ++++++++++++++++++ .../records-filter.test.ts | 10 +- .../server/test/e2e/organize-identify.test.ts | 121 ++++++++++ docs/runbook/organize.md | 25 ++ .../agent/skills/import-troubleshoot/SKILL.md | 7 +- .../agent/src/prompt/layers/capabilities.ts | 1 + packages/contracts/src/api/library.ts | 9 + packages/contracts/src/api/organize.ts | 16 ++ packages/contracts/src/capabilities-io.ts | 29 +++ packages/contracts/src/capability.ts | 15 ++ packages/contracts/src/organization.ts | 59 ++++- packages/contracts/src/reasons.ts | 8 +- packages/contracts/test/reasons.test.ts | 2 +- .../db/migrations/0078_organize_attention.ts | 15 ++ packages/db/src/types.ts | 1 + 25 files changed, 1037 insertions(+), 35 deletions(-) create mode 100644 apps/server/test/acquisition-organize/organize-attention.test.ts create mode 100644 apps/server/test/e2e/organize-identify.test.ts create mode 100644 packages/db/migrations/0078_organize_attention.ts diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index a3a997aa..0c8886cf 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -14,6 +14,7 @@ import { previewPolicy, publishRevision } from "./routes/rules.ts"; import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts"; import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts"; import { type FrozenPlan, planImport } from "./services/organize.ts"; +import { assignOrganizeIdentity, OrganizeDecisionError } from "./services/takeover.ts"; import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts"; // 本模块领域的能力处理器(规格一律取 contracts): @@ -591,6 +592,28 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => }; }); + // 整理时没识别出作品:智能体查到作品后指定,随即重新整理(授权见 agent/authorize.ts:只放行本事项的识别会话或用户对话) + registry.register(capabilitySpec("organize.assign_identity"), async (input, ctx) => { + if (!input.mediaEntityId && !input.tmdb) + throw new CapabilityError("invalid_input", "需要 mediaEntityId 或 tmdb(类型 + ID)"); + try { + return await assignOrganizeIdentity( + deps(), + input.decisionId, + input.mediaEntityId ? { mediaEntityId: input.mediaEntityId } : { tmdb: input.tmdb }, + { type: ctx.source === "web" ? "user" : "agent", id: ctx.agentRunId ?? ctx.actorId }, + input.reasons.join(";"), + ); + } catch (e) { + if (e instanceof OrganizeDecisionError) + throw new CapabilityError( + e.code === "bad_request" ? "invalid_input" : e.code === "not_found" ? "not_found" : "conflict", + e.message, + ); + throw e; + } + }); + registry.register(capabilitySpec("organize.execute"), async (input, ctx) => { const d = deps(); const db = d.ctx.db; diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index e5ea0055..0d0f431e 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -1,17 +1,68 @@ -import { Dashboard, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; +import { Api, Dashboard, type Decision, type DecisionKind, type SlotStatusCounts } from "@umt/contracts"; import { sql } from "@umt/db"; import type { ModuleApp } from "../../types.ts"; import type { AoDeps } from "../services/common.ts"; import { cleanupCandidates } from "../services/downloads.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { decideVolume, loadVolumes, usedPercent, volumeThresholds } from "../services/volumes.ts"; -import { startOfDay } from "../util.ts"; +import { startOfDay, toApiList } from "../util.ts"; import { mount } from "./helpers.ts"; // 首页六区块(REQ §13.4.3):只读投影,统计未知时给 null 并在 notes 说明。 const iso = (v: Date | null | undefined) => (v ? v.toISOString() : null); +/** 需要你批准的智能体操作:会话里的审批 / 方案选择、计划审批(整理记录上的审批在整理记录里处理)、原站页面等你接手 */ +const APPROVAL_SQL = sql`(subject_type = 'agent_run' + or (kind = 'approval_required' and coalesce(subject_type, '') <> 'organization_record') + or dedupe_key like 'browser.waiting:%')`; +/** 系统健康:站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线(恢复后自动关闭) */ +const HEALTH_SQL = sql`(kind in ('source_unreachable', 'downloader_offline', 'cache_volume_unknown', 'space_insufficient') + or dedupe_key like 'library_root_offline:%')`; +const OPEN_SQL = sql`status in ('open', 'investigating', 'awaiting_user')`; +const ATTENTION_ITEMS = 20; + export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { + // 铃铛 / 首页 / 运行状态共用的「需要关注」汇总(口径见 contracts AttentionSummary) + mount( + app, + Api.attention.summary, + async () => { + const db = d.ctx.db; + const group = async (where: typeof APPROVAL_SQL) => { + const [items, total] = await Promise.all([ + db + .selectFrom("decision") + .selectAll() + .where(OPEN_SQL) + .where(where) + .orderBy("created_at", "desc") + .limit(ATTENTION_ITEMS) + .execute(), + db + .selectFrom("decision") + .select((eb) => eb.fn.countAll().as("n")) + .where(OPEN_SQL) + .where(where) + .executeTakeFirst(), + ]); + return { total: Number(total?.n ?? 0), items: toApiList(items) }; + }; + const [organize, approvals, health] = await Promise.all([ + db + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")) + .where("action", "in", ["copy", "move", "in_place", "hardlink"]) + .where(attentionSql()) + .executeTakeFirst(), + group(APPROVAL_SQL), + group(HEALTH_SQL), + ]); + return { organize: Number(organize?.n ?? 0), approvals, health }; + }, + { tag: "dashboard" }, + ); + mount( app, { diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index 5f8daeb5..80ded260 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -15,6 +15,7 @@ import { type SourceGuard, sourceGuard, } from "../services/organize-guard.ts"; +import { attentionSql } from "../services/organize-records.ts"; import { actorOf, errMessage, hashJson, jsonb, notFound } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -50,7 +51,14 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { query, ).execute(); const n = (s: string) => Number(rows.find((r) => r.status === s)?.n ?? 0); + const att = await recordFilters( + db() + .selectFrom("organization_record") + .select((eb) => eb.fn.countAll().as("n")), + { ...query, attention: true }, + ).executeTakeFirst(); return { + attention: Number(att?.n ?? 0), total: rows.reduce((a, r) => a + Number(r.n), 0), succeeded: n("succeeded"), partial: n("partial"), @@ -102,6 +110,79 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); + // 忽略:这条记录不再算需要处理;关联的未关闭事项一并关闭(dismissed) + mount( + app, + Api.organization.dismissAttention, + async (c, { params }) => { + const by = actorOf(c); + const rec = await db() + .transaction() + .execute(async (trx) => { + const r = await trx + .updateTable("organization_record") + .set({ attention_cleared_at: d.ports.now() }) + .where("id", "=", params.id) + .returningAll() + .executeTakeFirst(); + if (!r) return null; + const closed = await trx + .updateTable("decision") + .set({ + status: "dismissed", + resolution: jsonb({ optionKey: null, note: "在整理记录里忽略", by }), + resolved_by: by, + resolved_at: d.ports.now(), + }) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .returning("id") + .execute(); + for (const x of closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId: x.id, status: "dismissed" }, + { idempotencyKey: `decision.updated:${x.id}:dismissed` }, + ); + return r; + }); + if (!rec) notFound("整理记录"); + await audit(d, by, "organize.attention_dismissed", { recordId: params.id }); + return (await loadRecordsPublic(db(), [rec!], false))[0]!; + }, + { tag: TAG }, + ); + + // 让智能体识别:没识别出作品的记录入队一次识别会话(agent 模块 resolveDecision trigger=identify),认出来自动重新整理 + mount( + app, + Api.organization.agentIdentify, + async (_c, { params }) => { + const dec = await db() + .selectFrom("decision") + .select(["id", "status", "evidence"]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "=", params.id) + .where("dedupe_key", "like", "organize_identity:%") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .orderBy("created_at", "desc") + .executeTakeFirst(); + if (!dec) throw new ApiException("conflict", "这条记录不是「没识别出作品」,不能让智能体识别"); + if (typeof (dec.evidence as { downloadTaskId?: unknown } | null)?.downloadTaskId !== "string") + throw new ApiException("conflict", "这条记录没有关联的下载任务。用整理向导手动整理"); + if (dec.status === "investigating") return { decisionId: dec.id, queued: false }; + await d.ctx.engine.enqueue( + "resolveDecision", + { decisionId: dec.id, trigger: "identify" }, + { idempotencyKey: `resolveDecision:identify:${dec.id}:${randomUUID()}`, traceId: dec.id }, + ); + return { decisionId: dec.id, queued: true }; + }, + { tag: TAG }, + ); + // 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪。 // 先过三道闸(organize-guard.ts):路径守卫(400)→ Move 授权(403)→ 纳管来源的做种义务(409 + 待处理)。 mount( @@ -273,6 +354,7 @@ function recordFilters=", new Date(query.since)); if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]); + if (query.attention) q = q.where(attentionSql()); if (query.kind === "none") q = q.where("media_entity_id", "is", null); else if (query.kind) q = q.where("media_entity_id", "in", (eb: ExpressionBuilder) => diff --git a/apps/server/src/modules/acquisition-organize/services/organize-records.ts b/apps/server/src/modules/acquisition-organize/services/organize-records.ts index 3cfb0f77..184b5cc6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-records.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-records.ts @@ -1,4 +1,4 @@ -import { reasonSentence } from "@umt/contracts"; +import { type DecisionOption, type RecordAttention, reasonSentence } from "@umt/contracts"; import { type OrganizationRecordRow, sql } from "@umt/db"; import { errMessage, jsonb } from "../util.ts"; import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "./common.ts"; @@ -9,11 +9,111 @@ import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 +// ── 需要处理(2026-10-06 去掉待处理中心:整理侧的问题在整理记录里就地处理) ── + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"] as const; + +/** + * 一条整理记录「需要处理」的口径(列表筛选、统计、首页 / 运行状态汇总共用): + * 没被同一下载任务后来的整理记录取代,并且 + * (有关联的未关闭事项(subject = 这条记录)) 或 (失败 / 部分完成且没被用户忽略)。 + * alias:外层 organization_record 的别名(kysely selectFrom("organization_record") 即表名)。 + */ +export function attentionSql(alias = "organization_record") { + const r = sql.raw(alias); + return sql`(not exists ( + select 1 from organization_record r2 + where ${r}.download_task_id is not null and r2.download_task_id = ${r}.download_task_id + and r2.created_at > ${r}.created_at) + and (exists ( + select 1 from decision dd + where dd.subject_type = 'organization_record' and dd.subject_id = ${r}.id + and dd.status in ('open', 'investigating', 'awaiting_user')) + or (${r}.status in ('failed', 'partial') and ${r}.attention_cleared_at is null)))`; +} + +/** 事项归到哪类处理:没识别出作品 / 目标冲突 / 其它 */ +function attentionTopic(dedupeKey: string | null, kind: string | null, reason: string | null) { + if (dedupeKey?.startsWith("organize_identity:") || reason?.startsWith("identity_unknown")) return "identity" as const; + if (dedupeKey?.startsWith("organize_blocked:") || kind === "path_conflict" || reason?.startsWith("blocked")) + return "conflict" as const; + return "other" as const; +} + +async function loadAttention(exec: Exec, rows: OrganizationRecordRow[]) { + const ids = rows.map((r) => r.id); + const [decisions, superseded] = await Promise.all([ + exec + .selectFrom("decision") + .select([ + "id", + "kind", + "status", + "title", + "summary", + "options", + "agent_run_id", + "evidence", + "dedupe_key", + "subject_id", + ]) + .where("subject_type", "=", "organization_record") + .where("subject_id", "in", ids) + .where("status", "in", [...OPEN_DECISION]) + .orderBy("created_at", "desc") + .execute(), + exec + .selectFrom("organization_record as r") + .select("r.id") + .where("r.id", "in", ids) + .where((eb) => + eb.exists( + eb + .selectFrom("organization_record as r2") + .select("r2.id") + .whereRef("r2.download_task_id", "=", "r.download_task_id") + .whereRef("r2.created_at", ">", "r.created_at"), + ), + ) + .execute(), + ]); + const old = new Set(superseded.map((x) => x.id)); + const out = new Map(); + for (const r of rows) { + const d = decisions.find((x) => x.subject_id === r.id) ?? null; + const failed = (r.status === "failed" || r.status === "partial") && !r.attention_cleared_at; + if (old.has(r.id) || (!d && !failed)) { + out.set(r.id, null); + continue; + } + const topic = attentionTopic(d?.dedupe_key ?? null, d?.kind ?? null, r.reason); + const ev = (d?.evidence ?? {}) as Record; + out.set(r.id, { + topic, + decision: d + ? { + id: d.id, + kind: d.kind, + status: d.status, + title: d.title, + summary: d.summary, + options: (d.options ?? []) as DecisionOption[], + agentRunId: d.agent_run_id, + evidence: ev, + } + : null, + agentIdentify: !!d && d.dedupe_key.startsWith("organize_identity:") && typeof ev.downloadTaskId === "string", + }); + } + return out; +} + // ── 公开记录映射 ── export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[], includeFiles: boolean) { if (rows.length === 0) return []; const ids = rows.map((r) => r.id); + const attention = await loadAttention(exec, rows); const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; const dlIds = [...new Set(rows.map((r) => r.download_task_id).filter((x): x is string => !!x))]; const [files, ops, entities, idents, units, sizes, dls] = await Promise.all([ @@ -196,6 +296,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ startedAt: iso(r.started_at), finishedAt: iso(r.finished_at), changeSeq: String(r.change_seq), + attention: attention.get(r.id) ?? null, }; }); } diff --git a/apps/server/src/modules/acquisition-organize/services/takeover.ts b/apps/server/src/modules/acquisition-organize/services/takeover.ts index da1e2809..bc7ccc65 100644 --- a/apps/server/src/modules/acquisition-organize/services/takeover.ts +++ b/apps/server/src/modules/acquisition-organize/services/takeover.ts @@ -326,13 +326,7 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { if (row.dedupe_key.startsWith("organize_identity:")) { if (!dt) throw new HookError("not_found", "关联的下载任务不存在"); const entityId = await chosenEntity(d, body, opt, actor); - await d.ctx.db - .updateTable("download_task") - .set({ media_entity_id: entityId }) - .where("id", "=", dt.id) - .execute(); - const r = await startManualImport(d, { downloadTaskId: dt.id, mediaEntityId: entityId }, actor, "decision"); - return { kind: "organize", mediaEntityId: entityId, ...r }; + return reorganizeAs(d, dt.id, entityId, actor, "decision"); } // organize_blocked:目标已存在 → 替换 / 保留两者 / 跳过 const key = body.optionKey; @@ -367,6 +361,84 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook { }; } +/** 回填下载任务的作品后按该作品重新整理(人工指定与智能体识别共用) */ +async function reorganizeAs( + d: AoDeps, + downloadTaskId: string, + entityId: string, + actor: DecisionHookActor, + via: string, +) { + await d.ctx.db + .updateTable("download_task") + .set({ media_entity_id: entityId }) + .where("id", "=", downloadTaskId) + .execute(); + const r = await startManualImport(d, { downloadTaskId, mediaEntityId: entityId }, actor, via); + return { kind: "organize", mediaEntityId: entityId, ...r }; +} + +const OPEN_DECISION = ["open", "investigating", "awaiting_user"]; + +/** + * organize.assign_identity:智能体给「没识别出作品」的整理事项指定作品(本地作品或 TMDB),随即重新整理并关闭事项。 + * 与人工在整理记录里「指定作品」走同一条路(回填下载任务 → 冻结手动整理计划 → 入队执行)。 + */ +export async function assignOrganizeIdentity( + d: AoDeps, + decisionId: string, + pick: { mediaEntityId?: string | undefined; tmdb?: { type: "movie" | "tv"; id: string } | undefined }, + actor: DecisionHookActor, + note: string, +): Promise<{ mediaEntityId: string; recordId: string | null; status: string; decisionStatus: "resolved" }> { + const db = d.ctx.db; + const row = await db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (!row) throw new HookError("not_found", "事项不存在"); + if (!row.dedupe_key.startsWith("organize_identity:")) + throw new HookError("bad_request", "只能用于整理时「没识别出作品」的事项"); + if (!OPEN_DECISION.includes(row.status)) throw new HookError("conflict", "这条记录已经处理过了"); + const ev = (row.evidence ?? {}) as { downloadTaskId?: string | null }; + const dtId = ev.downloadTaskId ?? null; + if (!dtId) throw new HookError("not_found", "这条记录没有关联的下载任务,只能在整理向导里手动整理"); + const entityId = pick.mediaEntityId + ? await chosenEntity( + d, + { identity: { mediaEntityId: pick.mediaEntityId } }, + { key: "x", label: "x", input: { requires: "mediaEntityId" } }, + actor, + ) + : await chosenEntity( + d, + { identity: { tmdb: pick.tmdb } }, + { key: "x", label: "x", input: { requires: "tmdb" } }, + actor, + ); + const r = await reorganizeAs(d, dtId, entityId, actor, "agent-identify"); + const by = actor.id ? `${actor.type}:${actor.id}` : actor.type; + await db.transaction().execute(async (trx) => { + const closed = await trx + .updateTable("decision") + .set({ + status: "resolved", + resolution: jsonb({ optionKey: null, note, by, execution: { ...r, via: "organize.assign_identity" } }), + resolved_by: by, + resolved_at: new Date(), + }) + .where("id", "=", decisionId) + .where("status", "in", OPEN_DECISION as never[]) + .returning("id") + .executeTakeFirst(); + if (closed) + await d.ctx.outbox.emit( + trx, + "decision.updated", + { decisionId, status: "resolved" }, + { idempotencyKey: `decision.updated:${decisionId}:resolved` }, + ); + }); + return { mediaEntityId: entityId, recordId: r.recordId, status: r.status, decisionStatus: "resolved" }; +} + /** 已登记下载任务里,内容路径(本机视图)与这些源路径重叠的(手动按路径整理时找回对应下载任务) */ export async function downloadTasksUnder(exec: Exec, paths: readonly string[]): Promise { if (paths.length === 0) return []; diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 9e690a71..8271d792 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -4,6 +4,7 @@ import type { CapabilitySpec } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { SettingsReader } from "../types.ts"; import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts"; +import { investigationDecisionId } from "./runner.ts"; import { exceedsAncestors } from "./subagents.ts"; // 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中 @@ -233,6 +234,24 @@ export function createAuthorizer(deps: { if (block) return { allowed: false, reason: block }; } + // 整理没识别出作品(organize.assign_identity):只放行这条事项的智能体识别会话(dedupe investigate:<事项>)与用户发起的对话; + // 不需要持续授权(用户在设置里开着「整理识别失败时叫智能体识别」即同意,默认开启) + if (d.id === "organize.assign_identity") { + if (!runId || (ctx.source !== "agent" && ctx.source !== "task")) + return { allowed: false, reason: "只有智能体会话或控制台可以指定整理作品" }; + const r = await db + .selectFrom("agent_run") + .select(["trigger", "dedupe_key"]) + .where("id", "=", runId) + .executeTakeFirst(); + if (r?.trigger === "user") return { allowed: true }; + const target = typeof inp.decisionId === "string" ? inp.decisionId : null; + const own = investigationDecisionId(r?.dedupe_key); + return target && own === target + ? { allowed: true } + : { allowed: false, reason: "只能处理本次识别的那条整理记录" }; + } + // 视觉子流程(spawn_reason = browser.solve_challenge)里的坐标动作:父会话那次 solve_challenge 已过授权, // 步数 / 预算 / 白名单由子流程约束,这里随之放行(否则只授权 solve_challenge 时子流程一步也点不了) if (runId && CHALLENGE_STEP_CAPS.has(d.id)) { diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 02c44ce5..2c620947 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -2,8 +2,16 @@ import type { DecisionOption, DownloadStatus } from "@umt/contracts"; import { type DecisionRow, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { GRANT_ACTIONS } from "./authorize.ts"; -import { type DriveResult, INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES, RECOVERY_ACTIONS } from "./runner.ts"; +import { + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + ORGANIZE_IDENTIFY_CAPABILITIES, + RECOVER_CAPABILITIES, + RECOVERY_ACTIONS, +} from "./runner.ts"; import { startRun } from "./service.ts"; +import { ORGANIZE_IDENTIFY_ACTOR } from "./usage.ts"; // 后台自动调查与授权内恢复(REQ §8.4.2、§10.7、AT68 / AT71): // decision.created → autoInvestigate:按 settings.agent.autoInvestigateMode 决定只读调查或授权内恢复, @@ -13,6 +21,9 @@ import { startRun } from "./service.ts"; // 按业务事实(download_task / task / source_release 状态;集数映射看事项内资源是否都已不是待确认)核实恢复结果, // 核实成功才关闭事项; // 失败 / 超时 / 预算超限一律保持待处理(finalize 把事项转为 awaiting_user),不自动再试。 +// 整理没识别出作品(organize_identity:*):不看 autoInvestigate,按 agent.autoIdentifyOrganize(缺省开)派一次智能体识别, +// 认出来调 organize.assign_identity 指定作品并重新整理(事项随即关闭);认不出停在整理记录「需要处理」里, +// 用户可在那里再点「让智能体识别」(manual,另起一个会话)。 const KINDS = ["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"] as const; export const AUTO_ACTOR = "system:auto-investigate"; @@ -66,8 +77,7 @@ export interface RecoveryOutcome { summary: string; } -const decisionIdOf = (dedupeKey: string | null | undefined) => - dedupeKey?.startsWith("investigate:") ? dedupeKey.slice("investigate:".length) : null; +const decisionIdOf = investigationDecisionId; export const isInvestigationRun = (run: { dedupe_key: string | null }) => decisionIdOf(run.dedupe_key) !== null; @@ -128,12 +138,13 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) { } function suggestionFor(d: DecisionRow, reason: string): string { + if (d.dedupe_key.startsWith("organize_identity:")) return `${reason}。可在整理记录里指定作品或重新整理。`; if (d.kind === "identity_ambiguous") - return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`; + return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`; if (d.kind === "episode_mapping") - return `${reason}。智能体模拟通过的规则会作为建议留在事项上,可在待处理中确认;或为 episode_mapping.apply_rule 发放持续授权。`; + return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`; return ( - `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + + `${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `换源、删除需人工决定。` ); } @@ -167,8 +178,10 @@ async function addRecheckOption(ctx: ServerContext, d: DecisionRow) { export async function autoInvestigate( ctx: ServerContext, decisionId: string, + opts: { manual?: boolean } = {}, ): Promise<{ runId: string | null; created: boolean; mode?: "investigate" | "recover"; skipped?: string }> { const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); + if (d?.dedupe_key.startsWith("organize_identity:")) return identifyOrganize(ctx, d, opts.manual === true); if (d?.status !== "open") return { runId: null, created: false }; if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false }; // Agent 自己引起的事项不再触发 Agent(防止递归自触发) @@ -284,6 +297,96 @@ export async function autoInvestigate( return { runId: run.id, created, mode }; } +/** 证据里的字段转成给模型看的一句(没有就空) */ +function evidenceLine(ev: Record): string { + const parsed = (ev.parsed ?? {}) as Record; + const parts = [ + typeof ev.torrentName === "string" ? `种子名「${ev.torrentName}」` : "", + typeof ev.mainFile === "string" ? `主视频「${ev.mainFile}」` : "", + parsed.title ? `解析出的标题「${String(parsed.title)}」` : "", + parsed.year ? `年份 ${String(parsed.year)}` : "", + parsed.season != null ? `第 ${String(parsed.season)} 季` : "", + Array.isArray(parsed.episodes) && parsed.episodes.length ? `集 ${parsed.episodes.join("、")}` : "", + ].filter(Boolean); + const cands = Array.isArray(ev.candidates) ? (ev.candidates as Record[]).slice(0, 5) : []; + const candText = cands.length + ? `;常规识别的候选:${cands + .map( + (c) => + `${String(c.title ?? "?")}${c.year ? ` (${String(c.year)})` : ""}${c.externalId ? ` tmdb:${String(c.entityType ?? "")}/${String(c.externalId)}` : ""}${c.localEntityId ? ` 本地作品 ${String(c.localEntityId)}` : ""}`, + ) + .join(";")}` + : ""; + return parts.length ? `${parts.join(",")}${candText}。` : candText ? `${candText.slice(1)}。` : ""; +} + +/** + * 整理没识别出作品 → 智能体识别一次(设置 agent.autoIdentifyOrganize,缺省开;manual = 用户在整理记录里点「让智能体识别」, + * 不看开关、事项已转人工也可以,另起一个会话)。会话在白名单内查作品,有把握才调 organize.assign_identity; + * 结果按恢复动作核实(事项关闭 = 已指定并重新整理),没认出来事项转 awaiting_user,留在整理记录「需要处理」里。 + */ +export async function identifyOrganize( + ctx: ServerContext, + d: DecisionRow, + manual: boolean, +): Promise<{ runId: string | null; created: boolean; mode?: "recover"; skipped?: string }> { + if (manual) { + if (!["open", "awaiting_user"].includes(d.status)) + return d.status === "investigating" && d.agent_run_id + ? { runId: d.agent_run_id, created: false, skipped: "running" } + : { runId: null, created: false, skipped: `status_${d.status}` }; + } else { + if (d.status !== "open") return { runId: null, created: false }; + const s = await ctx.settings.get("agent"); + if (!s.autoIdentifyOrganize) return { runId: null, created: false, skipped: "disabled" }; + } + const s = await ctx.settings.get("agent"); + const ev = (d.evidence ?? {}) as Record; + const goal = [ + `识别整理记录里的作品(待处理事项 ${d.id}):${d.title}。`, + evidenceLine(ev), + "用 catalog.lookup 按标题 / 年份查 TMDB,用 inventory.query 看媒体库里有没有这个作品;标题是拼音、缩写或译名对不上时可以用 web.search 查正式名称。", + `确认标题、年份、类型(电影 / 剧集)和季集都对得上时,调用一次 organize.assign_identity(decisionId 填 ${d.id},` + + "本地已有作品填 mediaEntityId,否则填 tmdb 类型和 ID,reasons 写依据),系统会按这个作品重新整理。", + "拿不准就不要指定,只说明查到了什么、为什么认不出。不下载、不删除、不改别的记录。", + ].join(""); + const { run, created } = await startRun(ctx, { + goal, + mode: "execute", + capabilityAllowlist: [...ORGANIZE_IDENTIFY_CAPABILITIES], + trigger: "event", + createdBy: ORGANIZE_IDENTIFY_ACTOR, + dedupeKey: manual ? `investigate:${d.id}#${Date.now()}` : `investigate:${d.id}`, + budget: { maxToolCalls: s.investigateMaxToolCalls, maxDurationSec: Math.min(900, s.maxDurationSec) }, + }); + if (created) { + const r = await ctx.db + .updateTable("decision") + .set({ status: "investigating", agent_run_id: run.id }) + .where("id", "=", d.id) + .where("status", "in", ["open", "awaiting_user"]) + .returning("id") + .executeTakeFirst(); + if (r) { + await mergeEvidence(ctx, d.id, { + mode: "recover", + requestedMode: "recover", + attempt: 1, + maxAttempts: 1, + recoverAttempted: true, + runId: run.id, + }); + await ctx.outbox.emit( + ctx.db, + "decision.updated", + { decisionId: d.id, status: "investigating" }, + { idempotencyKey: `decision.updated:${d.id}:investigating:${run.id}` }, + ); + } + } + return { runId: run.id, created, mode: "recover" }; +} + /** 本 run 调用过的恢复动作(账本)与被拒绝的调用(审计) */ export async function recoveryActions( ctx: ServerContext, @@ -356,6 +459,14 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[ : undefined; facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null; states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending"); + } else if (a.capabilityId === "organize.assign_identity") { + // 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录) + const id = String(a.input.decisionId ?? ""); + const d = id + ? await ctx.db.selectFrom("decision").select(["status"]).where("id", "=", id).executeTakeFirst() + : undefined; + facts[`decision:${id}`] = d ? { status: d.status } : null; + states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending"); } else if (a.capabilityId === "episode_mapping.apply_rule") { // 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复 const id = String(a.input.decisionId ?? ""); @@ -415,15 +526,15 @@ export async function applyRecoveryOutcome( ? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。` : outcome === "failed" ? done.length > 0 - ? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。` - : `未恢复:${what} 执行失败,已退回待处理。` + ? `未恢复:执行 ${what} 后业务状态仍为失败,已交还人工。` + : `未恢复:${what} 执行失败,已交还人工。` : outcome === "unverified" - ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。` + ? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已交还人工。` : outcome === "budget_exceeded" - ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。` + ? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已交还人工。` : outcome === "cancelled" ? "未恢复:运行已取消。" - : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`; + : `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已交还人工。`; const evidence: Partial = { runId, outcome, diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index 76ac9a08..cf36aa61 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -12,7 +12,14 @@ import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; import { dismissPendingInteractions } from "./interaction.ts"; import { buildGoalReport } from "./report.ts"; -import { type AgentEnv, type DriveResult, INVESTIGATE_CAPABILITIES, publishStatus, scopeFor } from "./runner.ts"; +import { + type AgentEnv, + type DriveResult, + INVESTIGATE_CAPABILITIES, + investigationDecisionId, + publishStatus, + scopeFor, +} from "./runner.ts"; import { cascadeCancel, descendants, @@ -364,8 +371,9 @@ export async function finalize( .where("id", "=", runId) .execute(); // 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒) - if (run.dedupe_key?.startsWith("investigate:")) { - const decisionId = run.dedupe_key.slice("investigate:".length); + const investigated = investigationDecisionId(run.dedupe_key); + if (investigated) { + const decisionId = investigated; await ctx.db.transaction().execute(async (trx) => { const r = await trx .updateTable("decision") diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 278436e4..4e8a89ba 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -177,12 +177,15 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) name: "resolveDecision", version: 1, lane: "agent", - inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated"]).default("updated") }), + // identify:用户在整理记录里点「让智能体识别」(整理没识别出作品的事项,另起一个识别会话) + inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated", "identify"]).default("updated") }), retryPolicy: DB_RETRY, handler: async (t) => { const e = env(); if (t.input.trigger === "created") return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId)); + if (t.input.trigger === "identify") + return t.step.run("identify", () => autoInvestigate(e.ctx, t.input.decisionId, { manual: true })); return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId)); }, }), diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index 840aabaf..18d17abb 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -23,13 +23,18 @@ const action = (id: string, group: string, title: string, href: string, keywords export const SEARCH_ACTIONS: readonly SearchAction[] = [ page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]), page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]), - page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]), page("schedules", "概览", "计划", "/console/schedules", ["schedule", "cron", "巡检", "计划", "定时任务"]), page("sites", "获取", "站点", "/console/sites", ["site", "pt", "站点账号", "rss", "feed", "bt"]), page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]), page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]), page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]), page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]), + page("organize-attention", "整理", "需要处理的整理记录", "/console/organize?tab=records&attention=1", [ + "inbox", + "待处理", + "识别不到", + "decision", + ]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]), diff --git a/apps/server/test/acquisition-organize/organize-attention.test.ts b/apps/server/test/acquisition-organize/organize-attention.test.ts new file mode 100644 index 00000000..6b4c6307 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-attention.test.ts @@ -0,0 +1,227 @@ +import { capabilitySpec } from "@umt/contracts"; +import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit"; +import { sql } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { assignOrganizeIdentity } from "../../src/modules/acquisition-organize/services/takeover.ts"; +import { createAuthorizer } from "../../src/modules/agent/authorize.ts"; +import { autoInvestigate } from "../../src/modules/agent/investigate.ts"; +import { ORGANIZE_IDENTIFY_CAPABILITIES } from "../../src/modules/agent/runner.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 去掉待处理中心后的整理侧(2026-10-06): +// - 下载完识别不到 → 整理记录「需要处理」(attention 口径:未被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略); +// - 识别失败自动派一次智能体识别(agent.autoIdentifyOrganize,缺省开;关掉不派), +// 智能体 organize.assign_identity 指定作品后重新整理、事项关闭,旧记录不再算需要处理; +// - 整理记录里「让智能体识别」入队一次识别会话;「忽略」清掉需要处理; +// - /attention:整理需要处理的条数;铃铛只放等你批准的智能体操作与系统健康,整理侧的审批不进铃铛。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; + +describe.skipIf(!hasPg)("整理记录需要处理 + 识别失败自动叫智能体识别", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let cache: TempTree; + let n = 0; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" }); + cache = await createTempTree({}, { prefix: "umt-cache-" }); + h = await createHarness("organize_attention", {}, { withAgent: true }); + await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + await h.setSettings("retention", { seedRetentionDays: 0 }); + }, 60_000); + + afterAll(async () => { + await h?.close(); + await qb?.close(); + await lib?.cleanup(); + await cache?.cleanup(); + }); + + async function adoptCompleted(name: string, file: string) { + n++; + const hash = n.toString(16).padStart(40, "b"); + qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); + await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] }); + await syncDownloads(h.d); + return h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirstOrThrow(); + } + + /** 接管任务识别不到 → 失败记录 + organize_identity 事项 */ + async function unidentified(name: string) { + const dt = await adoptCompleted(name, `${name}.mkv`); + const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); + expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" }); + const dec = await h.db + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", `organize_identity:${dt.id}`) + .executeTakeFirstOrThrow(); + return { dt, recordId: plan.recordId!, dec }; + } + + const attentionIds = async () => { + const r = await h.api("GET", "/organization-records?attention=true&organizeOnly=true"); + expect(r.status).toBe(200); + return (r.body.data.items as { id: string }[]).map((x) => x.id); + }; + + it("识别不到:记录进「需要处理」,行内带原因与可做的动作;统计同口径", async () => { + const { recordId, dec } = await unidentified("Vqzt.Nowhere.2019.1080p.WEB-DL"); + expect(await attentionIds()).toContain(recordId); + const one = await h.api("GET", `/organization-records/${recordId}`); + expect(one.body.data.attention).toMatchObject({ + topic: "identity", + agentIdentify: true, + decision: { id: dec.id, kind: "identity_ambiguous", status: "open" }, + }); + const stats = await h.api("GET", "/organization-records/stats?organizeOnly=true"); + expect(stats.body.data.attention).toBeGreaterThanOrEqual(1); + const summary = await h.api("GET", "/attention"); + expect(summary.status).toBe(200); + expect(summary.body.data.organize).toBe(stats.body.data.attention); + // 整理侧事项不进铃铛(铃铛只放等你批准的智能体操作与系统健康) + const bell = [...summary.body.data.approvals.items, ...summary.body.data.health.items].map( + (x: { id: string }) => x.id, + ); + expect(bell).not.toContain(dec.id); + }); + + it("开关开(缺省):识别失败自动派一次智能体识别;同一事项不重复派", async () => { + const { dec } = await unidentified("Wqxx.Elsewhere.2020.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + expect(out).toMatchObject({ created: true, mode: "recover" }); + const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", out.runId!).executeTakeFirstOrThrow(); + expect(run).toMatchObject({ + dedupe_key: `investigate:${dec.id}`, + created_by: "system:organize-identify", + trigger: "event", + mode: "execute", + }); + expect((run.scope as { capabilities: string[] }).capabilities.sort()).toEqual( + [...ORGANIZE_IDENTIFY_CAPABILITIES].sort(), + ); + expect(run.goal).toContain("organize.assign_identity"); + expect(run.goal).toContain(dec.id); + const after = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(after).toMatchObject({ status: "investigating", agent_run_id: run.id }); + expect((await autoInvestigate(h.ctx, dec.id)).created).toBe(false); + }); + + it("开关关:不派智能体,事项留在整理记录里", async () => { + await h.setSettings("agent", { autoIdentifyOrganize: false }); + try { + const { dec } = await unidentified("Xqyy.Nomatch.2021.1080p.WEB-DL"); + expect(await autoInvestigate(h.ctx, dec.id)).toMatchObject({ runId: null, created: false, skipped: "disabled" }); + const runs = await h.db + .selectFrom("agent_run") + .select("id") + .where("dedupe_key", "like", `investigate:${dec.id}%`) + .execute(); + expect(runs).toHaveLength(0); + // 用户在整理记录里点「让智能体识别」不看开关:入队一次识别 + const rec = await h.db + .selectFrom("decision") + .select("subject_id") + .where("id", "=", dec.id) + .executeTakeFirstOrThrow(); + const r = await h.api("POST", `/organization-records/${rec.subject_id}/agent-identify`); + expect(r.status).toBe(200); + expect(r.body.data).toEqual({ decisionId: dec.id, queued: true }); + expect(h.engine.enqueued.at(-1)).toMatchObject({ + task: "resolveDecision", + input: { decisionId: dec.id, trigger: "identify" }, + }); + const manual = await autoInvestigate(h.ctx, dec.id, { manual: true }); + expect(manual.created).toBe(true); + const run = await h.db + .selectFrom("agent_run") + .select("dedupe_key") + .where("id", "=", manual.runId!) + .executeTakeFirstOrThrow(); + expect(run.dedupe_key).toMatch(new RegExp(`^investigate:${dec.id}#\\d+$`)); + } finally { + await h.setSettings("agent", {}); + } + }); + + it("智能体识别成功:assign_identity 只放行本事项的会话;指定作品后自动重新整理,事项关闭,旧记录不再需要处理", async () => { + await h.setSettings("automation", { enabled: true }); + const target = await seedEntity(h.db, { kind: "movie", title: "Silent Meridian", year: 2018 }); + const { recordId, dec } = await unidentified("Smrd.2018.1080p.BluRay.x264-GRP"); + const other = await unidentified("Qqqq.Other.2017.1080p.WEB-DL"); + const out = await autoInvestigate(h.ctx, dec.id); + const authorize = createAuthorizer({ db: h.db, settings: h.ctx.settings }); + const spec = capabilitySpec("organize.assign_identity"); + const ctx = { + actorId: "agent", + taskId: "t", + scopeIds: [], + signal: new AbortController().signal, + source: "agent" as const, + agentRunId: out.runId!, + }; + const input = (decisionId: string) => ({ decisionId, mediaEntityId: target, reasons: ["标题年份一致"] }); + const allowed = await authorize({ descriptor: spec, input: input(dec.id), ctx }); + expect(allowed.allowed).toBe(true); + const denied = await authorize({ descriptor: spec, input: input(other.dec.id), ctx }); + expect(denied).toMatchObject({ allowed: false }); + + const r = await assignOrganizeIdentity( + h.d, + dec.id, + { mediaEntityId: target }, + { type: "agent", id: out.runId! }, + "标题年份一致", + ); + expect(r).toMatchObject({ mediaEntityId: target, status: "running", decisionStatus: "resolved" }); + const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + expect((await executeRecord(h.d, r.recordId!)).status).toBe("succeeded"); + const ids = await attentionIds(); + expect(ids).not.toContain(recordId); + expect(ids).not.toContain(r.recordId); + expect(ids).toContain(other.recordId); + // 同一事项再指定:已处理过 + await expect( + assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: target }, { type: "agent", id: null }, "x"), + ).rejects.toThrow(/已经处理过/); + }); + + it("忽略:失败记录不再需要处理,关联事项一并关闭", async () => { + const { recordId, dec } = await unidentified("Zzyy.Ignore.2016.1080p.WEB-DL"); + const r = await h.api("POST", `/organization-records/${recordId}/dismiss-attention`); + expect(r.status).toBe(200); + expect(r.body.data.attention).toBeNull(); + expect(await attentionIds()).not.toContain(recordId); + const d = await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(d.status).toBe("dismissed"); + }); + + it("/attention:系统健康与等你批准的智能体操作进铃铛,整理记录上的审批不进", async () => { + const run = await h.db + .insertInto("agent_run") + .values({ trigger: "user", goal: "g", status: "waiting", scope: "{}", budget: "{}", created_by: "user:x" }) + .returning("id") + .executeTakeFirstOrThrow(); + const rec = await h.db.selectFrom("organization_record").select("id").limit(1).executeTakeFirstOrThrow(); + const ins = (kind: string, key: string, subjectType: string | null, subjectId: string | null) => + sql`insert into decision (kind, severity, title, subject_type, subject_id, dedupe_key) + values (${kind}, 'warning', ${key}, ${subjectType}, ${subjectId}, ${key})`.execute(h.db); + await ins("cache_volume_unknown", "cache_volume_unknown:vol-1", "downloader_instance", null); + await ins("approval_required", "approval:run", "agent_run", run.id); + await ins("approval_required", "organize_blocked:hnr", "organization_record", rec.id); + await ins("episode_mapping", "episode_mapping:x", "media_entity", null); + const s = (await h.api("GET", "/attention")).body.data; + const keys = (g: { items: { dedupeKey: string }[] }) => g.items.map((x) => x.dedupeKey); + expect(keys(s.health)).toEqual(["cache_volume_unknown:vol-1"]); + expect(keys(s.approvals)).toEqual(["approval:run"]); + expect(s.health.total).toBe(1); + expect(s.approvals.total).toBe(1); + }); +}); diff --git a/apps/server/test/acquisition-organize/records-filter.test.ts b/apps/server/test/acquisition-organize/records-filter.test.ts index f0ee3fa6..8455337c 100644 --- a/apps/server/test/acquisition-organize/records-filter.test.ts +++ b/apps/server/test/acquisition-organize/records-filter.test.ts @@ -107,7 +107,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { it("统计与列表同口径,不受状态筛选影响", async () => { const all = await h.api("GET", "/organization-records/stats"); expect(all.status).toBe(200); - expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 }); + expect(all.body.data).toEqual({ + total: 4, + succeeded: 2, + partial: 0, + failed: 2, + running: 0, + cancelled: 0, + attention: 2, + }); const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true"); expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); }); diff --git a/apps/server/test/e2e/organize-identify.test.ts b/apps/server/test/e2e/organize-identify.test.ts new file mode 100644 index 00000000..700eff79 --- /dev/null +++ b/apps/server/test/e2e/organize-identify.test.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import type { FakeScript } from "@umt/agent"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts"; + +// 整理识别不到作品时自动叫智能体识别(2026-10-06,agent.autoIdentifyOrganize 缺省开): +// 用户手动加进 qB 的任务(贴管理标签)下完 → 整理按文件名认不出 → 整理记录失败 + 内部事项 organize_identity +// → decision.created → resolveDecision → 识别会话(白名单只有查询类 + organize.assign_identity,不需要持续授权) +// → 智能体查到本地作品后 organize.assign_identity → 按该作品自动重新整理入库,事项关闭,记录不再需要处理。 + +const hasPg = await pgAvailable(); + +/** 识别会话目标里带着事项 id(脚本从目标里取) */ +const cur: { decisionId?: string; entityId?: string } = {}; + +const identifyScript: FakeScript = { + name: "organize-identify", + match: (goal) => { + const m = /待处理事项 ([0-9a-f-]{36})/.exec(goal); + if (m && goal.includes("识别整理记录里的作品")) cur.decisionId = m[1]; + return goal.includes("识别整理记录里的作品"); + }, + steps: [ + { + text: "先看媒体库里有没有这部。", + toolCalls: [{ capability: "inventory.query", input: { query: "Silent Meridian" } }], + }, + { + text: "标题、年份都对得上,指定为媒体库里的《Silent Meridian》。", + toolCalls: [ + { + capability: "organize.assign_identity", + input: () => ({ + decisionId: cur.decisionId ?? "", + mediaEntityId: cur.entityId ?? "", + reasons: ["种子名缩写 Smrd 对应 Silent Meridian,年份 2018 一致"], + }), + }, + ], + }, + ], + finalText: "已指定作品并重新整理。", +}; + +describe.skipIf(!hasPg)("整理识别不到作品 → 智能体识别 → 自动重新整理", () => { + let e: E2E; + + beforeAll(async () => { + e = await createE2E("orgid", { fakeScripts: [identifyScript] }); + await seedBase(e); + const ent = await e.db + .insertInto("media_entity") + .values({ kind: "movie", title: "Silent Meridian", year: 2018, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + cur.entityId = ent.id; + await e.settings({ retention: { seedRetentionDays: 0 } }); + await e.startWorker(); + }, 120_000); + + afterAll(async () => { + await e?.close(); + }, 60_000); + + it("识别失败自动派一次识别会话;指定作品后自动重新整理入库,事项关闭,记录不再需要处理", async () => { + const name = "Smrd.2018.1080p.BluRay.x264-QQQ"; + const hash = createHash("sha1").update("orgid-1").digest("hex"); + e.qb.seed({ hash, name, size: 400_000, progress: 1, tags: ["umt"], save_path: e.cache.root }); + await e.qb.complete(hash, { files: [{ path: `${name}/${name}.mkv`, size: 400_000 }], writeFiles: true }); + + const dec = await waitFor( + () => + e.db.selectFrom("decision").selectAll().where("dedupe_key", "like", "organize_identity:%").executeTakeFirst(), + 60_000, + "整理识别不到 → organize_identity 事项", + ); + const run = await waitFor( + () => + e.db.selectFrom("agent_run").selectAll().where("dedupe_key", "=", `investigate:${dec.id}`).executeTakeFirst(), + 60_000, + "识别会话", + ); + expect(run).toMatchObject({ trigger: "event", mode: "execute", created_by: "system:organize-identify" }); + + const done = await waitFor( + async () => { + const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow(); + return r.finished_at ? r : null; + }, + 120_000, + "识别会话结束", + ); + const starts = (await runEvents(e, run.id)) + .filter((x) => x.type === "tool_call_start") + .map((x) => (x.payload as { capabilityId: string }).capabilityId); + expect(starts).toEqual(["inventory.query", "organize.assign_identity"]); + expect(done.status).toBe("succeeded"); + expect(done.summary).toContain("已恢复"); + + const closed = await e.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + + // 自动重新整理:同一下载任务的新整理记录成功入库,作品是智能体指定的 + const rec = await waitFor( + async () => { + const r = await e.db + .selectFrom("organization_record") + .selectAll() + .where("status", "=", "succeeded") + .where("media_entity_id", "=", cur.entityId!) + .executeTakeFirst(); + return r ?? null; + }, + 90_000, + "重新整理入库", + ); + expect(rec.download_task_id).toBeTruthy(); + const attention = await e.api("GET", "/organization-records?attention=true"); + expect(attention.body.data.items).toEqual([]); + }, 300_000); +}); diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index cb462d4a..756d03c2 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -103,6 +103,31 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue` | 批量重新整理 | 新增 | 选中记录的可用文件一起进整理弹窗(成功移动取目标,其他取源);同属一个作品时预选作品 | | 清空整理记录 | 不做 | 整理记录同时是文件变更的追溯记录;清理按保留期由计划页负责 | +## 需要处理(2026-10-06,待处理中心取消) + +整理侧的问题(下载完识别不到、目标已存在 / 路径冲突、文件被排除、非视频、整理卡住 / 失败)不再进待处理中心,在整理记录里就地处理。 +`decision` 表保留为内部机制(去重、触发智能体、自动关闭),整理侧事项的 subject 都是整理记录。 + +**口径**(`attentionSql`,`services/organize-records.ts`;列表 `?attention=true`、统计 `attention`、`GET /attention` 的 `organize` 共用): +没被同一下载任务后来的整理记录取代,并且(有 subject 为这条记录的未关闭事项,或失败 / 部分完成且 `attention_cleared_at` 为空)。 + +**行内动作**(`PublicOrganizationRecord.attention`:`topic` + 精简事项 + `agentIdentify`): + +| topic | 原因 | 动作 | +|---|---|---| +| `identity` | 没识别出作品(`organize_identity:*`) | 指定作品(本地 / TMDB,`/decisions/{id}/resolve` → 钩子重新整理)、候选、让智能体识别、重新整理、忽略 | +| `conflict` | 目标已存在 / 路径冲突(`organize_blocked:*`、`path_conflict`) | 事项选项:替换 / 保留两者 / 跳过;忽略 | +| `other` | 被排除、非视频、卡住、执行失败… | 重新整理(整理向导)、忽略 | + +- 忽略:`POST /organization-records/{id}/dismiss-attention` 记 `attention_cleared_at`(迁移 0078),关联的未关闭事项一并 `dismissed`。 +- 让智能体识别:`POST /organization-records/{id}/agent-identify`,见 agent.md「整理识别不到作品时叫智能体识别」;识别失败缺省会自动派一次。 +- 旧链接 `/console/inbox?kind=` 按问题类型跳转(`routes/console/redirects.ts inboxRedirect`):整理类 → 整理记录「需要处理」, + 下载类 → 下载面板,健康类 → 运行状态 `#attention`,集数待确认 → 订阅。 + +**铃铛与汇总**(`GET /attention`):铃铛只放等你批准的智能体操作(subject 为 agent_run 的审批 / 方案选择、计划审批、`browser.waiting:*`; +整理记录上的审批不进)与系统健康(`source_unreachable`、`downloader_offline`、`cache_volume_unknown`、`space_insufficient`、 +`library_root_offline:*`,恢复后自动关闭即消失)。首页「要我处理」与运行状态「需要关注」放汇总计数,点进去到对应页面。 + ## 删除 `POST /organization-records/delete {ids[≤200], deleteSource, deleteTarget, dryRun}`,scope `organize.execute`。 diff --git a/packages/agent/skills/import-troubleshoot/SKILL.md b/packages/agent/skills/import-troubleshoot/SKILL.md index 41e11f0a..d1397afc 100644 --- a/packages/agent/skills/import-troubleshoot/SKILL.md +++ b/packages/agent/skills/import-troubleshoot/SKILL.md @@ -8,6 +8,7 @@ triggers: - 识别(错|不出)|认错|没识别|对不上作品 - 为什么.{0,20}(没|不|还没) capabilities: + - catalog.lookup - inventory.query - download.list - download.inspect @@ -18,6 +19,7 @@ capabilities: - organize.execute - metadata.enrich - metadata.resolve_organize + - organize.assign_identity sessions: [chat, investigate, child] --- @@ -33,14 +35,15 @@ sessions: [chat, investigate, child] - 还在下载 / 无进展 → 汇报进度和最近进展时间; - 已完成待整理 / 整理中 → 看关联的核验入库任务(task.inspect 看错误); - 入库失败 → 读整理记录和错误,一句话说原因; - - 有「没识别出作品」的待处理事项 → decision.list 确认,说明要用户指定作品。 -4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。整理要用的资源连常规识别也对不上作品时,用 metadata.resolve_organize 补全译名重查 TMDB,仍没有合适条目才建立带来源证据的本地作品。 + - 整理记录「没识别出作品」→ decision.list 确认事项,按下面第 4 步认作品;认出来用 organize.assign_identity 指定(会重新整理),认不出说明要用户在整理记录里指定作品。 +4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。整理要用的资源连常规识别也对不上作品时,用 metadata.resolve_organize 补全译名重查 TMDB,仍没有合适条目才建立带来源证据的本地作品。整理时「没识别出作品」的记录(接管任务、文件名认不出):catalog.lookup / inventory.query 核对标题、年份、类型和季集都对得上后,organize.assign_identity 写一次(带依据),系统按该作品重新整理;拿不准不要指定。 5. 需要重新整理时:organize.plan 生成计划(不动文件),再 organize.execute。 ## 需要批准 - organize.execute(执行整理)、identity.resolve(身份映射)需要授权;没有授权时写一句「没做:重新整理,需要你批准」。 - organize.plan、查询类不需要批准。 +- organize.assign_identity(指定整理作品)不需要持续授权:用户对话里可直接用,后台识别会话只能处理它自己那条记录。 ## 汇报模板 diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index 7e9f17f5..61dcabed 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.ts @@ -130,6 +130,7 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [ { cap: "web.fetch", note: "读其中一条结果的正文" }, { cap: "metadata.enrich", note: "TMDB 查不到的作品出元数据建议" }, { cap: "metadata.resolve_organize", note: "整理用的资源对不上作品时兜底识别或建本地作品" }, + { cap: "organize.assign_identity", note: "整理时没识别出作品:查到作品后指定,随即重新整理" }, ], }, ]; diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts index 46cf35ba..7fbaf76b 100644 --- a/packages/contracts/src/api/library.ts +++ b/packages/contracts/src/api/library.ts @@ -149,6 +149,15 @@ export const libraryApi = { response: organization.Dashboard, }), }, + attention: { + summary: ep({ + method: "get", + path: "/attention", + scope: "read", + summary: "需要关注的汇总:整理记录需要处理的条数、等你批准的智能体操作、系统健康问题", + response: organization.AttentionSummary, + }), + }, downloadCache: { cleanup: ep({ method: "post", diff --git a/packages/contracts/src/api/organize.ts b/packages/contracts/src/api/organize.ts index 872c0f5a..8011ee50 100644 --- a/packages/contracts/src/api/organize.ts +++ b/packages/contracts/src/api/organize.ts @@ -49,6 +49,22 @@ export const organizeApi = { body: organization.OrganizationDeleteRequest, response: organization.OrganizationDeleteResult, }), + dismissAttention: ep({ + method: "post", + path: "/organization-records/{id}/dismiss-attention", + scope: "organize.execute", + summary: "忽略这条整理记录的问题:不再算需要处理,关联的未关闭事项一并关闭", + params: IdParam, + response: organization.PublicOrganizationRecord, + }), + agentIdentify: ep({ + method: "post", + path: "/organization-records/{id}/agent-identify", + scope: "organize.execute", + summary: "让智能体识别这条整理记录的作品(没识别出作品的记录),认出来自动重新整理", + params: IdParam, + response: organization.OrganizationAgentIdentifyResult, + }), create: ep({ method: "post", path: "/organization-records", diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index edc5b774..79733169 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -1088,6 +1088,34 @@ export const EpisodeMappingApplyRuleOutput = z.object({ decisionStatus: DecisionStatus, }); +/** organize.assign_identity:给「没识别出作品」的整理事项指定作品并重新整理 */ +/** mediaEntityId 与 tmdb 二选一(都给时用 mediaEntityId,都没给由处理器拒绝) */ +export const OrganizeAssignIdentityInput = z.object({ + /** 「没识别出作品」事项 ID(调查目标里给出) */ + decisionId: Id, + mediaEntityId: Id.optional(), + tmdb: z + .object({ + type: z.enum(["movie", "tv"]), + id: z + .string() + .trim() + .regex(/^\d{1,10}$/), + }) + .optional(), + /** 为什么是这个作品(依据),至少一条 */ + reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10), +}); +export const OrganizeAssignIdentityOutput = z.object({ + mediaEntityId: Id, + /** 重新整理的记录与状态 */ + recordId: Id.nullable(), + status: z.string(), + decisionStatus: DecisionStatus, +}); +export type OrganizeAssignIdentityInput = z.infer; +export type OrganizeAssignIdentityOutput = z.infer; + /** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */ // ── schedule.draft_check:按自然语言需求起草巡检(建成未启用,用户在计划页检查后启用) ── @@ -1167,6 +1195,7 @@ export const CAPABILITY_IO = { "episode_mapping.inspect": { input: EpisodeMappingInspectInput, output: EpisodeMappingInspectOutput }, "episode_mapping.simulate": { input: EpisodeMappingSimulateInput, output: EpisodeMappingSimulateOutput }, "episode_mapping.apply_rule": { input: EpisodeMappingApplyRuleInput, output: EpisodeMappingApplyRuleOutput }, + "organize.assign_identity": { input: OrganizeAssignIdentityInput, output: OrganizeAssignIdentityOutput }, } as const satisfies Record; type CapabilityIo = typeof CAPABILITY_IO; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index c1763ad2..0d8035d6 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -100,6 +100,7 @@ export const CAPABILITY_IDS = [ "episode_mapping.inspect", "episode_mapping.simulate", "episode_mapping.apply_rule", + "organize.assign_identity", ] as const; export const KnownCapabilityId = z.enum(CAPABILITY_IDS); export type KnownCapabilityId = z.infer; @@ -668,6 +669,19 @@ export const CAPABILITY_CATALOG: Record false, true, ), + // 整理时没识别出作品(organize_identity 事项):智能体查到作品后指定,随即按该作品重新整理 + "organize.assign_identity": d( + "organize.assign_identity", + "指定整理作品", + "给没识别出作品的整理记录指定作品(本地作品 ID 或 TMDB 类型 + ID),随即按这个作品重新整理。只用于「没识别出作品」事项;先用 catalog.lookup / inventory.query 核对标题、年份、季集,拿不准就不要指定", + "execute", + "OrganizeAssignIdentity", + "organize.execute", + "medium", + false, + false, + "never", + ), }; /** @@ -733,6 +747,7 @@ export const CAPABILITY_ACTIVITY: Record "episode_mapping.inspect": act("查", "集数映射"), "episode_mapping.simulate": act("模拟", "映射规则", "次"), "episode_mapping.apply_rule": act("写入", "映射规则", "条"), + "organize.assign_identity": act("指定", "整理作品", "个"), }; function d( diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 470cf93d..948ab5df 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AgentRunStatus, DecisionKind } from "./agent.ts"; +import { AgentRunStatus, Decision, DecisionKind } from "./agent.ts"; import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts"; import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts"; import { MediaEntity, MediaKind } from "./content.ts"; @@ -73,6 +73,8 @@ export const OrganizationRecord = EntityBase.extend({ startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), changeSeq: ChangeSeq, + /** 用户在整理记录里忽略了这条记录的问题(不再算需要处理) */ + attentionClearedAt: Timestamp.nullable(), }); export type OrganizationRecord = z.infer; export const OrganizationRecordCreate = OrganizationRecord.pick({ @@ -211,6 +213,32 @@ export const PublicOrganizationFile = z.object({ }); export type PublicOrganizationFile = z.infer; +/** 整理记录关联的未关闭事项(在整理记录里就地处理;选项与「处理」端点同 /decisions/{id}/resolve) */ +export const RecordAttentionDecision = Decision.pick({ + id: true, + kind: true, + status: true, + title: true, + summary: true, + options: true, + agentRunId: true, + evidence: true, +}); +export type RecordAttentionDecision = z.infer; + +/** + * 需要处理(2026-10-06 去掉待处理中心后,整理侧的问题在整理记录里就地处理): + * topic:identity = 没识别出作品(可指定作品 / 让智能体识别);conflict = 目标已存在 / 路径冲突(按选项替换、保留两者或跳过); + * other = 其余失败或部分完成(排除、非视频、卡住、执行失败…,重新整理或忽略)。 + * agentIdentify:可以让智能体识别(没识别出作品且有关联下载任务)。 + */ +export const RecordAttention = z.object({ + topic: z.enum(["identity", "conflict", "other"]), + decision: RecordAttentionDecision.nullable(), + agentIdentify: z.boolean(), +}); +export type RecordAttention = z.infer; + export const PublicOrganizationRecord = z.object({ /** 稳定记录 ID */ id: Id, @@ -261,6 +289,8 @@ export const PublicOrganizationRecord = z.object({ finishedAt: Timestamp.nullable(), /** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */ changeSeq: ChangeCursor, + /** 需要处理时给出原因与可做的动作;不需要为 null(旧客户端可忽略) */ + attention: RecordAttention.nullable().optional(), }); export type PublicOrganizationRecord = z.infer; @@ -303,6 +333,13 @@ export const OrganizationListQuery = z.object({ since: Timestamp.optional(), /** HIS-1:只看整理类记录(复制/移动/硬链接),不含文件管理的改名、删除、还原 */ organizeOnly: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).optional(), + /** + * 只看需要处理的记录:有关联的未关闭事项,或失败 / 部分完成、没被同一下载任务后来的整理取代、也没被忽略的 + * (口径见 docs/runbook/organize.md「需要处理」) + */ + attention: z + .union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")]) + .optional(), }); export type OrganizationListQuery = z.infer; @@ -317,6 +354,8 @@ export const OrganizationStats = z.object({ /** 排队 + 执行中 */ running: NonNegInt, cancelled: NonNegInt, + /** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */ + attention: NonNegInt, }); export type OrganizationStats = z.infer; @@ -764,3 +803,21 @@ export const OrganizePlanArtifactContent = z.object({ .optional(), }); export type OrganizePlanArtifactContent = z.infer; + +/** 让智能体识别整理记录里的作品(没识别出作品的记录):入队一次识别会话 */ +export const OrganizationAgentIdentifyResult = z.object({ decisionId: Id, queued: z.boolean() }); +export type OrganizationAgentIdentifyResult = z.infer; + +/** + * 需要用户关注的汇总(铃铛、首页、运行状态共用): + * - organize:整理记录里需要处理的条数(点进整理记录「需要处理」); + * - approvals:需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手); + * - health:系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线),恢复后自动消失。 + * 集数待确认、未识别资源等不在这里:前者在订阅详情与作品页集数映射里看原因,后者静默留在未识别里。 + */ +export const AttentionSummary = z.object({ + organize: NonNegInt, + approvals: z.object({ total: NonNegInt, items: z.array(Decision) }), + health: z.object({ total: NonNegInt, items: z.array(Decision) }), +}); +export type AttentionSummary = z.infer; diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index 3b8d0cdb..49ace094 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -1,4 +1,4 @@ -// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、待处理、下载时间线、任务都用这张表; +// 内部原因码 → 一句人话 + 下一步(DEAI-2)。整理记录、下载时间线、任务都用这张表; // 原码只放在界面的「技术信息」里,不直接给人看。不认识的码返回 null,由调用方决定兜底文字。 export type ReasonText = { @@ -19,9 +19,9 @@ const REASONS: Record = { identity_unresolved: { text: "没识别出作品", action: "指定作品" }, episode_mapping: { text: "集数对不上作品的季集", action: "确认集数映射" }, episode_unresolved: { text: "集数对不上作品的季集", action: "确认集数映射" }, - target_exists: { text: "媒体库里已有同名文件", action: "在待处理里选择替换或跳过" }, + target_exists: { text: "媒体库里已有同名文件", action: "在整理记录里选择替换、保留两者或跳过" }, duplicate_target: { text: "改名后与其他文件同名", action: "改命名模板或排除其中一个" }, - replace_requires_grant: { text: "替换已有文件需要你批准", action: "在待处理里批准" }, + replace_requires_grant: { text: "替换已有文件需要你批准", action: "在整理记录里选择是否替换" }, hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" }, hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" }, orphan_sidecar: { text: "字幕等附属文件找不到对应视频" }, @@ -51,7 +51,7 @@ const REASONS: Record = { downloader_unavailable: { text: "下载器连不上", action: "在设置 → 下载器里测试连接" }, downloader_error: { text: "下载器报错", action: "查看下载器日志" }, // DL-2b:下载面板整理状态、下载计划里出现的码 - blocked: { text: "有冲突要先处理", action: "去待处理" }, + blocked: { text: "有冲突要先处理", action: "在整理记录里处理" }, blocked_by_conflict: { text: "有冲突,暂不整理" }, transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" }, main_failed: { text: "主文件整理失败,附属文件一起跳过" }, diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts index 6f04da44..4e570477 100644 --- a/packages/contracts/test/reasons.test.ts +++ b/packages/contracts/test/reasons.test.ts @@ -12,7 +12,7 @@ describe("reasonText(DEAI-2 原因码人话化)", () => { it("下载面板用到的码(DL-2b):整理失败、冲突、下载被删", () => { expect(reasonSentence("transfer_failed")).toBe("文件没有整理成功。在整理记录里查看失败的文件"); - expect(reasonSentence("blocked")).toBe("有冲突要先处理。去待处理"); + expect(reasonSentence("blocked")).toBe("有冲突要先处理。在整理记录里处理"); expect(reasonText("download_removed")?.text).toBe("下载任务已从下载器删除"); expect(reasonText("target_root_not_managed")?.text).toContain("本工具管理"); for (const c of ["blocked_by_conflict", "main_failed", "not_imported_kind"]) expect(reasonText(c)).not.toBeNull(); diff --git a/packages/db/migrations/0078_organize_attention.ts b/packages/db/migrations/0078_organize_attention.ts new file mode 100644 index 00000000..fa7c0bc1 --- /dev/null +++ b/packages/db/migrations/0078_organize_attention.ts @@ -0,0 +1,15 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 整理记录「需要处理」(2026-10-06,去掉待处理中心):失败 / 部分完成的记录在整理记录里就地处理; +// 用户点「忽略」时记下时间,该记录不再算「需要处理」(关联的未关闭事项一并关闭)。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, `alter table organization_record add column attention_cleared_at timestamptz;`); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `alter table organization_record drop column if exists attention_cleared_at;`); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 530f79d2..041d46d2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1093,6 +1093,7 @@ export interface OrganizationRecordTable { updated_at: GeneratedTimestamptz; requeue_count: Generated; requeued_at: Timestamptz | null; + attention_cleared_at: Timestamptz | null; } export interface OrganizeSourceSnapshotTable { From fa76d7563c954c9b95ae82e19351c232cecbf7f4 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:47:25 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=BE=85=E5=A4=84=E7=90=86=E4=B8=AD=E5=BF=83?= =?UTF-8?q?,=E9=97=AE=E9=A2=98=E5=9B=9E=E5=88=B0=E5=8F=91=E7=94=9F?= =?UTF-8?q?=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅 - 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略), 详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用 - 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量 - 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」 - 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定 - 下载、站点、任务里指向待处理的链接改到对应页面 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/api/attention.ts | 37 + apps/web/app/api/records.ts | 30 + apps/web/app/components/agent/quick-goals.ts | 6 +- .../web/app/components/console/inbox-bell.tsx | 181 ++--- apps/web/app/components/console/nav.test.ts | 12 +- apps/web/app/components/console/nav.ts | 3 +- .../dashboard/attention-summary.test.ts | 87 +++ .../dashboard/attention-summary.tsx | 78 +++ .../components/decisions/decision-actions.tsx | 199 ++++++ .../components/downloads/board-format.test.ts | 6 +- .../app/components/downloads/board-format.ts | 9 +- .../components/downloads/download-board.tsx | 2 +- .../components/downloads/download-detail.tsx | 6 +- .../web/app/components/downloads/plan-row.tsx | 15 +- .../components/episode-mapping/decision.tsx | 2 +- .../app/components/episode-mapping/issues.tsx | 60 ++ .../episode-mapping/mapping-panel.tsx | 4 +- .../episode-mapping/subscription-summary.tsx | 3 + .../library/identity-pool-sheet.tsx | 94 ++- .../app/components/library/identity-views.tsx | 17 +- .../organize/record-attention.test.ts | 107 +++ .../components/organize/record-attention.tsx | 130 ++++ apps/web/app/components/settings/agent.tsx | 50 +- .../web/app/components/sites/site-dialogs.tsx | 2 +- apps/web/app/components/sites/site-fields.ts | 2 +- .../app/components/status/attention-cards.tsx | 129 ++++ apps/web/app/components/tasks/labels.ts | 3 +- apps/web/app/routes/console/home.tsx | 42 +- apps/web/app/routes/console/inbox.tsx | 643 +----------------- apps/web/app/routes/console/library.tsx | 17 +- apps/web/app/routes/console/organize.$id.tsx | 7 + apps/web/app/routes/console/organize.tsx | 87 ++- apps/web/app/routes/console/redirects.ts | 32 + apps/web/app/routes/console/status.tsx | 6 + docs/design/console-ia.md | 20 + docs/design/glossary.md | 2 +- 36 files changed, 1209 insertions(+), 921 deletions(-) create mode 100644 apps/web/app/api/attention.ts create mode 100644 apps/web/app/components/dashboard/attention-summary.test.ts create mode 100644 apps/web/app/components/dashboard/attention-summary.tsx create mode 100644 apps/web/app/components/decisions/decision-actions.tsx create mode 100644 apps/web/app/components/episode-mapping/issues.tsx create mode 100644 apps/web/app/components/organize/record-attention.test.ts create mode 100644 apps/web/app/components/organize/record-attention.tsx create mode 100644 apps/web/app/components/status/attention-cards.tsx diff --git a/apps/web/app/api/attention.ts b/apps/web/app/api/attention.ts new file mode 100644 index 00000000..cef9bde8 --- /dev/null +++ b/apps/web/app/api/attention.ts @@ -0,0 +1,37 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AgentSpend, AttentionSummary } from "@umt/contracts"; +import { useRef } from "react"; +import { useSse } from "~/lib/sse.ts"; +import { apiParsed } from "./parse.ts"; + +// 需要关注的汇总(2026-10-06 去掉待处理中心后):铃铛只放等你批准的智能体操作与系统健康, +// 首页 / 运行状态放汇总计数(整理需要处理的条数等),点进去到对应页面。 + +export const attentionKeys = { all: ["attention"] as const, spend: ["attention", "agent-spend"] as const }; + +/** 事项、整理记录变化时(节流)刷新;另有 60 秒兜底轮询 */ +export function useAttention() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse(["decision.created", "decision.updated", "organization.changed"], () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + void qc.invalidateQueries({ queryKey: attentionKeys.all }); + }, 800); + }); + return useQuery({ + queryKey: attentionKeys.all, + queryFn: ({ signal }) => apiParsed(AttentionSummary, "/api/v1/attention", { signal }), + refetchInterval: 60_000, + }); +} + +/** 智能体用量:今天与最近 7 天按会话类型的次数、token 与费用(不设上限,只展示) */ +export function useAgentSpend() { + return useQuery({ + queryKey: attentionKeys.spend, + queryFn: ({ signal }) => apiParsed(AgentSpend, "/api/v1/agent/usage", { signal }), + refetchInterval: 5 * 60_000, + }); +} diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 7a7b62a9..54001624 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -4,6 +4,7 @@ import { cursorPage, LibraryItem, LibraryRoot, + OrganizationAgentIdentifyResult, type OrganizationDeleteRequest, OrganizationDeleteResult, OrganizationStats, @@ -31,6 +32,8 @@ export type RecordFilters = { /** ISO 时间:只看之后创建的 */ since?: string; organizeOnly?: boolean; + /** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */ + attention?: boolean; }; export const recordKeys = { @@ -51,6 +54,33 @@ export function useRecordStats(f: Omit) { }); } +/** 需要处理的记录:忽略(不再算需要处理,关联事项一并关闭) */ +export function useDismissRecordAttention() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/dismiss-attention`, { body: {} }).then((d) => + PublicOrganizationRecord.parse(d), + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: recordKeys.all }); + void qc.invalidateQueries({ queryKey: ["attention"] }); + }, + }); +} + +/** 没识别出作品的记录:让智能体识别(认出来自动重新整理) */ +export function useAgentIdentifyRecord() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/api/v1/organization-records/${id}/agent-identify`, { body: {} }).then((d) => + OrganizationAgentIdentifyResult.parse(d), + ), + onSuccess: () => void qc.invalidateQueries({ queryKey: recordKeys.all }), + }); +} + /** HIS-1:删除整理记录(dryRun 只核对会删什么) */ export function useDeleteRecords() { const qc = useQueryClient(); diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts index a5436bc9..ad4d740c 100644 --- a/apps/web/app/components/agent/quick-goals.ts +++ b/apps/web/app/components/agent/quick-goals.ts @@ -30,16 +30,12 @@ const RULES: Rule[] = [ }, { match: /^\/console\/organize/, - goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"], + goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"], }, { match: /^\/console\/files/, goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"], }, - { - match: /^\/console\/inbox/, - goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"], - }, { match: /^\/console\/identity/, goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"], diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx index fb4b70a5..b144a66e 100644 --- a/apps/web/app/components/console/inbox-bell.tsx +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -1,71 +1,89 @@ -import { useQueryClient } from "@tanstack/react-query"; -import type { Decision, DecisionKind } from "@umt/contracts"; +import type { Decision } from "@umt/contracts"; import { ArrowRight, Bell, CheckCheck } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts"; +import { useAttention } from "~/api/attention.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { SEVERITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; -import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; -import { useSse } from "~/lib/sse.ts"; +import { formatRelative } from "~/lib/labels.ts"; import { cn } from "~/lib/utils.ts"; -// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。 -// 角标只算需要你处理的事项(警告及以上):"仅信息"类(巡检报告、旧版本待处置等)不计入,在待处理页可展开查看。 -// 身份类事项已按来源 + 标题合并,一条事项可能代表多条资源(标题里写明 N 条)。 +// 顶栏铃铛(2026-10-06 起只放两类):需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手) +// 与系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线,恢复后自动消失)。 +// 整理问题在整理记录里处理,集数待确认在订阅详情 / 作品页集数映射里看,不进铃铛。 -const RECENT = 8; +const RECENT = 6; const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const; -/** 未决数与最近事项;SSE decision.* 到达时刷新 */ -export function useLiveDecisions() { - const qc = useQueryClient(); - const timer = useRef | null>(null); - useSse(["decision.created", "decision.updated"], () => { - if (timer.current) return; - timer.current = setTimeout(() => { - timer.current = null; - void qc.invalidateQueries({ queryKey: decisionKeys.all }); - }, 800); - }); - return useDecisionSummary(undefined, "warning"); +/** 等你批准的事项点开去哪:智能体会话、下载、下载任务;其余去智能体页 */ +export function approvalHref(d: Pick): string { + if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId); + if (d.agentRunId) return consolePath("agent", d.agentRunId); + if (d.subjectType === "acquisition_plan" && d.subjectId) + return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`; + if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId); + return consolePath("agent"); } -export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox"); +export const HEALTH_HREF = `${consolePath("status")}#attention`; + +function Group({ + title, + total, + items, + href, + onGo, +}: { + title: string; + total: number; + items: Decision[]; + href: (d: Decision) => string; + onGo: () => void; +}) { + if (!total) return null; + return ( +
+

+ {title} + {total} +

+
    + {items.slice(0, RECENT).map((d) => ( +
  • + + {d.title} + + + {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} + + +
  • + ))} +
+
+ ); +} export function InboxBell() { - const summary = useLiveDecisions(); + const q = useAttention(); const [open, setOpen] = useState(false); - const list = useDecisions({ minSeverity: "warning" }, open); const navigate = useNavigate(); - const total = summary.data?.total ?? 0; - - const groups = useMemo(() => { - const items = (list.data?.pages[0]?.items ?? []).slice(0, RECENT); - const m = new Map(); - for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]); - const counts = new Map(); - for (const g of summary.data?.groups ?? []) counts.set(g.kind, (counts.get(g.kind) ?? 0) + g.count); - return [...m.entries()].map(([kind, ds]) => ({ kind, items: ds, count: counts.get(kind) ?? ds.length })); - }, [list.data, summary.data]); - - const go = (to: string) => { - setOpen(false); - navigate(to); - }; + const approvals = q.data?.approvals ?? { total: 0, items: [] }; + const health = q.data?.health ?? { total: 0, items: [] }; + const total = approvals.total + health.total; + const label = total ? `需要你关注 ${total} 项` : "通知"; return ( - - {list.isPending ? ( + {q.isPending ? ( - ) : list.isError ? ( + ) : q.isError ? ( - ) : groups.length === 0 ? ( - + ) : total === 0 ? ( + ) : (
- {groups.map((g) => ( -
- -
    - {g.items.map((d) => ( -
  • - setOpen(false)} - className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50" - > - {d.title} - - - {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} - - -
  • - ))} -
-
- ))} + setOpen(false)} + /> + HEALTH_HREF} + onGo={() => setOpen(false)} + />
)} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 20a94828..1f59cd1b 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -39,10 +39,18 @@ describe("控制台导航", () => { expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); - it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => { + it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => { const paths = NAV_ITEMS.map((i) => i.to); for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p); - expect(pageTitle("/console/inbox")).toBe("待处理"); + expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理"); + expect(pageTitle("/console/inbox")).toBeUndefined(); + }); + + it("旧待处理链接按问题类型落到对应页面", () => { + expect(legacyRedirect("/console/inbox", "?kind=path_conflict")).toBe("/console/organize?tab=records&attention=1"); + expect(legacyRedirect("/console/inbox", "?kind=download_failed")).toBe("/console/downloads"); + expect(legacyRedirect("/console/inbox", "?kind=space_insufficient")).toBe("/console/status#attention"); + expect(legacyRedirect("/console/inbox", "?kind=unknown_kind")).toBe("/console/organize?tab=records&attention=1"); }); it("每个导航项都有路由", () => { diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 535efe5d..542ff2dc 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -42,7 +42,7 @@ export const ACQUIRE_GROUP_TITLE = "获取"; /** * 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。 * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 - * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 + * 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ { @@ -139,7 +139,6 @@ export const NAV_GROUPS: NavGroup[] = [ /** 不在侧栏但有页面的路由:顶栏标题用 */ const EXTRA_TITLES: Record = { - "/console/inbox": "待处理", "/console/preferences": "偏好标签", "/console/search": "搜索", }; diff --git a/apps/web/app/components/dashboard/attention-summary.test.ts b/apps/web/app/components/dashboard/attention-summary.test.ts new file mode 100644 index 00000000..0940bb04 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.test.ts @@ -0,0 +1,87 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { approvalHref, HEALTH_HREF } from "../console/inbox-bell.tsx"; +import { attentionRows } from "./attention-summary.tsx"; + +// 首页 / 运行状态的「需要关注」汇总计数与铃铛跳转(2026-10-06 去掉待处理中心后)。 + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +const dec = (n: number, over: Partial): Decision => + ({ + id: id(n), + kind: "other", + status: "open", + severity: "warning", + title: "t", + summary: null, + subjectType: null, + subjectId: null, + dedupeKey: `k${n}`, + evidence: {}, + options: [], + resolution: null, + taskId: null, + agentRunId: null, + attempts: 0, + nextReviewAt: null, + resolvedBy: null, + resolvedAt: null, + createdAt: "2026-10-06T00:00:00.000Z", + updatedAt: "2026-10-06T00:00:00.000Z", + ...over, + }) as Decision; + +const empty: AttentionSummary = { organize: 0, approvals: { total: 0, items: [] }, health: { total: 0, items: [] } }; + +describe("需要关注汇总", () => { + it("只列非零项", () => { + expect(attentionRows(empty)).toEqual([]); + expect(attentionRows(undefined)).toEqual([]); + }); + + it("整理需要处理 / 等你批准 / 系统健康按问题归类,点进去到对应页面", () => { + const rows = attentionRows({ + organize: 3, + approvals: { + total: 1, + items: [dec(1, { kind: "approval_required", subjectType: "agent_run", subjectId: id(9) })], + }, + health: { + total: 3, + items: [ + dec(2, { kind: "source_unreachable" }), + dec(3, { kind: "downloader_offline" }), + dec(4, { kind: "other", dedupeKey: "library_root_offline:x" }), + ], + }, + }); + expect(rows.map((r) => [r.text, r.to])).toEqual([ + ["3 条整理需要处理", "/console/organize?tab=records&attention=1"], + ["1 个操作等你批准", `/console/agent/${id(9)}`], + ["1 个站点不可达", HEALTH_HREF], + ["1 个下载器离线", HEALTH_HREF], + ["1 个媒体库目录离线", HEALTH_HREF], + ]); + }); + + it("健康问题超过列出的条数时补一行", () => { + const rows = attentionRows({ ...empty, health: { total: 5, items: [dec(2, { kind: "space_insufficient" })] } }); + expect(rows.map((r) => r.text)).toEqual(["1 个下载盘空间不足", "4 个系统问题"]); + }); +}); + +describe("铃铛跳转", () => { + it("智能体会话 / 下载计划 / 下载任务", () => { + expect(approvalHref({ subjectType: "agent_run", subjectId: id(1), agentRunId: null })).toBe( + `/console/agent/${id(1)}`, + ); + expect(approvalHref({ subjectType: "acquisition_plan", subjectId: id(2), agentRunId: null })).toBe( + `/console/downloads?plan=${id(2)}`, + ); + expect(approvalHref({ subjectType: "download_task", subjectId: id(3), agentRunId: null })).toBe( + `/console/downloads/${id(3)}`, + ); + expect(approvalHref({ subjectType: null, subjectId: null, agentRunId: null })).toBe("/console/agent"); + expect(HEALTH_HREF).toBe("/console/status#attention"); + }); +}); diff --git a/apps/web/app/components/dashboard/attention-summary.tsx b/apps/web/app/components/dashboard/attention-summary.tsx new file mode 100644 index 00000000..b8084234 --- /dev/null +++ b/apps/web/app/components/dashboard/attention-summary.tsx @@ -0,0 +1,78 @@ +import type { AttentionSummary, Decision } from "@umt/contracts"; +import { ChevronRight } from "lucide-react"; +import { Link } from "react-router"; +import { approvalHref, HEALTH_HREF } from "~/components/console/inbox-bell.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { ORGANIZE_ATTENTION_PATH } from "~/routes/console/redirects.ts"; + +// 首页与运行状态共用的「需要关注」汇总计数(2026-10-06 去掉待处理中心后): +// 「3 条整理需要处理」「1 个站点不可达」…,点进去到对应页面;只列非零项。 + +/** 系统健康事项按问题归类成一句(量词 + 说法) */ +function healthKind(d: Pick): { key: string; unit: string } { + if (d.dedupeKey.startsWith("library_root_offline:")) return { key: "library_root", unit: "个媒体库目录离线" }; + switch (d.kind) { + case "source_unreachable": + return { key: d.kind, unit: "个站点不可达" }; + case "downloader_offline": + return { key: d.kind, unit: "个下载器离线" }; + case "cache_volume_unknown": + return { key: d.kind, unit: "个下载目录读不到容量" }; + case "space_insufficient": + return { key: d.kind, unit: "个下载盘空间不足" }; + default: + return { key: "other", unit: "个系统问题" }; + } +} + +export interface AttentionRow { + key: string; + text: string; + to: string; +} + +/** 汇总 → 一行一类(非零才列) */ +export function attentionRows(s: AttentionSummary | undefined): AttentionRow[] { + if (!s) return []; + const rows: AttentionRow[] = []; + if (s.organize) rows.push({ key: "organize", text: `${s.organize} 条整理需要处理`, to: ORGANIZE_ATTENTION_PATH }); + if (s.approvals.total) { + const first = s.approvals.items[0]; + rows.push({ + key: "approvals", + text: `${s.approvals.total} 个操作等你批准`, + to: s.approvals.total === 1 && first ? approvalHref(first) : consolePath("agent"), + }); + } + const counts = new Map(); + for (const d of s.health.items) { + const k = healthKind(d); + const cur = counts.get(k.key); + counts.set(k.key, { unit: k.unit, n: (cur?.n ?? 0) + 1 }); + } + const listed = [...counts.values()].reduce((a, x) => a + x.n, 0); + if (s.health.total > listed) { + const cur = counts.get("other"); + counts.set("other", { unit: "个系统问题", n: (cur?.n ?? 0) + s.health.total - listed }); + } + for (const [key, v] of counts) rows.push({ key, text: `${v.n} ${v.unit}`, to: HEALTH_HREF }); + return rows; +} + +export function AttentionList({ rows }: { rows: AttentionRow[] }) { + return ( +
    + {rows.map((r) => ( +
  • + + {r.text} + + +
  • + ))} +
+ ); +} diff --git a/apps/web/app/components/decisions/decision-actions.tsx b/apps/web/app/components/decisions/decision-actions.tsx new file mode 100644 index 00000000..54b00994 --- /dev/null +++ b/apps/web/app/components/decisions/decision-actions.tsx @@ -0,0 +1,199 @@ +import type { Decision, DecisionOption } from "@umt/contracts"; +import { Search } from "lucide-react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useResolveDecision } from "~/api/decisions.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { FormField } from "~/components/console/form-field.tsx"; +import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx"; +import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.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 { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +// 事项(decision)的处理按钮:待处理中心取消后(2026-10-06),问题在发生处就地处理(整理记录行内等), +// 选项与执行仍走 POST /decisions/{id}/resolve(后端注册的执行钩子:指定作品后重新整理、替换 / 保留两者 / 跳过…)。 + +/** 处理按钮只用到事项的这几个字段(整理记录里带的是精简版) */ +export type DecisionLike = Pick; + +const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null); + +/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */ +type Requires = "tmdb" | "mediaEntityId" | null; +function requiresOf(option: DecisionOption): Requires { + const r = option.input?.requires; + return r === "tmdb" || r === "mediaEntityId" ? r : null; +} + +/** 「指定作品」两个选项(本地作品 / TMDB)已并进 AssignWorkButton,其余选项各一个按钮 */ +export function plainOptions(d: DecisionLike): DecisionOption[] { + return d.options.filter((o) => o.key !== "dismiss" && requiresOf(o) === null); +} + +/** 事项带不带「指定作品」(本地作品或 TMDB) */ +export function canAssignWork(d: DecisionLike): boolean { + return d.options.some((o) => requiresOf(o) !== null); +} + +export function OptionButton({ + d, + option, + variant = "outline", +}: { + d: DecisionLike; + option: DecisionOption; + variant?: "outline" | "default"; +}) { + const resolve = useResolveDecision(); + const uid = useId(); + const [note, setNote] = useState(""); + const requires = requiresOf(option); + const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie"); + const [tmdbId, setTmdbId] = useState(""); + const [entity, setEntity] = useState(null); + const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim()); + const identity = + requires === "tmdb" + ? tmdbOk + ? { tmdb: { type: tmdbType, id: tmdbId.trim() } } + : null + : requires === "mediaEntityId" + ? entity + ? { mediaEntityId: entity.id } + : null + : undefined; + return ( + + {option.label} + + } + title={option.label} + description={option.description ? `${option.description}。` : undefined} + confirmLabel={option.label} + confirmDisabled={identity === null} + onConfirm={() => + resolve + .mutateAsync({ + id: d.id, + optionKey: option.key, + note: note.trim() || undefined, + ...(identity ? { identity } : {}), + }) + .then( + () => toast.success(`已处理:${option.label}`), + (err) => { + toast.error(errorMessage(err, "处理失败")); + throw err; + }, + ) + } + > + {requires === "tmdb" ? ( +
+ + + + + setTmdbId(e.target.value)} + aria-invalid={tmdbId.trim() !== "" && !tmdbOk} + /> + +
+ ) : requires === "mediaEntityId" ? ( + + + + ) : null} + +