@waterwx/dsh-novel-forge 1.0.0 → 1.1.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/src/assistant.ts CHANGED
@@ -13,21 +13,22 @@
13
13
  * result as a tool-role message, and continues the loop (bounded rounds).
14
14
  */
15
15
 
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, appendFileSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
  import { createUserMessage, createAssistantMessage, BlockAssembler, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
19
19
  import type { Context } from '@deepseek-ai/cordis'
20
20
  import type { AssistantMessage, NovelConfig, ProjectState } from './protocol.ts'
21
21
  import { emptyProjectAssets } from './assets.ts'
22
22
  import {
23
+ analyzeImpact,
24
+ bookOverview,
23
25
  chapterFileName,
24
26
  exportBook,
25
- extractFacts,
26
27
  generateChapterStream,
27
28
  readChapterFile,
28
29
  reviewChapter,
29
- summarizeChapter,
30
30
  saveProject,
31
+ summarizeAndExtractFacts,
31
32
  } from './engine.ts'
32
33
  import { rewriteChapterStream } from './engine.ts'
33
34
 
@@ -59,10 +60,21 @@ export function loadAssistantHistory(outputDir: string): AssistantMessage[] {
59
60
  return messages
60
61
  }
61
62
 
62
- /** Append one message to the persisted history. */
63
+ /** Append one message to the persisted history. Tool payloads (e.g. full
64
+ * outline / chapter text) are capped so the jsonl and later LLM context
65
+ * don't grow unboundedly. */
63
66
  function appendHistory(outputDir: string, message: AssistantMessage): void {
64
67
  mkdirSync(outputDir, { recursive: true })
65
- appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(message) + '\n', 'utf8')
68
+ const entry: AssistantMessage = message.role === 'tool' && message.content.length > 4000
69
+ ? { ...message, content: message.content.slice(0, 4000) + '\n…(已截断,如需完整内容请重新调用工具)' }
70
+ : message
71
+ appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + '\n', 'utf8')
72
+ }
73
+
74
+ /** 清空助手对话记录(删除历史文件)。 */
75
+ export function clearAssistantHistory(outputDir: string): void {
76
+ const file = join(outputDir, ASSISTANT_HISTORY_FILE)
77
+ if (existsSync(file)) rmSync(file, { force: true })
66
78
  }
67
79
 
68
80
  // ----------------------------------------------------------------- context
@@ -71,6 +83,14 @@ function appendHistory(outputDir: string, message: AssistantMessage): void {
71
83
  function renderProjectSnapshot(project: ProjectState): string {
72
84
  const sections: string[] = []
73
85
  sections.push(`书名:${project.bookName}`)
86
+ sections.push(`大纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`)
87
+ // 写作资产名称化(占位小,需要详情用 assets_status)。
88
+ const assetNames: string[] = []
89
+ if (project.assets?.genre !== undefined) assetNames.push(`题材:${project.assets.genre.name}`)
90
+ if (project.assets?.primaryProgression !== undefined) assetNames.push(`主推进:${project.assets.primaryProgression.name}`)
91
+ if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets!.styleAssets!.map(s => s.name).join('、')}`)
92
+ if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets!.antiAiRules!.map(r => r.name).join('、')}`)
93
+ if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(' · ')}`)
74
94
  if (project.bible !== undefined) {
75
95
  const bible = project.bible
76
96
  sections.push('【设定圣经】')
@@ -85,6 +105,13 @@ function renderProjectSnapshot(project: ProjectState): string {
85
105
  }
86
106
  if (bible.redLines.length > 0) sections.push('写作红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
87
107
  }
108
+ if (project.world !== undefined) {
109
+ const world = project.world
110
+ sections.push('【大世界】')
111
+ if (world.realms.length > 0) sections.push('境界体系:' + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(' → '))
112
+ if (world.regions.length > 0) sections.push('区域:' + world.regions.map(r => r.name).join('、'))
113
+ if (world.factions.length > 0) sections.push('势力:' + world.factions.map(f => `${f.name}(${f.kind})`).join('、'))
114
+ }
88
115
  if (project.volumes !== undefined && project.volumes.length > 0) {
89
116
  sections.push('【卷结构】')
90
117
  for (const v of project.volumes) {
@@ -92,11 +119,26 @@ function renderProjectSnapshot(project: ProjectState): string {
92
119
  }
93
120
  }
94
121
  if (project.chapters.length > 0) {
95
- sections.push('【章节计划与进度】')
96
- for (const c of project.chapters) {
122
+ // 快照只列最近 30 章(长书时防止上下文膨胀;更早章节可用
123
+ // book_overview scope=volume:N 或 chapter_text 按需查看)。
124
+ const shown = project.chapters.slice(-30)
125
+ sections.push(`【章节计划与进度(最近 ${shown.length} 章)】`)
126
+ for (const c of shown) {
97
127
  const statusText = { pending: '待生成', generating: '生成中', written: '待审稿', reviewing: '审稿中', approved: '已通过', rejected: '待修订', error: '失败' }[c.status]
98
128
  sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== undefined ? ` ${c.chars}字` : ''}${c.summary !== undefined && c.summary !== '' ? ` 摘要:${c.summary}` : ''}`)
