@waterwx/dsh-novel-forge 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -605,10 +605,11 @@ function formatMetric(metric, value) {
605
605
  }
606
606
  /**
607
607
  * 渲染**生成端**的风格目标(区间形式)。
608
- * @param reference - 写法样本的指纹。
608
+ * @param reference - 参照指纹(写法样本或蒸馏卡)。
609
+ * @param sourceLabel - 参照物的人类可读说明(缺省「绑定的写法样本」)。
609
610
  * @returns 可注入写作 prompt 的文本;样本过短时返回空串。
610
611
  */
611
- function renderFingerprintTargets(reference) {
612
+ function renderFingerprintTargets(reference, sourceLabel) {
612
613
  if (reference.chars < MIN_CHARS) return "";
613
614
  const items = [];
614
615
  const add = (metric, suffix) => {
@@ -631,7 +632,7 @@ function renderFingerprintTargets(reference) {
631
632
  add("imageryPer1000", " 次/千字");
632
633
  if (items.length === 0) return "";
633
634
  return [
634
- "==================== 风格目标(确定性指标,取自绑定的写法样本) ====================",
635
+ "==================== 风格目标(确定性指标,取自" + (sourceLabel ?? "绑定的写法样本") + ") ====================",
635
636
  "本章正文应落在这几个区间内。不是硬性红线,但明显越界会被审稿判为偏离绑定写法:",
636
637
  ...items
637
638
  ].join("\n");
@@ -3634,7 +3635,7 @@ function renderGenreAndProgression(assets) {
3634
3635
  return sections.join("\n");
3635
3636
  }
3636
3637
  /** 渲染写法资产提示词块(规则去重,省 token)。 */
3637
- function renderStyleAssets(assets) {
3638
+ function renderStyleAssets(assets, profile) {
3638
3639
  const styles = assets?.styleAssets ?? [];
3639
3640
  if (styles.length === 0) return "";
3640
3641
  const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
@@ -3645,7 +3646,10 @@ function renderStyleAssets(assets) {
3645
3646
  if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
3646
3647
  if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
3647
3648
  if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
3648
- if (style.sourceText !== void 0 && style.sourceText !== "") {
3649
+ if (profile !== void 0) {
3650
+ const targets = renderFingerprintTargets(profile.fingerprint, profile.label);
3651
+ if (targets !== "") sections.push(targets);
3652
+ } else if (style.sourceText !== void 0 && style.sourceText !== "") {
3649
3653
  const targets = renderFingerprintTargets(extractStyleFingerprint(style.sourceText));
3650
3654
  if (targets !== "") sections.push(targets);
3651
3655
  }
@@ -3653,10 +3657,10 @@ function renderStyleAssets(assets) {
3653
3657
  return sections.join("\n");
3654
3658
  }
3655
3659
  /** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
3656
- function renderAllAssets(assets) {
3660
+ function renderAllAssets(assets, profile) {
3657
3661
  return [
3658
3662
  renderGenreAndProgression(assets),
3659
- renderStyleAssets(assets),
3663
+ renderStyleAssets(assets, profile),
3660
3664
  renderAntiAiRules(assets)
3661
3665
  ].filter((part) => part !== "").join("\n\n");
3662
3666
  }
@@ -4576,6 +4580,278 @@ function detectTimelineIssues(project) {
4576
4580
  return issues;
4577
4581
  }
4578
4582
  //#endregion
4583
+ //#region src/name-forge.ts
4584
+ /** 取名类别表(顺序即面板顺序)。 */
4585
+ const NAME_CATEGORIES = [
4586
+ {
4587
+ id: "person",
4588
+ label: "人名",
4589
+ hint: "主角、配角、反派、路人——姓与名都要有题材气质。",
4590
+ brief: "人物姓名(姓氏+名,或复姓/单名/字号),需有记忆点,避免烂大街的谐音梗",
4591
+ placeholder: "例如:冷僻复姓;主角名带孤勇感;反派名要雅致但有压迫感",
4592
+ maxCount: 20
4593
+ },
4594
+ {
4595
+ id: "place",
4596
+ label: "地名",
4597
+ hint: "宗门所在地、秘境、城池、山川——世界观的空间锚点。",
4598
+ brief: "地名(山川/城池/秘境/宗门所在),需与世界观地理层级自洽",
4599
+ placeholder: "例如:剑修宗门设在雪山;秘境名字要带蛊惑感",
4600
+ maxCount: 20
4601
+ },
4602
+ {
4603
+ id: "technique",
4604
+ label: "功法",
4605
+ hint: "招式、心法、神通、禁术——战力体系的门面。",
4606
+ brief: "功法/招式/神通名(心法、剑式、禁术),需体现运转机理或意境,避免空泛形容词堆砌",
4607
+ placeholder: "例如:偏雷法;名字要短促狠戾;禁术名字带反噬感",
4608
+ maxCount: 20
4609
+ },
4610
+ {
4611
+ id: "faction",
4612
+ label: "势力",
4613
+ hint: "宗门、家族、王朝、组织——派系斗争的名字牌。",
4614
+ brief: "势力/组织/宗门/家族名,需体现其立身之道与行事风格",
4615
+ placeholder: "例如:邪道宗门;名字要堂皇但暗藏戾气",
4616
+ maxCount: 20
4617
+ },
4618
+ {
4619
+ id: "artifact",
4620
+ label: "宝物",
4621
+ hint: "法宝、丹药、神器、材料——金手指与争夺战的载体。",
4622
+ brief: "宝物/法宝/丹药/材料名,需暗示品阶或异能,避免「XX神器」式敷衍",
4623
+ placeholder: "例如:主角本命法宝;名字与功法同源",
4624
+ maxCount: 20
4625
+ }
4626
+ ];
4627
+ /** 类别 id → 规格。 */
4628
+ const CATEGORY_BY_ID = new Map(NAME_CATEGORIES.map((c) => [c.id, c]));
4629
+ /** 取一个类别规格;未知 id 抛错(路由层转 400)。 */
4630
+ function categorySpec(id) {
4631
+ const spec = CATEGORY_BY_ID.get(id);
4632
+ if (spec === void 0) throw new Error(`未知取名类别:${id}`);
4633
+ return spec;
4634
+ }
4635
+ /** 名字长度与理由长度上限(截断显式化)。 */
4636
+ const NAME_MAX = 30;
4637
+ const NOTE_MAX = 120;
4638
+ /** 归一化一条候选/入库条目(裁剪 + 截断;去重由调用方处理)。 */
4639
+ function normalizeNameEntry(raw) {
4640
+ return {
4641
+ name: typeof raw.name === "string" ? raw.name.replace(/\s+/g, " ").trim().slice(0, NAME_MAX) : "",
4642
+ note: typeof raw.note === "string" ? raw.note.replace(/\r?\n/g, " ").trim().slice(0, NOTE_MAX) : ""
4643
+ };
4644
+ }
4645
+ /**
4646
+ * 解析取名 LLM 输出为候选列表。
4647
+ *
4648
+ * 宽容度分级:标准 JSON → 去围栏 → 截取首尾花括号;仍失败返回空数组(由
4649
+ * 调用方决定重试或报错),绝不把模型废话当名字。
4650
+ *
4651
+ * @param text 模型原始输出。
4652
+ * @param category 目标类别(用于校验字段完整性)。
4653
+ * @param count 期望条数(上限)。
4654
+ * @returns 候选列表(已去重、去空、截断)。
4655
+ */
4656
+ function parseNameCandidates(text, count) {
4657
+ const raw = extractJsonObject(text);
4658
+ if (raw === void 0) return [];
4659
+ const list = Array.isArray(raw.names) ? raw.names : [];
4660
+ const out = [];
4661
+ const seen = /* @__PURE__ */ new Set();
4662
+ for (const item of list) {
4663
+ if (typeof item !== "object" || item === null) continue;
4664
+ const rec = item;
4665
+ const name = typeof rec.name === "string" ? rec.name.trim() : "";
4666
+ if (name === "" || seen.has(name)) continue;
4667
+ seen.add(name);
4668
+ const note = typeof rec.note === "string" ? rec.note.trim().slice(0, NOTE_MAX) : "";
4669
+ out.push({
4670
+ name: name.slice(0, NAME_MAX),
4671
+ note
4672
+ });
4673
+ if (out.length >= count) break;
4674
+ }
4675
+ return out;
4676
+ }
4677
+ /** 从模型输出里尽力挖出一个 JSON 对象(标准 / 围栏 / 截取三档)。 */
4678
+ function extractJsonObject(text) {
4679
+ const attempts = [text.trim(), text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim()];
4680
+ for (const attempt of attempts) try {
4681
+ const parsed = JSON.parse(attempt);
4682
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
4683
+ } catch {}
4684
+ const start = text.indexOf("{");
4685
+ const end = text.lastIndexOf("}");
4686
+ if (start !== -1 && end > start) try {
4687
+ const parsed = JSON.parse(text.slice(start, end + 1));
4688
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
4689
+ } catch {}
4690
+ }
4691
+ /**
4692
+ * 构造取名请求的 user 提示词。
4693
+ *
4694
+ * 书籍语境三件套:题材基调、世界设定(境界/区域/势力既有名)、已入库名字
4695
+ * (避重)——三者都可缺省,缺省时明确告诉模型「无既有设定,自由发挥」。
4696
+ *
4697
+ * @param category 类别规格。
4698
+ * @param count 期望条数。
4699
+ * @param hint 作者补充要求(可空)。
4700
+ * @param context 书籍语境。
4701
+ */
4702
+ function buildNameForgeUserPrompt(category, count, hint, context) {
4703
+ const genre = (context.genre ?? "").trim();
4704
+ const world = (context.worldNames ?? []).filter((n) => n.trim() !== "").slice(0, 40);
4705
+ const bank = (context.bankNames ?? []).filter((n) => n.trim() !== "").slice(0, 60);
4706
+ return [
4707
+ `类别:${category.label}(${category.brief})`,
4708
+ `请生成 ${count} 个候选。`,
4709
+ genre !== "" ? `本书题材基调:${genre}——名字气质必须贴合。` : "(未提供题材基调,按通用中文网文气质发挥)",
4710
+ world.length > 0 ? "世界观既有名称(新名字须与它们同一语感,不得重复或近音):\n" + world.map((n) => `- ${n}`).join("\n") : "(暂无世界观设定,自由发挥)",
4711
+ bank.length > 0 ? "已入库名字(一个都不得重复或变体):\n" + bank.map((n) => `- ${n}`).join("\n") : "",
4712
+ hint.trim() !== "" ? `作者补充要求:${hint.trim().slice(0, 300)}` : "",
4713
+ "输出必须是合法 JSON 对象:{\"names\":[{\"name\":\"名字\",\"note\":\"命名理由/意象(20字内)\"}]}。"
4714
+ ].filter((s) => s !== "").join("\n\n");
4715
+ }
4716
+ /** 从项目状态收集取名语境(题材 + 世界既有名 + 已入库名字)。 */
4717
+ function collectNameForgeContext(project) {
4718
+ const genre = project.assets?.genre?.name ?? project.bible?.genre ?? "";
4719
+ const worldNames = [];
4720
+ for (const realm of project.world?.realms ?? []) if (realm.name.trim() !== "") worldNames.push(realm.name.trim());
4721
+ for (const region of project.world?.regions ?? []) if (region.name.trim() !== "") worldNames.push(region.name.trim());
4722
+ for (const faction of project.world?.factions ?? []) if (faction.name.trim() !== "") worldNames.push(faction.name.trim());
4723
+ for (const role of project.roles ?? []) if (role.name.trim() !== "") worldNames.push(role.name.trim());
4724
+ for (const card of project.bible?.characters ?? []) if (card.name.trim() !== "") worldNames.push(card.name.trim());
4725
+ return {
4726
+ genre,
4727
+ worldNames,
4728
+ bankNames: readNameBank(project).map((e) => e.name)
4729
+ };
4730
+ }
4731
+ /** 读取并规整名字库(按类别分组返回前先做一致性校验;非法条目丢弃)。 */
4732
+ function readNameBank(project) {
4733
+ const raw = Array.isArray(project.nameBank) ? project.nameBank : [];
4734
+ const out = [];
4735
+ const seen = /* @__PURE__ */ new Set();
4736
+ for (const item of raw) {
4737
+ if (typeof item !== "object" || item === null) continue;
4738
+ const rec = item;
4739
+ const category = typeof rec.category === "string" ? rec.category : "";
4740
+ if (!CATEGORY_BY_ID.has(category)) continue;
4741
+ const name = typeof rec.name === "string" ? rec.name.trim() : "";
4742
+ if (name === "") continue;
4743
+ const key = `${category}:${name}`;
4744
+ if (seen.has(key)) continue;
4745
+ seen.add(key);
4746
+ out.push({
4747
+ id: typeof rec.id === "string" && rec.id !== "" ? rec.id : `${category}:${name}`,
4748
+ category,
4749
+ name: name.slice(0, NAME_MAX),
4750
+ note: typeof rec.note === "string" ? rec.note.slice(0, NOTE_MAX) : "",
4751
+ adopted: rec.adopted !== false,
4752
+ createdAt: typeof rec.createdAt === "string" ? rec.createdAt : (/* @__PURE__ */ new Date(0)).toISOString()
4753
+ });
4754
+ }
4755
+ return out;
4756
+ }
4757
+ /**
4758
+ * 写入名字库(采纳候选 / 弃用单条 / 清空)。
4759
+ *
4760
+ * 容量纪律:单类 ≤ 60、总数 ≤ 200;超限抛错由路由转 400,**不做静默截断**——
4761
+ * 让作者明确知道库满了,而不是疑惑「我刚取的名字去哪了」。
4762
+ */
4763
+ function mutateNameBank(project, op) {
4764
+ const current = readNameBank(project);
4765
+ let next;
4766
+ if (op.kind === "adopt") {
4767
+ const existing = new Set(current.map((e) => `${e.category}:${e.name}`));
4768
+ const fresh = op.candidates.map((c) => normalizeNameEntry(c)).filter((n) => n.name !== "" && !existing.has(`${op.category}:${n.name}`)).map((n) => ({
4769
+ id: `${op.category}:${n.name}`,
4770
+ category: op.category,
4771
+ name: n.name,
4772
+ note: n.note,
4773
+ adopted: true,
4774
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4775
+ }));
4776
+ if (fresh.length === 0) return current;
4777
+ next = [...current, ...fresh];
4778
+ } else if (op.kind === "discard") next = current.filter((e) => e.id !== op.id);
4779
+ else next = [];
4780
+ const perCategory = /* @__PURE__ */ new Map();
4781
+ for (const e of next) perCategory.set(e.category, (perCategory.get(e.category) ?? 0) + 1);
4782
+ for (const [category, n] of perCategory) if (n > 60) throw new Error(`名字库「${categorySpec(category).label}」已满(${n}/60),请先清理再采纳`);
4783
+ if (next.length > 200) throw new Error(`名字库已满(${next.length}/200),请先清理再采纳`);
4784
+ project.nameBank = next;
4785
+ return next;
4786
+ }
4787
+ /**
4788
+ * 把已采纳名字渲染成写作提示词块(本书层注入)。
4789
+ *
4790
+ * 声明边界:优先使用、不锁死——「表外创新名须与下表气质一致」。
4791
+ *
4792
+ * @param project 项目状态。
4793
+ * @returns 提示词片段;无库时返回空串。
4794
+ */
4795
+ function renderNameBank(project) {
4796
+ const entries = readNameBank(project).filter((e) => e.adopted);
4797
+ if (entries.length === 0) return "";
4798
+ const lines = ["==================== 已定名资产(优先使用,不得另起同名) ===================="];
4799
+ for (const spec of NAME_CATEGORIES) {
4800
+ const names = entries.filter((e) => e.category === spec.id).map((e) => e.name);
4801
+ if (names.length === 0) continue;
4802
+ lines.push(`${spec.label}:${names.join("、")}`);
4803
+ }
4804
+ lines.push("正文涉及上表类别的新专有名字,优先直接从表中取;表外创新名须与上表同一语感,不得与上表近音或重复。");
4805
+ return lines.join("\n");
4806
+ }
4807
+ //#endregion
4808
+ //#region src/style-profile.ts
4809
+ /**
4810
+ * 解析生成端生效的风格目标。
4811
+ *
4812
+ * 优先级:显式卡 > 绑定资产的样本文本 > 无。样本文本取「第一个有 sourceText
4813
+ * 的资产」,指纹由调用方现算——本模块不依赖 style-fingerprint,保持纯数据。
4814
+ *
4815
+ * @param project 项目状态(只需 styleProfile 与 assets.styleAssets[].sourceText)。
4816
+ * @returns 生效目标。
4817
+ */
4818
+ function resolveStyleTarget(project) {
4819
+ if (project.styleProfile !== void 0) return {
4820
+ kind: "profile",
4821
+ fingerprint: project.styleProfile.fingerprint,
4822
+ label: project.styleProfile.label
4823
+ };
4824
+ for (const style of project.assets?.styleAssets ?? []) {
4825
+ const sample = style.sourceText;
4826
+ if (sample !== void 0 && sample !== "") return {
4827
+ kind: "sample",
4828
+ sampleText: sample,
4829
+ label: "绑定写法资产的样本文本"
4830
+ };
4831
+ }
4832
+ return { kind: "none" };
4833
+ }
4834
+ /** 用指纹数据构造一张目标卡。 */
4835
+ function makeStyleProfile(source, label, fingerprint, sampleChapters) {
4836
+ return {
4837
+ source,
4838
+ label,
4839
+ fingerprint,
4840
+ ...sampleChapters !== void 0 ? { sampleChapters } : {},
4841
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4842
+ };
4843
+ }
4844
+ /**
4845
+ * 校验一次 distilled-sample 请求的样本文本。
4846
+ * @returns 归一化后的文本。
4847
+ * @throws 文本过短时抛错(路由转 400)。
4848
+ */
4849
+ function normalizeSampleText(text) {
4850
+ const value = (text ?? "").replace(/\r\n/g, "\n").trim();
4851
+ if (value.length < 200) throw new Error("样本文本过短(<200 字符),指标没有统计意义");
4852
+ return value.slice(0, 2e4);
4853
+ }
4854
+ //#endregion
4579
4855
  //#region src/snapshots.ts
4580
4856
  /**
4581
4857
  * 章节历史版本(快照):覆盖写入前自动留存正文,支持列表 / 回滚 / 清理。
@@ -5587,10 +5863,12 @@ function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
5587
5863
  const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
5588
5864
  book.push(outlineBlock);
5589
5865
  book.push("==================== 大纲结束 ====================");
5590
- const assetsBlock = renderAllAssets(project.assets);
5866
+ const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
5591
5867
  if (assetsBlock !== "") book.push(assetsBlock);
5592
5868
  const slotBlock = renderPromptSlots(project);
5593
5869
  if (slotBlock !== "") book.push(slotBlock);
5870
+ const nameBlock = renderNameBank(project);
5871
+ if (nameBlock !== "") book.push(nameBlock);
5594
5872
  blocks.push({
5595
5873
  layer: "book",
5596
5874
  text: book.join("\n")
@@ -5680,7 +5958,7 @@ function reviewSystemPrompt(project) {
5680
5958
  text: fixed.join("\n")
5681
5959
  });
5682
5960
  const book = [];
5683
- const assetsBlock = renderAllAssets(project.assets);
5961
+ const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
5684
5962
  if (assetsBlock !== "") book.push("\n" + assetsBlock);
5685
5963
  if (bible !== void 0) {
5686
5964
  book.push("\n==================== 道藏 ====================");
@@ -5774,7 +6052,7 @@ function authorReviewSystemPrompt() {
5774
6052
  //#region src/prompts/polish.ts
5775
6053
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
5776
6054
  function polishSystemPrompt(project) {
5777
- const assetsBlock = renderAllAssets(project.assets);
6055
+ const assetsBlock = renderAllAssets(project.assets, project.styleProfile);
5778
6056
  const bible = project.bible;
5779
6057
  const blocks = [];
5780
6058
  const fixed = [
@@ -6281,6 +6559,20 @@ function blurbSystemPrompt() {
6281
6559
  ].join("\n");
6282
6560
  }
6283
6561
  //#endregion
6562
+ //#region src/prompts/nameforge.ts
6563
+ /** 取名系统提示词:类别感知 + 避重 + 结构化 JSON 输出。 */
6564
+ function nameForgeSystemPrompt() {
6565
+ return [
6566
+ "你是资深中文网文命名师,擅长按题材气质取「读一遍就记得住」的专有名字。",
6567
+ "硬规则:",
6568
+ "1. 只输出 JSON 对象,格式 {\"names\":[{\"name\":\"名字\",\"note\":\"命名理由/意象(20字内)\"}]},不要输出其他文字;",
6569
+ "2. 名字必须是中文(人名2-4字、地名2-6字、功法3-7字、势力2-8字、宝物2-8字),不得出现外文、拼音、生僻怪字;",
6570
+ "3. 严格避雷:不得使用真实存在的明星、品牌、企业、敏感人物名;不套用其它作品的成名专名;",
6571
+ "4. 每批名字内部不得互相近音、近形;若给出「避用名单」,其中名字一个都不得再产出;",
6572
+ "5. note 只写命名思路(取自什么意象/气质),不复述名字本身。"
6573
+ ].join("\n");
6574
+ }
6575
+ //#endregion
6284
6576
  //#region src/protocol.ts
6285
6577
  /**
6286
6578
  * dsh-novel-forge — shared protocol between the host half (Node) and the
@@ -6318,8 +6610,12 @@ const NOVEL_API = {
6318
6610
  /** 张力曲线(GET 数据 / POST 设置)。 */
6319
6611
  tension: "/api/dsh-novel-forge/tension",
6320
6612
  styleHistory: "/api/dsh-novel-forge/style-history",
6613
+ /** 风格目标卡:GET 当前卡;POST {action:'distill-archived'|'distill-sample'|'clear'}。 */
6614
+ styleProfile: "/api/dsh-novel-forge/style-profile",
6321
6615
  /** 提示词槽位(GET 列表 / POST 写入)。 */
6322
6616
  promptSlots: "/api/dsh-novel-forge/prompt-slots",
6617
+ /** 取名器:POST {action:'generate'} 生成候选;GET 名字库;POST {action:'adopt'|'discard'|'clear'} 维护。 */
6618
+ nameForge: "/api/dsh-novel-forge/name-forge",
6323
6619
  /** 故事时间线(GET 列表 / POST 操作)。 */
6324
6620
  timeline: "/api/dsh-novel-forge/timeline",
6325
6621
  marketRadar: "/api/dsh-novel-forge/market-radar",
@@ -7555,24 +7851,11 @@ const STYLE_BASELINE_MIN_CHAPTERS = 3;
7555
7851
  * @returns 基线;既无已过审章节也无带样本文本的写法资产时返回 undefined。
7556
7852
  */
7557
7853
  function styleBaseline(outputDir, project) {
7558
- if (outputDir !== void 0) {
7559
- const approved = project.chapters.filter((c) => c.status === "approved" && c.file !== "");
7560
- if (approved.length >= STYLE_BASELINE_MIN_CHAPTERS) {
7561
- const step = Math.max(1, Math.floor(approved.length / STYLE_BASELINE_CHAPTERS));
7562
- const picked = approved.filter((_, index) => index % step === 0).slice(0, STYLE_BASELINE_CHAPTERS);
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
- }
7854
+ const archived = sampleArchivedFingerprint(outputDir, project);
7855
+ if (archived !== void 0) return {
7856
+ fingerprint: archived.fingerprint,
7857
+ label: "本书已过审 " + archived.count + " 章的中位数"
7858
+ };
7576
7859
  for (const style of project.assets?.styleAssets ?? []) {
7577
7860
  const sample = style.sourceText;
7578
7861
  if (sample === void 0 || sample === "") continue;
@@ -7584,6 +7867,51 @@ function styleBaseline(outputDir, project) {
7584
7867
  };
7585
7868
  }
7586
7869
  }
7870
+ /**
7871
+ * 从本书**已过审章节**等距抽样算风格指纹(中位数)。
7872
+ *
7873
+ * 审稿尺子(styleBaseline)与蒸馏卡(distillStyleProfile)共用这一份采样——
7874
+ * 两条路必须同源,否则「审稿说没偏离、生成目标却是另一个方向」。
7875
+ * @returns 抽样指纹与参与章数;不足 {@link STYLE_BASELINE_MIN_CHAPTERS} 章时返回 undefined。
7876
+ */
7877
+ function sampleArchivedFingerprint(outputDir, project) {
7878
+ if (outputDir === void 0) return void 0;
7879
+ const approved = project.chapters.filter((c) => c.status === "approved" && c.file !== "");
7880
+ if (approved.length < STYLE_BASELINE_MIN_CHAPTERS) return void 0;
7881
+ const step = Math.max(1, Math.floor(approved.length / STYLE_BASELINE_CHAPTERS));
7882
+ const picked = approved.filter((_, index) => index % step === 0).slice(0, STYLE_BASELINE_CHAPTERS);
7883
+ const samples = [];
7884
+ for (const chapter of picked) {
7885
+ const raw = readChapterFile(outputDir, chapter);
7886
+ if (raw === void 0) continue;
7887
+ const fp = extractStyleFingerprint(stripChapterHeadings(raw));
7888
+ if (fp.sentences > 0) samples.push(fp);
7889
+ }
7890
+ if (samples.length < STYLE_BASELINE_MIN_CHAPTERS) return void 0;
7891
+ return {
7892
+ fingerprint: mergeFingerprints(samples),
7893
+ count: samples.length
7894
+ };
7895
+ }
7896
+ /**
7897
+ * 蒸馏一张风格目标卡(作者显式动作,落 project.styleProfile)。
7898
+ *
7899
+ * archived:本书已过审章节的中位数(把「我现在实际写出来的样子」固化成目标);
7900
+ * sample:作者粘贴的参考样文本(开书定调)。
7901
+ *
7902
+ * 与审稿尺子刻意分工:见 style-profile.ts 文件头「棘轮效应」说明。
7903
+ * @throws 数据不足时抛错(路由转 400)。
7904
+ */
7905
+ function distillStyleProfile(outputDir, project, source, sampleText) {
7906
+ if (source === "archived") {
7907
+ const archived = sampleArchivedFingerprint(outputDir, project);
7908
+ if (archived === void 0) throw new Error(`已过审章节不足 ${STYLE_BASELINE_MIN_CHAPTERS} 章,不够蒸馏(先写几章并过审)`);
7909
+ return makeStyleProfile("archived", `本书已过审 ${archived.count} 章的中位数`, archived.fingerprint, archived.count);
7910
+ }
7911
+ const fingerprint = extractStyleFingerprint(normalizeSampleText(sampleText));
7912
+ if (fingerprint.sentences === 0) throw new Error("样本文本无法抽取指标,请换一份更完整的正文");
7913
+ return makeStyleProfile("sample", `参考样本蒸馏(${fingerprint.chars} 字)`, fingerprint);
7914
+ }
7587
7915
  function styleFidelityBlock(project, bodyText, outputDir) {
7588
7916
  const baseline = styleBaseline(outputDir, project);
7589
7917
  if (baseline === void 0) return "";
@@ -10156,6 +10484,26 @@ async function runIdeaInspiration(ctx, config, req) {
10156
10484
  })) : [];
10157
10485
  return { ideas: arr(raw.ideas).slice(0, count) };
10158
10486
  }
10487
+ /**
10488
+ * 取名器:按类别批量生成专有名字(题材感知 + 世界观避重 + 库内避重)。
10489
+ *
10490
+ * 候选**不落盘**——作者在面板逐条采纳后才进名字库(走 nameForgeRoute 的
10491
+ * adopt 动作)。生成失败(含模型输出无法解析)抛错,由路由转 500。
10492
+ */
10493
+ async function runNameForge(ctx, config, project, req) {
10494
+ const spec = categorySpec(req.category);
10495
+ const count = Math.max(1, Math.min(spec.maxCount, Math.floor(req.count ?? 10)));
10496
+ const context = collectNameForgeContext(project);
10497
+ const candidates = parseNameCandidates(await complete(ctx, config, {
10498
+ system: nameForgeSystemPrompt(),
10499
+ user: buildNameForgeUserPrompt(spec, count, req.hint ?? "", context),
10500
+ temperature: .9,
10501
+ maxTokens: Math.max(config.maxTokens, 2e3),
10502
+ liveLabel: `取名·${spec.label}`
10503
+ }), count);
10504
+ if (candidates.length === 0) throw new Error("取名失败:模型输出无法解析,请重试或调整补充要求");
10505
+ return candidates;
10506
+ }
10159
10507
  /** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
10160
10508
  async function runMarketIdeaInspiration(ctx, config, req) {
10161
10509
  const count = Math.max(1, Math.min(10, req.count ?? 5));
@@ -14539,6 +14887,66 @@ function makeRoutes(deps) {
14539
14887
  });
14540
14888
  }
14541
14889
  };
14890
+ /**
14891
+ * 风格目标卡:GET = 当前卡与生效说明;POST = 蒸馏 / 清空。
14892
+ *
14893
+ * 与 styleHistory 的分工:那条是**派生视图**(即时算、不落盘);本卡是作者
14894
+ * 显式决定的生成端锚点,落 project.styleProfile。详见 style-profile.ts 文件头。
14895
+ */
14896
+ const styleProfileRoute = {
14897
+ kind: "exact",
14898
+ path: NOVEL_API.styleProfile,
14899
+ handler: async (req, res) => {
14900
+ const config = getConfig();
14901
+ const qurl = new URL(req.url ?? "/", "http://localhost");
14902
+ if (req.method === "GET") {
14903
+ if (!isLoopbackRequest(req)) {
14904
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
14905
+ return;
14906
+ }
14907
+ const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
14908
+ if (project === void 0) {
14909
+ writeJson(res, 400, { error: "输出目录中没有项目" });
14910
+ return;
14911
+ }
14912
+ const target = resolveStyleTarget(project);
14913
+ writeJson(res, 200, {
14914
+ profile: project.styleProfile ?? null,
14915
+ approvedChapters: project.chapters.filter((c) => c.status === "approved" && c.file !== "").length,
14916
+ effectiveLabel: target.kind === "none" ? "无(未绑定带样本的写法资产)" : target.label
14917
+ });
14918
+ return;
14919
+ }
14920
+ if (!guard(req, res, "POST")) return;
14921
+ const body = await readJsonBody(req);
14922
+ if (body === void 0) {
14923
+ writeJson(res, 400, { error: "请求体缺失" });
14924
+ return;
14925
+ }
14926
+ const outputDir = resolveOutputDir(config, body.bookId);
14927
+ const project = loadProject(outputDir);
14928
+ if (project === void 0) {
14929
+ writeJson(res, 400, { error: "输出目录中没有项目" });
14930
+ return;
14931
+ }
14932
+ try {
14933
+ if (body.action === "clear") {
14934
+ delete project.styleProfile;
14935
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
14936
+ saveProject(outputDir, project);
14937
+ writeJson(res, 200, { profile: null });
14938
+ return;
14939
+ }
14940
+ const profile = distillStyleProfile(outputDir, project, body.action === "distill-archived" ? "archived" : "sample", body.text);
14941
+ project.styleProfile = profile;
14942
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
14943
+ saveProject(outputDir, project);
14944
+ writeJson(res, 200, { profile });
14945
+ } catch (error) {
14946
+ writeJson(res, 400, { error: error.message });
14947
+ }
14948
+ }
14949
+ };
14542
14950
  /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
14543
14951
  const marketRadarRoute = {
14544
14952
  kind: "exact",
@@ -15302,6 +15710,66 @@ function makeRoutes(deps) {
15302
15710
  });
15303
15711
  }
15304
15712
  };
15713
+ /**
15714
+ * 取名器:GET = 本书名字库;POST {action:'generate'} = 生成候选(不落盘);
15715
+ * POST {action:'adopt'|'discard'|'clear'} = 库维护。
15716
+ */
15717
+ const nameForgeRoute = {
15718
+ kind: "exact",
15719
+ path: NOVEL_API.nameForge,
15720
+ handler: async (req, res) => {
15721
+ const config = getConfig();
15722
+ const qurl = new URL(req.url ?? "/", "http://localhost");
15723
+ if (req.method === "GET") {
15724
+ if (!isLoopbackRequest(req)) {
15725
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
15726
+ return;
15727
+ }
15728
+ const project = loadProject(resolveOutputDir(config, qurl.searchParams.get("bookId") ?? void 0));
15729
+ if (project === void 0) {
15730
+ writeJson(res, 400, { error: "输出目录中没有项目" });
15731
+ return;
15732
+ }
15733
+ writeJson(res, 200, { entries: readNameBank(project) });
15734
+ return;
15735
+ }
15736
+ if (!guard(req, res, "POST")) return;
15737
+ const body = await readJsonBody(req);
15738
+ if (body === void 0) {
15739
+ writeJson(res, 400, { error: "请求体缺失" });
15740
+ return;
15741
+ }
15742
+ const outputDir = resolveOutputDir(config, body.bookId);
15743
+ const project = loadProject(outputDir);
15744
+ if (project === void 0) {
15745
+ writeJson(res, 400, { error: "输出目录中没有项目" });
15746
+ return;
15747
+ }
15748
+ try {
15749
+ if (body.action === "generate") {
15750
+ writeJson(res, 200, { candidates: await runNameForge(ctx, config, project, {
15751
+ category: body.category,
15752
+ count: body.count,
15753
+ hint: body.hint
15754
+ }) });
15755
+ return;
15756
+ }
15757
+ const entries = mutateNameBank(project, body.action === "adopt" ? {
15758
+ kind: "adopt",
15759
+ category: body.category ?? "person",
15760
+ candidates: body.candidates ?? []
15761
+ } : body.action === "discard" ? {
15762
+ kind: "discard",
15763
+ id: String(body.id ?? "")
15764
+ } : { kind: "clear" });
15765
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
15766
+ saveProject(outputDir, project);
15767
+ writeJson(res, 200, { entries });
15768
+ } catch (error) {
15769
+ writeJson(res, 400, { error: error.message });
15770
+ }
15771
+ }
15772
+ };
15305
15773
  const bookshelfRoute = {
15306
15774
  kind: "exact",
15307
15775
  path: NOVEL_API.bookshelf,
@@ -17051,6 +17519,7 @@ function makeRoutes(deps) {
17051
17519
  marketRadarBriefRoute,
17052
17520
  knowledgeRoute,
17053
17521
  bookAnalysisRoute,
17522
+ nameForgeRoute,
17054
17523
  ideaInspirationRoute,
17055
17524
  marketIdeaInspirationRoute,
17056
17525
  directorRoute,
@@ -17073,6 +17542,7 @@ function makeRoutes(deps) {
17073
17542
  bookshelfImportTextRoute,
17074
17543
  bookshelfImportTextPreviewRoute,
17075
17544
  styleHistoryRoute,
17545
+ styleProfileRoute,
17076
17546
  resetRoute,
17077
17547
  auditRoute,
17078
17548
  charactersRefreshRoute,
@@ -8,7 +8,7 @@
8
8
  * Assets persist with the project (novel-project.json) and are injected into
9
9
  * generation / planning / review prompts, and available to the AI assistant.
10
10
  */
11
- import type { AntiAiRule, GenreNode, PlotBeatTemplate, ProgressionMode, ProjectAssets, StarterStyleProfile, StyleAsset, StyleTemplate } from './protocol.ts';
11
+ import type { AntiAiRule, GenreNode, PlotBeatTemplate, ProgressionMode, ProjectAssets, StarterStyleProfile, StyleAsset, StyleProfile, StyleTemplate } from './protocol.ts';
12
12
  /** 预置叙事风格模板(8 套,一键绑定无需样本文本)。 */
13
13
  export declare const BUILTIN_STYLE_TEMPLATES: StyleTemplate[];
14
14
  /** 内置全局反 AI 规则(12 条,随插件发布;项目可叠加自定义规则)。 */
@@ -31,9 +31,9 @@ export declare function renderAntiAiRules(assets: ProjectAssets | undefined): st
31
31
  /** 渲染题材与推进模式提示词块。 */
32
32
  export declare function renderGenreAndProgression(assets: ProjectAssets | undefined): string;
33
33
  /** 渲染写法资产提示词块(规则去重,省 token)。 */
34
- export declare function renderStyleAssets(assets: ProjectAssets | undefined): string;
34
+ export declare function renderStyleAssets(assets: ProjectAssets | undefined, profile?: StyleProfile): string;
35
35
  /** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
36
- export declare function renderAllAssets(assets: ProjectAssets | undefined): string;
36
+ export declare function renderAllAssets(assets: ProjectAssets | undefined, profile?: StyleProfile): string;
37
37
  /** 预置写法模板 → 可直接绑定的 StyleAsset。 */
38
38
  export declare function styleTemplateToAsset(template: StyleTemplate): StyleAsset;
39
39
  /** 起始风格画像库:无样本文本也能快速绑定一套写法(4 档起步写法)。 */