@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.
- package/lib/client.js +555 -466
- package/lib/index.js +531 -93
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +0 -2
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +8 -3
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/docx.d.ts +0 -2
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/views.d.ts +1 -33
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +15 -3
- package/lib/types/llm-live.d.ts +0 -5
- package/lib/types/market-radar-scan.d.ts +0 -1
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/protocol.d.ts +21 -5
- package/lib/types/routes.d.ts +5 -3
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/stage-contract.d.ts +1 -3
- package/package.json +13 -11
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +0 -5
- package/src/author-assets.ts +4 -22
- package/src/bookshelf.ts +13 -7
- package/src/client/api.ts +2 -2
- package/src/client/docx.ts +0 -5
- package/src/client/locales.ts +1 -0
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AuthorAssetsView.tsx +1 -1
- package/src/client/panel/ImportModal.tsx +1 -1
- package/src/client/panel/MarketRadarView.tsx +55 -17
- package/src/client/panel/SettingsView.tsx +2 -1
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +4 -4
- package/src/client/panel/views.tsx +1 -86
- package/src/client/sidebar-entry.ts +3 -3
- package/src/engine.ts +103 -16
- package/src/global-assets.ts +3 -4
- package/src/home.ts +33 -0
- package/src/index.ts +136 -51
- package/src/llm-live.ts +0 -15
- package/src/market-radar-scan.ts +2 -8
- package/src/model-capability.ts +215 -0
- package/src/output-budget.ts +147 -0
- package/src/protocol.ts +22 -5
- package/src/routes.ts +34 -11
- package/src/settings-persist.ts +114 -0
- package/src/stage-contract.ts +1 -4
- 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
|
-
/**
|
|
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:
|
|
5181
|
-
reasoningEffort: ReasoningEffortId(
|
|
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:
|
|
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,
|
|
5474
|
-
summary: summary.slice(0,
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
* 状态持久化到
|
|
9434
|
+
* 状态持久化到 <DSH_HOME>/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
|
|
9145
9435
|
*/
|
|
9146
9436
|
/** 书架配置文件路径。 */
|
|
9147
9437
|
function bookshelfFile() {
|
|
9148
|
-
return
|
|
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(
|
|
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
|
-
/**
|
|
9304
|
-
|
|
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(
|
|
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
|
-
* 与书架同惯例持久化到
|
|
11577
|
+
* 与书架同惯例持久化到 <DSH_HOME>/dsh-novel-forge-author-assets.json。
|
|
11283
11578
|
* 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
|
|
11284
11579
|
* 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
|
|
11285
11580
|
*/
|
|
11286
11581
|
/** 作者资产库配置文件路径。 */
|
|
11287
11582
|
function authorAssetsFile() {
|
|
11288
|
-
return
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 :
|
|
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() :
|
|
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() :
|
|
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,
|
|
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 =
|
|
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 =
|
|
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/
|
|
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
|
-
*
|
|
15375
|
-
*
|
|
15376
|
-
*
|
|
15377
|
-
*
|
|
15378
|
-
*
|
|
15379
|
-
*
|
|
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.
|
|
15393
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
15531
|
-
|
|
15532
|
-
|
|
15533
|
-
|
|
15534
|
-
|
|
15535
|
-
|
|
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
|
|
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
|
-
|
|
16008
|
+
setCurrent(source());
|
|
15571
16009
|
sync();
|
|
15572
16010
|
},
|
|
15573
16011
|
onChange: sync
|