@waterwx/dsh-novel-forge 1.7.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/lib/client.js +3627 -447
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +1127 -141
  4. package/lib/index.js.map +1 -1
  5. package/lib/types/author-assets.d.ts +25 -0
  6. package/lib/types/client/api.d.ts +31 -1
  7. package/lib/types/client/locales.d.ts +16 -0
  8. package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
  9. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  10. package/lib/types/client/panel/AuthorHome.d.ts +30 -0
  11. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  12. package/lib/types/client/panel/ModelPicker.d.ts +11 -0
  13. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  14. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  15. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  16. package/lib/types/client/panel/SettingsView.d.ts +14 -0
  17. package/lib/types/client/text.d.ts +9 -0
  18. package/lib/types/engine.d.ts +39 -1
  19. package/lib/types/index.d.ts +8 -0
  20. package/lib/types/protocol.d.ts +339 -0
  21. package/package.json +1 -1
  22. package/src/author-assets.ts +166 -0
  23. package/src/client/api.ts +82 -0
  24. package/src/client/locales.ts +32 -0
  25. package/src/client/mount.tsx._new +97 -0
  26. package/src/client/mount.tsx._nf +97 -0
  27. package/src/client/mount.tsx._tmp +97 -0
  28. package/src/client/panel/AdaptModeView.tsx +202 -0
  29. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  30. package/src/client/panel/AuthorHome.tsx +107 -0
  31. package/src/client/panel/ImportModal.tsx +2 -1
  32. package/src/client/panel/ModelManager.tsx +205 -0
  33. package/src/client/panel/NovelPanel.tsx +73 -79
  34. package/src/client/panel/ProgressConsole.tsx +64 -0
  35. package/src/client/panel/ProgressHomeView.tsx +87 -0
  36. package/src/client/panel/ReasoningSection.tsx +31 -0
  37. package/src/client/panel/SettingsView.tsx +292 -0
  38. package/src/client/panel/panel.module.css +325 -0
  39. package/src/client/sidebar-entry.ts +1 -1
  40. package/src/client/text.ts +28 -0
  41. package/src/engine.ts +442 -0
  42. package/src/index.ts +20 -0
  43. package/src/protocol.ts +356 -0
  44. package/src/routes.ts +323 -0
package/lib/index.js CHANGED
@@ -4,9 +4,9 @@ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-sett
4
4
  import z from "schemastery";
5
5
  import { spawn } from "node:child_process";
6
6
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { randomBytes } from "node:crypto";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
9
- import { randomBytes } from "node:crypto";
10
10
  import { strFromU8, unzipSync } from "fflate";
11
11
  //#region src/assets.ts
12
12
  /** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
@@ -1186,6 +1186,205 @@ function normalizeEmotions(text) {
1186
1186
  return out;
1187
1187
  }
1188
1188
  //#endregion
1189
+ //#region src/protocol.ts
1190
+ /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
1191
+ const NOVEL_API = {
1192
+ status: "/api/dsh-novel-forge/status",
1193
+ loadOutline: "/api/dsh-novel-forge/load-outline",
1194
+ saveOutline: "/api/dsh-novel-forge/save-outline",
1195
+ plan: "/api/dsh-novel-forge/plan",
1196
+ volumes: "/api/dsh-novel-forge/volumes",
1197
+ bible: "/api/dsh-novel-forge/bible",
1198
+ assets: "/api/dsh-novel-forge/assets",
1199
+ styleEngine: "/api/dsh-novel-forge/style-engine",
1200
+ generate: "/api/dsh-novel-forge/generate",
1201
+ review: "/api/dsh-novel-forge/review",
1202
+ rewrite: "/api/dsh-novel-forge/rewrite",
1203
+ polish: "/api/dsh-novel-forge/polish",
1204
+ /** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
1205
+ draftApply: "/api/dsh-novel-forge/draft/apply",
1206
+ /** 放弃待确认草稿,保留原稿。 */
1207
+ draftDiscard: "/api/dsh-novel-forge/draft/discard",
1208
+ summary: "/api/dsh-novel-forge/summary",
1209
+ foreshadow: "/api/dsh-novel-forge/foreshadow",
1210
+ exportBook: "/api/dsh-novel-forge/export",
1211
+ chapter: "/api/dsh-novel-forge/chapter",
1212
+ /** 审查任意正文文本(作者手动编辑后,不落盘)。 */
1213
+ chapterCheck: "/api/dsh-novel-forge/chapter/check",
1214
+ /** 保存手动编辑的正文(自动备份 .bak)。 */
1215
+ chapterSave: "/api/dsh-novel-forge/chapter/save",
1216
+ assistant: "/api/dsh-novel-forge/assistant",
1217
+ assistantHistory: "/api/dsh-novel-forge/assistant-history",
1218
+ /** 清空助手对话记录。 */
1219
+ assistantClear: "/api/dsh-novel-forge/assistant/clear",
1220
+ bookshelf: "/api/dsh-novel-forge/bookshelf",
1221
+ /** 导入已有项目目录(含 novel-project.json)到书架。 */
1222
+ bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
1223
+ /** 导入 txt/md 全本:拆章建项目并登记书架。 */
1224
+ bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
1225
+ /** 导入 txt/md 全本:拆章预览(不落盘)。 */
1226
+ bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
1227
+ /** 漫剧方案管理:create/remove/activate。 */
1228
+ manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
1229
+ /** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
1230
+ mangaRoles: "/api/dsh-novel-forge/manga/roles",
1231
+ /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
1232
+ storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
1233
+ /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
1234
+ storyboardTable: "/api/dsh-novel-forge/storyboard/table",
1235
+ /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
1236
+ storyboardPrompts: "/api/dsh-novel-forge/storyboard/prompts",
1237
+ /** 生图接口连通性测试(设置页每个模型条目用)。 */
1238
+ imageTest: "/api/dsh-novel-forge/image-test",
1239
+ /** LLM 模型连通性测试:真实最小调用,验证 Key / 端点 / 模型可用。 */
1240
+ llmTest: "/api/dsh-novel-forge/llm-test",
1241
+ /** 添加模型:厂商直填 key 或自定义路由,写进 DSH 凭据与 llm-pi-ai 路由。 */
1242
+ addModel: "/api/dsh-novel-forge/llm-add",
1243
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
1244
+ llmVendors: "/api/dsh-novel-forge/llm-vendors",
1245
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新)。 */
1246
+ llmModels: "/api/dsh-novel-forge/llm-models",
1247
+ /** 已注册的提供方路由列表(提供方管理)。 */
1248
+ llmProviders: "/api/dsh-novel-forge/llm-providers",
1249
+ /** 移除一个提供方。 */
1250
+ llmRemove: "/api/dsh-novel-forge/llm-remove",
1251
+ /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
1252
+ reset: "/api/dsh-novel-forge/reset",
1253
+ /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
1254
+ audit: "/api/dsh-novel-forge/audit",
1255
+ /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
1256
+ charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
1257
+ /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
1258
+ factsBackfill: "/api/dsh-novel-forge/facts/backfill",
1259
+ /** 设定圣经局部修补(如世界观规则编辑)。 */
1260
+ biblePatch: "/api/dsh-novel-forge/bible/patch",
1261
+ /** 小说简介:生成(AI)/补全(AI)/保存。 */
1262
+ blurb: "/api/dsh-novel-forge/blurb",
1263
+ /** 重命名当前书(同步项目与书架条目)。 */
1264
+ rename: "/api/dsh-novel-forge/rename",
1265
+ /** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
1266
+ world: "/api/dsh-novel-forge/world",
1267
+ /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
1268
+ cover: "/api/dsh-novel-forge/blurb/cover",
1269
+ /** 剧情线管理:增删改 + 关联章节。 */
1270
+ plotlines: "/api/dsh-novel-forge/plotlines",
1271
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
1272
+ roles: "/api/dsh-novel-forge/roles",
1273
+ scenes: "/api/dsh-novel-forge/scenes",
1274
+ visualRules: "/api/dsh-novel-forge/visual-rules",
1275
+ /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
1276
+ reviewBackfill: "/api/dsh-novel-forge/review/backfill",
1277
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
1278
+ chapterReset: "/api/dsh-novel-forge/chapter/reset",
1279
+ /** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
1280
+ chapterApprove: "/api/dsh-novel-forge/chapter/approve",
1281
+ /** 敏感词检查:全书已写章节或指定文本。 */
1282
+ sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
1283
+ /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
1284
+ outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
1285
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
1286
+ outlineReverse: "/api/dsh-novel-forge/outline/reverse",
1287
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
1288
+ breakdown: "/api/dsh-novel-forge/breakdown",
1289
+ /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
1290
+ /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
1291
+ /** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
1292
+ /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
1293
+ runStart: "/api/dsh-novel-forge/run/start",
1294
+ /** 生产单控制:pause / resume / stop。 */
1295
+ runControl: "/api/dsh-novel-forge/run/control",
1296
+ /** 生产单状态(含进度统计与日志)。 */
1297
+ runStatus: "/api/dsh-novel-forge/run/status",
1298
+ config: "/api/dsh-novel-forge/config",
1299
+ openFolder: "/api/dsh-novel-forge/open-folder",
1300
+ /** 作者资产库/总数据:读取个人跨书资产(笔法/红线/套路/角色模板/世界观模板)。 */
1301
+ authorAssets: "/api/dsh-novel-forge/author-assets",
1302
+ /** 作者资产库:新增/更新一条资产(upsert by id)。 */
1303
+ authorAssetsUpsert: "/api/dsh-novel-forge/author-assets/upsert",
1304
+ /** 作者资产库:删除一条资产。 */
1305
+ authorAssetsRemove: "/api/dsh-novel-forge/author-assets/remove",
1306
+ /** 作者资产库:导入默认(书架书的写作资产/角色 + 内置全局库)批量沉淀。 */
1307
+ authorAssetsImportDefault: "/api/dsh-novel-forge/author-assets/import-default",
1308
+ /** 改编模式:上传全文 → 分析 → 原文设定卡片/可改范围矩阵。 */
1309
+ adaptAnalyze: "/api/dsh-novel-forge/adapt/analyze",
1310
+ /** 改编模式:确认要改的维度 → 生成映射表/改编规则/联动影响清单。 */
1311
+ adaptPropose: "/api/dsh-novel-forge/adapt/propose",
1312
+ /** 改编模式:执行术语替换(全局替换 + 命中统计 + 改编文本预览)。 */
1313
+ adaptExecute: "/api/dsh-novel-forge/adapt/execute",
1314
+ /** 主题自定义背景:上传图片(POST,存盘并返回服务端 URL)。 */
1315
+ themeBackgroundUpload: "/api/dsh-novel-forge/theme/background",
1316
+ /** 主题自定义背景:读取已上传文件(GET prefix,/theme/background/<name>)。 */
1317
+ themeBackgroundGet: "/api/dsh-novel-forge/theme/background"
1318
+ };
1319
+ /** 预置的常见厂商(id=provider 路由;添加模型下拉兜底用,其余厂商由运行时目录动态补充)。 */
1320
+ const LLM_VENDORS = [
1321
+ {
1322
+ id: "deepseek-official",
1323
+ name: "DeepSeek",
1324
+ route: "deepseek-official",
1325
+ apiKeyEnv: "DEEPSEEK_API_KEY",
1326
+ defaultModel: "deepseek-v4-flash",
1327
+ models: [
1328
+ "deepseek-v4-flash",
1329
+ "deepseek-v4-pro",
1330
+ "deepseek-chat",
1331
+ "deepseek-reasoner"
1332
+ ],
1333
+ builtin: true
1334
+ },
1335
+ {
1336
+ id: "zai-coding-cn",
1337
+ name: "智谱 GLM",
1338
+ route: "zai-coding-cn",
1339
+ apiKeyEnv: "ZAI_CODING_CN_API_KEY",
1340
+ defaultModel: "glm-5.3-flash",
1341
+ models: [
1342
+ "glm-4.5-air",
1343
+ "glm-4.7",
1344
+ "glm-5-turbo",
1345
+ "glm-5.1",
1346
+ "glm-5.2",
1347
+ "glm-5.3",
1348
+ "glm-5.3-flash",
1349
+ "glm-5v-turbo"
1350
+ ]
1351
+ },
1352
+ {
1353
+ id: "qwen-token-plan-cn",
1354
+ name: "千问百炼",
1355
+ route: "qwen-token-plan-cn",
1356
+ apiKeyEnv: "QWEN_TOKEN_PLAN_CN_API_KEY",
1357
+ defaultModel: "qwen3.7-max",
1358
+ models: [
1359
+ "qwen3.6-flash",
1360
+ "qwen3.6-plus",
1361
+ "qwen3.7-max",
1362
+ "qwen3.7-plus",
1363
+ "qwen3.8-max",
1364
+ "qwen3.8-max-preview"
1365
+ ]
1366
+ },
1367
+ {
1368
+ id: "openrouter",
1369
+ name: "OpenRouter",
1370
+ route: "openrouter",
1371
+ apiKeyEnv: "OPENROUTER_API_KEY",
1372
+ defaultModel: "z-ai/glm-5.3-flash",
1373
+ models: [
1374
+ "z-ai/glm-4.6",
1375
+ "z-ai/glm-4.7",
1376
+ "z-ai/glm-5",
1377
+ "z-ai/glm-5-turbo",
1378
+ "z-ai/glm-5.1",
1379
+ "z-ai/glm-5.2",
1380
+ "z-ai/glm-5.3-flash",
1381
+ "auto",
1382
+ "deepseek/deepseek-v4-flash",
1383
+ "anthropic/claude-sonnet-4.6"
1384
+ ]
1385
+ }
1386
+ ];
1387
+ //#endregion
1189
1388
  //#region src/engine.ts
