@waterwx/dsh-novel-forge 1.3.2-alpha → 1.3.3-alpha

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 (53) hide show
  1. package/lib/client.js +555 -466
  2. package/lib/index.js +531 -93
  3. package/lib/types/ai-scan.d.ts +0 -6
  4. package/lib/types/assets.d.ts +0 -2
  5. package/lib/types/author-assets.d.ts +1 -3
  6. package/lib/types/bookshelf.d.ts +8 -3
  7. package/lib/types/client/api.d.ts +1 -0
  8. package/lib/types/client/docx.d.ts +0 -2
  9. package/lib/types/client/locales.d.ts +1 -0
  10. package/lib/types/client/mount.d.ts +0 -2
  11. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  12. package/lib/types/client/panel/views.d.ts +1 -33
  13. package/lib/types/client/sidebar-entry.d.ts +0 -2
  14. package/lib/types/home.d.ts +11 -0
  15. package/lib/types/index.d.ts +15 -3
  16. package/lib/types/llm-live.d.ts +0 -5
  17. package/lib/types/market-radar-scan.d.ts +0 -1
  18. package/lib/types/model-capability.d.ts +93 -0
  19. package/lib/types/output-budget.d.ts +86 -0
  20. package/lib/types/protocol.d.ts +21 -5
  21. package/lib/types/routes.d.ts +5 -3
  22. package/lib/types/settings-persist.d.ts +59 -0
  23. package/lib/types/stage-contract.d.ts +1 -3
  24. package/package.json +13 -11
  25. package/src/ai-scan.ts +0 -24
  26. package/src/assets.ts +0 -5
  27. package/src/author-assets.ts +4 -22
  28. package/src/bookshelf.ts +13 -7
  29. package/src/client/api.ts +2 -2
  30. package/src/client/docx.ts +0 -5
  31. package/src/client/locales.ts +1 -0
  32. package/src/client/mount.tsx +3 -2
  33. package/src/client/panel/AuthorAssetsView.tsx +1 -1
  34. package/src/client/panel/ImportModal.tsx +1 -1
  35. package/src/client/panel/MarketRadarView.tsx +55 -17
  36. package/src/client/panel/SettingsView.tsx +2 -1
  37. package/src/client/panel/market-radar-state.ts +79 -0
  38. package/src/client/panel/panel.module.css +4 -4
  39. package/src/client/panel/views.tsx +1 -86
  40. package/src/client/sidebar-entry.ts +3 -3
  41. package/src/engine.ts +103 -16
  42. package/src/global-assets.ts +3 -4
  43. package/src/home.ts +33 -0
  44. package/src/index.ts +136 -51
  45. package/src/llm-live.ts +0 -15
  46. package/src/market-radar-scan.ts +2 -8
  47. package/src/model-capability.ts +215 -0
  48. package/src/output-budget.ts +147 -0
  49. package/src/protocol.ts +22 -5
  50. package/src/routes.ts +34 -11
  51. package/src/settings-persist.ts +114 -0
  52. package/src/stage-contract.ts +1 -4
  53. package/src/review-policy.ts +0 -161
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { homedir, tmpdir } from "node:os";
2
- import { basename, extname, join, resolve, sep } from "node:path";
3
1
  import z from "schemastery";
4
2
  import { spawn } from "node:child_process";
5
3
  import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, extname, join, resolve, sep } from "node:path";
6
5
  import { randomBytes } from "node:crypto";
6
+ import { homedir, tmpdir } from "node:os";
7
7
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
8
8
  import { strFromU8, unzipSync } from "fflate";
9
9
  //#region src/assets.ts
@@ -3485,6 +3485,232 @@ async function withModelFallback(input, run) {
3485
3485
  }
3486
3486
  }
3487
3487
  }
