dsh-novel-writer 3.9.0 → 3.9.5

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.9.0 bundle patch: 16 tools (incl. novel_semantic_search) + local embedding engine.
1
+ # dsh-novel-writer v3.9.5 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
@@ -106,12 +106,10 @@ const RHETORIC_MARKERS = [
106
106
  /不是[^。!?]{0,10}吗/
107
107
  ];
108
108
 
109
- /** 一般疑问句特征词。 */
110
- const QUESTION_WORDS = ["谁", "什么", "为什么", "怎么", "怎样", "如何", "哪", "几", "多少", "何时", "哪里", "哪儿", "是否", "能不能", "会不会", "要不要", "行不行", "好不好", "对不对", "有没有", "为啥", "吗", "么", "呢"];
111
-
112
- /** 祈使句动词/命令词。 */
113
- const IMPERATIVE_HARD = ["请", "快", "赶紧", "立刻", "马上", "别", "不要", "莫", "勿", "不许", "不准", "滚", "住手", "放手", "站住", "听我说", "给我", "让", "快点儿", "快点"];
114
- const IMPERATIVE_START = ["走", "来", "去", "坐", "站", "停", "开始", "出发", "算了吧", "算了", "吃", "喝", "看", "听", "说", "做", "干", "起来", "坐下", "住口"];
109
+ /** 祈使句硬词(v3.9.1 #7):句首出现即祈使(感叹只留给无祈使词的 !/! 句)。 */
110
+ const IMPERATIVE_HARD = ["不准", "不许", "禁止", "闭嘴", "住手", "住口", "站住", "停下", "小心", "当心", "注意", "赶紧", "立刻", "马上", "给我", "跟我", "起来", "坐下", "躺下", "放下", "松开", "放开", "请", "快", "别", "勿", "莫", "滚"];
111
+ /** 祈使句动词(句首、需带命令语气:吧/标点/句尾——"走了几步"这类陈述不算)。 */
112
+ const IMPERATIVE_VERBS = ["去", "来", "走"];
115
113
 
116
114
  /** 环境描写标记词(天色/天气/景物类名词,来自 v0.4.0,v0.6.0 扩充)。 */
117
115
  const ENV_MARKERS = /夜色|月光|晨光|暮色|夕阳|黄昏|清晨|深夜|夜空|天边|星光|灯火|细雨|微风|大雨|暴雨|风雪|薄雾|浓雾|云层|远山|山影|河面|湖水|街道|巷子|烛火|窗|雨声|风声|树影|落叶|残雪|霜|天色|晚风|夜风|晨曦|晚霞|晴空|雨滴|溪水|江水|海浪|沙漠|草原|森林|群山|雪地|庭院|檐下|廊下/;