99
129
  }
130
+ if (project.chapters.length > shown.length) {
131
+ sections.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 book_overview scope=volume:N 查看)`)
132
+ }
133
+ // 最近两章正文节选(讨论细节时参考)
134
+ const written = project.chapters.filter(c => c.status === 'approved' || c.status === 'written')
135
+ const recent = written.slice(-2)
136
+ if (recent.length > 0) {
137
+ sections.push('【最近章节正文节选】')
138
+ for (const c of recent) {
139
+ sections.push(`第${c.no}章《${c.title}》(节选,如需全文用 chapter_text):${c.beats.slice(0, 300)}`)
140
+ }
141
+ }
100
142
  }
101
143
  if (project.foreshadows.length > 0) {
102
144
  sections.push('【伏笔】')
@@ -104,23 +146,42 @@ function renderProjectSnapshot(project: ProjectState): string {
104
146
  sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
105
147
  }
106
148
  }
149
+ if ((project.facts ?? []).length > 0) {
150
+ sections.push('【已确立事实库(最近 40 条,回答设定问题必须遵守)】')
151
+ for (const f of (project.facts ?? []).slice(-40)) {
152
+ sections.push(`- [第${f.chapterNo}章] ${f.text}`)
153
+ }
154
+ }
155
+ if (project.blurb !== undefined && project.blurb !== '') {
156
+ sections.push(`【小说简介】${project.blurb}`)
157
+ }
107
158
  return sections.join('\n')
108
159
  }
109
160
 
110
161
  /** The assistant system prompt. */
111
162
  function assistantSystemPrompt(project: ProjectState): string {
112
163
  return [
113
- '你是这部小说的 AI 编辑助理,负责陪作者讨论剧情、人设、世界观,并把讨论结果落实到项目里。',
164
+ '你是「编辑老师」——服务这本书作者的资深中文网文编辑。',
165
+ '人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。',
166
+ '座右铭:「书是你的,但坑我替你盯着。」',
167
+ '职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。',
114
168
  '==================== 当前项目快照 ====================',
115
169
  renderProjectSnapshot(project),
116
170
  '==================== 快照结束 ====================',
117
171
  '',
118
- '你可以:',
119
- '1. 与作者讨论剧情走向、人物动机、爽点节奏、伏笔安排等,给出专业建议(直接文字回答)。',
120
- '2. 讨论达成一致后,用动作指令实际修改内容。动作指令格式(放在回复末尾单独一行):',
121
- ' <dsh-action name="工具名">{"参数名": 值}</dsh-action>',
172
+ '工作规则(严格遵守):',
173
+ '1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(大纲全文/设定圣经/大世界/事实库/全部章节要点/伏笔/简介);需要某章正文用 chapter_text。',
174
+ '2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
175
+ '3. 连锁维护:改动可能波及其它位置(其它章节、设定、事实库、简介)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
176
+ '4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
177
+ '5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
178
+ '6. 设定忠诚:忠于大纲、设定圣经、大世界、事实库;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
179
+ '7. 中文回复,简洁有干货。',
122
180
  '',
123
181
  '可用工具:',
182
+ '- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(大纲/道藏/大世界/章节要点/事实库/伏笔/简介)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
183
+ '- facts_query:{"keyword": "关键词"}。从编年录(事实库)按关键词检索相关事实(如灵石、境界名、人物名)。',
184
+ '- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/事实库)。',
124
185
  '- outline_text:无参数。返回当前大纲全文。',
125
186
  '- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在大纲中替换一段文字(old 必须能在大纲中找到)。',
126
187
  '- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改设定圣经的世界规则。',
@@ -135,7 +196,14 @@ function assistantSystemPrompt(project: ProjectState): string {
135
196
  '- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
136
197
  '- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
137
198
  '- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
138
- '- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)"}。新增反 AI 规则。',
199
+ '- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
200
+ '',
201
+ '回答质量要求(非常重要):',
202
+ '- 具体:回答必须引用项目里的真实内容(人名、境界、章节、伏笔、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
203
+ '- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。',
204
+ '- 主动:作者说"改一下",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。',
205
+ '- 忠于设定:以大纲、设定圣经、事实库为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
206
+ '- 中文回复;文字量适中,别啰嗦。',
139
207
  '',
140
208
  '使用规则(非常重要):',
141
209
  '- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
@@ -149,7 +217,7 @@ function assistantSystemPrompt(project: ProjectState): string {
149
217
  '- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
150
218
  '- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
151
219
  '- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
152
- '- 严格忠于设定圣经与大刚;不得自行发明与既有设定冲突的内容。',
220
+ '- 严格忠于设定圣经与大纲;不得自行发明与既有设定冲突的内容。',
153
221
  '- 用中文回复。',
154
222
  ].join('\n')
155
223
  }
@@ -181,6 +249,32 @@ export async function* executeAction(
181
249
  }
182
250
 
183
251
  switch (name) {
252
+ case 'book_overview': {
253
+ // 全书上下文包(分片:默认最近 30 章;scope=full 全量;scope=volume:N 指定卷)。
254
+ const scopeArg = str(args.scope)
255
+ const scope = scopeArg === 'full'
256
+ ? 'full' as const
257
+ : /^volume:(\d+)$/.test(scopeArg)
258
+ ? Number(scopeArg.slice(7))
259
+ : 'recent' as const
260
+ return bookOverview(project, scope)
261
+ }
262
+ case 'facts_query': {
263
+ // 从编年录(事实库)按关键词检索相关事实。
264
+ const keyword = str(args.keyword).trim()
265
+ if (keyword === '') throw new Error('facts_query 需要 keyword')
266
+ const hits = (project.facts ?? []).filter(f => f.text.includes(keyword)).slice(-30)
267
+ if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`
268
+ return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map(f => `- [第${f.chapterNo}章] ${f.text}`).join('\n')
269
+ }
270
+ case 'impact_analysis': {
271
+ const change = str(args.change)
272
+ if (change === '') throw new Error('impact_analysis 需要 change(要做的修改描述)')
273
+ const items = await analyzeImpact(ctx, config, project, outputDir, change)
274
+ if (items.length === 0) return '影响分析:未发现需要同步修改的位置。'
275
+ const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}(${it.kind === 'must' ? '必须同步' : it.kind === 'optional' ? '建议' : '备注'})`)
276
+ return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join('\n')}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`
277
+ }
184
278
  case 'outline_text': {
185
279
  return project.outline
186
280
  }
