dsh-novel-writer 3.6.0 → 3.7.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/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-novel-writer v3.5.0 bundle patch: 16 tools (incl. novel_semantic_search) + local embedding engine.
1
+ # dsh-novel-writer v3.7.0 bundle patch: 16 tools (incl. novel_semantic_search) + local embedding engine.
2
2
  # dsh-novel-writer bundle patch: mounts the novel-writing assistant plugin
3
3
  # into the profile's loader tree. The entry's name is the plugin package
4
4
  # itself, resolved from the profile's node_modules.
package/lib/analysis.js CHANGED
@@ -365,13 +365,24 @@ function emotionOf(text) {
365
365
  const seenWords = new Set();
366
366
  for (const list of Object.values(words)) for (const w of list) seenWords.add(w);
367
367
  dutirEmotion(""); // 确保 dutirLookup 已构建(懒加载)
368
- const bigrams = text.match(/[\u4e00-\u9fa5]{2}/g) || [];
368
+ // v3.7.0 高4:重叠滑窗提取二字组(非重叠会漏检——"他觉得一阵恶心。"奇偶偏移丢"恶心")
369
+ const bigrams = [];
370
+ for (let bi = 0; bi + 1 < text.length; bi += 1) {
371
+ const bw = text.slice(bi, bi + 2);
372
+ if (/[\u4e00-\u9fa5]/.test(bw[0]) && /[\u4e00-\u9fa5]/.test(bw[1])) bigrams.push(bw);
373
+ }
369
374
  for (const w of new Set(bigrams)) {
370
375
  if (seenWords.has(w)) continue;
371
376
  const emo = dutirLookup.get(w);
372
377
  if (!emo) continue;
373
- scores[emo] += 1;
374
- cleanScores[emo] += 1;
378
+ // v3.7.0 ⑥:DUTIR 兜底同样做否定过滤("不害怕"不再计 fear——emotionOf 有过滤,兜底漏了)
379
+ // v3.7.0 ④:按实际出现次数计(与主词表口径一致——"恶心恶心恶心"计 3 而非 1);否定只过滤首次位置前一字(接受)
380
+ const hitAt = text.indexOf(w);
381
+ if (hitAt > 0 && EMOTION_NEGATORS.has(text[hitAt - 1])) continue;
382
+ const nOccur = (text.split(w).length - 1);
383
+ if (nOccur === 0) continue;
384
+ scores[emo] += nOccur; // v3.7.0 ④:按出现次数(与主词表口径一致)
385
+ // v3.7.0 高5:DUTIR 27k 词无强弱分级(呵呵/傻乐等口语弱词)——只进 raw,不进 clean(clean 保持小词表强词口径,"净化后情感"承诺不失效)
375
386
  words[emo].push(w);
376
387
  seenWords.add(w);
377
388
  }
@@ -464,8 +475,8 @@ const VALENCE_WORDS = Object.freeze({
464
475
  "畏惧": -0.7, "惊恐": -0.9, "胆怯": -0.5, "发抖": -0.3, "哆嗦": -0.3, "心慌": -0.5,
465
476
  "毛骨悚然": -0.9, "冷汗": -0.5, "忐忑": -0.5, "惶恐": -0.9, "心悸": -0.5, "惊惶": -0.7,
466
477
  "胆战心惊": -0.9, "心虚": -0.5, "焦虑": -0.5, "恐慌": -0.9,
467
- "恶心": -0.7, "厌恶": -0.7, "鄙视": -0.7, "轻蔑": -0.5, "嫌弃": -0.7, "反感": -0.5,
468
- "憎恶": -0.9, "作呕": -0.7,
478
+ "恶心": -0.7, "鄙视": -0.7, "轻蔑": -0.5, "嫌弃": -0.7, "反感": -0.5,
479
+ "作呕": -0.7,
469
480
  "惊讶": -0.1, "震惊": -0.5, "意外": -0.1, "吃惊": -0.3, "诧异": -0.3, "愕然": -0.3,
470
481
  "愣住": -0.1, "目瞪口呆": -0.5, "难以置信": -0.5, "不可思议": -0.3, "惊愕": -0.5,
471
482
  "惊奇": 0.1, "震撼": -0.3, "傻眼": -0.3, "呆住": -0.1, "惊呆": -0.5, "骇然": -0.5
@@ -684,7 +695,8 @@ export function valenceSeries(text, winChars = 100) {
684
695
  from = idx + word.length;
685
696
  }
686
697
  }
687
- series.push(n === 0 ? 0 : Math.round((sum / n) * 1000) / 1000);
698
+ // v3.7.0 高6:零命中窗口(无情感词)不进 series——0 会稀释均值并伪造"趋势回升"信号
699
+ if (n > 0) series.push(Math.round((sum / n) * 1000) / 1000);
688
700
  windowPosNeg.push({ pos, neg });
689
701
  }
690
702
  const posWords = windowPosNeg.reduce((s, w) => s + w.pos, 0);
@@ -697,7 +709,8 @@ export function valenceStats(text) {
697
709
  // v3.5.0 #57:一次 valenceSeries 取全部(旧版窗口统计二次调用,大书白扫一遍词表)
698
710
  const { series, posWords, negWords, windowPosNeg } = valenceSeries(text);
699
711
  const n = series.length;
700
- if (n === 0) return { windows: 0 };
712
+ // v3.7.0:零命中窗口跳过后的空系列——补全字段(下游 emotion.quantification.stats 恒有值)
713
+ if (n === 0) return { windows: 0, variance: 0, adjVariance: 0, delta: 0, deltaRobust: 0, conflict: 0, posRatio: 0, negRatio: 0, meanValence: 0 };
701
714
  const mean = series.reduce((x, y) => x + y, 0) / n;
702
715
  // v3.6.0:样本方差口径 /(n-1)(n=1 时无方差=0)
703
716
  const variance = n > 1 ? series.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1) : 0;
@@ -743,7 +756,8 @@ export function explicitImplicitCompare(explicitMean, implicit) {
743
756
  const explicitSign = explicitMean > 0.15 ? "positive" : explicitMean < -0.15 ? "negative" : "neutral";
744
757
  const implicitSign = implicit.negative >= 0.6 ? "negative" : implicit.positive >= 0.6 ? "positive" : "neutral";
745
758
  return {
746
- explicitImplicitConflict: explicitSign === "positive" && implicitSign === "negative",
759
+ // v3.7.0 引擎⑦:双向冲突(显负+隐正也报——原只查显正+隐负)
760
+ explicitImplicitConflict: (explicitSign === "positive" && implicitSign === "negative") || (explicitSign === "negative" && implicitSign === "positive"),
747
761
  explicitSign,
748
762
  implicitSign
749
763
  };
@@ -1034,7 +1048,9 @@ export function analyzeText(text, options = {}) {
1034
1048
  }
1035
1049
  for (const [emotion, words] of Object.entries(sentence.emotion.words)) {
1036
1050
  for (const word of words) {
1037
- emotionWordCounts.set(word, (emotionWordCounts.get(word) ?? 0) + 1);
1051
+ // v3.7.0 引擎③:按文本内实际出现次数计数("开心开心开心"不再 count=1
1052
+ const nInSentence = sentence.text ? (sentence.text.split(word).length - 1) : 1;
1053
+ emotionWordCounts.set(word, (emotionWordCounts.get(word) ?? 0) + nInSentence);
1038
1054
  }
1039
1055
  }
1040
1056
  }
@@ -1071,7 +1087,8 @@ export function analyzeText(text, options = {}) {
1071
1087
  if (r18Density >= 1.5) pollutedBy.push("高密度 R18/生理描写(每千字 " + r18Density + ")");
1072
1088
  if (battleDensity >= 3) pollutedBy.push("高密度战斗/爽文描写(每千字 " + battleDensity + ")");
1073
1089
  if (horrorDensity >= 3) pollutedBy.push("高密度恐怖/疯狂描写(每千字 " + horrorDensity + ")");
1074
- const confidence = polluted ? "low" : (cleanDominantEmotion !== "neutral" && cleanDominantEmotion !== dominantEmotion ? "medium" : "high");
1090
+ // v3.7.0 引擎②:clean 无强词主导(全来自弱词)→ medium(不再判 high
1091
+ const confidence = polluted ? "low" : (cleanDominantEmotion === "neutral" ? "medium" : (cleanDominantEmotion !== dominantEmotion ? "medium" : "high"));
1075
1092
  const caveat = polluted
1076
1093
  ? "⚠️ 检测到" + pollutedBy.join("、") + ",dominant(" + EMOTION_LABELS[dominantEmotion] + ")可能来自生理/爽感反应词而非真实情感。请勿直接采信,须 novel_read 抽查 2-3 段原文复核真实情感基调后再下结论。"
1077
1094
  : (cleanDominantEmotion !== dominantEmotion
@@ -1102,13 +1119,23 @@ export function analyzeText(text, options = {}) {
1102
1119
  for (const [emotionName, words] of Object.entries(EMOTION_WORDS)) {
1103
1120
  if (words.includes(word)) { emotionOfWord = emotionName; break; }
1104
1121
  }
1122
+ // v3.7.0 引擎④:DUTIR 兜底词标出真实情感(不再与 scores 自相矛盾标 neutral)
1123
+ if (emotionOfWord === "neutral") emotionOfWord = dutirLookup.get(word) || "neutral";
1105
1124
  return { word, count, emotion: emotionOfWord };
1106
1125
  })
1107
1126
  .sort((a, b) => b.count - a.count || a.word.localeCompare(b.word))
1108
1127
  .slice(0, 12),
1109
1128
  curve: emotionCurve(blockMeta, curveSegments),
1110
1129
  // v1.6.0:情感量化(Valence 三指标 + 显隐对比 + 复杂度 + 复合共现)
1111
- quantification: emotionalQuantification(text, [], blockMeta.map((m) => m.sentences).filter((s) => s.length > 0), options.semResolver || null)
1130
+ quantification: emotionalQuantification(text, blockMeta.map(function (m, bi) {
1131
+ // v3.7.0 引擎⑤:perChapter 用段级分组(此前硬编码 [] → chapterDrift.swinging 恒 false)
1132
+ const pcCounts = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
1133
+ for (const s of m.sentences) {
1134
+ const cs = s.emotion.cleanScores ?? {};
1135
+ for (const k of Object.keys(pcCounts)) pcCounts[k] += cs[k] ?? 0;
1136
+ }
1137
+ return { chapter: "段" + (bi + 1), counts: pcCounts };
1138
+ }).filter((m) => Object.values(m.counts).some((v) => v > 0)), blockMeta.map((m) => m.sentences).filter((s) => s.length > 0), options.semResolver || null)
1112
1139
  };
1113
1140
 
1114
1141
  // 主观性指数(启发式 0-100)
package/lib/client.js CHANGED
@@ -678,8 +678,9 @@ var css = `
678
678
  var revAt = api.getSnapshot().rev;
679
679
  return fetchState().then(function (remote) {
680
680
  api.set({
681
- enabled: !!remote.enabled,
682
- autoAnalyze: !!remote.autoAnalyze,
681
+ // v3.7.0 ②:enabled/autoAnalyze 与 tools/features 同样受竞态守卫(慢请求不覆盖用户刚切的总开关)
682
+ enabled: (api.getSnapshot().rev === revAt ? !!remote.enabled : api.getSnapshot().enabled),
683
+ autoAnalyze: (api.getSnapshot().rev === revAt ? !!remote.autoAnalyze : api.getSnapshot().autoAnalyze),
683
684
  // v3.5.0 R4:请求期间版本未变(用户没操作)才用远端覆盖开关——慢请求不覆盖用户刚切的
684
685
  tools: (api.getSnapshot().rev === revAt ? (remote.tools || {}) : api.getSnapshot().tools || {}),
685
686
  features: (api.getSnapshot().rev === revAt ? (remote.features || {}) : api.getSnapshot().features || {}),
@@ -859,6 +860,8 @@ var css = `
859
860
  syncActive();
860
861
  tryPlace();
861
862
  return function () {
863
+ // v3.7.0 ⑤:卸载时清理 rawTimer 倒计时(防 interval 泄漏)
864
+ if (rawTimer) { clearInterval(rawTimer); rawTimer = null; }
862
865
  waitObserver.disconnect();
863
866
  rootObserver.disconnect();
864
867
  unsubscribe();
@@ -885,6 +888,8 @@ var css = `
885
888
  );
886
889
  }
887
890
  function PanelView(props) {
891
+ // v3.7.0 高1:isEn 声明在视图作用域(此前只在 rawSetPromise 内部,基线视图引用 ReferenceError 必崩)
892
+ var isEn = typeof document !== "undefined" && (document.documentElement.lang || "zh").toLowerCase().startsWith("en");
888
893
  var force = react.useState(0)[1];
889
894
  react.useEffect(function () {
890
895
  return props.controller.subscribe(function () { force(function (n) { return n + 1; }); });
@@ -907,7 +912,11 @@ var css = `
907
912
  );
908
913
  };
