@waterwx/dsh-novel-forge 1.1.2 → 1.1.4

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.
@@ -62,6 +62,17 @@ export declare class NovelApi {
62
62
  biblePatch(patch: import('../protocol.ts').BiblePatchRequest): Promise<{
63
63
  bible: import('../protocol.ts').StoryBible;
64
64
  }>;
65
+ /** 剧情线管理:增删改 + 关联章节。 */
66
+ plotlines(req: import('../protocol.ts').PlotlinesRequest): Promise<import('../protocol.ts').PlotlinesResponse>;
67
+ /** 敏感词检查:指定章节 / 任意文本 / 全书。 */
68
+ sensitiveCheck(req: import('../protocol.ts').SensitiveCheckRequest): Promise<import('../protocol.ts').SensitiveCheckResponse>;
69
+ /** 作者复盘补跑:单章(JSON)。 */
70
+ reviewBackfillChapter(no: number): Promise<{
71
+ no: number;
72
+ review: import('../protocol.ts').AuthorReview;
73
+ }>;
74
+ /** 作者复盘补跑:全书缺失章节(NDJSON 流)。 */
75
+ reviewBackfillAll(onFrame: (frame: JobFrame) => void): Promise<void>;
65
76
  /** 小说简介:AI 生成/补全(partial 留空 = 全量),或手动保存。 */
66
77
  blurb(action: 'generate' | 'save', text?: string, partial?: string): Promise<{
67
78
  blurb: string;
@@ -13,14 +13,14 @@ export declare const zh: {
13
13
  readonly 'common.success': "成功";
14
14
  readonly 'common.generating': "生成中…";
15
15
  readonly 'common.chars': "字";
16
- readonly 'tab.workflow': "工作流";
16
+ readonly 'tab.workflow': "工作台";
17
17
  readonly 'tab.overview': "总纲";
18
18
  readonly 'tab.plan': "章节";
19
19
  readonly 'tab.bible': "道藏";
20
20
  readonly 'tab.foreshadow': "暗线";
21
21
  readonly 'tab.assistant': "AI 助手";
22
22
  readonly 'tab.settings': "设置";
23
- readonly 'workflow.title': "创作工作流";
23
+ readonly 'workflow.title': "创作工作台";
24
24
  readonly 'workflow.step1': "① 加载总纲";
25
25
  readonly 'workflow.step2': "② 提炼道藏";
26
26
  readonly 'workflow.step3': "③ 规划卷";
@@ -99,17 +99,61 @@ export declare const zh: {
99
99
  readonly 'foreshadow.plantedAt': "埋设于";
100
100
  readonly 'foreshadow.setPlanted': "标记已埋设";
101
101
  readonly 'foreshadow.setResolved': "标记已回收";
102
+ readonly 'tab.facts': "编年录";
103
+ readonly 'tab.plotlines': "剧情线";
104
+ readonly 'plotlines.hint': "主线/支线/人物线/悬念线的推进管理:章节生成时会注入未完结的线,要求每章至少推进一条;关联章节记录推进节点。";
105
+ readonly 'plotlines.empty': "还没有剧情线——长篇小说建议先建主线与几条支线。";
106
+ readonly 'plotlines.new': "+ 新建剧情线";
107
+ readonly 'plotlines.name': "线名";
108
+ readonly 'plotlines.kind': "类型";
109
+ readonly 'plotlines.kindMain': "主线";
110
+ readonly 'plotlines.kindBranch': "支线";
111
+ readonly 'plotlines.kindCharacter': "人物线";
112
+ readonly 'plotlines.kindMystery': "悬念线";
113
+ readonly 'plotlines.goal': "目标/终点";
114
+ readonly 'plotlines.progress': "当前进度";
115
+ readonly 'plotlines.status': "状态";
116
+ readonly 'plotlines.statusActive': "推进中";
117
+ readonly 'plotlines.statusPaused': "暂停";
118
+ readonly 'plotlines.statusResolved': "已完结";
119
+ readonly 'plotlines.statusAbandoned': "已废弃";
120
+ readonly 'plotlines.save': "保存";
121
+ readonly 'plotlines.cancel': "取消";
122
+ readonly 'plotlines.edit': "编辑";
123
+ readonly 'plotlines.remove': "删除";
124
+ readonly 'plotlines.linkChapter': "关联本章";
125
+ readonly 'plotlines.chapters': "已关联";
126
+ readonly 'plotlines.workflowTitle': "剧情线进度";
127
+ readonly 'plotlines.workflowEmpty': "未建剧情线(去「剧情线」页创建)";
128
+ readonly 'sensitive.title': "敏感词检查";
129
+ readonly 'sensitive.scanAll': "🔞 全书敏感词检查";
130
+ readonly 'sensitive.hint': "内置违禁词库硬匹配(政治/擦边/暴力/辱骂/广告/其他),发布前建议检查;只做提示,是否修改由你判断。";
131
+ readonly 'sensitive.clean': "✅ 未命中任何违禁词(扫描 {n} 章)";
132
+ readonly 'sensitive.hits': "命中 {n} 处({chapters} 章受影响)";
133
+ readonly 'sensitive.goFix': "去修订";
134
+ readonly 'sensitive.fixPrefill': "请删除或替换正文中的违禁表达「{word}」(类别:{category}),用含蓄/间接的写法替代。";
135
+ readonly 'facts.title': "编年录 / 时间线({n} 条)";
136
+ readonly 'facts.hint': "每章生成后自动抽取「已确立事实」(人物状态/境界资源/关系变化/伏笔落地),最近 20 条注入后续章节生成,保证长期一致。";
137
+ readonly 'facts.backfill': "回填历史章节事实";
138
+ readonly 'facts.backfilled': "已从历史章节回填 {n} 条事实";
102
139
  readonly 'settings.title': "设置";
103
140
  readonly 'settings.outlinePath': "默认大纲路径";
104
141
  readonly 'settings.outputDir': "输出目录";
105
142
  readonly 'settings.provider': "模型提供商";
106
143
  readonly 'settings.model': "模型";
144
+ readonly 'settings.theme': "主题";
145
+ readonly 'settings.themeLiquid': "iOS 液态玻璃(绿)";
146
+ readonly 'settings.themeClassic': "经典毛玻璃(蓝)";
147
+ readonly 'settings.themeNeumorph': "新拟物(双阴影)";
148
+ readonly 'settings.themeHint': "存于浏览器本地,即时生效";
107
149
  readonly 'settings.chapterChars': "每章目标字数";
108
150
  readonly 'settings.maxTokens': "单章最大输出 tokens";
109
151
  readonly 'settings.reviewPassScore': "审稿通过分数(0-100)";
110
152
  readonly 'settings.editorFontSize': "编辑器字号(仅编辑页显示)";
111
153
  readonly 'settings.editorFontSizeHint': "存于浏览器本地,不影响其他设备";
112
154
  readonly 'settings.autoReview': "生成后自动审稿";
155
+ readonly 'settings.autoAuthorReview': "生成后自动作者复盘";
156
+ readonly 'settings.autoAuthorReviewHint': "复盘钩子兑现/结尾钩子/剧情线推进/连续性/节奏趋势,每章约 2000 token";
113
157
  readonly 'settings.save': "保存设置";
114
158
  readonly 'settings.saved': "设置已保存";
115
159
  readonly 'settings.openFolder': "打开输出文件夹";
@@ -1,7 +1,7 @@
1
1
  import type { NovelApi } from '../api.ts';
2
2
  import type { PanelController } from './controller.ts';
3
3
  /** The panel's tab identifiers. */
4
- export type NovelTab = 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings' | 'characters' | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle';
4
+ export type NovelTab = 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings' | 'characters' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle';
5
5
  /** Panel shell props. */
6
6
  export interface NovelPanelProps {
7
7
  /** The panel state owner (open/close/toggle). */
@@ -6,7 +6,7 @@
6
6
  * web-server dependencies), so routes stay thin and logic is testable.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
- import type { AuditIssue, ChapterPlan, Foreshadow, NovelConfig, ProjectState, ReviewReport, RoleStatusCard, StoryBible, Volume, WorldState } from './protocol.ts';
9
+ import type { AuditIssue, AuthorReview, ChapterPlan, Foreshadow, NovelConfig, Plotline, PlotlineHealthReport, PlotlinePlan, ProjectState, ReviewReport, RoleStatusCard, StoryBible, Volume, WorldState } from './protocol.ts';
10
10
  /** Project state file name inside the output dir. */
11
11
  export declare const PROJECT_FILE = "novel-project.json";
12
12
  /** Chapter output file name, e.g. 第001章_开篇.md */
@@ -17,6 +17,12 @@ export declare function inferBookName(outline: string): string;
17
17
  export declare function loadProject(outputDir: string): ProjectState | undefined;
18
18
  /** Persist the project state next to the chapters. */
19
19
  export declare function saveProject(outputDir: string, project: ProjectState): void;
20
+ /** 对一段文本做违禁词硬匹配,返回命中(词/类别/次数)。 */
21
+ export declare function checkSensitiveText(text: string): Array<{
22
+ word: string;
23
+ category: string;
24
+ count: number;
25
+ }>;
20
26
  /** List generated chapter files in the output dir (sorted). */
21
27
  export declare function listChapterFiles(outputDir: string): string[];
22
28
  /** Re-sync chapter status against files on disk (a file may exist without state). */
@@ -40,6 +46,18 @@ export declare function reviewChapter(ctx: Context, config: NovelConfig, project
40
46
  * 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
41
47
  */
42
48
  export declare function reviewChapterText(ctx: Context, config: NovelConfig, project: ProjectState, text: string): Promise<ReviewReport>;
49
+ /** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
50
+ export declare function authorReviewChapter(ctx: Context, config: NovelConfig, project: ProjectState, chapterNo: number, body: string, prevTail: string): Promise<AuthorReview>;
51
+ /** 复盘后自动关联:把本章号写入复盘标记推进的剧情线(按名称匹配,去重)。 */
52
+ export declare function autoLinkPlotlines(project: ProjectState, chapterNo: number, advancedLines: string[]): void;
53
+ /** AI 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
54
+ export declare function suggestPlotlines(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Plotline[]>;
55
+ /** AI 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
56
+ export declare function refreshPlotlineProgress(ctx: Context, config: NovelConfig, project: ProjectState, line: Plotline): Promise<string>;
57
+ /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
58
+ export declare function analyzePlotlineHealth(ctx: Context, config: NovelConfig, project: ProjectState): Promise<PlotlineHealthReport>;
59
+ /** ✨ AI 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
60
+ export declare function designPlotlinePlan(ctx: Context, config: NovelConfig, project: ProjectState, health?: PlotlineHealthReport): Promise<PlotlinePlan>;
43
61
  /**
44
62
  * Stream a chapter rewrite. With `target` (a passage of the body), only that
45
63
  * passage's paragraph is rewritten and spliced back — everything else stays
@@ -41,6 +41,8 @@ export interface Config {
41
41
  reviewPassScore?: number;
42
42
  /** Whether generation auto-runs review after writing. */
43
43
  autoReview?: boolean;
44
+ /** Whether generation auto-runs the author review (hook/continuity/trend). */
45
+ autoAuthorReview?: boolean;
44
46
  }
45
47
  export declare const Config: z<Config>;
46
48
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
@@ -53,6 +53,12 @@ export declare const NOVEL_API: {
53
53
  readonly world: "/api/dsh-novel-forge/world";
54
54
  /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
55
55
  readonly cover: "/api/dsh-novel-forge/blurb/cover";
56
+ /** 剧情线管理:增删改 + 关联章节。 */
57
+ readonly plotlines: "/api/dsh-novel-forge/plotlines";
58
+ /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
59
+ readonly reviewBackfill: "/api/dsh-novel-forge/review/backfill";
60
+ /** 敏感词检查:全书已写章节或指定文本。 */
61
+ readonly sensitiveCheck: "/api/dsh-novel-forge/sensitive-check";
56
62
  readonly config: "/api/dsh-novel-forge/config";
57
63
  readonly openFolder: "/api/dsh-novel-forge/open-folder";
58
64
  };
@@ -127,12 +133,33 @@ export interface ChapterPlan {
127
133
  summary?: string;
128
134
  /** Latest review report (present once reviewed). */
129
135
  review?: ReviewReport;
136
+ /** 作者复盘:钩子兑现/结尾钩子/剧情线推进/连续性/节奏趋势(生成后自动)。 */
137
+ authorReview?: AuthorReview;
130
138
  /**
131
139
  * 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
132
140
  * 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
133
141
  */
134
142
  pendingDraft?: string;
135
143
  }
144
+ /** 作者复盘:叙事结构层面的逐章检查(钩子/推进/连续性/趋势)。 */
145
+ export interface AuthorReview {
146
+ /** 上一章结尾钩子是否在本章兑现。 */
147
+ hookHonored: boolean;
148
+ /** 钩子兑现说明(未兑现时给出建议)。 */
149
+ hookNote: string;
150
+ /** 本章结尾钩子强度 0-10。 */
151
+ endingHook: number;
152
+ /** 剧情线推进情况(推进了哪条线/或未推进)。 */
153
+ plotlineProgress: string;
154
+ /** 结构化:本章推进的剧情线名称列表(与项目剧情线 name 精确匹配,复盘后自动关联章节)。 */
155
+ advancedLines?: string[];
156
+ /** 连续性检查(人物位置/时间/伤势/资源是否与上章衔接)。 */
157
+ continuity: string;
158
+ /** 近期节奏趋势提示(拖沓/爽点密度等)。 */
159
+ trend: string;
160
+ /** 复盘时间。 */
161
+ reviewedAt: string;
162
+ }
136
163
  /** One review finding. */
137
164
  export interface ReviewIssue {
138
165
  /** Severity: high = must fix, medium = should fix, low = suggestion. */
@@ -178,6 +205,8 @@ export interface CharacterCard {
178
205
  goals: string;
179
206
  /** Key relations to other characters. */
180
207
  relations: string;
208
+ /** 知情度:该角色已经知道的事实/秘密(未列出的信息该角色不知道)。 */
209
+ knowledge?: string[];
181
210
  }
182
211
  /** The structured story bible (worldbuilding extracted from the outline). */
183
212
  export interface StoryBible {
@@ -234,6 +263,94 @@ export interface AuditResponse {
234
263
  /** 质检时间。 */
235
264
  auditedAt: string;
236
265
  }
266
+ /** 一条剧情线(主线/支线/人物线/悬念线)。 */
267
+ export interface Plotline {
268
+ /** 稳定 id。 */
269
+ id: string;
270
+ /** 线名。 */
271
+ name: string;
272
+ /** 类型:主线 / 支线 / 人物线 / 悬念线。 */
273
+ kind: 'main' | 'branch' | 'character' | 'mystery';
274
+ /** 目标/终点(这条线最终要完成什么)。 */
275
+ goal: string;
276
+ /** 当前进度说明(最近推进到哪)。 */
277
+ progress: string;
278
+ /** 生命周期状态。 */
279
+ status: 'active' | 'paused' | 'resolved' | 'abandoned';
280
+ /** 关联章节号(推进/落地的章节)。 */
281
+ chapters: number[];
282
+ /** 创建时间。 */
283
+ createdAt: string;
284
+ }
285
+ /** POST /plotlines 请求:剧情线增删改 + 关联章节 + AI 辅助。 */
286
+ export interface PlotlinesRequest {
287
+ op: 'add' | 'update' | 'remove' | 'link' | 'suggest' | 'refresh' | 'health' | 'plan';
288
+ /** add / update 时传入的完整剧情线。 */
289
+ line?: Plotline;
290
+ /** remove / link / refresh 时的目标线 id。 */
291
+ id?: string;
292
+ /** link 时关联的章节号。 */
293
+ chapterNo?: number;
294
+ }
295
+ /** 剧情线健康检查报告。 */
296
+ export interface PlotlineHealthReport {
297
+ /** 是否需要新线(需要 / 暂不需要 / 再写 X 章后需要)。 */
298
+ verdict: string;
299
+ /** 建议添加新线的时机说明。 */
300
+ timing: string;
301
+ /** 依据(基于数据的理由,每条一句)。 */
302
+ reasons: string[];
303
+ /** 各线健康度。 */
304
+ lines: Array<{
305
+ name: string;
306
+ /** ok=健康 / warning=预警 / stale=搁置过久。 */
307
+ health: 'ok' | 'warning' | 'stale';
308
+ note: string;
309
+ }>;
310
+ }
311
+ /** AI 剧情方案:下一阶段目标 + 建议新线。 */
312
+ export interface PlotlinePlan {
313
+ /** 下一阶段(未来 5-10 章)剧情方向。 */
314
+ direction: string;
315
+ /** 建议的新线(可逐条采纳)。 */
316
+ suggestions: Plotline[];
317
+ }
318
+ /** POST /plotlines 响应。 */
319
+ export interface PlotlinesResponse {
320
+ plotlines: Plotline[];
321
+ /** op=suggest 时的 AI 建议候选线。 */
322
+ suggestions?: Plotline[];
323
+ /** op=health 时的健康检查报告。 */
324
+ health?: PlotlineHealthReport;
325
+ /** op=plan 时的剧情方案。 */
326
+ plan?: PlotlinePlan;
327
+ }
328
+ /** 一条敏感词命中。 */
329
+ export interface SensitiveHit {
330
+ /** 命中章节号(文本检测时为 0)。 */
331
+ chapterNo: number;
332
+ /** 命中的违禁词。 */
333
+ word: string;
334
+ /** 类别:政治 / 擦边 / 暴力 / 辱骂 / 广告 / 其他。 */
335
+ category: string;
336
+ /** 出现次数。 */
337
+ count: number;
338
+ }
339
+ /** POST /sensitive-check 请求:检测指定章节/任意文本/全书。 */
340
+ export interface SensitiveCheckRequest {
341
+ /** 检测该章正文。 */
342
+ chapterNo?: number;
343
+ /** 检测任意文本(优先于 chapterNo)。 */
344
+ text?: string;
345
+ /** 扫描全部已写章节。 */
346
+ all?: boolean;
347
+ }
348
+ /** POST /sensitive-check 响应。 */
349
+ export interface SensitiveCheckResponse {
350
+ hits: SensitiveHit[];
351
+ /** 参与扫描的章节数。 */
352
+ scannedChapters: number;
353
+ }
237
354
  /** 角色卡:角色当前状态(从事实库聚合)。 */
238
355
  export interface RoleStatusCard {
239
356
  name: string;
@@ -251,6 +368,8 @@ export interface BiblePatchRequest {
251
368
  worldRules?: string[];
252
369
  redLines?: string[];
253
370
  style?: string[];
371
+ /** 角色卡整体替换(人物志编辑知情度等)。 */
372
+ characters?: CharacterCard[];
254
373
  }
255
374
  /** POST /blurb 请求:AI 生成/补全或手动保存小说简介。 */
256
375
  export interface BlurbRequest {
@@ -351,6 +470,8 @@ export interface ProjectState {
351
470
  coverPath?: string;
352
471
  /** 大世界结构化数据(境界体系/区域/势力)。 */
353
472
  world?: WorldState;
473
+ /** 剧情线(主线/支线/人物线/悬念线)。 */
474
+ plotlines?: Plotline[];
354
475
  /** ISO timestamps. */
355
476
  createdAt: string;
356
477
  updatedAt: string;
@@ -373,6 +494,8 @@ export interface NovelConfig {
373
494
  reviewPassScore: number;
374
495
  /** Whether generation auto-runs review after writing. */
375
496
  autoReview: boolean;
497
+ /** Whether generation auto-runs the author review (hook/continuity/trend) after writing. */
498
+ autoAuthorReview: boolean;
376
499
  }
377
500
  /** GET /status response. */
378
501
  export interface StatusResponse {
@@ -453,6 +576,13 @@ export type JobFrame = {
453
576
  type: 'review';
454
577
  no: number;
455
578
  report: ReviewReport;
579
+ } | {
580
+ type: 'author-review';
581
+ no: number;
582
+ review: AuthorReview;
583
+ } | {
584
+ type: 'author-backfill-done';
585
+ count: number;
456
586
  } | {
457
587
  type: 'rewritten';
458
588
  no: number;
@@ -537,6 +667,7 @@ export interface ConfigPatch {
537
667
  maxTokens?: number;
538
668
  reviewPassScore?: number;
539
669
  autoReview?: boolean;
670
+ autoAuthorReview?: boolean;
540
671
  }
541
672
  /** One assistant conversation message (persisted per project). */
542
673
  export interface AssistantMessage {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@waterwx/dsh-novel-forge",
3
3
  "description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
4
- "version": "1.1.2",
4
+ "version": "1.1.4",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
package/src/assistant.ts CHANGED
@@ -39,7 +39,7 @@ export const ASSISTANT_HISTORY_FILE = 'novel-assistant.jsonl'
39
39
  const MAX_TOOL_ROUNDS = 6
40
40
 
41
41
  /** Max history messages kept in context (older ones summarized away). */
42
- const MAX_HISTORY_MESSAGES = 24
42
+ const MAX_HISTORY_MESSAGES = 18
43
43
 
44
44
  // ------------------------------------------------------------------ history
45
45
 
@@ -83,7 +83,7 @@ export function clearAssistantHistory(outputDir: string): void {
83
83
  function renderProjectSnapshot(project: ProjectState): string {
84
84
  const sections: string[] = []
85
85
  sections.push(`书名:${project.bookName}`)
86
- sections.push(`大纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`)
86
+ sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`)
87
87
  // 写作资产名称化(占位小,需要详情用 assets_status)。
88
88
  const assetNames: string[] = []
89
89
  if (project.assets?.genre !== undefined) assetNames.push(`题材:${project.assets.genre.name}`)
@@ -93,7 +93,7 @@ function renderProjectSnapshot(project: ProjectState): string {
93
93
  if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(' · ')}`)
94
94
  if (project.bible !== undefined) {
95
95
  const bible = project.bible
96
- sections.push('【设定圣经】')
96
+ sections.push('【道藏】')
97
97
  if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
98
98
  if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
99
99
  if (bible.characters.length > 0) {
@@ -141,19 +141,19 @@ function renderProjectSnapshot(project: ProjectState): string {
141
141
  }
142
142
  }
143
143
  if (project.foreshadows.length > 0) {
144
- sections.push('【伏笔】')
144
+ sections.push('【暗线】')
145
145
  for (const f of project.foreshadows) {
146
146
  sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
147
147
  }
148
148
  }
149
149
  if ((project.facts ?? []).length > 0) {
150
- sections.push('【已确立事实库(最近 40 条,回答设定问题必须遵守)】')
150
+ sections.push('【已确立编年录(最近 40 条,回答设定问题必须遵守)】')
151
151
  for (const f of (project.facts ?? []).slice(-40)) {
152
152
  sections.push(`- [第${f.chapterNo}章] ${f.text}`)
153
153
  }
154
154
  }
155
155
  if (project.blurb !== undefined && project.blurb !== '') {
156
- sections.push(`【小说简介】${project.blurb}`)
156
+ sections.push(`【卷首语】${project.blurb}`)
157
157
  }
158
158
  return sections.join('\n')
159
159
  }
@@ -165,33 +165,35 @@ function assistantSystemPrompt(project: ProjectState): string {
165
165
  '人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。',
166
166
  '座右铭:「书是你的,但坑我替你盯着。」',
167
167
  '职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。',
168
+ '==================== 模块正式名称(回复作者时一律使用,禁止使用括号里的旧称) ====================',
169
+ '总纲 = 总纲;道藏 = 道藏;暗线 = 暗线;卷首语 = 卷首语;编年录 = 编年录。',
168
170
  '==================== 当前项目快照 ====================',
169
171
  renderProjectSnapshot(project),
170
172
  '==================== 快照结束 ====================',
171
173
  '',
172
174
  '工作规则(严格遵守):',
173
- '1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(大纲全文/设定圣经/大世界/事实库/全部章节要点/伏笔/简介);需要某章正文用 chapter_text。',
175
+ '1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。',
174
176
  '2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
175
- '3. 连锁维护:改动可能波及其它位置(其它章节、设定、事实库、简介)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
177
+ '3. 连锁维护:改动可能波及其它位置(其它章节、设定、编年录、卷首语)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
176
178
  '4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
177
179
  '5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
178
- '6. 设定忠诚:忠于大纲、设定圣经、大世界、事实库;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
180
+ '6. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
179
181
  '7. 中文回复,简洁有干货。',
180
182
  '',
181
183
  '可用工具:',
182
- '- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(大纲/道藏/大世界/章节要点/事实库/伏笔/简介)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
183
- '- facts_query:{"keyword": "关键词"}。从编年录(事实库)按关键词检索相关事实(如灵石、境界名、人物名)。',
184
- '- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/事实库)。',
185
- '- outline_text:无参数。返回当前大纲全文。',
186
- '- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在大纲中替换一段文字(old 必须能在大纲中找到)。',
187
- '- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改设定圣经的世界规则。',
184
+ '- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
185
+ '- facts_query:{"keyword": "关键词"}。从编年录按关键词检索相关事实(如灵石、境界名、人物名)。',
186
+ '- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/编年录)。',
187
+ '- outline_text:无参数。返回当前总纲全文。',
188
+ '- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在总纲中替换一段文字(old 必须能在总纲中找到)。',
189
+ '- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改道藏的世界规则。',
188
190
  '- bible_set_redline:同上,修改写作红线。',
189
191
  '- chapter_text:{"no": 章节号}。返回该章正文。',
190
192
  '- chapter_rewrite:{"no": 章节号, "instructions": "修改要求", "target": "原文片段(可选,留空整章)"}。按讨论结果修订章节;给了 target 只改该自然段。',
191
193
  '- chapter_generate:{"no": 章节号}。重新生成该章。',
192
194
  '- chapter_review:{"no": 章节号}。对该章执行 AI 审稿。',
193
- '- foreshadow_add:{"description": "伏笔描述", "targetChapter": 预计回收章(可选)}。新增伏笔。',
194
- '- foreshadow_update:{"id": "伏笔id", "status": "planned|planted|progressing|resolved|abandoned"}。更新伏笔状态。',
195
+ '- foreshadow_add:{"description": "暗线描述", "targetChapter": 预计回收章(可选)}。新增暗线。',
196
+ '- foreshadow_update:{"id": "暗线id", "status": "planned|planted|progressing|resolved|abandoned"}。更新暗线状态。',
195
197
  '- export_txt:无参数。导出全本 TXT。',
196
198
  '- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
197
199
  '- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
@@ -199,25 +201,29 @@ function assistantSystemPrompt(project: ProjectState): string {
199
201
  '- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
200
202
  '',
201
203
  '回答质量要求(非常重要):',
202
- '- 具体:回答必须引用项目里的真实内容(人名、境界、章节、伏笔、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
204
+ '- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
203
205
  '- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。',
204
206
  '- 主动:作者说"改一下",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。',
205
- '- 忠于设定:以大纲、设定圣经、事实库为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
207
+ '- 忠于设定:以总纲、道藏、编年录为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
206
208
  '- 中文回复;文字量适中,别啰嗦。',
207
209
  '',
208
210
  '使用规则(非常重要):',
209
211
  '- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
210
212
  ' 正确示例:<dsh-action name="outline_replace">{"old":"要替换的原文","new":"新文本"}</dsh-action>',
211
213
  ' 正确示例:<dsh-action name="chapter_text">{"no":1}</dsh-action>',
212
- ' 错误示例(绝对不要这样回复):"好的,我先看一下大纲,马上改。" ← 这只是文字,不会执行任何操作',
214
+ ' 错误示例(绝对不要这样回复):"好的,我先看一下总纲,马上改。" ← 这只是文字,不会执行任何操作',
215
+ ' 错误示例(绝对不要这样回复):"先拉完整上下文确认改动落地情况,避免继续空转。" ← 没有动作标签,不会执行任何操作',
216
+ ' 错误示例(绝对不要这样回复):"现在处理暗线库,我先看当前列表定位 id。" ← 没有动作标签,不会执行任何操作',
217
+ '- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释"我要做什么"、禁止铺垫——直接输出标签。',
218
+ '- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。',
213
219
  '- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。',
214
220
  '- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。',
215
- '- 需要先看大纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
221
+ '- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
216
222
  '- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。',
217
223
  '- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
218
224
  '- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
219
225
  '- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
220
- '- 严格忠于设定圣经与大纲;不得自行发明与既有设定冲突的内容。',
226
+ '- 严格忠于道藏与总纲;不得自行发明与既有设定冲突的内容。',
221
227
  '- 用中文回复。',
222
228
  ].join('\n')
223
229
  }
@@ -260,7 +266,7 @@ export async function* executeAction(
260
266
  return bookOverview(project, scope)
261
267
  }
262
268
  case 'facts_query': {
263
- // 从编年录(事实库)按关键词检索相关事实。
269
+ // 从编年录按关键词检索相关事实。
264
270
  const keyword = str(args.keyword).trim()
265
271
  if (keyword === '') throw new Error('facts_query 需要 keyword')
266
272
  const hits = (project.facts ?? []).filter(f => f.text.includes(keyword)).slice(-30)
@@ -282,12 +288,12 @@ export async function* executeAction(
282
288
  const old = str(args.old)
283
289
  const next = str(args.new)
284
290
  if (old === '' || !project.outline.includes(old)) {
285
- throw new Error(`大纲中未找到片段「${old.slice(0, 40)}…」`)
291
+ throw new Error(`总纲中未找到片段「${old.slice(0, 40)}…」`)
286
292
  }
287
293
  project.outline = project.outline.replace(old, next)
288
294
  project.updatedAt = new Date().toISOString()
289
295
  saveProject(outputDir, project)
290
- return `大纲已修改:替换了 ${old.length} 字符的片段。`
296
+ return `总纲已修改:替换了 ${old.length} 字符的片段。`
291
297
  }
292
298
  case 'bible_set_rule': {
293
299
  if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
@@ -393,20 +399,20 @@ export async function* executeAction(
393
399
  })
394
400
  project.updatedAt = new Date().toISOString()
395
401
  saveProject(outputDir, project)
396
- return `已新增伏笔:「${description.slice(0, 50)}」`
402
+ return `已新增暗线:「${description.slice(0, 50)}」`
397
403
  }
398
404
  case 'foreshadow_update': {
399
405
  const id = str(args.id)
400
406
  const status = str(args.status) as 'planned' | 'planted' | 'progressing' | 'resolved' | 'abandoned'
401
407
  const target = project.foreshadows.find(f => f.id === id)
402
- if (target === undefined) throw new Error(`伏笔 ${id} 不存在`)
408
+ if (target === undefined) throw new Error(`暗线 ${id} 不存在`)
403
409
  if (!['planned', 'planted', 'progressing', 'resolved', 'abandoned'].includes(status)) {
404
410
  throw new Error(`非法状态 ${status}`)
405
411
  }
406
412
  target.status = status
407
413
  project.updatedAt = new Date().toISOString()
408
414
  saveProject(outputDir, project)
409
- return `伏笔已更新为 ${status}:「${target.description.slice(0, 50)}」`
415
+ return `暗线已更新为 ${status}:「${target.description.slice(0, 50)}」`
410
416
  }
411
417
  case 'export_txt': {
412
418
  const result = exportBook(outputDir, project, 'txt')
@@ -512,7 +518,7 @@ function historyToMessages(history: AssistantMessage[]): Message[] {
512
518
  source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
513
519
  }))
514
520
  } else if (entry.role === 'tool') {
515
- // 工具结果(book_overview/大纲全文等可能很大)截断后进上下文,
521
+ // 工具结果(book_overview/总纲全文等可能很大)截断后进上下文,
516
522
  // 避免多轮对话上下文翻倍膨胀触发「上下文到限制」。
517
523
  const body = entry.content.length > 2000
518
524
  ? entry.content.slice(0, 2000) + '\n…(结果过长已截断,需要完整内容请重新调用工具)'
@@ -534,6 +540,15 @@ async function chatOnce(
534
540
  history: AssistantMessage[],
535
541
  ): Promise<string> {
536
542
  const messages = historyToMessages(history)
543
+ // 纪律提醒贴在最后一条消息(当前用户输入)末尾:紧邻模型要生成回复的位置,
544
+ // 比放在 system 里更不容易被长上下文稀释(位置效应)。
545
+ const last = messages[messages.length - 1]
546
+ if (last?.role === 'user' && Array.isArray(last.content)) {
547
+ const text = (last.content as Array<{ type: string; text?: string }>).find(b => b.type === 'text')
548
+ if (text !== undefined && typeof text.text === 'string') {
549
+ text.text += '\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name="工具名">{"参数":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)'
550
+ }
551
+ }
537
552
  const request: GenerateOptions = {
538
553
  provider: config.provider,
539
554
  model: config.model,
@@ -590,20 +605,28 @@ export async function* runAssistantTurn(
590
605
  appendHistory(outputDir, userEntry)
591
606
 
592
607
  let round = 0
593
- /** Whether we already nudged the model to emit an action tag (avoid loops). */
594
- let nudged = false
608
+ /** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
609
+ let nudged = 0
610
+ const MAX_NUDGES = 6
595
611
  for (;;) {
612
+ if (round++ > 20) break
596
613
  const reply = await chatOnce(ctx, config, system, history)
597
614
  const action = extractAction(reply)
598
615
 
599
616
  if (action === undefined) {
600
617
  // No parseable action tag. If the reply clearly intends to modify
601
- // something (or contains a malformed action tag), nudge once.
602
- const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply)
618
+ // something (or contains a malformed action tag), nudge — repeatedly,
619
+ // with escalating firmness, until the model emits a tag or gives up.
620
+ const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply)
621
+ // 兜底:回复提到任何可操作对象名词(即使动词是"看/查/说"的变体)也视为有操作意图,
622
+ // 避免"先查一遍编年录"这类措辞漏网被静默当作纯聊天。
623
+ const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply)
603
624
  const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
604
- if ((intendsAction || strayTag) && !nudged) {
605
- nudged = true
606
- const nudge = '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
625
+ if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
626
+ const nudge = nudged === 0
627
+ ? '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
628
+ : `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`
629
+ nudged++
607
630
  history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
608
631
  appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
609
632
  continue