@@ -196,7 +290,7 @@ export async function* executeAction(
196
290
  return `大纲已修改:替换了 ${old.length} 字符的片段。`
197
291
  }
198
292
  case 'bible_set_rule': {
199
- if (project.bible === undefined) throw new Error('尚无设定圣经,请先提炼')
293
+ if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
200
294
  const index = num(args.index)
201
295
  if (index !== undefined) {
202
296
  project.bible.worldRules[index] = str(args.text)
@@ -210,7 +304,7 @@ export async function* executeAction(
210
304
  return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`
211
305
  }
212
306
  case 'bible_set_redline': {
213
- if (project.bible === undefined) throw new Error('尚无设定圣经,请先提炼')
307
+ if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
214
308
  const index = num(args.index)
215
309
  if (index !== undefined) {
216
310
  project.bible.redLines[index] = str(args.text)
@@ -258,10 +352,10 @@ export async function* executeAction(
258
352
  chapter.error = undefined
259
353
  project.updatedAt = new Date().toISOString()
260
354
  saveProject(outputDir, project)
261
- yield '(已采纳修订稿,正在生成章节摘要…)'
355
+ yield '(已采纳修订稿,正在生成章节摘要与编年录…)'
262
356
  try {
263
- await summarizeChapter(ctx, config, project, outputDir, no)
264
- } catch { /* summary is best-effort */ }
357
+ await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
358
+ } catch { /* best-effort */ }
265
359
  yield '(正在 AI 审稿…)'
266
360
  const report = await reviewChapter(ctx, config, project, outputDir, no)
267
361
  return `章节 ${no} 已${target === '' ? '整章' : '局部'}修订完成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。重新审稿:${report.score} 分 — ${report.verdict}`
@@ -272,12 +366,9 @@ export async function* executeAction(
272
366
  for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) {
273
367
  yield chunk
274
368
  }
275
- yield '(正在生成章节摘要…)'
276
- try {
277
- await summarizeChapter(ctx, config, project, outputDir, no)
278
- } catch { /* best-effort */ }
369
+ yield '(正在生成章节摘要与编年录…)'
279
370
  try {
280
- await extractFacts(ctx, config, project, outputDir, no)
371
+ await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
281
372
  } catch { /* best-effort */ }
282
373
  yield '(正在 AI 审稿…)'
283
374
  const report = await reviewChapter(ctx, config, project, outputDir, no)
@@ -390,18 +481,19 @@ export async function* executeAction(
390
481
 
391
482
  // ------------------------------------------------------------------- chat
392
483
 
393
- /** Extract the first action directive from a reply. */
484
+ /** Extract the first action directive from a reply (tolerant to common tag misspellings). */
394
485
  function extractAction(reply: string): { name: string; args: Record<string, unknown>; index: number } | undefined {
395
- const match = /<dsh-action\s+name="([^"]+)"\s*>([\s\S]*?)<\/dsh-action>/.exec(reply)
486
+ // 容错:dsh-action 的常见误拼(dash-action / dsah-action / dhs-action 等)。
487
+ const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply)
396
488
  if (match === null) return undefined
397
- const rawArgs = match[2]?.trim() ?? ''
489
+ const rawArgs = match[3]?.trim() ?? ''
398
490
  let args: Record<string, unknown>
399
491
  try {
400
492
  args = rawArgs === '' ? {} : JSON.parse(rawArgs) as Record<string, unknown>
401
493
  } catch {
402
494
  throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`)
403
495
  }
404
- return { name: match[1] ?? '', args, index: match.index }
496
+ return { name: match[2] ?? '', args, index: match.index }
405
497
  }