3488
+ //#endregion
3489
+ //#region src/model-capability.ts
3490
+ const KIMI_SOURCE = "https://platform.kimi.com/docs/api/models-overview.md";
3491
+ /**
3492
+ * 把模型 id 归一化为「模型身份」。
3493
+ *
3494
+ * 处理:去空白与首尾斜杠、小写、剥掉供应商前缀(`kimi/kimi-k3` → `kimi-k3`)。
3495
+ * @param model - 原始模型 id。
3496
+ * @returns 归一化后的身份(小写、无前缀)。
3497
+ */
3498
+ function canonicalModelId(model) {
3499
+ if (typeof model !== "string") return "";
3500
+ const trimmed = model.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
3501
+ if (trimmed === "") return "";
3502
+ const segments = trimmed.split("/");
3503
+ return segments[segments.length - 1] ?? "";
3504
+ }
3505
+ const RULES = [
3506
+ {
3507
+ rule: "kimi-k3-fixed-temperature",
3508
+ source: KIMI_SOURCE,
3509
+ matches: (m) => /^kimi[-_.]?k-?3(?:[-_.]|$)/.test(m),
3510
+ capability: {
3511
+ fixedTemperature: 1,
3512
+ supportsReasoning: true,
3513
+ reasoningControl: "reasoning-effort"
3514
+ }
3515
+ },
3516
+ {
3517
+ rule: "kimi-k2.7-code-fixed-temperature",
3518
+ source: KIMI_SOURCE,
3519
+ matches: (m) => /^kimi[-_.]?k-?2[-_.]?7(?:[-_.]|$)/.test(m),
3520
+ capability: {
3521
+ fixedTemperature: 1,
3522
+ supportsReasoning: false,
3523
+ reasoningControl: "thinking",
3524
+ reasoningForcedOff: true
3525
+ }
3526
+ },
3527
+ {
3528
+ rule: "kimi-k2.6-temperature-by-thinking",
3529
+ source: KIMI_SOURCE,
3530
+ matches: (m) => /^kimi[-_.]?k-?2[-_.]?6(?:[-_.]|$)/.test(m),
3531
+ capability: {
3532
+ fixedTemperature: 1,
3533
+ supportsReasoning: false,
3534
+ reasoningControl: "thinking"
3535
+ }
3536
+ },
3537
+ {
3538
+ rule: "deepseek-reasoner-fixed-temperature",
3539
+ source: "OpenAI 兼容约定:DeepSeek R1/reasoner 不接受自定义 temperature",
3540
+ matches: (m) => /^deepseek[-_.]?(r1|reasoner)(?:[-_.]|$)/.test(m),
3541
+ capability: {
3542
+ fixedTemperature: 1,
3543
+ supportsReasoning: true,
3544
+ reasoningControl: "reasoning-effort"
3545
+ }
3546
+ }
3547
+ ];
3548
+ /**
3549
+ * 解析一条模型的能力约束。
3550
+ * @param model - 模型 id(可能带供应商前缀)。
3551
+ * @returns 命中的能力;无约束时返回 undefined。
3552
+ */
3553
+ function resolveModelCapability(model) {
3554
+ const canonical = canonicalModelId(model);
3555
+ if (canonical === "") return void 0;
3556
+ for (const rule of RULES) if (rule.matches(canonical)) return {
3557
+ ...rule.capability,
3558
+ rule: rule.rule,
3559
+ source: rule.source
3560
+ };
3561
+ }
3562
+ /**
3563
+ * 把请求参数收敛到模型允许的范围。
3564
+ *
3565
+ * @param params - 调用方希望使用的参数。
3566
+ * @param model - 实际要调用的模型 id(应传**实际模型**;换备用模型后需重算)。
3567
+ * @param options - `structured: true` 表示这是严格结构化输出任务;
3568
+ * `omitFixedTemperature: true` 表示调用方可以不传温度(推荐,厂商建议如此),
3569
+ * 此时固定温度会收敛为 undefined 而不是钳到固定值。
3570
+ * @returns 最终请求值与被调整项。
3571
+ */
3572
+ function convergeRequestParams(params, model, options = {}) {
3573
+ const capability = resolveModelCapability(model);
3574
+ const adjustments = [];
3575
+ let temperature = params.temperature;
3576
+ let reasoningEffort = params.reasoningEffort;
3577
+ if (capability?.fixedTemperature !== void 0) {
3578
+ const fixed = capability.fixedTemperature;
3579
+ if (options.omitFixedTemperature === true) {
3580
+ if (temperature !== void 0) adjustments.push(`temperature: ${temperature} -> 省略(${capability.rule} 固定 ${fixed},厂商建议不要显式传入)`);
3581
+ temperature = void 0;
3582
+ } else if (temperature !== fixed) {
3583
+ adjustments.push(`temperature: ${temperature} -> ${fixed}(${capability.rule} 固定值)`);
3584
+ temperature = fixed;
3585
+ }
3586
+ } else if (capability !== void 0) {
3587
+ const min = capability.minTemperature ?? 0;
3588
+ const max = capability.maxTemperature ?? 2;
3589
+ const clamped = Math.min(max, Math.max(min, temperature ?? min));
3590
+ if (clamped !== temperature) {
3591
+ adjustments.push(`temperature: ${temperature} -> ${clamped}(${capability.rule} 取值范围)`);
3592
+ temperature = clamped;
3593
+ }
3594
+ }
3595
+ if (capability?.supportsReasoning === false && reasoningEffort !== void 0) {
3596
+ if (reasoningEffort !== "off") adjustments.push(`reasoningEffort: ${reasoningEffort} -> 省略(${capability.rule} 不支持该参数)`);
3597
+ reasoningEffort = void 0;
3598
+ }
3599
+ if (options.structured === true && capability?.reasoningForcedOff === true && reasoningEffort !== void 0) {
3600
+ adjustments.push(`reasoningEffort: ${reasoningEffort} -> off(结构化任务强制关闭思考)`);
3601
+ reasoningEffort = "off";
3602
+ if (capability.reasoningControl === "thinking") reasoningEffort = void 0;
3603
+ }
3604
+ return {
3605
+ temperature,
3606
+ reasoningEffort,
3607
+ adjustments,
3608
+ ...capability === void 0 ? {} : {
3609
+ rule: capability.rule,
3610
+ source: capability.source
3611
+ }
3612
+ };
3613
+ }
3614
+ //#endregion
3615
+ //#region src/output-budget.ts
3616
+ /**
3617
+ * 数值口径:以**本插件真实产出字段的体积**折算,不是照搬参考项目的数字
3618
+ * (它的卷战略字段与本插件的章节字段结构不同,直接套用会造成新的截断)。
3619
+ *
3620
+ * 折算假设:中文约 1.5-2 token/字;账本式 JSON 的键名与括号再占约 15%。
3621
+ * 例如单章条目 = 标题(≤40) + 摘要(≤300) + 本章目标(≤200) + beats + 最多 4 条
3622
+ * payoff 指令 → 约 250-400 字 → **约 350 token/章**。若字段合同调整,这里必须同步。
3623
+ */
3624
+ const SPECS = {
3625
+ "volume-strategy": {
3626
+ floor: 1800,
3627
+ base: 1200,
3628
+ perUnit: 220,
3629
+ ceiling: 6e3
3630
+ },
3631
+ "chapter-list": {
3632
+ floor: 2500,
3633
+ base: 2e3,
3634
+ perUnit: 350,
3635
+ ceiling: 2e4
3636
+ },
3637
+ "character-cast": {
3638
+ floor: 1500,
3639
+ base: 1e3,
3640
+ perUnit: 150,
3641
+ ceiling: 8e3
3642
+ },
3643
+ "item-list": {
3644
+ floor: 1e3,
3645
+ base: 800,
3646
+ perUnit: 60,
3647
+ ceiling: 4e3
3648
+ },
3649
+ analysis: {
3650
+ floor: 3e3,
3651
+ base: 2e3,
3652
+ perUnit: 12,
3653
+ ceiling: 16e3
3654
+ }
3655
+ };
3656
+ /**
3657
+ * 取出与任务类型相关的那一个规模量。
3658
+ * @param kind - 任务类型。
3659
+ * @param scope - 规模描述。
3660
+ * @returns 单位数;分析类返回千字单位(向上取整)。
3661
+ */
3662
+ function scopeUnits(kind, scope) {
3663
+ const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
3664
+ switch (kind) {
3665
+ case "volume-strategy": return positive(scope.volumes);
3666
+ case "chapter-list": return positive(scope.chapters);
3667
+ case "character-cast": return positive(scope.characters);
3668
+ case "item-list": return positive(scope.items);
3669
+ case "analysis": return Math.ceil(positive(scope.sourceChars) / 1e3);
3670
+ }
3671
+ }
3672
+ /**
3673
+ * 按规模推算输出额度。
3674
+ * @param kind - 结构化任务类型。
3675
+ * @param scope - 本次任务的规模(缺省时按最小规模处理)。
3676
+ * @returns 建议的 `maxTokens`,落在该类型的 [floor, ceiling] 内。
3677
+ */
3678
+ function resolveOutputBudget(kind, scope = {}) {
3679
+ const spec = SPECS[kind];
3680
+ const raw = spec.base + scopeUnits(kind, scope) * spec.perUnit;
3681
+ return Math.min(spec.ceiling, Math.max(spec.floor, Math.round(raw)));
3682
+ }
3683
+ /**
3684
+ * 结构化字段长度合同 —— 与 prompt 里的长度声明必须逐项一致。
3685
+ *
3686
+ * 两边同步的理由:只写进 schema,模型不知道就会超长被拒;只写进 prompt,模型
3687
+ * 偶尔超长没人拦。本表是单一数据源。
3688
+ */
3689
+ const STRUCTURED_FIELD_LIMITS = {
3690
+ /** 分卷战略:书级阶梯类字段。 */
3691
+ volumeLadder: 160,
3692
+ /** 分卷战略:每卷定位标签。 */
3693
+ volumeRoleLabel: 32,
3694
+ /** 分卷战略:每卷核心回报 / 升级焦点。 */
3695
+ volumeReward: 64,
3696
+ /** 分卷战略:不确定项原因。 */
3697
+ volumeUncertaintyReason: 64,
3698
+ /** 章节计划:单章标题。 */
3699
+ chapterTitle: 40,
3700
+ /** 章节计划:单章摘要。 */
3701
+ chapterSummary: 300,
3702
+ /** 章节计划:本章目标。 */
3703
+ chapterGoal: 200
3704
+ };
3705
+ /**
3706
+ * 把长度合同渲染成可直接塞进 system prompt 的一句话,避免 prompt 与表漂移。
3707
+ * @param entries - 要声明的字段(描述 + 上限),按声明顺序渲染。
3708
+ * @returns 形如「为保证输出完整:A 不超过 160 字;B 不超过 64 字。」的中文说明。
3709
+ */
3710
+ function renderLengthContract(entries) {
3711
+ if (entries.length === 0) return "";
3712
+ return `为保证输出完整:${entries.map(([label, limit]) => `${label}不超过 ${limit} 字`).join(";")}。每个字段只保留完成本阶段决策所需的信息,写完后立即结束输出,不要展开细纲或重复说明。`;
3713
+ }
3488
3714
  /**
3489
3715
  * 本项目设计的参考曲线形状(0-100 的采样序列)。
3490
3716
  * 只描述"形状",不含任何外部数值表。
@@ -4649,7 +4875,7 @@ const COMPLIANCE_REDLINES = [
4649
4875
  "8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
4650
4876
  "9. 不得出现法律法规禁止的其他内容。"
4651
4877
  ];
4652
- /** 审稿维度取值(与 review-policy.ts 的 REVIEW_DIMENSIONS 对齐,用于归一化模型输出的 dimension 字段)。 */
4878
+ /** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
4653
4879
  const REVIEW_DIMENSION_IDS = /* @__PURE__ */ new Set([
4654
4880
  "character",
4655
4881
  "setting",
@@ -5150,6 +5376,35 @@ function createProject(outline, outlinePath) {
5150
5376
  updatedAt: now
5151
5377
  };
5152
5378
  }
5379
+ /**
5380
+ * 把已经拼好的请求过一遍模型参数能力层。
5381
+ *
5382
+ * **凡是自己构造 `GenerateOptions`、不走 {@link complete} 的调用点都必须经过它**,
5383
+ * 否则那条路径会绕过厂商参数约束(固定温度、不支持的 reasoning 等),对 Kimi
5384
+ * 这类模型直接报参数错误 —— 而"测试连通"恰恰是用户用来判断配置对不对的地方。
5385
+ *
5386
+ * 抽成函数而不是在每个调用点内联:流式调用点有 5 处,还要各自处理备用模型切换,
5387
+ * 内联五遍就是同一件事写五份,必然漂移。
5388
+ * @param base - 已拼好的请求(调用方已确定 temperature / reasoningEffort)。
5389
+ * @param model - **本次实际要调用的模型**(切换备用模型后必须重算,故在循环内调用)。
5390
+ * @param options - `structured` 表示严格结构化输出任务。
5391
+ * @returns 收敛后的请求(固定温度的模型会省略 temperature / 不支持的参数会被移除)。
5392
+ */
5393
+ function applyModelCapability(base, model, options = {}) {
5394
+ const converged = convergeRequestParams({
5395
+ temperature: base.temperature ?? .7,
5396
+ reasoningEffort: base.reasoningEffort === void 0 ? void 0 : String(base.reasoningEffort)
5397
+ }, model, {
5398
+ structured: options.structured === true,
5399
+ omitFixedTemperature: true
5400
+ });
5401
+ return {
5402
+ ...base,
5403
+ model,
5404
+ ...converged.temperature === void 0 ? { temperature: void 0 } : { temperature: converged.temperature },
5405
+ ...converged.reasoningEffort === void 0 ? { reasoningEffort: void 0 } : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
5406
+ };
5407
+ }
5153
5408
  /** One complete non-streaming LLM call. */
5154
5409
  async function complete(ctx, config, options) {
5155
5410
  const liveLabel = options.liveLabel ?? "LLM 调用";
@@ -5171,14 +5426,22 @@ async function complete(ctx, config, options) {
5171
5426
  system: options.system,
5172
5427
  user: options.user
5173
5428
  });
5429
+ const converged = convergeRequestParams({
5430
+ temperature: options.temperature ?? .7,
5431
+ reasoningEffort: options.reasoning ?? config.reasoningEffort ?? "off"
5432
+ }, model, {
5433
+ structured: options.structured === true,
5434
+ omitFixedTemperature: true
5435
+ });
5436
+ if (converged.adjustments.length > 0) ctx.logger("novel-forge").info("模型参数收敛(%s @ %s):%s", liveLabel, model, converged.adjustments.join(";"));
5174
5437
  const request = {
5175
5438
  provider: config.provider,
5176
5439
  model,
5177
5440
  messages,
5178
5441
  system: options.system,
5179
5442
  maxTokens: options.maxTokens ?? config.maxTokens,
5180
- temperature: options.temperature ?? .7,
5181
- reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? "off")
5443
+ ...converged.temperature === void 0 ? {} : { temperature: converged.temperature },
5444
+ ...converged.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
5182
5445
  };