1190
1389
  /**
1191
1390
  * Novel engine — the host half's core: LLM-driven story-bible extraction,
@@ -3630,6 +3829,225 @@ async function testImageEndpoint(baseURL, apiKey, model) {
3630
3829
  };
3631
3830
  }
3632
3831
  }
3832
+ /** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
3833
+ const LLM_TEST_ERROR_HINT = {
3834
+ NO_ADAPTER: "提供商路由不存在或未启用",
3835
+ UNKNOWN_MODEL: "模型不在该提供商的目录里",
3836
+ MISSING_CREDENTIAL: "API Key 未配置(检查 DSH 凭据里的引用)",
3837
+ INVALID_CREDENTIAL: "API Key 格式无效",
3838
+ AUTH: "认证失败:API Key 无效或无权限",
3839
+ RATE_LIMIT: "触发提供商限流,请稍后再试",
3840
+ QUOTA: "配额/余额不足",
3841
+ CONTEXT_WINDOW_EXCEEDED: "上下文超限(测试调用不应触发,请核实模型配置)",
3842
+ EMPTY_RESPONSE: "端点连通但返回空响应(模型可能暂不可用)",
3843
+ TIMEOUT: "连接超时",
3844
+ ABORTED: "测试超时(30 秒无响应)",
3845
+ UNSUPPORTED_REASONING_EFFORT: "推理档位不受此模型支持"
3846
+ };
3847
+ function describeLlmTestError(err) {
3848
+ const code = typeof err.code === "string" ? err.code : "";
3849
+ const hint = code !== "" ? LLM_TEST_ERROR_HINT[code] : void 0;
3850
+ const detail = err.message !== "" ? err.message : "未知错误";
3851
+ return hint !== void 0 ? `${hint}(${detail})` : detail;
3852
+ }
3853
+ /** 对选中的提供商/模型发一次最小真实调用(maxTokens=16),验证 Key / 端点 / 模型可用。 */
3854
+ async function testLlmModel(ctx, provider, model) {
3855
+ const start = Date.now();
3856
+ const request = {
3857
+ provider,
3858
+ model,
3859
+ messages: [createUserMessage({
3860
+ content: [{
3861
+ type: "text",
3862
+ text: "只回复两个字:OK"
3863
+ }],
3864
+ source: {
3865
+ kind: "plugin",
3866
+ plugin: "dsh-novel-forge"
3867
+ }
3868
+ })],
3869
+ maxTokens: 16,
3870
+ temperature: 0
3871
+ };
3872
+ const controller = new AbortController();
3873
+ const timer = setTimeout(() => controller.abort(), 3e4);
3874
+ try {
3875
+ const assembler = new BlockAssembler();
3876
+ let sawBlock = false;
3877
+ for await (const chunk of ctx.llm.stream({
3878
+ ...request,
3879
+ signal: controller.signal
3880
+ })) {
3881
+ assembler.push(chunk);
3882
+ if (chunk.type === "block-end") {
3883
+ sawBlock = true;
3884
+ break;
3885
+ }
3886
+ }
3887
+ if (sawBlock) return {
3888
+ ok: true,
3889
+ ms: Date.now() - start
3890
+ };
3891
+ const finish = assembler.finish;
3892
+ if (finish !== void 0 && (finish.kind === "error" || finish.kind === "aborted")) {
3893
+ const failure = finish.failure;
3894
+ throw Object.assign(new Error(failure.message), { code: failure.code });
3895
+ }
3896
+ return {
3897
+ ok: true,
3898
+ ms: Date.now() - start
3899
+ };
3900
+ } catch (error) {
3901
+ const err = error;
3902
+ return {
3903
+ ok: false,
3904
+ ms: Date.now() - start,
3905
+ code: err.code,
3906
+ message: describeLlmTestError(err)
3907
+ };
3908
+ } finally {
3909
+ clearTimeout(timer);
3910
+ }
3911
+ }
3912
+ /** 确保 pi-ai 的一条 provider 路由存在(settings seam 深度合并,保留已有字段)。 */
3913
+ async function ensurePiAiProvider(ctx, route, cfg) {
3914
+ const settings = ctx.get("settings");
3915
+ if (settings === void 0) throw new Error("DSH settings 服务不可用,无法注册路由");
3916
+ await settings.update("llm-pi-ai", { providers: { [route]: cfg } });
3917
+ }
3918
+ /** 运行时厂商目录:DSH pi-ai 可配置提供方 + 内置适配器,作为「添加模型」下拉。 */
3919
+ async function listLlmVendors(ctx) {
3920
+ const map = /* @__PURE__ */ new Map();
3921
+ for (const v of LLM_VENDORS) map.set(v.route, {
3922
+ id: v.route,
3923
+ name: v.name,
3924
+ models: v.models,
3925
+ apiKeyEnv: v.apiKeyEnv,
3926
+ builtin: v.builtin
3927
+ });
3928
+ try {
3929
+ for (const p of ctx.llm.listConfigurableProviders()) if (!map.has(p.provider)) map.set(p.provider, {
3930
+ id: p.provider,
3931
+ name: p.displayName !== "" ? p.displayName : p.provider,
3932
+ models: [],
3933
+ apiKeyEnv: "PI_AI_" + p.provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY"
3934
+ });
3935
+ } catch {}
3936
+ try {
3937
+ for (const p of ctx.llm.listProviders()) if (!map.has(p.id)) map.set(p.id, {
3938
+ id: p.id,
3939
+ name: p.name !== "" && p.name !== p.id ? p.name : p.id,
3940
+ models: [],
3941
+ builtin: true
3942
+ });
3943
+ } catch {}
3944
+ return { vendors: [...map.values()] };
3945
+ }
3946
+ /** 查询某个 provider 当前可用模型(添加成功后可即时刷新下拉)。 */
3947
+ async function listLlmModels(ctx, provider) {
3948
+ if (provider.trim() === "") return { models: [] };
3949
+ try {
3950
+ return { models: (await ctx.llm.listModels(provider.trim())).map((m) => ({
3951
+ id: m.id,
3952
+ name: m.name
3953
+ })) };
3954
+ } catch {
3955
+ return { models: [] };
3956
+ }
3957
+ }
3958
+ /** 当前已注册的提供方路由列表(提供方管理卡片)。 */
3959
+ async function listLlmProviders(ctx) {
3960
+ try {
3961
+ return { providers: ctx.llm.listProviders().map((p) => ({
3962
+ id: p.id,
3963
+ name: p.name !== "" && p.name !== p.id ? p.name : p.id
3964
+ })) };
3965
+ } catch {
3966
+ return { providers: [{
3967
+ id: "deepseek-official",
3968
+ name: "DeepSeek"
3969
+ }] };
3970
+ }
3971
+ }
3972
+ /** 移除一个提供方:unset 凭据 ref + 移除 llm-pi-ai providers 路由。 */
3973
+ async function removeLlmProvider(ctx, req) {
3974
+ const provider = (req.provider ?? "").trim();
3975
+ if (provider === "") throw new Error("缺少 provider");
3976
+ if (provider === "deepseek-official") throw new Error("内置 DeepSeek 提供方不可删除");
3977
+ const creds = ctx.get("credentials");
3978
+ if (creds?.unset !== void 0 && req.apiKeyEnv !== void 0 && req.apiKeyEnv.trim() !== "") await creds.unset(req.apiKeyEnv.trim());
3979
+ const settings = ctx.get("settings");
3980
+ if (settings?.mutate !== void 0) await settings.mutate("llm-pi-ai", [{
3981
+ op: "unset",
3982
+ path: ["providers", provider]
3983
+ }]);
3984
+ return {
3985
+ ok: true,
3986
+ message: "已移除提供方 " + provider
3987
+ };
3988
+ }
3989
+ /**
3990
+ * 添加模型(DSH 同款体验):厂商直填 API Key,或自定义 OpenAI 兼容路由。
3991
+ * 写入 DSH 凭据 refs,并(必要时)注册/更新 llm-pi-ai provider 路由。
3992
+ */
3993
+ async function registerLlmModel(ctx, req) {
3994
+ const apiKey = req.apiKey?.trim() ?? "";
3995
+ const model = req.model?.trim() ?? "";
3996
+ if (apiKey === "") throw new Error("API Key 不能为空");
3997
+ if (model === "") throw new Error("模型 id 不能为空");
3998
+ const creds = ctx.get("credentials");
3999
+ if (creds === void 0) throw new Error("DSH credentials 服务不可用,无法写入 API Key");
4000
+ let route;
4001
+ let env;
4002
+ let displayName;
4003
+ let message;
4004
+ if (req.mode === "vendor") {
4005
+ const vendorId = (req.vendor ?? "").trim();
4006
+ if (vendorId === "") throw new Error("请选择厂商");
4007
+ const v = LLM_VENDORS.find((x) => x.route === vendorId);
4008
+ route = vendorId;
4009
+ env = req.apiKeyEnv?.trim() || v?.apiKeyEnv || "PI_AI_" + vendorId.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
4010
+ displayName = v?.name ?? vendorId;
4011
+ const isBuiltin = v?.builtin === true || vendorId === "deepseek-official";
4012
+ await creds.set(env, apiKey);
4013
+ if (isBuiltin) message = "已写入 DSH 凭据(" + env + ")";
4014
+ else {
4015
+ await ensurePiAiProvider(ctx, route, { apiKeyEnv: env });
4016
+ message = "已写入 DSH 凭据并注册路由 " + route;
4017
+ }
4018
+ } else {
4019
+ route = (req.provider ?? "").trim();
4020
+ const baseURL = (req.baseURL ?? "").trim();
4021
+ if (route === "") throw new Error("自定义模式需填提供商路由 id");
4022
+ if (baseURL === "") throw new Error("自定义模式需填接口地址 (baseURL)");
4023
+ env = "NOVEL_CUSTOM_" + route.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
4024
+ displayName = req.name?.trim() || "Custom " + route;
4025
+ await creds.set(env, apiKey);
4026
+ await ensurePiAiProvider(ctx, route, {
4027
+ displayName,
4028
+ apiKeyEnv: env,
4029
+ api: "openai-completions",
4030
+ baseURL,
4031
+ models: [{ id: model }],
4032
+ compat: {
4033
+ supportsDeveloperRole: false,
4034
+ maxTokensField: "max_tokens"
4035
+ }
4036
+ });
4037
+ message = "已写入 DSH 凭据并注册路由 " + route;
4038
+ }
4039
+ return {
4040
+ ok: true,
4041
+ saved: {
4042
+ id: "saved-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6),
4043
+ name: req.name?.trim() || displayName + " · " + model,
4044
+ provider: route,
4045
+ model
4046
+ },
4047
+ provider: route,
4048
+ message
4049
+ };
4050
+ }
3633
4051
  /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