406
498
 
407
499
  /** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
@@ -420,8 +512,13 @@ function historyToMessages(history: AssistantMessage[]): Message[] {
420
512
  source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
421
513
  }))
422
514
  } else if (entry.role === 'tool') {
515
+ // 工具结果(book_overview/大纲全文等可能很大)截断后进上下文,
516
+ // 避免多轮对话上下文翻倍膨胀触发「上下文到限制」。
517
+ const body = entry.content.length > 2000
518
+ ? entry.content.slice(0, 2000) + '\n…(结果过长已截断,需要完整内容请重新调用工具)'
519
+ : entry.content
423
520
  messages.push(createUserMessage({
424
- content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${entry.content}` }],
521
+ content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${body}` }],
425
522
  source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
426
523
  }))
427
524
  }
@@ -442,7 +539,8 @@ async function chatOnce(
442
539
  model: config.model,
443
540
  messages,
444
541
  system,
445
- maxTokens: config.maxTokens,
542
+ // v4-flash 推理模型:reasoning channel 占预算,给足避免回复被截断。
543
+ maxTokens: Math.max(config.maxTokens, 16000),
446
544
  temperature: 0.7,
447
545
  }
448
546
  const assembler = new BlockAssembler()
@@ -499,12 +597,13 @@ export async function* runAssistantTurn(
499
597
  const action = extractAction(reply)
500
598
 
501
599
  if (action === undefined) {
502
- // No action tag. If the reply clearly intends to modify something but
503
- // forgot the tag, nudge once and continue; otherwise it's plain prose.
600
+ // No parseable action tag. If the reply clearly intends to modify
601
+ // something (or contains a malformed action tag), nudge once.
504
602
  const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply)
505
- if (intendsAction && !nudged) {
603
+ const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
604
+ if ((intendsAction || strayTag) && !nudged) {
506
605
  nudged = true
507
- const nudge = '你的上一条回复表达了想操作项目的意图(如查看/修改大纲、章节等),但没有输出动作指令标签,因此没有执行任何操作。请直接输出 <dsh-action name="工具名">{"参数":值}</dsh-action> 标签来执行,不要用文字描述意图。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
606
+ const nudge = '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
508
607
  history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
509
608
  appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
510
609
  continue
package/src/bookshelf.ts CHANGED
@@ -57,11 +57,14 @@ export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
57
57
  books: store.books.map(book => {
58
58
  const project = loadProject(book.outputDir)
59
59
  const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
60
+ const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
60
61
  return {
61
62
  ...book,
62
63
  done,
63
64
  total: project?.chapters.length ?? 0,
64
65
  hasProject: project !== undefined,
66
+ hasCover,
67
+ blurb: project?.blurb,
65
68
  }
66
69
  }),
67
70
  activeBookId: store.activeBookId,
package/src/client/api.ts CHANGED
@@ -108,6 +108,16 @@ export class NovelApi {
108
108
  return readJson<ChapterResponse>(response)
109
109
  }
110
110
 
111
+ /** 审查手动编辑的正文(不落盘)。 */
112
+ async chapterCheck(no: number, text: string): Promise<{ report: ReviewReport }> {
113
+ return postJson<{ report: ReviewReport }>(NOVEL_API.chapterCheck, { chapterNo: no, text })
114
+ }
115
+
116
+ /** 保存手动编辑的正文(自动备份 .bak;带报告则沿用落盘,否则保存后自动审稿)。 */
117
+ async chapterSave(no: number, text: string, report?: ReviewReport): Promise<import('../protocol.ts').ChapterSaveResponse> {
118
+ return postJson<import('../protocol.ts').ChapterSaveResponse>(NOVEL_API.chapterSave, { chapterNo: no, text, report })
119
+ }
120
+
111
121
  async patchConfig(patch: ConfigPatch): Promise<{ config: NovelConfig }> {
112
122
  return postJson<{ config: NovelConfig }>(NOVEL_API.config, patch)
113
123
  }
