dsh-novel-writer 0.8.0 → 0.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/README.md +10 -2
- package/lib/index.js +114 -19
- package/package.json +2 -2
- package/skills/novel-writing/SKILL.md +13 -0
- package/test/e2e-test.mjs +13 -3
package/README.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
# dsh-novel-writer — 小说写作助手插件(v0.
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v0.9.0:世界观层)
|
|
2
2
|
|
|
3
3
|
DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
|
|
4
4
|
浏览器端仅依赖 Web GUI 自带的 react。
|
|
5
|
-
**v0.
|
|
5
|
+
**v0.9.0 在 v0.8.0 基础上补全「世界观层」:第五张设定表「世界观/用语规范」、文化基准自动判断(detect)、用语风格扫描、续写用语一致性检查**:分析缓存与报告导出、风格自检、伏笔登记表、
|
|
6
6
|
关键词三字组/疑似人名、情感词典去噪、祈使句与环境分类改进、局域网开关可选放行。
|
|
7
7
|
|
|
8
8
|
## 工具清单(13 个,全部带独立 UI 开关)
|
|
9
9
|
|
|
10
10
|
novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot / **novel_settings(设定管理)** / **novel_summary(章节摘要)** / **novel_continuity_check(连贯性审计)**
|
|
11
11
|
|
|
12
|
+
## v0.9.0 新增能力(世界观层)
|
|
13
|
+
- **novel_settings 第五张表「世界观/用语规范」(category=worldview)**:
|
|
14
|
+
- `detect`:自动判断文化基准(western/eastern/mixed/unknown)+ 置信度 + 证据(中西词表按时代错置分类:器物/称谓/计量/宗教仪式/市井风貌/服饰/食物/制度);
|
|
15
|
+
- 登记:name / basis / bannedWords(禁用词)/ recommended(替代词映射)/ ritual(仪式规范);
|
|
16
|
+
- 文化基准由 AI 自行判断,不预设东西方。
|
|
17
|
+
- **novel_continuity_check 用语风格扫描**:对照 worldview 禁用词表输出「用语冲突」候选(带建议替换词,未登记时用默认基准并提示 detect);
|
|
18
|
+
- **续写提示词**:新增世界观一致性检查步骤。
|
|
19
|
+
|
|
12
20
|
## v0.8.0 新增能力
|
|
13
21
|
|
|
14
22
|
- **novel_settings(四张表)**:人物卡/地点卡/道具清单/时间线,list/add/update/delete + scan 候选提取;
|
package/lib/index.js
CHANGED
|
@@ -581,6 +581,54 @@ const TOOL_LABELS = Object.freeze({
|
|
|
581
581
|
novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记",
|
|
582
582
|
novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计"
|
|
583
583
|
});
|
|
584
|
+
/**
|
|
585
|
+
* v0.9.0 世界观文化标记词表(detect 用)。
|
|
586
|
+
* 按"时代错置(anachronism)"分类组织:器物 / 称谓 / 计量 / 宗教仪式 / 市井风貌 / 服饰 / 食物 / 制度。
|
|
587
|
+
* 参考:Medieval Wordbook (M.P.Cosman)、The Writer's Complete Fantasy Reference、
|
|
588
|
+
* Fantasy Writers Phrase Book、凌力"错用现代词汇破坏历史氛围"论。
|
|
589
|
+
*/
|
|
590
|
+
|
|
591
|
+
const CULTURE_MARKERS = {
|
|
592
|
+
western: {
|
|
593
|
+
label: "西方/欧式中世纪",
|
|
594
|
+
words: ["教堂","神甫","神官","公爵","伯爵","骑士","城堡","教廷","祈祷","礼拜","弥撒","修士","修道院","蜡烛","烛台","绞刑架","马车","庄园","国王","王后","领主","圣器","圣水","神像","火刑","审问","燧发","火枪","决斗","舞会","礼服","女仆","男仆","面包","葡萄酒","钟楼","教典","贵族","城堡","审判庭"]
|
|
595
|
+
},
|
|
596
|
+
eastern: {
|
|
597
|
+
label: "东方/中式古代",
|
|
598
|
+
words: ["老夫","上香","三炷香","香炉","丫鬟","门房","婶子","布鞋","糖人","菜篮","青石板","幌子","伙计","掌柜","时辰","府邸","俸禄","科举","衙门","轿子","花轿","厢房","八仙桌","银两","江湖","武林","大侠","客栈","庙","和尚","道士","菩萨","佛祖","烧香","磕头","祠堂","家丁","账房","郎中","油条","绣楼","官差","老爷","夫人","奉茶"]
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
/** v0.9.0 默认用语规范(无 worldview 登记时用于欧式基准扫描)。 */
|
|
602
|
+
const DEFAULT_BANNED_WORDS = {
|
|
603
|
+
culture: "欧式中世纪晚期(默认)",
|
|
604
|
+
bannedWords: ["老夫","上香","三炷香","香炉","丫鬟","门房","婶子","布鞋","糖人","菜篮","青石板","幌子","伙计","掌柜","时辰","府邸","俸禄","科举","衙门","轿子","花轿","厢房","八仙桌","银两","江湖","武林","大侠","客栈","和尚","道士","菩萨","佛祖","烧香","磕头","祠堂","家丁","账房","郎中","油条","绣楼","官差","奉茶"],
|
|
605
|
+
recommended: { "老夫": "我", "上香": "点烛", "三炷香": "点烛", "香炉": "烛台", "丫鬟": "侍女", "门房": "守门人", "婶子": "厨娘", "布鞋": "软靴", "时辰": "钟头", "府邸": "宅邸", "伙计": "店员", "掌柜": "店主", "郎中": "医师", "客栈": "旅店", "轿子": "马车", "烧香": "点烛" }
|
|
606
|
+
};
|
|
607
|
+
/** 自动判断文本的文化基准:统计中西词表命中,返回建议与证据。 */
|
|
608
|
+
function detectCulture(text) {
|
|
609
|
+
const scores = { western: 0, eastern: 0 };
|
|
610
|
+
const evidence = { western: [], eastern: [] };
|
|
611
|
+
for (const [culture, table] of Object.entries(CULTURE_MARKERS)) {
|
|
612
|
+
for (const word of table.words) {
|
|
613
|
+
const n = text.split(word).length - 1;
|
|
614
|
+
if (n > 0) {
|
|
615
|
+
scores[culture] += n;
|
|
616
|
+
if (evidence[culture].length < 8) evidence[culture].push(word + "×" + n);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const total = scores.western + scores.eastern;
|
|
621
|
+
let culture = "unknown";
|
|
622
|
+
let confidence = 0;
|
|
623
|
+
if (total > 0) {
|
|
624
|
+
const ratio = scores.western / Math.max(scores.eastern, 1);
|
|
625
|
+
if (ratio >= 2) { culture = "western"; confidence = Math.min(0.95, 0.5 + scores.western / (total * 2)); }
|
|
626
|
+
else if (ratio <= 0.5) { culture = "eastern"; confidence = Math.min(0.95, 0.5 + scores.eastern / (total * 2)); }
|
|
627
|
+
else { culture = "mixed"; confidence = 0.5; }
|
|
628
|
+
}
|
|
629
|
+
return { culture, confidence: Math.round(confidence * 100) / 100, scores, evidence, total };
|
|
630
|
+
}
|
|
631
|
+
|
|
584
632
|
/** 工具级开关是否开启(默认开;state.tools[name] === false 时关闭)。 */
|
|
585
633
|
function toolEnabled(state, name) {
|
|
586
634
|
const tools = state?.tools ?? {};
|
|
@@ -1375,10 +1423,11 @@ async function readSettings(root, book) {
|
|
|
1375
1423
|
characters: Array.isArray(parsed.characters) ? parsed.characters : [],
|
|
1376
1424
|
locations: Array.isArray(parsed.locations) ? parsed.locations : [],
|
|
1377
1425
|
items: Array.isArray(parsed.items) ? parsed.items : [],
|
|
1378
|
-
timeline: Array.isArray(parsed.timeline) ? parsed.timeline : []
|
|
1426
|
+
timeline: Array.isArray(parsed.timeline) ? parsed.timeline : [],
|
|
1427
|
+
worldview: Array.isArray(parsed.worldview) ? parsed.worldview : []
|
|
1379
1428
|
};
|
|
1380
1429
|
} catch {
|
|
1381
|
-
return { characters: [], locations: [], items: [], timeline: [] };
|
|
1430
|
+
return { characters: [], locations: [], items: [], timeline: [], worldview: [] };
|
|
1382
1431
|
}
|
|
1383
1432
|
}
|
|
1384
1433
|
async function writeSettings(root, book, data) {
|
|
@@ -1388,12 +1437,15 @@ async function writeSettings(root, book, data) {
|
|
|
1388
1437
|
}
|
|
1389
1438
|
function normalizeSettingEntry(entry) {
|
|
1390
1439
|
const out = { name: String(entry.name) };
|
|
1391
|
-
for (const key of ["description", "traits", "relationships", "firstSeen", "notes", "owner", "status", "lastSeen", "day", "event", "chapter"]) {
|
|
1440
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "notes", "owner", "status", "lastSeen", "day", "event", "chapter", "basis", "ritual"]) {
|
|
1392
1441
|
if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
|
|
1393
1442
|
}
|
|
1394
|
-
for (const key of ["alias"]) {
|
|
1443
|
+
for (const key of ["alias", "bannedWords"]) {
|
|
1395
1444
|
if (Array.isArray(entry[key])) out[key] = entry[key].map(String);
|
|
1396
1445
|
}
|
|
1446
|
+
if (entry.recommended !== void 0 && entry.recommended !== null && typeof entry.recommended === "object") {
|
|
1447
|
+
out.recommended = { ...entry.recommended };
|
|
1448
|
+
}
|
|
1397
1449
|
return out;
|
|
1398
1450
|
}
|
|
1399
1451
|
function registerNovelSettings(ctx, config) {
|
|
@@ -1404,8 +1456,8 @@ function registerNovelSettings(ctx, config) {
|
|
|
1404
1456
|
type: "object",
|
|
1405
1457
|
properties: {
|
|
1406
1458
|
book: { type: "string", description: "书名。" },
|
|
1407
|
-
category: { type: "string", enum: ["character", "location", "item", "timeline"], description: "表类别(list/add/update/delete/scan
|
|
1408
|
-
action: { type: "string", enum: ["list", "add", "update", "delete", "scan"], description: "list=查看(默认);add=登记;update=修改;delete=删除;scan
|
|
1459
|
+
category: { type: "string", enum: ["character", "location", "item", "timeline", "worldview"], description: "表类别(character/location/item/timeline/worldview;list/add/update/delete/scan 需要;detect 固定 worldview)。" },
|
|
1460
|
+
action: { type: "string", enum: ["list", "add", "update", "delete", "scan", "detect"], description: "list=查看(默认);add=登记;update=修改;delete=删除;scan=扫描章节提取候选;detect=自动判断世界观文化基准(worldview 专用)。" },
|
|
1409
1461
|
name: { type: "string", description: "条目名(人物名/地名/道具名;timeline 用 day 字段)。" },
|
|
1410
1462
|
description: { type: "string", description: "可选:描述。" },
|
|
1411
1463
|
traits: { type: "string", description: "character 专用:性格/外貌特征。" },
|
|
@@ -1436,6 +1488,12 @@ function registerNovelSettings(ctx, config) {
|
|
|
1436
1488
|
locations: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1437
1489
|
items: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1438
1490
|
timeline: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1491
|
+
worldview: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1492
|
+
culture: { type: "string" },
|
|
1493
|
+
confidence: { type: "number" },
|
|
1494
|
+
scores: { type: "object", additionalProperties: true },
|
|
1495
|
+
evidence: { type: "object", additionalProperties: true },
|
|
1496
|
+
total: { type: "integer" },
|
|
1439
1497
|
candidates: { type: "array", items: { type: "string" } },
|
|
1440
1498
|
message: { type: "string" }
|
|
1441
1499
|
},
|
|
@@ -1444,13 +1502,17 @@ function registerNovelSettings(ctx, config) {
|
|
|
1444
1502
|
render: (_args, value) => {
|
|
1445
1503
|
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-settings</type>", "<content>", ""];
|
|
1446
1504
|
if (value.message) lines.push(value.message);
|
|
1447
|
-
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[value.category] ?? value.category;
|
|
1448
|
-
const list = value.characters ?? value.locations ?? value.items ?? value.timeline ?? [];
|
|
1505
|
+
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线", worldview: "世界观/用语规范" }[value.category] ?? value.category;
|
|
1506
|
+
const list = value.characters ?? value.locations ?? value.items ?? value.timeline ?? value.worldview ?? [];
|
|
1449
1507
|
lines.push(`${categoryLabel}(${list.length} 条):`);
|
|
1450
1508
|
for (const e of list) {
|
|
1451
|
-
const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, e.day + (e.event ? " " + e.event : "")].filter(Boolean).join(" | ");
|
|
1509
|
+
const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, e.day + (e.event ? " " + e.event : ""), e.basis, e.ritual, Array.isArray(e.bannedWords) ? "禁用词:" + e.bannedWords.join("/") : "", e.recommended ? "替代:" + Object.entries(e.recommended).map(([k, v]) => k + "→" + v).join("/") : ""].filter(Boolean).join(" | ");
|
|
1452
1510
|
lines.push(` - ${e.name}${extra ? ":" + extra : ""}${e.chapter ? "(" + e.chapter + ")" : ""}`);
|
|
1453
1511
|
}
|
|
1512
|
+
if (value.culture && value.evidence) {
|
|
1513
|
+
lines.push(`判断结果:${value.message ?? value.culture}(西${value.scores?.western ?? 0} / 东${value.scores?.eastern ?? 0})`);
|
|
1514
|
+
lines.push("证据:" + (value.evidence.western ?? []).slice(0, 5).join(" ") + " | " + (value.evidence.eastern ?? []).slice(0, 5).join(" "));
|
|
1515
|
+
}
|
|
1454
1516
|
if (Array.isArray(value.candidates) && value.candidates.length > 0) {
|
|
1455
1517
|
lines.push("", "扫描候选(供登记):");
|
|
1456
1518
|
for (const cand of value.candidates) lines.push(" ? " + cand);
|
|
@@ -1461,23 +1523,26 @@ function registerNovelSettings(ctx, config) {
|
|
|
1461
1523
|
},
|
|
1462
1524
|
async execute(args, exec) {
|
|
1463
1525
|
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1526
|
+
const isDetect = args?.action === "detect";
|
|
1527
|
+
const category = isDetect ? "worldview" : ["character", "location", "item", "timeline", "worldview"].includes(args?.category) ? args.category : "character";
|
|
1528
|
+
const action = ["add", "update", "delete", "scan", "detect"].includes(args?.action) ? args.action : "list";
|
|
1466
1529
|
const root = resolveRoot(config, args, exec);
|
|
1467
1530
|
const data = await readSettings(root, book);
|
|
1468
1531
|
const now = new Date().toISOString();
|
|
1469
1532
|
let message = "";
|
|
1470
|
-
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[category] ?? category;
|
|
1471
|
-
const listKey = { character: "characters", location: "locations", item: "items", timeline: "timeline" }[category];
|
|
1533
|
+
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线", worldview: "世界观/用语规范" }[category] ?? category;
|
|
1534
|
+
const listKey = { character: "characters", location: "locations", item: "items", timeline: "timeline", worldview: "worldview" }[category];
|
|
1472
1535
|
const list = data[listKey];
|
|
1473
1536
|
if (action === "add") {
|
|
1474
1537
|
const name = optionalString(args, "name") ?? optionalString(args, "day");
|
|
1475
1538
|
assert(name !== void 0, "novel_settings add 需要 name(timeline 用 day)参数");
|
|
1476
1539
|
const entry = { name };
|
|
1477
|
-
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
|
|
1540
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes", "basis", "ritual"]) {
|
|
1478
1541
|
if (args?.[key] !== void 0) entry[key] = String(args[key]);
|
|
1479
1542
|
}
|
|
1480
1543
|
if (Array.isArray(args?.alias)) entry.alias = args.alias.map(String);
|
|
1544
|
+
if (Array.isArray(args?.bannedWords)) entry.bannedWords = args.bannedWords.map(String);
|
|
1545
|
+
if (args?.recommended !== void 0 && typeof args?.recommended === "object") entry.recommended = { ...args.recommended };
|
|
1481
1546
|
list.push(entry);
|
|
1482
1547
|
message = `已登记 ${categoryLabel}:${name}`;
|
|
1483
1548
|
await writeSettings(root, book, data);
|
|
@@ -1490,10 +1555,12 @@ function registerNovelSettings(ctx, config) {
|
|
|
1490
1555
|
list.splice(index, 1);
|
|
1491
1556
|
message = `已删除 ${categoryLabel}:${name}`;
|
|
1492
1557
|
} else {
|
|
1493
|
-
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
|
|
1558
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes", "basis", "ritual"]) {
|
|
1494
1559
|
if (args?.[key] !== void 0) list[index][key] = String(args[key]);
|
|
1495
1560
|
}
|
|
1496
1561
|
if (Array.isArray(args?.alias)) list[index].alias = args.alias.map(String);
|
|
1562
|
+
if (Array.isArray(args?.bannedWords)) list[index].bannedWords = args.bannedWords.map(String);
|
|
1563
|
+
if (args?.recommended !== void 0 && typeof args?.recommended === "object") list[index].recommended = { ...args.recommended };
|
|
1497
1564
|
message = `已更新 ${categoryLabel}:${name}`;
|
|
1498
1565
|
}
|
|
1499
1566
|
await writeSettings(root, book, data);
|
|
@@ -1514,10 +1581,21 @@ function registerNovelSettings(ctx, config) {
|
|
|
1514
1581
|
const candidates = [...nameCounts.entries()].filter(([n, count]) => count >= 2 && !known.has(n)).map(([n, count]) => n + "(" + count + "次)").slice(0, 15);
|
|
1515
1582
|
message = `扫描全书,提取 ${candidates.length} 个人物候选`;
|
|
1516
1583
|
const result = { book, category, action, candidates, message };
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1584
|
+
result.characters = data.characters.map(normalizeSettingEntry);
|
|
1585
|
+
result.locations = data.locations.map(normalizeSettingEntry);
|
|
1586
|
+
result.items = data.items.map(normalizeSettingEntry);
|
|
1587
|
+
result.timeline = data.timeline.map(normalizeSettingEntry);
|
|
1588
|
+
result.worldview = data.worldview.map(normalizeSettingEntry);
|
|
1589
|
+
return result;
|
|
1590
|
+
} else if (action === "detect") {
|
|
1591
|
+
const dir = bookDir(root, book);
|
|
1592
|
+
const chapters = await scanChapters(dir);
|
|
1593
|
+
let text = "";
|
|
1594
|
+
for (const chapter of chapters) text += await readTextFile(join(dir, chapter.file), exec);
|
|
1595
|
+
const detection = detectCulture(text);
|
|
1596
|
+
const result = { book, category: "worldview", action, ...detection };
|
|
1597
|
+
result.worldview = data.worldview.map(normalizeSettingEntry);
|
|
1598
|
+
result.message = `自动判断:${CULTURE_MARKERS[detection.culture]?.label ?? "无法判断(词表未命中)"}(置信度 ${Math.round(detection.confidence * 100)}%)`;
|
|
1521
1599
|
return result;
|
|
1522
1600
|
}
|
|
1523
1601
|
const result = { book, category, action };
|
|
@@ -1525,6 +1603,7 @@ function registerNovelSettings(ctx, config) {
|
|
|
1525
1603
|
result.locations = data.locations.map(normalizeSettingEntry);
|
|
1526
1604
|
result.items = data.items.map(normalizeSettingEntry);
|
|
1527
1605
|
result.timeline = data.timeline.map(normalizeSettingEntry);
|
|
1606
|
+
result.worldview = data.worldview.map(normalizeSettingEntry);
|
|
1528
1607
|
if (message) result.message = message;
|
|
1529
1608
|
return result;
|
|
1530
1609
|
}
|
|
@@ -1758,6 +1837,21 @@ function registerNovelContinuityCheck(ctx, config) {
|
|
|
1758
1837
|
const dup = names.filter((n, i) => names.indexOf(n) !== i);
|
|
1759
1838
|
if (dup.length > 0) candidates.push({ type: "设定重复", detail: `${key} 中存在重复条目:${[...new Set(dup)].join("、")}`, chapters: [] });
|
|
1760
1839
|
}
|
|
1840
|
+
// v0.9.0 用语风格扫描:对照 worldview 禁用词表(无登记则用默认欧式基准)
|
|
1841
|
+
const worldviewEntry = Array.isArray(settings.worldview) ? settings.worldview[0] : void 0;
|
|
1842
|
+
const styleRule = (worldviewEntry && Array.isArray(worldviewEntry.bannedWords) && worldviewEntry.bannedWords.length > 0) ? worldviewEntry : DEFAULT_BANNED_WORDS;
|
|
1843
|
+
const cultureName = (worldviewEntry && worldviewEntry.name) || styleRule.culture;
|
|
1844
|
+
for (const word of styleRule.bannedWords) {
|
|
1845
|
+
const hits = chapterTexts.filter(({ text }) => text.includes(word)).map(({ file }) => file);
|
|
1846
|
+
if (hits.length > 0) {
|
|
1847
|
+
const rec = styleRule.recommended?.[word] ? `(建议改为「${styleRule.recommended[word]}」)` : "";
|
|
1848
|
+
candidates.push({
|
|
1849
|
+
type: "用语冲突",
|
|
1850
|
+
detail: `「${word}」×${hits.length}章 与当前文化基准「${cultureName}」不符${rec}${worldviewEntry ? "" : "(未登记 worldview,使用默认基准;可用 novel_settings detect 自动判断)"}`,
|
|
1851
|
+
chapters: hits
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1761
1855
|
const advice = candidates.length === 0
|
|
1762
1856
|
? "设定表与章节基本一致。建议继续登记新章节出现的新人物/新地点。"
|
|
1763
1857
|
: "以上为规则候选,请逐条人工判断:确认为矛盾则更新设定表(novel_settings update)或修正正文;非矛盾(如回忆/刻意缺席)可忽略。";
|
|
@@ -2400,6 +2494,7 @@ const WORKFLOW_TEXT = [
|
|
|
2400
2494
|
" 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
|
|
2401
2495
|
"7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
|
|
2402
2496
|
"8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
|
|
2497
|
+
" 世界观一致性:续写前先确认文化基准——novel_settings category=worldview action=detect 自动判断(或人工 add/update),动笔时对照其 bannedWords/recommended 用词,避免中西意象混搭(如欧式背景不写老夫/上香/时辰);novel_continuity_check 会扫描用语冲突候选;",
|
|
2403
2498
|
" novel_settings 维护四张设定表(人物/地点/道具/时间线),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
|
|
2404
2499
|
" novel_sentence_analysis 结果自动缓存到书库数据目录 <root>/.novel-writer/analysis/(reportFile 字段),novel_keywords 结果同样落盘,需要重算时传 fresh=true。"
|
|
2405
2500
|
].join("\n");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "小说写作助手插件(v0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "小说写作助手插件(v0.9.0 世界观层):novel_settings 第五张表「世界观/用语规范」(detect 自动判断文化基准 + 禁用词表 + 推荐替代 + 仪式规范)、continuity 用语风格扫描、续写提示词用语一致性检查",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -59,3 +59,16 @@
|
|
|
59
59
|
|
|
60
60
|
- 用户放入章节文件的方式不限:自己复制、AI 用 `write`/`edit` 创建,或用 novel_import 批量导入。
|
|
61
61
|
- 分析结果与续写正文都应使用中文呈现。
|
|
62
|
+
|
|
63
|
+
## 世界观用语规范(v0.9.0 新增)
|
|
64
|
+
|
|
65
|
+
- **文化基准自动判断**:§BT§novel_settings§BT§(category=worldview,action=detect)扫描全书,按中西词表命中自动判断文化基准(western/eastern/mixed/unknown)并给出证据与置信度;
|
|
66
|
+
- **用语规范登记**:detect 后可用 §BT§novel_settings§BT§ add(category=worldview)登记:name(基准名)、basis(判断依据)、bannedWords(禁用词表)、recommended(替代词映射)、ritual(仪式规范,如"点烛不烧香");
|
|
67
|
+
- **续写前检查**:动笔前先确认 worldview(detect 或人工指定),对照 bannedWords/recommended 用词;
|
|
68
|
+
- **自动化审计**:§BT§novel_continuity_check§BT§ 会自动扫描禁用词命中,输出「用语冲突」候选(含建议替换词);
|
|
69
|
+
- **时代错置分类**(词表按此组织):器物 / 称谓 / 计量 / 宗教仪式 / 市井风貌 / 服饰 / 食物 / 制度;
|
|
70
|
+
- **参考资料**(写作时校对):
|
|
71
|
+
- Medieval Wordbook(Madeleine Pelner Cosman)
|
|
72
|
+
- The Writer's Complete Fantasy Reference
|
|
73
|
+
- Fantasy Writers Phrase Book(Jackson Dean Chase)
|
|
74
|
+
- 凌力谈历史小说语言:错用一个现代词会破坏整个历史氛围
|
package/test/e2e-test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 端到端测试:
|
|
1
|
+
// 端到端测试:10 工具注册、缓存、伏笔、风格自检、路由(v0.6.0)
|
|
2
2
|
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
@@ -21,7 +21,7 @@ apply(ctx, { root: testRoot, sentenceAnalysis: { enabled: true, autoAnalyze: tru
|
|
|
21
21
|
const defs = Object.fromEntries(registry.map((d) => [d.name, d]));
|
|
22
22
|
const names = registry.map((d) => d.name);
|
|
23
23
|
console.log("工具数:", names.length, names.join(", "));
|
|
24
|
-
if (names.length !== 13) throw new Error("expected 13 tools");
|
|
24
|
+
if (names.length !== 13) throw new Error("expected 13 tools (v0.8.0)");
|
|
25
25
|
|
|
26
26
|
const exec = { agent: { session: { header: { cwd: testRoot } } } };
|
|
27
27
|
|
|
@@ -54,7 +54,7 @@ console.log("DSH schema 规则检查: 全部通过");
|
|
|
54
54
|
|
|
55
55
|
// 1) 分析 + 缓存(第一次 miss,第二次 hit)
|
|
56
56
|
const first = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
57
|
-
console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("
|
|
57
|
+
console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("analysis") ? "✓ 新目录" : "✗ " + first.reportFile);
|
|
58
58
|
if (first.cache !== "miss") throw new Error("first should be miss");
|
|
59
59
|
const second = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
60
60
|
console.log("二次分析 cache:", second.cache, "| 指纹一致:", second.fingerprint === first.fingerprint);
|
|
@@ -89,6 +89,16 @@ const kw = await defs.novel_keywords.execute({ book: "测试", top: 20 }, exec);
|
|
|
89
89
|
console.log("关键词:", kw.keywords.map((k) => k.word + "(" + k.kind + ")").join(" "));
|
|
90
90
|
if (!kw.keywords.some((k) => k.kind === "name-candidate" && (k.word.includes("露西") || k.word.includes("琉璃")))) throw new Error("name candidate missing");
|
|
91
91
|
|
|
92
|
+
// 4.5) v0.9.0 世界观:detect 判断 + add 登记 + continuity 用语扫描
|
|
93
|
+
const det = await defs.novel_settings.execute({ book: "测试", action: "detect" }, exec);
|
|
94
|
+
console.log("worldview detect:", det.culture, det.confidence, "| 证据:", (det.evidence?.western ?? []).slice(0, 2).join(" "));
|
|
95
|
+
if (typeof det.culture !== "string") throw new Error("detect broken");
|
|
96
|
+
await defs.novel_settings.execute({ book: "测试", category: "worldview", action: "add", name: "欧式中世纪", basis: "教堂/神甫/马车", bannedWords: ["上香", "老夫"], recommended: { "上香": "点烛" }, ritual: "点烛不烧香" }, exec);
|
|
97
|
+
const wv = await defs.novel_settings.execute({ book: "测试", category: "worldview", action: "list" }, exec);
|
|
98
|
+
if (!wv.worldview.some((e) => e.name === "欧式中世纪")) throw new Error("worldview add broken");
|
|
99
|
+
const cont2 = await defs.novel_continuity_check.execute({ book: "测试" }, exec);
|
|
100
|
+
console.log("continuity 用语扫描:", cont2.candidates.filter((x) => x.type === "用语冲突").map((x) => x.detail.slice(0, 30)).join(" | ") || "(无)");
|
|
101
|
+
|
|
92
102
|
// 5) 路由 allowLan:非 loopback 同源放行
|
|
93
103
|
const handler = routes.find((r) => r.path === "/api/dsh-novel-writer/state").handler;
|
|
94
104
|
const res = { status: 0, body: "", writeHead(s) { this.status = s; }, end(b) { this.body = String(b); } };
|