5183
5446
  const assembler = new BlockAssembler();
5184
5447
  for await (const chunk of ctx.llm.stream(request)) {
@@ -5447,17 +5710,19 @@ function volumeSystemPrompt() {
5447
5710
  "输出必须是合法 JSON 数组,不要输出任何其他文字:",
5448
5711
  "[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
5449
5712
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
5450
- "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
5713
+ "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
5714
+ renderLengthContract([["卷名", STRUCTURED_FIELD_LIMITS.chapterTitle], ["每卷定位", STRUCTURED_FIELD_LIMITS.chapterSummary]])
5451
5715
  ].join("\n");
5452
5716
  }
5453
5717
  /** Plan volumes from an outline. */
5454
5718
  async function planVolumes(ctx, config, outline) {
5455
5719
  const user = `请为下面这部小说划分卷:\n\n${outline}`;
5720
+ const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1200)));
5456
5721
  const parsed = parseJsonArray(await complete(ctx, config, {
5457
5722
  system: volumeSystemPrompt(),
5458
5723
  user,
5459
5724
  temperature: .4,
5460
- maxTokens: Math.max(config.maxTokens, 12e3)
5725
+ maxTokens: resolveOutputBudget("volume-strategy", { volumes: expectedVolumes })
5461
5726
  }));
