From 16ab2a6c84d7743e9161182eb2052c8924d58d32 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 02:09:39 +0800 Subject: [PATCH] docs(plan): v0.2 adds Pi subagents and scheduled-jobs board Co-Authored-By: Claude Fable 5.1 --- .../__tests__/organize-guard.test.ts | 4 +- .../acquisition-organize/capabilities.ts | 20 +++-- .../src/modules/acquisition-organize/ports.ts | 2 +- .../acquisition-organize/routes/dashboard.ts | 4 +- .../acquisition-organize/routes/downloads.ts | 3 +- .../routes/organization.ts | 4 +- .../acquisition-organize/routes/rules.ts | 12 +-- .../services/organize-guard.ts | 22 ++--- .../acquisition-organize/services/organize.ts | 32 ++++---- .../services/subscriptions.ts | 4 +- apps/server/src/modules/agent/authorize.ts | 4 +- apps/server/src/modules/agent/fake-scripts.ts | 2 +- apps/server/src/modules/agent/investigate.ts | 19 +++-- .../server/src/modules/agent/invoke-limits.ts | 4 +- apps/server/src/modules/agent/report.ts | 4 +- apps/server/src/modules/agent/routes.ts | 13 ++- apps/server/src/modules/agent/runner.ts | 13 ++- apps/server/src/modules/agent/service.ts | 2 +- .../src/modules/auth-settings/auth-routes.ts | 2 +- .../src/modules/auth-settings/auth-service.ts | 8 +- .../modules/auth-settings/instances-routes.ts | 2 +- .../src/modules/auth-settings/keys-routes.ts | 2 +- .../server/src/modules/file-manager/common.ts | 10 +-- .../src/modules/file-manager/download.ts | 5 +- .../src/modules/file-manager/identify.ts | 4 +- .../server/src/modules/file-manager/ledger.ts | 11 ++- .../server/src/modules/file-manager/mutate.ts | 12 +-- .../src/modules/file-manager/organize.ts | 12 +-- apps/server/src/modules/file-manager/probe.ts | 2 +- .../src/modules/file-manager/transfer.ts | 25 +++--- apps/server/src/modules/file-manager/trash.ts | 24 +++--- .../modules/sources-identity/capabilities.ts | 4 +- .../src/modules/sources-identity/defs.ts | 4 +- .../sources-identity/routes/decisions.ts | 2 +- .../sources-identity/routes/sources.ts | 8 +- .../sources-identity/routes/workbench.ts | 4 +- .../modules/sources-identity/service/feeds.ts | 4 +- .../service/identity-admin.ts | 4 +- .../sources-identity/service/resolve.ts | 2 +- .../sources-identity/service/snapshot.ts | 2 +- apps/server/test/file-manager/w5a2.test.ts | 2 +- apps/web/app/api/parse.ts | 2 +- apps/web/app/api/tasks.ts | 10 +-- .../app/components/console/account-menu.tsx | 11 ++- .../app/components/console/api-key-dialog.tsx | 2 +- .../console/workspace-placeholder.tsx | 6 +- .../components/downloads/business-tasks.tsx | 6 +- .../downloads/business-timeline.tsx | 2 +- .../components/downloads/cache-volumes.tsx | 10 +-- .../components/downloads/download-detail.tsx | 9 +-- .../components/downloads/organize-dialog.tsx | 22 +++-- apps/web/app/components/files/dialogs.tsx | 54 +++++-------- apps/web/app/components/files/file-detail.tsx | 8 +- apps/web/app/components/files/mock.ts | 2 +- apps/web/app/components/files/model.test.ts | 2 +- apps/web/app/components/files/model.ts | 14 ++-- .../app/components/files/organize-wizard.tsx | 28 +++---- .../app/components/library/entity-dialogs.tsx | 30 +++---- .../components/library/identity-aliases.tsx | 8 +- .../library/identity-external-sheet.tsx | 3 +- .../library/identity-pool-sheet.tsx | 17 ++-- apps/web/app/components/library/labels.ts | 4 +- .../components/library/preferences-form.tsx | 8 +- apps/web/app/components/library/slot-card.tsx | 2 +- .../web/app/components/library/slot-rules.tsx | 27 +++---- .../library/subscriptions-policy.tsx | 81 +++++++++---------- .../library/subscriptions-slots.tsx | 26 +++--- .../components/workbench/download-dialog.tsx | 18 ++--- .../app/components/workbench/feed-form.tsx | 8 +- .../workbench/release-detail-sheet.tsx | 18 ++--- .../components/workbench/snapshot-panel.tsx | 4 +- apps/web/app/lib/api.ts | 2 +- apps/web/app/lib/labels.ts | 10 +-- apps/web/app/routes/console/audit.tsx | 10 +-- apps/web/app/routes/console/engine.$runId.tsx | 24 +++--- apps/web/app/routes/console/engine.tsx | 8 +- apps/web/app/routes/console/files.trash.tsx | 16 ++-- apps/web/app/routes/console/files.tsx | 28 +++---- apps/web/app/routes/console/identity.tsx | 13 ++- apps/web/app/routes/console/inbox.tsx | 16 ++-- apps/web/app/routes/console/library.$id.tsx | 57 +++++++------ apps/web/app/routes/console/library.tsx | 11 ++- apps/web/app/routes/console/organize.$id.tsx | 6 +- apps/web/app/routes/console/organize.tsx | 8 +- apps/web/app/routes/console/preferences.tsx | 25 +++--- apps/web/app/routes/console/sites.$siteId.tsx | 43 ++++------ apps/web/app/routes/console/sites.tsx | 9 +-- apps/web/app/routes/console/sources.tsx | 11 ++- apps/web/app/routes/console/subscriptions.tsx | 45 +++++------ apps/web/app/routes/console/tasks.$id.tsx | 1 - apps/web/app/routes/console/tasks.tsx | 6 +- apps/web/app/routes/login.tsx | 28 +++---- docs/design/glossary.md | 44 ++++++++++ docs/plan/00-first-version-plan.md | 2 + packages/agent/src/system-prompt.ts | 16 ++-- packages/contracts/src/api.ts | 22 ++--- packages/contracts/src/capability.ts | 14 ++-- 97 files changed, 592 insertions(+), 648 deletions(-) create mode 100644 docs/design/glossary.md diff --git a/apps/server/src/modules/acquisition-organize/__tests__/organize-guard.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/organize-guard.test.ts index 4b3d66c9..93a5a4c4 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/organize-guard.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/organize-guard.test.ts @@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务" for (const dryRun of [true, false]) { const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun }); expect(r.status).toBe(400); - expect(r.body.error.message).toContain("不在已登记的媒体库根或下载器路径映射之下"); + expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下"); } // `..` 即使规范化后仍在根内也拒绝 const inside = await file("Dots.2020.1080p.BluRay-X.mkv"); @@ -240,7 +240,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务" // 按下载任务 ID(管理员也一样被做种义务拦下) const byId = await post({ downloadTaskId: dtId, action: "move" }); expect(byId.status).toBe(409); - expect(byId.body.error.message).toContain("未改为 Copy"); + expect(byId.body.error.message).toContain("未改为复制"); // 按路径命中纳管任务的内容 const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv"); expect(existsSync(content)).toBe(true); diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index 3ba43ad9..b6ed70ea 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -80,13 +80,13 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => const d = deps(); const db = d.ctx.db; const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst(); - if (!slot) throw new CapabilityError("not_found", "收录目标(Slot)不存在"); + if (!slot) throw new CapabilityError("not_found", "版本目标不存在"); const rel = await db .selectFrom("source_release") .select(["id", "media_entity_id"]) .where("id", "=", input.sourceReleaseId) .executeTakeFirst(); - if (!rel) throw new CapabilityError("not_found", "来源资源不存在"); + if (!rel) throw new CapabilityError("not_found", "资源不存在"); const out = await evaluateAcquisition(d, { sourceReleaseId: input.sourceReleaseId, acquisitionSlotId: input.slotId, @@ -111,7 +111,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] }; for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message }); for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r }); - if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划(Slot 忙或条件不满足)" }); + if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" }); } const fresh = await db .selectFrom("acquisition_slot") @@ -148,8 +148,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => .where("id", "=", plan.slot_id) .forUpdate() .executeTakeFirstOrThrow(); - if (slot.revision + 1 !== plan.expected_revision) return "Slot 已变化,请重新生成计划"; - if (BUSY.includes(slot.status)) return `Slot 正忙(${slot.status})`; + if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划"; + if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`; let st = { status: slot.status, hasAsset: slot.current_asset_id !== null }; if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" }); st = transition(st, { type: "reserve" }); @@ -172,7 +172,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => return null; }) .catch((err) => { - if ((err as { code?: string }).code === "23505") return "Slot 已有其它有效预留"; + if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留"; throw err; }); if (ok) throw new CapabilityError("conflict", ok); @@ -205,7 +205,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => registry.register(capabilitySpec("organize.plan"), async (input, ctx) => { const d = deps(); if (!input.downloadTaskId) - throw new CapabilityError("precondition", "v0.1 只支持按下载任务(downloadTaskId)生成整理计划"); + throw new CapabilityError("precondition", "只支持按下载任务(downloadTaskId)生成整理计划"); const dt = await d.ctx.db .selectFrom("download_task") .select(["status"]) @@ -231,9 +231,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => if (!frozen) { throw new CapabilityError( "precondition", - res.recordId - ? `该下载任务已有进行中的整理记录 ${res.recordId}` - : `无法生成整理计划:${res.reason ?? res.status}`, + res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`, ); } const entity = await d.ctx.db @@ -315,7 +313,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => dryRun: true, }); if (!again.plan || planHash(req, again.plan) !== art.content_hash) - throw new CapabilityError("conflict", "源文件或目标已变化,计划失效,请重新生成整理计划"); + throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划"); const taskId = await ensureTaskRow(db, { kind: "verifyAndImport", idempotencyKey: taskKey, diff --git a/apps/server/src/modules/acquisition-organize/ports.ts b/apps/server/src/modules/acquisition-organize/ports.ts index df59f1bc..784fd701 100644 --- a/apps/server/src/modules/acquisition-organize/ports.ts +++ b/apps/server/src/modules/acquisition-organize/ports.ts @@ -107,7 +107,7 @@ export function createPorts( return client; }, async torrentSource({ release, site }) { - if (!site) throw new Error("来源站点不存在,无法取种子"); + if (!site) throw new Error("站点不存在,无法获取种子"); const raw = (release.raw ?? {}) as Record; const siteMeta = (raw._site ?? {}) as Record; const defId = [str(siteMeta.adapterSiteId), site.key].find((k) => k && getSiteDefinition(k)); diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index d8968314..5bb9087f 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -121,9 +121,9 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { const dv = decideVolume(v, cache); const cands = await cleanupCandidates(d, v); const notes: string[] = []; - if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已保守阻止新增下载" : "实例未配置保存路径,无法统计"); + if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计"); if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`); - if (dv.unmanagedGrowthRisk) notes.push("存在未纳管任务仍在增长,暂停纳管任务后磁盘仍可能增长"); + if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长"); const pct = usedPercent(v); volumes.push({ volumeKey: v.volumeKey, diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index d99c7dd3..cae96db1 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -75,8 +75,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) { const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst(); if (!exists) notFound("下载任务"); const r = await userResume(d, params.id); - if (r === "threshold_active") - throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,阈值暂停不能被手动绕过"); + if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复"); return toApi(r); }, { tag: TAG }, diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index 8285a7de..546fc753 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -137,8 +137,8 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { if (block) { throw new ApiException( "conflict", - `来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},` + - `拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`, + `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` + + `待处理事项 ${block.decisionId}`, ); } await audit(d, actorOf(c), "organize.move_authorized", { diff --git a/apps/server/src/modules/acquisition-organize/routes/rules.ts b/apps/server/src/modules/acquisition-organize/routes/rules.ts index bab8900e..0c74f347 100644 --- a/apps/server/src/modules/acquisition-organize/routes/rules.ts +++ b/apps/server/src/modules/acquisition-organize/routes/rules.ts @@ -249,7 +249,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { .where("id", "=", params.id) .executeTakeFirst(); if (!sub) notFound("订阅"); - if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅尚未关联作品,无法按标题搜索"); + if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅未关联作品,无法搜索"); try { return await enqueueSubscriptionSearch(d, sub!.id, "manual"); } catch (err) { @@ -371,7 +371,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { Api.acquisitionSlots.get, async (_c, { params }) => { const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst(); - return toApi(r ?? notFound("收录目标")); + return toApi(r ?? notFound("版本目标")); }, { tag: TAG }, ); @@ -404,7 +404,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { .onConflict((oc) => oc.doNothing()) .returningAll() .executeTakeFirst(); - if (!r) conflict("同一作品/内容单元下已有同名 Slot"); + if (!r) conflict("该作品或内容单元下已有同名版本目标"); await kickEvaluate(d, { acquisitionSlotId: r!.id }); return toApi(r!); }, @@ -439,7 +439,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { .select("revision") .where("id", "=", params.id) .executeTakeFirst(); - if (!exists) notFound("收录目标"); + if (!exists) notFound("版本目标"); conflict(`revision 不匹配(当前 ${exists!.revision})`); } await kickEvaluate(d, { acquisitionSlotId: params.id }); @@ -523,7 +523,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { async (_c, { params, body }) => { const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst(); if (!cur) notFound("策略版本"); - if (cur!.status !== "draft") conflict("只有草案可以修改;已发布版本请新建草案"); + if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案"); if (body.rules !== undefined) checkRules(cur!.kind, body.rules); const r = await db() .updateTable("policy_revision") @@ -670,7 +670,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) { .executeTakeFirst(); if (sup) { // Agent 不得据旧证据自动加回;用户手动(或对话确认)重新添加即解除抑制 - if (origin === "agent_inferred") conflict("该偏好已被用户取消(存在抑制记录),Agent 不能自动加回"); + if (origin === "agent_inferred") conflict("用户已取消该偏好,Agent 不能自动加回"); await trx .updateTable("preference_suppression") .set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) }) diff --git a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts index 638a5caf..d143b5da 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts @@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise { throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p); } if (!roots.some((root) => within(r, root))) - throw new PathGuardError(`源路径不在已登记的媒体库根或下载器路径映射之下: ${p}`, p); + throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下: ${p}`, p); return r; }, }; @@ -205,7 +205,7 @@ export async function authorizeMoveEntities( const req = ((a.content ?? {}) as { request?: Parameters[0] }).request; if (!req || requestKey(req) !== key) continue; if (a.payload_hash !== a.content_hash) { - reasons.push(`审批 ${a.id} 绑定的计划内容已变化`); + reasons.push(`审批 ${a.id} 对应的计划内容已变化`); continue; } // 预检(不消费):执行阶段会再调一次并原子消费 @@ -249,7 +249,7 @@ export async function authorizeMoveEntities( else for (const g of grants) { if (g.policy_revision_id && g.policy_status !== "published") { - why.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`); + why.push(`授权 ${g.id} 的策略版本已不是发布状态`); continue; } const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] }; @@ -280,7 +280,7 @@ export async function authorizeMoveEntities( unauthorized.push({ label: s.label, entityId: null, - reason: "无法确定所属作品,持续授权无从核对范围(只接受一次性审批)", + reason: "无法确定所属作品,只能用一次性审批", }); continue; } @@ -306,8 +306,8 @@ export async function authorizeMoveEntities( unauthorized, reason: detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null) - ? `Move 需要授权:${detail.join(";")}` - : "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批", + ? `移动需要授权:${detail.join(";")}` + : "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批", }; } @@ -339,11 +339,11 @@ export async function hnrBlockForMove( if (hnr === true) continue; const decisionId = await upsertDecision(d, db, { kind: "approval_required", - title: `手动 Move 被做种义务阻止:${dt.name ?? dt.torrent_hash}`, + title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`, summary: hnr === false - ? "来源是纳管下载任务,H&R / 做种要求尚未满足;Move 会删源并移除任务,已拒绝(未改为 Copy)" - : "来源是纳管下载任务,H&R 要求未知,按未满足处理;Move 已拒绝(未改为 Copy)", + ? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)" + : "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)", subjectType: "download_task", subjectId: dt.id, dedupeKey: `manual_move_hnr:${dt.id}`, @@ -356,8 +356,8 @@ export async function hnrBlockForMove( requestedBy: ctx.actor, }, options: [ - { key: "wait", label: "等待做种义务满足后再 Move" }, - { key: "copy", label: "改用 Copy 入库(需重新发起)" }, + { key: "wait", label: "做种满足后再移动" }, + { key: "copy", label: "改用复制(需重新发起)" }, ], }); return { downloadTaskId: dt.id, hnr, decisionId }; diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index ebcb9fe8..f008c39f 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -327,7 +327,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown"); await upsertDecision(d, trx, { kind: hnrConflict ? "approval_required" : "path_conflict", - title: hnrConflict ? `Move 入库被做种义务阻止:${entity.title}` : `整理冲突:${entity.title}`, + title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`, summary: blocking .map((c) => c.detail ?? c.kind) .join(";") @@ -633,10 +633,10 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次 if (fsr.partialMaxAgeMs > 0) @@ -910,13 +910,13 @@ export async function executeRecord( if (outcome.status === "failed" && outcome.error && fsErrorClass(outcome.error.code) === "retry") { await upsertDecision(d, db, { kind: "other", - title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,重试已达上限" : "文件读写失败,重试已达上限", - summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载保留,不重下)`, + title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限", + summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`, subjectType: "organization_record", subjectId: recordId, dedupeKey: `fs_retry_exhausted:${opId}`, evidence: { src: pf.src, dst: pf.dst, error: outcome.error, attempts: outcome.attempt ?? null }, - options: [{ key: "retry", label: "检查挂载后重新执行整理" }], + options: [{ key: "retry", label: "检查挂载后重试" }], }); } // 被替换的旧文件登记进文件管理回收站(可在回收站视图还原/清除);登记失败只告警,旧文件留在 .umt-trash @@ -927,7 +927,7 @@ export async function executeRecord( if (outcome.replaced?.disposition === "left") { await upsertDecision(d, db, { kind: "other", - title: "替换入库后旧文件未能处置", + title: "新版本已入库,旧文件未能处置", summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`, subjectType: "organization_record", subjectId: recordId, @@ -938,7 +938,7 @@ export async function executeRecord( if (outcome.status === "attention") { await upsertDecision(d, db, { kind: "other", - title: "跨卷搬运后删源失败", + title: "跨卷移动后删除源文件失败", summary: `${pf.src} 已复制到目标并校验,源文件未删除`, subjectType: "organization_record", subjectId: recordId, @@ -1168,7 +1168,7 @@ async function transferFile( if (!x.allowCrossVolume) return { status: "failed", - error: { code: "EXDEV", message: "跨卷 Move 未启用(settings.organize.allowCrossVolumeMove)" }, + error: { code: "EXDEV", message: "未启用跨卷移动(settings.organize.allowCrossVolumeMove)" }, }; const r = await px.crossDeviceMove(src, dst, { hash: d.ports.options.verifyHash }, call); const failedStep = r.steps.find((st) => st.status === "failed"); @@ -1469,7 +1469,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { const r = results.find((x) => x.capabilityId === "inventory.query"); const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0; - return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不会提交下载;需要真实执行请在设置里配置 Pi 上游。`; + return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`; }, }, ]; diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index aefa411d..a6bd3e09 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -126,11 +126,10 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) { } function suggestionFor(d: DecisionRow, reason: string): string { - if (d.kind === "identity_ambiguous") - return `${reason}。建议在待处理中心人工选择身份,或发布识别策略授权 identity.resolve。`; + if (d.kind === "identity_ambiguous") return `${reason}。建议在待处理中人工选择身份,或为 identity.resolve 发放授权。`; return ( - `${reason}。建议:在待处理中心选择"强制重新校验"(download.recheck),或为自动恢复发放持续授权` + - `(action=download.recheck / task.retry,可绑定已发布的 automation 策略);换源、删除仍需人工决定。` + `${reason}。建议在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权` + + `(可绑定已发布的 automation 策略);换源、删除仍需人工决定。` ); } @@ -234,9 +233,9 @@ export async function autoInvestigate( dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "", taskId ? `关联任务 ${taskId}。` : "", mode === "recover" - ? "在已授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + + ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。" - : "只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。", + : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", ].join(""); const { run, created } = await startRun(ctx, { goal, @@ -382,14 +381,14 @@ export async function applyRecoveryOutcome( outcome === "recovered" ? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。` : outcome === "failed" - ? `未恢复:执行 ${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/invoke-limits.ts b/apps/server/src/modules/agent/invoke-limits.ts index 68630263..5728774b 100644 --- a/apps/server/src/modules/agent/invoke-limits.ts +++ b/apps/server/src/modules/agent/invoke-limits.ts @@ -31,13 +31,13 @@ export function externalUpstream(capabilityId: string, input: unknown): Upstream export class UpstreamBusyError extends Error { constructor(readonly upstream: Upstream) { - super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,请稍后重试`); + super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,稍后重试`); } } export class UpstreamTimeoutError extends Error { constructor(readonly upstream: Upstream) { - super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s),已放弃等待`); + super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s)`); } } diff --git a/apps/server/src/modules/agent/report.ts b/apps/server/src/modules/agent/report.ts index 7f6eead4..aea7a22a 100644 --- a/apps/server/src/modules/agent/report.ts +++ b/apps/server/src/modules/agent/report.ts @@ -102,10 +102,10 @@ export async function buildGoalReport( const done = records.filter((r) => r.status === "succeeded" || r.status === "partial"); const summary = state === "no_actions" - ? "本次运行没有提交下载或整理;结论见模型汇报(未产生可核查的业务变更)。" + ? "本次没有提交下载或整理,结论见模型汇报。" : [ `已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`, - state === "waiting" ? "已提交≠已入库:仍在等待下载完成与核验入库。" : "", + state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "", done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "", ] .filter(Boolean) diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 81e29859..8c354a40 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -227,7 +227,7 @@ export function registerAgentRoutes( const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst(); if (!cur) throw new ApiException("not_found", "授权不存在"); if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at) - throw new ApiException("validation_failed", "只能缩短有效期;扩大范围请新建授权"); + throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权"); const r = await db() .updateTable("automation_grant") .set({ @@ -302,7 +302,7 @@ export function registerAgentRoutes( .executeTakeFirst(); if (!cur) throw new ApiException("not_found", "审批不存在"); if (cur.payload_hash !== body.payloadHash) - throw new ApiException("conflict", "payloadHash 不一致:计划已变化,请重新申请审批"); + throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批"); const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"]; if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`); return trx @@ -361,16 +361,15 @@ export function registerAgentRoutes( .select(["id", "media_entity_id"]) .where("id", "=", body.sourceReleaseId) .executeTakeFirst(); - if (!rel) throw new ApiException("not_found", "来源资源不存在"); + if (!rel) throw new ApiException("not_found", "资源不存在"); let slotIds: string[]; if (body.slotId) { const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst(); - if (!s) throw new ApiException("not_found", "收录目标(Slot)不存在"); + if (!s) throw new ApiException("not_found", "版本目标不存在"); slotIds = [s.id]; } else { const entityId = body.entityId ?? rel.media_entity_id; - if (!entityId) - throw new ApiException("precondition_failed", "来源资源尚未识别到本地作品,请先在“身份与元数据”指定作品"); + if (!entityId) throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在“身份与元数据”指定作品"); slotIds = ( await db() .selectFrom("acquisition_slot") @@ -382,7 +381,7 @@ export function registerAgentRoutes( } const rejected: { slotId: string | null; code: string; message: string }[] = []; if (slotIds.length === 0) - rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标(Slot),请先在订阅里建目标" }); + rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标,先在订阅里新建" }); const invokeCtx = { actorId, taskId: `http:${c.get("requestId") ?? "req"}`, diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 8d8e9be4..82864773 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -123,7 +123,7 @@ async function agentSettings( const pi = s.pi; let apiKey = "fake-key-not-used"; if (!fake) { - if (!pi.apiKeySecretRef) throw new Error("未配置 Pi 上游密钥(设置 → pi.apiKey)"); + if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游密钥(设置 → pi.apiKey)"); apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef); if (!pi.model) throw new Error("未配置模型(设置 → pi.model)"); } @@ -250,17 +250,16 @@ export async function reconcilePending( function systemPromptFor(run: AgentRunRow, scope: RunScope): string { const constraints: string[] = [ `可用能力:${scope.capabilities.join(", ")}`, - "有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒绝时如实说明缺少哪项授权。", + "有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。", ]; - if (run.mode === "plan_only") - constraints.push("本次为“先给方案”:只调查与规划,不提交下载、不做文件变更、不发布策略。"); + if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。"); if (run.trigger === "event" && run.mode === "execute") constraints.push( - "本任务由后台事件触发,是有界的授权内恢复:先查证据,再只在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," + - "每种恢复动作最多一次;不换源、不删除、不清理;恢复是否成功由系统按业务状态核实,不要自行宣称已恢复。", + "本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," + + "每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。", ); else if (run.trigger === "event") - constraints.push("本任务由后台事件触发,是有界的只读调查:收集证据、给出结论与建议,不要反复重试同一操作。"); + constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。"); return buildSystemPrompt({ role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手", goal: run.goal, diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index b2b7fa01..63451737 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -333,7 +333,7 @@ export async function autoInvestigate( `调查待处理事项 ${d.id}(${d.kind}):${d.title}。`, d.summary ? `摘要:${d.summary}。` : "", d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "", - "只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。", + "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", ].join(""); const { run, created } = await startRun(ctx, { goal, diff --git a/apps/server/src/modules/auth-settings/auth-routes.ts b/apps/server/src/modules/auth-settings/auth-routes.ts index 3f7777ff..cf485013 100644 --- a/apps/server/src/modules/auth-settings/auth-routes.ts +++ b/apps/server/src/modules/auth-settings/auth-routes.ts @@ -136,7 +136,7 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) { endpoint(app, D.password, async ({ body, c, actor }) => { const session = c.get("session"); - if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行"); + if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作"); const revoked = await changePassword(deps(), body, { adminUserId: session.adminUserId, sessionId: session.id, diff --git a/apps/server/src/modules/auth-settings/auth-service.ts b/apps/server/src/modules/auth-settings/auth-service.ts index a3934464..710edff6 100644 --- a/apps/server/src/modules/auth-settings/auth-service.ts +++ b/apps/server/src/modules/auth-settings/auth-service.ts @@ -103,7 +103,7 @@ async function recentFailures(db: Kysely, col: "ip" | "username", value: str export async function assertNotRateLimited(db: Kysely, ip: string, username: string): Promise { const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]); if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) { - throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试"); + throw new ApiException("rate_limited", "失败次数过多,15 分钟后再试"); } } @@ -190,7 +190,7 @@ export async function setupAdmin( const admin = await loadAdmin(deps.db); if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化"); await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" }); - throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)"); + throw new ApiException("unauthorized", "setup token 无效。使用服务端日志里最新打印的那个"); } const admin = await loadAdmin(deps.db); if (!admin) throw new Error("admin_user 行丢失"); @@ -206,7 +206,7 @@ export async function verifyLogin( ): Promise { await assertNotRateLimited(deps.db, ip, input.username); const admin = await loadAdmin(deps.db); - if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化"); + if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化"); const userOk = admin.username === input.username; // 用户名不对也跑一次 argon2,避免用时差探测账号 let pwOk = false; @@ -284,7 +284,7 @@ export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: } export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise { - if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥"); + if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥"); const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); const counter = verifyTotp(secret, code, null); if (counter === null) throw new ApiException("unauthorized", "验证码错误"); diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts index 24bd4d12..dad3f771 100644 --- a/apps/server/src/modules/auth-settings/instances-routes.ts +++ b/apps/server/src/modules/auth-settings/instances-routes.ts @@ -68,7 +68,7 @@ const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500); -const LR = crudDefs("/library-roots", "媒体库根", { +const LR = crudDefs("/library-roots", "库根", { row: LibraryRoot, create: LibraryRootCreate, update: LibraryRootUpdate, diff --git a/apps/server/src/modules/auth-settings/keys-routes.ts b/apps/server/src/modules/auth-settings/keys-routes.ts index 94dd537e..18c76935 100644 --- a/apps/server/src/modules/auth-settings/keys-routes.ts +++ b/apps/server/src/modules/auth-settings/keys-routes.ts @@ -174,7 +174,7 @@ export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) { .where("id", "=", params.id) .returning("id") .executeTakeFirst() - .catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用")); + .catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用")); if (!r) throw new ApiException("not_found", "凭证不存在"); await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id }); return { ok: true as const }; diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index 811b31d6..52e0b0bf 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -122,7 +122,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null { const msg = e instanceof Error ? e.message : String(e); if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`); if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`); - if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `无权限访问(${code})`); + if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`); return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`); } @@ -233,7 +233,7 @@ export async function probeRoot(def: RootDef, timeoutMs: number): Promise { const h = await probeRoot(def, d.options.healthTimeoutMs); if (h.health !== "healthy" || !h.real) - throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health}),不显示为空目录`); + throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health})`); return h.real; } @@ -288,7 +288,7 @@ export async function resolveIn( if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`); throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`); } - if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接逃出根: ${rel}`); + if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外: ${rel}`); const abs = join(parentReal, basename(joined)); let lst: Awaited> | null = null; try { @@ -302,7 +302,7 @@ export async function resolveIn( if (isSymlink) { const target = await realpath(abs).catch(() => null); if (!target || !within(target, rootReal)) - throw new FilesError("path_escape", `符号链接指向根外或无法解析,已禁用操作: ${rel}`); + throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作: ${rel}`); } return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink }; } @@ -316,7 +316,7 @@ export async function recheck(abs: string, rootReal: string): Promise { if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`); throw new FilesError("plan_stale", `源已不存在: ${abs}`); } - if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时发现路径逃出根(符号链接被替换?): ${abs}`); + if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外: ${abs}`); return r; } diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts index 483dcc06..940141b2 100644 --- a/apps/server/src/modules/file-manager/download.ts +++ b/apps/server/src/modules/file-manager/download.ts @@ -43,13 +43,12 @@ export async function downloadFile( ): Promise { // 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御) if (!isAdminSession(principal)) - throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key / DEV key 不可用)"); + throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用"); const r = await resolveIn(d, q.root, q.path); if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录"); const name = basename(r.abs); const kind = kindOfName(name); - if (!ALLOWED.has(kind)) - throw new FilesError("forbidden", `只允许下载 NFO/字幕/图片(${kind}),视频等大文件不经浏览器下载`); + if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`); const real = await recheck(r.abs, r.rootReal); const file = await Deno.open(real, { read: true }); let size: number; diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index a7b1d3c0..11576698 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -156,7 +156,7 @@ export async function identify( if (remoteQueries >= REMOTE_QUERY_MAX) { res = { list: [], - warning: `本次识别的远端查询已达上限 ${REMOTE_QUERY_MAX} 个,该项仅本地召回;可缩小范围后再识别`, + warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`, }; remoteCache.set(key, res); return res; @@ -193,7 +193,7 @@ export async function identify( })); res = { list, warning: null }; } catch (e) { - res = { list: [], warning: `远端候选不可用(仅本地召回):${errMessage(e).slice(0, 200)}` }; + res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` }; } remoteCache.set(key, res); return res; diff --git a/apps/server/src/modules/file-manager/ledger.ts b/apps/server/src/modules/file-manager/ledger.ts index 2a32760f..bbffbe1d 100644 --- a/apps/server/src/modules/file-manager/ledger.ts +++ b/apps/server/src/modules/file-manager/ledger.ts @@ -44,13 +44,12 @@ export async function loadPlan(d: FmDeps, planId: string, op: string, request .select(["id", "content", "content_hash", "expires_at", "title", "kind"]) .where("id", "=", planId) .executeTakeFirst(); - if (row?.kind !== "plan" || row.title !== "files_plan") - throw new FilesError("plan_stale", "planId 不存在,请重新预览"); + if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览"); const content = row.content as StoredPlan["content"]; if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`); if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime()) - throw new FilesError("plan_stale", "计划已过期,请重新预览"); - if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求内容与预览时不一致,请重新预览"); + throw new FilesError("plan_stale", "计划已过期,重新预览"); + if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览"); return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at }; } @@ -101,7 +100,7 @@ export async function idempotent( if (!row) return undefined; const c = row.content as { reqHash: string; state: string; result?: T }; if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求"); - if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试"); + if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); return c.result as T; }; const prev = await replay(); @@ -116,7 +115,7 @@ export async function idempotent( if (!id) { const again = await replay(); if (again !== undefined) return again; - throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试"); + throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); } try { const result = await fn(); diff --git a/apps/server/src/modules/file-manager/mutate.ts b/apps/server/src/modules/file-manager/mutate.ts index 243cc48a..a2c6c621 100644 --- a/apps/server/src/modules/file-manager/mutate.ts +++ b/apps/server/src/modules/file-manager/mutate.ts @@ -16,7 +16,7 @@ const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : ""); function assertCleanName(name: string) { if (sanitizeSegment(name) !== name) - throw new FilesError("bad_request", `文件名含非法字符或首尾空白/点,建议:${sanitizeSegment(name)}`); + throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`); } export async function mkdirIn( @@ -56,8 +56,8 @@ export async function renameEntry( body: FilesRenameRequest, ): Promise { const r = await resolveIn(d, body.root, body.path); - if (r.rel === "") throw new FilesError("bad_request", "不能改名根目录"); - if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 为只读登记(register_only)`); + if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名"); + if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`); assertCleanName(body.newName); const oldName = basename(r.abs); if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同"); @@ -68,7 +68,7 @@ export async function renameEntry( if (hits.length > 0) throw new FilesError( "download_active", - `该路径属于纳管下载任务(${hits.map((h) => h.taskId).join(", ")}),改名会让下载器找不到文件;请先完成整理或移交`, + `该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`, ); const dirAbs = dirname(r.abs); @@ -124,10 +124,10 @@ export async function renameEntry( const before = await lstat(p.fromAbs); const res = await moveSafe(p.fromAbs, p.toAbs); if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`); - if (res.status !== "moved") throw new FilesError("bad_request", `改名失败(${res.status}): ${p.from}`); + if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`); const after = await lstat(p.toAbs); if (after.ino !== before.ino || after.dev !== before.dev) - throw new FilesError("bad_request", `改名后 inode 不一致,已中止: ${p.to}`); + throw new FilesError("bad_request", `重命名后 inode 不一致,已中止: ${p.to}`); done.push(p); } catch (e) { await rollback(); diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index 41ce943a..c8d3d598 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -126,7 +126,7 @@ export async function organizeFiles( if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`); const action = it.operation ?? body.action; if (action === "move" && !r.def.modifiable) - throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`); + throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`); roots.add(it.root); const isDir = !!r.lst?.isDirectory(); const kind = entities.get(it.mediaEntityId); @@ -173,10 +173,10 @@ export async function organizeFiles( if (slotId) { const s = slots.get(slotId); if (!s || s.media_entity_id !== it.mediaEntityId) { - warnings.push(`${r.rel}: Slot ${slotId} 不属于该作品,未应用`); + warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`); slotId = null; } else if (BUSY_SLOT.has(s.status)) { - warnings.push(`${r.rel}: Slot 正在获取中(${s.status}),不改其状态`); + warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`); slotId = null; } } @@ -313,7 +313,7 @@ export async function organizeFiles( const again = await planImport(d.ao, importReq(true)); const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst })); if (hashJson(nowPairs) !== hashJson(frozen.pairs)) - throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览"); + throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览"); if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`); if (g.action === "move") { @@ -329,7 +329,7 @@ export async function organizeFiles( if (block) throw new FilesError( "download_active", - `来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`, + `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`, ); } await retirePlan(d, planId); @@ -415,6 +415,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise< .where("title", "=", "files_plan") .execute(); const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash); - if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,请重新预览"); + if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览"); return hit.id; } diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts index d2ca922f..856ff7dc 100644 --- a/apps/server/src/modules/file-manager/probe.ts +++ b/apps/server/src/modules/file-manager/probe.ts @@ -117,7 +117,7 @@ export async function scrape( .where("media_entity_id", "=", e.id) .where("status", "=", "active") .executeTakeFirst(); - if (!registered) throw new FilesError("bad_request", "作品还没有已登记资产;未登记文件请先识别并整理入库,再刮削"); + if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削"); const { runId } = await d.ctx.engine.enqueue( "refreshMetadata", { mediaEntityId: e.id }, diff --git a/apps/server/src/modules/file-manager/transfer.ts b/apps/server/src/modules/file-manager/transfer.ts index 705a423f..3fbc3722 100644 --- a/apps/server/src/modules/file-manager/transfer.ts +++ b/apps/server/src/modules/file-manager/transfer.ts @@ -95,17 +95,16 @@ export async function transfer( const t = await resolveIn(d, body.target.root, body.target.path); if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录"); if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal))) - throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`); + throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`); const sources: Resolved[] = []; for (const it of body.items) { const s = await resolveIn(d, it.root, it.path); - if (s.rel === "") throw new FilesError("bad_request", "不能移动/复制根目录本身"); - if (mode === "move" && !s.def.modifiable) - throw new FilesError("not_writable", `源根 ${s.def.name} 为只读登记,不能移动(可复制)`); + if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制"); + if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`); if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`); if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`); - if (s.isSymlink) throw new FilesError("bad_request", `不移动符号链接本身: ${s.rel}`); + if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`); sources.push(s); } const names = sources.map((s) => basename(s.abs)); @@ -149,7 +148,7 @@ export async function transfer( const reason = planned.reason ?? "plan_failed"; throw new FilesError( reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request", - `无法生成搬运计划:${reason}`, + `无法生成计划:${reason}`, ); } const planFiles = planned.plan.organize.files; @@ -190,9 +189,9 @@ export async function transfer( const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove; const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice); const warnings: string[] = []; - if (crossBlocked) warnings.push("跨卷移动未开启(设置 → 整理 → 允许跨卷 Move),执行会被拒绝;可改用复制"); + if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启"); if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`); - if (truncated) warnings.push("源文件数超过上限,请分批操作"); + if (truncated) warnings.push("源文件数超过上限,分批操作"); const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings }; if (body.dryRun) { @@ -219,19 +218,19 @@ export async function transfer( throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`); } if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs)) - throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标已变化),请重新预览"); - if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作"); + throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览"); + if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作"); if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`); if (crossBlocked) throw new FilesError( "bad_request", - "跨卷移动未开启(settings.organize.allowCrossVolumeMove),请改用复制或开启后重试", + "未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试", ); if (mode === "move") { if (hnrBlocks.length > 0) throw new FilesError( "download_active", - `源属于纳管下载任务(${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`, + `源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`, ); if (!isAdmin(principal)) { // 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项 @@ -273,7 +272,7 @@ export async function transfer( } if (!rec.recordId || rec.status === "failed") { await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status }); - throw new FilesError("bad_request", `搬运计划不可执行:${rec.reason ?? rec.status}`); + throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`); } const recordId = rec.recordId; await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute(); diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index b410abc9..a74e309b 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -155,13 +155,13 @@ const BLOCK_ERR: Record< NonNullable, [ConstructorParameters[0], string] > = { - download_active: ["download_active", "属于纳管下载任务(未完成或做种义务未满足)"], - is_root: ["bad_request", "不能删除根本身或只读登记根下的内容"], + download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"], + is_root: ["bad_request", "不能删除根目录或只读根下的内容"], needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"], needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"], - root_unavailable: ["root_unavailable", "根不可用,不当作已删除"], + root_unavailable: ["root_unavailable", "根不可用"], not_found: ["not_found", "不存在"], - escape: ["path_escape", "路径逃出根"], + escape: ["path_escape", "路径指向根外"], }; export async function deleteFiles( @@ -171,7 +171,7 @@ export async function deleteFiles( ): Promise { if (body.mode === "permanent") { if (!d.options.allowPermanentDelete) - throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站"); + throw new FilesError("permanent_disabled", "未开启永久删除(FILES_ALLOW_PERMANENT_DELETE)。改用回收站"); if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话"); } const downloads = await managedDownloads(d); @@ -276,8 +276,8 @@ export async function deleteFiles( for (const s of slots) await upsertDecision(d.ao, trx, { kind: "other", - title: `Slot 满足资产已被删除:${s.name}`, - summary: "文件管理删除了满足该 Slot 的资产(进回收站或永久删除),Slot 退回 attention,请确认是否重新获取", + title: `版本目标的文件已删除:${s.name}`, + summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取", subjectType: "acquisition_slot", subjectId: s.id, dedupeKey: `files_delete_slot:${s.id}`, @@ -396,7 +396,7 @@ async function moveToTrash( if (check.size > d.options.trashCrossMaxBytes) throw new FilesError( "bad_request", - `无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes};请使用永久删除`, + `无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`, ); await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined); trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path); @@ -487,10 +487,10 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise throw e; }); if (st?.isSymbolicLink()) - throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,拒绝`); + throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`); if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`); if (!st) await mkdir(cur); - if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径逃出根: ${rel}`); + if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外: ${rel}`); } } const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1)); @@ -509,7 +509,7 @@ export async function restoreTrash( throw new FilesError("bad_request", "还原目标必须是已存在的目录"); // "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5) if (target && !(await isWritableTarget(target.def, target.rootReal))) - throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写(仅 managed 库根可作为目标)`); + throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`); for (const id of body.ids) { try { const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst(); @@ -547,7 +547,7 @@ export async function restoreTrash( if (bytes > d.options.trashCrossMaxBytes) throw new FilesError( "too_large", - `跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes};请在同卷还原,或手工处理 ${row.trash_path}`, + `跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`, ); await copyTree(d, row.trash_path, dst, files); await rm(row.trash_path, { recursive: true }); diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts index f27fdf53..3d425bb0 100644 --- a/apps/server/src/modules/sources-identity/capabilities.ts +++ b/apps/server/src/modules/sources-identity/capabilities.ts @@ -107,7 +107,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () .selectAll() .where("id", "=", input.sourceReleaseId) .executeTakeFirst(); - if (!row) throw new Error("来源资源不存在"); + if (!row) throw new Error("资源不存在"); // 库存(media_asset)归 W2-C,这里只按作品只读查询 const inventory = row.media_entity_id ? await r.ctx.db @@ -130,7 +130,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () .executeTakeFirst(); srId = o?.source_release_id ?? null; } - if (!srId) throw new Error("找不到来源资源"); + if (!srId) throw new Error("资源不存在"); const actor = { type: "agent" as const, id: ctx.actorId }; const c = input.candidate; if (c?.mediaEntityId || (c?.provider && c.entityType && c.externalId)) { diff --git a/apps/server/src/modules/sources-identity/defs.ts b/apps/server/src/modules/sources-identity/defs.ts index ba0e407b..f6f8da2c 100644 --- a/apps/server/src/modules/sources-identity/defs.ts +++ b/apps/server/src/modules/sources-identity/defs.ts @@ -157,7 +157,7 @@ export const D = { sourceReleases: { list: list( "/source-releases", - "站点资源列表", + "资源列表", SourceRelease, { siteId: Id.optional(), @@ -167,7 +167,7 @@ export const D = { }, TR, ), - get: get("/source-releases/{id}", "站点资源详情", SourceRelease, TR), + get: get("/source-releases/{id}", "资源详情", SourceRelease, TR), }, observations: { list: list( diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index b57fb259..6e0f7410 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -129,7 +129,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .where("source_release_id", "=", d.subject_id) .executeTakeFirst(); if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) { - throw new ApiException("conflict", "候选已变化,请刷新后重试"); + throw new ApiException("conflict", "候选已变化,刷新后重试"); } await assignReleaseIdentity( rt(), diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index 3f22173c..55785aa2 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -521,7 +521,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }); route(app, D.sourceReleases.get, async ({ params }) => { const row = await db().selectFrom("source_release").selectAll().where("id", "=", params.id).executeTakeFirst(); - if (!row) throw new ApiException("not_found", "来源资源不存在"); + if (!row) throw new ApiException("not_found", "资源不存在"); return toApi(row); }); route( @@ -530,7 +530,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { method: "post", path: "/source-releases/{id}/identity", scope: "organize.execute", - summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)", + summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)", tags: T_RELEASES, params: IdParam, body: ReleaseIdentityAssign, @@ -544,7 +544,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { method: "post", path: "/source-releases/{id}/unlock", scope: "organize.execute", - summary: "解除来源资源的人工锁并重新识别", + summary: "解除资源的人工锁并重新识别", tags: T_RELEASES, params: IdParam, response: Accepted, @@ -556,7 +556,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .where("id", "=", params.id) .returning(["id", "site_id"]) .executeTakeFirst(); - if (!row) throw new ApiException("not_found", "来源资源不存在"); + if (!row) throw new ApiException("not_found", "资源不存在"); await audit(db(), actor, "identity.release_unlocked", { type: "source_release", id: params.id }); const r = await rt().ctx.engine.enqueue( "resolveObservation", diff --git a/apps/server/src/modules/sources-identity/routes/workbench.ts b/apps/server/src/modules/sources-identity/routes/workbench.ts index 2e9979c2..23533241 100644 --- a/apps/server/src/modules/sources-identity/routes/workbench.ts +++ b/apps/server/src/modules/sources-identity/routes/workbench.ts @@ -17,7 +17,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) { method: "get", path: "/sites/{id}/releases", scope: "read", - summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", + summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", tags: T, params: z.object({ id: Id }), query: WorkbenchReleaseQuery, @@ -31,7 +31,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) { method: "get", path: "/sites/{id}/releases/{rid}", scope: "read", - summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)", + summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)", tags: T, params: z.object({ id: Id, rid: Id }), query: z.object({ refresh: z.enum(["true", "false"]).optional() }), diff --git a/apps/server/src/modules/sources-identity/service/feeds.ts b/apps/server/src/modules/sources-identity/service/feeds.ts index 1bcada56..121b6551 100644 --- a/apps/server/src/modules/sources-identity/service/feeds.ts +++ b/apps/server/src/modules/sources-identity/service/feeds.ts @@ -209,14 +209,14 @@ export async function ingestFeed( kind: "source_unreachable", severity: "warning", title: `Feed「${feed.name}」凭证失效`, - summary: "RSS 返回 401/403 或过期提示;已停止自动重试。请更新 Feed 链接或站点账号凭证后手动恢复。", + summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。", subjectType: "feed_source", subjectId: feed.id, dedupeKey: `feed-auth:${feed.id}`, evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError }, options: [ { key: "reset", label: "已更新凭证,恢复抓取" }, - { key: "disable", label: "停用该 Feed" }, + { key: "disable", label: "停用 Feed" }, ], }); out = { ...out, status: "auth_failed", reason: "auth" }; diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index 9023cea2..fb9ecd25 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -64,7 +64,7 @@ export async function assignReleaseIdentity( ): Promise<{ mediaEntityId: string; contentUnitIds: string[] }> { const { ctx } = rt; const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst(); - if (!release) throw new ApiException("not_found", "来源资源不存在"); + if (!release) throw new ApiException("not_found", "资源不存在"); const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}); const site = ((release.raw as Record)?._site ?? {}) as { categoryName?: string }; const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw); @@ -241,7 +241,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in if (!id) throw new ApiException("not_found", "作品不存在"); const other = await entityByExternal(trx, input); if (other && other !== id) { - throw new ApiException("conflict", `该外部身份已属于作品 ${other},如为重复作品请使用合并`); + throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`); } if (other === id) return { mediaEntityId: id, changed: false }; const olds = await trx diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 0af3e7ac..2b910265 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -557,7 +557,7 @@ async function toPool( kind: "identity_ambiguous", severity: "info", title: `待确认身份:${release.title}`, - summary: `${candidates.length} 个候选证据等级相近(${detail}),请选择或忽略`, + summary: `${candidates.length} 个候选证据等级相近(${detail})。选择一个或忽略`, subjectType: "source_release", subjectId: release.id, dedupeKey: `identity:${release.id}`, diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index aef6f370..cb7c465e 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -85,7 +85,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site kind: "source_unreachable", severity: "warning", title: `站点「${site.name}」登录失效`, - summary: `${e.kind}:${e.message}。已停止该站自动访问,请更新账号凭证后恢复。`, + summary: `${e.kind}:${e.message}。已停止自动访问该站点。更新账号凭证后恢复访问。`, subjectType: "site", subjectId: site.id, dedupeKey: `site-auth:${site.id}`, diff --git a/apps/server/test/file-manager/w5a2.test.ts b/apps/server/test/file-manager/w5a2.test.ts index 1b9772a0..c38adb1f 100644 --- a/apps/server/test/file-manager/w5a2.test.ts +++ b/apps/server/test/file-manager/w5a2.test.ts @@ -72,7 +72,7 @@ describe.skipIf(skip)("files W5-A2", () => { await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: false })}::jsonb) on conflict (key) do update set value = excluded.value`.execute(h.db); const off = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }); - expect(off.body.data.warnings.join()).toMatch(/跨卷移动未开启/); + expect(off.body.data.warnings.join()).toMatch(/未开启跨卷移动/); const offExec = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), diff --git a/apps/web/app/api/parse.ts b/apps/web/app/api/parse.ts index dfa498ad..2ddb4815 100644 --- a/apps/web/app/api/parse.ts +++ b/apps/web/app/api/parse.ts @@ -13,7 +13,7 @@ export async function apiParsed(schema: S, path: string, ar const issue = parsed.error.issues[0]; throw new ApiRequestError( "internal" satisfies ApiErrorCode, - `响应格式与合同不符:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`, + `响应格式不符合接口定义:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`, ); } return parsed.data; diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 7f88e20a..2921b25e 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -22,7 +22,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord); const DecisionPage = cursorPage(Decision); const RESOLUTION_LABEL = { - unresolved: "未识别", + unresolved: "待识别", resolved: "已识别", ambiguous: "有歧义,待确认", unidentifiable: "无法识别", @@ -101,7 +101,7 @@ const TASK_KIND_LABEL: Partial> = { verifyAndImport: "核验入库", evaluateAcquisition: "评估候选", cleanupDownloadCache: "清理缓存", - resolveDecision: "处理待办", + resolveDecision: "处理待处理事项", }; const TASK_STATUS_TONE: Record = { @@ -188,7 +188,7 @@ export function buildTimeline(input: { key: "dl-pause", stage: "download", at: task.updatedAt, - title: task.pauseReason === "threshold" ? "缓存达到硬阈值,系统暂停" : `已暂停(${task.pauseReason})`, + title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`, tone: "warning", ongoing: task.status === "paused", }); @@ -197,7 +197,7 @@ export function buildTimeline(input: { key: "dl-stall", stage: "download", at: task.noProgressSince, - title: "无进展(只记录,不自动换源)", + title: "无进展,不自动换源", tone: "warning", ongoing: task.status === "stalled", }); @@ -227,7 +227,7 @@ export function buildTimeline(input: { key: `rec-${r.id}`, stage: "organization", at: r.startedAt ?? r.createdAt, - title: `整理 ${r.action === "copy" ? "Copy" : r.action === "move" ? "Move" : r.action}:${r.status}`, + title: `整理 ${r.action === "copy" ? "复制" : r.action === "move" ? "移动" : r.action}:${r.status}`, detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`, tone: RECORD_TONE[r.status], href: `/console/organize/${r.id}`, diff --git a/apps/web/app/components/console/account-menu.tsx b/apps/web/app/components/console/account-menu.tsx index df33927e..2f5d6471 100644 --- a/apps/web/app/components/console/account-menu.tsx +++ b/apps/web/app/components/console/account-menu.tsx @@ -147,14 +147,14 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b try { if (enabled) { if (!password) { - setError("请输入当前密码"); + setError("输入当前密码"); return; } await action.mutateAsync({ action: "disable", password, code }); toast.success("已关闭二次验证"); } else { await action.mutateAsync({ action: "enable", code }); - toast.success("已开启二次验证,下次登录需要输入验证码"); + toast.success("已开启二次验证"); } reset(); onOpenChange(false); @@ -174,8 +174,8 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b {status.isLoading ? "读取状态…" : enabled - ? "已开启:登录时需要验证器里的 6 位验证码。关闭需要当前密码和验证码。" - : "未开启:开启后登录除密码外还需要验证器(如 1Password、Google Authenticator)里的 6 位验证码。"} + ? "已开启。关闭需要当前密码和验证码。" + : "未开启。开启后登录还要输入验证器里的 6 位验证码。"} {!enabled && !setup ? ( @@ -190,8 +190,7 @@ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: b {setup ? (