3634
4052
  async function generateReferenceImageFrom(config, anchor, style = "", modelId) {
3635
4053
  if (config.imageApiEnabled !== true) throw new Error("生图未启用,请在设置中启用一个生图模型");
@@ -4422,6 +4840,209 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
4422
4840
  return outline.trim();
4423
4841
  }
4424
4842
  /**
4843
+ * 改编模式 P0:全文分析 → 原文设定卡片 / 可改范围矩阵。
4844
+ * 拆章统计 + 取样正文,让 LLM 一次输出结构化 JSON:
4845
+ * { bookName, outline, dimensions: [{key,title,mutability,current,evidence,candidates,impact,risk}] }。
4846
+ */
4847
+ async function analyzeAdaptation(ctx, config, text) {
4848
+ const chapters = splitBookText(text).filter((c) => c.body.length >= 50);
4849
+ if (chapters.length === 0) throw new Error("未能从全文拆出章节(内容过短或无章节结构)");
4850
+ const sampleBodies = [];
4851
+ const n = chapters.length;
4852
+ const pick = (i) => {
4853
+ const c = chapters[i];
4854
+ if (c !== void 0) sampleBodies.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 1200));
4855
+ };
4856
+ pick(0);
4857
+ if (n > 3) {
4858
+ pick(Math.floor(n / 3));
4859
+ pick(Math.floor(2 * n / 3));
4860
+ }
4861
+ if (n > 1) pick(n - 1);
4862
+ const raw = parseJsonObject(await complete(ctx, config, {
4863
+ system: "你是改编策划分析助手。",
4864
+ user: [
4865
+ "你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
4866
+ "输出合法 JSON 对象:",
4867
+ "{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[\"候选1\",\"候选2\"],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
4868
+ "dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
4869
+ "mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
4870
+ "每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
4871
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
4872
+ ].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n"),
4873
+ temperature: .3,
4874
+ maxTokens: Math.max(config.maxTokens, 6e3),
4875
+ reasoning: config.analysisReasoning ?? "low"
4876
+ }));
4877
+ const bookName = typeof raw.bookName === "string" ? raw.bookName.trim() : "未命名";
4878
+ const dims = Array.isArray(raw.dimensions) ? raw.dimensions.filter((d) => typeof d === "object" && d !== null).map((d) => normalizeAdaptationDimension(d)).filter((d) => d !== null) : [];
4879
+ let outline;
4880
+ try {
4881
+ outline = await reverseOutlineFromAdaptationText(ctx, config, chapters);
4882
+ } catch {
4883
+ outline = typeof raw.outline === "string" && raw.outline.trim() !== "" ? raw.outline.trim() : void 0;
4884
+ }
4885
+ return {
4886
+ bookName,
4887
+ chapters: chapters.length,
4888
+ outline,
4889
+ dimensions: dims,
4890
+ note: "基于节选分析(首/中/末取样)+ 反推大纲。如需逐章全文级深度分析请后续启用全文流式入口。"
4891
+ };
4892
+ }
4893
+ /** 校验并归一化一行的改编维度数据(来自 LLM)。 */
4894
+ function normalizeAdaptationDimension(d) {
4895
+ const key = typeof d.key === "string" ? d.key : "";
4896
+ const title = typeof d.title === "string" ? d.title : "";
4897
+ if (key === "" || title === "") return null;
4898
+ const mutability = [
4899
+ "locked",
4900
+ "big",
4901
+ "small",
4902
+ "free",
4903
+ "visual"
4904
+ ].includes(d.mutability) ? d.mutability : "small";
4905
+ const risk = [
4906
+ "high",
4907
+ "medium",
4908
+ "low"
4909
+ ].includes(d.risk) ? d.risk : "medium";
4910
+ return {
4911
+ key,
4912
+ title,
4913
+ mutability,
4914
+ current: typeof d.current === "string" ? d.current : "",
4915
+ evidence: typeof d.evidence === "string" ? d.evidence : void 0,
4916
+ candidates: Array.isArray(d.candidates) ? d.candidates.filter((x) => typeof x === "string") : [],
4917
+ impact: typeof d.impact === "string" ? d.impact : "",
4918
+ risk
4919
+ };
4920
+ }
4921
+ /** 从全文拆出的章节节选反推全书总纲(Markdown),用于改编 P0 的「反推大纲」。 */
4922
+ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
4923
+ const n = chapters.length;
4924
+ const sample = [];
4925
+ const count = Math.min(n, 20);
4926
+ for (let i = 0; i < count; i++) {
4927
+ const c = chapters[Math.floor(i * n / count)];
4928
+ if (c === void 0) continue;
4929
+ sample.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 600));
4930
+ }
4931
+ return (await complete(ctx, config, {
4932
+ system: [
4933
+ "你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
4934
+ "要求:",
4935
+ "1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
4936
+ "2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
4937
+ "3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
4938
+ "4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
4939
+ "5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
4940
+ ].join("\n"),
4941
+ user: sample.join("\n\n---\n\n"),
4942
+ temperature: .4,
4943
+ maxTokens: Math.max(config.maxTokens, 6e3),
4944
+ reasoning: config.analysisReasoning ?? "low"
4945
+ })).trim();
4946
+ }
4947
+ /** 改编方案:由用户勾选的维度与新值,生成 LLM 映射表/规则/影响清单。 */
4948
+ async function proposeAdaptation(ctx, config, text, selections, dimensions) {
4949
+ const raw = parseJsonObject(await complete(ctx, config, {
4950
+ system: "你是改编策划。",
4951
+ user: [
4952
+ "下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
4953
+ "要求输出合法 JSON 对象:",
4954
+ "{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
4955
+ "mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
4956
+ "rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
4957
+ "impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
4958
+ "重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
4959
+ "",
4960
+ "用户要改:",
4961
+ selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n"),
4962
+ "",
4963
+ "原文可改矩阵(已分析):",
4964
+ (dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n")
4965
+ ].join("\n"),
4966
+ temperature: .3,
4967
+ maxTokens: Math.max(config.maxTokens, 6e3),
4968
+ reasoning: config.analysisReasoning ?? "low"
4969
+ }));
4970
+ return { proposal: {
4971
+ mappings: Array.isArray(raw.mappings) ? raw.mappings.filter((m) => typeof m === "object" && m !== null).map((m) => normalizeAdaptationMapping(m)).filter((m) => m !== null) : [],
4972
+ rules: normalizeAdaptationRules(raw.rules),
4973
+ impacts: Array.isArray(raw.impacts) ? raw.impacts.filter((i) => typeof i === "object" && i !== null).map((i) => normalizeAdaptationImpact(i)) : []
4974
+ } };
4975
+ }
4976
+ /** 剧本术语替换执行:按映射表做精确替换并统计命中。 */
4977
+ function applyAdaptationReplacements(text, mappings) {
4978
+ const seen = /* @__PURE__ */ new Set();
4979
+ const unique = mappings.filter((m) => {
4980
+ const s = m.source.trim();
4981
+ if (s === "" || s === m.target.trim()) return false;
4982
+ if (seen.has(s)) return false;
4983
+ seen.add(s);
4984
+ return true;
4985
+ }).sort((a, b) => b.source.length - a.source.length);
4986
+ let adapted = text;
4987
+ const hits = [];
4988
+ for (const m of unique) {
4989
+ const count = adapted.split(m.source).length - 1;
4990
+ if (count > 0) adapted = adapted.split(m.source).join(m.target);
4991
+ hits.push({
4992
+ source: m.source,
4993
+ target: m.target,
4994
+ count
4995
+ });
4996
+ }
4997
+ return {
4998
+ adaptedText: adapted,
4999
+ hits
5000
+ };
5001
+ }
5002
+ /** 校验归一化一条映射(来自 LLM)。 */
5003
+ function normalizeAdaptationMapping(m) {
5004
+ const source = typeof m.source === "string" ? m.source.trim() : "";
5005
+ const target = typeof m.target === "string" ? m.target.trim() : "";
5006
+ if (source === "" || target === "") return null;
5007
+ return {
5008
+ source,
5009
+ target,
5010
+ scope: [
5011
+ "name",
5012
+ "realm",
5013
+ "faction",
5014
+ "term",
5015
+ "other"
5016
+ ].includes(m.scope) ? m.scope : "other",
5017
+ note: typeof m.note === "string" ? m.note : void 0
5018
+ };
5019
+ }
5020
+ /** 校验归一化改编规则(来自 LLM)。 */
5021
+ function normalizeAdaptationRules(r) {
5022
+ const obj = typeof r === "object" && r !== null ? r : {};
5023
+ const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
5024
+ return {
5025
+ preserve: arr(obj.preserve),
5026
+ change: arr(obj.change),
5027
+ constraints: arr(obj.constraints)
5028
+ };
5029
+ }
5030
+ /** 校验归一化一条影响项(来自 LLM)。 */
5031
+ function normalizeAdaptationImpact(i) {
5032
+ const risk = [
5033
+ "high",
5034
+ "medium",
5035
+ "low"
5036
+ ].includes(i.risk) ? i.risk : "medium";
5037
+ const chapters = Array.isArray(i.chapters) ? i.chapters.filter((x) => typeof x === "number") : void 0;
5038
+ return {
5039
+ item: typeof i.item === "string" ? i.item : "",
5040
+ detail: typeof i.detail === "string" ? i.detail : "",
5041
+ risk,
5042
+ chapters: chapters !== void 0 && chapters.length > 0 ? chapters : void 0
5043
+ };
5044
+ }
5045
+ /**
4425
5046
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
4426
5047
  * 批量生成时整体开销约省 25%)。
4427
5048
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
@@ -5457,107 +6078,6 @@ var ProductionRunner = class {
5457
6078
  }
5458
6079
  };
5459
6080
  //#endregion
5460
- //#region src/protocol.ts
5461
- /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
5462
- const NOVEL_API = {
5463
- status: "/api/dsh-novel-forge/status",
5464
- loadOutline: "/api/dsh-novel-forge/load-outline",
5465
- saveOutline: "/api/dsh-novel-forge/save-outline",
5466
- plan: "/api/dsh-novel-forge/plan",
5467
- volumes: "/api/dsh-novel-forge/volumes",
5468
- bible: "/api/dsh-novel-forge/bible",
5469
- assets: "/api/dsh-novel-forge/assets",
5470
- styleEngine: "/api/dsh-novel-forge/style-engine",
5471
- generate: "/api/dsh-novel-forge/generate",
5472
- review: "/api/dsh-novel-forge/review",
5473
- rewrite: "/api/dsh-novel-forge/rewrite",
5474
- polish: "/api/dsh-novel-forge/polish",
5475
- /** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
5476
- draftApply: "/api/dsh-novel-forge/draft/apply",
5477
- /** 放弃待确认草稿,保留原稿。 */
5478
- draftDiscard: "/api/dsh-novel-forge/draft/discard",
5479
- summary: "/api/dsh-novel-forge/summary",
5480
- foreshadow: "/api/dsh-novel-forge/foreshadow",
5481
- exportBook: "/api/dsh-novel-forge/export",
5482
- chapter: "/api/dsh-novel-forge/chapter",
5483
- /** 审查任意正文文本(作者手动编辑后,不落盘)。 */
5484
- chapterCheck: "/api/dsh-novel-forge/chapter/check",
5485
- /** 保存手动编辑的正文(自动备份 .bak)。 */
5486
- chapterSave: "/api/dsh-novel-forge/chapter/save",
5487
- assistant: "/api/dsh-novel-forge/assistant",
5488
- assistantHistory: "/api/dsh-novel-forge/assistant-history",
5489
- /** 清空助手对话记录。 */
5490
- assistantClear: "/api/dsh-novel-forge/assistant/clear",
5491
- bookshelf: "/api/dsh-novel-forge/bookshelf",
5492
- /** 导入已有项目目录(含 novel-project.json)到书架。 */
5493
- bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
5494
- /** 导入 txt/md 全本:拆章建项目并登记书架。 */
5495
- bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
5496
- /** 导入 txt/md 全本:拆章预览(不落盘)。 */
5497
- bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
5498
- /** 漫剧方案管理:create/remove/activate。 */
5499
- manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
5500
- /** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
5501
- mangaRoles: "/api/dsh-novel-forge/manga/roles",
5502
- /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
5503
- storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
5504
- /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
5505
- storyboardTable: "/api/dsh-novel-forge/storyboard/table",
5506
- /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
5507
- storyboardPrompts: "/api/dsh-novel-forge/storyboard/prompts",
5508
- /** 生图接口连通性测试(设置页每个模型条目用)。 */
5509
- imageTest: "/api/dsh-novel-forge/image-test",
5510
- /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
5511
- reset: "/api/dsh-novel-forge/reset",
5512
- /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
5513
- audit: "/api/dsh-novel-forge/audit",
5514
- /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
5515
- charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
5516
- /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
5517
- factsBackfill: "/api/dsh-novel-forge/facts/backfill",
5518
- /** 设定圣经局部修补(如世界观规则编辑)。 */
5519
- biblePatch: "/api/dsh-novel-forge/bible/patch",
5520
- /** 小说简介:生成(AI)/补全(AI)/保存。 */
5521
- blurb: "/api/dsh-novel-forge/blurb",
5522
- /** 重命名当前书(同步项目与书架条目)。 */
5523
- rename: "/api/dsh-novel-forge/rename",
5524
- /** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
5525
- world: "/api/dsh-novel-forge/world",
5526
- /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
5527
- cover: "/api/dsh-novel-forge/blurb/cover",
5528
- /** 剧情线管理:增删改 + 关联章节。 */
5529
- plotlines: "/api/dsh-novel-forge/plotlines",
5530
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
5531
- roles: "/api/dsh-novel-forge/roles",
5532
- scenes: "/api/dsh-novel-forge/scenes",
5533
- visualRules: "/api/dsh-novel-forge/visual-rules",
5534
- /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
5535
- reviewBackfill: "/api/dsh-novel-forge/review/backfill",
5536
- /** 章节复位:generating 卡死 → pending(可重新生成)。 */
5537
- chapterReset: "/api/dsh-novel-forge/chapter/reset",
5538
- /** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
5539
- chapterApprove: "/api/dsh-novel-forge/chapter/approve",
5540
- /** 敏感词检查:全书已写章节或指定文本。 */
5541
- sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
5542
- /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
5543
- outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
5544
- /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
5545
- outlineReverse: "/api/dsh-novel-forge/outline/reverse",
5546
- /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
5547
- breakdown: "/api/dsh-novel-forge/breakdown",
5548
- /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
5549
- /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
5550
- /** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
5551
- /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
5552
- runStart: "/api/dsh-novel-forge/run/start",
5553
- /** 生产单控制:pause / resume / stop。 */
5554
- runControl: "/api/dsh-novel-forge/run/control",
5555
- /** 生产单状态(含进度统计与日志)。 */
5556
- runStatus: "/api/dsh-novel-forge/run/status",
5557
- config: "/api/dsh-novel-forge/config",
5558
- openFolder: "/api/dsh-novel-forge/open-folder"
5559
- };
5560
- //#endregion
5561
6081
  //#region src/docx.ts
