@waterwx/dsh-novel-forge 2.4.0 → 2.5.1
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/README.md +8 -6
- package/lib/client.js +2543 -1834
- package/lib/index.js +732 -134
- package/lib/types/assets.d.ts +5 -5
- package/lib/types/client/api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +0 -1
- package/lib/types/client/panel/ChapterWorkbench.d.ts +69 -0
- package/lib/types/client/panel/NameForgeView.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +0 -1
- package/lib/types/client/panel/StyleView.d.ts +4 -0
- package/lib/types/client/panel/WorkspaceView.d.ts +51 -0
- package/lib/types/client/panel/home-model.d.ts +53 -0
- package/lib/types/engine.d.ts +42 -2
- package/lib/types/index.d.ts +5 -1
- package/lib/types/name-forge.d.ts +110 -0
- package/lib/types/prompts/nameforge.d.ts +16 -0
- package/lib/types/protocol.d.ts +113 -1
- package/lib/types/style-fingerprint.d.ts +3 -2
- package/lib/types/style-profile.d.ts +62 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -278,10 +278,26 @@ function scanAiFlavor(text, rules) {
|
|
|
278
278
|
const totalChars = text.length;
|
|
279
279
|
const clicheHits = [];
|
|
280
280
|
let weightedTotal = 0;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
const occupied = new Uint8Array(text.length);
|
|
282
|
+
const patterns = Object.keys(PATTERN_COVERAGE).sort((a, b) => b.length - a.length);
|
|
283
|
+
for (const word of patterns) {
|
|
284
|
+
if (word.length === 0) continue;
|
|
284
285
|
const weight = patternWeight(PATTERN_COVERAGE[word] ?? 0);
|
|
286
|
+
let count = 0;
|
|
287
|
+
let at = text.indexOf(word);
|
|
288
|
+
while (at !== -1) {
|
|
289
|
+
let free = true;
|
|
290
|
+
for (let i = at; i < at + word.length; i++) if (occupied[i] === 1) {
|
|
291
|
+
free = false;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
if (free) {
|
|
295
|
+
count += 1;
|
|
296
|
+
for (let i = at; i < at + word.length; i++) occupied[i] = 1;
|
|
297
|
+
}
|
|
298
|
+
at = text.indexOf(word, at + word.length);
|
|
299
|
+
}
|
|
300
|
+
if (count === 0) continue;
|
|
285
301
|
clicheHits.push({
|
|
286
302
|
word,
|
|
287
303
|
count,
|
|
@@ -605,10 +621,11 @@ function formatMetric(metric, value) {
|
|
|
605
621
|
}
|
|
606
622
|
/**
|
|
607
623
|
* 渲染**生成端**的风格目标(区间形式)。
|
|
608
|
-
* @param reference -
|
|
624
|
+
* @param reference - 参照指纹(写法样本或蒸馏卡)。
|
|
625
|
+
* @param sourceLabel - 参照物的人类可读说明(缺省「绑定的写法样本」)。
|
|
609
626
|
* @returns 可注入写作 prompt 的文本;样本过短时返回空串。
|
|
610
627
|
*/
|
|
611
|
-
function renderFingerprintTargets(reference) {
|
|
628
|
+
function renderFingerprintTargets(reference, sourceLabel) {
|
|
612
629
|
if (reference.chars < MIN_CHARS) return "";
|
|
613
630
|
const items = [];
|
|
614
631
|
const add = (metric, suffix) => {
|
|
@@ -631,7 +648,7 @@ function renderFingerprintTargets(reference) {
|
|
|
631
648
|
add("imageryPer1000", " 次/千字");
|
|
632
649
|
if (items.length === 0) return "";
|
|
633
650
|
return [
|
|
634
|
-
"====================
|
|
651
|
+
"==================== 风格目标(确定性指标,取自" + (sourceLabel ?? "绑定的写法样本") + ") ====================",
|
|
635
652
|
"本章正文应落在这几个区间内。不是硬性红线,但明显越界会被审稿判为偏离绑定写法:",
|
|
636
653
|
...items
|
|
637
654
|
].join("\n");
|
|
@@ -3634,7 +3651,7 @@ function renderGenreAndProgression(assets) {
|
|
|
3634
3651
|
return sections.join("\n");
|
|
3635
3652
|
}
|
|
3636
3653
|
/** 渲染写法资产提示词块(规则去重,省 token)。 */
|
|
3637
|
-
function renderStyleAssets(assets) {
|
|
3654
|
+
function renderStyleAssets(assets, profile) {
|
|
3638
3655
|
const styles = assets?.styleAssets ?? [];
|
|
3639
3656
|
if (styles.length === 0) return "";
|
|
3640
3657
|
const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
|
|
@@ -3645,7 +3662,10 @@ function renderStyleAssets(assets) {
|
|
|
3645
3662
|
if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
|
|
3646
3663
|
if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
|
|
3647
3664
|
if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
|
|
3648
|
-
if (
|
|
3665
|
+
if (profile !== void 0) {
|
|
3666
|
+
const targets = renderFingerprintTargets(profile.fingerprint, profile.label);
|
|
3667
|
+
if (targets !== "") sections.push(targets);
|
|
3668
|
+
} else if (style.sourceText !== void 0 && style.sourceText !== "") {
|
|
3649
3669
|
const targets = renderFingerprintTargets(extractStyleFingerprint(style.sourceText));
|
|
3650
3670
|
if (targets !== "") sections.push(targets);
|
|
3651
3671
|
}
|
|
@@ -3653,10 +3673,10 @@ function renderStyleAssets(assets) {
|
|
|
3653
3673
|
return sections.join("\n");
|
|
3654
3674
|
}
|
|
3655
3675
|
/** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
|
|
3656
|
-
function renderAllAssets(assets) {
|
|
3676
|
+
function renderAllAssets(assets, profile) {
|
|
3657
3677
|
return [
|
|
3658
3678
|
renderGenreAndProgression(assets),
|
|
3659
|
-
renderStyleAssets(assets),
|
|
3679
|
+
renderStyleAssets(assets, profile),
|
|
3660
3680
|
renderAntiAiRules(assets)
|
|
3661
3681
|
].filter((part) => part !== "").join("\n\n");
|
|
3662
3682
|
}
|
|
@@ -4576,6 +4596,278 @@ function detectTimelineIssues(project) {
|
|
|
4576
4596
|
return issues;
|
|
4577
4597
|
}
|
|
4578
4598
|
//#endregion
|
|
4599
|
+
//#region src/name-forge.ts
|
|
4600
|
+
/** 取名类别表(顺序即面板顺序)。 */
|
|
4601
|
+
const NAME_CATEGORIES = [
|
|
4602
|
+
{
|
|
4603
|
+
id: "person",
|
|
4604
|
+
label: "人名",
|
|
4605
|
+
hint: "主角、配角、反派、路人——姓与名都要有题材气质。",
|
|
4606
|
+
brief: "人物姓名(姓氏+名,或复姓/单名/字号),需有记忆点,避免烂大街的谐音梗",
|
|
4607
|
+
placeholder: "例如:冷僻复姓;主角名带孤勇感;反派名要雅致但有压迫感",
|
|
4608
|
+
maxCount: 20
|
|
4609
|
+
},
|
|
4610
|
+
{
|
|
4611
|
+
id: "place",
|
|
4612
|
+
label: "地名",
|
|
4613
|
+
hint: "宗门所在地、秘境、城池、山川——世界观的空间锚点。",
|
|
4614
|
+
brief: "地名(山川/城池/秘境/宗门所在),需与世界观地理层级自洽",
|
|
4615
|
+
placeholder: "例如:剑修宗门设在雪山;秘境名字要带蛊惑感",
|
|
4616
|
+
maxCount: 20
|
|
4617
|
+
},
|
|
4618
|
+
{
|
|
4619
|
+
id: "technique",
|
|
4620
|
+
label: "功法",
|
|
4621
|
+
hint: "招式、心法、神通、禁术——战力体系的门面。",
|
|
4622
|
+
brief: "功法/招式/神通名(心法、剑式、禁术),需体现运转机理或意境,避免空泛形容词堆砌",
|
|
4623
|
+
placeholder: "例如:偏雷法;名字要短促狠戾;禁术名字带反噬感",
|
|
4624
|
+
maxCount: 20
|
|
4625
|
+
},
|
|
4626
|
+
{
|
|
4627
|
+
id: "faction",
|
|
4628
|
+
label: "势力",
|
|
4629
|
+
hint: "宗门、家族、王朝、组织——派系斗争的名字牌。",
|
|
4630
|
+
brief: "势力/组织/宗门/家族名,需体现其立身之道与行事风格",
|
|
4631
|
+
placeholder: "例如:邪道宗门;名字要堂皇但暗藏戾气",
|
|
4632
|
+
maxCount: 20
|
|
4633
|
+
},
|
|
4634
|
+
{
|
|
4635
|
+
id: "artifact",
|
|
4636
|
+
label: "宝物",
|
|
4637
|
+
hint: "法宝、丹药、神器、材料——金手指与争夺战的载体。",
|
|
4638
|
+
brief: "宝物/法宝/丹药/材料名,需暗示品阶或异能,避免「XX神器」式敷衍",
|
|
4639
|
+
placeholder: "例如:主角本命法宝;名字与功法同源",
|
|
4640
|
+
maxCount: 20
|
|
4641
|
+
}
|
|
4642
|
+
];
|
|
4643
|
+
/** 类别 id → 规格。 */
|
|
4644
|
+
const CATEGORY_BY_ID = new Map(NAME_CATEGORIES.map((c) => [c.id, c]));
|
|
4645
|
+
/** 取一个类别规格;未知 id 抛错(路由层转 400)。 */
|
|
4646
|
+
function categorySpec(id) {
|
|
4647
|
+
const spec = CATEGORY_BY_ID.get(id);
|
|
4648
|
+
if (spec === void 0) throw new Error(`未知取名类别:${id}`);
|
|
4649
|
+
return spec;
|
|
4650
|
+
}
|
|
4651
|
+
/** 名字长度与理由长度上限(截断显式化)。 */
|
|
4652
|
+
const NAME_MAX = 30;
|
|
4653
|
+
const NOTE_MAX = 120;
|
|
4654
|
+
/** 归一化一条候选/入库条目(裁剪 + 截断;去重由调用方处理)。 */
|
|
4655
|
+
function normalizeNameEntry(raw) {
|
|
4656
|
+
return {
|
|
4657
|
+
name: typeof raw.name === "string" ? raw.name.replace(/\s+/g, " ").trim().slice(0, NAME_MAX) : "",
|
|
4658
|
+
note: typeof raw.note === "string" ? raw.note.replace(/\r?\n/g, " ").trim().slice(0, NOTE_MAX) : ""
|
|
4659
|
+
};
|
|
4660
|
+
}
|
|
4661
|
+
/**
|
|
4662
|
+
* 解析取名 LLM 输出为候选列表。
|
|
4663
|
+
*
|
|
4664
|
+
* 宽容度分级:标准 JSON → 去围栏 → 截取首尾花括号;仍失败返回空数组(由
|
|
4665
|
+
* 调用方决定重试或报错),绝不把模型废话当名字。
|
|
4666
|
+
*
|
|
4667
|
+
* @param text 模型原始输出。
|
|
4668
|
+
* @param category 目标类别(用于校验字段完整性)。
|
|
4669
|
+
* @param count 期望条数(上限)。
|
|
4670
|
+
* @returns 候选列表(已去重、去空、截断)。
|
|
4671
|
+
*/
|
|
4672
|
+
function parseNameCandidates(text, count) {
|
|
4673
|
+
const raw = extractJsonObject(text);
|
|
4674
|
+
if (raw === void 0) return [];
|
|
4675
|
+
const list = Array.isArray(raw.names) ? raw.names : [];
|
|
4676
|
+
const out = [];
|
|
4677
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4678
|
+
for (const item of list) {
|
|
4679
|
+
if (typeof item !== "object" || item === null) continue;
|
|
4680
|
+
const rec = item;
|
|
4681
|
+
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
|
4682
|
+
if (name === "" || seen.has(name)) continue;
|
|
4683
|
+
seen.add(name);
|
|
4684
|
+
const note = typeof rec.note === "string" ? rec.note.trim().slice(0, NOTE_MAX) : "";
|
|
4685
|
+
out.push({
|
|
4686
|
+
name: name.slice(0, NAME_MAX),
|
|
4687
|
+
note
|
|
4688
|
+
});
|
|
4689
|
+
if (out.length >= count) break;
|
|
4690
|
+
}
|
|
4691
|
+
return out;
|
|
4692
|
+
}
|
|
4693
|
+
/** 从模型输出里尽力挖出一个 JSON 对象(标准 / 围栏 / 截取三档)。 */
|
|
4694
|
+
function extractJsonObject(text) {
|
|
4695
|
+
const attempts = [text.trim(), text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim()];
|
|
4696
|
+
for (const attempt of attempts) try {
|
|
4697
|
+
const parsed = JSON.parse(attempt);
|
|
4698
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
4699
|
+
} catch {}
|
|
4700
|
+
const start = text.indexOf("{");
|
|
4701
|
+
const end = text.lastIndexOf("}");
|
|
4702
|
+
if (start !== -1 && end > start) try {
|
|
4703
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
4704
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
4705
|
+
} catch {}
|
|
4706
|
+
}
|
|
4707
|
+
/**
|
|
4708
|
+
* 构造取名请求的 user 提示词。
|
|
4709
|
+
*
|
|
4710
|
+
* 书籍语境三件套:题材基调、世界设定(境界/区域/势力既有名)、已入库名字
|
|
4711
|
+
* (避重)——三者都可缺省,缺省时明确告诉模型「无既有设定,自由发挥」。
|
|
4712
|
+
*
|
|
4713
|
+
* @param category 类别规格。
|
|
4714
|
+
* @param count 期望条数。
|
|
4715
|
+
* @param hint 作者补充要求(可空)。
|
|
4716
|
+
* @param context 书籍语境。
|
|
4717
|
+
*/
|
|
4718
|
+
function buildNameForgeUserPrompt(category, count, hint, context) {
|
|
4719
|
+
const genre = (context.genre ?? "").trim();
|
|
4720
|
+
const world = (context.worldNames ?? []).filter((n) => n.trim() !== "").slice(0, 40);
|
|
4721
|
+
const bank = (context.bankNames ?? []).filter((n) => n.trim() !== "").slice(0, 60);
|
|
4722
|
+
return [
|
|
4723
|
+
`类别:${category.label}(${category.brief})`,
|
|
4724
|
+
`请生成 ${count} 个候选。`,
|
|
4725
|
+
genre !== "" ? `本书题材基调:${genre}——名字气质必须贴合。` : "(未提供题材基调,按通用中文网文气质发挥)",
|
|
4726
|
+
world.length > 0 ? "世界观既有名称(新名字须与它们同一语感,不得重复或近音):\n" + world.map((n) => `- ${n}`).join("\n") : "(暂无世界观设定,自由发挥)",
|
|
4727
|
+
bank.length > 0 ? "已入库名字(一个都不得重复或变体):\n" + bank.map((n) => `- ${n}`).join("\n") : "",
|
|
4728
|
+
hint.trim() !== "" ? `作者补充要求:${hint.trim().slice(0, 300)}` : "",
|
|
4729
|
+
"输出必须是合法 JSON 对象:{\"names\":[{\"name\":\"名字\",\"note\":\"命名理由/意象(20字内)\"}]}。"
|
|
4730
|
+
].filter((s) => s !== "").join("\n\n");
|
|
4731
|
+
}
|
|
4732
|
+
/** 从项目状态收集取名语境(题材 + 世界既有名 + 已入库名字)。 */
|
|
4733
|
+
function collectNameForgeContext(project) {
|
|
4734
|
+
const genre = project.assets?.genre?.name ?? project.bible?.genre ?? "";
|
|
4735
|
+
const worldNames = [];
|
|
4736
|
+
for (const realm of project.world?.realms ?? []) if (realm.name.trim() !== "") worldNames.push(realm.name.trim());
|
|
4737
|
+
for (const region of project.world?.regions ?? []) if (region.name.trim() !== "") worldNames.push(region.name.trim());
|
|
4738
|
+
for (const faction of project.world?.factions ?? []) if (faction.name.trim() !== "") worldNames.push(faction.name.trim());
|
|
4739
|
+
for (const role of project.roles ?? []) if (role.name.trim() !== "") worldNames.push(role.name.trim());
|
|
4740
|
+
for (const card of project.bible?.characters ?? []) if (card.name.trim() !== "") worldNames.push(card.name.trim());
|
|
4741
|
+
return {
|
|
4742
|
+
genre,
|
|
4743
|
+
worldNames,
|
|
4744
|
+
bankNames: readNameBank(project).map((e) => e.name)
|
|
4745
|
+
};
|
|
4746
|
+
}
|
|
4747
|
+
/** 读取并规整名字库(按类别分组返回前先做一致性校验;非法条目丢弃)。 */
|
|
4748
|
+
function readNameBank(project) {
|
|
4749
|
+
const raw = Array.isArray(project.nameBank) ? project.nameBank : [];
|
|
4750
|
+
const out = [];
|
|
4751
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4752
|
+
for (const item of raw) {
|
|
4753
|
+
if (typeof item !== "object" || item === null) continue;
|
|
4754
|
+
const rec = item;
|
|
4755
|
+
const category = typeof rec.category === "string" ? rec.category : "";
|
|
4756
|
+
if (!CATEGORY_BY_ID.has(category)) continue;
|
|
4757
|
+
const name = typeof rec.name === "string" ? rec.name.trim() : "";
|
|
4758
|
+
if (name === "") continue;
|
|
4759
|
+
const key = `${category}:${name}`;
|
|
4760
|
+
if (seen.has(key)) continue;
|
|
4761
|
+
seen.add(key);
|
|
4762
|
+
out.push({
|
|
4763
|
+
id: typeof rec.id === "string" && rec.id !== "" ? rec.id : `${category}:${name}`,
|
|
4764
|
+
category,
|
|
4765
|
+
name: name.slice(0, NAME_MAX),
|
|
4766
|
+
note: typeof rec.note === "string" ? rec.note.slice(0, NOTE_MAX) : "",
|
|
4767
|
+
adopted: rec.adopted !== false,
|
|
4768
|
+
createdAt: typeof rec.createdAt === "string" ? rec.createdAt : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
4769
|
+
});
|
|
4770
|
+
}
|
|
4771
|
+
return out;
|
|
4772
|
+
}
|
|
4773
|
+
/**
|
|
4774
|
+
* 写入名字库(采纳候选 / 弃用单条 / 清空)。
|
|
4775
|
+
*
|
|
4776
|
+
* 容量纪律:单类 ≤ 60、总数 ≤ 200;超限抛错由路由转 400,**不做静默截断**——
|
|
4777
|
+
* 让作者明确知道库满了,而不是疑惑「我刚取的名字去哪了」。
|
|
4778
|
+
*/
|
|
4779
|
+
function mutateNameBank(project, op) {
|
|
4780
|
+
const current = readNameBank(project);
|
|
4781
|
+
let next;
|
|
4782
|
+
if (op.kind === "adopt") {
|
|
4783
|
+
const existing = new Set(current.map((e) => `${e.category}:${e.name}`));
|
|
4784
|
+
const fresh = op.candidates.map((c) => normalizeNameEntry(c)).filter((n) => n.name !== "" && !existing.has(`${op.category}:${n.name}`)).map((n) => ({
|
|
4785
|
+
id: `${op.category}:${n.name}`,
|
|
4786
|
+
category: op.category,
|
|
4787
|
+
name: n.name,
|
|
4788
|
+
note: n.note,
|
|
4789
|
+
adopted: true,
|
|
4790
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4791
|
+
}));
|
|
4792
|
+
if (fresh.length === 0) return current;
|
|
4793
|
+
next = [...current, ...fresh];
|
|
4794
|
+
} else if (op.kind === "discard") next = current.filter((e) => e.id !== op.id);
|
|
4795
|
+
else next = [];
|
|
4796
|
+
const perCategory = /* @__PURE__ */ new Map();
|
|
4797
|
+
for (const e of next) perCategory.set(e.category, (perCategory.get(e.category) ?? 0) + 1);
|
|
4798
|
+
for (const [category, n] of perCategory) if (n > 60) throw new Error(`名字库「${categorySpec(category).label}」已满(${n}/60),请先清理再采纳`);
|
|
4799
|
+
if (next.length > 200) throw new Error(`名字库已满(${next.length}/200),请先清理再采纳`);
|
|
4800
|
+
project.nameBank = next;
|
|
4801
|
+
return next;
|
|
4802
|
+
}
|
|
4803
|
+
/**
|
|
4804
|
+
* 把已采纳名字渲染成写作提示词块(本书层注入)。
|
|
4805
|
+
*
|
|
4806
|
+
* 声明边界:优先使用、不锁死——「表外创新名须与下表气质一致」。
|
|
4807
|
+
*
|
|
4808
|
+
* @param project 项目状态。
|
|
4809
|
+
* @returns 提示词片段;无库时返回空串。
|
|
4810
|
+
*/
|
|
4811
|
+
function renderNameBank(project) {
|
|
4812
|
+
const entries = readNameBank(project).filter((e) => e.adopted);
|
|
4813
|
+
if (entries.length === 0) return "";
|
|
4814
|
+
const lines = ["==================== 已定名资产(优先使用,不得另起同名) ===================="];
|
|
4815
|
+
for (const spec of NAME_CATEGORIES) {
|
|
4816
|
+
const names = entries.filter((e) => e.category === spec.id).map((e) => e.name);
|
|
4817
|
+
if (names.length === 0) continue;
|
|
4818
|
+
lines.push(`${spec.label}:${names.join("、")}`);
|
|
4819
|
+
}
|
|
4820
|
+
lines.push("正文涉及上表类别的新专有名字,优先直接从表中取;表外创新名须与上表同一语感,不得与上表近音或重复。");
|
|
4821
|
+
return lines.join("\n");
|
|
4822
|
+
}
|
|
4823
|
+
//#endregion
|
|
4824
|
+
//#region src/style-profile.ts
|
|
4825
|
+
/**
|
|
4826
|
+
* 解析生成端生效的风格目标。
|
|
4827
|
+
*
|
|
4828
|
+
* 优先级:显式卡 > 绑定资产的样本文本 > 无。样本文本取「第一个有 sourceText
|
|
4829
|
+
* 的资产」,指纹由调用方现算——本模块不依赖 style-fingerprint,保持纯数据。
|
|
4830
|
+
*
|
|
4831
|
+
* @param project 项目状态(只需 styleProfile 与 assets.styleAssets[].sourceText)。
|
|
4832
|
+
* @returns 生效目标。
|
|
4833
|
+
*/
|
|
4834
|
+
function resolveStyleTarget(project) {
|
|
4835
|
+
if (project.styleProfile !== void 0) return {
|
|
4836
|
+
kind: "profile",
|
|
4837
|
+
fingerprint: project.styleProfile.fingerprint,
|
|
4838
|
+
label: project.styleProfile.label
|
|
4839
|
+
};
|
|
4840
|
+
for (const style of project.assets?.styleAssets ?? []) {
|
|
4841
|
+
const sample = style.sourceText;
|
|
4842
|
+
if (sample !== void 0 && sample !== "") return {
|
|
4843
|
+
kind: "sample",
|
|
4844
|
+
sampleText: sample,
|
|
4845
|
+
label: "绑定写法资产的样本文本"
|
|
4846
|
+
};
|
|
4847
|
+
}
|
|
4848
|
+
return { kind: "none" };
|
|
4849
|
+
}
|
|
4850
|
+
/** 用指纹数据构造一张目标卡。 */
|
|
4851
|
+
function makeStyleProfile(source, label, fingerprint, sampleChapters) {
|
|
4852
|
+
return {
|
|
4853
|
+
source,
|
|
4854
|
+
label,
|
|
4855
|
+
fingerprint,
|
|
4856
|
+
...sampleChapters !== void 0 ? { sampleChapters } : {},
|
|
4857
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4858
|
+
};
|
|
4859
|
+
}
|
|
4860
|
+
/**
|
|
4861
|
+
* 校验一次 distilled-sample 请求的样本文本。
|
|
4862
|
+
* @returns 归一化后的文本。
|
|
4863
|
+
* @throws 文本过短时抛错(路由转 400)。
|
|
4864
|
+
*/
|
|
4865
|
+
function normalizeSampleText(text) {
|
|
4866
|
+
const value = (text ?? "").replace(/\r\n/g, "\n").trim();
|
|
4867
|
+
if (value.length < 200) throw new Error("样本文本过短(<200 字符),指标没有统计意义");
|
|
4868
|
+
return value.slice(0, 2e4);
|
|
4869
|
+
}
|
|
4870
|
+
//#endregion
|
|
4579
4871
|
//#region src/snapshots.ts
|
|
4580
4872
|
/**
|
|
4581
4873
|
* 章节历史版本(快照):覆盖写入前自动留存正文,支持列表 / 回滚 / 清理。
|
|
@@ -5442,7 +5734,8 @@ function buildCurveData(project) {
|
|
|
5442
5734
|
const a = actualTension(c);
|
|
5443
5735
|
if (t !== void 0) point.target = t;
|
|
5444
5736
|
if (a !== void 0) point.actual = a;
|
|
5445
|
-
|
|
5737
|
+
const refValue = preset === "custom" ? custom.length > 0 ? custom[c.no - 1] : t : reference[i];
|
|
5738
|
+
if (refValue !== void 0) point.reference = refValue;
|
|
5446
5739
|
return point;
|
|
5447
5740
|
})
|
|
5448
5741
|
};
|
|
@@ -5587,10 +5880,12 @@ function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
|
|
|
5587
5880
|
const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
|
|
5588
5881
|
book.push(outlineBlock);
|
|
5589
5882
|
book.push("==================== 大纲结束 ====================");
|
|
5590
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
5883
|
+
const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
|
|
5591
5884
|
if (assetsBlock !== "") book.push(assetsBlock);
|
|
5592
5885
|
const slotBlock = renderPromptSlots(project);
|
|
5593
5886
|
if (slotBlock !== "") book.push(slotBlock);
|
|
5887
|
+
const nameBlock = renderNameBank(project);
|
|
5888
|
+
if (nameBlock !== "") book.push(nameBlock);
|
|
5594
5889
|
blocks.push({
|
|
5595
5890
|
layer: "book",
|
|
5596
5891
|
text: book.join("\n")
|
|
@@ -5680,7 +5975,7 @@ function reviewSystemPrompt(project) {
|
|
|
5680
5975
|
text: fixed.join("\n")
|
|
5681
5976
|
});
|
|
5682
5977
|
const book = [];
|
|
5683
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
5978
|
+
const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
|
|
5684
5979
|
if (assetsBlock !== "") book.push("\n" + assetsBlock);
|
|
5685
5980
|
if (bible !== void 0) {
|
|
5686
5981
|
book.push("\n==================== 道藏 ====================");
|
|
@@ -5774,7 +6069,7 @@ function authorReviewSystemPrompt() {
|
|
|
5774
6069
|
//#region src/prompts/polish.ts
|
|
5775
6070
|
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
5776
6071
|
function polishSystemPrompt(project) {
|
|
5777
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
6072
|
+
const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
|
|
5778
6073
|
const bible = project.bible;
|
|
5779
6074
|
const blocks = [];
|
|
5780
6075
|
const fixed = [
|
|
@@ -6281,6 +6576,20 @@ function blurbSystemPrompt() {
|
|
|
6281
6576
|
].join("\n");
|
|
6282
6577
|
}
|
|
6283
6578
|
//#endregion
|
|
6579
|
+
//#region src/prompts/nameforge.ts
|
|
6580
|
+
/** 取名系统提示词:类别感知 + 避重 + 结构化 JSON 输出。 */
|
|
6581
|
+
function nameForgeSystemPrompt() {
|
|
6582
|
+
return [
|
|
6583
|
+
"你是资深中文网文命名师,擅长按题材气质取「读一遍就记得住」的专有名字。",
|
|
6584
|
+
"硬规则:",
|
|
6585
|
+
"1. 只输出 JSON 对象,格式 {\"names\":[{\"name\":\"名字\",\"note\":\"命名理由/意象(20字内)\"}]},不要输出其他文字;",
|
|
6586
|
+
"2. 名字必须是中文(人名2-4字、地名2-6字、功法3-7字、势力2-8字、宝物2-8字),不得出现外文、拼音、生僻怪字;",
|
|
6587
|
+
"3. 严格避雷:不得使用真实存在的明星、品牌、企业、敏感人物名;不套用其它作品的成名专名;",
|
|
6588
|
+
"4. 每批名字内部不得互相近音、近形;若给出「避用名单」,其中名字一个都不得再产出;",
|
|
6589
|
+
"5. note 只写命名思路(取自什么意象/气质),不复述名字本身。"
|
|
6590
|
+
].join("\n");
|
|
6591
|
+
}
|
|
6592
|
+
//#endregion
|
|
6284
6593
|
//#region src/protocol.ts
|
|
6285
6594
|
/**
|
|
6286
6595
|
* dsh-novel-forge — shared protocol between the host half (Node) and the
|
|
@@ -6318,8 +6627,12 @@ const NOVEL_API = {
|
|
|
6318
6627
|
/** 张力曲线(GET 数据 / POST 设置)。 */
|
|
6319
6628
|
tension: "/api/dsh-novel-forge/tension",
|
|
6320
6629
|
styleHistory: "/api/dsh-novel-forge/style-history",
|
|
6630
|
+
/** 风格目标卡:GET 当前卡;POST {action:'distill-archived'|'distill-sample'|'clear'}。 */
|
|
6631
|
+
styleProfile: "/api/dsh-novel-forge/style-profile",
|
|
6321
6632
|
/** 提示词槽位(GET 列表 / POST 写入)。 */
|
|
6322
6633
|
promptSlots: "/api/dsh-novel-forge/prompt-slots",
|
|
6634
|
+
/** 取名器:POST {action:'generate'} 生成候选;GET 名字库;POST {action:'adopt'|'discard'|'clear'} 维护。 */
|
|
6635
|
+
nameForge: "/api/dsh-novel-forge/name-forge",
|
|
6323
6636
|
/** 故事时间线(GET 列表 / POST 操作)。 */
|
|
6324
6637
|
timeline: "/api/dsh-novel-forge/timeline",
|
|
6325
6638
|
marketRadar: "/api/dsh-novel-forge/market-radar",
|
|
@@ -7555,24 +7868,11 @@ const STYLE_BASELINE_MIN_CHAPTERS = 3;
|
|
|
7555
7868
|
* @returns 基线;既无已过审章节也无带样本文本的写法资产时返回 undefined。
|
|
7556
7869
|
*/
|
|
7557
7870
|
function styleBaseline(outputDir, project) {
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
const samples = [];
|
|
7564
|
-
for (const chapter of picked) {
|
|
7565
|
-
const raw = readChapterFile(outputDir, chapter);
|
|
7566
|
-
if (raw === void 0) continue;
|
|
7567
|
-
const fp = extractStyleFingerprint(stripChapterHeadings(raw));
|
|
7568
|
-
if (fp.sentences > 0) samples.push(fp);
|
|
7569
|
-
}
|
|
7570
|
-
if (samples.length >= STYLE_BASELINE_MIN_CHAPTERS) return {
|
|
7571
|
-
fingerprint: mergeFingerprints(samples),
|
|
7572
|
-
label: "本书已过审 " + samples.length + " 章的中位数"
|
|
7573
|
-
};
|
|
7574
|
-
}
|
|
7575
|
-
}
|
|
7871
|
+
const archived = sampleArchivedFingerprint(outputDir, project);
|
|
7872
|
+
if (archived !== void 0) return {
|
|
7873
|
+
fingerprint: archived.fingerprint,
|
|
7874
|
+
label: "本书已过审 " + archived.count + " 章的中位数"
|
|
7875
|
+
};
|
|
7576
7876
|
for (const style of project.assets?.styleAssets ?? []) {
|
|
7577
7877
|
const sample = style.sourceText;
|
|
7578
7878
|
if (sample === void 0 || sample === "") continue;
|
|
@@ -7584,6 +7884,51 @@ function styleBaseline(outputDir, project) {
|
|
|
7584
7884
|
};
|
|
7585
7885
|
}
|
|
7586
7886
|
}
|
|
7887
|
+
/**
|
|
7888
|
+
* 从本书**已过审章节**等距抽样算风格指纹(中位数)。
|
|
7889
|
+
*
|
|
7890
|
+
* 审稿尺子(styleBaseline)与蒸馏卡(distillStyleProfile)共用这一份采样——
|
|
7891
|
+
* 两条路必须同源,否则「审稿说没偏离、生成目标却是另一个方向」。
|
|
7892
|
+
* @returns 抽样指纹与参与章数;不足 {@link STYLE_BASELINE_MIN_CHAPTERS} 章时返回 undefined。
|
|
7893
|
+
*/
|
|
7894
|
+
function sampleArchivedFingerprint(outputDir, project) {
|
|
7895
|
+
if (outputDir === void 0) return void 0;
|
|
7896
|
+
const approved = project.chapters.filter((c) => c.status === "approved" && c.file !== "");
|
|
7897
|
+
if (approved.length < STYLE_BASELINE_MIN_CHAPTERS) return void 0;
|
|
7898
|
+
const step = Math.max(1, Math.floor(approved.length / STYLE_BASELINE_CHAPTERS));
|
|
7899
|
+
const picked = approved.filter((_, index) => index % step === 0).slice(0, STYLE_BASELINE_CHAPTERS);
|
|
7900
|
+
const samples = [];
|
|
7901
|
+
for (const chapter of picked) {
|
|
7902
|
+
const raw = readChapterFile(outputDir, chapter);
|
|
7903
|
+
if (raw === void 0) continue;
|
|
7904
|
+
const fp = extractStyleFingerprint(stripChapterHeadings(raw));
|
|
7905
|
+
if (fp.sentences > 0) samples.push(fp);
|
|
7906
|
+
}
|
|
7907
|
+
if (samples.length < STYLE_BASELINE_MIN_CHAPTERS) return void 0;
|
|
7908
|
+
return {
|
|
7909
|
+
fingerprint: mergeFingerprints(samples),
|
|
7910
|
+
count: samples.length
|
|
7911
|
+
};
|
|
7912
|
+
}
|
|
7913
|
+
/**
|
|
7914
|
+
* 蒸馏一张风格目标卡(作者显式动作,落 project.styleProfile)。
|
|
7915
|
+
*
|
|
7916
|
+
* archived:本书已过审章节的中位数(把「我现在实际写出来的样子」固化成目标);
|
|
7917
|
+
* sample:作者粘贴的参考样文本(开书定调)。
|
|
7918
|
+
*
|
|
7919
|
+
* 与审稿尺子刻意分工:见 style-profile.ts 文件头「棘轮效应」说明。
|
|
7920
|
+
* @throws 数据不足时抛错(路由转 400)。
|
|
7921
|
+
*/
|
|
7922
|
+
function distillStyleProfile(outputDir, project, source, sampleText) {
|
|
7923
|
+
if (source === "archived") {
|
|
7924
|
+
const archived = sampleArchivedFingerprint(outputDir, project);
|
|
7925
|
+
if (archived === void 0) throw new Error(`已过审章节不足 ${STYLE_BASELINE_MIN_CHAPTERS} 章,不够蒸馏(先写几章并过审)`);
|
|
7926
|
+
return makeStyleProfile("archived", `本书已过审 ${archived.count} 章的中位数`, archived.fingerprint, archived.count);
|
|
7927
|
+
}
|
|
7928
|
+
const fingerprint = extractStyleFingerprint(normalizeSampleText(sampleText));
|
|
7929
|
+
if (fingerprint.sentences === 0) throw new Error("样本文本无法抽取指标,请换一份更完整的正文");
|
|
7930
|
+
return makeStyleProfile("sample", `参考样本蒸馏(${fingerprint.chars} 字)`, fingerprint);
|
|
7931
|
+
}
|
|
7587
7932
|
function styleFidelityBlock(project, bodyText, outputDir) {
|
|
7588
7933
|
const baseline = styleBaseline(outputDir, project);
|
|
7589
7934
|
if (baseline === void 0) return "";
|
|
@@ -7756,7 +8101,9 @@ async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTa
|
|
|
7756
8101
|
system: authorReviewSystemPrompt(),
|
|
7757
8102
|
user,
|
|
7758
8103
|
temperature: .3,
|
|
7759
|
-
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
8104
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
8105
|
+
model: config.reviewModel,
|
|
8106
|
+
liveLabel: "作者复盘"
|
|
7760
8107
|
}));
|
|
7761
8108
|
const knownLineNames = new Set((project.plotlines ?? []).map((l) => l.name));
|
|
7762
8109
|
const advancedLines = Array.isArray(raw.advancedLines) ? raw.advancedLines.filter((n) => typeof n === "string" && n.trim() !== "" && knownLineNames.has(n.trim())).map((n) => n.trim()) : [];
|
|
@@ -8073,11 +8420,13 @@ async function extractRoles(ctx, config, project) {
|
|
|
8073
8420
|
/** AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
8074
8421
|
function splitBookText(raw) {
|
|
8075
8422
|
const lines = raw.split(/\r?\n/);
|
|
8076
|
-
const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[
|
|
8423
|
+
const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千万零〇]+)\s*[章回节]\s*(.*?)\s*$/;
|
|
8077
8424
|
const cnOnlyHead = /^\s*(?:#\s*)?([一二三四五六七八九十百千万零〇]{1,6})(?:[、..::]\s*(.*?)\s*)?$/;
|
|
8078
8425
|
const specialHead = /^\s*(?:#\s*)?(序章|序言|楔子|引子|前言|开篇|尾声|终章|大结局|番外(?:篇|章)?|后记|完结感言|上架感言|作者的话)\s*[::、.\s]*(.*?)\s*$/;
|
|
8079
8426
|
const enHead = /^\s*(?:#\s*)?chapter\s+(\d+)\s*[.::、\-\s]*(.*?)\s*$/i;
|
|
8080
8427
|
const cnNum = {
|
|
8428
|
+
零: 0,
|
|
8429
|
+
〇: 0,
|
|
8081
8430
|
一: 1,
|
|
8082
8431
|
二: 2,
|
|
8083
8432
|
三: 3,
|
|
@@ -8089,7 +8438,8 @@ function splitBookText(raw) {
|
|
|
8089
8438
|
九: 9,
|
|
8090
8439
|
十: 10,
|
|
8091
8440
|
百: 100,
|
|
8092
|
-
千: 1e3
|
|
8441
|
+
千: 1e3,
|
|
8442
|
+
万: 1e4
|
|
8093
8443
|
};
|
|
8094
8444
|
const parseCn = (s) => {
|
|
8095
8445
|
if (/^\d+$/.test(s)) return Number(s);
|
|
@@ -8098,7 +8448,11 @@ function splitBookText(raw) {
|
|
|
8098
8448
|
for (const ch of s) {
|
|
8099
8449
|
const v = cnNum[ch];
|
|
8100
8450
|
if (v === void 0) return 0;
|
|
8101
|
-
if (v
|
|
8451
|
+
if (v === 0) continue;
|
|
8452
|
+
if (v >= 1e4) {
|
|
8453
|
+
section = (section + total) * v;
|
|
8454
|
+
total = 0;
|
|
8455
|
+
} else if (v >= 10) {
|
|
8102
8456
|
total += (section > 0 ? section : 1) * v;
|
|
8103
8457
|
section = 0;
|
|
8104
8458
|
} else section = v;
|
|
@@ -9467,12 +9821,12 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
|
|
|
9467
9821
|
const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
|
|
9468
9822
|
const factLines = parseFactLines(raw.facts);
|
|
9469
9823
|
if (summary !== "") chapter.summary = summary;
|
|
9470
|
-
dedupAndAddFacts(project, chapterNo, factLines);
|
|
9824
|
+
const added = dedupAndAddFacts(project, chapterNo, factLines);
|
|
9471
9825
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9472
9826
|
saveProject(outputDir, project);
|
|
9473
9827
|
return {
|
|
9474
9828
|
summary,
|
|
9475
|
-
factCount:
|
|
9829
|
+
factCount: added
|
|
9476
9830
|
};
|
|
9477
9831
|
}
|
|
9478
9832
|
/**
|
|
@@ -9521,13 +9875,13 @@ async function summarizeFactsAndTimeline(ctx, config, project, outputDir, chapte
|
|
|
9521
9875
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9522
9876
|
const extracted = parseTimelineItems(raw.timeline).map((item, index) => normalizeTimelineEvent(mapTimelineItem(item, index), chapterNo, index, now)).filter((e) => e.event !== "");
|
|
9523
9877
|
if (summary !== "") chapter.summary = summary;
|
|
9524
|
-
dedupAndAddFacts(project, chapterNo, factLines);
|
|
9878
|
+
const added = dedupAndAddFacts(project, chapterNo, factLines);
|
|
9525
9879
|
if (extracted.length > 0) project.timeline = [...(project.timeline ?? []).filter((e) => e.chapterNo !== chapterNo), ...extracted];
|
|
9526
9880
|
project.updatedAt = now;
|
|
9527
9881
|
saveProject(outputDir, project);
|
|
9528
9882
|
return {
|
|
9529
9883
|
summary,
|
|
9530
|
-
factCount:
|
|
9884
|
+
factCount: added,
|
|
9531
9885
|
timelineCount: extracted.length
|
|
9532
9886
|
};
|
|
9533
9887
|
}
|
|
@@ -10156,6 +10510,26 @@ async function runIdeaInspiration(ctx, config, req) {
|
|
|
10156
10510
|
})) : [];
|
|
10157
10511
|
return { ideas: arr(raw.ideas).slice(0, count) };
|
|
10158
10512
|
}
|
|
10513
|
+
/**
|
|
10514
|
+
* 取名器:按类别批量生成专有名字(题材感知 + 世界观避重 + 库内避重)。
|
|
10515
|
+
*
|
|
10516
|
+
* 候选**不落盘**——作者在面板逐条采纳后才进名字库(走 nameForgeRoute 的
|
|
10517
|
+
* adopt 动作)。生成失败(含模型输出无法解析)抛错,由路由转 500。
|
|
10518
|
+
*/
|
|
10519
|
+
async function runNameForge(ctx, config, project, req) {
|
|
10520
|
+
const spec = categorySpec(req.category);
|
|
10521
|
+
const count = Math.max(1, Math.min(spec.maxCount, Math.floor(req.count ?? 10)));
|
|
10522
|
+
const context = collectNameForgeContext(project);
|
|
10523
|
+
const candidates = parseNameCandidates(await complete(ctx, config, {
|
|
10524
|
+
system: nameForgeSystemPrompt(),
|
|
10525
|
+
user: buildNameForgeUserPrompt(spec, count, req.hint ?? "", context),
|
|
10526
|
+
temperature: .9,
|
|
10527
|
+
maxTokens: Math.max(config.maxTokens, 2e3),
|
|
10528
|
+
liveLabel: `取名·${spec.label}`
|
|
10529
|
+
}), count);
|
|
10530
|
+
if (candidates.length === 0) throw new Error("取名失败:模型输出无法解析,请重试或调整补充要求");
|
|
10531
|
+
return candidates;
|
|
10532
|
+
}
|
|
10159
10533
|
/** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
|
|
10160
10534
|
async function runMarketIdeaInspiration(ctx, config, req) {
|
|
10161
10535
|
const count = Math.max(1, Math.min(10, req.count ?? 5));
|
|
@@ -10281,58 +10655,98 @@ async function reviewTimeline(ctx, config, project) {
|
|
|
10281
10655
|
const events = sortTimeline(project.timeline ?? []);
|
|
10282
10656
|
if (events.length === 0) return [];
|
|
10283
10657
|
const NL = String.fromCharCode(10);
|
|
10284
|
-
const
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
|
|
10301
|
-
|
|
10302
|
-
|
|
10303
|
-
|
|
10304
|
-
|
|
10305
|
-
|
|
10658
|
+
const system = timelineReviewSystemPrompt();
|
|
10659
|
+
const issues = [];
|
|
10660
|
+
const BATCH = 150;
|
|
10661
|
+
const totalBatches = Math.ceil(events.length / BATCH);
|
|
10662
|
+
for (let start = 0; start < events.length; start += BATCH) {
|
|
10663
|
+
const batch = events.slice(start, start + BATCH);
|
|
10664
|
+
const batchNo = Math.floor(start / BATCH) + 1;
|
|
10665
|
+
const text = await complete(ctx, config, {
|
|
10666
|
+
system,
|
|
10667
|
+
user: `故事时间线(按章节顺序;第 ${batchNo}/${totalBatches} 批):@NL@${batch.map((e) => {
|
|
10668
|
+
const parts = [`第${e.chapterNo}章`];
|
|
10669
|
+
if (e.order !== void 0) parts.push(`#${e.order}`);
|
|
10670
|
+
if (e.time !== "") parts.push(e.time);
|
|
10671
|
+
if (e.place !== "") parts.push(`@${e.place}`);
|
|
10672
|
+
if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
|
|
10673
|
+
parts.push(e.event);
|
|
10674
|
+
return "- " + parts.join(" ");
|
|
10675
|
+
}).join(NL)}`.replace("@NL@", NL),
|
|
10676
|
+
temperature: .2,
|
|
10677
|
+
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
10678
|
+
model: config.auditModel || config.model,
|
|
10679
|
+
liveLabel: `时间线复核 · 第${batchNo}/${totalBatches}批`
|
|
10680
|
+
});
|
|
10681
|
+
let parsed = [];
|
|
10682
|
+
try {
|
|
10683
|
+
parsed = parseJson(text, true);
|
|
10684
|
+
} catch {
|
|
10685
|
+
parsed = [];
|
|
10686
|
+
}
|
|
10687
|
+
if (!Array.isArray(parsed)) continue;
|
|
10688
|
+
issues.push(...parsed.filter((item) => typeof item === "object" && item !== null).map((item) => {
|
|
10689
|
+
return {
|
|
10690
|
+
severity: item.severity === "high" || item.severity === "low" ? item.severity : "medium",
|
|
10691
|
+
chapters: Array.isArray(item.chapters) ? item.chapters.filter((c) => typeof c === "number" && Number.isFinite(c)) : [],
|
|
10692
|
+
item: typeof item.item === "string" ? item.item.slice(0, 300) : "",
|
|
10693
|
+
suggestion: typeof item.suggestion === "string" ? item.suggestion.slice(0, 300) : ""
|
|
10694
|
+
};
|
|
10695
|
+
}).filter((issue) => issue.item !== ""));
|
|
10306
10696
|
}
|
|
10307
|
-
|
|
10308
|
-
return parsed.filter((item) => typeof item === "object" && item !== null).map((item) => {
|
|
10309
|
-
return {
|
|
10310
|
-
severity: item.severity === "high" || item.severity === "low" ? item.severity : "medium",
|
|
10311
|
-
chapters: Array.isArray(item.chapters) ? item.chapters.filter((c) => typeof c === "number" && Number.isFinite(c)) : [],
|
|
10312
|
-
item: typeof item.item === "string" ? item.item.slice(0, 300) : "",
|
|
10313
|
-
suggestion: typeof item.suggestion === "string" ? item.suggestion.slice(0, 300) : ""
|
|
10314
|
-
};
|
|
10315
|
-
}).filter((issue) => issue.item !== "");
|
|
10697
|
+
return issues;
|
|
10316
10698
|
}
|
|
10317
|
-
/**
|
|
10699
|
+
/**
|
|
10700
|
+
* 选一个不覆盖已有文件的导出名:同名已存在时依次加 ` (2)`、` (3)`…
|
|
10701
|
+
*
|
|
10702
|
+
* 为什么不再固定文件名覆盖:作者往往「导出 → 看 → 改 → 再导出」,
|
|
10703
|
+
* 固定名会把上一版直接擦掉;导出件是成品,不该只有一份。
|
|
10704
|
+
* @param outputDir 书目录。
|
|
10705
|
+
* @param base 不含扩展名的文件名主体。
|
|
10706
|
+
* @param ext 扩展名(不带点)。
|
|
10707
|
+
*/
|
|
10708
|
+
function uniqueExportFile(outputDir, base, ext) {
|
|
10709
|
+
let file = `${base}.${ext}`;
|
|
10710
|
+
let n = 2;
|
|
10711
|
+
while (existsSync(join(outputDir, file))) {
|
|
10712
|
+
file = `${base} (${n}).${ext}`;
|
|
10713
|
+
n++;
|
|
10714
|
+
}
|
|
10715
|
+
return file;
|
|
10716
|
+
}
|
|
10717
|
+
/**
|
|
10718
|
+
* Export the whole book as one txt/md file.
|
|
10719
|
+
*
|
|
10720
|
+
* 只收真实存在且非空的章节文件:`chapter.file` 有值不等于文件没被手删/清空,
|
|
10721
|
+
* 旧实现会把它们当空串拼进去,导出件看起来完整、实际缺章。跳过的章号随
|
|
10722
|
+
* `skipped` 一起返回,由面板如实提示。
|
|
10723
|
+
*/
|
|
10318
10724
|
function exportBook(outputDir, project, format) {
|
|
10319
10725
|
const parts = [];
|
|
10320
10726
|
if (format === "md") parts.push(`# ${project.bookName}\n`);
|
|
10321
10727
|
else parts.push(project.bookName, "");
|
|
10322
|
-
const
|
|
10323
|
-
|
|
10324
|
-
|
|
10728
|
+
const written = [];
|
|
10729
|
+
const skipped = [];
|
|
10730
|
+
for (const chapter of project.chapters) {
|
|
10731
|
+
if (chapter.file === void 0 || chapter.file === "") continue;
|
|
10732
|
+
const body = readChapterFile(outputDir, chapter);
|
|
10733
|
+
if (body === void 0 || body.trim() === "") {
|
|
10734
|
+
skipped.push(chapter.no);
|
|
10735
|
+
continue;
|
|
10736
|
+
}
|
|
10737
|
+
written.push(chapter);
|
|
10325
10738
|
if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
|
|
10326
10739
|
else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
|
|
10327
10740
|
}
|
|
10328
10741
|
const content = parts.join("\n");
|
|
10329
10742
|
const ext = format === "md" ? "md" : "txt";
|
|
10330
|
-
const file = `《${safeFileName(project.bookName)}
|
|
10743
|
+
const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》全本`, ext);
|
|
10331
10744
|
writeFileSync(join(outputDir, file), content, "utf8");
|
|
10332
10745
|
return {
|
|
10333
10746
|
file,
|
|
10334
10747
|
chars: content.length,
|
|
10335
|
-
chapters:
|
|
10748
|
+
chapters: written.length,
|
|
10749
|
+
...skipped.length > 0 ? { skipped } : {}
|
|
10336
10750
|
};
|
|
10337
10751
|
}
|
|
10338
10752
|
/** 逐行渲染「- 名称:值」清单,跳过空值。 */
|
|
@@ -10509,7 +10923,7 @@ function exportScope(outputDir, project, scope, format) {
|
|
|
10509
10923
|
project
|
|
10510
10924
|
};
|
|
10511
10925
|
const content = JSON.stringify(payload, null, 2);
|
|
10512
|
-
const file = `《${safeFileName(project.bookName)}
|
|
10926
|
+
const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》项目备份`, "json");
|
|
10513
10927
|
writeFileSync(join(outputDir, file), content, "utf8");
|
|
10514
10928
|
return {
|
|
10515
10929
|
file,
|
|
@@ -10520,7 +10934,7 @@ function exportScope(outputDir, project, scope, format) {
|
|
|
10520
10934
|
const body = scope === "settings" ? renderSettings(project) : scope === "plan" ? renderPlan(project) : scope === "characters" ? renderCharacters(project) : renderReview(project);
|
|
10521
10935
|
const ext = format === "json" ? "md" : format;
|
|
10522
10936
|
const label = scope === "settings" ? "项目设定" : scope === "plan" ? "规划" : scope === "characters" ? "角色" : "质检记录";
|
|
10523
|
-
const file = `《${safeFileName(project.bookName)}》${label}
|
|
10937
|
+
const file = uniqueExportFile(outputDir, `《${safeFileName(project.bookName)}》${label}`, ext);
|
|
10524
10938
|
writeFileSync(join(outputDir, file), body, "utf8");
|
|
10525
10939
|
return {
|
|
10526
10940
|
file,
|
|
@@ -11560,7 +11974,7 @@ function computeBookStage(project) {
|
|
|
11560
11974
|
allow: ["chapter_rewrite", "chapter_review"],
|
|
11561
11975
|
reason: `第 ${rejected.map((c) => c.no).join("、")} 章审稿未过,需要按意见修订`
|
|
11562
11976
|
};
|
|
11563
|
-
const awaitingReview = chapters.filter((c) => c.status === "written"
|
|
11977
|
+
const awaitingReview = chapters.filter((c) => c.status === "written");
|
|
11564
11978
|
if (awaitingReview.length > 0) return {
|
|
11565
11979
|
id: "review",
|
|
11566
11980
|
label: "审稿",
|
|
@@ -12850,11 +13264,15 @@ function upsertAuthorAsset(input) {
|
|
|
12850
13264
|
createdAt: prev.createdAt,
|
|
12851
13265
|
updatedAt: now
|
|
12852
13266
|
};
|
|
12853
|
-
} else
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
13267
|
+
} else {
|
|
13268
|
+
const id = input.id !== "" ? input.id : "aa-" + Date.now().toString(36) + "-" + randomBytes(4).toString("hex");
|
|
13269
|
+
library.items.push({
|
|
13270
|
+
...input,
|
|
13271
|
+
id,
|
|
13272
|
+
createdAt: input.createdAt !== "" ? input.createdAt : now,
|
|
13273
|
+
updatedAt: now
|
|
13274
|
+
});
|
|
13275
|
+
}
|
|
12858
13276
|
saveAuthorAssets(library);
|
|
12859
13277
|
return library;
|
|
12860
13278
|
}
|
|
@@ -13180,8 +13598,14 @@ async function scanMarketRanking(platforms) {
|
|
|
13180
13598
|
*/
|
|
13181
13599
|
const FILE = dshHomePath("novel-forge-global-assets.json");
|
|
13182
13600
|
let cache = null;
|
|
13601
|
+
/** 缓存对应的文件 mtime(ms);-1 = 文件不存在。 */
|
|
13602
|
+
let cacheMtime = -1;
|
|
13183
13603
|
function load() {
|
|
13184
|
-
|
|
13604
|
+
let mtime = -1;
|
|
13605
|
+
try {
|
|
13606
|
+
mtime = statSync(FILE).mtimeMs;
|
|
13607
|
+
} catch {}
|
|
13608
|
+
if (cache !== null && cacheMtime === mtime) return cache;
|
|
13185
13609
|
try {
|
|
13186
13610
|
const raw = readFileSync(FILE, "utf8");
|
|
13187
13611
|
const parsed = JSON.parse(raw);
|
|
@@ -13195,12 +13619,18 @@ function load() {
|
|
|
13195
13619
|
modes: []
|
|
13196
13620
|
};
|
|
13197
13621
|
}
|
|
13622
|
+
cacheMtime = mtime;
|
|
13198
13623
|
return cache;
|
|
13199
13624
|
}
|
|
13200
13625
|
function persist() {
|
|
13201
13626
|
if (cache === null) return;
|
|
13202
13627
|
mkdirSync(dshHome(), { recursive: true });
|
|
13203
13628
|
writeFileSync(FILE, JSON.stringify(cache, null, 2), "utf8");
|
|
13629
|
+
try {
|
|
13630
|
+
cacheMtime = statSync(FILE).mtimeMs;
|
|
13631
|
+
} catch {
|
|
13632
|
+
cacheMtime = -1;
|
|
13633
|
+
}
|
|
13204
13634
|
}
|
|
13205
13635
|
function globalGenreLibrary() {
|
|
13206
13636
|
return load().genres;
|
|
@@ -13380,6 +13810,26 @@ function isAbortError(err) {
|
|
|
13380
13810
|
/** Default chapter count for planning when the request omits it. */
|
|
13381
13811
|
const DEFAULT_PLAN_COUNT = 30;
|
|
13382
13812
|
/**
|
|
13813
|
+
* 复盘结果回灌编年录(与生产单 run.ts 同口径:stateChanges + clues)。
|
|
13814
|
+
*
|
|
13815
|
+
* 为什么必须回灌:作者复盘是**当前状态**的事实来源(谁伤了、谁拿到了什么、哪条线落地);
|
|
13816
|
+
* 只写 chapter.authorReview、不回灌 facts,会让面板单独补跑复盘的章节在后续生成里
|
|
13817
|
+
* 「失忆」——编年录缺了这段,下一章就可能写飞。
|
|
13818
|
+
*
|
|
13819
|
+
* @param project 项目状态(就地修改)。
|
|
13820
|
+
* @param chapterNo 章节号。
|
|
13821
|
+
* @param review 复盘结果。
|
|
13822
|
+
*/
|
|
13823
|
+
function backfillReviewFacts(project, chapterNo, review) {
|
|
13824
|
+
const facts = [...review.stateChanges ?? [], ...review.clues ?? []];
|
|
13825
|
+
if (facts.length === 0) return;
|
|
13826
|
+
project.facts ??= [];
|
|
13827
|
+
for (const text of facts) project.facts.push({
|
|
13828
|
+
chapterNo,
|
|
13829
|
+
text
|
|
13830
|
+
});
|
|
13831
|
+
}
|
|
13832
|
+
/**
|
|
13383
13833
|
* The profile this host booted (`--profile <name>` on the dsh CLI invocation).
|
|
13384
13834
|
* Profile-scoped paths must not assume a name: the shipped template is `web`,
|
|
13385
13835
|
* but a custom profile (`web-alpha`) is the one actually running.
|
|
@@ -13788,7 +14238,8 @@ function makeRoutes(deps) {
|
|
|
13788
14238
|
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
13789
14239
|
chapter.authorReview = review;
|
|
13790
14240
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
13791
|
-
|
|
14241
|
+
backfillReviewFacts(project, no, review);
|
|
14242
|
+
mergeVolatileFromDisk(config.outputDir, project, ["plotlines", "facts"]);
|
|
13792
14243
|
saveProject(config.outputDir, project);
|
|
13793
14244
|
send({
|
|
13794
14245
|
type: "author-review",
|
|
@@ -13974,9 +14425,9 @@ function makeRoutes(deps) {
|
|
|
13974
14425
|
handler: async (req, res) => {
|
|
13975
14426
|
if (!guard(req, res, "POST")) return;
|
|
13976
14427
|
const config = getConfig();
|
|
13977
|
-
const project = requireProjectAt(req, res);
|
|
13978
|
-
if (project === void 0) return;
|
|
13979
14428
|
const body = await readJsonBody(req);
|
|
14429
|
+
const project = requireProjectAt(req, res, body);
|
|
14430
|
+
if (project === void 0) return;
|
|
13980
14431
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
13981
14432
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
13982
14433
|
return;
|
|
@@ -14025,9 +14476,9 @@ function makeRoutes(deps) {
|
|
|
14025
14476
|
handler: async (req, res) => {
|
|
14026
14477
|
if (!guard(req, res, "POST")) return;
|
|
14027
14478
|
const config = getConfig();
|
|
14028
|
-
const project = requireProjectAt(req, res);
|
|
14029
|
-
if (project === void 0) return;
|
|
14030
14479
|
const body = await readJsonBody(req);
|
|
14480
|
+
const project = requireProjectAt(req, res, body);
|
|
14481
|
+
if (project === void 0) return;
|
|
14031
14482
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
14032
14483
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
14033
14484
|
return;
|
|
@@ -14051,9 +14502,9 @@ function makeRoutes(deps) {
|
|
|
14051
14502
|
handler: async (req, res) => {
|
|
14052
14503
|
if (!guard(req, res, "POST")) return;
|
|
14053
14504
|
const config = getConfig();
|
|
14054
|
-
const project = requireProjectAt(req, res);
|
|
14055
|
-
if (project === void 0) return;
|
|
14056
14505
|
const body = await readJsonBody(req);
|
|
14506
|
+
const project = requireProjectAt(req, res, body);
|
|
14507
|
+
if (project === void 0) return;
|
|
14057
14508
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
14058
14509
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
14059
14510
|
return;
|
|
@@ -14195,9 +14646,9 @@ function makeRoutes(deps) {
|
|
|
14195
14646
|
handler: async (req, res) => {
|
|
14196
14647
|
if (!guard(req, res, "POST")) return;
|
|
14197
14648
|
const config = getConfig();
|
|
14198
|
-
const project = requireProjectAt(req, res);
|
|
14199
|
-
if (project === void 0) return;
|
|
14200
14649
|
const body = await readJsonBody(req);
|
|
14650
|
+
const project = requireProjectAt(req, res, body);
|
|
14651
|
+
if (project === void 0) return;
|
|
14201
14652
|
const text = body?.text?.trim() ?? "";
|
|
14202
14653
|
if (text.length < 50) {
|
|
14203
14654
|
writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
|
|
@@ -14217,9 +14668,9 @@ function makeRoutes(deps) {
|
|
|
14217
14668
|
handler: async (req, res) => {
|
|
14218
14669
|
if (!guard(req, res, "POST")) return;
|
|
14219
14670
|
const config = getConfig();
|
|
14220
|
-
const project = requireProjectAt(req, res);
|
|
14221
|
-
if (project === void 0) return;
|
|
14222
14671
|
const body = await readJsonBody(req);
|
|
14672
|
+
const project = requireProjectAt(req, res, body);
|
|
14673
|
+
if (project === void 0) return;
|
|
14223
14674
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
14224
14675
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
14225
14676
|
return;
|
|
@@ -14539,6 +14990,66 @@ function makeRoutes(deps) {
|
|
|
14539
14990
|
});
|
|
14540
14991
|
}
|
|
14541
14992
|
};
|
|
14993
|
+
/**
|
|
14994
|
+
* 风格目标卡:GET = 当前卡与生效说明;POST = 蒸馏 / 清空。
|
|
14995
|
+
*
|
|
14996
|
+
* 与 styleHistory 的分工:那条是**派生视图**(即时算、不落盘);本卡是作者
|
|
14997
|
+
* 显式决定的生成端锚点,落 project.styleProfile。详见 style-profile.ts 文件头。
|
|
14998
|
+
*/
|
|
14999
|
+
const styleProfileRoute = {
|
|
15000
|
+
kind: "exact",
|
|
15001
|
+
path: NOVEL_API.styleProfile,
|
|
15002
|
+
handler: async (req, res) => {
|
|
15003
|
+
const config = getConfig();
|
|
15004
|
+
const qurl = new URL(req.url ?? "/", "http://localhost");
|
|
15005
|
+
if (req.method === "GET") {
|
|
15006
|
+
if (!isLoopbackRequest(req)) {
|
|
15007
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
15008
|
+
return;
|
|
15009
|
+
}
|
|
15010
|
+
const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
|
|
15011
|
+
if (project === void 0) {
|
|
15012
|
+
writeJson(res, 400, { error: "输出目录中没有项目" });
|
|
15013
|
+
return;
|
|
15014
|
+
}
|
|
15015
|
+
const target = resolveStyleTarget(project);
|
|
15016
|
+
writeJson(res, 200, {
|
|
15017
|
+
profile: project.styleProfile ?? null,
|
|
15018
|
+
approvedChapters: project.chapters.filter((c) => c.status === "approved" && c.file !== "").length,
|
|
15019
|
+
effectiveLabel: target.kind === "none" ? "无(未绑定带样本的写法资产)" : target.label
|
|
15020
|
+
});
|
|
15021
|
+
return;
|
|
15022
|
+
}
|
|
15023
|
+
if (!guard(req, res, "POST")) return;
|
|
15024
|
+
const body = await readJsonBody(req);
|
|
15025
|
+
if (body === void 0) {
|
|
15026
|
+
writeJson(res, 400, { error: "请求体缺失" });
|
|
15027
|
+
return;
|
|
15028
|
+
}
|
|
15029
|
+
const outputDir = resolveOutputDir(config, body.bookId);
|
|
15030
|
+
const project = loadProject(outputDir);
|
|
15031
|
+
if (project === void 0) {
|
|
15032
|
+
writeJson(res, 400, { error: "输出目录中没有项目" });
|
|
15033
|
+
return;
|
|
15034
|
+
}
|
|
15035
|
+
try {
|
|
15036
|
+
if (body.action === "clear") {
|
|
15037
|
+
delete project.styleProfile;
|
|
15038
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15039
|
+
saveProject(outputDir, project);
|
|
15040
|
+
writeJson(res, 200, { profile: null });
|
|
15041
|
+
return;
|
|
15042
|
+
}
|
|
15043
|
+
const profile = distillStyleProfile(outputDir, project, body.action === "distill-archived" ? "archived" : "sample", body.text);
|
|
15044
|
+
project.styleProfile = profile;
|
|
15045
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15046
|
+
saveProject(outputDir, project);
|
|
15047
|
+
writeJson(res, 200, { profile });
|
|
15048
|
+
} catch (error) {
|
|
15049
|
+
writeJson(res, 400, { error: error.message });
|
|
15050
|
+
}
|
|
15051
|
+
}
|
|
15052
|
+
};
|
|
14542
15053
|
/** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
14543
15054
|
const marketRadarRoute = {
|
|
14544
15055
|
kind: "exact",
|
|
@@ -15302,6 +15813,66 @@ function makeRoutes(deps) {
|
|
|
15302
15813
|
});
|
|
15303
15814
|
}
|
|
15304
15815
|
};
|
|
15816
|
+
/**
|
|
15817
|
+
* 取名器:GET = 本书名字库;POST {action:'generate'} = 生成候选(不落盘);
|
|
15818
|
+
* POST {action:'adopt'|'discard'|'clear'} = 库维护。
|
|
15819
|
+
*/
|
|
15820
|
+
const nameForgeRoute = {
|
|
15821
|
+
kind: "exact",
|
|
15822
|
+
path: NOVEL_API.nameForge,
|
|
15823
|
+
handler: async (req, res) => {
|
|
15824
|
+
const config = getConfig();
|
|
15825
|
+
const qurl = new URL(req.url ?? "/", "http://localhost");
|
|
15826
|
+
if (req.method === "GET") {
|
|
15827
|
+
if (!isLoopbackRequest(req)) {
|
|
15828
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
15829
|
+
return;
|
|
15830
|
+
}
|
|
15831
|
+
const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
|
|
15832
|
+
if (project === void 0) {
|
|
15833
|
+
writeJson(res, 400, { error: "输出目录中没有项目" });
|
|
15834
|
+
return;
|
|
15835
|
+
}
|
|
15836
|
+
writeJson(res, 200, { entries: readNameBank(project) });
|
|
15837
|
+
return;
|
|
15838
|
+
}
|
|
15839
|
+
if (!guard(req, res, "POST")) return;
|
|
15840
|
+
const body = await readJsonBody(req);
|
|
15841
|
+
if (body === void 0) {
|
|
15842
|
+
writeJson(res, 400, { error: "请求体缺失" });
|
|
15843
|
+
return;
|
|
15844
|
+
}
|
|
15845
|
+
const outputDir = resolveOutputDir(config, body.bookId);
|
|
15846
|
+
const project = loadProject(outputDir);
|
|
15847
|
+
if (project === void 0) {
|
|
15848
|
+
writeJson(res, 400, { error: "输出目录中没有项目" });
|
|
15849
|
+
return;
|
|
15850
|
+
}
|
|
15851
|
+
try {
|
|
15852
|
+
if (body.action === "generate") {
|
|
15853
|
+
writeJson(res, 200, { candidates: await runNameForge(ctx, config, project, {
|
|
15854
|
+
category: body.category,
|
|
15855
|
+
count: body.count,
|
|
15856
|
+
hint: body.hint
|
|
15857
|
+
}) });
|
|
15858
|
+
return;
|
|
15859
|
+
}
|
|
15860
|
+
const entries = mutateNameBank(project, body.action === "adopt" ? {
|
|
15861
|
+
kind: "adopt",
|
|
15862
|
+
category: body.category ?? "person",
|
|
15863
|
+
candidates: body.candidates ?? []
|
|
15864
|
+
} : body.action === "discard" ? {
|
|
15865
|
+
kind: "discard",
|
|
15866
|
+
id: String(body.id ?? "")
|
|
15867
|
+
} : { kind: "clear" });
|
|
15868
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15869
|
+
saveProject(outputDir, project);
|
|
15870
|
+
writeJson(res, 200, { entries });
|
|
15871
|
+
} catch (error) {
|
|
15872
|
+
writeJson(res, 400, { error: error.message });
|
|
15873
|
+
}
|
|
15874
|
+
}
|
|
15875
|
+
};
|
|
15305
15876
|
const bookshelfRoute = {
|
|
15306
15877
|
kind: "exact",
|
|
15307
15878
|
path: NOVEL_API.bookshelf,
|
|
@@ -15558,7 +16129,8 @@ function makeRoutes(deps) {
|
|
|
15558
16129
|
handler: async (req, res) => {
|
|
15559
16130
|
if (!guard(req, res, "POST")) return;
|
|
15560
16131
|
const config = getConfig();
|
|
15561
|
-
const
|
|
16132
|
+
const body = await readJsonBody(req);
|
|
16133
|
+
const project = requireProjectAt(req, res, body);
|
|
15562
16134
|
if (project === void 0) return;
|
|
15563
16135
|
try {
|
|
15564
16136
|
const cards = await refreshCharacters(ctx, config, project, config.outputDir);
|
|
@@ -15578,7 +16150,8 @@ function makeRoutes(deps) {
|
|
|
15578
16150
|
handler: async (req, res) => {
|
|
15579
16151
|
if (!guard(req, res, "POST")) return;
|
|
15580
16152
|
const config = getConfig();
|
|
15581
|
-
const
|
|
16153
|
+
const body = await readJsonBody(req);
|
|
16154
|
+
const project = requireProjectAt(req, res, body);
|
|
15582
16155
|
if (project === void 0) return;
|
|
15583
16156
|
try {
|
|
15584
16157
|
writeJson(res, 200, {
|
|
@@ -15597,13 +16170,13 @@ function makeRoutes(deps) {
|
|
|
15597
16170
|
handler: async (req, res) => {
|
|
15598
16171
|
if (!guard(req, res, "POST")) return;
|
|
15599
16172
|
const config = getConfig();
|
|
15600
|
-
const
|
|
16173
|
+
const body = await readJsonBody(req);
|
|
16174
|
+
const project = requireProjectAt(req, res, body);
|
|
15601
16175
|
if (project === void 0) return;
|
|
15602
16176
|
if (project.bible === void 0) {
|
|
15603
16177
|
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
15604
16178
|
return;
|
|
15605
16179
|
}
|
|
15606
|
-
const body = await readJsonBody(req);
|
|
15607
16180
|
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
|
|
15608
16181
|
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
|
|
15609
16182
|
if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
|
|
@@ -15632,9 +16205,9 @@ function makeRoutes(deps) {
|
|
|
15632
16205
|
handler: async (req, res) => {
|
|
15633
16206
|
if (!guard(req, res, "POST")) return;
|
|
15634
16207
|
const config = getConfig();
|
|
15635
|
-
const project = requireProjectAt(req, res);
|
|
15636
|
-
if (project === void 0) return;
|
|
15637
16208
|
const body = await readJsonBody(req);
|
|
16209
|
+
const project = requireProjectAt(req, res, body);
|
|
16210
|
+
if (project === void 0) return;
|
|
15638
16211
|
try {
|
|
15639
16212
|
if (body?.action === "save") {
|
|
15640
16213
|
const text = body.text?.trim() ?? "";
|
|
@@ -15686,9 +16259,9 @@ function makeRoutes(deps) {
|
|
|
15686
16259
|
}
|
|
15687
16260
|
if (req.method === "POST") {
|
|
15688
16261
|
if (!guard(req, res, "POST")) return;
|
|
15689
|
-
const project = requireProjectAt(req, res);
|
|
15690
|
-
if (project === void 0) return;
|
|
15691
16262
|
const body = await readJsonBody(req);
|
|
16263
|
+
const project = requireProjectAt(req, res, body);
|
|
16264
|
+
if (project === void 0) return;
|
|
15692
16265
|
try {
|
|
15693
16266
|
if (body?.action === "remove") {
|
|
15694
16267
|
if (project.coverPath !== void 0 && project.coverPath !== "") {
|
|
@@ -15742,9 +16315,9 @@ function makeRoutes(deps) {
|
|
|
15742
16315
|
handler: async (req, res) => {
|
|
15743
16316
|
if (!guard(req, res, "POST")) return;
|
|
15744
16317
|
const config = getConfig();
|
|
15745
|
-
const project = requireProjectAt(req, res);
|
|
15746
|
-
if (project === void 0) return;
|
|
15747
16318
|
const body = await readJsonBody(req);
|
|
16319
|
+
const project = requireProjectAt(req, res, body);
|
|
16320
|
+
if (project === void 0) return;
|
|
15748
16321
|
try {
|
|
15749
16322
|
if (body?.action === "save" && body.world !== void 0) {
|
|
15750
16323
|
project.world = {
|
|
@@ -15774,9 +16347,10 @@ function makeRoutes(deps) {
|
|
|
15774
16347
|
handler: async (req, res) => {
|
|
15775
16348
|
if (!guard(req, res, "POST")) return;
|
|
15776
16349
|
const config = getConfig();
|
|
15777
|
-
const
|
|
16350
|
+
const body = await readJsonBody(req);
|
|
16351
|
+
const project = requireProjectAt(req, res, body);
|
|
15778
16352
|
if (project === void 0) return;
|
|
15779
|
-
const bookName =
|
|
16353
|
+
const bookName = body?.bookName?.trim();
|
|
15780
16354
|
if (bookName === void 0 || bookName === "") {
|
|
15781
16355
|
writeJson(res, 400, { error: "书名不能为空" });
|
|
15782
16356
|
return;
|
|
@@ -15796,9 +16370,9 @@ function makeRoutes(deps) {
|
|
|
15796
16370
|
handler: async (req, res) => {
|
|
15797
16371
|
if (!guard(req, res, "POST")) return;
|
|
15798
16372
|
const config = getConfig();
|
|
15799
|
-
const project = requireProjectAt(req, res);
|
|
15800
|
-
if (project === void 0) return;
|
|
15801
16373
|
const body = await readJsonBody(req);
|
|
16374
|
+
const project = requireProjectAt(req, res, body);
|
|
16375
|
+
if (project === void 0) return;
|
|
15802
16376
|
if (project.plotlines === void 0) project.plotlines = [];
|
|
15803
16377
|
const op = body?.op;
|
|
15804
16378
|
if (op === "add" && body?.line !== void 0) {
|
|
@@ -15925,9 +16499,9 @@ function makeRoutes(deps) {
|
|
|
15925
16499
|
handler: async (req, res) => {
|
|
15926
16500
|
if (!guard(req, res, "POST")) return;
|
|
15927
16501
|
const config = getConfig();
|
|
15928
|
-
const project = requireProjectAt(req, res);
|
|
15929
|
-
if (project === void 0) return;
|
|
15930
16502
|
const body = await readJsonBody(req);
|
|
16503
|
+
const project = requireProjectAt(req, res, body);
|
|
16504
|
+
if (project === void 0) return;
|
|
15931
16505
|
const hits = [];
|
|
15932
16506
|
let scanned = 0;
|
|
15933
16507
|
if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
|
|
@@ -15979,9 +16553,9 @@ function makeRoutes(deps) {
|
|
|
15979
16553
|
handler: async (req, res) => {
|
|
15980
16554
|
if (!guard(req, res, "POST")) return;
|
|
15981
16555
|
const config = getConfig();
|
|
15982
|
-
const project = requireProjectAt(req, res);
|
|
15983
|
-
if (project === void 0) return;
|
|
15984
16556
|
const body = await readJsonBody(req);
|
|
16557
|
+
const project = requireProjectAt(req, res, body);
|
|
16558
|
+
if (project === void 0) return;
|
|
15985
16559
|
if (project.roles === void 0) project.roles = [];
|
|
15986
16560
|
const op = body?.op;
|
|
15987
16561
|
if (op === "extract") try {
|
|
@@ -16017,9 +16591,10 @@ function makeRoutes(deps) {
|
|
|
16017
16591
|
handler: async (req, res) => {
|
|
16018
16592
|
if (!guard(req, res, "POST")) return;
|
|
16019
16593
|
const config = getConfig();
|
|
16020
|
-
const
|
|
16594
|
+
const body = await readJsonBody(req);
|
|
16595
|
+
const project = requireProjectAt(req, res, body);
|
|
16021
16596
|
if (project === void 0) return;
|
|
16022
|
-
const no =
|
|
16597
|
+
const no = body?.chapterNo;
|
|
16023
16598
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
16024
16599
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
16025
16600
|
return;
|
|
@@ -16047,9 +16622,10 @@ function makeRoutes(deps) {
|
|
|
16047
16622
|
handler: async (req, res) => {
|
|
16048
16623
|
if (!guard(req, res, "POST")) return;
|
|
16049
16624
|
const config = getConfig();
|
|
16050
|
-
const
|
|
16625
|
+
const body = await readJsonBody(req);
|
|
16626
|
+
const project = requireProjectAt(req, res, body);
|
|
16051
16627
|
if (project === void 0) return;
|
|
16052
|
-
const no =
|
|
16628
|
+
const no = body?.chapterNo;
|
|
16053
16629
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
16054
16630
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
16055
16631
|
return;
|
|
@@ -16081,9 +16657,9 @@ function makeRoutes(deps) {
|
|
|
16081
16657
|
handler: async (req, res) => {
|
|
16082
16658
|
if (!guard(req, res, "POST")) return;
|
|
16083
16659
|
const config = getConfig();
|
|
16084
|
-
const project = requireProjectAt(req, res);
|
|
16085
|
-
if (project === void 0) return;
|
|
16086
16660
|
const body = await readJsonBody(req);
|
|
16661
|
+
const project = requireProjectAt(req, res, body);
|
|
16662
|
+
if (project === void 0) return;
|
|
16087
16663
|
const no = body?.chapterNo;
|
|
16088
16664
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
16089
16665
|
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
@@ -16142,9 +16718,9 @@ function makeRoutes(deps) {
|
|
|
16142
16718
|
handler: async (req, res) => {
|
|
16143
16719
|
if (!guard(req, res, "POST")) return;
|
|
16144
16720
|
const config = getConfig();
|
|
16145
|
-
const project = requireProjectAt(req, res);
|
|
16146
|
-
if (project === void 0) return;
|
|
16147
16721
|
const body = await readJsonBody(req);
|
|
16722
|
+
const project = requireProjectAt(req, res, body);
|
|
16723
|
+
if (project === void 0) return;
|
|
16148
16724
|
const abort = abortOnClientClose(req, res);
|
|
16149
16725
|
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
16150
16726
|
const runOne = async (chapter) => {
|
|
@@ -16172,6 +16748,7 @@ function makeRoutes(deps) {
|
|
|
16172
16748
|
const review = await runOne(chapter);
|
|
16173
16749
|
chapter.authorReview = review;
|
|
16174
16750
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
16751
|
+
backfillReviewFacts(project, chapter.no, review);
|
|
16175
16752
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
16176
16753
|
saveProject(config.outputDir, project);
|
|
16177
16754
|
writeJson(res, 200, {
|
|
@@ -16202,6 +16779,7 @@ function makeRoutes(deps) {
|
|
|
16202
16779
|
const review = await runOne(chapter);
|
|
16203
16780
|
chapter.authorReview = review;
|
|
16204
16781
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
16782
|
+
backfillReviewFacts(project, chapter.no, review);
|
|
16205
16783
|
done++;
|
|
16206
16784
|
saveProject(config.outputDir, project);
|
|
16207
16785
|
send({
|
|
@@ -16330,7 +16908,8 @@ function makeRoutes(deps) {
|
|
|
16330
16908
|
handler: async (req, res) => {
|
|
16331
16909
|
if (!guard(req, res, "POST")) return;
|
|
16332
16910
|
const config = getConfig();
|
|
16333
|
-
const
|
|
16911
|
+
const body = await readJsonBody(req);
|
|
16912
|
+
const project = requireProjectAt(req, res, body);
|
|
16334
16913
|
if (project === void 0) return;
|
|
16335
16914
|
res.writeHead(200, {
|
|
16336
16915
|
"content-type": "application/x-ndjson; charset=utf-8",
|
|
@@ -16471,9 +17050,9 @@ function makeRoutes(deps) {
|
|
|
16471
17050
|
handler: async (req, res) => {
|
|
16472
17051
|
if (!guard(req, res, "POST")) return;
|
|
16473
17052
|
const config = getConfig();
|
|
16474
|
-
const project = requireProjectAt(req, res);
|
|
16475
|
-
if (project === void 0) return;
|
|
16476
17053
|
const body = await readJsonBody(req);
|
|
17054
|
+
const project = requireProjectAt(req, res, body);
|
|
17055
|
+
if (project === void 0) return;
|
|
16477
17056
|
try {
|
|
16478
17057
|
writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
|
|
16479
17058
|
} catch (error) {
|
|
@@ -16734,11 +17313,18 @@ function makeRoutes(deps) {
|
|
|
16734
17313
|
const body = await readJsonBody(req);
|
|
16735
17314
|
const config = getConfig();
|
|
16736
17315
|
let text = body?.text?.trim() ?? "";
|
|
16737
|
-
if (text === "" && body?.filePath !== void 0 && body.filePath !== "")
|
|
16738
|
-
|
|
16739
|
-
|
|
16740
|
-
|
|
16741
|
-
|
|
17316
|
+
if (text === "" && body?.filePath !== void 0 && body.filePath !== "") {
|
|
17317
|
+
const sourcePath = body.filePath.trim();
|
|
17318
|
+
if (!IMPORTABLE_EXTENSIONS.includes(extname(sourcePath).toLowerCase())) {
|
|
17319
|
+
writeJson(res, 400, { error: "只支持 txt / md / markdown 文件" });
|
|
17320
|
+
return;
|
|
17321
|
+
}
|
|
17322
|
+
try {
|
|
17323
|
+
text = readFileSync(sourcePath, "utf8");
|
|
17324
|
+
} catch (err) {
|
|
17325
|
+
writeJson(res, 400, { error: "读取文件失败:" + err.message });
|
|
17326
|
+
return;
|
|
17327
|
+
}
|
|
16742
17328
|
}
|
|
16743
17329
|
if (text.length < 200) {
|
|
16744
17330
|
writeJson(res, 400, { error: "全文内容过短(<200 字符),请上传完整小说文本" });
|
|
@@ -17051,6 +17637,7 @@ function makeRoutes(deps) {
|
|
|
17051
17637
|
marketRadarBriefRoute,
|
|
17052
17638
|
knowledgeRoute,
|
|
17053
17639
|
bookAnalysisRoute,
|
|
17640
|
+
nameForgeRoute,
|
|
17054
17641
|
ideaInspirationRoute,
|
|
17055
17642
|
marketIdeaInspirationRoute,
|
|
17056
17643
|
directorRoute,
|
|
@@ -17073,6 +17660,7 @@ function makeRoutes(deps) {
|
|
|
17073
17660
|
bookshelfImportTextRoute,
|
|
17074
17661
|
bookshelfImportTextPreviewRoute,
|
|
17075
17662
|
styleHistoryRoute,
|
|
17663
|
+
styleProfileRoute,
|
|
17076
17664
|
resetRoute,
|
|
17077
17665
|
auditRoute,
|
|
17078
17666
|
charactersRefreshRoute,
|
|
@@ -17310,6 +17898,7 @@ const Config = z.object({
|
|
|
17310
17898
|
reviewModel: live(z.string().default("")),
|
|
17311
17899
|
auditModel: live(z.string().default("")),
|
|
17312
17900
|
fallbackModel: live(z.string().default("")),
|
|
17901
|
+
fallbackMaxTokens: live(z.number().default(0)),
|
|
17313
17902
|
autoTimeline: live(z.boolean().default(true)),
|
|
17314
17903
|
reasoningEffort: live(z.union([
|
|
17315
17904
|
"off",
|
|
@@ -17332,7 +17921,13 @@ const Config = z.object({
|
|
|
17332
17921
|
themeBackground: live(z.string().default("")),
|
|
17333
17922
|
themeBackgroundBlur: live(z.number().default(0)),
|
|
17334
17923
|
themeOpacity: live(z.number().default(100)),
|
|
17335
|
-
enableAdaptMode: live(z.boolean().default(true))
|
|
17924
|
+
enableAdaptMode: live(z.boolean().default(true)),
|
|
17925
|
+
savedModels: live(z.array(z.object({
|
|
17926
|
+
id: z.string(),
|
|
17927
|
+
name: z.string(),
|
|
17928
|
+
provider: z.string(),
|
|
17929
|
+
model: z.string()
|
|
17930
|
+
})).default([]))
|
|
17336
17931
|
});
|
|
17337
17932
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
17338
17933
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -17365,6 +17960,7 @@ function resolveConfig(value) {
|
|
|
17365
17960
|
reviewModel: value?.reviewModel,
|
|
17366
17961
|
auditModel: value?.auditModel,
|
|
17367
17962
|
fallbackModel: value?.fallbackModel ?? DEFAULT_FALLBACK_MODEL,
|
|
17963
|
+
fallbackMaxTokens: value?.fallbackMaxTokens,
|
|
17368
17964
|
autoTimeline: value?.autoTimeline ?? DEFAULT_AUTO_TIMELINE,
|
|
17369
17965
|
reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
17370
17966
|
analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
|
|
@@ -17377,7 +17973,8 @@ function resolveConfig(value) {
|
|
|
17377
17973
|
themeBackground: value?.themeBackground ?? "",
|
|
17378
17974
|
themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
|
|
17379
17975
|
themeOpacity: value?.themeOpacity ?? 100,
|
|
17380
|
-
enableAdaptMode: value?.enableAdaptMode ?? true
|
|
17976
|
+
enableAdaptMode: value?.enableAdaptMode ?? true,
|
|
17977
|
+
savedModels: value?.savedModels ?? []
|
|
17381
17978
|
};
|
|
17382
17979
|
}
|
|
17383
17980
|
/**
|
|
@@ -17440,6 +18037,7 @@ function apply(ctx, config) {
|
|
|
17440
18037
|
if (patch.reviewModel !== void 0) next.reviewModel = patch.reviewModel;
|
|
17441
18038
|
if (patch.auditModel !== void 0) next.auditModel = patch.auditModel;
|
|
17442
18039
|
if (patch.fallbackModel !== void 0) next.fallbackModel = patch.fallbackModel;
|
|
18040
|
+
if (patch.fallbackMaxTokens !== void 0) next.fallbackMaxTokens = patch.fallbackMaxTokens;
|
|
17443
18041
|
if (patch.autoTimeline !== void 0) next.autoTimeline = patch.autoTimeline;
|
|
17444
18042
|
if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
|
|
17445
18043
|
if (patch.analysisReasoning !== void 0) next.analysisReasoning = patch.analysisReasoning;
|
|
@@ -17453,6 +18051,7 @@ function apply(ctx, config) {
|
|
|
17453
18051
|
if (patch.themeBackgroundBlur !== void 0) next.themeBackgroundBlur = patch.themeBackgroundBlur;
|
|
17454
18052
|
if (patch.themeOpacity !== void 0) next.themeOpacity = patch.themeOpacity;
|
|
17455
18053
|
if (patch.enableAdaptMode !== void 0) next.enableAdaptMode = patch.enableAdaptMode;
|
|
18054
|
+
if (patch.savedModels !== void 0) next.savedModels = patch.savedModels;
|
|
17456
18055
|
const settings = ctx.get("settings");
|
|
17457
18056
|
const persisted = await persistConfigPatch(current(), next, settings, settingsNs, NOVEL_SETTINGS_NAMESPACE);
|
|
17458
18057
|
setCurrent(persisted.config);
|
|
@@ -17518,7 +18117,6 @@ function apply(ctx, config) {
|
|
|
17518
18117
|
},
|
|
17519
18118
|
onChange: sync
|
|
17520
18119
|
});
|
|
17521
|
-
ctx.get("skills");
|
|
17522
18120
|
sync();
|
|
17523
18121
|
}
|
|
17524
18122
|
//#endregion
|