- 在验证器里选“手动输入密钥”,填入下面的密钥(基于时间,6 位);或把 otpauth - 链接粘贴到支持的验证器。密钥只显示这一次。 + 在验证器里手动输入下面的密钥,或粘贴 otpauth 链接。密钥只显示这一次。

{grouped} diff --git a/apps/web/app/components/console/api-key-dialog.tsx b/apps/web/app/components/console/api-key-dialog.tsx index 2d13339e..2c4ff19b 100644 --- a/apps/web/app/components/console/api-key-dialog.tsx +++ b/apps/web/app/components/console/api-key-dialog.tsx @@ -46,7 +46,7 @@ export function ApiKeyDialog() { setValue(e.target.value)} /> diff --git a/apps/web/app/components/console/workspace-placeholder.tsx b/apps/web/app/components/console/workspace-placeholder.tsx index 5b68b1d7..189e3b69 100644 --- a/apps/web/app/components/console/workspace-placeholder.tsx +++ b/apps/web/app/components/console/workspace-placeholder.tsx @@ -11,11 +11,7 @@ export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string return ( <> - + ); } diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index d8e24671..5eb92e62 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -25,7 +25,7 @@ export const TASK_KIND_LABEL: Record = { refreshMetadata: "刷新元数据", reconcileLibrary: "资产对账", cleanupDownloadCache: "清理缓存", - resolveDecision: "处理待办", + resolveDecision: "处理待处理事项", agentRun: "Agent 运行", }; @@ -76,7 +76,7 @@ export function BusinessTasks() { body: op === "cancel" ? { reason: "控制台取消" } : {}, }).then((d) => Task.parse(d)), onSuccess: (_t, v) => { - toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队"); + toast.success(v.op === "cancel" ? "已请求取消,后续步骤不再执行" : "已重新排队"); void qc.invalidateQueries({ queryKey: ["tasks"] }); }, onError: (e) => toast.error(errorMessage(e)), @@ -218,7 +218,7 @@ export function BusinessTasks() { empty={{ icon: ListTodo, title: kind || status ? "没有符合筛选的任务" : "还没有业务任务", - description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。", + description: "提交下载、核验入库、清理缓存、对账等后台任务会出现在这里。", }} /> diff --git a/apps/web/app/components/downloads/business-timeline.tsx b/apps/web/app/components/downloads/business-timeline.tsx index 55b20cfd..d9c5003d 100644 --- a/apps/web/app/components/downloads/business-timeline.tsx +++ b/apps/web/app/components/downloads/business-timeline.tsx @@ -61,7 +61,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[]; /> )} {loading && events.length ? ( -

正在补全关联的整理记录与引擎 run…

+

正在加载关联的整理记录与引擎运行…

) : null}
); diff --git a/apps/web/app/components/downloads/cache-volumes.tsx b/apps/web/app/components/downloads/cache-volumes.tsx index 1cddd9f5..b65b2339 100644 --- a/apps/web/app/components/downloads/cache-volumes.tsx +++ b/apps/web/app/components/downloads/cache-volumes.tsx @@ -63,8 +63,8 @@ function VolumeBar({ v }: { v: Volume }) { 保留锁定 {formatBytes(v.retentionLockedBytes, 1)}
- {pct === null ? 读不到磁盘状态:已保守阻止新提交 : null} - {tripped ? 已达硬阈值:停止新提交 : null} + {pct === null ? 读不到磁盘状态,已暂停新提交 : null} + {tripped ? 已达硬阈值,停止新提交 : null} {v.pausedByThreshold ? 阈值暂停 {v.pausedByThreshold} 个任务 : null}
{v.notes.map((n) => ( @@ -93,12 +93,12 @@ export function CacheVolumes() { } title="按规则清理下载缓存?" - description="只清理已核验入库、且满足保留期与 H&R 的纳管任务;未纳管任务与未满足保留的不会动。" + description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。" confirmLabel="清理" onConfirm={() => cleanup.mutateAsync(undefined).then( (r) => { - toast.success(`已排队清理任务 ${r.taskId.slice(0, 8)}`, { description: "后台执行,结果见任务详情" }); + toast.success(`已排队清理任务 ${r.taskId.slice(0, 8)}`, { description: "结果见任务详情" }); navigate(`/console/engine/${r.runId}`); }, (e) => toast.error(errorMessage(e)), @@ -111,7 +111,7 @@ export function CacheVolumes() { ) : vols.error ? ( void vols.refetch()} /> ) : !vols.data.length ? ( -

还没有下载器缓存卷的统计(未配置下载器或尚未巡检)。

+

还没有缓存卷数据。配置下载器并完成巡检后显示。

) : (
{vols.data.map((v) => ( diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index 9d7e5b95..4128224b 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -66,14 +66,14 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | variant="outline" size={size} disabled={act.isPending} - title={threshold ? "阈值暂停:缓存回落到恢复水位后由系统自动恢复" : undefined} + title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined} onKeyDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); act.mutate( { id: t.id, action: canResume ? "resume" : "pause" }, { - onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停(记为用户暂停,阈值恢复不会自动恢复它)"), + onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。阈值回落时不会自动恢复"), onError: (err) => toast.error(errorMessage(err)), }, ); @@ -128,13 +128,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { {task.pauseReason === "threshold" ? (

- 缓存卷达到硬阈值,系统暂停了这个纳管任务。水位回落到恢复线后自动恢复;手动恢复、优先级或 Agent - 推荐都不能绕过阈值。 + 缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。

) : null} {task.noProgressSince ? (

- 自 {formatDateTime(task.noProgressSince)} 起无进展。系统只记录证据并汇入待处理,不会自动换源。 + 自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。

) : null} diff --git a/apps/web/app/components/downloads/organize-dialog.tsx b/apps/web/app/components/downloads/organize-dialog.tsx index 68d5784f..e42a623a 100644 --- a/apps/web/app/components/downloads/organize-dialog.tsx +++ b/apps/web/app/components/downloads/organize-dialog.tsx @@ -44,9 +44,7 @@ function PlanPreview({ planId }: { planId: string }) { return (

计划已生成(planId {planId.slice(0, 8)}…), - {isMissingEndpoint(art.error) - ? "计划内容不存在或已过期,请重新预览。" - : `读取计划失败:${errorMessage(art.error)}`} + {isMissingEndpoint(art.error) ? "计划已过期,重新预览。" : `读取计划失败:${errorMessage(art.error)}`}

); } @@ -65,7 +63,7 @@ function PlanPreview({ planId }: { planId: string }) {
{org.operation} - {org.crossDevice ? 跨卷:Copy → 核验 → 删源 : null} + {org.crossDevice ? 跨卷:复制 → 核验 → 删源 : null} {org.requiresApproval ? 需要审批 : null} {blocking.length ? 阻塞冲突 {blocking.length} : null}
@@ -155,7 +153,7 @@ export function OrganizeDialog({ const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths")); setErrors({ ...e, - paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径(每行一个绝对路径)" : "", + paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径" : "", }); return; } @@ -165,8 +163,8 @@ export function OrganizeDialog({ if (dryRun) { setPlanId(r.planId); if (r.truncated) - toast.warning("源目录文件数超过上限,预览只含前一部分文件", { - description: "正式执行会被拒绝,请把源路径缩小到具体的作品目录", + toast.warning("源目录文件过多,预览只含前一部分", { + description: "执行会被拒绝。把源路径缩小到具体的作品目录", }); } else { toast.success("已发起整理"); @@ -183,9 +181,7 @@ export function OrganizeDialog({ 手动整理 - - 先预览计划(不动文件),确认后再执行。Move 跨卷时按 Copy → 核验 → 删源执行。 - + 先预览计划(不动文件),确认后执行。跨卷移动按复制 → 核验 → 删源执行。
{downloadTaskId ? ( @@ -266,7 +262,7 @@ export function OrganizeDialog({ diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 3c0ecb47..136a6ba1 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -220,7 +220,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: onSuccess: (r) => { const it = r.items[0]; const c = it?.candidates[0]; - if (!it || !c) return toast.info("没有可信的识别结果,无法给出建议名"); + if (!it || !c) return toast.info("识别结果不可信,没有建议名"); setName(suggestName(c, it.parsed, entry.name, isDir)); setErr(null); }, @@ -255,7 +255,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: reason === "exists" ? "目标名已存在,不会覆盖" : reason === "download_active" - ? "做种中,改名会让下载器找不到文件" + ? "正在做种,改名会让下载器找不到文件" : filesErrorText(e), ); }, @@ -325,14 +325,10 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: ))} {pv.assetLocations > 0 ? ( - - 将同步 {pv.assetLocations} 条资产位置,并写一条整理记录(action=rename)。 - + 将同步 {pv.assetLocations} 条资产位置,并写一条整理记录。 ) : null} {pv.bindings.length > 0 ? ( - - 这是作品/季的绑定目录:将新建绑定并把旧绑定标记为已替代,之后新集入库进入新目录。 - + 这是作品/季的绑定目录。改名后旧绑定标为已替代,新集入库到新目录。 ) : null} ) : null} @@ -422,7 +418,7 @@ export function TransferDialog({ onError: (e) => { const reason = filesErrorReason(e); if (reason === "plan_stale") { - setErr("源文件在预览后发生了变化,已重新生成预览,请再确认一次。"); + setErr("源文件在预览后有变化,已重新预览。确认后再执行。"); runPreview(target, mode); } else setErr(filesErrorText(e)); }, @@ -437,7 +433,7 @@ export function TransferDialog({ {mode === "move" ? "移动到…" : "复制到…"} {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · - 先预览前后路径、冲突与空间,确认后才执行; 统一走可恢复的整理计划,跨卷为 Copy → 核验 → 删源。 + 先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。
@@ -475,7 +471,7 @@ export function TransferDialog({ }} />
-

只有可写的媒体库根能作为目标。

+

只有可写的库根能作为目标。

{target && targetRoot ? ( @@ -512,7 +508,7 @@ export function TransferDialog({
{hnrBlocked ? ( - {plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能 Move。可改用“复制”,或等 H&R 满足后再移动。 + {plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 ); @@ -386,8 +385,8 @@ export function OrganizeWizard({
-

命名:按系统设置里的命名模板重命名;已绑定的作品/季沿用原目录。

-

目标已存在:不覆盖,列为冲突进待处理。

+

命名:按系统设置的命名模板;已绑定的作品/季沿用原目录。

+

目标已存在:不覆盖,作为冲突进入待处理。

) : null} @@ -446,9 +445,7 @@ export function OrganizeWizard({ ); })} -

- 整理在后台执行(逐文件可恢复),完成后列表会自动刷新状态。 -

+

整理在后台执行,完成后列表自动刷新。

) ) : null} @@ -568,7 +565,7 @@ function GroupCard({ g, title }: { g: FilesOrganizeGroup; title?: Entity }) { {g.error ? (

- {g.error.message}(返回第 1 步修改后重新预览) + {g.error.message}(回到第 1 步修改后重新预览)

) : null} {g.warnings.map((w) => ( @@ -714,7 +711,7 @@ function SlotSelect({ disabled={!entityId} className="h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50" > - + {items.map((s) => (