5462
5727
  const volumes = [];
5463
5728
  for (let i = 0; i < parsed.length; i++) {
@@ -5470,8 +5735,8 @@ async function planVolumes(ctx, config, outline) {
5470
5735
  const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
5471
5736
  volumes.push({
5472
5737
  no,
5473
- title: title.slice(0, 40),
5474
- summary: summary.slice(0, 300),
5738
+ title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
5739
+ summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
5475
5740
  chapterStart: start ?? 1,
5476
5741
  chapterEnd: end ?? 1
5477
5742
  });
@@ -5691,7 +5956,7 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
5691
5956
  system: planSystemPrompt(project.volumes) + (continuation ? "\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。" : ""),
5692
5957
  user,
5693
5958
  temperature: .7,
5694
- maxTokens: Math.max(config.maxTokens, 4e4),
5959
+ maxTokens: resolveOutputBudget("chapter-list", { chapters: chapterCount }),
5695
5960
  liveLabel: "章节规划"
5696
5961
  }, (t) => parseJsonArray(t));
5697
5962
  const chapters = [];
@@ -6179,7 +6444,8 @@ async function extractRoles(ctx, config, project) {
6179
6444
  system,
6180
6445
  user,
6181
6446
  temperature: .3,
6182
- maxTokens: Math.max(config.maxTokens, 16e3),
6447
+ maxTokens: resolveOutputBudget("character-cast", { characters: 20 }),
6448
+ structured: true,
6183
6449
  reasoning: config.analysisReasoning ?? "low"
6184
6450
  });
6185
6451
  let raw = parseJsonArray(text);
@@ -6190,7 +6456,8 @@ async function extractRoles(ctx, config, project) {
6190
6456
  system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
6191
6457
  user,
6192
6458
  temperature: .3,
6193
- maxTokens: Math.max(config.maxTokens, 12e3),
6459
+ maxTokens: resolveOutputBudget("character-cast", { characters: 24 }),
6460
+ structured: true,
6194
6461
  reasoning: config.analysisReasoning ?? "low"
6195
6462
  });
6196
6463
  raw = parseJsonArray(text);