@@ -152,6 +162,33 @@ export class NovelApi {
152
162
  return postJson<{ bible: import('../protocol.ts').StoryBible }>(NOVEL_API.biblePatch, patch)
153
163
  }
154
164
 
165
+ /** 小说简介:AI 生成/补全(partial 留空 = 全量),或手动保存。 */
166
+ async blurb(action: 'generate' | 'save', text?: string, partial?: string): Promise<{ blurb: string }> {
167
+ return postJson<{ blurb: string }>(NOVEL_API.blurb, { action, text, partial })
168
+ }
169
+
170
+ /** 封面:读取(dataUrl;dir 指定某本书的输出目录,省略为当前书)。 */
171
+ async coverGet(dir?: string): Promise<import('../protocol.ts').CoverResponse> {
172
+ const query = dir !== undefined ? `?dir=${encodeURIComponent(dir)}` : ''
173
+ const response = await fetch(NOVEL_API.cover + query)
174
+ return readJson<import('../protocol.ts').CoverResponse>(response)
175
+ }
176
+
177
+ /** 封面:上传(base64 data URL)或移除。 */
178
+ async coverPost(action: 'upload' | 'remove', dataUrl?: string): Promise<{ ok: boolean; coverPath?: string | null }> {
179
+ return postJson<{ ok: boolean; coverPath?: string | null }>(NOVEL_API.cover, { action, dataUrl })
180
+ }
181
+
182
+ /** 重命名当前书(同步项目与书架条目)。 */
183
+ async rename(bookName: string): Promise<{ bookName: string }> {
184
+ return postJson<{ bookName: string }>(NOVEL_API.rename, { bookName })
185
+ }
186
+
187
+ /** 大世界:AI 提炼(generate)或手动保存(save)。 */
188
+ async world(action: 'generate' | 'save', world?: import('../protocol.ts').WorldState): Promise<{ world: import('../protocol.ts').WorldState }> {
189
+ return postJson<{ world: import('../protocol.ts').WorldState }>(NOVEL_API.world, { action, world })
190
+ }
191
+
155
192
  /** 切换当前书。 */