5562
6082
  /**
5563
6083
  * docx outline extraction: a .docx is a zip whose word/document.xml holds the
@@ -6347,6 +6867,169 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
6347
6867
  }
6348
6868
  }
6349
6869
  //#endregion
6870
+ //#region src/author-assets.ts
6871
+ /**
6872
+ * 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
6873
+ * 与书架同惯例持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
6874
+ * 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
6875
+ * 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
6876
+ */
6877
+ /** 作者资产库配置文件路径。 */
6878
+ function authorAssetsFile() {
6879
+ return join(homedir(), ".dsh", "dsh-novel-forge-author-assets.json");
6880
+ }
6881
+ /** 默认空资产库。 */
6882
+ function defaultLibrary() {
6883
+ return {
6884
+ version: 1,
6885
+ items: []
6886
+ };
6887
+ }
6888
+ /** 读取作者资产库(不存在/损坏则返回空库,不抛错)。 */
6889
+ function loadAuthorAssets() {
6890
+ const file = authorAssetsFile();
6891
+ if (!existsSync(file)) return defaultLibrary();
6892
+ try {
6893
+ let raw = readFileSync(file, "utf8");
6894
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
6895
+ const parsed = JSON.parse(raw);
6896
+ if (!Array.isArray(parsed.items)) return defaultLibrary();
6897
+ return {
6898
+ version: 1,
6899
+ items: parsed.items
6900
+ };
6901
+ } catch {
6902
+ return defaultLibrary();
6903
+ }
6904
+ }
6905
+ /** 持久化作者资产库。 */
6906
+ function saveAuthorAssets(library) {
6907
+ const file = authorAssetsFile();
6908
+ mkdirSync(join(homedir(), ".dsh"), { recursive: true });
6909
+ writeFileSync(file, JSON.stringify(library, null, 2), "utf8");
6910
+ }
6911
+ /** 按 id 更新一条资产;不存在则追加。 */
6912
+ function upsertAuthorAsset(input) {
6913
+ const library = loadAuthorAssets();
6914
+ const idx = library.items.findIndex((a) => a.id === input.id);
6915
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6916
+ if (idx >= 0) {
6917
+ const prev = library.items[idx];
6918
+ if (prev !== void 0) library.items[idx] = {
6919
+ ...prev,
6920
+ ...input,
6921
+ id: prev.id,
6922
+ createdAt: prev.createdAt,
6923
+ updatedAt: now
6924
+ };
6925
+ } else library.items.push({
6926
+ ...input,
6927
+ createdAt: input.createdAt !== "" ? input.createdAt : now,
6928
+ updatedAt: now
6929
+ });
6930
+ saveAuthorAssets(library);
6931
+ return library;
6932
+ }
6933
+ /** 删除一条资产;返回更新后的资产库。 */
6934
+ function removeAuthorAsset(id) {
6935
+ const library = loadAuthorAssets();
6936
+ library.items = library.items.filter((a) => a.id !== id);
6937
+ saveAuthorAssets(library);
6938
+ return library;
6939
+ }
6940
+ /**
6941
+ * 导入默认:把书架所有书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。
6942
+ * 按 `kind:name` 去重(已存在则跳过),不覆盖用户已有条目。
6943
+ * @returns 更新后的资产库。
6944
+ */
6945
+ function importDefaultAuthorAssets() {
6946
+ const library = loadAuthorAssets();
6947
+ const seen = new Set(library.items.map((a) => a.kind + ":" + a.name));
6948
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6949
+ const push = (kind, name, summary, content, tags, sourceBooks, structured) => {
6950
+ const key = kind + ":" + name.trim();
6951
+ if (name.trim() === "" || seen.has(key)) return;
6952
+ seen.add(key);
6953
+ library.items.push({
6954
+ id: "aa-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex"),
6955
+ kind,
6956
+ name: name.trim(),
6957
+ summary: summary.trim(),
6958
+ content: content.trim(),
6959
+ sourceBooks,
6960
+ tags,
6961
+ structured,
6962
+ createdAt: now,
6963
+ updatedAt: now
6964
+ });
6965
+ };
6966
+ for (const g of flattenGenres(BUILTIN_GENRE_LIBRARY)) push("genre", g.name, g.description, g.description, ["内置", "题材"], [], {
6967
+ description: g.description,
6968
+ children: (g.children ?? []).map((c) => c.name)
6969
+ });
6970
+ for (const r of BUILTIN_ANTI_AI_RULES) push("antiAi", r.name, r.avoid, "避免:" + r.avoid + "\n修正:" + r.fix, ["内置", "反AI"], [], {
6971
+ avoid: r.avoid,
6972
+ fix: r.fix,
6973
+ detectPatterns: r.detectPatterns
6974
+ });
6975
+ for (const t of BUILTIN_STYLE_TEMPLATES) push("style", t.name, t.description, "分类:" + t.category + "\n适用:" + t.applicableGenres.join("、") + "\n叙述:" + t.proseRules.join(";") + "\n台词:" + t.dialogueRules.join(";") + "\n语言:" + t.languageRules.join(";") + "\n节奏:" + t.rhythmRules.join(";"), ["内置", "风格模板"], [], {
6976
+ category: t.category,
6977
+ applicableGenres: t.applicableGenres
6978
+ });
6979
+ for (const p of BUILTIN_PROGRESSION_MODES) push("progression", p.name, p.driver, "驱动:" + p.driver + "\n期待:" + p.readerExpectation + "\n兑现:" + p.payoffs.join(";") + "\n风险:" + p.risks.join(";"), ["内置", "推进"], [], {
6980
+ driver: p.driver,
6981
+ primary: p.primary
6982
+ });
6983
+ const shelf = loadBookshelf();
6984
+ for (const book of shelf.books) {
6985
+ const project = loadProject(book.outputDir);
6986
+ if (project === void 0) continue;
6987
+ const src = [project.bookName];
6988
+ const assets = project.assets;
6989
+ if (assets !== void 0) {
6990
+ if (assets.genre !== void 0) push("genre", assets.genre.name, assets.genre.description, assets.genre.description, ["书", "题材"], src, { description: assets.genre.description });
6991
+ if (assets.primaryProgression !== void 0) push("progression", assets.primaryProgression.name, assets.primaryProgression.driver, "驱动:" + assets.primaryProgression.driver + "\n期待:" + assets.primaryProgression.readerExpectation, src, [], {
6992
+ driver: assets.primaryProgression.driver,
6993
+ primary: true
6994
+ });
6995
+ for (const p of assets.auxiliaryProgressions ?? []) push("progression", p.name, p.driver, "驱动:" + p.driver + "\n期待:" + p.readerExpectation, src, [], {
6996
+ driver: p.driver,
6997
+ primary: false
6998
+ });
6999
+ for (const r of assets.antiAiRules ?? []) push("antiAi", r.name, r.avoid, "避免:" + r.avoid + "\n修正:" + r.fix, src, [], {
7000
+ avoid: r.avoid,
7001
+ fix: r.fix,
7002
+ detectPatterns: r.detectPatterns
7003
+ });
7004
+ for (const s of assets.styleAssets ?? []) push("style", s.name, s.proseRules.join(";"), "叙述:" + s.proseRules.join(";") + "\n台词:" + s.dialogueRules.join(";") + "\n描写:" + s.descriptionRules.join(";") + "\n边界:" + s.boundaries.join(";"), src, [], { sourceText: s.sourceText });
7005
+ }
7006
+ for (const role of project.roles ?? []) {
7007
+ const persona = ("身份:" + role.identity + "\n性格:" + role.traits.join("、") + "\n目标:" + role.goals + "\n关系:" + role.relations.join("、") + "\n成长线:" + (role.arc.length > 0 ? role.arc.join(" > ") : "—") + "\n知情度:" + role.knowledge.join("、")).trim();
7008
+ push("roleTemplate", role.name, role.identity, persona, src, [], {
7009
+ roleLabel: role.roleLabel,
7010
+ traits: role.traits,
7011
+ relations: role.relations,
7012
+ arc: role.arc,
7013
+ knowledge: role.knowledge
7014
+ });
7015
+ }
7016
+ }
7017
+ saveAuthorAssets(library);
7018
+ return library;
7019
+ }
7020
+ /** 拍平题材库(含子题材,父级名称并入 tag)。 */
7021
+ function flattenGenres(nodes, parentName) {
7022
+ const out = [];
7023
+ for (const n of nodes) {
7024
+ out.push({
7025
+ ...n,
7026
+ name: parentName !== void 0 ? parentName + "/" + n.name : n.name
7027
+ });
7028
+ if (n.children.length > 0) out.push(...flattenGenres(n.children, parentName !== void 0 ? parentName + "/" + n.name : n.name));
7029
+ }
7030
+ return out;
7031
+ }
7032
+ //#endregion
6350
7033
  //#region src/routes.ts