@@ -127,7 +125,11 @@ export function splitBlocks(text) {
127
125
  const normalized = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
128
126
  const lines = normalized.split("\n");
129
127
  // 段落分隔符 = 前后都有内容的"中间空行";仅开头/结尾的空行不算分段。
130
- const hasBlank = lines.some((line, index) => line.trim() === "" && index > 0 && index < lines.length - 1);
128
+ // v3.9.1 #4:hasBlank 必须同时要求空行两侧都是非空行——文末 \n\n 不再把全文并成一块
129
+ const hasBlank = lines.some((line, index) =>
130
+ line.trim() === "" && index > 0 && index < lines.length - 1 &&
131
+ lines[index - 1].trim() !== "" && lines[index + 1].trim() !== ""
132
+ );
131
133
  const blocks = [];
132
134
  let current = [];
133
135
  for (const line of lines) {
@@ -243,16 +245,24 @@ function isExclamation(text) {
243
245
  return false;
244
246
  }
245
247
 
248
+ /**
249
+ * v3.9.1 #7:硬祈使判定——句首硬祈使词(可带呼语前缀),或句首单字动词 去/来/走
250
+ * 且紧跟命令语气(吧/句末标点/句尾),或含"吧"的非疑问句。
251
+ * 疑问/反问在前置阶段已返回,这里不重复处理。
252
+ */
246
253
  function isImperative(text) {
247
- if (!/吧/.test(text) && !/[!!]$/.test(text)) return false;
248
- for (const verb of IMPERATIVE_HARD) {
249
- if (text.includes(verb)) return true;
250
- }
251
- return new RegExp("^(你|您|我们|咱们|大家)?(" + IMPERATIVE_START.join("|") + ")").test(text) && /吧/.test(text);
254
+ const starts = "^(你|您|我们|咱们|大家|喂)?(" + IMPERATIVE_HARD.sort(function (a, b) { return b.length - a.length; }).join("|") + ")";
255
+ if (new RegExp(starts).test(text)) return true;
256
+ const verbRe = new RegExp("^(你|您|我们|咱们|大家|喂)?(" + IMPERATIVE_VERBS.join("|") + ")(吧|[。!?!?…]|$)");
257
+ if (verbRe.test(text)) return true;
258
+ // "太好了吧!"这类感叹式"吧"句不算祈使(太…了吧 → 感叹留给 isExclamation)
259
+ if (/太[^。!?]{0,12}了吧/.test(text)) return false;
260
+ return /吧/.test(text);
252
261
  }
253
262
 
254
263
  /**
255
- * 单句分类(优先级:对话 > 心理 > 反问 > 疑问 > 感叹 > 祈使 > 环境 > 省略 > 陈述)。
264
+ * 单句分类(优先级:对话 > 心理 > 反问 > 疑问 > 祈使 > 感叹 > 环境 > 省略 > 陈述)。
265
+ * v3.9.1 #7:祈使先于感叹判定——含祈使词的 !/! 句是祈使,感叹只留给无祈使词的 !/! 句。
256
266
  * 引语优先:引号内的直接引语算对话;引号内含心理标记(内心独白)算心理。
257
267
  */
258
268
  export function classifySentence(raw) {
@@ -267,26 +277,35 @@ export function classifySentence(raw) {
267
277
  if (hasPsychMarker(text)) return { type: "psychology", text, quoted: false };
268
278
  if (isRhetoric(text)) return { type: "rhetoric-question", text, quoted: false };
269
279
  if (isQuestion(text)) return { type: "question", text, quoted: false };
270
- if (isExclamation(text)) return { type: "exclamation", text, quoted: false };
271
280
  if (isImperative(text)) return { type: "imperative", text, quoted: false };
281
+ if (isExclamation(text)) return { type: "exclamation", text, quoted: false };
272
282
  if (ENV_MARKERS.test(text)) return { type: "environment", text, quoted: false };
273
283
  if (/…+$/.test(text)) return { type: "ellipsis", text, quoted: false };
274
284
  return { type: "statement", text, quoted: false };
275
285
  }
276
286
 
277
- /** 单字情感词的多字搭配排除("叹气"不算"气"、"笑道"不算"笑")。 */
278
- const EMOTION_EXCLUDE = {
279
- joy: { "笑": ["笑道", "说笑", "玩笑", "搞笑", "笑话", "苦笑", "冷笑", "嘲笑", "嬉笑", "傻笑", "微笑", "笑死", "笑呵呵", "笑眯眯"] },
280
- anger: { "气": ["叹气", "运气", "客气", "一口气", "服气", "脾气", "争气", "节气", "淘气", "景气", "暖气", "口气", "名气", "勇气", "底气", "俗气", "风气", "香气", "水气", "湿气", "潮气", "和气", "生气勃勃"], "生气": ["生气勃勃"] },
281
- sorrow: { "悲": ["慈悲"], "哭": ["哭丧"] },
282
- fear: { "怕": ["哪怕", "只怕", "生怕"] },
283
- surprise: { "愣": ["愣住"] }
284
- };
285
-
286
- /** 情感词前紧邻的否定字("不害怕"不计入恐惧)。 */
287
- const EMOTION_NEGATORS = new Set(["不", "没", "无", "别", "莫", "未"]);
287
+ /**
288
+ * v3.9.1 #6:EMOTION_EXCLUDE(单字情感词的多字搭配排除)已删除——STRONG/WEAK 情感词表
289
+ * 与 DUTIR 兜底词表都不含单字词(叹气/笑道里的"气/笑"从来不会被查),该排除分支是
290
+ * 永不触发的死代码;行为与现状一致("叹气"不产生 anger 等)。
291
+ *
292
+ * v3.9.1 #5:情感否定统一判定。命中情感词时检查其前 1-3 字:
293
+ * - 单字否定:不/没/无/别/莫/未
294
+ * - 双/三字否定前缀:不要/不用/不必/不再/不是/并非/绝非/毫不/丝毫不/从不/从未/没有/没啥/未曾/无法/难以
295
+ */
296
+ const NEGATION_PREFIXES = ["不要", "不用", "不必", "不再", "不是", "并非", "绝非", "毫不", "丝毫不", "从不", "从未", "没有", "没啥", "未曾", "无法", "难以"];
297
+ const NEGATION_SINGLE = new Set(["不", "没", "无", "别", "莫", "未"]);
298
+
299
+ /** text[idx] 起的情感词命中是否被紧邻否定语否定。 */
300
+ function negatedAt(text, idx) {
301
+ if (idx <= 0 || idx >= text.length) return false;
302
+ if (NEGATION_SINGLE.has(text[idx - 1])) return true;
303
+ if (idx >= 2 && NEGATION_PREFIXES.includes(text.slice(idx - 2, idx))) return true;
304
+ if (idx >= 3 && NEGATION_PREFIXES.includes(text.slice(idx - 3, idx))) return true;
305
+ return false;
306
+ }
288
307
 
289
- /** 情感词计数(v0.6.0:扫描式匹配 + 搭配排除 + 否定过滤 + 副词窗口加权)。 */
308
+ /** 情感词计数(v0.6.0:扫描式匹配 + 否定过滤 + 副词窗口加权)。 */
290
309
  function emotionOf(text) {
291
310
  const scores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
292
311
  const cleanScores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
@@ -303,27 +322,8 @@ function emotionOf(text) {
303
322
  while (from < text.length) {
304
323
  const idx = text.indexOf(word, from);
305
324
  if (idx === -1) break;
306
- // 单字词的搭配排除(如"叹气"中的"气")
307
- const exclude = EMOTION_EXCLUDE[emotion]?.[word];
308
- if (exclude !== void 0) {
309
- // v3.5.0 #54:窗口按最长排除词取("生气勃勃"=4 字,旧窗口永远命不中)
310
- const maxLen = Math.max(4, ...exclude.map(function (x) { return x.length; }));
311
- const two = text.slice(Math.max(0, idx - 1), idx + 2);
312
- const prevTwo = text.slice(Math.max(0, idx - 1), idx + 1);
313
- const nextTwo = text.slice(idx, idx + 2);
314
- const prevFour = text.slice(Math.max(0, idx - maxLen), idx + maxLen);
315
- let excluded = false;
316
- for (const bad of exclude) {
317
- if (two.includes(bad) || prevTwo === bad || nextTwo === bad || prevFour.includes(bad)) {
318
- excluded = true;
319
- break;
320
- }
321
- }
322
- if (excluded) { from = idx + word.length; continue; }
323
- }
324
- // 否定过滤:"不害怕"不计入
325
- const prevChar = idx > 0 ? text[idx - 1] : "";
326
- if (EMOTION_NEGATORS.has(prevChar)) { from = idx + word.length; continue; }
325
+ // v3.9.1 #5:否定过滤(共享 negatedAt)——"不要害怕/不再担心/丝毫不慌"不计该次
326
+ if (negatedAt(text, idx)) { from = idx + word.length; continue; }
327
327
  // 副词窗口加权:词前 6 字符内出现的强/弱副词生效一次
328
328
  const window = text.slice(Math.max(0, idx - 6), idx);
329
329
  let weight = 1;
@@ -376,12 +376,15 @@ function emotionOf(text) {
376
376
  const emo = dutirLookup.get(w);
377
377
  if (!emo) continue;
378
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);
379
+ // v3.9.1 #5:逐次 indexOf 循环——每次命中处都做 negatedAt 过滤,按通过次数计 nOcc(不再"只过滤首次+按全文出现次数")
380
+ let nOccur = 0;
381
+ let hitPos = text.indexOf(w);
382
+ while (hitPos !== -1) {
383
+ if (!negatedAt(text, hitPos)) nOccur += 1;
384
+ hitPos = text.indexOf(w, hitPos + w.length);
385
+ }
383
386
  if (nOccur === 0) continue;
384
- scores[emo] += nOccur; // v3.7.0 ④:按出现次数(与主词表口径一致)
387
+ scores[emo] += nOccur; // v3.7.0 ④:按通过过滤的出现次数(与主词表口径一致)
385
388
  // v3.7.0 高5:DUTIR 27k 词无强弱分级(呵呵/傻乐等口语弱词)——只进 raw,不进 clean(clean 保持小词表强词口径,"净化后情感"承诺不失效)
386
389
  words[emo].push(w);
387
390
  seenWords.add(w);
@@ -610,7 +613,7 @@ export function implicitEmotionScan(text, semResolver = null) {
610
613
  const ambiguous = [];
611
614
  const see = (label, word, n, valence) => {
612
615
  if (valence < 0) negHits += n * -valence;
613
- else { posHits += n * valence; if (false) fragileHits += n; }
616
+ else posHits += n * valence; // v3.9.1 #9:移除 if(false) fragileHits 死分支(保留 else 逻辑)
614
617
  carrierCounts.set(label + ":" + word, (carrierCounts.get(label + ":" + word) ?? 0) + n);
615
618
  };
616
619
  // ① 单方向表(原有)——v3.5.0 M2:跳过与多方向表重叠的词(雨/黄昏/烛火等),防同一出现计 2-3 次(语境裁决交给 ②)
@@ -666,6 +669,10 @@ export function implicitEmotionScan(text, semResolver = null) {
666
669
  ambiguousRatio: total === 0 ? 0 : Math.round((ambHits / (total + ambHits)) * 100) / 100,
667
670
  fragile: fragileHits > 0,
668
671
  totalHits: negHits + posHits,
672
+ // v3.9.1 #11:透出内部实际使用的加权计数(与上面比率计算同源;比率口径不变)
673
+ negHits,
674
+ posHits,
675
+ ambHits,
669
676
  ambiguous,
670
677
  topCarriers: top
671
678
  };
@@ -690,8 +697,11 @@ export function valenceSeries(text, winChars = 100) {
690
697
  while (from < slice.length) {
691
698
  const idx = slice.indexOf(word, from);
692
699
  if (idx === -1) break;
693
- sum += val; n += 1;
694
- if (val > 0) pos += 1; else if (val < 0) neg += 1;
700
+ // v3.9.1 #5:对每个情感词命中位置做 negatedAt 过滤——被否定则该次不计词、不进 pos/neg 计数
701
+ if (!negatedAt(text, start + idx)) {
702
+ sum += val; n += 1;
703
+ if (val > 0) pos += 1; else if (val < 0) neg += 1;
704
+ }
695
705
  from = idx + word.length;
696
706
  }
697
707
  }
@@ -892,12 +902,14 @@ export function analyzeText(text, options = {}) {
892
902
  const blocks = splitBlocks(text);
893
903
  const sentences = [];
894
904
  const blockMeta = [];
905
+ // v3.9.1 #10:是否因 maxSentences 上限提前 break(前缀采样)——只影响 guidance 尾注,不加顶层字段
906
+ let truncated = false;
895
907
  for (let b = 0; b < blocks.length; b += 1) {
896
- if (sentences.length >= maxSentences) break;
908
+ if (sentences.length >= maxSentences) { truncated = true; break; }
897
909
  const parts = splitSentences(blocks[b]);
898
910
  const meta = { sentences: [], opening: void 0, closing: void 0 };
899
911
  for (const part of parts) {
900
- if (sentences.length >= maxSentences) break;
912
+ if (sentences.length >= maxSentences) { truncated = true; break; }
901
913
  const classified = classifySentence(part);
902
914
  const record = {
903
915
  text: part,
@@ -1152,8 +1164,8 @@ export function analyzeText(text, options = {}) {
1152
1164
  fpSum += parts.length - 1;
1153
1165
  fpText = parts.join("\u0000".repeat(w.length));
1154
1166
  }
1155
- // 剩余孤立单字"我"
1156
- fpSum += (fpText.match(/我/g) || []).length;
1167
+ // v3.9.1 #8:剩余孤立单字统计 我/俺/咱 三者(原只补"我",俺/咱方言第一人称漏计)
1168
+ fpSum += (fpText.match(/[我俺咱]/g) || []).length;
1157
1169
  return fpSum;
1158
1170
  })();
1159
1171
  const firstPersonDensity = totalChars === 0 ? 0 : (firstPersonCount / totalChars) * 1000;
@@ -1192,7 +1204,11 @@ export function analyzeText(text, options = {}) {
1192
1204
  }
1193
1205
 
1194
1206
  const ratios = Object.fromEntries(CATEGORY_ORDER.map((type) => [type, totalSentences === 0 ? 0 : round(counts[type] / totalSentences, 4)]));
1195
- const guidance = buildGuidance(ratios, motifs, chapterPatterns);
1207
+ let guidance = buildGuidance(ratios, motifs, chapterPatterns);
1208
+ // v3.9.1 #10:因 maxSentences 前缀截断时在 guidance 末尾追加一行提示(不新增顶层字段)
1209
+ if (truncated) {
1210
+ guidance += "\n⚠️ 文本超过采样上限(" + maxSentences + " 句),以上统计基于前缀采样;建议分章分析。";
1211
+ }
1196
1212
 
1197
1213
  const fingerprint = buildFingerprint(categories, lengths, style, emotion, motifs);
1198
1214
  const density = densityOf(text);
@@ -1321,7 +1337,8 @@ const DENSITY_OBJECT_WORDS = /油灯|灯|床|门|窗|桌|椅|凳|杯|碗|碟|盘
1321
1337
  const DENSITY_SENSE = {
1322
1338
  visual: /看|见|映入|光|亮|暗|色|影|闪|照|映|目光|眼神|脸|面|状|样/,
1323
1339
  auditory: /听|声|音|响|钟|脚步|敲|喊|叫|低语|呢喃|咕哝|咚|砰|咔/,
1324
- tactile: /凉|热|疼|痛|滑|糙|冷|暖|烫|冰|湿|干|硬|软|麻|痒/,
1340
+ // v3.9.1 #12:温度词(冷/热/凉/暖/烫)只归 temperature 通道——从 tactile 剔除,防同一词 sensePer1000 双计
1341
+ tactile: /疼|痛|滑|糙|冰|湿|干|硬|软|麻|痒/,
1325
1342
  olfactory: /香|臭|味|气|腥|甜|苦|酸/,
1326
1343
  temperature: /热|烫|凉|冷|暖|温/
1327
1344
  };
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-novel-writer — 浏览器端(client half, v3.9.0)
2
+ * dsh-novel-writer — 浏览器端(client half, v3.9.5)
3
3
  *
4
4
  * 在 Web GUI 侧边栏挂载「句式分析」入口,点击后在会话区打开开关面板:
5
5
  * - 启用句式分析(enabled):控制 AI 是否可用 novel_sentence_analysis
@@ -181,10 +181,6 @@ var css = `
181
181
  "panel.back": "Back",
182
182
  "panel.featuresTitle": "功能开关",
183
183
  "panel.featuresHint": "情感净化预警:剔除感官/爽感词干扰后提示 AI 复核真实情绪;题材与流派检测:detect 时输出主/副题材与设定流派。",
184
- "feature.emotionDeep": "情感深度检测",
185
- "feature.emotionDeep.desc": "剔除感官性反应词汇的干扰,呈现作品真实的情感基调",
186
- "feature.styleDetect": "风格检测",
187
- "feature.styleDetect.desc": "识别世界观背景、题材类型与整体气质(如西幻、克苏鲁、甜宠)",
188
184
  "feature.webnovelVibe": "网文信号(风格检测内)",
189
185
  "feature.webnovelVibe.desc": "识别网文套路词与题材联动(打脸/秒杀/追妻火葬场),让甜宠/热血也能被识别",
190
186
  "feature.emotionCaveat": "情感净化预警(高级)",
@@ -387,10 +383,6 @@ var css = `
387
383
  "panel.autoAnalyzeHint": "When on, the AI proactively includes sentence analysis; otherwise only on explicit request.",
388
384
  "panel.featuresTitle": "Feature switches",
389
385
  "panel.featuresHint": "Emotion caveat: strip physiological/euphoric words and ask the AI to re-check real tone. Genre/theme: output main/secondary theme and setting genre on detect.",
390
- "feature.emotionDeep": "Emotion Depth",
391
- "feature.emotionDeep.desc": "Filters physiological reaction words to see the true mood",
392
- "feature.styleDetect": "Style Detect",
393
- "feature.styleDetect.desc": "Detects worldview, theme and vibe (fantasy/cthulhu/romance)",
394
386
  "feature.webnovelVibe": "Webnovel signals",
395
387
  "feature.webnovelVibe.desc": "Recognizes webnovel trope words (face-slap/one-shot kill)",
396
388
  "feature.emotionCaveat": "Emotion caveat (advanced)",
@@ -644,7 +636,6 @@ var css = `
644
636
  demoLoading: false,
645
637
  reportsGroups: null,
646
638
  reportContent: null,
647
- reportLoading: false,
648
639
  features: {},
649
640
  dirs: null,
650
641
  view: "main",
@@ -1177,7 +1168,7 @@ var TOOL_GROUPS = [
1177
1168
  fetch("/api/dsh-novel-writer/reports?read=" + encodeURIComponent(file), { headers: { accept: "application/json" } }).then(function (r2) { if (!r2.ok) throw new Error("HTTP " + r2.status); return r2.json(); }).then(function (d) {
1178
1169
  if (reqId !== reportReqId) return; // v3.5.0 #47:快速切换时丢弃过期响应
1179
1170
  props.controller.set({ reportContent: d.ok ? JSON.stringify(d.content, null, 2) : (t("reports.readFail") + ":" + (d.error || "")) });
1180
- }).catch(function () { props.controller.set({ reportContent: t("reports.readFailDetail") }); });
1171
+ }).catch(function () { if (reqId !== reportReqId) return; props.controller.set({ reportContent: t("reports.readFailDetail") }); });
1181
1172
  };
1182
1173
  var groups = state.reportsGroups || [];
1183
1174
  var totalFiles = groups.reduce(function (a, g) { return a + (g.files || []).length; }, 0);
@@ -1448,10 +1439,10 @@ var TOOL_GROUPS = [
1448
1439
  ),
1449
1440
  // v3.2.0:报告历史入口
1450
1441
  el("button", { key: "reportsEntry", type: "button", style: { display: "flex", alignItems: "center", width: "100%", marginTop: "8px", padding: "9px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff", cursor: "pointer", textAlign: "left" }, onClick: function () {
1451
- props.controller.set({ reportsGroups: null, reportContent: null, reportLoading: true });
1442
+ props.controller.set({ reportsGroups: null, reportContent: null });
1452
1443
  fetch("/api/dsh-novel-writer/reports", { headers: { accept: "application/json" } }).then(function (r2) { if (!r2.ok) throw new Error("HTTP " + r2.status); return r2.json(); }).then(function (d) {
1453
- props.controller.set({ reportsGroups: d.groups || [], reportLoading: false });
1454
- }).catch(function () { props.controller.set({ reportsGroups: [], reportLoading: false }); });
1444
+ props.controller.set({ reportsGroups: d.groups || [] });
1445
+ }).catch(function () { props.controller.set({ reportsGroups: [] }); });
1455
1446
  props.controller.openView("reports");
1456
1447
  } },
1457
1448
  el("span", { style: { flex: "1", fontSize: "13px", fontWeight: "600", color: "#334155" } }, "📊 " + t("reports.title")),
@@ -1596,6 +1587,10 @@ var TOOL_GROUPS = [
1596
1587
  if (container !== void 0 && container.isConnected === true) return;
1597
1588
  var column = conversationColumn();
1598
1589
  if (column === void 0) return;
1590
+ // v3.9.5 修正:宿主重建会话列导致容器脱离 DOM 时,先卸载旧 React root 并移除旧容器,
1591
+ // 避免 PanelView 的订阅闭包累积(旧 root 永不 unmount = 监听泄漏)
1592
+ if (root !== void 0) { try { root.unmount(); } catch (error) { /* ignore */ } root = void 0; }
1593
+ if (container !== void 0) { try { container.remove(); } catch (error) { /* ignore */ } container = void 0; }
1599
1594
  container = document.createElement("div");
1600
1595
  container.dataset.dshNovelWriterView = "";
1601
1596
  container.style.display = "none";
@@ -1640,6 +1635,8 @@ var TOOL_GROUPS = [
1640
1635
  var inject = ["slots", "locale"];
1641
1636
  var applied = false;
1642
1637
  function apply(ctx) {
1638
+ // 注(v3.9.5):applied 防重入依赖宿主 module materialize 语义——若宿主在同一模块实例上 disable→enable/HMR,
1639
+ // 第二次 apply 会被静默跳过且旧 UI 已卸载;如遇此场景需宿主重新加载模块实例
1643
1640
  if (applied) return;
1644
1641
  applied = true;
1645
1642
  var controller = createController();
@@ -1650,26 +1647,31 @@ var TOOL_GROUPS = [
1650
1647
  .then(function (uc) { if (uc) controller.set({ updateCheck: uc }); })
1651
1648
  .catch(function () { /* 静默 */ });
1652
1649
  try {
1650
+ var revAt = controller.getSnapshot().rev;
1653
1651
  var remote = await fetchState();
1652
+ // v3.9.5 修正:初次加载接入 rev 竞态守卫——慢 GET 到达时若用户已操作(rev 变化),
1653
+ // 不得用旧快照覆盖 enabled/autoAnalyze/tools/features/容差/设定等开关字段
1654
+ var revSame = controller.getSnapshot().rev === revAt;
1655
+ var snap = controller.getSnapshot();
1654
1656
  controller.set({
1655
- enabled: !!remote.enabled,
1656
- autoAnalyze: !!remote.autoAnalyze,
1657
- tools: remote.tools || {},
1658
- features: remote.features || {},
1657
+ enabled: revSame ? !!remote.enabled : snap.enabled,
1658
+ autoAnalyze: revSame ? !!remote.autoAnalyze : snap.autoAnalyze,
1659
+ tools: revSame ? (remote.tools || {}) : (snap.tools || {}),
1660
+ features: revSame ? (remote.features || {}) : (snap.features || {}),
1659
1661
  plotsDir: remote.plotsDir || "",
1660
1662
  dataDir: remote.dataDir || "",
1661
1663
  // v2.6.0:初始加载同样接入数据目录占用 + 语义引擎状态(与 refresh 白名单一致)
1662
1664
  dataDirSize: remote.dataDirSize || 0,
1663
1665
  embeddingStatus: remote.embeddingStatus || null,
1664
- styleTolerance: remote.styleTolerance || null,
1665
- creationProfile: remote.creationProfile || null,
1666
- creationProfiles: remote.creationProfiles || {},
1666
+ styleTolerance: revSame ? (remote.styleTolerance || null) : (snap.styleTolerance || null),
1667
+ creationProfile: revSame ? (remote.creationProfile || null) : (snap.creationProfile || null),
1668
+ creationProfiles: revSame ? (remote.creationProfiles || {}) : (snap.creationProfiles || {}),
1667
1669
  books: remote.books || [],
1668
1670
  booksStats: typeof remote.booksStats === "undefined" ? null : (remote.booksStats || []),
1669
1671
  dirs: remote.dirs || null,
1670
1672
  file: remote.file || "",
1671
1673
  saveFailed: false, // v3.7.0 ③:刷新成功清除错误横幅
1672
- source: "host",
1674
+ source: "host",
1673
1675
  hostOk: true,
1674
1676
  loading: false
1675
1677
  });
@@ -1716,11 +1718,21 @@ var TOOL_GROUPS = [
1716
1718
  }
1717
1719
  controller.set(merged);
1718
1720
  try {
1721
+ var revAt2 = controller.getSnapshot().rev;
1719
1722
  var remote = await saveState(patch);
1723
+ var revSame2 = controller.getSnapshot().rev === revAt2;
1724
+ var snap2 = controller.getSnapshot();
1720
1725
  controller.set({
1721
1726
  enabled: !!remote.enabled,
1722
1727
  autoAnalyze: !!remote.autoAnalyze,
1723
- file: remote.file || controller.getSnapshot().file,
1728
+ // v3.9.5 修正:保存成功后回读宿主完整 next(tools/features/容差/设定),
1729
+ // 前提是期间没有更新的用户操作(rev 未变)——避免服务端钳制/规范化与 UI 长期不一致
1730
+ tools: revSame2 && remote.tools ? remote.tools : (snap2.tools || {}),
1731
+ features: revSame2 && remote.features ? remote.features : (snap2.features || {}),
1732
+ styleTolerance: revSame2 && remote.styleTolerance ? remote.styleTolerance : (snap2.styleTolerance || null),
1733
+ creationProfile: revSame2 && remote.creationProfile ? remote.creationProfile : (snap2.creationProfile || null),
1734
+ creationProfiles: revSame2 && remote.creationProfiles ? remote.creationProfiles : (snap2.creationProfiles || {}),
1735
+ file: remote.file || snap2.file,
1724
1736
  source: "host",
1725
1737
  hostOk: true
1726
1738
  });
@@ -1746,6 +1758,8 @@ var openPanel = function () {
1746
1758
  console.warn("[dsh-novel-writer] UI mount failed:", error);
1747
1759
  }
1748
1760
  // 官方设置页「插件配置」卡片(v0.4.0 合并;slots 服务由 inject 声明)
1761
+ // 注(v3.9.5):settings.plugin.item 是 keyed slot——宿主需在其 settingsScope served 集合中登记
1762
+ // novel-writer-config 命名空间该卡片才会被渲染;若宿主未实现则此卡片为“仅侧边栏入口”模式,属预期行为
1749
1763
  try {
1750
1764
  ctx.slots.inject("settings.plugin.item", function () {
1751
1765
  return ctx.slots.register({
package/lib/embedding.js CHANGED
@@ -12,7 +12,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const require = createRequire(import.meta.url);
15
- // v2.6.0 审查修复:Node <22.3 无 zstd——条件获取,不可用时索引缓存回退纯 JSON(插件照常加载,不崩)
15
+ // v2.6.0 审查修复:zstd 条件获取,不可用时索引缓存回退纯 JSON(插件照常加载,不崩)
16
+ // v3.9.1 注释修正:Node 实际在 v23.8.0 才引入 zlib 的 zstd(并非 22.3);22.3–23.7 及更旧版本自动回退纯 JSON 是安全设计
16
17
  let zstdCompressSync = null;
17
18
  let zstdDecompressSync = null;
18
19
  try {
@@ -24,11 +25,11 @@ try {
24
25
  } catch { /* 旧 Node:回退纯 JSON 缓存 */ }
25
26
  const MODEL_DIR = path.join(__dirname, "models");
26
27
  const MODEL_NAME = "bge-small-zh-v1.5"; // v3.5.0 #55:索引缓存模型标识(换模型旧缓存作废)
27
- const EMBED_DIM = 128; // v3.5.0 #55:缓存向量维度(降采样后)
28
+ const EMBED_DIM = 128; // v3.5.0 #55:缓存向量维度(降采样后);v3.9.1:512→128 是每 4 取 1 的有损降采样,无信息量保持保证——只作维度压缩/缓存瘦身,别当作无损语义保持
28
29
  const EMBEDDING_CACHE_ROOT = ".novel-writer";
29
30
 
30
31
  let enginePromise = null;
31
- let engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null };
32
+ let engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null, failedAt: null };
32
33
 
33
34
  /** 模型文件是否随包存在(纯文件探测)。 */
34
35
  function modelPresent() {
@@ -62,9 +63,12 @@ function initEngine() {
62
63
  engine.ort = ort;
63
64
  engine.available = true;
64
65
  engine.error = null;
66
+ engine.failedAt = null;
65
67
  } catch (e) {
66
68
  engine.available = false;
67
69
  engine.error = String(e).slice(0, 300);
70
+ // v3.9.1:失败粘性修复——记录失败时刻,isAvailable() 冷却 30s 后自动重试
71
+ engine.failedAt = Date.now();
68
72
  }
69
73
  return engine;
70
74
  })();
@@ -76,9 +80,14 @@ function status() {
76
80
  return { modelPresent: modelPresent(), loaded: engine.available, error: engine.error };
77
81
  }
78
82
 
79
- /** 可用性(懒加载后返回;失败不抛)。 */
83
+ /** 可用性(懒加载后返回;失败不抛)。失败不是永久态:冷却 30s 后允许一次重载尝试。 */
80
84
  async function isAvailable() {
81
85
  if (!modelPresent()) return false;
86
+ // v3.9.1 失败粘性修复:initEngine 缓存了失败的 enginePromise——超过 30s 冷却即作废重试(仍失败则 failedAt 刷新,再等 30s)
87
+ if (!engine.available && engine.failedAt && Date.now() - engine.failedAt > 30 * 1000) {
88
+ enginePromise = null;
89
+ engine.failedAt = null;
90
+ }
82
91
  await initEngine();
83
92
  return engine.available;
84
93
  }
@@ -108,9 +117,10 @@ async function embed(text) {
108
117
  return cls.map((x) => x / norm);
109
118
  }
110
119
 
111
- /** 批量嵌入(并发 4)。 */
120
+ /** 批量嵌入(并发 4;失败块整体重试一轮,偶发 WASM 抖动不落永久缺失)。 */
112
121
  async function embedMany(chunks) {
113
122
  const out = new Array(chunks.length);
123
+ const failed = [];
114
124
  let cursor = 0;
115
125
  const worker = async () => {
116
126
  while (cursor < chunks.length) {
@@ -118,11 +128,34 @@ async function embedMany(chunks) {
118
128
  // v3.5.0 #53:单块容错——一块 embed 抛错(WASM 抖动/超长)不拖垮整本书索引
119
129
  try {
120
130
  const vec = await embed(chunks[i].text);
121
- out[i] = vec ? { id: chunks[i].id, text: chunks[i].text, vec } : null;
122
- } catch { out[i] = null; }
131
+ if (vec) {
132
+ out[i] = { id: chunks[i].id, text: chunks[i].text, vec };
133
+ } else {
134
+ out[i] = null;
135
+ failed.push(i);
136
+ }
137
+ } catch {
138
+ out[i] = null;
139
+ failed.push(i);
140
+ }
123
141
  }
124
142
  };
125
143
  await Promise.all(Array.from({ length: 4 }, worker));
144
+ // v3.9.1:第一轮失败(抛错或返回 null)的块,第二轮再 embed 一次;仍失败才保留 null 由 filter 剔除
145
+ const retry = failed.slice();
146
+ if (retry.length > 0) {
147
+ let rc = 0;
148
+ const retryWorker = async () => {
149
+ while (rc < retry.length) {
150
+ const i = retry[rc]; rc += 1;
151
+ try {
152
+ const vec = await embed(chunks[i].text);
153
+ out[i] = vec ? { id: chunks[i].id, text: chunks[i].text, vec } : null;
154
+ } catch { out[i] = null; }
155
+ }
156
+ };
157
+ await Promise.all(Array.from({ length: Math.min(4, retry.length) }, retryWorker));
158
+ }
126
159
  return out.filter(Boolean);
127
160
  }
128
161
 
@@ -135,14 +168,14 @@ function cosine(a, b) {
135
168
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
136
169
  }
137
170
 
138
- /** 语义检索(query 维度跟随索引:首次直出 512 维 / 缓存命中 128 维)。 */
171
+ /** 语义检索(query 维度跟随索引;v3.9.1:落库索引已统一 128 维,仅对内存中可能出现的 512 维索引做防御性兼容)。 */
139
172
  async function search(query, index, k = 5) {
140
173
  const qv = await embed(query);
141
174
  if (!qv || !index || index.length === 0) return [];
142
- // v2.5.0 修复轮 5:首次 built 的索引为 512 维(embedMany 直出),缓存命中为 128 维(saveIndex 降采样)——
143
- // query 必须与 index 同维度,否则 cosine(128,512) 长度不等恒返回 0(首次检索全 0 分、排序无意义)
175
+ // v3.9.1 注释修正:原「首次 built 的索引为 512 维」描述已过期——saveIndex/buildIndexIncremental 现在统一 128 维落库;
176
+ // 此处保留 512/128 双维防御(cosine 要求等长,否则恒返回 0),query 为 512 维模型直出时与 128 维索引匹配需降采样
144
177
  const dim = index[0]?.vec?.length ?? 0;
145
- const qvCache = dim === qv.length ? qv : qv.filter((_, i) => i % 4 === 0);
178
+ const qvCache = dim === qv.length ? qv : downsample(qv);
146
179
  return index
147
180
  .map((item) => ({ ...item, score: cosine(qvCache, item.vec) }))
148
181
  .sort((x, y) => y.score - x.score)
@@ -251,6 +284,19 @@ function textHash(text) {
251
284
  return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
252
285
  }
253
286
 
287
+ /**
288
+ * v3.9.1 共享降采样:512 维模型直出 → 128 维(每 4 维取 1),随后做一次 L2 归一化。
289
+ * 降采样只压缩维度、不保证保留完整语义信息(有损);对已是 128 维的旧缓存/复用向量只做归一化,不二次降采样。
290
+ * 注意:不应把 128 维缓存视为损坏——旧缓存 128 维合法。
291
+ */
292
+ function downsample(vec) {
293
+ const raw = Array.isArray(vec) ? vec : [];
294
+ const v = raw.length > 128 ? raw.filter((_, i) => i % 4 === 0) : raw;
295
+ let norm = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0));
296
+ if (!norm || !Number.isFinite(norm)) norm = 1;
297
+ return v.map((x) => x / norm);
298
+ }
299
+
254
300
  /** 读取索引缓存文件(v2.6.0:zstd 压缩存储;旧版纯 JSON 自动兼容)。 */
255
301
  function readIndexData(root, book) {
256
302
  const p = cachePath(root, book);
@@ -262,16 +308,16 @@ function readIndexData(root, book) {
262
308
  try { return JSON.parse(buf.toString("utf8")); } catch { return null; }
263
309
  }
264
310
 
265
- /** 保存索引(v2.6.0:统一 128 维 + 4 位小数降精度 + zstd 压缩,体积约 1/10;fp=内容指纹)。 */
311
+ /** 保存索引(v2.6.0:统一 128 维 + 4 位小数降精度 + zstd 压缩,体积约 1/10;fp=内容指纹)。v3.9.1:正文截断上限 200 字(与检索返回一致)。 */
266
312
  function saveIndex(root, book, index, fp = "") {
267
313
  try {
268
314
  const dir = path.dirname(cachePath(root, book));
269
315
  fs.mkdirSync(dir, { recursive: true });
270
316
  const compact = index.map((item) => {
271
- const vec = item.vec.length > 128 ? item.vec.filter((_, i) => i % 4 === 0) : item.vec;
317
+ const vec = downsample(item.vec);
272
318
  return {
273
319
  id: item.id,
274
- t: item.text.slice(0, 120),
320
+ t: item.text.slice(0, 200),
275
321
  th: item.th ?? textHash(item.text),
276
322
  ch: item.chapter ?? null,
277
323
  v: vec.map((x) => Math.round(x * 10000) / 10000)
@@ -284,24 +330,32 @@ function saveIndex(root, book, index, fp = "") {
284
330
  } catch { return false; }
285
331
  }
286
332
 
287
- /** 读取索引缓存(不存在返回 null)。 */
333
+ /** 读取索引缓存(不存在返回 null)。v3.9.1:逐向量长度校验——任一 vec 维度与 EMBED_DIM 不符即视为缓存损坏(旧 128 维缓存合法,不被误杀)。 */
288
334
  function loadIndex(root, book) {
289
335
  try {
290
336
  const data = readIndexData(root, book);
291
337
  if (!data) return null;
292
338
  // v3.5.0 #55:与 loadIndexMeta 一致——model/dim 不符视为无缓存
293
339
  if (data.model !== MODEL_NAME || data.dim !== EMBED_DIM) return null;
340
+ if (!Array.isArray(data.items)) return null;
341
+ for (const item of data.items) {
342
+ if (!Array.isArray(item?.v) || item.v.length !== EMBED_DIM) return null;
343
+ }
294
344
  return data.items.map((item) => ({ id: item.id, text: item.t, vec: item.v, chapter: item.ch ?? null }));
295
345
  } catch { return null; }
296
346
  }
297
347
 
298
- /** 读取索引缓存 + 内容指纹(旧格式无 fp 时 fp=null,视为失效需重建;不靠版本号失效)。 */
348
+ /** 读取索引缓存 + 内容指纹(旧格式无 fp 时 fp=null,视为失效需重建;不靠版本号失效)。v3.9.1:同 loadIndex 的逐向量长度校验。 */
299
349
  function loadIndexMeta(root, book) {
300
350
  try {
301
351
  const data = readIndexData(root, book);
302
352
  if (!data) return { items: null, fp: null };
303
353
  // v3.5.0 #55:缓存校验 model/dim——换模型/维度变化时旧缓存作废(触发重建),防 cosine 恒 0
304
354
  if (data.model !== MODEL_NAME || data.dim !== EMBED_DIM) return { items: null, fp: null };
355
+ if (!Array.isArray(data.items)) return { items: null, fp: null };
356
+ for (const item of data.items) {
357
+ if (!Array.isArray(item?.v) || item.v.length !== EMBED_DIM) return { items: null, fp: null };
358
+ }
305
359
  return {
306
360
  items: data.items.map((item) => ({ id: item.id, text: item.t, th: item.th ?? null, vec: item.v, chapter: item.ch ?? null })),
307
361
  fp: data.fp ?? null
@@ -340,7 +394,7 @@ async function buildIndexIncremental(chunks, oldItems) {
340
394
  id: chunks[i].id,
341
395
  text: chunks[i].text,
342
396
  th: textHash(chunks[i].text),
343
- vec: v.vec.length > 128 ? v.vec.filter((_, j) => j % 4 === 0) : v.vec,
397
+ vec: downsample(v.vec),
344
398
  chapter: chunks[i].chapter ?? null
345
399
  };
346
400
  }
@@ -407,6 +461,6 @@ function chunkText(text) {
407
461
  }
408
462
 
409
463
  /** 显式释放(仅重置单例,WASM 内存由 GC 回收)。 */
410
- function reset() { enginePromise = null; engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null }; }
464
+ function reset() { enginePromise = null; engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null, failedAt: null }; }
411
465
 
412
466
  export { status, isAvailable, embed, embedMany, cosine, search, saveIndex, loadIndex, loadIndexMeta, fingerprint, textHash, buildIndexIncremental, chunkText, reset, detectImplicitEmotions, IMPLICIT_EMOTION_PROTOTYPES, MODEL_DIR, cachePath, engine };