import { z } from "zod"; import { Id, Timestamp } from "./common.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({ defaultMode: OrganizeMode, /** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */ allowCrossVolumeMove: z.boolean(), /** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */ minSizeBytes: z.number().int().nonnegative(), }); 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), /** 单次运行:token 上限(null = 不限) */ maxTokensPerRun: z.number().int().positive().nullable(), /** 单次运行:费用上限(美元,null = 不限) */ maxCostUsdPerRun: z.number().positive().nullable(), /** 后台自动调查的工具调用上限(比用户委托更小) */ 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 */ 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), agentMaxRunsPerDay: z.number().int().min(0), agentMaxCostUsdPerDay: z.number().min(0), }); 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(), }); 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, }); 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, }); 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, }, cacheTtl: { tmdbSearchDays: 7, tmdbDetailDays: 30, tmdbMissDays: 1, siteDetailDays: 7, webSearchDays: 1 }, }; /** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */ 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: { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024 }, 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, agentMaxRunsPerDay: 50, agentMaxCostUsdPerDay: 5, }, agent: { autoInvestigate: false, autoInvestigateMode: "investigate", investigateMaxAttempts: 2, recoverVerifySec: 600, maxToolCalls: 40, maxDurationSec: 900, maxTokensPerRun: null, maxCostUsdPerRun: null, investigateMaxToolCalls: 8, 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, }, 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.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", ] as const; // ── 设置页分区(docs/design/console-ia.md §3.4) ── // 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。 // 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。 export const SettingsSectionId = z.enum([ "automation", "sites", "downloaders", "library", "media-servers", "metadata", "agent", "api-keys", "interface", "account", ]); export type SettingsSectionId = z.infer; export type SettingsSectionDef = { id: SettingsSectionId; label: string; description: string; /** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */ legacyTabs: { tab: string; anchor?: string }[]; /** 分区内锚点(节),顺序即页面顺序 */ anchors: { id: string; label: string }[]; /** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */ fields: string[]; /** 本分区管理的非 settings 对象 */ resources: string[]; }; export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ { id: "automation", label: "自动化", description: "总开关、下载盘、每日预算、保留天数", legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }], anchors: [ { id: "switches", label: "开关" }, { id: "cache", label: "下载盘" }, { id: "budget", label: "每日预算" }, { id: "retention", label: "保留" }, { id: "advanced", label: "高级" }, ], fields: [ "automation.enabled", "automation.readOnlyReconcile", "automation.workerLanes", "cache.hardThresholdPercent", "cache.resumePercent", "cache.checkIntervalSec", "cache.cleanupTargetPercent", "budget.maxNewTitlesPerDay", "budget.maxDownloadBytesPerDay", "budget.maxConcurrentDownloads", "retention.seedRetentionDays", "retention.logRetentionDays", "storage.budgets", "storage.retentionDays", "storage.cacheTtl", ], resources: [], }, { id: "sites", label: "站点默认", description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理", legacyTabs: [{ tab: "site-credentials" }], anchors: [ { id: "defaults", label: "站点默认" }, { id: "browser", label: "浏览器服务" }, ], fields: [ "sites.defaultRateLimit", "sites.defaultSnapshotIntervalSec", "sites.userAgent", "sites.proxyUrl", "sites.userDataMaxSeedingPages", "sites.defaultSnapshotMaxPages", "sites.defaultHealthCheckIntervalSec", "sites.defaultDetailCacheSec", "search.defaultSiteMode", "search.siteIds", ], resources: [], }, { id: "downloaders", label: "下载器", description: "qBittorrent 实例、连通测试、路径映射", legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }], anchors: [{ id: "instances", label: "实例" }], fields: [], resources: ["downloader-instances", "path-mappings"], }, { id: "library", label: "媒体库与目录", description: "媒体库目录、默认整理方式、命名模板", legacyTabs: [{ tab: "library-roots", anchor: "roots" }], anchors: [ { id: "roots", label: "媒体库目录" }, { id: "organize", label: "整理方式" }, { id: "naming", label: "命名模板" }, ], fields: [ "organize.defaultMode", "organize.allowCrossVolumeMove", "organize.minSizeBytes", "naming.movie", "naming.tv", "naming.variety", "naming.lockFileNameForBound", ], resources: ["library-roots"], }, { id: "media-servers", label: "媒体服务器", description: "Emby / Jellyfin 实例、连通测试、路径映射", legacyTabs: [{ tab: "media-servers" }], anchors: [{ id: "instances", label: "实例" }], fields: [], resources: ["media-server-instances", "path-mappings"], }, { id: "metadata", label: "元数据", description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理", legacyTabs: [], anchors: [ { id: "tmdb", label: "TMDB" }, { id: "tmdb-cache", label: "TMDB 缓存" }, { id: "identity", label: "资源识别" }, { id: "images", label: "图片代理与缓存" }, ], fields: [ "metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl", "metadata.languages", "metadata.overviewNonChineseFallback", "metadata.includeSpecials", "cache.tmdbMaxSize", "identity.eagerTmdbLookup", "identity.autoEnrich", "identity.enrichDailyBudget", "identity.enrichDomains", "identity.enrichSnippetAllSites", "cache.globalImageCache", "cache.globalImageCacheDays", ], resources: [], }, { id: "agent", label: "智能体", description: "模型服务、运行预算、自动调查、网页搜索", legacyTabs: [{ tab: "pi" }], anchors: [ { id: "upstream", label: "模型服务" }, { id: "runtime", label: "上下文与子会话" }, { id: "budget", label: "预算" }, { id: "investigate", label: "自动调查" }, { id: "websearch", label: "网页搜索" }, { id: "advanced", label: "高级" }, { id: "mcp", label: "MCP 服务端" }, { id: "capabilities", label: "能力清单" }, ], fields: [ "pi.defaultUpstreamId", "pi.contextBudget", "pi.compaction", "pi.subagentsEnabled", "pi.subagents", "budget.agentMaxRunsPerDay", "budget.agentMaxCostUsdPerDay", "agent.autoInvestigate", "agent.autoInvestigateMode", "agent.investigateMaxAttempts", "agent.recoverVerifySec", "agent.maxToolCalls", "agent.maxDurationSec", "agent.maxTokensPerRun", "agent.maxCostUsdPerRun", "agent.investigateMaxToolCalls", "agent.goalPollSec", "agent.goalWaitHours", "agent.optionsTimeoutSec", "agent.optionsTimeoutAction", "agent.approvalTimeoutSec", "agent.fakeScript", "agent.sessionDir", "agent.webSearch", ], resources: ["pi-upstreams"], }, { id: "api-keys", label: "API Key", description: "签发给外部程序的 Key", legacyTabs: [{ tab: "api-keys" }], anchors: [{ id: "keys", label: "API Key" }], fields: [], resources: ["api-keys"], }, { id: "interface", label: "界面", description: "只影响当前浏览器", legacyTabs: [], anchors: [{ id: "display", label: "显示" }], fields: [], resources: ["ui-preferences"], }, { id: "account", label: "账户与安全", description: "密码、二次验证、会话、快捷键", legacyTabs: [], anchors: [ { id: "password", label: "密码" }, { id: "totp", label: "二次验证" }, { id: "passkeys", label: "通行密钥" }, { id: "session", label: "会话" }, { id: "shortcuts", label: "快捷键" }, ], fields: [], resources: ["auth"], }, ]; /** 旧 `?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; }