dsh-novel-writer 3.7.1 → 3.9.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 +1 -1
- package/lib/client.js +5 -4
- package/lib/embedding.js +1 -1
- package/lib/index.js +406 -71
- package/lib/prompts.js +2 -1
- package/lib/vibe.js +15 -3
- package/package.json +2 -2
- package/skills/novel-writing/SKILL.md +2 -1
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-novel-writer v3.
|
|
1
|
+
# dsh-novel-writer v3.9.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/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-novel-writer — 浏览器端(client half,
|
|
2
|
+
* dsh-novel-writer — 浏览器端(client half, v3.9.0)
|
|
3
3
|
*
|
|
4
4
|
* 在 Web GUI 侧边栏挂载「句式分析」入口,点击后在会话区打开开关面板:
|
|
5
5
|
* - 启用句式分析(enabled):控制 AI 是否可用 novel_sentence_analysis
|
|
@@ -276,7 +276,7 @@ var css = `
|
|
|
276
276
|
"tool.novel_summary": "章节摘要 novel_summary",
|
|
277
277
|
"tool.novel_continuity_check": "连贯性审计 novel_continuity_check",
|
|
278
278
|
"tool.novel_semantic_search": "语义检索 novel_semantic_search",
|
|
279
|
-
"tool.novel_settings.desc": "
|
|
279
|
+
"tool.novel_settings.desc": "五张设定表:人物/地点/道具/时间线/世界观用语规范。",
|
|
280
280
|
"tool.novel_summary.desc": "保存/读取每章摘要,长书续写先读摘要。",
|
|
281
281
|
"tool.novel_continuity_check.desc": "对照设定表扫描全书,输出矛盾候选。",
|
|
282
282
|
"tool.novel_semantic_search.desc": "按语义检索全书相关内容:即使段落未出现查询关键词,也能基于含义匹配。",
|
|
@@ -750,7 +750,7 @@ var css = `
|
|
|
750
750
|
},
|
|
751
751
|
setToggle: function (fn) { toggleRef = fn; },
|
|
752
752
|
openView: function (view) { api.set({ view: view, revealMsg: "", revealErr: false }); },
|
|
753
|
-
setToolGroupOpen: function (patch) { api.set({ toolGroupOpen: Object.assign({},
|
|
753
|
+
setToolGroupOpen: function (patch) { api.set({ toolGroupOpen: Object.assign({}, state.toolGroupOpen || {}, patch) }); },
|
|
754
754
|
rawToggle: function (wantOn) {
|
|
755
755
|
if (!wantOn) {
|
|
756
756
|
if (rawTimer) { clearInterval(rawTimer); rawTimer = null; }
|
|
@@ -969,7 +969,8 @@ var css = `
|
|
|
969
969
|
var TOOL_GROUPS = [
|
|
970
970
|
{ key: "analyze", icon: "📊", tools: ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_sentence_analysis", "novel_style_check", "novel_style_report", "novel_semantic_search", "novel_continuity_check"] },
|
|
971
971
|
{ key: "settings", icon: "📚", tools: ["novel_settings", "novel_plot", "novel_summary", "novel_import"] },
|
|
972
|
-
|
|
972
|
+
// v3.9.0:novel_sentence_config 移出组开关(宿主会剥离该键,全关后 UI 显示与实际不一致)——config 仅由二级页/设置单独管理
|
|
973
|
+
{ key: "create", icon: "✍️", tools: ["novel_new_chapter", "novel_outline"] }
|
|
973
974
|
];
|
|
974
975
|
var toolGroupOpen = state.toolGroupOpen || {};
|
|
975
976
|
var toolRows = TOOL_GROUPS.map(function (grp) {
|
package/lib/embedding.js
CHANGED
|
@@ -409,4 +409,4 @@ function chunkText(text) {
|
|
|
409
409
|
/** 显式释放(仅重置单例,WASM 内存由 GC 回收)。 */
|
|
410
410
|
function reset() { enginePromise = null; engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null }; }
|
|
411
411
|
|
|
412
|
-
export { status, isAvailable, embed, embedMany, cosine, search, saveIndex, loadIndex, loadIndexMeta, fingerprint, textHash, buildIndexIncremental, chunkText, reset, detectImplicitEmotions, IMPLICIT_EMOTION_PROTOTYPES, MODEL_DIR, cachePath };
|
|
412
|
+
export { status, isAvailable, embed, embedMany, cosine, search, saveIndex, loadIndex, loadIndexMeta, fingerprint, textHash, buildIndexIncremental, chunkText, reset, detectImplicitEmotions, IMPLICIT_EMOTION_PROTOTYPES, MODEL_DIR, cachePath, engine };
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-novel-writer — 小说写作助手插件(
|
|
2
|
+
* dsh-novel-writer — 小说写作助手插件(v3.9.0:render 全量可见化 / 锚包写作 / 对照修正)
|
|
3
3
|
* - novel_sentence_analysis 句式模式分析(句式分布/排列规律/段落结构/句长节奏/情感曲线/风格指纹)
|
|
4
4
|
* - novel_sentence_config 句式分析开关(Web GUI 侧边栏「句式分析」面板同步)
|
|
5
5
|
*
|
|
@@ -236,7 +236,13 @@ function bookDir(root, book) {
|
|
|
236
236
|
}
|
|
237
237
|
/** 读取一部作品的章节文件列表(按序号排序),返回带元信息的条目。 */
|
|
238
238
|
async function scanChapters(bookPath) {
|
|
239
|
-
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = await readdir(bookPath, { withFileTypes: true });
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// v3.9.0:缺失书目录不再抛裸 ENOENT——友好报错(各工具行为统一)
|
|
244
|
+
throw new Error("书库中未找到作品目录:" + bookPath + "(检查书名是否精确匹配——可用 novel_books 查看全部作品名;或确认 novels/ 下已创建该书目录)", { cause: e });
|
|
245
|
+
}
|
|
240
246
|
const chapters = [];
|
|
241
247
|
for (const entry of entries) {
|
|
242
248
|
if (!entry.isFile()) continue;
|
|
@@ -393,6 +399,90 @@ function tallyCjkRun(run, cjkChars, cjkBigrams, cjkTrigrams) {
|
|
|
393
399
|
const IMPORT_NOISE = new Set(["原稿件", "单章", "调教计划", "未命名", "新建文档", "草稿", "无题", "正文", "手稿"]);
|
|
394
400
|
|
|
395
401
|
/** 从文件名提取书名候选:去扩展名、章号标记、噪音词与分隔符。失败返回 void 0。 */
|
|
402
|
+
/** v3.9.0 修正:导入重名解决——解析目标章号 → 扫描目录全部已占用章号(含补零/中文数字/前缀/中间形式,异形同号算同一章)→ 取空闲章号 → 按原格式生成文件名;无法解析章号才退化为 -k 后缀。 */
|
|
403
|
+
function numberToCjk(n) {
|
|
404
|
+
const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
|
|
405
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 10000) return null;
|
|
406
|
+
if (n < 10) return digits[n];
|
|
407
|
+
if (n < 20) return "十" + (n % 10 === 0 ? "" : digits[n % 10]);
|
|
408
|
+
const tens = Math.floor(n / 10);
|
|
409
|
+
const rest = n % 10;
|
|
410
|
+
let s = (tens === 1 ? "一" : digits[tens]) + "十";
|
|
411
|
+
if (rest > 0) s += digits[rest];
|
|
412
|
+
return s;
|
|
413
|
+
}
|
|
414
|
+
function nextFreeChapterFile(destDir, fileName) {
|
|
415
|
+
const stem = fileName.replace(/\.[^.]+$/, "");
|
|
416
|
+
const ext = extname(fileName);
|
|
417
|
+
const baseNum = parseChapterNumber(stem);
|
|
418
|
+
// v3.9.0 复查修正:同名或同章号(异形)占用都触发改名
|
|
419
|
+
const numOccupied = function (num) {
|
|
420
|
+
try {
|
|
421
|
+
for (const ex of readdirSync(destDir)) {
|
|
422
|
+
if (parseChapterNumber(ex) === num) return true;
|
|
423
|
+
}
|
|
424
|
+
} catch { /* 目录不可读按同名处理 */ }
|
|
425
|
+
return false;
|
|
426
|
+
};
|
|
427
|
+
if (baseNum === void 0) {
|
|
428
|
+
if (!existsSync(join(destDir, fileName))) return fileName;
|
|
429
|
+
let k = 2;
|
|
430
|
+
while (existsSync(join(destDir, stem + "-" + k + ext))) k += 1;
|
|
431
|
+
return stem + "-" + k + ext;
|
|
432
|
+
}
|
|
433
|
+
if (!existsSync(join(destDir, fileName)) && !numOccupied(baseNum)) return fileName;
|
|
434
|
+
// 扫描目录内全部已占用章号(第01章/第1章/第一章/01-标题 都算同一章)
|
|
435
|
+
const used = new Set();
|
|
436
|
+
try {
|
|
437
|
+
for (const existing of readdirSync(destDir)) {
|
|
438
|
+
const n = parseChapterNumber(existing);
|
|
439
|
+
if (n !== void 0) used.add(n);
|
|
440
|
+
}
|
|
441
|
+
} catch { /* 目录不可读时按文件名探测 */ }
|
|
442
|
+
let next = baseNum + 1;
|
|
443
|
+
while (used.has(next)) next += 1;
|
|
444
|
+
// 按原文件名格式生成空闲章号(保持补零宽度)
|
|
445
|
+
const mArab = /^(.*第)(\d{1,4})([章回话].*)$/.exec(stem);
|
|
446
|
+
if (mArab) {
|
|
447
|
+
const width = mArab[2].length;
|
|
448
|
+
let bump = next;
|
|
449
|
+
let candidate = mArab[1] + String(bump).padStart(width, "0") + mArab[3] + ext;
|
|
450
|
+
while (existsSync(join(destDir, candidate))) { bump += 1; candidate = mArab[1] + String(bump).padStart(width, "0") + mArab[3] + ext; }
|
|
451
|
+
return candidate;
|
|
452
|
+
}
|
|
453
|
+
const mCjk = /^(.*第)([零一二三四五六七八九十百两]+)([章回话].*)$/.exec(stem);
|
|
454
|
+
if (mCjk) {
|
|
455
|
+
let bump = next;
|
|
456
|
+
let candidate = null;
|
|
457
|
+
while (!candidate || existsSync(join(destDir, candidate))) {
|
|
458
|
+
const cjk = numberToCjk(bump);
|
|
459
|
+
if (cjk === null) break;
|
|
460
|
+
candidate = mCjk[1] + cjk + mCjk[3] + ext;
|
|
461
|
+
bump += 1;
|
|
462
|
+
}
|
|
463
|
+
if (candidate) return candidate;
|
|
464
|
+
}
|
|
465
|
+
const mLead = /^(\d{1,4})([-._].*)$/.exec(stem);
|
|
466
|
+
if (mLead) {
|
|
467
|
+
const width = mLead[1].length;
|
|
468
|
+
let bump = next;
|
|
469
|
+
let candidate = String(bump).padStart(width, "0") + mLead[2] + ext;
|
|
470
|
+
while (existsSync(join(destDir, candidate))) { bump += 1; candidate = String(bump).padStart(width, "0") + mLead[2] + ext; }
|
|
471
|
+
return candidate;
|
|
472
|
+
}
|
|
473
|
+
const mMid = /^(.*?[-._\s])(\d{1,4})([-._\s].*)$/.exec(stem);
|
|
474
|
+
if (mMid) {
|
|
475
|
+
const width = mMid[2].length;
|
|
476
|
+
let bump = next;
|
|
477
|
+
let candidate = mMid[1] + String(bump).padStart(width, "0") + mMid[3] + ext;
|
|
478
|
+
while (existsSync(join(destDir, candidate))) { bump += 1; candidate = mMid[1] + String(bump).padStart(width, "0") + mMid[3] + ext; }
|
|
479
|
+
return candidate;
|
|
480
|
+
}
|
|
481
|
+
let k = 2;
|
|
482
|
+
while (existsSync(join(destDir, stem + "-" + k + ext))) k += 1;
|
|
483
|
+
return stem + "-" + k + ext;
|
|
484
|
+
}
|
|
485
|
+
|
|
396
486
|
function bookNameFromFileName(fileName) {
|
|
397
487
|
const stem = fileName.replace(/\.[^.]+$/, "");
|
|
398
488
|
// v3.6.0:文件名启发式只取章号前的书名前缀("斗破苍穹 第1章 陨落.md" → "斗破苍穹",不再把本章标题拼进书名)
|
|
@@ -457,9 +547,9 @@ async function collectTextFiles(dir, recursive, out = [], prefix = "") {
|
|
|
457
547
|
function formatImport(value) {
|
|
458
548
|
const lines = [`<path>${value.src}</path>`, `<type>novel-import-${value.mode}</type>`, "<content>", ""];
|
|
459
549
|
lines.push(`扫描结果:${value.groups.reduce((n, g) => n + g.files.length, 0)} 个文件 → ${value.groups.length} 组` +
|
|
460
|
-
(value.skipped.length > 0 ? `(跳过 ${value.skipped.length}
|
|
550
|
+
(value.skipped.length > 0 ? `(跳过 ${value.skipped.length} 个文件:无法读取或未分类)` : ""));
|
|
461
551
|
for (const group of value.groups) {
|
|
462
|
-
const from = group.from === "file" ? "文件名" : group.from === "content" ? "文件头内容" : "强制指定";
|
|
552
|
+
const from = group.from === "file" ? "文件名" : group.from === "content" ? "文件头内容" : group.from === "unclassified" ? "未分类" : "强制指定";
|
|
463
553
|
lines.push("");
|
|
464
554
|
lines.push(`[${group.book}] (来自${from}, ${group.files.length} 个文件)`);
|
|
465
555
|
for (const f of group.files) {
|
|
@@ -467,6 +557,10 @@ function formatImport(value) {
|
|
|
467
557
|
lines.push(` - ${ch} ${f.file}`);
|
|
468
558
|
}
|
|
469
559
|
}
|
|
560
|
+
if (value.skipped.length > 0) {
|
|
561
|
+
lines.push("", "跳过:");
|
|
562
|
+
for (const item of value.skipped) lines.push(` - ${item}`);
|
|
563
|
+
}
|
|
470
564
|
if (value.imported.length > 0) {
|
|
471
565
|
lines.push("");
|
|
472
566
|
lines.push(`已导入 ${value.imported.length} 个文件:`);
|
|
@@ -651,20 +745,32 @@ function registerNovelImport(ctx, config) {
|
|
|
651
745
|
const imported = [];
|
|
652
746
|
if (mode === "apply") {
|
|
653
747
|
for (const group of groups) {
|
|
654
|
-
if (group.book === "未分类")
|
|
748
|
+
if (group.book === "未分类") {
|
|
749
|
+
// v3.9.0:未分类文件不再静默丢弃——计入 skipped 并说明原因
|
|
750
|
+
for (const row of group.files) skipped.push(row.file + "(未分类,未导入——请用 book 参数指定归属书名后重试)");
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
655
753
|
const safeBook = sanitizeSegment(group.book, "book");
|
|
656
754
|
const destDir = join(novelsDir(root), safeBook);
|
|
657
755
|
await mkdir(destDir, { recursive: true });
|
|
658
756
|
for (const row of group.files) {
|
|
659
757
|
const srcFull = join(src, row.file);
|
|
660
|
-
// v3.5.0 #19:目标同名已存在 →
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
758
|
+
// v3.5.0 #19:目标同名已存在 → 自动改名,不静默覆盖、move 模式不丢源
|
|
759
|
+
// v3.9.0:统一按扩展名处理(.markdown/.docx 等不再丢失扩展名);章号冲突改用空闲章号
|
|
760
|
+
// v3.9.0 复查:异形同号也算占用(第一章 vs 第01章 文件名不同但章号相同 → 改名,不再产生重复章号)
|
|
761
|
+
const rawName = basename(row.file);
|
|
762
|
+
const rawNum = parseChapterNumber(rawName);
|
|
763
|
+
let destFile = rawName;
|
|
764
|
+
if (rawNum !== void 0) {
|
|
765
|
+
let occupied = false;
|
|
766
|
+
try {
|
|
767
|
+
for (const ex of readdirSync(destDir)) {
|
|
768
|
+
if (parseChapterNumber(ex) === rawNum) { occupied = true; break; }
|
|
769
|
+
}
|
|
770
|
+
} catch { /* 目录不可读按重名处理 */ }
|
|
771
|
+
if (occupied) destFile = nextFreeChapterFile(destDir, rawName);
|
|
772
|
+
} else if (existsSync(join(destDir, rawName))) {
|
|
773
|
+
destFile = nextFreeChapterFile(destDir, rawName);
|
|
668
774
|
}
|
|
669
775
|
const destFull = join(destDir, destFile);
|
|
670
776
|
await copyFile(srcFull, destFull);
|
|
@@ -673,6 +779,7 @@ function registerNovelImport(ctx, config) {
|
|
|
673
779
|
}
|
|
674
780
|
}
|
|
675
781
|
}
|
|
782
|
+
if (mode === "apply" && imported.length > 0) await noteRoot(root); // v3.9.0:apply 导入后记录书库根
|
|
676
783
|
return { src, mode, groups, skipped, imported };
|
|
677
784
|
}
|
|
678
785
|
});
|
|
@@ -828,6 +935,8 @@ async function enrichSemanticImplicit(state, root, book, result, exec) {
|
|
|
828
935
|
try {
|
|
829
936
|
if (!semanticFeatureEnabled(state, "semanticImplicit")) return result;
|
|
830
937
|
if (!(await embedding.isAvailable())) return result;
|
|
938
|
+
// v3.9.0 修正:缓存已带 semanticImplicit(miss 路径算好写盘的)→ 直接复用,不再重建索引全量读盘
|
|
939
|
+
if (result?.emotion?.quantification?.semanticImplicit) return result;
|
|
831
940
|
const dir = bookDir(root, book);
|
|
832
941
|
const chapters = await scanChapters(dir);
|
|
833
942
|
const chunks = [];
|
|
@@ -978,6 +1087,20 @@ async function atomicWriteJson(file, obj) {
|
|
|
978
1087
|
|
|
979
1088
|
// v3.5.0 #18:state 读-改-写串行队列(并发 POST 不再互相覆盖)
|
|
980
1089
|
let stateWriteChain = Promise.resolve();
|
|
1090
|
+
/** v3.9.0:风格基线容差钳制(low ∈ [-99,0]、high ∈ [0,99],非法键丢弃)——工具与 POST /state 共用 */
|
|
1091
|
+
function clampStyleTolerance(tol) {
|
|
1092
|
+
const cleaned = {};
|
|
1093
|
+
if (tol !== null && typeof tol === "object") {
|
|
1094
|
+
for (const [tk, tv] of Object.entries(tol)) {
|
|
1095
|
+
if (tv !== null && typeof tv === "object") {
|
|
1096
|
+
const low = typeof tv.low === "number" ? Math.max(-99, Math.min(0, tv.low)) : void 0;
|
|
1097
|
+
const high = typeof tv.high === "number" ? Math.max(0, Math.min(99, tv.high)) : void 0;
|
|
1098
|
+
if (low !== void 0 && high !== void 0 && low <= high) cleaned[tk] = { low, high };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return cleaned;
|
|
1103
|
+
}
|
|
981
1104
|
async function writeSentenceState(patch) {
|
|
982
1105
|
const run = stateWriteChain.then(async function () {
|
|
983
1106
|
const current = await readSentenceState();
|
|
@@ -988,7 +1111,7 @@ async function writeSentenceState(patch) {
|
|
|
988
1111
|
...(patch?.tools !== null && typeof patch?.tools === "object" ? { tools: { ...(current.tools ?? {}), ...patch.tools } } : {}),
|
|
989
1112
|
...(typeof patch?.lastRoot === "string" ? { lastRoot: patch.lastRoot } : {}),
|
|
990
1113
|
...(patch?.features !== null && typeof patch?.features === "object" ? { features: { ...(current.features ?? {}), ...patch.features } } : {}),
|
|
991
|
-
...(patch?.styleTolerance !== null && typeof patch?.styleTolerance === "object" ? { styleTolerance: patch.styleTolerance } : {}),
|
|
1114
|
+
...(patch?.styleTolerance !== null && typeof patch?.styleTolerance === "object" ? { styleTolerance: clampStyleTolerance(patch.styleTolerance) } : {}), // v3.9.0:写路径统一钳制
|
|
992
1115
|
...(patch?.styleTolerance === null ? { styleTolerance: null } : {}),
|
|
993
1116
|
...(patch?.creationProfile !== null && typeof patch?.creationProfile === "object" ? { creationProfile: patch.creationProfile } : {}),
|
|
994
1117
|
...(patch?.creationProfile === null ? { creationProfile: null } : {}),
|
|
@@ -1062,12 +1185,15 @@ function writeJson(res, status, body) {
|
|
|
1062
1185
|
}
|
|
1063
1186
|
|
|
1064
1187
|
/** 读取 JSON 请求体(上限 16KB)。 */
|
|
1188
|
+
// v3.9.0 修正:超限哨兵用 Symbol——普通 JSON 无法伪造({"tooLarge":true} 不再误判 413)
|
|
1189
|
+
const BODY_TOO_LARGE = Symbol("bodyTooLarge");
|
|
1065
1190
|
async function readJsonBody(req) {
|
|
1066
1191
|
const chunks = [];
|
|
1067
1192
|
let size = 0;
|
|
1193
|
+
const LIMIT = 1024 * 1024; // v3.9.0:16KB → 1MB(原创模式长设定被 400 拒,UI 静默降级 localStorage)
|
|
1068
1194
|
for await (const chunk of req) {
|
|
1069
1195
|
size += chunk.length;
|
|
1070
|
-
if (size >
|
|
1196
|
+
if (size > LIMIT) return { [BODY_TOO_LARGE]: true };
|
|
1071
1197
|
chunks.push(chunk);
|
|
1072
1198
|
}
|
|
1073
1199
|
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return void 0; }
|
|
@@ -1163,6 +1289,7 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1163
1289
|
}
|
|
1164
1290
|
if (method === "POST") {
|
|
1165
1291
|
const body = await readJsonBody(req);
|
|
1292
|
+
if (body && body[BODY_TOO_LARGE]) return writeJson(res, 413, { error: "request body too large (limit 1MB)" });
|
|
1166
1293
|
if (body === void 0 || body === null || typeof body !== "object" || Array.isArray(body)) return writeJson(res, 400, { error: "invalid JSON body" });
|
|
1167
1294
|
try {
|
|
1168
1295
|
// v3.7.0 高3:路由层剥离 novel_sentence_config(与工具侧一致)——UI 一键全关不能锁死 AI 通道(关了 AI 就无法再开)
|
|
@@ -1206,6 +1333,12 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1206
1333
|
return writeJson(res, 500, { ok: false, error: String(e).slice(0, 200) });
|
|
1207
1334
|
}
|
|
1208
1335
|
};
|
|
1336
|
+
// v3.9.0:UI 路由统一根回退(与 GET /state 一致:config.root → lastRoot → cwd)
|
|
1337
|
+
const resolveUiRoot = (cfg, st) => {
|
|
1338
|
+
const c = typeof cfg?.root === "string" && cfg.root.length > 0 ? cfg.root : "";
|
|
1339
|
+
const l = !c && typeof st?.lastRoot === "string" && st.lastRoot.length > 0 ? st.lastRoot : "";
|
|
1340
|
+
return c || l || process.cwd();
|
|
1341
|
+
};
|
|
1209
1342
|
const revealHandler = async (req, res) => {
|
|
1210
1343
|
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1211
1344
|
if ((req.method ?? "GET") !== "POST") return writeJson(res, 405, { error: "method not allowed" });
|
|
@@ -1214,8 +1347,8 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1214
1347
|
const allowedTargets = ["data-dir", "plots-dir", "settings-dir", "summaries-dir", "analysis-dir", "audits-dir", "embedding-dir", "state-file"];
|
|
1215
1348
|
if (!allowedTargets.includes(target)) return writeJson(res, 400, { error: 'unknown target (allowed: ' + allowedTargets.join(", ") + ')' });
|
|
1216
1349
|
const state = await readSentenceState();
|
|
1217
|
-
//
|
|
1218
|
-
const root = (
|
|
1350
|
+
// v3.9.0:与 GET /state 同一回退(config.root → lastRoot → cwd),全新环境不再 404
|
|
1351
|
+
const root = resolveUiRoot(config, state);
|
|
1219
1352
|
if (typeof root !== "string" || root.length === 0) {
|
|
1220
1353
|
return writeJson(res, 404, { error: "书库根未知:先在对话中调用一次 novel_plot / novel_settings(或配置插件 root),即可在 UI 中打开数据文件夹。" });
|
|
1221
1354
|
}
|
|
@@ -1246,7 +1379,7 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1246
1379
|
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1247
1380
|
try {
|
|
1248
1381
|
const state = await readSentenceState();
|
|
1249
|
-
const root = (
|
|
1382
|
+
const root = resolveUiRoot(config, state);
|
|
1250
1383
|
const dataDir = typeof root === "string" && root.length > 0 ? novelDataDir(root) : "";
|
|
1251
1384
|
const qs = String(req.url || "").split("?")[1] || "";
|
|
1252
1385
|
const params = new URLSearchParams(qs);
|
|
@@ -1359,6 +1492,50 @@ ${value.message ?? ""}
|
|
|
1359
1492
|
lines.push(`- 情绪分布: ${emoWords || "无明显情绪词"}`);
|
|
1360
1493
|
const curve = value.emotion.curve.map((point) => `${point.segment}:${EMOTION_CODE[point.dominant] ?? point.dominant}${point.intensity}`).join(" ");
|
|
1361
1494
|
lines.push(`- 情感曲线: ${curve || "-"}`);
|
|
1495
|
+
// v3.9.0:render 补全——情感净化预警/量化三指标/细节密度/语义隐性情感(此前只在 value,模型看不到)
|
|
1496
|
+
if (value.emotion && value.emotion.cleanDominant && value.emotion.cleanDominant !== value.emotion.dominant) {
|
|
1497
|
+
lines.push("", "【情感净化预警】");
|
|
1498
|
+
lines.push(`- 规则主导: ${EMOTION_CODE[value.emotion.dominant] ?? value.emotion.dominant},净化后主导: ${EMOTION_CODE[value.emotion.cleanDominant] ?? value.emotion.cleanDominant}(置信度 ${value.emotion.confidence ?? "?"})`);
|
|
1499
|
+
if (value.emotion.caveat) lines.push(`- 注意: ${value.emotion.caveat}`);
|
|
1500
|
+
if (value.emotion.aiAction) lines.push(`- 建议: ${value.emotion.aiAction}`);
|
|
1501
|
+
if (value.emotion.pollution && Object.keys(value.emotion.pollution).length > 0) {
|
|
1502
|
+
lines.push(`- 污染词: ${Object.entries(value.emotion.pollution).map(([k, v]) => k + "=" + JSON.stringify(v)).join("; ")}`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (value.emotion && value.emotion.quantification) {
|
|
1506
|
+
const q = value.emotion.quantification;
|
|
1507
|
+
const stats = q.stats || {};
|
|
1508
|
+
if (Object.keys(stats).length > 0) {
|
|
1509
|
+
lines.push("", "【情感量化】");
|
|
1510
|
+
lines.push(`- Valence: 均值 ${stats.meanValence ?? 0}, 方差V ${stats.variance ?? 0}, 相邻撕裂 ${stats.adjVariance ?? 0}, 斜率Δ ${stats.delta ?? 0}, 矛盾指数C ${stats.conflict ?? 0}`);
|
|
1511
|
+
if (q.compare) lines.push(`- 显隐对比: ${JSON.stringify(q.compare).slice(0, 120)}`);
|
|
1512
|
+
if (q.complexity) lines.push(`- 复杂度: ${JSON.stringify(q.complexity).slice(0, 120)}`);
|
|
1513
|
+
if (q.implicit && (q.implicit.ambiguous || []).length > 0) {
|
|
1514
|
+
// v3.9.0 修正:implicit 无 hits 字段(不再输出误导性的「隐性情感句 0 处」)
|
|
1515
|
+
lines.push(`- 隐性意象: 变色龙词 ${(q.implicit.ambiguous || []).length} 个(语义层裁决见下文/quantification.implicit)`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (q.semanticImplicit && (q.semanticImplicit.hits || []).length > 0) {
|
|
1519
|
+
lines.push("", "【语义隐性情感】");
|
|
1520
|
+
const si = q.semanticImplicit;
|
|
1521
|
+
const sample = si.hits.slice(0, 3).map((h) => "[" + (h.top || "?") + "] " + h.text.slice(0, 30)).join(" / "); // v3.9.0 修正:字段是 top(情感标签)
|
|
1522
|
+
lines.push(`- 命中 ${si.hits.length} 处(情感原型语义检索): ${sample}`);
|
|
1523
|
+
if (si.distribution && Object.keys(si.distribution).length > 0) lines.push(`- 分布: ${JSON.stringify(si.distribution).slice(0, 120)}`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (value.density) {
|
|
1527
|
+
const d = value.density;
|
|
1528
|
+
const parts = [];
|
|
1529
|
+
if (typeof d.actionVerbsPer1000 === "number") parts.push(`动作动词 ${d.actionVerbsPer1000}/千字`);
|
|
1530
|
+
if (typeof d.actionChainRatio === "number") parts.push(`动作链占比 ${d.actionChainRatio.toFixed(1)}%`); // v3.9.0 修正:已是百分数(0~100),不再 ×100
|
|
1531
|
+
if (typeof d.objectNounsPer1000 === "number") parts.push(`物体名词 ${d.objectNounsPer1000}/千字`);
|
|
1532
|
+
if (typeof d.sensePer1000 === "number") parts.push(`感官词 ${d.sensePer1000}/千字`);
|
|
1533
|
+
if (parts.length > 0) {
|
|
1534
|
+
lines.push("", "【细节密度】");
|
|
1535
|
+
lines.push("- " + parts.join(" | "));
|
|
1536
|
+
if (Array.isArray(d.actionChainExamples) && d.actionChainExamples.length > 0) lines.push(`- 动作链例: ${d.actionChainExamples.slice(0, 3).join(" / ")}`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1362
1539
|
lines.push("", "【节奏建议】");
|
|
1363
1540
|
if (value.guidance) {
|
|
1364
1541
|
for (const line of value.guidance.split("\n")) lines.push(`- ${line}`);
|
|
@@ -1571,13 +1748,9 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1571
1748
|
selected = chapters;
|
|
1572
1749
|
scope = `全书 ${chapters.length} 章`;
|
|
1573
1750
|
}
|
|
1574
|
-
|
|
1575
|
-
const chapterTexts = [];
|
|
1751
|
+
// v3.9.0 性能:先 stat 组缓存键——命中缓存时不再全量读章节文本(大书省整次读盘)
|
|
1576
1752
|
const scopeKeyParts = [];
|
|
1577
1753
|
for (const chapter of selected) {
|
|
1578
|
-
const chapterText = await readTextFile(join(dir, chapter.file), exec);
|
|
1579
|
-
text += chapterText;
|
|
1580
|
-
if (chapterArg === void 0) chapterTexts.push({ chapter: chapter.file, text: chapterText });
|
|
1581
1754
|
try {
|
|
1582
1755
|
const info = await stat(join(dir, chapter.file));
|
|
1583
1756
|
scopeKeyParts.push(chapter.file + ":" + info.mtimeMs + ":" + info.size);
|
|
@@ -1590,7 +1763,7 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1590
1763
|
book,
|
|
1591
1764
|
scope,
|
|
1592
1765
|
enabled: false,
|
|
1593
|
-
totalChars
|
|
1766
|
+
// v3.9.0 修正:不输出 totalChars(stat.size 是字节数,会误导为「N 字」;output 顶层无必填约束)
|
|
1594
1767
|
totalSentences: 0,
|
|
1595
1768
|
message: "句式模式分析当前已关闭。可在 Web GUI 侧边栏「句式分析」面板开启,或用 novel_sentence_config 设置 enabled=true。"
|
|
1596
1769
|
};
|
|
@@ -1599,27 +1772,40 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1599
1772
|
const cacheDir = join(novelDataDir(root), "analysis");
|
|
1600
1773
|
const cacheKey = createHash("sha1").update(book + "|" + scope + "|" + scopeKeyParts.join(",")).digest("hex").slice(0, 20);
|
|
1601
1774
|
const reportFile = join(cacheDir, book + "-" + cacheKey + ".json");
|
|
1775
|
+
let cachedHit = null;
|
|
1602
1776
|
if (!fresh) {
|
|
1603
1777
|
try {
|
|
1604
1778
|
const cached = JSON.parse(await readFile(reportFile, "utf8"));
|
|
1605
1779
|
// v3.5.0 #22:缓存带算法版本校验——升级后旧的情感/句式缓存不再命中(防双计旧结果)
|
|
1606
1780
|
if (cached && cached.ver !== CACHE_VERSION) throw new Error("cache stale");
|
|
1607
|
-
|
|
1608
|
-
cached.emotion = cropEmotion(cached.emotion);
|
|
1609
|
-
}
|
|
1610
|
-
if (!featureEnabled(state, "emotionComplexity") && cached.emotion?.quantification) {
|
|
1611
|
-
delete cached.emotion.quantification;
|
|
1612
|
-
}
|
|
1613
|
-
await enrichSemanticImplicit(state, root, book, cached, exec);
|
|
1614
|
-
if (args?.brief === true && cached.categories && cached.lengths && cached.emotion) {
|
|
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) + "%"; });
|
|
1616
|
-
cached.brief = scope + " " + cached.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cached.lengths.avg.toFixed(1) + ";主导情绪 " + (cached.emotion.dominant || "-") + "。";
|
|
1617
|
-
}
|
|
1618
|
-
// v3.5.0 H2:缓存命中剥离内部 ver 键(契约:output schema 无 ver)
|
|
1619
|
-
const { ver: _ver, ...rest } = cached;
|
|
1620
|
-
return { ...rest, book, scope, cache: "hit", reportFile, cachedAt: cached.cachedAt ?? new Date().toISOString() };
|
|
1781
|
+
cachedHit = cached;
|
|
1621
1782
|
} catch { /* 缓存缺失/损坏,重新分析 */ }
|
|
1622
1783
|
}
|
|
1784
|
+
if (cachedHit) {
|
|
1785
|
+
// v3.9.0 复查修正:先 enrich(缓存自带 semanticImplicit → 早退零读盘)再裁剪——emotionComplexity 关闭时也不会触发全量重读
|
|
1786
|
+
await enrichSemanticImplicit(state, root, book, cachedHit, exec);
|
|
1787
|
+
if (!featureEnabled(state, "emotionCaveat") && cachedHit.emotion) {
|
|
1788
|
+
cachedHit.emotion = cropEmotion(cachedHit.emotion);
|
|
1789
|
+
}
|
|
1790
|
+
if (!featureEnabled(state, "emotionComplexity") && cachedHit.emotion?.quantification) {
|
|
1791
|
+
delete cachedHit.emotion.quantification;
|
|
1792
|
+
}
|
|
1793
|
+
if (args?.brief === true && cachedHit.categories && cachedHit.lengths && cachedHit.emotion) {
|
|
1794
|
+
const top3b = cachedHit.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) + "%"; });
|
|
1795
|
+
cachedHit.brief = scope + " " + cachedHit.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cachedHit.lengths.avg.toFixed(1) + ";主导情绪 " + (cachedHit.emotion.dominant || "-") + "。";
|
|
1796
|
+
}
|
|
1797
|
+
// v3.5.0 H2:缓存命中剥离内部 ver 键(契约:output schema 无 ver)
|
|
1798
|
+
const { ver: _ver, ...rest } = cachedHit;
|
|
1799
|
+
return { ...rest, book, scope, cache: "hit", reportFile, cachedAt: cachedHit.cachedAt ?? new Date().toISOString() };
|
|
1800
|
+
}
|
|
1801
|
+
// 缓存未命中(或 fresh)→ 此时才读章节文本
|
|
1802
|
+
let text = "";
|
|
1803
|
+
const chapterTexts = [];
|
|
1804
|
+
for (const chapter of selected) {
|
|
1805
|
+
const chapterText = await readTextFile(join(dir, chapter.file), exec);
|
|
1806
|
+
text += chapterText;
|
|
1807
|
+
if (chapterArg === void 0) chapterTexts.push({ chapter: chapter.file, text: chapterText });
|
|
1808
|
+
}
|
|
1623
1809
|
const options = { top, maxSentences, curveSegments };
|
|
1624
1810
|
if (chapterTexts.length > 0) options.chapterTexts = chapterTexts;
|
|
1625
1811
|
const result = {
|
|
@@ -1637,6 +1823,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1637
1823
|
if (resolved) result.emotion.quantification.implicit = resolved;
|
|
1638
1824
|
}
|
|
1639
1825
|
} catch { /* 语义裁决失败不阻塞 */ }
|
|
1826
|
+
// v3.9.0 复查修正:先 enrich 后写缓存——缓存文件必须含 semanticImplicit,命中路径才能早退复用(否则每次命中仍全量读盘重建索引)
|
|
1827
|
+
await enrichSemanticImplicit(state, root, book, result, exec);
|
|
1640
1828
|
try {
|
|
1641
1829
|
await mkdir(cacheDir, { recursive: true });
|
|
1642
1830
|
await atomicWriteJson(reportFile, { ...result, cachedAt: new Date().toISOString(), ver: CACHE_VERSION });
|
|
@@ -1647,7 +1835,6 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1647
1835
|
if (!featureEnabled(state, "emotionComplexity") && result.emotion?.quantification) {
|
|
1648
1836
|
delete result.emotion.quantification;
|
|
1649
1837
|
}
|
|
1650
|
-
await enrichSemanticImplicit(state, root, book, result, exec);
|
|
1651
1838
|
if (args?.brief === true && result.categories && result.lengths && result.emotion) {
|
|
1652
1839
|
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) + "%"; });
|
|
1653
1840
|
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 ? "留白较多,注意节奏" : "");
|
|
@@ -1668,7 +1855,7 @@ function registerNovelSentenceConfig(ctx, config) {
|
|
|
1668
1855
|
enabled: { type: "boolean", description: "写作助手功能总开关。" },
|
|
1669
1856
|
autoAnalyze: { type: "boolean", description: "分析作品时是否主动使用句式分析。" },
|
|
1670
1857
|
tools: { type: "object", additionalProperties: true, description: "各工具开关(如 { novel_plot: false }),键必须是 novel_* 工具名。" },
|
|
1671
|
-
features: { type: "object", additionalProperties: true, description: "功能开关(emotionCaveat=情感净化预警 / genreTheme
|
|
1858
|
+
features: { type: "object", additionalProperties: true, description: "功能开关(emotionCaveat=情感净化预警 / genreTheme=题材与流派检测 / webnovelVibe=网文信号 / rawWriting=非净化直白模式 / semanticEmbedding=本地语义增强),如 { emotionCaveat: false }。注意:rawWriting=true 会跳过 UI 的双重确认+承诺输入流程,仅应在用户明确要求时开启。" },
|
|
1672
1859
|
styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(每维 { low: -20, high: 20 },low 为负/高为正;空对象 {} 清除恢复推荐——宿主不支持 null 类型,清除一律用空对象)。" },
|
|
1673
1860
|
creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(worldview/characters/forbidden/mainConflict/genre/extra 字符串键,留空项省略;空对象 {} 清除全部交给模型——宿主不支持 null 类型)。" },
|
|
1674
1861
|
creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=同上结构;空对象 {} 清除全部书的专属设定——宿主不支持 null 类型)。" }
|
|
@@ -1748,7 +1935,9 @@ ${featLines.join("\n")}
|
|
|
1748
1935
|
if (args.styleTolerance === null) patch.styleTolerance = null;
|
|
1749
1936
|
// v3.1.0:空对象 {} = 清除(DSH schema 不支持 type 数组/剥离 null,空 object 是合规的“传了但清空”)
|
|
1750
1937
|
else if (args.styleTolerance !== null && typeof args.styleTolerance === "object" && Object.keys(args.styleTolerance).length === 0) patch.styleTolerance = null;
|
|
1751
|
-
else if (args.styleTolerance !== null && typeof args.styleTolerance === "object")
|
|
1938
|
+
else if (args.styleTolerance !== null && typeof args.styleTolerance === "object") {
|
|
1939
|
+
patch.styleTolerance = clampStyleTolerance(args.styleTolerance); // v3.9.0:与 POST /state 共用同一钳制
|
|
1940
|
+
}
|
|
1752
1941
|
}
|
|
1753
1942
|
// v3.1.0:原创模式设定(世界观/角色/禁忌/主线/题材/额外要求;null/空对象清除)
|
|
1754
1943
|
if (args?.creationProfile !== void 0) {
|
|
@@ -1841,7 +2030,9 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1841
2030
|
advice: { type: "string" },
|
|
1842
2031
|
semantic: { type: "object", additionalProperties: true },
|
|
1843
2032
|
// v3.0.0:文笔六维基线对照(enabled=false 时仅 note)
|
|
1844
|
-
metric: { type: "object", additionalProperties: true }
|
|
2033
|
+
metric: { type: "object", additionalProperties: true },
|
|
2034
|
+
// v3.8.0:跑偏时附原著锚段(对照修正用)
|
|
2035
|
+
fixAnchors: { type: "array", items: { type: "object", additionalProperties: false, properties: { label: { type: "string" }, text: { type: "string" } }, required: ["label", "text"] } }
|
|
1845
2036
|
},
|
|
1846
2037
|
required: ["book", "chapter", "baselineScope", "similarity", "verdict", "diffs", "chapterFingerprint", "baselineFingerprint", "advice", "semantic"]
|
|
1847
2038
|
},
|
|
@@ -1857,6 +2048,11 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1857
2048
|
lines.push("偏差清单:");
|
|
1858
2049
|
for (const d of value.diffs) lines.push(` - ${d.dimension}: ${d.note} (${d.diff > 0 ? "+" : ""}${d.diff})`);
|
|
1859
2050
|
}
|
|
2051
|
+
if (value.semantic && value.semantic.note) lines.push(`语义对比: ${value.semantic.note}` + (typeof value.semantic.similarity === "number" ? `(${(value.semantic.similarity * 100).toFixed(1)}%)` : ""));
|
|
2052
|
+
if (Array.isArray(value.fixAnchors) && value.fixAnchors.length > 0) {
|
|
2053
|
+
lines.push("", "【对照修正·原著锚段】逐句对比本章对应写法,只修跑偏部分,不得整章重写:");
|
|
2054
|
+
for (const a of value.fixAnchors) lines.push(" [" + a.label + "] " + a.text);
|
|
2055
|
+
}
|
|
1860
2056
|
lines.push(`本章指纹: ${value.chapterFingerprint}`);
|
|
1861
2057
|
lines.push(`基线指纹: ${value.baselineFingerprint}`);
|
|
1862
2058
|
// v3.0.0:文笔六维基线对照
|
|
@@ -1903,6 +2099,12 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1903
2099
|
const baseline = (await bookAnalysisCached(root, book + "__base_" + target.file, baselineText, null)).analysis;
|
|
1904
2100
|
const chapter = analyzeText(targetText, {});
|
|
1905
2101
|
const similarity = fingerprintSimilarity(baseline, chapter);
|
|
2102
|
+
// v3.8.0:偏差时附原著锚段(对照修正用,不整章重写)
|
|
2103
|
+
let fixAnchors = [];
|
|
2104
|
+
try {
|
|
2105
|
+
const pkgA = await buildStyleAnchorPackage(root, book, others, exec, baselineChapters); // 复用已读基线章文本
|
|
2106
|
+
fixAnchors = pkgA.anchors.slice(0, 2);
|
|
2107
|
+
} catch { /* 锚段失败不影响判定 */ }
|
|
1906
2108
|
const diffs = styleDiffs(baseline, chapter);
|
|
1907
2109
|
const verdict = similarity >= 0.9 ? "high" : similarity >= 0.75 ? "medium" : "low";
|
|
1908
2110
|
let advice = "";
|
|
@@ -1911,7 +2113,8 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1911
2113
|
} else {
|
|
1912
2114
|
// v3.7.0 ⑦:方向全映射(原只认"偏高","偏长/偏低/偏短"一律输出"略少")
|
|
1913
2115
|
const noteMap = { "偏高": "略多", "偏低": "略少", "偏长": "略长", "偏短": "略短" };
|
|
1914
|
-
|
|
2116
|
+
// v3.8.0:跑偏不整章重写——对照 fixAnchors(原著锚段)逐句修正跑偏维度
|
|
2117
|
+
advice = "续写风格偏差:" + diffs.slice(0, 4).map((d) => d.dimension + (noteMap[d.note] || d.note)).join("、") + "。请对照 fixAnchors 中的原著锚段,逐句对比本章对应写法,只修改跑偏的部分(句式/修饰/节奏),不要整章重写;若为情节需要(如章节情绪转折),说明原因后可接受,但不要持续漂移。";
|
|
1915
2118
|
}
|
|
1916
2119
|
// v2.0.0:语义级风格对比(本地 embedding,可选增强;开关开且模型可用时生效)
|
|
1917
2120
|
let semantic = null;
|
|
@@ -1996,6 +2199,7 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1996
2199
|
metric,
|
|
1997
2200
|
chapterFingerprint: chapter.fingerprint,
|
|
1998
2201
|
baselineFingerprint: baseline.fingerprint,
|
|
2202
|
+
fixAnchors,
|
|
1999
2203
|
advice
|
|
2000
2204
|
});
|
|
2001
2205
|
}
|
|
@@ -2116,35 +2320,57 @@ const meaningful = c.split("\n").map(function (l) { return l.trim(); }).filter(B
|
|
|
2116
2320
|
/** v3.1.0:列出书库中的作品名(novels 下目录,跳过创作资料;原创模式书选择器用)。 */
|
|
2117
2321
|
/** v3.2.0:书库统计(每本:章数/总字数/近 7 天活跃字数,按活跃度排序)——写作打卡卡。 */
|
|
2118
2322
|
// v3.5.0 #28:书库统计增量缓存——novels 目录与各书目录 mtime 未变则复用(大书库不卡页面)
|
|
2323
|
+
// v3.9.0:bookStats 指纹缓存(30s TTL)——指纹用文件 stat(不读内容),命中跳过全量读文本
|
|
2324
|
+
const bookStatsCache = { key: null, at: 0, data: null };
|
|
2119
2325
|
async function bookStats(root) {
|
|
2120
2326
|
const dir = join(root, "novels");
|
|
2121
|
-
// v3.5.0 R2c:去掉增量缓存——统计本身要读全部章节文本(chars/recent7),缓存键只能靠 mtime/size(Windows 精度不够,同长改文不刷新);实算最可靠且仅几百毫秒
|
|
2122
2327
|
try {
|
|
2123
2328
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2124
|
-
|
|
2125
|
-
|
|
2329
|
+
// 指纹:书目录名 + 章节文件名/size/mtime(不读内容)
|
|
2330
|
+
let fp = "";
|
|
2331
|
+
const bookFiles = [];
|
|
2126
2332
|
for (const entry of entries) {
|
|
2127
2333
|
if (!entry.isDirectory() || entry.name === "创作资料" || entry.name.startsWith(".")) continue;
|
|
2128
2334
|
const bookPath = join(dir, entry.name);
|
|
2129
|
-
let chapters = 0, chars = 0, recent7 = 0;
|
|
2130
2335
|
try {
|
|
2131
|
-
// v3.5.0 #29:只把章节文本文件(.md/.txt)计入章数与字数——图片/备份等非文本不再混入
|
|
2132
2336
|
const files = await readdir(bookPath, { withFileTypes: true });
|
|
2337
|
+
const list = [];
|
|
2133
2338
|
for (const f of files) {
|
|
2134
|
-
if (!f.isFile()) continue;
|
|
2135
|
-
if (!/\.(md|markdown|txt)$/i.test(f.name)) continue; // v3.7.0:补 .markdown(与 novel_books 同口径)
|
|
2339
|
+
if (!f.isFile() || !/\.(md|markdown|txt)$/i.test(f.name)) continue;
|
|
2136
2340
|
const st = await stat(join(bookPath, f.name));
|
|
2341
|
+
fp += entry.name + "/" + f.name + ":" + st.mtimeMs + ":" + st.size + ";";
|
|
2342
|
+
list.push(f.name);
|
|
2343
|
+
}
|
|
2344
|
+
bookFiles.push({ name: entry.name, files: list });
|
|
2345
|
+
} catch { /* 跳过不可读 */ }
|
|
2346
|
+
}
|
|
2347
|
+
// v3.9.0 修正:缓存键含书库根——多 profile/切换工作区不串数据
|
|
2348
|
+
const cacheKey = root + "|" + fp;
|
|
2349
|
+
if (bookStatsCache.key === cacheKey && Date.now() - bookStatsCache.at < 30000) return bookStatsCache.data;
|
|
2350
|
+
const out = [];
|
|
2351
|
+
const weekAgo = Date.now() - 7 * 86400000;
|
|
2352
|
+
for (const book of bookFiles) {
|
|
2353
|
+
const bookPath = join(dir, book.name);
|
|
2354
|
+
let chapters = 0, chars = 0, recent7 = 0;
|
|
2355
|
+
try {
|
|
2356
|
+
for (const fname of book.files) {
|
|
2357
|
+
const full = join(bookPath, fname);
|
|
2358
|
+
const st = await stat(full);
|
|
2137
2359
|
chapters += 1;
|
|
2138
2360
|
// v3.5.0 M11:与其余工具同口径解码(GBK/UTF-16 章节字数不再按 UTF-8 误读)
|
|
2139
|
-
const buf11 = await readFile(
|
|
2140
|
-
const txt = decodeTextBuffer(buf11,
|
|
2361
|
+
const buf11 = await readFile(full);
|
|
2362
|
+
const txt = decodeTextBuffer(buf11, full);
|
|
2141
2363
|
chars += txt.length;
|
|
2142
2364
|
if (st.mtimeMs >= weekAgo) recent7 += txt.length;
|
|
2143
2365
|
}
|
|
2144
2366
|
} catch { /* 跳过不可读 */ }
|
|
2145
|
-
out.push({ name:
|
|
2367
|
+
out.push({ name: book.name, chapters, chars, recent7Chars: recent7 });
|
|
2146
2368
|
}
|
|
2147
|
-
|
|
2369
|
+
const result = out.sort(function (a, b) { return b.recent7Chars - a.recent7Chars; });
|
|
2370
|
+
bookStatsCache.key = root + "|" + fp;
|
|
2371
|
+
bookStatsCache.at = Date.now();
|
|
2372
|
+
bookStatsCache.data = result;
|
|
2373
|
+
return result;
|
|
2148
2374
|
} catch {
|
|
2149
2375
|
return [];
|
|
2150
2376
|
}
|
|
@@ -2308,6 +2534,7 @@ function createOutlineTool(config) {
|
|
|
2308
2534
|
}
|
|
2309
2535
|
}
|
|
2310
2536
|
}
|
|
2537
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2311
2538
|
return { book, action, message: "创作资料已初始化(" + files.length + " 个文件)" };
|
|
2312
2539
|
}
|
|
2313
2540
|
|
|
@@ -2337,6 +2564,7 @@ function createOutlineTool(config) {
|
|
|
2337
2564
|
const content = requiredString(args, "content");
|
|
2338
2565
|
await mkdir(dir, { recursive: true });
|
|
2339
2566
|
await writeFile(join(dir, CREATION_FILES.bible), content, "utf8");
|
|
2567
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2340
2568
|
return { book, action, message: "创作设定已更新(" + content.length + " 字)" };
|
|
2341
2569
|
}
|
|
2342
2570
|
|
|
@@ -2354,6 +2582,7 @@ function createOutlineTool(config) {
|
|
|
2354
2582
|
if (new RegExp("^[-*] " + String(name).replace(/[.*+?^${}()|[\]\\]/g, function (ch) { return "\\" + ch; }) + "(:|$)", "m").test(cur2)) return { book, action, message: "人物\u201c" + name + "\u201d已存在(未重复登记)" };
|
|
2355
2583
|
await mkdir(dir, { recursive: true });
|
|
2356
2584
|
await writeFile(f, cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line, "utf8");
|
|
2585
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2357
2586
|
return { book, action, message: "已登记人物“" + name + "”到" + (role === "main" ? "主要" : "次要") + "人物设定" };
|
|
2358
2587
|
}
|
|
2359
2588
|
|
|
@@ -2371,6 +2600,7 @@ function createOutlineTool(config) {
|
|
|
2371
2600
|
}
|
|
2372
2601
|
await mkdir(dir, { recursive: true });
|
|
2373
2602
|
await writeFile(f, cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line, "utf8");
|
|
2603
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2374
2604
|
return { book, action, message: "大纲已补:第 " + number + " 章 " + title };
|
|
2375
2605
|
}
|
|
2376
2606
|
|
|
@@ -2390,6 +2620,7 @@ function createOutlineTool(config) {
|
|
|
2390
2620
|
const next = re.test(cur2) ? cur2.replace(re, line) : (cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line);
|
|
2391
2621
|
await mkdir(dir, { recursive: true });
|
|
2392
2622
|
await writeFile(f, next, "utf8");
|
|
2623
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2393
2624
|
return { book, action, message: "第 " + number + " 章钩子已回填" };
|
|
2394
2625
|
}
|
|
2395
2626
|
|
|
@@ -2397,6 +2628,7 @@ function createOutlineTool(config) {
|
|
|
2397
2628
|
const content = requiredString(args, "content");
|
|
2398
2629
|
await mkdir(dir, { recursive: true });
|
|
2399
2630
|
await writeFile(join(dir, CREATION_FILES.status), content, "utf8");
|
|
2631
|
+
await noteRoot(root); // v3.9.0:outline 写操作记录书库根
|
|
2400
2632
|
return { book, action, message: "创作状态卡已刷新" };
|
|
2401
2633
|
}
|
|
2402
2634
|
|
|
@@ -2553,10 +2785,16 @@ async function readSettings(root, book) {
|
|
|
2553
2785
|
return { characters: [], locations: [], items: [], timeline: [], worldview: [] };
|
|
2554
2786
|
}
|
|
2555
2787
|
}
|
|
2788
|
+
/** v3.9.0:书库写操作后记录 lastRoot(UI「打开文件夹」依赖;吞错不阻塞主功能) */
|
|
2789
|
+
async function noteRoot(root) {
|
|
2790
|
+
if (!root) return;
|
|
2791
|
+
try { await writeSentenceState({ lastRoot: root }); } catch { /* 记录失败不影响主功能 */ }
|
|
2792
|
+
}
|
|
2556
2793
|
async function writeSettings(root, book, data) {
|
|
2557
2794
|
const file = settingsFile(root, book);
|
|
2558
2795
|
await mkdir(dirname(file), { recursive: true });
|
|
2559
2796
|
await atomicWriteJson(file, { book, ...data });
|
|
2797
|
+
await noteRoot(root); // v3.9.0:settings 写操作统一记录书库根
|
|
2560
2798
|
}
|
|
2561
2799
|
function normalizeSettingEntry(entry) {
|
|
2562
2800
|
const out = { name: String(entry.name) };
|
|
@@ -2624,7 +2862,18 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2624
2862
|
},
|
|
2625
2863
|
required: ["book", "report"]
|
|
2626
2864
|
},
|
|
2627
|
-
render: (_args, value) =>
|
|
2865
|
+
render: (_args, value) => {
|
|
2866
|
+
const lines = [value.report || ""];
|
|
2867
|
+
if (Array.isArray(value.anchors) && value.anchors.length > 0) {
|
|
2868
|
+
lines.push("", "【风格锚·原著段落(续写照样例写,数字只做事后校验)】");
|
|
2869
|
+
for (const a of value.anchors) lines.push(" [" + a.label + "] " + a.text);
|
|
2870
|
+
}
|
|
2871
|
+
if (Array.isArray(value.skeletons) && value.skeletons.length > 0) {
|
|
2872
|
+
lines.push("", "【句式骨架(按此形状造句)】");
|
|
2873
|
+
for (const sk of value.skeletons) lines.push(" [" + sk.type + "] " + sk.text);
|
|
2874
|
+
}
|
|
2875
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
2876
|
+
}
|
|
2628
2877
|
},
|
|
2629
2878
|
async execute(args, exec) {
|
|
2630
2879
|
await assertToolEnabled(config, "novel_style_report");
|
|
@@ -2648,6 +2897,8 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2648
2897
|
}
|
|
2649
2898
|
}
|
|
2650
2899
|
const { text, chapters } = await readBookAllText(root, book, exec);
|
|
2900
|
+
// v3.8.0:风格锚包(原著代表性段落 + 句式骨架——生成端照样例写,勿只依赖数字基线)
|
|
2901
|
+
const anchorPkg = await buildStyleAnchorPackage(root, book, chapters, exec, text); // 复用 readBookAllText 已读全文
|
|
2651
2902
|
const featState = await readSentenceState();
|
|
2652
2903
|
// v2.6.0:复用全书分析缓存(大书不再每次重算 analyzeText/detectCulture/流派题材)
|
|
2653
2904
|
const { analysis, detection } = await bookAnalysisCached(root, book, text, featState);
|
|
@@ -2745,6 +2996,7 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2745
2996
|
} else lines.push(" (测量失败)");
|
|
2746
2997
|
lines.push("");
|
|
2747
2998
|
lines.push("── 以上全部为插件测量数据,不包含任何风格判断 ──");
|
|
2999
|
+
lines.push("【风格锚包】anchors/skeletons 字段含原著代表性段落与句式骨架——续写时照样例写,数字基线只做事后校验。");
|
|
2748
3000
|
lines.push("请结合六个维度判断本书的风格气质、读者感受与写法特征。");
|
|
2749
3001
|
const reportText = lines.join("\n");
|
|
2750
3002
|
const saved = {};
|
|
@@ -2780,6 +3032,7 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2780
3032
|
const dims = Object.keys(result.baseline).filter(function (k) { return result.baseline[k] && typeof result.baseline[k].mu === "number"; });
|
|
2781
3033
|
result.brief = "全书 " + result.chars + " 字:六维基线 μ=" + dims.map(function (k) { return k + ":" + result.baseline[k].mu.toFixed(1); }).join(" ") + ";推荐容差 " + dims.map(function (k) { return result.baseline[k].recTol + "%"; }).slice(0, 3).join("/") + "…。";
|
|
2782
3034
|
}
|
|
3035
|
+
if (judgment) await noteRoot(root); // v3.9.0:判断保存记录书库根
|
|
2783
3036
|
return cleanOutput({
|
|
2784
3037
|
book,
|
|
2785
3038
|
chars: result.chars,
|
|
@@ -2791,12 +3044,68 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2791
3044
|
dimensions: { fingerprint: analysis.fingerprint, culture: result.culture, genre: result.genre?.dominant, theme: result.theme?.dominant, emotion: emotion.cleanDominant, vibe: Object.fromEntries(vibe.axes.slice(0, 12).map((a) => [a.name, a.score])) },
|
|
2792
3045
|
semantic: semantic.map((s, i) => ({ ...s, discrim: i === 0 && semantic.length >= 2 ? Math.round((semantic[0].score - semantic[1].score) * 1000) / 1000 : null })),
|
|
2793
3046
|
...(judgment ? { saved: { savedAt: saved.savedAt, ...(saved.error ? { error: saved.error } : {}) }, savedJudgment: judgment } : {}),
|
|
2794
|
-
|
|
3047
|
+
anchors: anchorPkg.anchors,
|
|
3048
|
+
skeletons: anchorPkg.skeletons,
|
|
3049
|
+
message: judgment ? (saved?.error ? "风格画像报告已生成,但判断保存失败:" + saved.error : "风格画像报告已生成,判断结果已保存") : "风格画像报告已生成(仅测量数据,判断请由 AI 完成并回传保存)"
|
|
2795
3050
|
});
|
|
2796
3051
|
}
|
|
2797
3052
|
});
|
|
2798
3053
|
}
|
|
2799
3054
|
|
|
3055
|
+
/** v3.8.0:风格锚包——从原著抽取代表性段落(anchors)与句式骨架(skeletons)。
|
|
3056
|
+
* 理念:生成端只喂原文样例(LLM 擅模仿不擅服从数字),统计基线只做校验。纯规则实现,无 embedding 依赖,成本≈0。 */
|
|
3057
|
+
async function buildStyleAnchorPackage(root, book, chapters, exec, preloadedText) {
|
|
3058
|
+
const anchors = [];
|
|
3059
|
+
const skeletons = [];
|
|
3060
|
+
try {
|
|
3061
|
+
// v3.8.0 性能:复用调用方已读文本(preloadedText=string 全文 / {file,text}[] 数组)——大书不再双读
|
|
3062
|
+
let allText = "";
|
|
3063
|
+
if (typeof preloadedText === "string" && preloadedText.length > 0) {
|
|
3064
|
+
allText = preloadedText;
|
|
3065
|
+
} else if (Array.isArray(preloadedText)) {
|
|
3066
|
+
allText = preloadedText.map(function (x) { return x.text || ""; }).join("\n");
|
|
3067
|
+
} else {
|
|
3068
|
+
const dir = bookDir(root, book);
|
|
3069
|
+
for (const ch of chapters) allText += (await readTextFile(join(dir, ch.file), exec)) + "\n";
|
|
3070
|
+
}
|
|
3071
|
+
const paras = allText.split(/\n+/).map(function (p) { return p.trim(); }).filter(function (p) { return p.length >= 40; });
|
|
3072
|
+
const pick = function (list, label, n) {
|
|
3073
|
+
let got = 0;
|
|
3074
|
+
for (const p of list) {
|
|
3075
|
+
if (got >= n) break;
|
|
3076
|
+
anchors.push({ label: label, text: p.slice(0, 200) });
|
|
3077
|
+
got += 1;
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
const dialogue = paras.filter(function (p) { return /["“]/.test(p) && p.length <= 200; });
|
|
3081
|
+
const psych = paras.filter(function (p) { return /想|觉得|心|怕|慌|记忆/.test(p) && !/["“]/.test(p) && p.length <= 200; });
|
|
3082
|
+
const desc = paras.filter(function (p) { return !/["“]/.test(p) && p.length >= 80 && p.length <= 200; });
|
|
3083
|
+
pick(dialogue, "对话", 2);
|
|
3084
|
+
pick(psych, "心理", 1);
|
|
3085
|
+
pick(desc, "描写", 2);
|
|
3086
|
+
if (anchors.length === 0) {
|
|
3087
|
+
for (const p of paras.slice(0, 3)) anchors.push({ label: "原文", text: p.slice(0, 200) });
|
|
3088
|
+
}
|
|
3089
|
+
const sentences = allText.split(/[。!?…]+/).map(function (s) { return s.replace(/\s+/g, "").trim(); }).filter(function (s) { return s.length >= 20 && s.length <= 45; });
|
|
3090
|
+
const seen = new Set();
|
|
3091
|
+
for (const s of sentences) {
|
|
3092
|
+
if (skeletons.length >= 4) break;
|
|
3093
|
+
if (seen.has(s)) continue;
|
|
3094
|
+
seen.add(s);
|
|
3095
|
+
let type = "陈述";
|
|
3096
|
+
if (/["“]/.test(s)) type = "对话";
|
|
3097
|
+
else if (/[??]$/.test(s)) type = "疑问";
|
|
3098
|
+
else if (/想|觉得|仿佛|好像|似乎/.test(s)) type = "心理";
|
|
3099
|
+
skeletons.push({ type: type, text: s });
|
|
3100
|
+
}
|
|
3101
|
+
if (skeletons.length === 0) {
|
|
3102
|
+
const firsts = allText.split(/\n+/).map(function (p) { return p.trim(); }).filter(function (p) { return p.length >= 15; }).slice(0, 4);
|
|
3103
|
+
for (const ft of firsts) skeletons.push({ type: "陈述", text: ft.slice(0, 60) });
|
|
3104
|
+
}
|
|
3105
|
+
} catch { /* 锚包失败不影响主功能 */ }
|
|
3106
|
+
return { anchors: anchors.slice(0, 4), skeletons: skeletons.slice(0, 4) };
|
|
3107
|
+
}
|
|
3108
|
+
|
|
2800
3109
|
function parseEvidenceCounts(arr) {
|
|
2801
3110
|
let n = 0;
|
|
2802
3111
|
for (const item of arr || []) {
|
|
@@ -2994,9 +3303,16 @@ function registerNovelSettings(ctx, config) {
|
|
|
2994
3303
|
list.splice(index, 1);
|
|
2995
3304
|
message = `已删除 ${categoryLabel}:${name}`;
|
|
2996
3305
|
} else {
|
|
3306
|
+
const oldDay = category === "timeline" ? list[index].day : void 0; // v3.9.0:改名同步前记录旧 day
|
|
2997
3307
|
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes", "basis", "ritual", "day"]) {
|
|
2998
3308
|
if (args?.[key] !== void 0) list[index][key] = String(args[key]);
|
|
2999
3309
|
}
|
|
3310
|
+
// v3.9.0:timeline 的 day 变更时同步 name(否则身份字段分裂:{name:第1天, day:第2天} 渲染/归一歧义)
|
|
3311
|
+
if (category === "timeline" && args?.day !== void 0) {
|
|
3312
|
+
// name 与旧 day 相同(add 时同步的别名)→ 跟随新 day
|
|
3313
|
+
if (list[index].name === oldDay) list[index].name = String(args.day);
|
|
3314
|
+
else if (!list[index].name) list[index].name = String(args.day);
|
|
3315
|
+
}
|
|
3000
3316
|
// v3.5.0 #13:speechStyle 对象字段(update 也要支持)
|
|
3001
3317
|
if (args?.speechStyle !== void 0 && args.speechStyle !== null && typeof args.speechStyle === "object") {
|
|
3002
3318
|
list[index].speechStyle = { ...(list[index].speechStyle ?? {}), ...args.speechStyle };
|
|
@@ -3010,7 +3326,8 @@ function registerNovelSettings(ctx, config) {
|
|
|
3010
3326
|
await writeSettings(root, book, data);
|
|
3011
3327
|
// v1.0.2:update 返回单条
|
|
3012
3328
|
if (action === "update") {
|
|
3013
|
-
|
|
3329
|
+
// v3.9.0:改名后 lastSettingName 可能已失效(name 跟随 day 变更)——直接用索引取
|
|
3330
|
+
const updated = list[index];
|
|
3014
3331
|
const result = { book, category, action, message };
|
|
3015
3332
|
result[listKey] = updated ? [normalizeSettingEntry(updated)] : [];
|
|
3016
3333
|
return result;
|
|
@@ -3200,6 +3517,7 @@ function registerNovelSummary(ctx, config) {
|
|
|
3200
3517
|
}
|
|
3201
3518
|
await mkdir(dirname(summariesFile(root, book)), { recursive: true });
|
|
3202
3519
|
await atomicWriteJson(summariesFile(root, book), { book, summaries });
|
|
3520
|
+
await noteRoot(root); // v3.9.0:summary 写操作记录书库根
|
|
3203
3521
|
// v1.0.2:add/update 返回单条确认(不再返回全量列表)
|
|
3204
3522
|
if (action === "add" || action === "update") {
|
|
3205
3523
|
const single = summaries[index !== -1 ? index : summaries.length - 1];
|
|
@@ -4112,11 +4430,13 @@ function registerNovelRead(ctx, config) {
|
|
|
4112
4430
|
// v3.5.0 #11:单行超长裁剪(防首行超长绕过上限)
|
|
4113
4431
|
let line = allLines[i];
|
|
4114
4432
|
if (line.length > 3000) line = line.slice(0, 3000) + "…(行过长已裁剪)";
|
|
4115
|
-
|
|
4116
|
-
|
|
4433
|
+
// v3.9.0:先判断后累加——截断行不计入;换行只算「与上一已返回行之间」的(与 lines.join("\n") 口径一致,截断在中间时末尾换行不再多算)
|
|
4434
|
+
const addChars = line.length + (lines.length > 0 ? 1 : 0);
|
|
4435
|
+
if (chars + addChars > READ_MAX_CHARS && lines.length > 0) {
|
|
4117
4436
|
hitCharCap = true;
|
|
4118
4437
|
break;
|
|
4119
4438
|
}
|
|
4439
|
+
chars += addChars;
|
|
4120
4440
|
lines.push({ number: i + 1, text: line });
|
|
4121
4441
|
}
|
|
4122
4442
|
// v3.5.0 #10:truncated 真实判定——字符上限/行数上限/未到文件尾任一即截断
|
|
@@ -4282,18 +4602,28 @@ function registerNovelNewChapter(ctx, config) {
|
|
|
4282
4602
|
chars: { type: "integer" },
|
|
4283
4603
|
// v3.7.0:强制风格基线——创建章节时自动附全书六维基线摘要(模型动笔前必得数据)
|
|
4284
4604
|
baseline: { type: "string" },
|
|
4285
|
-
reminder: { type: "string" }
|
|
4605
|
+
reminder: { type: "string" },
|
|
4606
|
+
// v3.8.0:风格锚包——原著代表性段落 + 句式骨架(照样例写,数字只做校验)
|
|
4607
|
+
anchors: { type: "array", items: { type: "object", additionalProperties: false, properties: { label: { type: "string" }, text: { type: "string" } }, required: ["label", "text"] } },
|
|
4608
|
+
skeletons: { type: "array", items: { type: "object", additionalProperties: false, properties: { type: { type: "string" }, text: { type: "string" } }, required: ["type", "text"] } }
|
|
4286
4609
|
},
|
|
4287
4610
|
required: ["book", "number", "file", "path", "chars"]
|
|
4288
4611
|
},
|
|
4289
|
-
render: (_args, value) =>
|
|
4290
|
-
type
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4612
|
+
render: (_args, value) => {
|
|
4613
|
+
const lines = [`<path>${value.path}</path>`, "<type>novel-chapter-created</type>", "<content>", `已创建 第${String(value.number).padStart(2, "0")}章(${value.chars} 字)`];
|
|
4614
|
+
if (value.baseline) { lines.push("", "【风格基线 μ】" + value.baseline); }
|
|
4615
|
+
if (Array.isArray(value.anchors) && value.anchors.length > 0) {
|
|
4616
|
+
lines.push("", "【风格锚·原著段落】动笔照此味道写(数字基线只做事后校验):");
|
|
4617
|
+
for (const a of value.anchors) lines.push(" [" + a.label + "] " + a.text);
|
|
4618
|
+
}
|
|
4619
|
+
if (Array.isArray(value.skeletons) && value.skeletons.length > 0) {
|
|
4620
|
+
lines.push("", "【句式骨架】按此形状造句:");
|
|
4621
|
+
for (const sk of value.skeletons) lines.push(" [" + sk.type + "] " + sk.text);
|
|
4622
|
+
}
|
|
4623
|
+
if (value.reminder) lines.push("", value.reminder);
|
|
4624
|
+
lines.push("</content>");
|
|
4625
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
4626
|
+
}
|
|
4297
4627
|
},
|
|
4298
4628
|
async execute(args, exec) {
|
|
4299
4629
|
await assertToolEnabled(config, "novel_new_chapter");
|
|
@@ -4328,9 +4658,10 @@ Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
|
|
|
4328
4658
|
await writeFile(filePath, text, { encoding: "utf8", signal: exec.signal });
|
|
4329
4659
|
// v3.7.0:强制风格基线——创建章节时自动计算全书六维基线附在返回中(即使模型没先调 novel_style_report,也能拿到基线)
|
|
4330
4660
|
let baselineLine = "";
|
|
4661
|
+
let anchorPkg = { anchors: [], skeletons: [] };
|
|
4662
|
+
let bTexts = [];
|
|
4331
4663
|
try {
|
|
4332
4664
|
const allChs = await scanChapters(dir);
|
|
4333
|
-
const bTexts = [];
|
|
4334
4665
|
for (const ch of allChs) {
|
|
4335
4666
|
if (ch.file === fileName) continue;
|
|
4336
4667
|
bTexts.push({ file: ch.file, text: await readTextFile(join(dir, ch.file), exec) });
|
|
@@ -4341,8 +4672,12 @@ Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
|
|
|
4341
4672
|
baselineLine = METRIC_ORDER.map(function (k) { return METRIC_LABELS[k] + " " + (bl[k] && typeof bl[k].mu === "number" ? bl[k].mu : "-"); }).join(" / ");
|
|
4342
4673
|
}
|
|
4343
4674
|
} catch { /* 基线计算失败不影响章节创建 */ }
|
|
4344
|
-
|
|
4345
|
-
|
|
4675
|
+
try {
|
|
4676
|
+
anchorPkg = await buildStyleAnchorPackage(root, book, chapters, exec, bTexts); // 复用基线计算已读文本
|
|
4677
|
+
} catch { /* 锚包失败不影响创建 */ }
|
|
4678
|
+
const reminder = "本章已创建(第" + String(number).padStart(2, "0") + "章)。【风格基线强制流程】① 动笔前:对照上方 baseline 六维 μ,并按 anchors/skeletons 原著样例的味道与句式骨架写(数字只做校验,样例才是写法);② 写完:必须调用 novel_style_check 对照——verdict 低于 high 或出带时,按返回的 fixAnchors 原著锚段逐句修正跑偏部分,不得整章重写;属情节需要则说明原因。";
|
|
4679
|
+
await noteRoot(root); // v3.9.0:新章创建记录书库根
|
|
4680
|
+
return cleanOutput({ book, number, file: fileName, path: filePath, chars: text.length, baseline: baselineLine, reminder, anchors: anchorPkg.anchors, skeletons: anchorPkg.skeletons });
|
|
4346
4681
|
}
|
|
4347
4682
|
});
|
|
4348
4683
|
}
|
package/lib/prompts.js
CHANGED
|
@@ -22,6 +22,7 @@ const WORKFLOW_TEXT = [
|
|
|
22
22
|
" 以及用 novel_keywords 提取的高频关键词(作者词汇偏好与意象母题),并说明这些手法/关键词在具体章节中的例证。",
|
|
23
23
|
"3. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
|
|
24
24
|
" 【强制·风格基线】动笔前必须调用 novel_style_report 读取全书六维风格基线(μ/σ/容差带)并记录;novel_new_chapter 创建章节时会自动附基线 μ 摘要——若基线与你从正文感受的文风不符,以基线数据为准。",
|
|
25
|
+
" 【v3.8.0·锚包写作】novel_style_report / novel_new_chapter 返回的 anchors(原著代表性段落)与 skeletons(句式骨架)是风格锚——动笔时照锚段的味道与句式的形状写,数字基线只做事后校验;不要试图把数字翻译成写作规则。",
|
|
25
26
|
" 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
|
|
26
27
|
"4. 句式模式分析(增强,受 UI 开关控制):分析作品时可调用 novel_sentence_analysis 提取句式分布(陈述/环境/心理/对话/疑问/反问/感叹/祈使/省略留白九类)、",
|
|
27
28
|
" 句式排列规律(转移、高频模板、段首段尾句式、按章节的压缩节奏序列)、句长节奏、情感曲线、风格指纹与节奏建议,用于快速掌握作者的写作习惯与主观情感。",
|
|
@@ -34,7 +35,7 @@ const WORKFLOW_TEXT = [
|
|
|
34
35
|
" 若发现异名同书(如\"旧版/精修版\"实为同一本),用 book 参数强制合并后以 apply 模式导入到 novels/<书名>/ 分类存放。",
|
|
35
36
|
"6. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
|
|
36
37
|
"7. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);",
|
|
37
|
-
" 【强制·写完对照】每写完一章必须调用 novel_style_check
|
|
38
|
+
" 【强制·写完对照】每写完一章必须调用 novel_style_check 对照基线(相似度+偏差清单+六维判定)——verdict 低于 high 或任一维度出带时,按返回的 fixAnchors(原著锚段)逐句对照修正跑偏部分,不得整章重写;确属情节需要则说明原因;novel_plot scan 自动更新伏笔提及记录;",
|
|
38
39
|
" 世界观一致性:续写前先确认文化基准——novel_settings category=worldview action=detect 自动判断(或人工 add/update),动笔时对照其 bannedWords/recommended 用词,避免中西意象混搭(如欧式背景不写老夫/上香/时辰);novel_continuity_check 会扫描用语冲突候选;",
|
|
39
40
|
" 语用一致性:不只管词,还要管'怎么说话'——对照 worldview 的 speechStyle(title 称谓规范/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气),人物开口前检查称谓是否欧式(Miss+名)、客套是否避免'提点/承蒙/在下'、宗教仪式是否点烛而非烧香/上X柱香、对话是否口语化不文言;",
|
|
40
41
|
" novel_settings 维护五张设定表(人物/地点/道具/时间线/世界观用语规范),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
|
package/lib/vibe.js
CHANGED
|
@@ -102,6 +102,8 @@ const THEME_AXIS_LINK = [
|
|
|
102
102
|
* @returns 氛围光谱 { axes, top, conclusion, confidence, evidence }
|
|
103
103
|
*/
|
|
104
104
|
export function computeVibe(detect, emotion, text = "") {
|
|
105
|
+
// v3.9.0 修正:外部直接传 null/undefined 不抛(v3.8 的 if (text) 防御恢复)
|
|
106
|
+
text = typeof text === "string" ? text : String(text ?? "");
|
|
105
107
|
const ev = detect.evidence || {};
|
|
106
108
|
const wF = parseFreq(ev.western || []);
|
|
107
109
|
const mF = parseFreq(ev.modern || []);
|
|
@@ -134,8 +136,18 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
134
136
|
for (const w of axisWords) { if (w.length >= 2 && w.length <= 4) signalLens.add(w.length); else if (w.length > 4) longWords.add(w); }
|
|
135
137
|
}
|
|
136
138
|
const textWordCounts = new Map();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
// v3.9.0 性能:n-gram 滑窗限长(大书 50 万字 ~150 万键 → 上限 ~60 万键)
|
|
140
|
+
// v3.9.0 修正:头/中/尾三段等距抽样(只取头部会让集中在末尾的信号词完全丢失——实测甜宠词失真 0.089 vs 0.033)
|
|
141
|
+
const NGRAM_LIMIT = 200000;
|
|
142
|
+
let scanText = text;
|
|
143
|
+
if (scanText.length > NGRAM_LIMIT) {
|
|
144
|
+
const seg = Math.floor(scanText.length / 3);
|
|
145
|
+
const third = Math.floor(NGRAM_LIMIT / 3);
|
|
146
|
+
// v3.9.0 复查修正:第三段取 slice(-third) 覆盖真正的文末(原 seg*2 是「最后 1/3 的开头」,末尾约 1/9 永不采样)
|
|
147
|
+
scanText = scanText.slice(0, third) + scanText.slice(seg, seg + third) + scanText.slice(-third);
|
|
148
|
+
}
|
|
149
|
+
if (scanText) {
|
|
150
|
+
for (const run of String(scanText).match(/[\u4e00-\u9fa5]{2,}/g) || []) {
|
|
139
151
|
for (const L of signalLens) {
|
|
140
152
|
for (let i = 0; i + L <= run.length; i++) {
|
|
141
153
|
const w = run.slice(i, i + L);
|
|
@@ -146,7 +158,7 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
146
158
|
// >4 字词:直接 indexOf 计数(这些词低频出现,全文本扫一遍开销可忽略)
|
|
147
159
|
for (const lw of longWords) {
|
|
148
160
|
let from = 0, cnt = 0;
|
|
149
|
-
while ((from =
|
|
161
|
+
while ((from = scanText.indexOf(lw, from)) !== -1) { cnt++; from += lw.length; }
|
|
150
162
|
if (cnt > 0) textWordCounts.set(lw, cnt);
|
|
151
163
|
}
|
|
152
164
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "小说写作助手插件(v3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
|
+
"description": "小说写作助手插件(v3.9.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带、原创模式与创作资料、写作哨兵(衔接/OOC/大纲)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -30,8 +30,9 @@
|
|
|
30
30
|
## 风格自检(v0.6.0 新增,v3.7.0 起强制)
|
|
31
31
|
|
|
32
32
|
- **续写前必须读基线**:动笔前调用 `novel_style_report`(完整版,包含每维 μ/σ/容差带;或 brief 拿 μ 摘要)确认本书六维风格基线;`novel_new_chapter` 创建章节时会自动附基线 μ 摘要——若发现与自己从正文中感受到的文风不符,以基线数据为准。
|
|
33
|
+
- **v3.8.0 锚包写作**:report / new_chapter 返回的 `anchors`(原著代表性段落)与 `skeletons`(句式骨架)是风格锚——动笔时**照锚段的味道与句式形状写**,数字基线只做事后校验;不要试图把数字翻译成写作规则(语言概括有损且模型不可执行)。
|
|
33
34
|
- **写完必须对照**:每写完一章调用 `novel_style_check`:本章 vs 全书其余章节 → 相似度 + 偏差清单 + 建议;
|
|
34
|
-
-
|
|
35
|
+
- **强制判定(v3.8.0 对照修正)**:verdict 低于 high,或任一维度出带(metric verdicts 含 out)时,按返回的 `fixAnchors` 原著锚段**逐句对照修正跑偏部分,不得整章重写**;确属情节需要则说明原因;
|
|
35
36
|
- 对照结果应体现在续写说明中(偏差维度 + 是否修正)。
|
|
36
37
|
|
|
37
38
|
## 设定管理(v0.8.0 新增)
|