6351
7034
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
6352
7035
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
@@ -8533,6 +9216,99 @@ function makeRoutes(deps) {
8533
9216
  }
8534
9217
  }
8535
9218
  };
9219
+ /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
9220
+ const llmTestRoute = {
9221
+ kind: "exact",
9222
+ path: NOVEL_API.llmTest,
9223
+ handler: async (req, res) => {
9224
+ if (!guard(req, res, "POST")) return;
9225
+ const body = await readJsonBody(req);
9226
+ const provider = (body?.provider ?? "").trim();
9227
+ const model = (body?.model ?? "").trim();
9228
+ if (provider === "" || model === "") {
9229
+ writeJson(res, 400, { error: "provider 与 model 必填" });
9230
+ return;
9231
+ }
9232
+ writeJson(res, 200, await testLlmModel(ctx, provider, model));
9233
+ }
9234
+ };
9235
+ /** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
9236
+ const addModelRoute = {
9237
+ kind: "exact",
9238
+ path: NOVEL_API.addModel,
9239
+ handler: async (req, res) => {
9240
+ if (!guard(req, res, "POST")) return;
9241
+ const body = await readJsonBody(req);
9242
+ if (body === void 0) {
9243
+ writeJson(res, 400, { error: "缺少请求体" });
9244
+ return;
9245
+ }
9246
+ try {
9247
+ writeJson(res, 200, await registerLlmModel(ctx, body));
9248
+ } catch (error) {
9249
+ writeJson(res, 500, { error: `添加模型失败:${error.message}` });
9250
+ }
9251
+ }
9252
+ };
9253
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
9254
+ const llmVendorsRoute = {
9255
+ kind: "exact",
9256
+ path: NOVEL_API.llmVendors,
9257
+ handler: async (req, res) => {
9258
+ if (!guard(req, res, "GET")) return;
9259
+ try {
9260
+ writeJson(res, 200, await listLlmVendors(ctx));
9261
+ } catch (error) {
9262
+ writeJson(res, 500, { error: `读取厂商目录失败:${error.message}` });
9263
+ }
9264
+ }
9265
+ };
9266
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
9267
+ const llmModelsRoute = {
9268
+ kind: "exact",
9269
+ path: NOVEL_API.llmModels,
9270
+ handler: async (req, res) => {
9271
+ if (!guard(req, res, "GET")) return;
9272
+ const url = new URL(req.url ?? "", "http://localhost");
9273
+ const provider = decodeURIComponent((url.searchParams.get("provider") ?? "").trim());
9274
+ try {
9275
+ writeJson(res, 200, await listLlmModels(ctx, provider));
9276
+ } catch (error) {
9277
+ writeJson(res, 500, { error: `读取模型列表失败:${error.message}` });
9278
+ }
9279
+ }
9280
+ };
9281
+ /** 已注册的提供方路由列表(提供方管理卡片)。 */
9282
+ const llmProvidersRoute = {
9283
+ kind: "exact",
9284
+ path: NOVEL_API.llmProviders,
9285
+ handler: async (req, res) => {
9286
+ if (!guard(req, res, "GET")) return;
9287
+ try {
9288
+ writeJson(res, 200, await listLlmProviders(ctx));
9289
+ } catch (error) {
9290
+ writeJson(res, 500, { error: `读取提供方列表失败:${error.message}` });
9291
+ }
9292
+ }
9293
+ };
9294
+ /** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
9295
+ const llmRemoveRoute = {
9296
+ kind: "exact",
9297
+ path: NOVEL_API.llmRemove,
9298
+ handler: async (req, res) => {
9299
+ if (!guard(req, res, "POST")) return;
9300
+ const body = await readJsonBody(req);
9301
+ if (body === void 0) {
9302
+ writeJson(res, 400, { error: "缺少请求体" });
9303
+ return;
9304
+ }
9305
+ try {
9306
+ writeJson(res, 200, await removeLlmProvider(ctx, body));
9307
+ } catch (error) {
9308
+ writeJson(res, 500, { error: `移除提供方失败:${error.message}` });
9309
+ }
9310
+ }
9311
+ };
8536
9312
  /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
8537
9313
  const storyboardTableRoute = {
8538
9314
  kind: "exact",
@@ -8783,6 +9559,191 @@ function makeRoutes(deps) {
8783
9559
  ctx,
8784
9560
  getConfig
8785
9561
  });
9562
+ const runStartRoute = {
9563
+ kind: "exact",
9564
+ path: NOVEL_API.runStart,
9565
+ handler: async (req, res) => {
9566
+ if (!guard(req, res, "POST")) return;
9567
+ const body = await readJsonBody(req);
9568
+ const project = loadProject(getConfig().outputDir);
9569
+ if (project === void 0) {
9570
+ writeJson(res, 400, { error: "输出目录中没有项目" });
9571
+ return;
9572
+ }
9573
+ const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
9574
+ let endNo;
9575
+ if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
9576
+ else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
9577
+ else {
9578
+ writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
9579
+ return;
9580
+ }
9581
+ try {
9582
+ writeJson(res, 200, await runner.start(startNo, endNo));
9583
+ } catch (error) {
9584
+ writeJson(res, 409, { error: error.message });
9585
+ }
9586
+ }
9587
+ };
9588
+ const runControlRoute = {
9589
+ kind: "exact",
9590
+ path: NOVEL_API.runControl,
9591
+ handler: async (req, res) => {
9592
+ if (!guard(req, res, "POST")) return;
9593
+ const body = await readJsonBody(req);
9594
+ if (runner.status() === null) {
9595
+ writeJson(res, 400, { error: "没有生产单" });
9596
+ return;
9597
+ }
9598
+ if (body?.action === "pause") runner.pause();
9599
+ else if (body?.action === "resume") runner.resume();
9600
+ else if (body?.action === "stop") runner.stop();
9601
+ else {
9602
+ writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
9603
+ return;
9604
+ }
9605
+ writeJson(res, 200, runner.status());
9606
+ }
9607
+ };
9608
+ const runStatusRoute = {
9609
+ kind: "exact",
9610
+ path: NOVEL_API.runStatus,
9611
+ handler: (req, res) => {
9612
+ if (!guard(req, res, "GET")) return;
9613
+ writeJson(res, 200, runner.status());
9614
+ }
9615
+ };
9616
+ const authorAssetsRoute = {
9617
+ kind: "exact",
9618
+ path: NOVEL_API.authorAssets,
9619
+ handler: (req, res) => {
9620
+ if (!guard(req, res, "GET")) return;
9621
+ writeJson(res, 200, { assets: loadAuthorAssets() });
9622
+ }
9623
+ };
9624
+ const authorAssetsUpsertRoute = {
9625
+ kind: "exact",
9626
+ path: NOVEL_API.authorAssetsUpsert,
9627
+ handler: async (req, res) => {
9628
+ if (!guard(req, res, "POST")) return;
9629
+ const asset = (await readJsonBody(req))?.asset;
9630
+ if (asset === void 0 || asset.name.trim() === "" || asset.content.trim() === "") {
9631
+ writeJson(res, 400, { error: "asset 须含 name / content" });
9632
+ return;
9633
+ }
9634
+ writeJson(res, 200, { assets: upsertAuthorAsset(asset) });
9635
+ }
9636
+ };
9637
+ const authorAssetsRemoveRoute = {
9638
+ kind: "exact",
9639
+ path: NOVEL_API.authorAssetsRemove,
9640
+ handler: async (req, res) => {
9641
+ if (!guard(req, res, "POST")) return;
9642
+ const id = (await readJsonBody(req))?.id ?? "";
9643
+ if (id === "") {
9644
+ writeJson(res, 400, { error: "id 必填" });
9645
+ return;
9646
+ }
9647
+ writeJson(res, 200, { assets: removeAuthorAsset(id) });
9648
+ }
9649
+ };
9650
+ const authorAssetsImportDefaultRoute = {
9651
+ kind: "exact",
9652
+ path: NOVEL_API.authorAssetsImportDefault,
9653
+ handler: (req, res) => {
9654
+ if (!guard(req, res, "POST")) return;
9655
+ writeJson(res, 200, { assets: importDefaultAuthorAssets() });
9656
+ }
9657
+ };
9658
+ const adaptAnalyzeRoute = {
9659
+ kind: "exact",
9660
+ path: NOVEL_API.adaptAnalyze,
9661
+ handler: async (req, res) => {
9662
+ if (!guard(req, res, "POST")) return;
9663
+ if (!getConfig().enableAdaptMode) {
9664
+ writeJson(res, 404, { error: "改编模式未开启" });
9665
+ return;
9666
+ }
9667
+ const body = await readJsonBody(req);
9668
+ const config = getConfig();
9669
+ let text = body?.text?.trim() ?? "";
9670
+ if (text === "" && body?.filePath !== void 0 && body.filePath !== "") try {
9671
+ text = readFileSync(body.filePath, "utf8");
9672
+ } catch (err) {
9673
+ writeJson(res, 400, { error: "读取文件失败:" + err.message });
9674
+ return;
9675
+ }
9676
+ if (text.length < 200) {
9677
+ writeJson(res, 400, { error: "全文内容过短(<200 字符),请上传完整小说文本" });
9678
+ return;
9679
+ }
9680
+ try {
9681
+ writeJson(res, 200, await analyzeAdaptation(ctx, config, text));
9682
+ } catch (err) {
9683
+ writeJson(res, 400, { error: err.message });
9684
+ }
9685
+ }
9686
+ };
9687
+ const adaptProposeRoute = {
9688
+ kind: "exact",
9689
+ path: NOVEL_API.adaptPropose,
9690
+ handler: async (req, res) => {
9691
+ if (!guard(req, res, "POST")) return;
9692
+ if (!getConfig().enableAdaptMode) {
9693
+ writeJson(res, 404, { error: "改编模式未开启" });
9694
+ return;
9695
+ }
9696
+ const body = await readJsonBody(req);
9697
+ const config = getConfig();
9698
+ const text = (body?.text ?? "").trim();
9699
+ const selections = body?.selections ?? [];
9700
+ if (text.length < 200 || selections.length === 0) {
9701
+ writeJson(res, 400, { error: "请提供全文与至少一条要改的维度" });
9702
+ return;
9703
+ }
9704
+ try {
9705
+ writeJson(res, 200, await proposeAdaptation(ctx, config, text, selections, body?.dimensions));
9706
+ } catch (err) {
9707
+ writeJson(res, 400, { error: err.message });
9708
+ }
9709
+ }
9710
+ };
9711
+ const adaptExecuteRoute = {
9712
+ kind: "exact",
9713
+ path: NOVEL_API.adaptExecute,
9714
+ handler: async (req, res) => {
9715
+ if (!guard(req, res, "POST")) return;
9716
+ if (!getConfig().enableAdaptMode) {
9717
+ writeJson(res, 404, { error: "改编模式未开启" });
9718
+ return;
9719
+ }
9720
+ const body = await readJsonBody(req);
9721
+ const text = body?.text ?? "";
9722
+ const mappings = body?.mappings ?? [];
9723
+ if (text.length < 200 || mappings.length === 0) {
9724
+ writeJson(res, 400, { error: "请提供全文与映射表" });
9725
+ return;
9726
+ }
9727
+ if (body?.mode === "rewrite") {
9728
+ writeJson(res, 400, { error: "rewrite 模式尚未实现,请使用 replace(术语替换)" });
9729
+ return;
9730
+ }
9731
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings);
9732
+ writeJson(res, 200, {
9733
+ adaptedText,
9734
+ mappings: mappings.length,
9735
+ hits
9736
+ });
9737
+ }
9738
+ };
9739
+ const THEME_BG_DIR = join(homedir(), ".dsh", "dsh-novel-forge-assets");
9740
+ const MIME_BY_EXT = {
9741
+ png: "image/png",
9742
+ jpg: "image/jpeg",
9743
+ jpeg: "image/jpeg",
9744
+ webp: "image/webp",
9745
+ gif: "image/gif"
9746
+ };
8786
9747
  return [
8787
9748
  statusRoute,
8788
9749
  loadOutlineRoute,
@@ -8839,61 +9800,74 @@ function makeRoutes(deps) {
8839
9800
  styleImageRoute,
8840
9801
  storyboardSkeletonRoute,
8841
9802
  imageTestRoute,
9803
+ llmTestRoute,
9804
+ addModelRoute,
9805
+ llmVendorsRoute,
9806
+ llmModelsRoute,
9807
+ llmProvidersRoute,
9808
+ llmRemoveRoute,
8842
9809
  storyboardTableRoute,
8843
9810
  storyboardPromptsRoute,
8844
9811
  breakdownRoute,
9812
+ runStartRoute,
9813
+ runControlRoute,
9814
+ runStatusRoute,
9815
+ authorAssetsRoute,
9816
+ authorAssetsUpsertRoute,
9817
+ authorAssetsRemoveRoute,
9818
+ authorAssetsImportDefaultRoute,
9819
+ adaptAnalyzeRoute,
9820
+ adaptProposeRoute,
9821
+ adaptExecuteRoute,
8845
9822
  {
8846
9823
  kind: "exact",
8847
- path: NOVEL_API.runStart,
9824
+ path: NOVEL_API.themeBackgroundUpload,
8848
9825
  handler: async (req, res) => {
8849
9826
  if (!guard(req, res, "POST")) return;
8850
- const body = await readJsonBody(req);
8851
- const project = loadProject(getConfig().outputDir);
8852
- if (project === void 0) {
8853
- writeJson(res, 400, { error: "输出目录中没有项目" });
9827
+ const dataUrl = (await readJsonBody(req))?.dataUrl ?? "";
9828
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl);
9829
+ if (m === null) {
9830
+ writeJson(res, 400, { error: "仅支持 PNG/JPG/WebP/GIF 图片" });
8854
9831
  return;
8855
9832
  }
8856
- const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
8857
- let endNo;
8858
- if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
8859
- else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
8860
- else {
8861
- writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
9833
+ const ext = m[1].toLowerCase() === "jpg" ? "jpeg" : m[1].toLowerCase();
9834
+ const buf = Buffer.from(m[2], "base64");
9835
+ if (buf.length === 0) {
9836
+ writeJson(res, 400, { error: "图片数据为空" });
8862
9837
  return;
8863
9838
  }
8864
- try {
8865
- writeJson(res, 200, await runner.start(startNo, endNo));
8866
- } catch (error) {
8867
- writeJson(res, 409, { error: error.message });
8868
- }
9839
+ mkdirSync(THEME_BG_DIR, { recursive: true });
9840
+ const name = "theme-bg-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex") + "." + ext;
9841
+ writeFileSync(join(THEME_BG_DIR, name), buf);
9842
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + "/" + name });
8869
9843
  }
8870
9844
  },
8871
9845
  {
8872
- kind: "exact",
8873
- path: NOVEL_API.runControl,
8874
- handler: async (req, res) => {
8875
- if (!guard(req, res, "POST")) return;
8876
- const body = await readJsonBody(req);
8877
- if (runner.status() === null) {
8878
- writeJson(res, 400, { error: "没有生产单" });
9846
+ kind: "prefix",
9847
+ path: NOVEL_API.themeBackgroundGet,
9848
+ handler: (req, res) => {
9849
+ if (!guard(req, res, "GET")) return;
9850
+ let pathName = "";
9851
+ try {
9852
+ pathName = new URL(req.url ?? "", "http://localhost").pathname;
9853
+ } catch {}
9854
+ const name = basename(pathName);
9855
+ if (!name.startsWith("theme-bg-")) {
9856
+ writeJson(res, 404, { error: "not found" });
8879
9857
  return;
8880
9858
  }
8881
- if (body?.action === "pause") runner.pause();
8882
- else if (body?.action === "resume") runner.resume();
8883
- else if (body?.action === "stop") runner.stop();
8884
- else {
8885
- writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
9859
+ const file = join(THEME_BG_DIR, name);
9860
+ if (!existsSync(file)) {
9861
+ writeJson(res, 404, { error: "not found" });
8886
9862
  return;
8887
9863
  }
8888
- writeJson(res, 200, runner.status());
8889
- }
8890
- },
8891
- {
8892
- kind: "exact",
8893
- path: NOVEL_API.runStatus,
8894
- handler: (req, res) => {
8895
- if (!guard(req, res, "GET")) return;
8896
- writeJson(res, 200, runner.status());
9864
+ const ext = extname(file).slice(1);
9865
+ const type = MIME_BY_EXT[ext] ?? "application/octet-stream";
9866
+ res.writeHead(200, {
9867
+ "content-type": type,
9868
+ "cache-control": "public, max-age=31536000"
9869
+ });
9870
+ res.end(readFileSync(file));
8897
9871
  }
8898
9872
  }
8899
9873
  ];
@@ -8957,7 +9931,11 @@ const Config = z.object({
8957
9931
  })).default([]),
8958
9932
  imageApiKey: z.string().default(""),
8959
9933
  imageApiModel: z.string().default(""),
8960
- imageApiEnabled: z.boolean().default(false)
9934
+ imageApiEnabled: z.boolean().default(false),
9935
+ themeBackground: z.string().default(""),
9936
+ themeBackgroundBlur: z.number().default(0),
9937
+ themeOpacity: z.number().default(100),
9938
+ enableAdaptMode: z.boolean().default(true)
8961
9939
  });
8962
9940
  /** Schema defaults, re-read for hand-built test contexts. */