156
193
  async bookActivate(id: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
157
194
  return postJson<import('../protocol.ts').BookshelfSnapshot>('/api/dsh-novel-forge/bookshelf/activate', { id })
@@ -286,4 +323,9 @@ export class NovelApi {
286
323
  const body = await readJson<{ messages: import('../protocol.ts').AssistantMessage[] }>(response)
287
324
  return body.messages
288
325
  }
326
+
327
+ /** 清空助手对话记录。 */
328
+ async assistantClear(): Promise<{ ok: boolean }> {
329
+ return postJson<{ ok: boolean }>(NOVEL_API.assistantClear, {})
330
+ }
289
331
  }
@@ -5,7 +5,7 @@
5
5
  /** zh dictionary. */
6
6
  export const zh = {
7
7
  'entry.label': '小说工坊',
8
- 'entry.tooltip': 'AI 编译小说工作台:大纲 → 设定圣经 → 卷计划 → 章节计划 → 逐章生成+审稿',
8
+ 'entry.tooltip': 'AI 编译小说工作台:总纲 → 道藏 → 卷计划 → 章节计划 → 逐章生成+审稿',
9
9
  'panel.title': '小说工坊',
10
10
  'common.close': '关闭',
11
11
  'common.loading': '加载中…',
@@ -15,36 +15,36 @@ export const zh = {
15
15
  'common.generating': '生成中…',
16
16
  'common.chars': '字',
17
17
  'tab.workflow': '工作流',
18
- 'tab.overview': '大纲',
18
+ 'tab.overview': '总纲',
19
19
  'tab.plan': '章节',
20
- 'tab.bible': '设定库',
21
- 'tab.foreshadow': '伏笔',
20
+ 'tab.bible': '道藏',
21
+ 'tab.foreshadow': '暗线',
22
22
  'tab.assistant': 'AI 助手',
23
23
  'tab.settings': '设置',
24
24
  'workflow.title': '创作工作流',
25
- 'workflow.step1': '① 加载大纲',
26
- 'workflow.step2': '② 提炼设定圣经',
25
+ 'workflow.step1': '① 加载总纲',
26
+ 'workflow.step2': '② 提炼道藏',
27
27
  'workflow.step3': '③ 规划卷',
28
28
  'workflow.step4': '④ 生成章节计划',
29
29
  'workflow.step5': '⑤ 逐章写作 + AI 审稿',
30
30
  'workflow.step6': '⑥ 润色 / 导出',
31
- 'workflow.loadOutline': '读取大纲',
32
- 'workflow.genBible': '提炼设定圣经',
31
+ 'workflow.loadOutline': '读取总纲',
32
+ 'workflow.genBible': '提炼道藏',
33
33
  'workflow.genVolumes': '生成卷计划',
34
34
  'workflow.genPlan': '生成章节计划',
35
35
  'workflow.done': '已完成',
36
36
  'workflow.todo': '待办',
37
- 'workflow.bibleDone': '设定圣经已生成({n} 条规则 / {c} 个角色 / {r} 条红线)',
37
+ 'workflow.bibleDone': '道藏已生成({n} 条规则 / {c} 个人物 / {r} 条红线)',
38
38
  'workflow.volumesDone': '卷计划已生成({n} 卷)',
39
39
  'workflow.planDone': '章节计划已生成({n} 章)',
40
- 'workflow.progress': '进度:大纲 ✓ · 设定 {bible} · 卷 {volumes} · 计划 {plan} · 已完成 {done}/{total} 章',
41
- 'overview.loadDocx': '从 docx 读取大纲',
42
- 'overview.loadDocxDefault': '读取默认大纲',
40
+ 'workflow.progress': '进度:总纲 ✓ · 道藏 {bible} · 卷 {volumes} · 计划 {plan} · 已完成 {done}/{total} 章',
41
+ 'overview.loadDocx': '从 docx 读取总纲',
42
+ 'overview.loadDocxDefault': '读取默认总纲',
43
43
  'overview.loadingOutline': '正在解析 docx…',
44
- 'overview.outlineHint': '大纲文本(可编辑)',
45
- 'overview.outlineChars': '大纲字数',
46
- 'overview.saveOutline': '保存大纲',
47
- 'overview.saved': '大纲已保存',
44
+ 'overview.outlineHint': '总纲文本(可编辑)',
45
+ 'overview.outlineChars': '总纲字数',
46
+ 'overview.saveOutline': '保存总纲',
47
+ 'overview.saved': '总纲已保存',
48
48
  'overview.bookName': '书名',
49
49
  'overview.loadCustom': '指定 docx 路径',
50
50
  'overview.loadCustomHint': '绝对路径,留空使用默认',
@@ -79,17 +79,17 @@ export const zh = {
79
79
  'plan.summary': '章节摘要',
80
80
  'plan.volumes': '卷',
81
81
  'plan.noVolume': '未分卷',
82
- 'bible.title': '设定圣经',
83
- 'bible.gen': 'AI 提炼设定圣经',
82
+ 'bible.title': '道藏',
83
+ 'bible.gen': 'AI 提炼道藏',
84
84
  'bible.genre': '题材基调',
85
85
  'bible.worldRules': '世界规则',
86
- 'bible.characters': '角色卡',
86
+ 'bible.characters': '人物志',
87
87
  'bible.redLines': '写作红线',
88
88
  'bible.style': '风格要求',
89
- 'bible.none': '尚未生成设定圣经。生成后写作会严格遵守人设与金手指规则,审稿也会按红线检查。',
90
- 'foreshadow.title': '伏笔管理',
91
- 'foreshadow.suggest': 'AI 建议伏笔',
92
- 'foreshadow.none': '暂无伏笔',
89
+ 'bible.none': '尚未生成道藏。生成后写作会严格遵守人设与金手指规则,审稿也会按红线检查。',
90
+ 'foreshadow.title': '暗线管理',
91
+ 'foreshadow.suggest': 'AI 建议暗线',
92
+ 'foreshadow.none': '暂无暗线',
93
93
  'foreshadow.status': '状态',
94
94
  'foreshadow.planned': '计划中',
95
95
  'foreshadow.planted': '已埋设',
@@ -123,7 +123,7 @@ export const zh = {
123
123
  'progress.rewriting': '正在修订第 {no} 章…',
124
124
  'progress.polishing': '正在润色第 {no} 章…',
125
125
  'progress.empty': '生成/审稿进度将显示在这里',
126
- 'assistant.hint': '和 AI 编辑讨论剧情、人设、伏笔;达成一致后可让它直接修改大纲、设定圣经、章节内容。',
126
+ 'assistant.hint': '和 AI 编辑讨论剧情、人设、暗线;达成一致后可让它直接修改总纲、道藏、章节内容。',
127
127
  'assistant.placeholder': '例如:我想让第 2 章结尾加一个悬念——墟境里传来爷爷的声音…',
128
128
  'assistant.send': '发送',
129
129
  'assistant.toolStart': '⚙ 执行操作:{name}…',
@@ -51,9 +51,9 @@ type AssetSubTab = 'genre' | 'progression' | 'templates' | 'rules' | 'style'
51
51
  const SUB_TABS: ReadonlyArray<{ id: AssetSubTab; label: string }> = [
52
52
  { id: 'genre', label: '题材基底' },
53
53
  { id: 'progression', label: '推进模式' },
54
- { id: 'templates', label: '预置写法' },
55
- { id: 'rules', label: '反 AI 规则' },
56
- { id: 'style', label: '自定义写法' },
54
+ { id: 'templates', label: '笔法帖' },
55
+ { id: 'rules', label: '文戒' },
56
+ { id: 'style', label: '心法' },
57
57
  ]
58
58
 
59
59
  /** 写作资产页签。 */
@@ -225,33 +225,16 @@ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
225
225
  <span className={css.assetStatLabel}>已绑定写法</span>
226
226
  <span className={css.assetStatValue}>{assets.styleAssets?.length ?? 0} 套</span>
227
227
  <span className={css.assetStatDetail} title={(assets.styleAssets ?? []).map(s => s.name).join('、')}>
228
- {(assets.styleAssets ?? []).map(s => s.name).join('、') || '未绑定(可在「预置写法」一键选用)'}
228
+ {(assets.styleAssets ?? []).map(s => s.name).join('、') || '未绑定(可在「笔法帖」一键选用)'}
229
229
  </span>
230
230
  </div>
231
231
  <div className={css.assetStat}>
232
- <span className={css.assetStatLabel}>反 AI 规则</span>
232
+ <span className={css.assetStatLabel}>文戒</span>
233
233
  <span className={css.assetStatValue}>{builtinRules.length} 内置 + {(assets.antiAiRules ?? []).length} 自定义</span>
234
234
  <span className={css.assetStatDetail}>全部生效于生成与审稿提示词</span>
235
235
  </div>
236
236
  </div>
237
237
 
238
- {/* 子页签栏 */}
239
- <div className={css.tabBar} role="tablist" style={{ padding: '0 0 8px', borderBottom: '1px solid var(--nf-border)' }}>
240
- {SUB_TABS.map(tab => (
241
- <button
242
- key={tab.id}
243
- type="button"
244
- role="tab"
245
- aria-selected={assetTab === tab.id}
246
- data-active={assetTab === tab.id ? '' : undefined}
247
- className={css.tab}
248
- onClick={() => { setAssetTab(tab.id) }}
249
- >
250
- {tab.label}
251
- </button>
252
- ))}
253
- </div>
254
-
255
238
  {/* 题材基底库 */}
256
239
  {assetTab === 'genre' && (
257
240
  <div className={css.card}>
@@ -300,10 +283,10 @@ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
300
283
  </div>
301
284
  )}
302
285
 
303
- {/* 预置写法模板(一键绑定) */}
286
+ {/* 笔法帖(一键绑定) */}
304
287
  {assetTab === 'templates' && (
305
288
  <div className={css.card}>
306
- <span className={css.cardTitle}>预置写法模板</span>
289
+ <span className={css.cardTitle}>笔法帖</span>
307
290
  <span className={css.meta}>从内置 8 套叙事风格模板中一键选用(来自 AI-Novel-Writing-Assistant 写法引擎),无需样本文本;绑定后生成与润色都遵循该风格。</span>
308
291
  <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 420, overflowY: 'auto' }}>
309
292
  {data.styleTemplates.map(template => {
@@ -340,10 +323,10 @@ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
340
323
  </div>
341
324
  )}
342
325
 
343
- {/* 反 AI 规则 */}
326
+ {/* 文戒 */}
344
327
  {assetTab === 'rules' && (
345
328
  <div className={css.card}>
346
- <span className={css.cardTitle}>反 AI 规则</span>
329
+ <span className={css.cardTitle}>文戒</span>
347
330
  <span className={css.meta}>写作时必须遵守的表达边界(内置全局 + 项目自定义),生成与审稿都会检查。内置规则可用「覆盖编辑」复制为自定义版本调整。</span>
348
331
  <div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
349
332
  {builtinRules.map(rule => {
@@ -418,10 +401,10 @@ export function AssetsTab({ api, initialTab = 'genre' }: AssetsTabProps) {
418
401
  </div>
419
402
  )}
420
403
 
421
- {/* 自定义写法引擎 */}
404
+ {/* 心法(自定义写法引擎) */}
422
405
  {assetTab === 'style' && (
423
406
  <div className={css.card}>
424
- <span className={css.cardTitle}>自定义写法引擎</span>
407
+ <span className={css.cardTitle}>心法</span>
425
408
  <span className={css.meta}>粘贴一段你喜欢的样本文本,AI 提取叙事风格规则并绑定到本书,后续章节保持同一味道。</span>
426
409
  {assets.styleAssets.map(style => (
427
410
  <div key={style.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 6, padding: '6px 10px', fontSize: 12 }}>