@@ -6720,7 +6987,7 @@ function describeLlmTestError(err) {
6720
6987
  /** 对选中的提供商/模型发一次最小真实调用(maxTokens=16),验证 Key / 端点 / 模型可用。 */
6721
6988
  async function testLlmModel(ctx, provider, model) {
6722
6989
  const start = Date.now();
6723
- const request = {
6990
+ const request = applyModelCapability({
6724
6991
  provider,
6725
6992
  model,
6726
6993
  messages: [createUserMessage({
@@ -6735,7 +7002,7 @@ async function testLlmModel(ctx, provider, model) {
6735
7002
  })],
6736
7003
  maxTokens: 16,
6737
7004
  temperature: 0
6738
- };
7005
+ }, model);
6739
7006
  const controller = new AbortController();
6740
7007
  const timer = setTimeout(() => controller.abort(), 3e4);
6741
7008
  try {
@@ -7130,10 +7397,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
7130
7397
  });
7131
7398
  const assembler = new BlockAssembler();
7132
7399
  let produced = 0;
7133
- for await (const chunk of ctx.llm.stream({
7134
- ...request,
7135
- model
7136
- })) {
7400
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
7137
7401
  assembler.push(chunk);
7138
7402
  if (chunk.type === "text-delta") {
7139
7403
  produced += chunk.text.length;
@@ -7242,10 +7506,7 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
7242
7506
  });
7243
7507
  const assembler = new BlockAssembler();
7244
7508
  let produced = 0;
7245
- for await (const chunk of ctx.llm.stream({
7246
- ...request,
7247
- model
7248
- })) {
7509
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
7249
7510
  assembler.push(chunk);
7250
7511
  if (chunk.type === "text-delta") {
7251
7512
  produced += chunk.text.length;
@@ -7387,10 +7648,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
7387
7648
  });
7388
7649
  const assembler = new BlockAssembler();
7389
7650
  let produced = 0;
7390
- for await (const chunk of ctx.llm.stream({
7391
- ...request,
7392
- model
7393
- })) {
7651
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
7394
7652
  assembler.push(chunk);
7395
7653
  if (chunk.type === "text-delta") {
7396
7654
  produced += chunk.text.length;
@@ -8661,7 +8919,8 @@ async function runMarketRadar(ctx, config, req) {
8661
8919
  };
8662
8920
  return {
8663
8921
  signals: signalAs(raw.signals),
8664
- productionFoundation
8922
+ productionFoundation,
8923
+ reportId: `mr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
8665
8924
  };
8666
8925
  }
8667
8926
  const MARKET_MODE_HINT = {
@@ -9138,14 +9397,45 @@ function exportScope(outputDir, project, scope, format) {
9138
9397
  };
9139
9398
  }
9140
9399
  //#endregion
9400
+ //#region src/home.ts
9401
+ /**
9402
+ * DeepSeek Harness home resolution for this plugin.
9403
+ *
9404
+ * Precedence mirrors `resolveDshHome` from `@deepseek-ai/dsh-home-paths`:
9405
+ * `$DSH_HOME` when set and non-blank, else `~/.dsh`. That package's remaining
9406
+ * input — the harness's own explicit override — is not reachable from a
9407
+ * plugin, so this reproduces the part a plugin can observe instead of taking
9408
+ * a dependency whose published range tracks a different harness line.
9409
+ *
9410
+ * State files must resolve through here rather than `~/.dsh` directly: the
9411
+ * desktop build points `DSH_HOME` at its own root, and a hardcoded path made
9412
+ * both builds share one store and lose it whenever that directory was cleaned.
9413
+ */
9414
+ /**
9415
+ * Absolute Harness home for this process.
9416
+ * @returns the normalized home directory.
9417
+ */
9418
+ function dshHome() {
9419
+ const configured = process.env.DSH_HOME?.trim();
9420
+ return resolve(configured !== void 0 && configured.length > 0 ? configured : join(homedir(), ".dsh"));
9421
+ }
9422
+ /**
9423
+ * Join path segments onto the Harness home.
9424
+ * @param segments - path segments appended to the home.
9425
+ * @returns the normalized absolute joined path.
9426
+ */
9427
+ function dshHomePath(...segments) {
9428
+ return join(dshHome(), ...segments);
9429
+ }
9430
+ //#endregion
9141
9431
  //#region src/bookshelf.ts
9142
9432
  /**
9143
9433
  * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
9144
- * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
9434
+ * 状态持久化到 <DSH_HOME>/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
9145
9435
  */
9146
9436
  /** 书架配置文件路径。 */
9147
9437
  function bookshelfFile() {
9148
- return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
9438
+ return dshHomePath("dsh-novel-forge-bookshelf.json");
9149
9439
  }
9150
9440
  function defaultStore() {
9151
9441
  return {
@@ -9174,7 +9464,7 @@ function loadBookshelf() {
9174
9464
  /** 持久化书架。 */
9175
9465
  function saveBookshelf(store) {
9176
9466
  const file = bookshelfFile();
9177
- mkdirSync(join(homedir(), ".dsh"), { recursive: true });
9467
+ mkdirSync(dshHome(), { recursive: true });
9178
9468
  writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
9179
9469
  }
9180
9470
  /** 当前激活的书。 */
@@ -9300,10 +9590,15 @@ function repointBookDirs(from, to) {
9300
9590
  if (count > 0) saveBookshelf(store);
9301
9591
  return count;
9302
9592
  }
9303
- /** 默认输出目录推断:~/.dsh/novels/书名。 */
9304
- function defaultOutputDirFor(bookName) {
9593
+ /**
9594
+ * 新书默认输出目录:`<配置的输出目录>/书名`;未配置时回退 `<DSH_HOME>/novels/书名`。
9595
+ * @param bookName - 书名(剔除文件系统非法字符并截断到 40 字)。
9596
+ * @param baseDir - settings 里配置的输出目录(未经激活书合并的原始值)。
9597
+ * @returns 该书应写入的绝对目录。
9598
+ */
9599
+ function defaultOutputDirFor(bookName, baseDir) {
9305
9600
  const clean = bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说";
9306
- return join(homedir(), ".dsh", "novels", clean);
9601
+ return join(baseDir !== void 0 && baseDir.trim() !== "" ? baseDir.trim() : dshHomePath("novels"), clean);
9307
9602
  }
9308
9603
  const REVISION_SOURCE_LABELS = {
9309
9604
  review: "审稿",
@@ -11279,13 +11574,13 @@ function removeDir(dir) {
11279
11574
  //#region src/author-assets.ts
11280
11575
  /**
11281
11576
  * 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
11282
- * 与书架同惯例持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
11577
+ * 与书架同惯例持久化到 <DSH_HOME>/dsh-novel-forge-author-assets.json。
11283
11578
  * 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
11284
11579
  * 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
11285
11580
  */
11286
11581
  /** 作者资产库配置文件路径。 */
11287
11582
  function authorAssetsFile() {
11288
- return join(homedir(), ".dsh", "dsh-novel-forge-author-assets.json");
11583
+ return dshHomePath("dsh-novel-forge-author-assets.json");
11289
11584
  }
11290
11585
  /** 默认空资产库。 */
11291
11586
  function defaultLibrary() {
@@ -11315,7 +11610,7 @@ function loadAuthorAssets() {
11315
11610
  /** 持久化作者资产库。 */
11316
11611
  function saveAuthorAssets(library) {
11317
11612
  const file = authorAssetsFile();
11318
- mkdirSync(join(homedir(), ".dsh"), { recursive: true });
11613
+ mkdirSync(dshHome(), { recursive: true });
11319
11614
  writeFileSync(file, JSON.stringify(library, null, 2), "utf8");
11320
11615
  }
11321
11616
  /** 按 id 更新一条资产;不存在则追加。 */
@@ -11618,7 +11913,6 @@ async function collectMarketSource(source) {
11618
11913
  /**
11619
11914
  * 热门题材雷达:真实榜单扫榜(聚合各平台榜单源,容错)。
11620
11915
  */
11621
- let lastScan = null;
11622
11916
  async function scanMarketRanking(platforms) {
11623
11917
  const wanted = new Set(platforms?.length ? platforms : [
11624
11918
  "fanqie",
@@ -11650,11 +11944,10 @@ async function scanMarketRanking(platforms) {
11650
11944
  });
11651
11945
  }
11652
11946
  }));
11653
- lastScan = {
11947
+ return {
11654
11948
  scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
11655
11949
  groups
11656
11950
  };
11657
- return lastScan;
11658
11951
  }
11659
11952
  //#endregion
11660
11953
  //#region src/global-assets.ts
@@ -11662,7 +11955,7 @@ async function scanMarketRanking(platforms) {
11662
11955
  * 全局资源库(跨书可复用的自定义题材/推进模式)。
11663
11956
  * 雷达「同步底座到全局资源库」写入此处;/assets 返回时与内置库合并展示。
11664
11957
  */
11665
- const FILE = join(homedir(), ".dsh", "novel-forge-global-assets.json");
11958
+ const FILE = dshHomePath("novel-forge-global-assets.json");
11666
11959
  let cache = null;
11667
11960
  function load() {
11668
11961
  if (cache !== null) return cache;
@@ -11683,7 +11976,7 @@ function load() {
11683
11976
  }
11684
11977
  function persist() {
11685
11978
  if (cache === null) return;
11686
- mkdirSync(join(homedir(), ".dsh"), { recursive: true });
11979
+ mkdirSync(dshHome(), { recursive: true });
11687
11980
  writeFileSync(FILE, JSON.stringify(cache, null, 2), "utf8");
11688
11981
  }
11689
11982
  function globalGenreLibrary() {
@@ -11778,12 +12071,29 @@ async function readJsonBody(req) {
11778
12071
  /** Default chapter count for planning when the request omits it. */
11779
12072
  const DEFAULT_PLAN_COUNT = 30;
11780
12073
  /**
12074
+ * The profile this host booted (`--profile <name>` on the dsh CLI invocation).
12075
+ * Profile-scoped paths must not assume a name: the shipped template is `web`,
12076
+ * but a custom profile (`web-alpha`) is the one actually running.
12077
+ * @returns the profile name, or undefined when the flag is absent.
12078
+ */
12079
+ function argvProfile() {
12080
+ const flag = process.argv.indexOf("--profile");
12081
+ const value = flag === -1 ? void 0 : process.argv[flag + 1];
12082
+ return value !== void 0 && !value.startsWith("-") ? value : void 0;
12083
+ }
12084
+ /**
11781
12085
  * Build every /api/dsh-novel-forge route.
11782
12086
  * @param deps - context, config resolver, config patcher.
11783
12087
  * @returns the route list.
11784
12088
  */
11785
12089
  function makeRoutes(deps) {
11786
12090
  const { ctx, getConfig, patchConfig } = deps;
12091
+ /**
12092
+ * 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
12093
+ * outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
12094
+ * 回退到 `<DSH_HOME>/novels`。
12095
+ */
12096
+ const defaultBookDir = (bookName) => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir);
11787
12097
  /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
11788
12098
  let auditState = {
11789
12099
  status: "idle",
@@ -13645,7 +13955,7 @@ function makeRoutes(deps) {
13645
13955
  writeJson(res, 400, { error: "bookName 不能为空" });
13646
13956
  return;
13647
13957
  }
13648
- const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
13958
+ const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultBookDir(bookName);
13649
13959
  const book = createBook(bookName, outputDir);
13650
13960
  const outline = body?.outline?.trim();
13651
13961
  if (outline !== void 0 && outline.length >= 50) {
@@ -13756,7 +14066,7 @@ function makeRoutes(deps) {
13756
14066
  try {
13757
14067
  if (body?.text !== void 0 && body.text.length > 0) {
13758
14068
  const bookName = (body.fileName ?? "").replace(/\.[^.]+$/, "").trim().slice(0, 40) || "导入小说";
13759
- const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
14069
+ const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultBookDir(bookName);
13760
14070
  const result = importBookTextFromText(body.text, outDir, bookName);
13761
14071
  const { book } = importDir(outDir);
13762
14072
  writeJson(res, 200, {
@@ -13775,7 +14085,7 @@ function makeRoutes(deps) {
13775
14085
  return;
13776
14086
  }
13777
14087
  const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
13778
- const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
14088
+ const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultBookDir(bookName);
13779
14089
  const result = importBookText(filePath, outDir);
13780
14090
  const { book } = importDir(outDir);
13781
14091
  writeJson(res, 200, {
@@ -14446,7 +14756,7 @@ function makeRoutes(deps) {
14446
14756
  return;
14447
14757
  }
14448
14758
  try {
14449
- writeJson(res, 200, { config: await patchConfig(body) });
14759
+ writeJson(res, 200, await patchConfig(body));
14450
14760
  } catch (error) {
14451
14761
  writeJson(res, 400, { error: error.message });
14452
14762
  }
@@ -14485,7 +14795,7 @@ function makeRoutes(deps) {
14485
14795
  path: NOVEL_API.pluginUpdate,
14486
14796
  handler: async (req, res) => {
14487
14797
  if (!guard(req, res, "POST")) return;
14488
- const profileDir = join(homedir(), ".dsh", "profiles", "web");
14798
+ const profileDir = dshHomePath("profiles", argvProfile() ?? "web");
14489
14799
  writeJson(res, 200, await new Promise((resolve) => {
14490
14800
  const child = spawn("pnpm", ["add", "@waterwx/dsh-novel-forge@latest"], {
14491
14801
  cwd: profileDir,
@@ -15213,7 +15523,7 @@ function makeRoutes(deps) {
15213
15523
  }
15214
15524
  }
15215
15525
  };
15216
- const THEME_BG_DIR = join(homedir(), ".dsh", "dsh-novel-forge-assets");
15526
+ const THEME_BG_DIR = dshHomePath("dsh-novel-forge-assets");
15217
15527
  const MIME_BY_EXT = {
15218
15528
  png: "image/png",
15219
15529
  jpg: "image/jpeg",
@@ -15369,15 +15679,83 @@ function makeRoutes(deps) {
15369
15679
  ];
15370
15680
  }
15371
15681
  //#endregion
15372
- //#region src/index.ts
15682
+ //#region src/settings-persist.ts
15683
+ /**
15684
+ * The Loader entry id the settings section is addressed by.
15685
+ * @param ctx - plugin context (structurally typed: only `fiber.entry.options.id` is read).
15686
+ * @returns the entry id, or undefined when the host exposes none.
15687
+ */
15688
+ function novelSettingsNamespace(ctx) {
15689
+ const id = ctx?.fiber?.entry?.options?.id;
15690
+ return typeof id === "string" && id.trim() !== "" ? id : void 0;
15691
+ }
15692
+ /**
15693
+ * Pick the settings section to write through.
15694
+ *
15695
+ * The entry id is authoritative; the declared legacy name is used only when the
15696
+ * host actually registered a section under it. Guessing an id is never allowed:
15697
+ * a write to a wrong section would persist another plugin's config.
15698
+ * @param settings - the settings service, when present.
15699
+ * @param entryNs - entry id resolved from the context, when available.
15700
+ * @param legacyNs - this plugin's declared section name (0.1.x wiring).
15701
+ * @returns the section id to write to, or undefined when none is addressable.
15702
+ */
15703
+ function resolveSettingsTarget(settings, entryNs, legacyNs) {
15704
+ const describe = settings?.describe;
15705
+ if (settings?.update === void 0) return void 0;
15706
+ if (describe === void 0) return entryNs;
15707
+ try {
15708
+ const names = (describe() ?? []).map((row) => row?.ns).filter((ns) => typeof ns === "string");
15709
+ if (entryNs !== void 0 && names.includes(entryNs)) return entryNs;
15710
+ return names.includes(legacyNs) ? legacyNs : void 0;
15711
+ } catch {
15712
+ return entryNs;
15713
+ }
15714
+ }
15373
15715
  /**
15374
- * dsh-novel-forge — host half. Mounts the AI novel-forge workbench: docx
15375
- * outline import, LLM chapter planning, chapter-by-chapter generation
15376
- * (3000-4000 chars each), Markdown output into your chosen folder, and the
15377
- * /api/dsh-novel-forge route family. The browser half (./client) renders the
15378
- * workbench panel. Everything rides official NPM SDK packages — no dsh source
15379
- * changes.
15716
+ * Merge one patch into the live config and persist it when the host allows.
15717
+ *
15718
+ * Never throws for a persistence failure: the edit is valid and already applied,
15719
+ * so failing the request would lose it and misreport a working save as an error.
15720
+ * @param current - the config as currently resolved.
15721
+ * @param patch - the subset to merge (`undefined` values are ignored by callers).
15722
+ * @param settings - the settings service, when present.
15723
+ * @param entryNs - entry id resolved from the context, when available.
15724
+ * @param legacyNs - this plugin's declared section name.
15725
+ * @returns the merged config plus a warning when it could not be persisted.
15380
15726
  */
15727
+ async function persistConfigPatch(current, patch, settings, entryNs, legacyNs) {
15728
+ const next = {
15729
+ ...current,
15730
+ ...patch
15731
+ };
15732
+ const target = resolveSettingsTarget(settings, entryNs, legacyNs);
15733
+ if (settings?.update === void 0) return {
15734
+ config: next,
15735
+ settingsWarning: "宿主未提供 settings.update,配置只在本次进程内生效。"
15736
+ };
15737
+ if (target === void 0) {
15738
+ let registered = "不可枚举";
15739
+ try {
15740
+ registered = (settings.describe?.() ?? []).map((row) => row?.ns).filter((ns) => typeof ns === "string").join("、") || "(空)";
15741
+ } catch {}
15742
+ return {
15743
+ config: next,
15744
+ settingsWarning: `宿主 settings 未登记本插件的配置段(entry id「${entryNs ?? "未知"}」,已登记的段:${registered}),配置只在本次进程内生效。`
15745
+ };
15746
+ }
15747
+ try {
15748
+ await settings.update(target, patch);
15749
+ return { config: next };
15750
+ } catch (error) {
15751
+ return {
15752
+ config: next,
15753
+ settingsWarning: `写入宿主设置段「${target}」失败:${error instanceof Error ? error.message : String(error)};配置只在本次进程内生效。`
15754
+ };
15755
+ }
15756
+ }
15757
+ //#endregion
15758
+ //#region src/index.ts
15381
15759
  /** Stable cordis plugin name. */
15382
15760
  const name = "novel-forge";
15383
15761
  /** Services required before the novel-forge surfaces can mount. */
@@ -15389,49 +15767,85 @@ const inject = [
15389
15767
  ];
15390
15768
  /**
15391
15769
  * Settings namespace of the novel-forge capability — the section the web
15392
- * settings surface edits. Spelled here rather than imported: the browser half
15393
- * spells the same value and must not depend on a Host package.
15770
+ * settings surface edits.
15771
+ *
15772
+ * Legacy fallback only: from Harness 0.2 the Loader derives the section from this
15773
+ * plugin's exported `Config`, and addresses it by the **entry id** it assigned in
15774
+ * the active profile. That id is not guaranteed to be this name (bundle rows may
15775
+ * be given a generated id), so the live id is read from the context instead —
15776
+ * see {@link novelSettingsNamespace}. The constant stays for the browser half and
15777
+ * for installations whose entry id really is this spelling.
15394
15778
  */
15395
15779
  const NOVEL_SETTINGS_NAMESPACE = "dsh-novel-forge";
15780
+ /**
15781
+ * Mark a config field as live-editable.
15782
+ *
15783
+ * From Harness 0.2 the settings surface builds its editable form from this
15784
+ * plugin's exported `Config` and only exposes fields whose nearest ancestor
15785
+ * declares volatility (`SettingsForms.schema` + `volatileForm`). Without the
15786
+ * marker the host refuses a write with `Plugin entry "…" has no volatile
15787
+ * fields`, so the plugin's own 保存设置 route could never persist anything.
15788
+ *
15789
+ * The canonical form is schemastery's `.volatile()` builder, but that builder
15790
+ * ships only in the Harness fork (`@deepseek-ai/schemastery`); the npm
15791
+ * `schemastery` this package depends on exposes the same switch as metadata
15792
+ * (`extra('volatile', true)`), which is what the fork's builder sets anyway.
15793
+ * Prefer the builder when the loaded copy has it, fall back to the metadata so
15794
+ * the schema is correct on either copy.
15795
+ * @param schema - the field schema.
15796
+ * @returns the same field marked live-editable.
15797
+ */
15798
+ function live(schema) {
15799
+ const candidate = schema;
15800
+ if (typeof candidate.volatile === "function") return candidate.volatile();
15801
+ if (typeof candidate.extra === "function") return candidate.extra("volatile", true);
15802
+ return schema;
15803
+ }
15804
+ /**
15805
+ * Plugin config schema.
15806
+ *
15807
+ * Every field is live-editable (see {@link live}): the plugin re-reads the
15808
+ * settings section on every request, so a change must apply without remounting.
15809
+ */
15396
15810
  const Config = z.object({
15397
- announceToAgent: z.boolean().default(true),
15398
- enabled: z.boolean().default(true),
15399
- outlinePath: z.string().default(""),
15400
- outputDir: z.string().default(join(homedir(), ".dsh", "novels")),
15401
- provider: z.string().default("deepseek-official"),
15402
- model: z.string().default("deepseek-flash"),
15403
- generateModel: z.string().default(""),
15404
- reviewModel: z.string().default(""),
15405
- auditModel: z.string().default(""),
15406
- fallbackModel: z.string().default(""),
15407
- autoTimeline: z.boolean().default(true),
15408
- reasoningEffort: z.union([
15811
+ announceToAgent: live(z.boolean().default(true)),
15812
+ enabled: live(z.boolean().default(true)),
15813
+ outlinePath: live(z.string().default("")),
15814
+ outputDir: live(z.string().default(dshHomePath("novels"))),
15815
+ provider: live(z.string().default("deepseek-official")),
15816
+ model: live(z.string().default("deepseek-flash")),
15817
+ generateModel: live(z.string().default("")),
15818
+ reviewModel: live(z.string().default("")),
15819
+ auditModel: live(z.string().default("")),
15820
+ fallbackModel: live(z.string().default("")),
15821
+ autoTimeline: live(z.boolean().default(true)),
15822
+ reasoningEffort: live(z.union([
15409
15823
  "off",
15410
15824
  "low",
15411
15825
  "high",
15412
15826
  "max"
15413
- ]).default("off"),
15414
- analysisReasoning: z.union([
15827
+ ]).default("off")),
15828
+ analysisReasoning: live(z.union([
15415
15829
  "off",
15416
15830
  "low",
15417
15831
  "high",
15418
15832
  "max"
15419
- ]).default("low"),
15420
- chapterChars: z.number().default(3500),
15421
- maxTokens: z.number().default(12e3),
15422
- reviewPassScore: z.number().default(70),
15423
- autoReview: z.boolean().default(true),
15424
- autoAuthorReview: z.boolean().default(true),
15425
- autoReviewAfterRevise: z.boolean().default(true),
15426
- themeBackground: z.string().default(""),
15427
- themeBackgroundBlur: z.number().default(0),
15428
- themeOpacity: z.number().default(100),
15429
- enableAdaptMode: z.boolean().default(true)
15833
+ ]).default("low")),
15834
+ chapterChars: live(z.number().default(3500)),
15835
+ maxTokens: live(z.number().default(12e3)),
15836
+ reviewPassScore: live(z.number().default(70)),
15837
+ autoReview: live(z.boolean().default(true)),
15838
+ autoAuthorReview: live(z.boolean().default(true)),
15839
+ autoReviewAfterRevise: live(z.boolean().default(true)),
15840
+ themeBackground: live(z.string().default("")),
15841
+ themeBackgroundBlur: live(z.number().default(0)),
15842
+ themeOpacity: live(z.number().default(100)),
15843
+ enableAdaptMode: live(z.boolean().default(true))
15430
15844
  });
15431
15845
  /** Schema defaults, re-read for hand-built test contexts. */
15432
15846
  const DEFAULT_ANNOUNCE = true;
15433
15847
  const DEFAULT_OUTLINE_PATH = "";
15434
- const DEFAULT_OUTPUT_DIR = join(homedir(), ".dsh", "novels");
15848
+ const DEFAULT_OUTPUT_DIR = dshHomePath("novels");
15435
15849
  const DEFAULT_PROVIDER = "deepseek-official";
15436
15850
  const DEFAULT_MODEL = "deepseek-flash";
15437
15851
  const DEFAULT_REASONING_EFFORT = "off";
@@ -15447,7 +15861,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
15447
15861
  /** Order of the announcement section within the tool-guidance band. */
15448
15862
  const SECTION_ORDER = 160;
15449
15863
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
15450
- const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要与编年录事实、抽取故事时间线(故事内时间/地点/在场角色/事件)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑/反 AI/呈现/合规九维)与作者复盘;支持按审稿意见合并修订(审稿 high + 时间线矛盾 + 张力偏差合成一轮,每章最多两轮后转人工)、去 AI 味润色、章节历史版本回滚、暗线(伏笔)管理、剧情线与角色库、张力曲线、全书一致性质检与敏感词扫描、分范围导出与项目备份、批量连写(生产单,可暂停/续跑)。限制:所有 AI 操作消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度;张力/时间线问题默认只是建议(写日志与待办),不会自动改正文。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
15864
+ const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown(默认输出到插件设置里「输出目录」指定的位置,未配置时落到 Harness 数据目录下的 novels/书名);每章自动生成摘要与编年录事实、抽取故事时间线(故事内时间/地点/在场角色/事件)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑/反 AI/呈现/合规九维)与作者复盘;支持按审稿意见合并修订(审稿 high + 时间线矛盾 + 张力偏差合成一轮,每章最多两轮后转人工)、去 AI 味润色、章节历史版本回滚、暗线(伏笔)管理、剧情线与角色库、张力曲线、全书一致性质检与敏感词扫描、分范围导出与项目备份、批量连写(生产单,可暂停/续跑)。限制:所有 AI 操作消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度;张力/时间线问题默认只是建议(写日志与待办),不会自动改正文。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
15451
15865
  /** Resolve a config-like value into the full runtime config. */
15452
15866
  function resolveConfig(value) {
15453
15867
  return {
@@ -15480,7 +15894,11 @@ function resolveConfig(value) {
15480
15894
  * @param config - resolved plugin config (schema defaults applied by the loader).
15481
15895
  */
15482
15896
  function apply(ctx, config) {
15483
- let current = () => config ?? {};
15897
+ let currentState = config ?? {};
15898
+ const current = () => currentState;
15899
+ const setCurrent = (next) => {
15900
+ currentState = next;
15901
+ };
15484
15902
  const resolve = () => {
15485
15903
  const resolved = resolveConfig(current());
15486
15904
  const shelfDir = activeBookOutputDir();
@@ -15503,6 +15921,23 @@ function apply(ctx, config) {
15503
15921
  if (bs.autoReviewAfterRevise !== void 0) book.autoReviewAfterRevise = bs.autoReviewAfterRevise;
15504
15922
  return book;
15505
15923
  };
15924
+ /**
15925
+ * The settings section this instance is addressed by, resolved once at mount.
15926
+ * Undefined when the host exposes neither an entry id nor a registered section,
15927
+ * in which case config edits stay in memory (see {@link patchConfig}).
15928
+ */
15929
+ const settingsNs = novelSettingsNamespace(ctx);
15930
+ /**
15931
+ * Persist one config patch.
15932
+ *
15933
+ * Applies to the live config either way. Persistence goes through the host
15934
+ * settings section when that section is addressable; when it is not (unknown
15935
+ * entry id, or the section never registered), the patch is kept in memory and
15936
+ * the caller is told it will not survive a restart — a config save must never
15937
+ * fail the request just because the host does not offer persistence.
15938
+ * @param patch - the subset to merge.
15939
+ * @returns the config the host will read next, plus a persistence caveat.
15940
+ */
15506
15941
  const patchConfig = async (patch) => {
15507
15942
  const next = {};
15508
15943
  if (patch.outlinePath !== void 0) next.outlinePath = patch.outlinePath;
@@ -15527,12 +15962,13 @@ function apply(ctx, config) {
15527
15962
  if (patch.themeOpacity !== void 0) next.themeOpacity = patch.themeOpacity;
15528
15963
  if (patch.enableAdaptMode !== void 0) next.enableAdaptMode = patch.enableAdaptMode;
15529
15964
  const settings = ctx.get("settings");
15530
- if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
15531
- else current = () => ({
15532
- ...current(),
15533
- ...next
15534
- });
15535
- return resolve();
15965
+ const persisted = await persistConfigPatch(current(), next, settings, settingsNs, NOVEL_SETTINGS_NAMESPACE);
15966
+ setCurrent(persisted.config);
15967
+ if (persisted.settingsWarning !== void 0) ctx.logger("novel-forge").warn("%s", persisted.settingsWarning);
15968
+ return {
15969
+ config: resolve(),
15970
+ ...persisted.settingsWarning === void 0 ? {} : { settingsWarning: persisted.settingsWarning }
15971
+ };
15536
15972
  };
15537
15973
  let disposeSection;
15538
15974
  let disposeRoutes;
@@ -15556,7 +15992,8 @@ function apply(ctx, config) {
15556
15992
  ctx,
15557
15993
  getConfig: resolve,
15558
15994
  patchConfig,
15559
- rawConfig: () => current()
15995
+ rawConfig: () => current(),
15996
+ settingsNs
15560
15997
  });
15561
15998
  disposeRoutes = ctx.effect(() => {
15562
15999
  const disposers = routes.map((route) => ctx.webServer.register(route));
@@ -15565,9 +16002,10 @@ function apply(ctx, config) {
15565
16002
  };
15566
16003
  }, "dsh-novel-forge: routes");
15567
16004
  };
15568
- ctx.settings.installSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
16005
+ const settingsService = ctx.get("settings");
16006
+ if (settingsService !== void 0 && typeof settingsService.installSection === "function") settingsService.installSection(ctx, settingsNs ?? "dsh-novel-forge", Config, config ?? {}, {
15569
16007
  setSource: (source) => {
15570
- current = source;
16008
+ setCurrent(source());
15571
16009
  sync();
15572
16010
  },
15573
16011
  onChange: sync