feat: 功能清单进智能体提示,补字幕按功能模块约定改为样板

- 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应;
  系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的)
- 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务),
  写入字幕后在同一事务发 subtitle.written;原有行为不变

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 19:27:14 +08:00
1 parent 1db689d9a7
commit 7aa5182f3d
14 files changed
+237 -17

No files matched your search

+2
View File
@@ -1,3 +1,4 @@
import { featureManifest } from "@umt/contracts";
import { sourcesIdentityModule } from "../sources-identity/index.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerBrowserCapabilities } from "./capabilities.ts";
@@ -36,6 +37,7 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu
};
return {
name: "browser",
manifest: featureManifest("browser"),
service,
registerRoutes(app, ctx) {
registerBrowserRoutes(app, () => service(ctx));
+2
View File
@@ -1,3 +1,4 @@
import { featureManifest } from "@umt/contracts";
import { browserServiceFor } from "../browser/index.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
@@ -11,6 +12,7 @@ export function createDesktopModule(): ServerModule {
let closeAll: (() => void) | null = null;
return {
name: "desktop",
manifest: featureManifest("desktop"),
registerRoutes(app, ctx: ServerContext) {
const r = registerDesktopRoutes(app, {
upstream: desktopUpstream({
+2
View File
@@ -1,3 +1,4 @@
import { featureManifest } from "@umt/contracts";
import denoJson from "../../../deno.json" with { type: "json" };
import type { ServerModule } from "../types.ts";
import { createMcpConfigStore } from "./config-store.ts";
@@ -13,6 +14,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule &
const table = new McpSessionTable();
return {
name: "mcp",
manifest: featureManifest("mcp"),
sessions: table,
registerRoutes(app, ctx) {
registerMcpRoutes(app, ctx, {
@@ -1,3 +1,4 @@
import { featureManifest } from "@umt/contracts";
import type { ServerContext, ServerModule } from "../types.ts";
import { builtinAgentCheckDefs } from "./agent-checks.ts";
import { registerScheduleCapabilities } from "./capabilities.ts";
@@ -26,6 +27,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
let scheduler: Scheduler | null = null;
return {
name: "schedules",
manifest: featureManifest("schedules"),
scheduler: () => scheduler,
registerRoutes(app, ctx) {
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
@@ -0,0 +1,28 @@
import { type EventPayload, parseEventPayload } from "@umt/contracts";
import type { OutboxRouteFn } from "@umt/tasks";
import type { ServerContext } from "../types.ts";
// 补字幕订阅的生命周期事件(ADR-006 功能模块样板)。
// 「入库后自动补字幕」还没做(docs/plan/subtitles.md):这里只认出值得补字幕的整理,记一条调试日志,不建任务。
// 以后做自动补字幕时把 null 换成 { task: "...", input } 即可,订阅关系不用再改。
const IMPORT_ACTIONS = new Set(["copy", "move", "in_place"]);
/** 整理结束事件里值得补字幕的:成功或部分成功、入库类动作(复制 / 移动 / 原地入库)、认到了作品 */
export function subtitleCandidate(
p: EventPayload<"organization.finished">,
): { recordId: string; mediaEntityId: string } | null {
if (p.outcome === "failed" || !IMPORT_ACTIONS.has(p.action) || !p.mediaEntityId) return null;
return { recordId: p.recordId, mediaEntityId: p.mediaEntityId };
}
export function subtitlesOutboxRoutes(ctx: Pick<ServerContext, "logger">): Record<string, OutboxRouteFn> {
return {
"organization.finished": (ev) => {
const p = parseEventPayload("organization.finished", ev.payload);
const c = p ? subtitleCandidate(p) : null;
if (c) ctx.logger.debug(c, "补字幕:整理已完成,自动补字幕还没做,跳过");
return null;
},
};
}
+15 -2
View File
@@ -1,9 +1,16 @@
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
import {
Api,
featureManifest,
type SubtitleApplyRequest,
type SubtitleFetchRequest,
type SubtitleSource,
} from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import { route } from "../agent/http.ts";
import { human } from "../browser/routes.ts";
import { sourcesIdentityModule } from "../sources-identity/index.ts";
import type { ServerModule } from "../types.ts";
import { subtitlesOutboxRoutes } from "./events.ts";
import {
downloadFromSession,
downloadOnline,
@@ -13,10 +20,15 @@ import {
} from "./online/index.ts";
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
/**
* 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销。
* 功能模块样板(ADR-006):manifest 进功能清单(给智能体),订阅 organization.finished(events.ts),
* 写入后发 subtitle.written;前端经插槽挂在作品页操作区与整理记录行菜单(apps/web/app/features/subtitles.tsx)。
*/
export function createSubtitlesModule(): ServerModule {
return {
name: "subtitles",
manifest: featureManifest("subtitles"),
registerRoutes(app, ctx) {
const tag = { tag: "subtitles" };
const rt = () => sourcesIdentityModule.runtime(ctx);
@@ -69,6 +81,7 @@ export function createSubtitlesModule(): ServerModule {
route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag);
},
registerTasks() {},
outboxRoutes: (ctx) => subtitlesOutboxRoutes(ctx),
};
}
+26 -11
View File
@@ -1,15 +1,16 @@
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type {
SubtitleApplyRequest,
SubtitleApplyResult,
SubtitleLanguage,
SubtitleParsedItem,
SubtitleParseResponse,
SubtitleSource,
SubtitleTarget,
SubtitleTargetsResponse,
SubtitleWriteView,
import {
lifecycleKey,
type SubtitleApplyRequest,
type SubtitleApplyResult,
type SubtitleLanguage,
type SubtitleParsedItem,
type SubtitleParseResponse,
type SubtitleSource,
type SubtitleTarget,
type SubtitleTargetsResponse,
type SubtitleWriteView,
} from "@umt/contracts";
import type { SubtitleWriteTable } from "@umt/db";
import {
@@ -259,7 +260,7 @@ export async function applySubtitles(
})
.execute();
}
return await trx
const w = await trx
.insertInto("subtitle_write")
.values({
id,
@@ -277,6 +278,20 @@ export async function applySubtitles(
})
.returningAll()
.executeTakeFirstOrThrow();
// 生命周期广播(ADR-006):字幕已写入
await ctx.outbox.emit(
trx,
"subtitle.written",
{
writeId: w.id,
mediaEntityId: w.media_entity_id,
videoLocationId: t.locationId,
language: w.language as SubtitleLanguage,
source: w.source as SubtitleSource,
},
{ idempotencyKey: lifecycleKey("subtitle.written", w.id) },
);
return w;
});
written.push(writeView(row));
} catch (e) {
@@ -1,3 +1,4 @@
import { featureManifest } from "@umt/contracts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerWebSearchCapabilities } from "./capabilities.ts";
import { registerWebSearchRoutes } from "./routes.ts";
@@ -24,6 +25,7 @@ export function createWebSearchModule(opts: WebSearchModuleOptions = {}): Server
};
return {
name: "websearch",
manifest: featureManifest("websearch"),
service,
registerRoutes(app, ctx) {
registerWebSearchRoutes(app, () => service(ctx));
+91 -3
View File
@@ -1,8 +1,8 @@
import { createRoute, z } from "@hono/zod-openapi";
import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
import { describe, expect, it } from "vitest";
import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { createApp } from "../src/http/app.ts";
import { requireScope } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse.ts";
@@ -103,7 +103,7 @@ describe("ServerModule 骨架", () => {
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
});
it("outbox 路由合并;同一事件被两个模块认领报错", () => {
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
expect(merged).toContain("test.ping");
expect(merged).toContain("release.resolved");
@@ -111,6 +111,94 @@ describe("ServerModule 骨架", () => {
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
});
it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => {
const { logger } = memoryLogger();
const ctx = { role: "all", logger } as unknown as ServerContext;
const sub = (name: string, fn: () => unknown): ServerModule => ({
name,
registerRoutes() {},
registerTasks() {},
outboxRoutes: () => ({ "organization.finished": fn as never }),
});
const routes = collectOutboxRoutes(
[
...SERVER_MODULES,
sub("a", () => ({ task: "taskA", input: { n: 1 } })),
sub("broken", () => {
throw new Error("坏了");
}),
sub("b", () => [{ task: "taskB", input: {} }]),
sub("none", () => null),
],
ctx,
);
const ev = {
id: "e1",
type: "organization.finished",
at: new Date(),
payload: {
recordId: "00000000-0000-4000-8000-000000000001",
outcome: "succeeded",
action: "copy",
trigger: "auto",
mediaEntityId: "00000000-0000-4000-8000-000000000002",
downloadTaskId: null,
reason: null,
changeSeq: "7",
},
};
// 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做)
expect(routes["organization.finished"]!(ev)).toEqual([
{ task: "taskA", input: { n: 1 } },
{ task: "taskB", input: {} },
]);
// 只有补字幕一个订阅方时它自己的路由不建任务
expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull();
// 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领)
expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow();
const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) };
expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/);
// 广播事件都登记了 payload schema
expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17);
});
it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => {
const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]);
for (const [name, id] of declared) expect(id).toBe(name);
expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort());
const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!;
expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]);
expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes);
});
it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => {
const order: string[] = [];
const mod = (name: string): ServerModule => ({
name,
registerRoutes() {},
registerTasks() {},
registerHooks(hooks) {
hooks.rewrite("organize.rename", (n) => {
order.push(name);
return `${n}.${name}`;
});
},
});
const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx);
expect(
await hooks.runRewrite("organize.rename", "a", {
targetPath: "/a",
sourcePath: "/b",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
}),
).toBe("a.first.second");
expect(order).toEqual(["first", "second"]);
expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({});
});
it("引擎就绪前调用给出明确错误", async () => {
const e = new DeferredTaskEngine();
expect(e.ready).toBe(false);
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseEventPayload } from "@umt/contracts";
import { zipSync } from "fflate";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
@@ -159,6 +160,18 @@ describe.skipIf(!hasPg)("补字幕:上传、写入、撤销(真实 PG + 临时
const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`);
expect(hist.body.data.items).toHaveLength(2);
// 生命周期广播(ADR-006):每个写入一条 subtitle.written,payload 过 contracts schema
const events = await h.db
.selectFrom("outbox_event")
.select(["payload", "idempotency_key"])
.where("type", "=", "subtitle.written")
.orderBy("at")
.execute();
expect(events.map((e) => parseEventPayload("subtitle.written", e.payload))).toEqual([
{ writeId: w1.id, mediaEntityId: entityId, videoLocationId: loc.e1, language: "zh-Hant", source: "upload" },
{ writeId: w2.id, mediaEntityId: entityId, videoLocationId: loc.e2, language: "zh-Hans&en", source: "upload" },
]);
expect(events.map((e) => e.idempotency_key)).toEqual([`subtitle.written/${w1.id}`, `subtitle.written/${w2.id}`]);
expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy();
expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕");
+2
View File
@@ -2,6 +2,8 @@
参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。
功能模块样板(ADR-006,2026-10-06):本模块已声明 manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志)、写入后发 `subtitle.written`;前端经插槽挂在作品页操作区与整理记录行菜单。做自动补字幕时把订阅路由换成真实任务即可。
## 拆分
| 层面 | 放在哪 |
+1
View File
@@ -18,6 +18,7 @@ export {
type IntentRoute,
needsApproval,
type RouteStep,
renderFeatures,
} from "./layers/capabilities.ts";
export { renderEnvironment } from "./layers/environment.ts";
export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts";
@@ -1,4 +1,4 @@
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, FEATURES, type KnownCapabilityId } from "@umt/contracts";
import type { PromptSection } from "../types.ts";
// 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用",
@@ -190,6 +190,20 @@ export function renderRoutes(available: ReadonlySet<string>): string[] {
return out;
}
/**
* 功能清单(ADR-006):外围功能各一行,说明做什么、入口在哪、智能体能不能做。清单来自 contracts 的 FEATURES
* (服务端模块的 manifest 指向同一份),功能都已启用、没有开关;能力只列本次可用的。
*/
export function renderFeatures(available: ReadonlySet<string>): string[] {
return [
"- 外围功能(都已启用,没有开关;用户问到时按这里指路):",
...FEATURES.map((f) => {
const caps = f.capabilities.filter((c) => available.has(c));
return ` - ${f.name}:${f.summary}${f.usage}${caps.length ? `(能力:${caps.join("、")})` : ""}`;
}),
];
}
export function capabilityLayer(capabilities: readonly string[] | undefined): PromptSection {
if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] };
const available = new Set(capabilities);
@@ -202,6 +216,7 @@ export function capabilityLayer(capabilities: readonly string[] | undefined): Pr
"按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。",
...routes,
...CAPABILITY_TIPS.map((t) => `- ${t}`),
...renderFeatures(available),
],
};
}
@@ -0,0 +1,35 @@
import { CAPABILITY_CATALOG, FEATURES } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { buildLayeredPrompt, renderFeatures } from "../src/index.ts";
// 功能清单(ADR-006):外围功能的 manifest 进系统提示「能力地图」一节,让智能体知道有哪些功能、入口在哪;没有开关。
const section = (p: string, title: string) => {
const start = p.indexOf(`## ${title}`);
const rest = p.slice(start);
const next = rest.indexOf("\n## ", 3);
return next < 0 ? rest : rest.slice(0, next);
};
describe("功能清单进系统提示", () => {
it("能力地图里列出每个外围功能的名称、说明与入口", () => {
const p = buildLayeredPrompt({ kind: "chat", role: "x", goal: "y", capabilities: ["download.list"] });
const map = section(p, "能力地图");
expect(map).toContain("外围功能(都已启用,没有开关");
for (const f of FEATURES) expect(map).toContain(` - ${f.name}:${f.summary}`);
expect(map).toContain("作品页「补字幕」");
});
it("功能的能力只列本次可用的;没给的能力 ID 不出现在清单里", () => {
const lines = renderFeatures(new Set(["web.search"]));
const web = lines.find((l) => l.includes("网页搜索"))!;
expect(web).toContain("(能力:web.search)");
const text = lines.join("\n");
for (const id of Object.keys(CAPABILITY_CATALOG)) if (id !== "web.search") expect(text).not.toContain(id);
});
it("没有能力的会话(视觉子流程)不带功能清单", () => {
const p = buildLayeredPrompt({ kind: "vision", role: "x", goal: "y" });
expect(p).not.toContain("外围功能");
});
});