8963
9941
  const DEFAULT_ANNOUNCE = true;
@@ -9010,7 +9988,11 @@ function resolveConfig(value) {
9010
9988
  imageApiModel: (() => {
9011
9989
  return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.model ?? value?.imageApiModel;
9012
9990
  })(),
9013
- imageApiEnabled: value?.imageApiEnabled ?? false
9991
+ imageApiEnabled: value?.imageApiEnabled ?? false,
9992
+ themeBackground: value?.themeBackground ?? "",
9993
+ themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
9994
+ themeOpacity: value?.themeOpacity ?? 100,
9995
+ enableAdaptMode: value?.enableAdaptMode ?? true
9014
9996
  };
9015
9997
  }
9016
9998
  /** 生图默认接口地址(豆包 ark,OpenAI 兼容)。 */
@@ -9048,6 +10030,10 @@ function apply(ctx, config) {
9048
10030
  if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
9049
10031
  if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
9050
10032
  if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
10033
+ if (patch.themeBackground !== void 0) next.themeBackground = patch.themeBackground;
10034
+ if (patch.themeBackgroundBlur !== void 0) next.themeBackgroundBlur = patch.themeBackgroundBlur;
10035
+ if (patch.themeOpacity !== void 0) next.themeOpacity = patch.themeOpacity;
10036
+ if (patch.enableAdaptMode !== void 0) next.enableAdaptMode = patch.enableAdaptMode;
9051
10037
  if (patch.imageModels !== void 0) next.imageModels = patch.imageModels;
9052
10038
  const settings = ctx.get("settings");
9053
10039
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);