909
914
  var backBtn = function () {
910
- return el("button", { type: "button", className: "nwBackBtn", onClick: function () { props.controller.openView(view === "reports" || view === "creation" ? "main" : view === "baseline" ? "main" : view === "creation-form" ? "creation" : view === "model" ? "features" : "main"); } }, "‹ " + t("panel.back"));
915
+ return el("button", { type: "button", className: "nwBackBtn", onClick: function () {
916
+ // v3.7.0 ④:creation-form 返回前 dirty 确认(与 switchBook 同机制,防静默丢修改)
917
+ if (view === "creation-form" && state.creationDirty && !window.confirm(t("creation.dirtyWarn"))) return;
918
+ props.controller.openView(view === "reports" || view === "creation" ? "main" : view === "baseline" ? "main" : view === "creation-form" ? "creation" : view === "model" ? "features" : "main");
919
+ } }, "‹ " + t("panel.back"));
911
920
  };
912
921
  var switchRow = function (name, fOn, onToggle, extra) {
913
922
  return el("div", { className: "nwToolRow" + (fOn ? " nwToolRowOn" : ""), key: name },
@@ -1434,6 +1443,10 @@ var TOOL_GROUPS = [
1434
1443
  entry(t("panel.creationTitle"), t("panel.creationHint"), null, "creation"),
1435
1444
  // v3.0.0:风格基线(六维 ±% 容差带)
1436
1445
  entry(t("panel.baselineTitle"), t("panel.baselineHint"), null, "baseline"),
1446
+ // v3.7.0 ②:主面板功能开关(合并行:情感深度/风格检测/语义增强 + 非净化)——原注释位置,此前误渲染进 tools 子页
1447
+ el("div", { className: "nwSectionTitle" }, t("panel.featuresTitle")),
1448
+ el("div", { className: "nwToolsHint" }, t("panel.featuresHint")),
1449
+ featureRows,
1437
1450
  // v2.6.0:数据目录占用 + 语义引擎状态(一目了然)
1438
1451
  el("div", { className: "nwRow nwRowOff" },
1439
1452
  el("div", { className: "nwRowText" },
@@ -1684,7 +1697,8 @@ var TOOL_GROUPS = [
1684
1697
  booksStats: typeof remote.booksStats === "undefined" ? null : (remote.booksStats || []),
1685
1698
  dirs: remote.dirs || null,
1686
1699
  file: remote.file || "",
1687
- source: "host",
1700
+ saveFailed: false, // v3.7.0 ③:刷新成功清除错误横幅
1701
+ source: "host",
1688
1702
  hostOk: true,
1689
1703
  loading: false
1690
1704
  });
package/lib/embedding.js CHANGED
@@ -364,6 +364,8 @@ function extractChapterTitle(line) {
364
364
  /^Chapter\s+\d+/i.test(stripped) ||
365
365
  /^(序章|序言|楔子|引子|尾声|终章|番外)/.test(stripped)
366
366
  ) {
367
+ // v3.7.0 ⑪:以句号/问号结尾的正文句("第二章内容已更新。")不当标题
368
+ if (/[。!?!?;;,,]$/.test(stripped)) return null;
367
369
  return stripped.slice(0, 30);
368
370
  }
369
371
  return null;
package/lib/index.js CHANGED
@@ -142,6 +142,9 @@ function parseChapterNumber(name) {
142
142
  const stem = String(name).replace(/\.[^.]+$/, "");
143
143
  const arabicMark = /第?(\d{1,4})[章回话]/.exec(stem);
144
144
  if (arabicMark) return Number(arabicMark[1]);
145
+ // v3.7.0 ②:书名-01-标题 的中段数字也是章号(斗破苍穹-01-陨落 → 1)
146
+ const sepMark = /[-._\s](\d{1,4})[-._\s]/.exec(stem);
147
+ if (sepMark) return Number(sepMark[1]);
145
148
  const leading = LEADING_NUMBER.exec(stem);
146
149
  if (leading) return Number(leading[1]);
147
150
  const cjkMark = /第([零一二三四五六七八九十百两]+)[章回话]/.exec(stem);
@@ -152,6 +155,23 @@ function parseChapterNumber(name) {
152
155
  return void 0;
153
156
  }
154
157
 
158
+ /** v3.7.0 ③:章节标识归一(2/02/第2章/第二章/第02章.md → 第02章)——novel_summary 存查同键,防重复插入 */
159
+ function normalizeChapterKey(c) {
160
+ const s = String(c ?? "").trim().replace(/\.md$/i, "").replace(/^#+\s*/, "");
161
+ const m = s.match(/第?(\d{1,4}|[零一二三四五六七八九十百两]+)[章回话节]/);
162
+ if (m) {
163
+ let n;
164
+ if (/^\d+$/.test(m[1])) n = Number(m[1]);
165
+ else n = cjkToNumber(m[1]);
166
+ if (n !== void 0 && Number.isInteger(n)) return "第" + String(n).padStart(2, "0") + "章";
167
+ }
168
+ const m2 = s.match(/^(\d{1,4})[-._\s]/);
169
+ if (m2) return "第" + String(Number(m2[1])).padStart(2, "0") + "章";
170
+ const m3 = s.match(/^(\d{1,4})$/);
171
+ if (m3) return "第" + String(Number(m3[1])).padStart(2, "0") + "章"; // v3.7.0 ③:纯数字章号(2/02)也归一
172
+ return s;
173
+ }
174
+
155
175
  /** 清理章节标题:去掉"第N章/N章"标记、行首序号与常见分隔符。 */
156
176
  function cleanChapterTitle(stem) {
157
177
  return stem
@@ -225,7 +245,7 @@ async function scanChapters(bookPath) {
225
245
  chapters.push({
226
246
  file: entry.name,
227
247
  number: parseChapterNumber(entry.name),
228
- title: cleanChapterTitle(entry.name.slice(0, -ext.length)) || entry.name
248
+ title: cleanChapterTitle(entry.name.slice(0, -ext.length)) || "" // v3.6.0:无标题章节返回空串(不把文件名当标题)
229
249
  });
230
250
  }
231
251
  chapters.sort((a, b) => (a.number ?? Number.MAX_SAFE_INTEGER) - (b.number ?? Number.MAX_SAFE_INTEGER) || a.file.localeCompare(b.file));
@@ -375,7 +395,11 @@ const IMPORT_NOISE = new Set(["原稿件", "单章", "调教计划", "未命名"
375
395
  /** 从文件名提取书名候选:去扩展名、章号标记、噪音词与分隔符。失败返回 void 0。 */
376
396
  function bookNameFromFileName(fileName) {
377
397
  const stem = fileName.replace(/\.[^.]+$/, "");
378
- const parts = stem
398
+ // v3.6.0:文件名启发式只取章号前的书名前缀("斗破苍穹 第1章 陨落.md" → "斗破苍穹",不再把本章标题拼进书名)
399
+ // v3.7.0 ②:数字+分隔符也算章号(斗破苍穹-01-陨落.md → 斗破苍穹;01-初遇.md 仍归未分类)
400
+ const chapterMark = stem.match(/第?(\d{1,4}|[零一二三四五六七八九十百两]+)[章回话节]?[\s._\-—~]/);
401
+ const head = chapterMark ? stem.slice(0, chapterMark.index).trim() : stem;
402
+ const parts = head
379
403
  .replace(/第?(\d{1,4})[章回话]/g, " ")
380
404
  .replace(/第[零一二三四五六七八九十百两]+[章回话]/g, " ")
381
405
  .replace(LEADING_NUMBER, " ")
@@ -485,7 +509,7 @@ function registerNovelImport(ctx, config) {
485
509
  additionalProperties: false,
486
510
  properties: {
487
511
  book: { type: "string" },
488
- from: { type: "string", enum: ["file", "content", "forced"] },
512
+ from: { type: "string", enum: ["file", "content", "forced", "unclassified"] }, // v3.7.0:未分类组 from 标签(前轮漏加枚举,宿主拒绝整个工具输出)
489
513
  maybe: { type: "array", items: { type: "string" }, description: "可能同书的其他分组名(供 AI 判断是否用 book 合并)。" },
490
514
  files: {
491
515
  type: "array",
@@ -585,7 +609,7 @@ function registerNovelImport(ctx, config) {
585
609
  from = "content";
586
610
  } else {
587
611
  book = "未分类";
588
- from = "content";
612
+ from = "unclassified"; // v3.7.0 ③:未分类不再标注"来自文件头内容"(误导)
589
613
  }
590
614
  if (!groupMap.has(book)) {
591
615
  groupMap.set(book, []);
@@ -727,11 +751,22 @@ function detectCulture(text) {
727
751
  function detectGenre(text) {
728
752
  const hits = {};
729
753
  const evidence = {};
754
+ // v3.7.0 引擎①:同流派子串最长匹配("魔法师"不再被 魔法+法师+魔法师 计 3 次;先长后短 + 占位)
730
755
  for (const [genre, words] of Object.entries(GENRE_MARKERS)) {
731
756
  let count = 0;
732
757
  const found = [];
733
- for (const word of words) {
734
- const n = text.split(word).length - 1;
758
+ const sorted = words.slice().sort(function (x, y) { return y.length - x.length; });
759
+ let scanText = text;
760
+ for (const word of sorted) {
761
+ let n = 0;
762
+ let from = 0;
763
+ while (true) {
764
+ const hit = scanText.indexOf(word, from);
765
+ if (hit === -1) break;
766
+ n += 1;
767
+ scanText = scanText.slice(0, hit) + "\u0000".repeat(word.length) + scanText.slice(hit + word.length);
768
+ from = hit + word.length;
769
+ }
735
770
  if (n > 0) { count += n; if (found.length < 6) found.push(word + "×" + n); }
736
771
  }
737
772
  if (count > 0) { hits[genre] = count; evidence[genre] = found; }
@@ -782,7 +817,7 @@ function detectTheme(text) {
782
817
 
783
818
 
784
819
  /** v1.5.0 功能级开关(默认全开):emotionCaveat=情感净化预警,genreTheme=题材/流派检测。 */
785
- const FEATURE_DEFAULTS = Object.freeze({ emotionCaveat: true, genreTheme: true, emotionComplexity: true, semanticEmbedding: true, semanticSearch: true, semanticStyle: true, semanticImplicit: true, rawWriting: false });
820
+ const FEATURE_DEFAULTS = Object.freeze({ emotionCaveat: true, genreTheme: true, emotionComplexity: true, semanticEmbedding: true, semanticSearch: true, semanticStyle: true, semanticImplicit: true, rawWriting: false, webnovelVibe: true }); // v3.7.0 ④:webnovelVibe 进白名单(UI 可开、模型工具可查改)
786
821
  function featureEnabled(state, name) {
787
822
  const features = state?.features ?? {};
788
823
  return features[name] !== false;
@@ -1071,9 +1106,11 @@ function makeStateRoutes(allowLan, config) {
1071
1106
  if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
1072
1107
  const method = req.method ?? "GET";
1073
1108
  if (method === "GET") {
1109
+ try {
1074
1110
  const state = await readSentenceState();
1075
1111
  // v0.8.0:修复 cordis 注入空字符串 root 时 ?? 不生效的问题
1076
- const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
1112
+ // v3.7.0 高2:全新环境(无 config.root、无 lastRoot)回退 cwd——listBookNames(undefined) 不再 TypeError
1113
+ const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : (typeof state.lastRoot === "string" && state.lastRoot.length > 0 ? state.lastRoot : process.cwd());
1077
1114
  const dataDir = typeof root === "string" && root.length > 0 ? novelDataDir(root) : "";
1078
1115
  // v2.6.0:数据目录占用 + 语义引擎状态(status() 只探测文件、不触发模型加载)
1079
1116
  let dataDirSize = 0;
@@ -1119,11 +1156,21 @@ function makeStateRoutes(allowLan, config) {
1119
1156
  } : null,
1120
1157
  file: stateFilePath()
1121
1158
  });
1159
+ } catch (err) {
1160
+ // v3.7.0 高2:GET 任何异常返回 500 JSON(不裸抛杀宿主)
1161
+ return writeJson(res, 500, { error: "state read failed: " + String(err).slice(0, 120) });
1162
+ }
1122
1163
  }
1123
1164
  if (method === "POST") {
1124
1165
  const body = await readJsonBody(req);
1125
1166
  if (body === void 0 || body === null || typeof body !== "object" || Array.isArray(body)) return writeJson(res, 400, { error: "invalid JSON body" });
1126
1167
  try {
1168
+ // v3.7.0 高3:路由层剥离 novel_sentence_config(与工具侧一致)——UI 一键全关不能锁死 AI 通道(关了 AI 就无法再开)
1169
+ if (body?.tools && typeof body.tools === "object") {
1170
+ const toolsClone = { ...body.tools };
1171
+ delete toolsClone.novel_sentence_config;
1172
+ body.tools = toolsClone;
1173
+ }
1127
1174
  const next = await writeSentenceState(body);
1128
1175
  // v3.1.0:保存设定 → 同步创建/更新该书创作资料(有书库根时;文件写失败不影响设定保存)
1129
1176
  const cpRoot = (typeof config?.root === "string" && config.root.length > 0) ? config.root : (next.lastRoot || null);
@@ -1566,7 +1613,7 @@ function registerNovelSentenceAnalysis(ctx, config) {
1566
1613
  await enrichSemanticImplicit(state, root, book, cached, exec);
1567
1614
  if (args?.brief === true && cached.categories && cached.lengths && cached.emotion) {
1568
1615
  const top3b = cached.categories.slice().sort(function (a, b) { return b.count - a.count; }).slice(0, 3).map(function (c) { return c.label + " " + Math.round(c.ratio * 100) + "%"; });
1569
- cached.brief = "全书 " + cached.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cached.lengths.avg.toFixed(1) + ";主导情绪 " + (cached.emotion.dominant || "-") + "。";
1616
+ cached.brief = scope + " " + cached.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cached.lengths.avg.toFixed(1) + ";主导情绪 " + (cached.emotion.dominant || "-") + "。";
1570
1617
  }
1571
1618
  // v3.5.0 H2:缓存命中剥离内部 ver 键(契约:output schema 无 ver)
1572
1619
  const { ver: _ver, ...rest } = cached;
@@ -1603,7 +1650,7 @@ function registerNovelSentenceAnalysis(ctx, config) {
1603
1650
  await enrichSemanticImplicit(state, root, book, result, exec);
1604
1651
  if (args?.brief === true && result.categories && result.lengths && result.emotion) {
1605
1652
  const top3 = result.categories.slice().sort(function (a, b) { return b.count - a.count; }).slice(0, 3).map(function (c) { return c.label + " " + Math.round(c.ratio * 100) + "%"; });
1606
- result.brief = "全书 " + result.totalSentences + " 句:句式以 " + top3.join("、") + " 为主;均句长 " + result.lengths.avg.toFixed(1) + ";主导情绪 " + (result.emotion.dominant || "-") + "。" + (result.categories.find(function (c) { return c.type === "ellipsis"; }) && result.categories.find(function (c) { return c.type === "ellipsis"; }).ratio > 0.2 ? "留白较多,注意节奏" : "");
1653
+ result.brief = scope + " " + result.totalSentences + " 句:句式以 " + top3.join("、") + " 为主;均句长 " + result.lengths.avg.toFixed(1) + ";主导情绪 " + (result.emotion.dominant || "-") + "。" + (result.categories.find(function (c) { return c.type === "ellipsis"; }) && result.categories.find(function (c) { return c.type === "ellipsis"; }).ratio > 0.2 ? "留白较多,注意节奏" : "");
1607
1654
  }
1608
1655
  return { ...result, cache: "miss", reportFile, cachedAt: new Date().toISOString() };
1609
1656
  }
@@ -1622,9 +1669,9 @@ function registerNovelSentenceConfig(ctx, config) {
1622
1669
  autoAnalyze: { type: "boolean", description: "分析作品时是否主动使用句式分析。" },
1623
1670
  tools: { type: "object", additionalProperties: true, description: "各工具开关(如 { novel_plot: false }),键必须是 novel_* 工具名。" },
1624
1671
  features: { type: "object", additionalProperties: true, description: "功能开关(emotionCaveat=情感净化预警 / genreTheme=题材与流派检测),如 { emotionCaveat: false }。" },
1625
- styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(每维 { low: -20, high: 20 },low 为负/高为正;传空对象 {} 清除恢复推荐)。" },
1626
- creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(worldview/characters/forbidden/mainConflict/genre/extra 字符串键,留空项省略;传空对象 {} 清除全部交给模型)。" },
1627
- creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=同上结构;传空对象 {} 清除全部书的专属设定)。" }
1672
+ styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(每维 { low: -20, high: 20 },low 为负/高为正;空对象 {} 清除恢复推荐——宿主不支持 null 类型,清除一律用空对象)。" },
1673
+ creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(worldview/characters/forbidden/mainConflict/genre/extra 字符串键,留空项省略;空对象 {} 清除全部交给模型——宿主不支持 null 类型)。" },
1674
+ creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=同上结构;空对象 {} 清除全部书的专属设定——宿主不支持 null 类型)。" }
1628
1675
  },
1629
1676
  additionalProperties: false
1630
1677
  },
@@ -1641,9 +1688,9 @@ function registerNovelSentenceConfig(ctx, config) {
1641
1688
  tools: { type: "object", additionalProperties: true, description: "各工具当前开关状态。" },
1642
1689
  features: { type: "object", additionalProperties: true, description: "各功能开关状态(emotionCaveat/genreTheme/emotionComplexity/semanticEmbedding)。" },
1643
1690
  embedding: { type: "object", additionalProperties: true, description: "语义嵌入引擎状态(available/error)。" },
1644
- styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(用户自定义 ±%;空对象=使用推荐)。" },
1645
- creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(用户在侧边栏填写的创作意图;空对象=未设置)。" },
1646
- creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=设定对象;空对象=未设置)。" }
1691
+ styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(用户自定义 ±%;未设置=空对象 {},此时使用推荐)。" },
1692
+ creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(用户在侧边栏填写的创作意图;未设置=空对象 {})。" },
1693
+ creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=设定对象;未设置=空对象 {})。" }
1647
1694
  },
1648
1695
  required: ["file", "enabled", "autoAnalyze", "tools", "features", "embedding", "styleTolerance", "creationProfile"]
1649
1696
  },
@@ -1691,7 +1738,7 @@ ${featLines.join("\n")}
1691
1738
  }
1692
1739
  if (args?.features !== null && typeof args?.features === "object") {
1693
1740
  const featPatch = {};
1694
- for (const name of ["emotionCaveat", "genreTheme", "emotionComplexity", "semanticEmbedding", "semanticSearch", "semanticStyle", "semanticImplicit", "rawWriting"]) {
1741
+ for (const name of ["emotionCaveat", "genreTheme", "emotionComplexity", "semanticEmbedding", "semanticSearch", "semanticStyle", "semanticImplicit", "rawWriting", "webnovelVibe"]) {
1695
1742
  if (typeof args.features[name] === "boolean") featPatch[name] = args.features[name];
1696
1743
  }
1697
1744
  if (Object.keys(featPatch).length > 0) patch.features = featPatch;
@@ -1729,9 +1776,10 @@ ${featLines.join("\n")}
1729
1776
  const features = {};
1730
1777
  for (const name of Object.keys(FEATURE_DEFAULTS)) features[name] = featureEnabled(current, name);
1731
1778
  // v3.0.0:风格基线容差(±%)
1732
- const styleTolerance = current?.styleTolerance !== null && typeof current?.styleTolerance === "object" ? current.styleTolerance : null;
1779
+ // v3.7.0:宿主不支持 null 类型——未设置输出空对象 {}(语义:使用推荐)
1780
+ const styleTolerance = current?.styleTolerance !== null && typeof current?.styleTolerance === "object" ? current.styleTolerance : {};
1733
1781
  // v3.1.0:原创模式设定
1734
- const creationProfile = current?.creationProfile !== null && typeof current?.creationProfile === "object" ? current.creationProfile : null;
1782
+ const creationProfile = current?.creationProfile !== null && typeof current?.creationProfile === "object" ? current.creationProfile : {}; // v3.7.0:未设置输出空对象(宿主不支持 null
1735
1783
  const creationProfiles = current?.creationProfiles !== null && typeof current?.creationProfiles === "object" ? current.creationProfiles : {};
1736
1784
  // v2.0.0:语义引擎状态(懒探测——不加载模型,仅文件检查 + 已加载状态)
1737
1785
  const embStatus = embedding.status();
@@ -1861,7 +1909,9 @@ function registerNovelStyleCheck(ctx, config) {
1861
1909
  if (diffs.length === 0) {
1862
1910
  advice = "本章与全书风格高度一致,可以放心续写。";
1863
1911
  } else {
1864
- advice = "续写时建议注意:" + diffs.slice(0, 4).map((d) => d.dimension + (d.note === "偏高" ? "略多" : "略少")).join("、") + "。若为情节需要(如章节情绪转折),可接受适度偏离,但不要持续漂移。";
1912
+ // v3.7.0 ⑦:方向全映射(原只认"偏高""偏长/偏低/偏短"一律输出"略少"
1913
+ const noteMap = { "偏高": "略多", "偏低": "略少", "偏长": "略长", "偏短": "略短" };
1914
+ advice = "续写时建议注意:" + diffs.slice(0, 4).map((d) => d.dimension + (noteMap[d.note] || d.note)).join("、") + "。若为情节需要(如章节情绪转折),可接受适度偏离,但不要持续漂移。";
1865
1915
  }
1866
1916
  // v2.0.0:语义级风格对比(本地 embedding,可选增强;开关开且模型可用时生效)
1867
1917
  let semantic = null;
@@ -2082,7 +2132,7 @@ async function bookStats(root) {
2082
2132
  const files = await readdir(bookPath, { withFileTypes: true });
2083
2133
  for (const f of files) {
2084
2134
  if (!f.isFile()) continue;
2085
- if (!/\.(md|txt)$/i.test(f.name)) continue;
2135
+ if (!/\.(md|markdown|txt)$/i.test(f.name)) continue; // v3.7.0:补 .markdown(与 novel_books 同口径)
2086
2136
  const st = await stat(join(bookPath, f.name));
2087
2137
  chapters += 1;
2088
2138
  // v3.5.0 M11:与其余工具同口径解码(GBK/UTF-16 章节字数不再按 UTF-8 误读)
@@ -2842,7 +2892,9 @@ function registerNovelSettings(ctx, config) {
2842
2892
  const list = value.characters ?? value.locations ?? value.items ?? value.timeline ?? value.worldview ?? [];
2843
2893
  lines.push(`${categoryLabel}(${list.length} 条):`);
2844
2894
  for (const e of list) {
2845
- const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, e.day + (e.event ? " " + e.event : ""), e.basis, e.ritual, Array.isArray(e.bannedWords) ? "禁用词:" + e.bannedWords.join("/") : "", e.recommended ? "替代:" + Object.entries(e.recommended).map(([k, v]) => k + "→" + v).join("/") : ""].filter(Boolean).join(" | ");
2895
+ // v3.7.0 ⑧:无 day 不拼接 "undefined"
2896
+ const dayPart = e.day ? e.day + (e.event ? " " + e.event : "") : (e.event || "");
2897
+ const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, dayPart, e.basis, e.ritual, Array.isArray(e.bannedWords) ? "禁用词:" + e.bannedWords.join("/") : "", e.recommended ? "替代:" + Object.entries(e.recommended).map(([k, v]) => k + "→" + v).join("/") : ""].filter(Boolean).join(" | ");
2846
2898
  lines.push(` - ${e.name}${extra ? ":" + extra : ""}${e.chapter ? "(" + e.chapter + ")" : ""}`);
2847
2899
  }
2848
2900
  if (value.culture && value.evidence) {
@@ -2884,6 +2936,8 @@ function registerNovelSettings(ctx, config) {
2884
2936
  const name = optionalString(args, "name") ?? optionalString(args, "day");
2885
2937
  assert(name !== void 0, "novel_settings add 需要 name(timeline 用 day)参数");
2886
2938
  const entry = { name };
2939
+ // v3.7.0 ⑤:timeline 的 day 字段与 name 同步(update 双查/渲染都用 day,缺失会显示 undefined)
2940
+ if (category === "timeline") entry.day = name;
2887
2941
  for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes", "basis", "ritual"]) {
2888
2942
  if (args?.[key] !== void 0) entry[key] = String(args[key]);
2889
2943
  }
@@ -3025,7 +3079,9 @@ function registerNovelSettings(ctx, config) {
3025
3079
  }
3026
3080
  return result;
3027
3081
  }
3082
+ // v3.7.0 ⑨:total 在 list(默认)返回块——schema 契约(上轮误加进 scan 分支)
3028
3083
  const result = { book, category, action };
3084
+ result.total = data.characters.length + data.locations.length + data.items.length + data.timeline.length + data.worldview.length;
3029
3085
  result.characters = data.characters.map(normalizeSettingEntry);
3030
3086
  result.locations = data.locations.map(normalizeSettingEntry);
3031
3087
  result.items = data.items.map(normalizeSettingEntry);
@@ -3118,7 +3174,9 @@ function registerNovelSummary(ctx, config) {
3118
3174
  if (action !== "list") {
3119
3175
  const chapterArg = optionalString(args, "chapter");
3120
3176
  assert(chapterArg !== void 0, `novel_summary ${action} 需要 chapter 参数`);
3121
- const index = summaries.findIndex((s) => s.chapter === chapterArg);
3177
+ // v3.7.0 ③:归一比较(存"第02章.md"后按"2"也能查到)
3178
+ const normArg = normalizeChapterKey(chapterArg);
3179
+ const index = summaries.findIndex((s) => normalizeChapterKey(s.chapter) === normArg);
3122
3180
  if (action === "get") {
3123
3181
  const found = index !== -1 ? [summaries[index]] : [];
3124
3182
  return { book, action, summaries: found.map((s) => ({ ...s })) };
@@ -3130,7 +3188,7 @@ function registerNovelSummary(ctx, config) {
3130
3188
  const summary = optionalString(args, "summary");
3131
3189
  assert(summary !== void 0, `novel_summary ${action} 需要 summary 参数`);
3132
3190
  const entry = {
3133
- chapter: chapterArg,
3191
+ chapter: normalizeChapterKey(chapterArg), // v3.7.0 ③:存归一键(2/第2章/第02章.md 同键)
3134
3192
  summary,
3135
3193
  keyEvents: Array.isArray(args?.keyEvents) ? args.keyEvents.map(String) : void 0,
3136
3194
  keySettings: Array.isArray(args?.keySettings) ? args.keySettings.map(String) : void 0,
@@ -3444,7 +3502,23 @@ function registerNovelContinuityCheck(ctx, config) {
3444
3502
  const worldviewEntry = Array.isArray(settings.worldview)
3445
3503
  ? settings.worldview.slice().reverse().find((e) => e && e.speechStyle) ?? settings.worldview[0]
3446
3504
  : void 0;
3447
- const styleRule = (worldviewEntry && Array.isArray(worldviewEntry.bannedWords) && worldviewEntry.bannedWords.length > 0) ? worldviewEntry : DEFAULT_BANNED_WORDS;
3505
+ // v3.7.0 ⑥:eastern/中式世界观且未配置 bannedWords 时用反向默认(禁西式词)——不再按欧式默认扫描中式词(老夫/上香被误报"与欧式不符")
3506
+ const entryHasBanned = worldviewEntry && Array.isArray(worldviewEntry.bannedWords) && worldviewEntry.bannedWords.length > 0;
3507
+ let styleRule = entryHasBanned ? worldviewEntry : DEFAULT_BANNED_WORDS;
3508
+ if (!entryHasBanned) {
3509
+ // v3.7.0 ⑥:未登记 worldview 时按正文检测文化基准(中式书不再被欧式默认词表误报老夫/上香)
3510
+ const detCulture = detectCulture(chapterTexts.map(({ text }) => text).join("\n")).culture;
3511
+ if (detCulture === "eastern" || /东方|中式|eastern|古代/i.test(String(worldviewEntry?.name ?? ""))) {
3512
+ styleRule = {
3513
+ culture: "东方/中式古代(默认)",
3514
+ bannedWords: ["Miss", "先生", "阁下", "神甫", "修士", "教堂", "弥撒", "圣器", "城堡", "骑士", "公爵", "伯爵", "庄园", "葡萄酒", "钟楼", "教典", "祈祷", "礼拜", "蜡烛", "烛台", "点烛"],
3515
+ recommended: { "先生": "老爷/相公", "阁下": "大人/官人", "神甫": "和尚/法师", "教堂": "庙/寺", "弥撒": "法事", "点烛": "上香" }
3516
+ };
3517
+ } else if (detCulture === "modern") {
3518
+ // 现代都市无文化禁用词——跳过扫描
3519
+ styleRule = { culture: "现代都市(默认)", bannedWords: [], recommended: {} };
3520
+ }
3521
+ }
3448
3522
  const cultureName = (worldviewEntry && worldviewEntry.name) || styleRule.culture;
3449
3523
  for (const word of styleRule.bannedWords) {
3450
3524
  const hits = chapterTexts.filter(({ text }) => text.includes(word)).map(({ file }) => file);
@@ -3471,7 +3545,7 @@ function registerNovelContinuityCheck(ctx, config) {
3471
3545
  }
3472
3546
  for (const [word, files] of honorHits) {
3473
3547
  const rec = speech.honorGood?.[word] ? `(建议改「${speech.honorGood[word]}」)` : "";
3474
- candidates.push({ type: "语用冲突·客套", detail: `「${word}」×${files.length}章 为中式客套,与当前世界观不符${rec}`, chapters: files });
3548
+ candidates.push({ type: "语用冲突·客套", detail: `「${word}」×${files.length}章 属于「${styleRule.culture}」禁用表达${rec}`, chapters: files }); // v3.7.0 ⑥:按实际词表文化标注
3475
3549
  }
3476
3550
  }
3477
3551
  if (speech && Array.isArray(speech.ritualBadPatterns)) {
@@ -3488,7 +3562,7 @@ function registerNovelContinuityCheck(ctx, config) {
3488
3562
  if (ritualHits.length > 0) {
3489
3563
  candidates.push({
3490
3564
  type: "语用冲突·仪式",
3491
- detail: `检测到烧香/上香类仪式表达(${speech.ritualGoodNote || "应为点烛"})`,
3565
+ detail: `检测到仪式类表达(规范:${speech.ritualGoodNote || "见 worldview 设定"})`,
3492
3566
  chapters: ritualHits
3493
3567
  });
3494
3568
  }
@@ -3793,7 +3867,8 @@ function registerNovelSemanticSearch(ctx, config) {
3793
3867
  const results = await embedding.search(query, index, top);
3794
3868
  return {
3795
3869
  book, query, available: true, cache, indexSize: index.length,
3796
- results: results.map((r) => ({ id: r.id, chapter: r.chapter || String(r.id).split("|")[0] || "全书", text: r.text.slice(0, 200), score: Math.round(r.score * 10000) / 10000 })),
3870
+ results: results.map((r) => ({ id: r.id, chapter: r.chapter || String(r.id).split("|")[0] || "全书", text: String(r.text || "").replace(/\r/g, "").slice(0, 200), score: Math.round(r.score * 10000) / 10000 })),
3871
+ // v3.6.0:清洗 \r(CR 分行文本摘要不出现乱码)
3797
3872
  message: cache === "hit" ? "使用本地语义索引(缓存)" : "首次建索引完成,已缓存"
3798
3873
  };
3799
3874
  } catch (e) {
@@ -4065,8 +4140,9 @@ function formatRead(value) {
4065
4140
  const endLine = value.lines.length > 0 ? value.lines[value.lines.length - 1].number : value.offset - 1;
4066
4141
  const body = value.lines.map((line) => `${line.number}: ${line.text}`).join("\n");
4067
4142
  let footer;
4068
- if (value.truncated) footer = `(输出截断。共 ${value.totalLines} 行 / ${value.chars} 字,已显示 ${value.offset}-${endLine} 行。用 offset=${endLine + 1} 继续阅读。)`;
4069
- else if (endLine < value.totalLines) footer = `( ${value.totalLines} / ${value.chars} 字,已显示 ${value.offset}-${endLine} 行。用 offset=${endLine + 1} 继续阅读。)`;
4143
+ // v3.7.0 ④:chars 是已显示部分字数——文案不再误导为"本章共"
4144
+ if (value.truncated) footer = `(输出截断。本章共 ${value.totalLines} 行,已显示 ${value.offset}-${endLine} / ${value.chars} 字。用 offset=${endLine + 1} 继续阅读。)`;
4145
+ else if (endLine < value.totalLines) footer = `(本章共 ${value.totalLines} 行,已显示 ${value.offset}-${endLine} 行 / ${value.chars} 字。用 offset=${endLine + 1} 继续阅读。)`;
4070
4146
  else footer = `(本章共 ${value.totalLines} 行 / ${value.chars} 字)`;
4071
4147
  return `<path>${value.path}</path>
4072
4148
  <type>novel-chapter</type>
@@ -158,10 +158,21 @@ export function measureStyleMetrics(text) {
158
158
  const actionDensity = per1000(actionCount);
159
159
 
160
160
  // 5) 不确定性:模糊限制语
161
+ // v3.7.0 引擎⑥:HEDGE 最长匹配("像是要"不再计 像是×2+像是要×2;先长后短 + 占位)
161
162
  let hedgeCount = 0;
162
- for (const w of HEDGE_WORDS) {
163
- const parts = t.split(w).length - 1;
164
- hedgeCount += parts;
163
+ const hedgeSorted = HEDGE_WORDS.slice().sort(function (x, y) { return y.length - x.length; });
164
+ let hedgeText = t;
165
+ for (const w of hedgeSorted) {
166
+ let n = 0;
167
+ let from = 0;
168
+ while (true) {
169
+ const hit = hedgeText.indexOf(w, from);
170
+ if (hit === -1) break;
171
+ n += 1;
172
+ hedgeText = hedgeText.slice(0, hit) + "\u0000".repeat(w.length) + hedgeText.slice(hit + w.length);
173
+ from = hit + w.length;
174
+ }
175
+ hedgeCount += n;
165
176
  }
166
177
  const hedgeDensity = per1000(hedgeCount);
167
178
 
@@ -26,7 +26,7 @@ function normalizeVersion(v) {
26
26
  export function isNewerVersion(current, latest) {
27
27
  // v3.5.0 #56:剥预发布后缀(2.10.0-beta 与 2.10.0 视为同版本;currentVersion 为空不误报)
28
28
  if (!String(current ?? "").trim()) return false;
29
- const strip = (v) => String(v).replace(/(?:-|\.)(?:alpha|beta|rc|pre|dev)[0-9.]*$/i, "");
29
+ const strip = (v) => String(v).replace(/(?:-|\.)(?:alpha|beta|rc|pre|dev)[0-9.]*$|-[\d]+$/i, "") // v3.7.0 ⑮修:只剥预发布(-beta2/.rc1)与 build(-1);不再匹配正常次版本段("3.7.0" 不再被剥成 "3");
30
30
  const a = strip(normalizeVersion(current)).split(".").map((n) => parseInt(n, 10) || 0);
31
31
  // v3.5.0 #56:latest 同样剥预发布后缀(2.10.0-beta 不视为比 2.10.0 新)
32
32
  const b = strip(normalizeVersion(latest)).split(".").map((n) => parseInt(n, 10) || 0);
package/lib/vibe.js CHANGED
@@ -288,7 +288,7 @@ export function computeVibe(detect, emotion, text = "") {
288
288
  const envHits = (String(text).match(/月色|清辉|荷塘|暮色|薄雾|余韵|浮光|微凉|静默|细碎|素净|落英|烟雨|水光|风过|光影|夜色|黄昏|月光|露珠|水墨|晚风|斜阳|残阳|疏影|波光|氤氲|幽静|空濛|斑斓/g) || []).length;
289
289
  const envDensity = envHits >= 3 ? Math.min(1, envHits / Math.max(1, text.length / 300)) : 0;
290
290
  const dlgRatio = (String(text).match(/[“"「『]/g) || []).length / Math.max(1, (String(text).match(/[。!?!?]/g) || []).length);
291
- push("aesthetic", Math.min(1, sem.aesthetic * 0.5), 1.5, "语义怅惘/释然");
291
+ push("aesthetic", Math.min(1, sem.aesthetic * 0.5), 1.5, "语义唯美/文艺"); // v3.7.0 引擎⑧:标签与轴匹配(原复制粘贴成 tearjerker 的怅惘/释然)
292
292
  push("aesthetic", envDensity * 2.5, 2.5, "环境意象密度");
293
293
  push("aesthetic", dlgRatio < 0.12 ? 0.35 : 0.05, 1.2, "叙述性文本(对话少)");
294
294
  wnv("aesthetic", 1.3);
@@ -296,7 +296,7 @@ export function computeVibe(detect, emotion, text = "") {
296
296
  // ⑫ 情欲暧昧
297
297
  push("sensual", themes.includes("情色R18") || /情色|R18/.test(theme) ? 0.75 : 0.05, 2, "情色R18题材");
298
298
  wnv("sensual", 1.2);
299
- push("sensual", Math.min(1, sem.sensual * 0.4), 1, "语义甜蜜/仰慕");
299
+ push("sensual", Math.min(1, sem.sensual * 0.4), 1, "语义情欲/暧昧"); // v3.7.0 引擎⑧:标签与轴匹配(原复制粘贴成 fluff/heartwarming 的甜蜜/仰慕)
300
300
 
301
301
  const names = {
302
302
  nightmare: "噩梦感", angst: "焦虑压抑", heartwarming: "温馨治愈", fluff: "甜宠日常",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "3.6.0",
4
- "description": "小说写作助手插件(v3.5.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带、原创模式与创作资料、写作哨兵(衔接/OOC/大纲)。",
3
+ "version": "3.7.0",
4
+ "description": "小说写作助手插件(v3.7.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带、原创模式与创作资料、写作哨兵(衔接/OOC/大纲)。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -71,7 +71,7 @@
71
71
 
72
72
  ## 世界观用语规范(v0.9.0 新增)
73
73
 
74
- - **文化基准自动判断**:`novel_settings`(category=worldview,action=detect)扫描全书,按中西词表命中自动判断文化基准(western/eastern/mixed/unknown)并给出证据与置信度;
74
+ - **文化基准自动判断**:`novel_settings`(category=worldview,action=detect)扫描全书,按中西词表命中自动判断文化基准(western/eastern/mixed/modern/unknown)并给出证据与置信度;
75
75
  - **用语规范登记**:detect 后可用 `novel_settings` add(category=worldview)登记:name(基准名)、basis(判断依据)、bannedWords(禁用词表)、recommended(替代词映射)、ritual(仪式规范,如"点烛不烧香");
76
76
  - **续写前检查**:动笔前先确认 worldview(detect 或人工指定),对照 bannedWords/recommended 用词,**并对照 speechStyle 检查说话方式**(称谓/客套/仪式/语气——不只管"词",还管"怎么说话");
77
77
  - **语用级检查(v1.0.0)**:worldview 的 speechStyle 定义 title(称谓规范)/ honorBad(中式客套禁词)/ ritualBadPatterns(仪式通配,如"上X柱香")/ tone(语气);novel_continuity_check 会输出「语用冲突·客套 / 语用冲突·仪式 / 语用冲突·称谓」三类候选;
@@ -109,7 +109,7 @@ novel_sentence_analysis 的 emotion.quantification 是纯规则计算的数字
109
109
  ## 语义隐性情感(v2.0.0)
110
110
 
111
111
  novel_sentence_analysis 的 emotion.quantification.semanticImplicit(semanticEmbedding 开启且模型可用时输出):
112
- - **hits**:全书"词表外疑似意象段落"top 10——每条含情感标签(温暖/甜蜜/释然/幸福/温柔/眷恋/仰慕/压抑的愤怒/隐忍/悲伤/孤独/怅惘/失落/心碎/恐惧/焦虑/不安/厌恶/震惊/疏离/决绝/不舍/无奈/脆弱/苦涩,共 25 类原型)+ 余弦分数 + 章节;
112
+ - **hits**:全书"词表外疑似意象段落"top 10——每条含情感标签(温暖/甜蜜/释然/幸福/温柔/眷恋/仰慕/压抑的愤怒/隐忍/悲伤/孤独/怅惘/失落/心碎/恐惧/焦虑/不安/厌恶/震惊/疏离/决绝/不舍/无奈/脆弱/苦涩/甜宠/悬疑/唯美/情欲,共 29 类原型)+ 余弦分数 + 章节;
113
113
  - **distribution**:各情感命中段落数分布;
114
114
  - 与规则意象表(雨/黄昏/攥紧衣角)互补:规则抓"已知载体",语义抓"没有关键词但读起来就是那个情绪"的段落;
115
115
  - 用法:写复杂心理时,可据此感知全文的隐性情感基调(如 distribution 显示"脆弱 2/焦虑 2/失落 1" → 这本书表面中性、内里压抑),不用读全文。