import { z } from "zod"; import { DEFAULT_CLASSIFICATION_YAML } from "./classification.ts"; import { Id, Timestamp } from "./common.ts"; import { OrganizeSourceConfig } from "./organize-sources.ts"; import { SiteRateLimit } from "./sources.ts"; // 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。 // schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值); // 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。 const Percent = z.number().min(1).max(100); /** 并发槽上限(与 io worker 各道开的槽位数一致;改设置只在这个范围内热生效) */ export const WORKER_LANE_MAX = 16; /** * io worker 三条道的并发(WK-1,docs/runbook/dev.md「IO Worker 分道」):改后约 5 秒内热生效,不用重启; * 设置了环境变量 WORKER_FILE_CONCURRENCY / WORKER_NETWORK_CONCURRENCY(旧 WORKER_CONCURRENCY)/ * WORKER_MAINTENANCE_CONCURRENCY 时以环境变量为准。 */ export const WorkerLaneSettings = z.object({ /** 文件道:整理、硬链接/移动、ffprobe、校验 */ file: z.number().int().min(1).max(WORKER_LANE_MAX), /** 网络道:站点、RSS、TMDB、下载器、搜索(单站限流另计) */ network: z.number().int().min(1).max(WORKER_LANE_MAX), /** 维护道:清理、回填、对账;文件道 / 网络道有活时让路 */ maintenance: z.number().int().min(1).max(WORKER_LANE_MAX), }); export type WorkerLaneSettings = z.infer; export const DEFAULT_WORKER_LANES: WorkerLaneSettings = { file: 2, network: 4, maintenance: 1 }; export const AutomationSettings = z.object({ /** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */ enabled: z.boolean(), /** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */ readOnlyReconcile: z.boolean(), /** io worker 分道并发;旧库缺省时补 DEFAULT_WORKER_LANES */ workerLanes: WorkerLaneSettings.default(DEFAULT_WORKER_LANES), }); export type AutomationSettings = z.infer; const CacheShape = z.object({ /** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */ hardThresholdPercent: Percent, /** 恢复水位:降到此值以下恢复因阈值暂停的任务 */ resumePercent: Percent, checkIntervalSec: z.number().int().min(10).max(3600), /** 缓存清理的目标使用率(低水位) */ cleanupTargetPercent: Percent, /** * @deprecated CACHE-2 起由 storage.cacheTtl(tmdbSearchDays / tmdbDetailDays)取代,服务端不再读取; * 保留在 schema 里只为兼容已存的 JSON 与旧设置页。 */ tmdbTtlHours: z.number().int().min(1).max(720), /** TMDB 缓存每类条数上限,超出删最旧(MP CONF.tmdb 为 256 / 大内存 1024;本项目存 PG 不占进程内存,缺省 5000) */ tmdbMaxSize: z.number().int().min(16).max(100_000), /** @deprecated CACHE-2 起由 storage.cacheTtl.tmdbMissDays 取代,服务端不再读取 */ emptyResultTtlSec: z.number().int().min(0).max(86_400), /** 图片代理落盘缓存(对照 MP GLOBAL_IMAGE_CACHE,缺省关闭;关闭时仍代理,只是不落盘) */ globalImageCache: z.boolean(), /** 图片缓存保留天数,超过即清理(对照 MP GLOBAL_IMAGE_CACHE_DAYS,缺省 7) */ globalImageCacheDays: z.number().int().min(1).max(3650), }); /** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */ export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, { message: "恢复水位必须低于硬阈值", }).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" }); export type CacheSettings = z.infer; export const OrganizeMode = z.enum(["copy", "move"]); export type OrganizeMode = z.infer; export const OrganizeSettings = z.object({ classificationYaml: z.string().min(1).max(100_000), sources: z.array(OrganizeSourceConfig), defaultMode: OrganizeMode, /** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */ allowCrossVolumeMove: z.boolean(), /** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */ minSizeBytes: z.number().int().nonnegative(), /** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */ scrapeAfterImport: z.boolean(), /** * 目标文件已存在时:update = 更新(缺省;只覆盖本工具写过且没被改过的文件)/ missing = 只补缺失(MP「仅缺失」)/ * overwrite = 全部覆盖 */ scrapeMode: z.enum(["missing", "overwrite", "update"]), /** SCR-1:下载海报、背景图、季海报 */ scrapeImages: z.boolean(), /** SCR-1:下载每集剧照(`<视频名>-thumb.jpg`) */ scrapeEpisodeThumbs: z.boolean(), }); export type OrganizeSettings = z.infer; export const RetentionSettings = z.object({ /** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */ seedRetentionDays: z.number().int().min(0).max(365), /** 观察/日志类数据保留天数 */ logRetentionDays: z.number().int().min(1).max(3650), }); export type RetentionSettings = z.infer; export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); export type ThinkingLevel = z.infer; /** * Pi Agent 设置(REQ §10.5)。W7-A 起上游改为 pi_upstream 表(多上游共存,见 contracts/pi.ts 与 /pi/upstreams), * 这里只留默认上游与运行预算(上下文预算/压缩/子代理)。 * * provider / baseUrl / model / thinkingLevel / apiKeySecretRef 是 v0.1 的旧字段(DEPRECATED_SETTINGS_FIELDS): * 0027 迁移已把保存的旧值转成一条上游并设为默认。W8-A 起服务端不再读取、PATCH 不再接受(传了会被丢弃), * 设置页不展示;保留在 schema 里只为兼容已存的 JSON。runAgent 只走上游,没有上游直接失败。 */ export const PiSettings = z.object({ /** 缺省上游(runAgent 未指定 upstreamId 时使用);null = 未配置,Agent 运行直接失败 */ defaultUpstreamId: Id.nullable(), /** @deprecated 旧字段,不再读取,见上 */ provider: z.string().min(1), /** @deprecated */ baseUrl: z.url().nullable(), /** @deprecated */ model: z.string().min(1).nullable(), /** @deprecated 上游级 thinkingLevel 见 pi_upstream */ thinkingLevel: ThinkingLevel, /** 上下文预算(token) */ contextBudget: z.number().int().min(4000).max(2_000_000), compaction: z.object({ enabled: z.boolean(), /** 达到上下文预算的该比例时触发压缩 */ thresholdRatio: z.number().min(0.3).max(0.95), }), /** @deprecated 同 subagents.enabled(合并设置时两者保持一致);新代码读 subagents.enabled */ subagentsEnabled: z.boolean(), /** * 子代理(agent.spawn,W7-B):关闭时不向模型提供派生能力、服务端拒绝派生; * maxConcurrent = 一个会话同时在跑的子会话上限,maxDepth = 派生层数上限(1 = 子会话不能再派生)。 */ subagents: z.object({ enabled: z.boolean(), maxConcurrent: z.number().int().min(1).max(8), maxDepth: z.number().int().min(1).max(3), }), /** @deprecated */ apiKeySecretRef: Id.nullable(), }); export type PiSettings = z.infer; /** * Agent 网页搜索(web.search / web.fetch,REQ §10.2、§10.5):默认关闭(provider = none,不向模型提供这两个能力)。 * Key 只存 secret 引用(apiKeySecretRef);搜索结果与网页正文按不可信数据交给模型。 */ export const WebSearchProvider = z.enum(["brave", "none"]); export type WebSearchProvider = z.infer; export const WebSearchSafesearch = z.enum(["off", "moderate", "strict"]); export type WebSearchSafesearch = z.infer; export const WebSearchSettings = z.object({ provider: WebSearchProvider, apiKeySecretRef: Id.nullable(), /** 搜索地区:两位国家码(如 CN、US)或 ALL */ country: z.string().regex(/^([A-Z]{2}|ALL)$/, "两位大写国家码或 ALL"), /** 搜索语言(Brave search_lang,如 zh-hans、en);null = 不指定 */ searchLang: z .string() .regex(/^[a-z]{2}(-[a-z]{2,4})?$/, "语言码,如 zh-hans、en") .nullable(), safesearch: WebSearchSafesearch, /** 每次搜索返回的条数(模型可以要更少,不能更多) */ maxResults: z.number().int().min(1).max(10), /** 每分钟最多调用几次搜索接口(三个进程共用;缓存命中不计) */ requestsPerMinute: z.number().int().min(1).max(600), }); export type WebSearchSettings = z.infer; export const DEFAULT_WEB_SEARCH: WebSearchSettings = { provider: "none", apiKeySecretRef: null, country: "ALL", searchLang: null, safesearch: "moderate", maxResults: 5, requestsPerMinute: 30, }; /** * Agent 运行参数(REQ §10.5、§10.7):单次运行预算、后台自动调查、fake 演示脚本。 * 上游/模型/思考档位在 pi_upstream(见 contracts/pi.ts);provider = "fake" 的上游使用脚本化假模型(不联网,演示与 e2e 用)。 */ export const AgentRuntimeSettings = z.object({ /** 识别歧义 / 下载失败诊断等待处理事项创建时,自动发起有界调查 */ autoInvestigate: z.boolean(), /** * 自动调查的模式:investigate = 只读调查,结论交还人工; * recover = 在已发布策略 / 持续授权范围内可执行恢复类能力(task.retry、download.recheck、identity.resolve), * 换源、删除、清理始终排除;没有覆盖的授权时自动降级为 investigate。 */ autoInvestigateMode: z.enum(["investigate", "recover"]), /** 同一根因(decision 去重键)最多自动恢复几次;用尽后只交还人工,不再唤醒模型 */ investigateMaxAttempts: z.number().int().min(1).max(10), /** 恢复动作后等待业务事实(下载状态 / 任务状态)核实的最长时间(秒) */ recoverVerifySec: z .number() .int() .min(5) .max(24 * 3600), /** 单次运行:工具调用次数上限 */ maxToolCalls: z.number().int().min(1).max(500), /** 单次运行:时长上限(秒) */ maxDurationSec: z .number() .int() .min(10) .max(24 * 3600), /** 已弃用(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), /** 等待目标关联的下载/入库结束时的轮询间隔(秒) */ goalPollSec: z.number().int().min(1).max(3600), /** 等待目标完成的最长时间(小时),超时后以"仍在等待"汇报结束 */ goalWaitHours: z .number() .min(0) .max(24 * 30), /** agent.propose_options 等用户选择的最长时间(秒);超时按 optionsTimeoutAction 处理 */ optionsTimeoutSec: z .number() .int() .min(5) .max(7 * 86400), /** 方案选择超时:recommended = 自动选推荐项(没有推荐项时选第一个);stop = 不选,模型收尾 */ optionsTimeoutAction: z.enum(["recommended", "stop"]), /** 会话内审批卡的有效期(秒);过期未批准的调用按拒绝处理 */ approvalTimeoutSec: z .number() .int() .min(60) .max(30 * 86400), /** fake 模式脚本(JSON,FakeScript 的可序列化子集);null 用内置演示脚本 */ fakeScript: z.string().max(100_000).nullable(), /** * Agent JSONL 会话目录(绝对路径,api 与 worker 进程都要能读写); * null = 部署默认(环境变量 AGENT_SESSION_DIR,缺省 DATA_DIR/agent-sessions) */ sessionDir: z.string().max(1024).regex(/^\//, "必须是绝对路径").nullable(), /** 网页搜索(web.search / web.fetch) */ webSearch: WebSearchSettings, }); export type AgentRuntimeSettings = z.infer; /** 元数据源(TMDB):密钥只存 secret 引用;未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN */ export const MetadataSettings = z.object({ tmdbApiKeySecretRef: Id.nullable(), /** 自建反代或测试 mock;null 用官方地址 */ tmdbBaseUrl: z.url().nullable(), /** * 元数据语言回退链(MD-1):作品 / 季 / 集的标题与简介按顺序取第一个非空值;中文名(titleZh)取链中第一个中文值。 * BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US。 * 多语言保留规则(adapters tmdb/language-policy.ts):只有简中(zh-CN / zh-SG)、繁中(zh-TW / zh-HK / zh-MO)、 * 英文(en-*)和作品原语言生效;其它语言(如 ja-JP)只在作品原语言是它时生效,否则按作品跳过 */ languages: z .array(z.string().regex(/^[a-z]{2,3}(-[A-Z]{2})?$/)) .min(1) .max(8) .default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]), /** * MD-2:简介 / 标语是否允许回退到非中文。缺省 false = 只按链里的中文变体(及排在第一个中文之前的语言)取, * 取不到留空;标题不受影响。链里没有中文时不起作用 */ overviewNonChineseFallback: z.boolean().default(false), /** * DH-1 季 0(特别篇):true = full 刷新与元数据回刷在 TMDB 有季 0 时一并拉取每集信息并建集单元(对照 MP); * 季 0 不计入正片季总集数与订阅完成判定 */ includeSpecials: z.boolean().default(true), }); export type MetadataSettings = z.infer; /** * 读不到浏览器身份(没有浏览器服务 / 旧镜像 / 暂时连不上)时站点请求用的 UA:与浏览器身份同型的 Windows Firefox * (camofox 1.17.0 = Camoufox 152;升级浏览器镜像时同步改这里,BR-4)。 */ export const FALLBACK_SITE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"; /** BR-4 之前的缺省 UA(Windows Chrome 140):迁移时据此把存量账号标"待切换",设置里的旧缺省值清空改为跟随浏览器 */ export const LEGACY_SITE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"; /** 站点默认(site-center §8):站点行的限流 / 快照周期为空时继承;站点和凭证在站点页管理 */ export const SitesSettings = z.object({ /** 站点 rate_limit 为空时使用 */ defaultRateLimit: SiteRateLimit, /** 站点未设快照周期时使用(秒) */ defaultSnapshotIntervalSec: z .number() .int() .min(300) .max(7 * 24 * 3600), /** * 全局 UA 覆盖:空串 = 跟随浏览器(缺省,BR-4);填了则账号没有手动 UA 时都用它(浏览器仍是自己的 UA,两端会不一致) */ userAgent: z.string().trim().max(500), /** 站点开"使用代理"时生效;null = 不用代理 */ proxyUrl: z.url().nullable(), /** 用户数据做种分页上限 */ userDataMaxSeedingPages: z.number().int().min(1).max(50), /** 快照(定时 / 手动)最多抓几页列表;站点定义给了更小的值时取更小的 */ defaultSnapshotMaxPages: z.number().int().min(1).max(20), /** 同一站点两次定时健康检测的最短间隔(秒);站点定义给了更长的取更长的 */ defaultHealthCheckIntervalSec: z .number() .int() .min(900) .max(7 * 24 * 3600), /** 详情页结果缓存(秒):期内不重复抓同一条详情;0 = 不缓存 */ defaultDetailCacheSec: z .number() .int() .min(0) .max(7 * 24 * 3600), }); export type SitesSettings = z.infer; // ── 整理命名模板(docs/design/naming-template.md;引擎在 @umt/domain naming/**)── /** 一条全路径模板(Jinja 安全子集,`/` 切目录,最后一段是文件名且以 {{fileExt}} 结尾) */ export const NamingTemplate = z.string().min(1).max(1024); export const NamingKind = z.enum(["movie", "tv", "variety"]); export type NamingKind = z.infer; /** 库根 / 作品级覆盖:按类型覆盖,未给的类型回落上一级 */ export const NamingOverride = z.object({ movie: NamingTemplate.optional(), tv: NamingTemplate.optional(), variety: NamingTemplate.optional(), }); export type NamingOverride = z.infer; export const NamingSettings = z.object({ movie: NamingTemplate, tv: NamingTemplate, variety: NamingTemplate, /** 已绑定作品也固定文件名模板版本(按 path_binding.template_version 取当时的模板);false = 每次入库按当前模板 */ lockFileNameForBound: z.boolean(), }); export type NamingSettings = z.infer; /** MP 默认(与 @umt/domain MP_DEFAULT_NAMING 一致,server 测试断言) */ export const DEFAULT_NAMING_TEMPLATES = { movie: "{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}", tv: "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}", variety: "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate | date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}", } as const; export const NamingIssue = z.object({ code: z.string(), message: z.string(), line: z.number().int(), col: z.number().int(), }); export type NamingIssue = z.infer; export const NamingSegmentRole = z.enum(["category", "root", "season", "sub", "file"]); export const NamingSource = z.enum(["entity", "root", "global"]); export const NamingVariable = z.object({ name: z.string(), group: z.enum(["title", "episode", "tech", "id", "ours"]), label: z.string(), example: z.string(), /** 我们特有(MP 没有) */ ours: z.boolean(), }); export const NamingVariablesResponse = z.object({ items: z.array(NamingVariable), filters: z.array(z.object({ name: z.string(), doc: z.string() })), /** 预览内置样本(sample.builtin 取 id) */ samples: z.array(z.object({ id: z.number().int(), label: z.string(), kind: NamingKind, fileName: z.string() })), }); export type NamingVariablesResponse = z.infer; export const NamingSample = z.union([ z.object({ fileName: z.string().min(1).max(1024) }), z.object({ sourceReleaseId: Id }), z.object({ assetId: Id }), z.object({ builtin: z.number().int().min(1) }), ]); export const NamingPreviewInput = z.object({ /** 不传 = 按 作品 > 库根 > 全局 取当前生效模板 */ template: NamingTemplate.optional(), kind: NamingKind, /** 不传 = 该类型的第一条内置样本 */ sample: NamingSample.optional(), libraryRootId: Id.optional(), /** 作品:取其覆盖模板与路径绑定(已绑定时目录用绑定路径) */ entityId: Id.optional(), }); export type NamingPreviewInput = z.infer; export const NamingPreviewResult = z.object({ ok: z.boolean(), relativePath: z.string(), segments: z.array(z.object({ text: z.string(), role: NamingSegmentRole })), errors: z.array(NamingIssue), warnings: z.array(z.string()), /** 输出为空的变量 */ missing: z.array(z.string()), /** 目录来自已有路径绑定(模板只决定文件名) */ usesBinding: z.boolean(), templateSource: z.union([NamingSource, z.literal("input")]), template: z.string(), sample: z.object({ fileName: z.string(), entityTitle: z.string() }), /** 渲染所用变量值(调试/变量清单"当前示例值") */ vars: z.record(z.string(), z.union([z.string(), z.number()])), }); export type NamingPreviewResult = z.infer; export const NamingTemplatesQuery = z.object({ libraryRootId: Id.optional(), entityId: Id.optional() }); export const NamingTemplatesView = z.object({ global: NamingSettings, defaults: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }), root: NamingOverride.nullable(), entity: NamingOverride.nullable(), effective: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }), sources: z.object({ movie: NamingSource, tv: NamingSource, variety: NamingSource }), }); export type NamingTemplatesView = z.infer; export const NamingSettingsPut = NamingSettings.partial(); export const LibraryRootNamingPut = z.object({ naming: NamingOverride.nullable() }); export const EntityNamingOverridePut = z.object({ namingOverride: NamingOverride.nullable() }); /** 每日预算与并发(REQ §6.4、§10.5) */ 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), /** 已弃用(2026-10-06 起智能体不设每日次数上限,旧值兼容读取、不生效) */ agentMaxRunsPerDay: z.number().int().min(0).optional(), /** 已弃用(2026-10-06 起智能体不设每日费用上限,旧值兼容读取、不生效) */ agentMaxCostUsdPerDay: z.number().min(0).optional(), }); export type BudgetSettings = z.infer; /** * 资源搜索(SEARCH-2):GET /search/releases 不带 sites 时参与搜索的站点。 * default = 启用且「默认参与搜索」的站点;all = 全部启用站点;custom = siteIds 里仍启用的站点。 */ export const SearchSiteMode = z.enum(["default", "all", "custom"]); export type SearchSiteMode = z.infer; export const SearchSettings = z.object({ defaultSiteMode: SearchSiteMode, /** custom 时的站点 ID */ siteIds: z.array(z.uuid()).max(200), }); export type SearchSettings = z.infer; /** * 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。 * eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情 * (识别缓存命中则 0 次);一个每小时 100 条的 RSS 约为每天 2400–7200 次 TMDB 请求,多数是站点独有的短剧 / 综艺,查不到。 * 关闭(缺省)时只在命中订阅 / 本地作品别名、已发布的新作品收录规则前置条件通过、用户手动识别或搜索时查询。 */ /** 元数据补全缺省白名单:视频平台、短剧平台、百科 / 影视资料站(不含任何 PT 站) */ export const DEFAULT_ENRICH_DOMAINS: string[] = [ "baike.baidu.com", "douban.com", "hongguoduanju.com", "wikipedia.org", "iqiyi.com", "v.qq.com", "youku.com", "mgtv.com", "bilibili.com", "douyin.com", "kuaishou.com", "ixigua.com", "baike.sogou.com", "mydramalist.com", // META-2:快懂百科、河马剧场(短剧平台公开作品页);红果短剧的 /series/ 页 robots 不允许,只读 robots 放行的页面 "baike.com", "kuaikaw.cn", ]; export const IdentitySettings = z.object({ eagerTmdbLookup: z.boolean(), /** @deprecated CACHE-2 起由 storage.retentionDays.identityPoolArchive 取代,服务端不再读取 */ poolArchiveDays: z.number().int().min(1).max(3650), /** * META-1:订阅别名 / 新作品收录规则命中、TMDB 却查不到时自动尝试一次元数据补全(只出建议,不建作品)。 * 每次约 1–2 次网页搜索 + 最多 3 次读取网页 + 1 次模型调用。 */ autoEnrich: z.boolean(), /** 元数据补全每日上限(手动 + 自动;命中 7 天标题缓存不计);0 = 停用补全 */ enrichDailyBudget: z.number().int().min(0).max(500), /** 元数据补全只读取这些域名(含子域名)的网页:平台站点、百科类;已登记站点的域名一律不读 */ enrichDomains: z .array( z .string() .trim() .toLowerCase() .regex(/^[a-z0-9.-]+\.[a-z]{2,}$/), ) .max(100), /** * META-2 摘要模式:白名单外网站(多为短剧聚合站)的搜索结果摘要也用来抽字段(只读搜索引擎给的摘要,不打开这些网页)。 * 缺省开;关掉后只用白名单网站的摘要与网页。旧设置没有这个键,按开处理 */ enrichSnippetAllSites: z.boolean().optional(), /** * ID-3 识别升级:标题识别不确定时,每个站点每小时最多为识别抓多少个详情页(取 IMDb / TMDB / 豆瓣 ID);0 = 不抓。 * 只在搜索结果、订阅相关的候选、池条目补全和手动重新识别时抓,RSS 批量入库不抓。旧设置没有这个键,按缺省 10 */ detailIdsPerSiteHour: z.number().int().min(0).max(200).optional(), }); export type IdentitySettings = z.infer; // ── 存储预算与保留(CACHE-2,docs/runbook/storage.md)── const Gb = z.number().min(0.01).max(10_000); const Days = z.number().int().min(1).max(3650); export const StorageBudgets = z .object({ /** PG 数据目录预算(GB):超过 pgDataWarnGb 告警,超过本值 critical */ pgDataGb: Gb, pgDataWarnGb: Gb, /** 图片代理缓存容量(GB):超出按最久未访问删除(LRU) */ imagesGb: Gb, /** DATA_DIR/backups 占用预算(GB) */ backupsGb: Gb, /** Agent 会话 + 视觉证据(DATA_DIR/agent-sessions、tmp/evidence)预算(GB) */ sessionsEvidenceGb: Gb, }) .refine((v) => v.pgDataWarnGb <= v.pgDataGb, { message: "PG 告警线不得高于预算" }); export type StorageBudgets = z.infer; /** 各类流水数据保留期(到期由 storage.retention.* 定时任务分批删除) */ export const StorageRetentionDays = z.object({ /** observation(Feed 观察) */ observation: Days, /** sse_event(SSE 补读) */ sseEvent: Days, /** process_heartbeat_sample(小时) */ heartbeatHours: z .number() .int() .min(1) .max(24 * 90), /** 已结束的业务任务 task 与引擎 run */ taskRun: Days, /** 已结束 Agent 会话的 agent_run_event / agent_run_message */ agentEvent: Days, /** audit_event(能力调用审计除外) */ audit: Days, /** 能力调用统计:audit_event(target_type=capability)与已结束的 capability_invocation */ capabilityStats: Days, /** 待识别池 unmatched 条目无新证据多少天后归档 */ identityPoolArchive: Days, // ── 2026-10-06 数据库清理(docs/runbook/storage.md「保留规则」);旧库缺这些字段时按缺省补 ── /** 已结束的引擎 run(步骤级联)、运行元数据 / 事实 / 信号;仍被保留中的业务任务引用的跟随任务 */ engineRun: Days, /** 已投递的 outbox 事件(只删无幂等键或一次性 / 带时间槽的幂等键) */ outboxEvent: Days, /** 识别尝试记录;每条资源至少保留最近 3 次 */ resolutionAttempt: Days, /** 已识别(资源已绑定作品)的待识别池条目;忽略、归档的不删 */ identityResolved: Days, /** 站点列表快照;每个站点最近一次成功的保留 */ siteSnapshot: Days, }); export type StorageRetentionDays = z.infer; /** 缓存有效期(天):只改 TTL 的取值来源,缓存语义仍对照 MP(docs/porting/moviepilot-tmdb-cache.md) */ export const StorageCacheTtl = z.object({ /** TMDB 搜索 / find 响应与识别结果 */ tmdbSearchDays: Days, /** TMDB 详情(/movie/{id}、/tv/{id}、季);追踪中的作品命中时续期 */ tmdbDetailDays: Days, /** TMDB 空结果与未识别负缓存(MP EMPTY_RESULT_CACHE_TTL) */ tmdbMissDays: Days, /** 站点详情页扩展内容(kv_cache site.detail) */ siteDetailDays: Days, /** 网页搜索结果 */ webSearchDays: Days, /** 豆瓣 / bgm 搜索请求(kv_cache douban.request / bangumi.request;条目详情走 douban.subject / bangumi.subject,见 docs/porting/moviepilot-discover.md) */ metaSearchDays: Days, /** 豆瓣 / bgm 榜单、热门、每日放送等列表请求(小时) */ metaListHours: z.number().int().min(1).max(720), /** 豆瓣 / bgm 列表空结果(小时,对照 MP empty_ttl);搜索空结果改按 metaMissDays */ metaEmptyHours: z.number().int().min(1).max(720), // ── 统一元数据缓存(2026-10-06,docs/runbook/cache.md 策略表);旧库缺这些字段时按缺省补 ── /** 豆瓣 / bgm 条目详情(douban.subject / bangumi.subject)与发现页条目 → TMDB 匹配(discover.match) */ metaDetailDays: Days, /** 豆瓣 / bgm 查无结果:搜索空结果、条目不存在(meta.subject_miss) */ metaMissDays: Days, /** TMDB 榜单 / 热门 / 趋势 / 按条件筛选(小时) */ tmdbListHours: z.number().int().min(1).max(720), /** TMDB 列表空结果(小时) */ tmdbListEmptyHours: z.number().int().min(1).max(720), /** TMDB 人物 / 图片列表(/person/*、/images) */ imageDays: Days, }); export type StorageCacheTtl = z.infer; export const StorageSettings = z.object({ budgets: StorageBudgets, retentionDays: StorageRetentionDays, cacheTtl: StorageCacheTtl, }); export type StorageSettings = z.infer; export const DEFAULT_STORAGE_SETTINGS: StorageSettings = { budgets: { pgDataGb: 25, pgDataWarnGb: 20, imagesGb: 20, backupsGb: 20, sessionsEvidenceGb: 10 }, retentionDays: { observation: 90, sseEvent: 7, heartbeatHours: 24, taskRun: 60, agentEvent: 90, audit: 180, capabilityStats: 90, identityPoolArchive: 30, engineRun: 14, outboxEvent: 30, resolutionAttempt: 30, identityResolved: 60, siteSnapshot: 30, }, cacheTtl: { tmdbSearchDays: 7, tmdbDetailDays: 30, tmdbMissDays: 1, siteDetailDays: 7, webSearchDays: 1, metaSearchDays: 7, metaListHours: 6, metaEmptyHours: 1, metaDetailDays: 30, metaMissDays: 1, tmdbListHours: 6, tmdbListEmptyHours: 1, imageDays: 90, }, }; /** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */ export function withStorageDefaults(v: unknown): StorageSettings { const o = (v && typeof v === "object" ? v : {}) as Partial>; return { budgets: { ...DEFAULT_STORAGE_SETTINGS.budgets, ...(o.budgets ?? {}) }, retentionDays: { ...DEFAULT_STORAGE_SETTINGS.retentionDays, ...(o.retentionDays ?? {}) }, cacheTtl: { ...DEFAULT_STORAGE_SETTINGS.cacheTtl, ...(o.cacheTtl ?? {}) }, } as StorageSettings; } export const GlobalSettings = z.object({ automation: AutomationSettings, cache: CacheSettings, organize: OrganizeSettings, retention: RetentionSettings, pi: PiSettings, budget: BudgetSettings, agent: AgentRuntimeSettings, metadata: MetadataSettings, sites: SitesSettings, naming: NamingSettings, identity: IdentitySettings, search: SearchSettings, storage: StorageSettings, }); export type GlobalSettings = z.infer; /** settings 表的合法 key */ export const SettingsKey = GlobalSettings.keyof(); export type SettingsKey = z.infer; /** 缺省设置(settings 表缺 key 或缺字段时使用) */ export const DEFAULT_SETTINGS: GlobalSettings = { automation: { enabled: true, readOnlyReconcile: false, workerLanes: DEFAULT_WORKER_LANES }, cache: { hardThresholdPercent: 90, resumePercent: 85, checkIntervalSec: 60, cleanupTargetPercent: 80, tmdbTtlHours: 24, tmdbMaxSize: 5000, emptyResultTtlSec: 1800, globalImageCache: false, globalImageCacheDays: 7, }, organize: { classificationYaml: DEFAULT_CLASSIFICATION_YAML, sources: [], defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024, // 缺省开:只对有 TMDB id 或本地元数据的作品排刮削(否则在整理记录上记「未刮削」),「更新」方式不动别人写的文件 scrapeAfterImport: true, scrapeMode: "update", scrapeImages: true, scrapeEpisodeThumbs: true, }, retention: { seedRetentionDays: 7, logRetentionDays: 180 }, pi: { defaultUpstreamId: null, provider: "anthropic", baseUrl: null, model: null, thinkingLevel: "medium", contextBudget: 128_000, compaction: { enabled: true, thresholdRatio: 0.8 }, subagentsEnabled: false, subagents: { enabled: false, maxConcurrent: 2, maxDepth: 1 }, apiKeySecretRef: null, }, budget: { maxNewTitlesPerDay: 20, maxDownloadBytesPerDay: 500 * 1024 ** 3, maxConcurrentDownloads: 5, }, agent: { autoInvestigate: false, autoInvestigateMode: "investigate", investigateMaxAttempts: 2, recoverVerifySec: 600, maxToolCalls: 120, maxDurationSec: 1800, autoIdentifyOrganize: true, investigateMaxToolCalls: 20, goalPollSec: 60, goalWaitHours: 72, optionsTimeoutSec: 1800, optionsTimeoutAction: "recommended", approvalTimeoutSec: 86400, fakeScript: null, sessionDir: null, webSearch: DEFAULT_WEB_SEARCH, }, metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null, languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"], overviewNonChineseFallback: false, includeSpecials: true, }, sites: { defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 }, defaultSnapshotIntervalSec: 3600, userAgent: "", proxyUrl: null, userDataMaxSeedingPages: 10, defaultSnapshotMaxPages: 1, defaultHealthCheckIntervalSec: 21600, defaultDetailCacheSec: 86400, }, naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false }, identity: { eagerTmdbLookup: false, poolArchiveDays: 30, autoEnrich: false, enrichDailyBudget: 20, enrichDomains: DEFAULT_ENRICH_DOMAINS, enrichSnippetAllSites: true, detailIdsPerSiteHour: 10, }, search: { defaultSiteMode: "default", siteIds: [] }, storage: DEFAULT_STORAGE_SETTINGS, }; /** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */ export const GlobalSettingsUpdate = z.object({ automation: AutomationSettings.omit({ workerLanes: true }) .partial() .extend({ workerLanes: WorkerLaneSettings.partial().optional() }) .optional(), cache: CacheShape.partial().optional(), organize: OrganizeSettings.omit({ sources: true }).partial().optional(), retention: RetentionSettings.partial().optional(), /** * defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游; * 旧字段(provider / baseUrl / model / thinkingLevel / apiKey)不再接受,传了会被丢弃。 */ pi: PiSettings.omit({ apiKeySecretRef: true, defaultUpstreamId: true, provider: true, baseUrl: true, model: true, thinkingLevel: true, }) .partial() .extend({ compaction: PiSettings.shape.compaction.partial().optional(), subagents: PiSettings.shape.subagents.partial().optional(), }) .optional(), budget: BudgetSettings.partial().optional(), agent: AgentRuntimeSettings.omit({ webSearch: true }) .partial() .extend({ webSearch: WebSearchSettings.omit({ apiKeySecretRef: true }) .partial() .extend({ /** 只写:搜索服务 API Key,服务端存为 secret 并回填 apiKeySecretRef;空串不变,null 清除 */ apiKey: z.string().max(4096).nullable().optional(), }) .optional(), }) .optional(), metadata: MetadataSettings.omit({ tmdbApiKeySecretRef: true }) .partial() .extend({ /** 只写:TMDB API Key(v3 key 或 v4 read token),服务端存为 secret 并回填 tmdbApiKeySecretRef;空串不变,null 清除 */ tmdbApiKey: z.string().max(4096).nullable().optional(), }) .optional(), sites: SitesSettings.partial().optional(), naming: NamingSettings.partial().optional(), identity: IdentitySettings.partial().optional(), search: SearchSettings.partial().optional(), storage: z .object({ budgets: z.object(StorageBudgets.shape).partial().optional(), retentionDays: StorageRetentionDays.partial().optional(), cacheTtl: StorageCacheTtl.partial().optional(), }) .optional(), }); export type GlobalSettingsUpdate = z.infer; /** 合并部分更新(纯函数,web 乐观更新与 server 共用) */ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings { const { compaction, subagents, ...pi } = patch.pi ?? {}; const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {}; const { webSearch: wsPatch, ...agent } = patch.agent ?? {}; const { apiKey: _wsApiKey, ...webSearch } = wsPatch ?? {}; return { automation: { ...current.automation, ...patch.automation, workerLanes: { ...DEFAULT_WORKER_LANES, ...current.automation.workerLanes, ...patch.automation?.workerLanes }, }, cache: { ...current.cache, ...patch.cache }, organize: { ...current.organize, ...patch.organize }, retention: { ...current.retention, ...patch.retention }, pi: mergePi(current.pi, pi, compaction, subagents), budget: { ...current.budget, ...patch.budget }, agent: { ...current.agent, ...agent, webSearch: { ...DEFAULT_WEB_SEARCH, ...current.agent.webSearch, ...webSearch }, }, metadata: { ...current.metadata, ...metadata }, sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites }, naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming }, identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity }, search: { ...(current.search ?? DEFAULT_SETTINGS.search), ...patch.search }, storage: mergeStorage(current.storage, patch.storage), }; } function mergeStorage(current: StorageSettings | undefined, patch: GlobalSettingsUpdate["storage"]): StorageSettings { const base = withStorageDefaults(current); return { budgets: { ...base.budgets, ...patch?.budgets }, retentionDays: { ...base.retentionDays, ...patch?.retentionDays }, cacheTtl: { ...base.cacheTtl, ...patch?.cacheTtl }, }; } /** 子代理开关:subagents.enabled 优先,旧字段 subagentsEnabled 作别名,合并后两者一致 */ function mergePi( current: PiSettings, pi: Partial>, compaction: Partial | undefined, subagents: Partial | undefined, ): PiSettings { const base = current.subagents ?? DEFAULT_SETTINGS.pi.subagents; const enabled = subagents?.enabled ?? pi.subagentsEnabled ?? base.enabled; return { ...current, ...pi, compaction: { ...current.compaction, ...compaction }, subagents: { ...base, ...subagents, enabled }, subagentsEnabled: enabled, }; } /** settings 表行 */ export const SettingEntry = z.object({ key: SettingsKey, value: z.unknown(), updatedAt: Timestamp, }); export type SettingEntry = z.infer; /** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释);设置页不显示 */ export const DEPRECATED_SETTINGS_FIELDS = [ "pi.provider", "pi.baseUrl", "pi.model", "pi.thinkingLevel", "pi.apiKeySecretRef", // CACHE-2:由 storage.cacheTtl / storage.retentionDays 取代 "cache.tmdbTtlHours", "cache.emptyResultTtlSec", "identity.poolArchiveDays", // CACHE-2 起流水数据按 storage.retentionDays 分类清理,这个总的日志保留天数没有任何地方读取 "retention.logRetentionDays", // 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制 "budget.agentMaxRunsPerDay", "budget.agentMaxCostUsdPerDay", "agent.maxTokensPerRun", "agent.maxCostUsdPerRun", ] as const; // ── 设置页分区(docs/design/console-ia.md §3.4,2026-10-06 重排) ── // 只是"展示分区 → 字段"的映射,不改存储 key 与缺省值;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。 // 非 settings 表的对象(实例、站点、API Key、模型服务、账户)用 resources 标注,不进 fields。 // 分区按三组排:接入对象(外部服务与数据源)→ 业务环节(获取 → 落盘 → 整理 → 智能体)→ 系统与账户。 export const SettingsSectionId = z.enum([ "downloaders", "media-servers", "sites", "metadata", "acquire", "library", "organize", "agent", "system", "cache", "account", "interface", ]); export type SettingsSectionId = z.infer; export const SettingsGroupId = z.enum(["connect", "flow", "system"]); export type SettingsGroupId = z.infer; export const SETTINGS_GROUPS: readonly { id: SettingsGroupId; label: string }[] = [ { id: "connect", label: "接入" }, { id: "flow", label: "流程" }, { id: "system", label: "系统" }, ]; export type SettingsAnchorDef = { id: string; label: string; /** 搜索用的别名:不在表单字段里的设置(实例抽屉的字段、登录方式等) */ keywords?: string[]; }; export type SettingsSectionDef = { id: SettingsSectionId; group: SettingsGroupId; label: string; /** 一句说明:这里管什么 */ description: string; /** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */ legacyTabs: { tab: string; anchor?: string }[]; /** 分区内锚点(节),顺序即页面顺序 */ anchors: SettingsAnchorDef[]; /** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */ fields: string[]; /** 本分区管理的非 settings 对象 */ resources: string[]; }; export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ { id: "downloaders", group: "connect", label: "下载器", description: "qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改", legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }, { tab: "storage", anchor: "cache" }], anchors: [ { id: "instances", label: "实例", keywords: [ "qBittorrent", "WebUI", "管理标签", "缓存卷标识", "默认下载器", "路径映射", "H&R 未知也可清理", "认证方式", ], }, { id: "cache", label: "下载盘", keywords: ["水位", "缓存盘"] }, { id: "seeding", label: "保种", keywords: ["做种"] }, ], fields: [ "cache.hardThresholdPercent", "cache.resumePercent", "cache.checkIntervalSec", "cache.cleanupTargetPercent", "retention.seedRetentionDays", ], resources: ["downloader-instances", "path-mappings"], }, { id: "media-servers", group: "connect", label: "媒体服务器", description: "Emby / Jellyfin 实例与路径映射,只用来查作品有没有", legacyTabs: [{ tab: "media-servers" }], anchors: [{ id: "instances", label: "实例", keywords: ["Emby", "Jellyfin", "用户 ID"] }], fields: [], resources: ["media-server-instances", "path-mappings"], }, { id: "sites", group: "connect", label: "站点与网络", description: "站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页", legacyTabs: [{ tab: "site-credentials" }], anchors: [ { id: "defaults", label: "站点缺省值", keywords: ["限流", "抓取周期", "健康检测"] }, { id: "network", label: "UA 与代理", keywords: ["User-Agent", "代理", "出口"] }, { id: "browser", label: "浏览器服务", keywords: ["原站视图", "Cloudflare"] }, ], fields: [ "sites.defaultRateLimit", "sites.defaultSnapshotIntervalSec", "sites.userAgent", "sites.proxyUrl", "sites.userDataMaxSeedingPages", "sites.defaultSnapshotMaxPages", "sites.defaultHealthCheckIntervalSec", "sites.defaultDetailCacheSec", ], resources: ["browser-providers"], }, { id: "metadata", group: "connect", label: "元数据", description: "TMDB 接入、语言回退与特别篇;缓存在「缓存」里管理", legacyTabs: [], anchors: [ { id: "tmdb", label: "TMDB", keywords: ["API Key", "Base URL"] }, { id: "language", label: "语言与季集", keywords: ["季 0", "特别篇", "简介"] }, ], fields: [ "metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl", "metadata.languages", "metadata.overviewNonChineseFallback", "metadata.includeSpecials", ], resources: [], }, { id: "acquire", group: "flow", label: "获取", description: "每日下载预算、搜索站点、资源识别;订阅和规则在各自页面", legacyTabs: [], anchors: [ { id: "budget", label: "每日预算", keywords: ["并发下载"] }, { id: "search", label: "搜索站点", keywords: ["默认参与搜索"] }, { id: "identity", label: "资源识别", keywords: ["元数据补全", "TMDB"] }, ], fields: [ "budget.maxNewTitlesPerDay", "budget.maxDownloadBytesPerDay", "budget.maxConcurrentDownloads", "search.defaultSiteMode", "search.siteIds", "identity.eagerTmdbLookup", "identity.autoEnrich", "identity.enrichDailyBudget", "identity.enrichDomains", "identity.enrichSnippetAllSites", "identity.detailIdsPerSiteHour", ], resources: [], }, { id: "library", group: "flow", label: "媒体库与目录", description: "存储、媒体库目录、整理来源、分类规则、命名模板", legacyTabs: [{ tab: "library-roots", anchor: "roots" }], anchors: [ { id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] }, { id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] }, { id: "sources", label: "整理来源" }, { id: "classification", label: "分类规则", keywords: ["二级分类"] }, { id: "naming", label: "命名模板", keywords: ["重命名"] }, { id: "naming-roots", label: "目录命名覆盖" }, ], fields: [ "organize.classificationYaml", "organize.sources", "naming.movie", "naming.tv", "naming.variety", "naming.lockFileNameForBound", ], resources: ["storage-instances", "library-roots"], }, { id: "organize", group: "flow", label: "整理与刮削", description: "整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式", legacyTabs: [], anchors: [ { id: "mode", label: "整理方式", keywords: ["复制", "移动", "跨卷"] }, { id: "scrape", label: "刮削", keywords: ["NFO", "海报", "剧照", "更新", "只补缺失", "全部覆盖"] }, ], fields: [ "organize.defaultMode", "organize.allowCrossVolumeMove", "organize.minSizeBytes", "organize.scrapeAfterImport", "organize.scrapeMode", "organize.scrapeImages", "organize.scrapeEpisodeThumbs", ], resources: [], }, { id: "agent", group: "flow", label: "智能体", description: "模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP", legacyTabs: [{ tab: "pi" }], anchors: [ { id: "upstream", label: "模型服务", keywords: ["默认模型服务", "订阅登录"] }, { id: "budget", label: "用量与限制", keywords: ["花费", "预算"] }, { id: "investigate", label: "自动处理", keywords: ["自动调查", "识别失败"] }, { id: "runtime", label: "上下文与子会话", keywords: ["压缩"] }, { id: "websearch", label: "网页搜索", keywords: ["Brave"] }, { id: "mcp", label: "MCP 服务端" }, { id: "capabilities", label: "能力清单" }, { id: "advanced", label: "高级", keywords: ["方案", "审批"] }, ], fields: [ "pi.defaultUpstreamId", "pi.contextBudget", "pi.compaction", "pi.subagentsEnabled", "pi.subagents", "agent.autoInvestigate", "agent.autoIdentifyOrganize", "agent.autoInvestigateMode", "agent.investigateMaxAttempts", "agent.recoverVerifySec", "agent.maxToolCalls", "agent.maxDurationSec", "agent.investigateMaxToolCalls", "agent.goalPollSec", "agent.goalWaitHours", "agent.optionsTimeoutSec", "agent.optionsTimeoutAction", "agent.approvalTimeoutSec", "agent.fakeScript", "agent.sessionDir", "agent.webSearch", ], resources: ["pi-upstreams"], }, { id: "system", group: "system", label: "系统", description: "自动化总开关、只读对账、后台任务并发、存储预算、数据保留", legacyTabs: [{ tab: "automation" }], anchors: [ { id: "switches", label: "自动化", keywords: ["总开关", "暂停"] }, { id: "readonly", label: "只读对账" }, { id: "worker-lanes", label: "后台任务并发" }, { id: "storage", label: "存储预算" }, { id: "storage-retention", label: "数据保留", keywords: ["日志保留"] }, ], fields: [ "automation.enabled", "automation.readOnlyReconcile", "automation.workerLanes", "storage.budgets", "storage.retentionDays", ], resources: [], }, { id: "cache", group: "system", label: "缓存", description: "各类缓存的条数、占用和有效期,可以按区清空", legacyTabs: [], anchors: [ { id: "regions", label: "缓存区", keywords: ["清空缓存", "豆瓣", "Bangumi", "站点详情", "发现", "网页搜索"], }, { id: "tmdb-cache", label: "TMDB 缓存", keywords: ["条数上限"] }, { id: "images", label: "图片缓存", keywords: ["海报"] }, { id: "ttl", label: "缓存有效期" }, ], fields: ["cache.tmdbMaxSize", "cache.globalImageCache", "cache.globalImageCacheDays", "storage.cacheTtl"], resources: ["kv-cache", "image-cache"], }, { id: "account", group: "system", label: "账户与安全", description: "密码、二次验证、通行密钥、会话与本机 Token、API Key", legacyTabs: [{ tab: "api-keys", anchor: "api-keys" }], anchors: [ { id: "password", label: "密码" }, { id: "totp", label: "二次验证", keywords: ["TOTP", "恢复码"] }, { id: "passkeys", label: "通行密钥", keywords: ["Passkey"] }, { id: "session", label: "会话", keywords: ["本机 Token", "退出登录"] }, { id: "api-keys", label: "API Key", keywords: ["外部程序"] }, ], fields: [], resources: ["auth", "api-keys"], }, { id: "interface", group: "system", label: "界面", description: "只影响当前浏览器", legacyTabs: [], anchors: [ { id: "display", label: "显示", keywords: ["技术信息"] }, { id: "shortcuts", label: "快捷键" }, ], fields: [], resources: ["ui-preferences"], }, ]; /** 旧 `?tab=` → 分区与锚点;不认识的返回 null */ export function settingsSectionForLegacyTab(tab: string): { section: SettingsSectionId; anchor?: string } | null { for (const s of SETTINGS_SECTIONS) { const hit = s.legacyTabs.find((t) => t.tab === tab); if (hit) return { section: s.id, anchor: hit.anchor }; } return null; } /** * 旧 hash(2026-10-06 重排前的 `#分区[/节]`)→ 新位置。先查整条,再查只有分区的那一条; * 新旧同名的(`#downloaders`、`#sites/browser`、`#agent/budget` 等)不用登记。 */ export const SETTINGS_HASH_ALIASES: Readonly> = { automation: "system", "automation/switches": "system/switches", "automation/cache": "downloaders/cache", "automation/budget": "acquire/budget", "automation/retention": "downloaders/seeding", "automation/advanced": "system/readonly", "automation/readonly": "system/readonly", "automation/worker-lanes": "system/worker-lanes", "automation/storage": "system/storage", "automation/storage-retention": "system/storage-retention", "automation/storage-ttl": "cache/ttl", "system/storage-ttl": "cache/ttl", "metadata/tmdb-cache": "cache/tmdb-cache", "metadata/images": "cache/images", "library/organize": "organize/mode", "library/scrape": "organize/scrape", "metadata/identity": "acquire/identity", "api-keys": "account/api-keys", "api-keys/keys": "account/api-keys", "account/shortcuts": "interface/shortcuts", "sites/accounts": "sites", }; /** * 解析设置页 hash:`#分区[/节]`,旧 hash 按 SETTINGS_HASH_ALIASES 换成新位置(moved = true 时调用方应 replace 地址栏); * 不认识的分区返回 null 分区(调用方落到第一个分区),不认识的节丢掉。 */ export function resolveSettingsHash(hash: string): { section: SettingsSectionId | null; anchor: string | null; moved: boolean; } { let raw = hash.replace(/^#/, ""); try { raw = decodeURIComponent(raw); } catch { // 保持原样 } const [head = "", tail] = raw.split("/"); const full = tail ? `${head}/${tail}` : head; const alias = SETTINGS_HASH_ALIASES[full]; let target = alias ?? full; let moved = alias !== undefined; if (!alias && SETTINGS_HASH_ALIASES[head] && !SETTINGS_SECTIONS.some((s) => s.id === head)) { // 旧分区里没登记的节:落到新分区顶部 target = SETTINGS_HASH_ALIASES[head] as string; moved = true; } const [sid, anchor] = target.split("/"); const section = SETTINGS_SECTIONS.find((s) => s.id === sid); if (!section) return { section: null, anchor: null, moved: false }; const known = anchor && section.anchors.some((a) => a.id === anchor) ? anchor : null; return { section: section.id, anchor: known, moved }; }