@waterwx/dsh-novel-forge 0.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.
Files changed (50) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +180 -0
  3. package/cordis.patch.yml +13 -0
  4. package/lib/client.js +3924 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +3120 -0
  7. package/lib/index.js.map +1 -0
  8. package/lib/types/assets.d.ts +35 -0
  9. package/lib/types/assistant.d.ts +43 -0
  10. package/lib/types/bookshelf.d.ts +35 -0
  11. package/lib/types/client/api.d.ts +68 -0
  12. package/lib/types/client/docx.d.ts +15 -0
  13. package/lib/types/client/index.d.ts +14 -0
  14. package/lib/types/client/locales.d.ts +139 -0
  15. package/lib/types/client/mount.d.ts +9 -0
  16. package/lib/types/client/panel/AssetsTab.d.ts +7 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +7 -0
  18. package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
  19. package/lib/types/client/panel/NovelPanel.d.ts +13 -0
  20. package/lib/types/client/panel/controller.d.ts +19 -0
  21. package/lib/types/client/panel/helpers.d.ts +8 -0
  22. package/lib/types/client/sidebar-entry.d.ts +13 -0
  23. package/lib/types/docx.d.ts +19 -0
  24. package/lib/types/engine.d.ts +95 -0
  25. package/lib/types/index.d.ts +55 -0
  26. package/lib/types/protocol.d.ts +521 -0
  27. package/lib/types/routes.d.ts +29 -0
  28. package/package.json +105 -0
  29. package/src/assets.ts +518 -0
  30. package/src/assistant.ts +547 -0
  31. package/src/bookshelf.ts +137 -0
  32. package/src/client/api.ts +254 -0
  33. package/src/client/css-modules.d.ts +8 -0
  34. package/src/client/docx.ts +69 -0
  35. package/src/client/index.ts +34 -0
  36. package/src/client/locales.ts +271 -0
  37. package/src/client/mount.tsx +97 -0
  38. package/src/client/panel/AssetsTab.tsx +341 -0
  39. package/src/client/panel/AssistantTab.tsx +188 -0
  40. package/src/client/panel/BookshelfBar.tsx +116 -0
  41. package/src/client/panel/NovelPanel.tsx +990 -0
  42. package/src/client/panel/controller.ts +45 -0
  43. package/src/client/panel/helpers.ts +17 -0
  44. package/src/client/panel/panel.module.css +894 -0
  45. package/src/client/sidebar-entry.ts +122 -0
  46. package/src/docx.ts +83 -0
  47. package/src/engine.ts +1019 -0
  48. package/src/index.ts +184 -0
  49. package/src/protocol.ts +539 -0
  50. package/src/routes.ts +955 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * AI assistant engine — a conversational editor over the novel project.
3
+ *
4
+ * The user talks to the assistant about plot, characters, settings; the
5
+ * assistant can reply in prose AND emit action directives that the host
6
+ * executes (rewrite a paragraph, edit the bible, regenerate a chapter,
7
+ * export the book, ...). Conversation history persists next to the project
8
+ * as NDJSON, so a reload keeps the thread.
9
+ *
10
+ * Action protocol: the model emits a line of the form
11
+ * <dsh-action name="toolName">{jsonArgs}</dsh-action>
12
+ * anywhere in its reply. The host strips it, executes the tool, appends the
13
+ * result as a tool-role message, and continues the loop (bounded rounds).
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { createUserMessage, createAssistantMessage, BlockAssembler, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+ import type { AssistantMessage, NovelConfig, ProjectState } from './protocol.ts'
21
+ import { emptyProjectAssets } from './assets.ts'
22
+ import {
23
+ chapterFileName,
24
+ exportBook,
25
+ generateChapterStream,
26
+ readChapterFile,
27
+ reviewChapter,
28
+ summarizeChapter,
29
+ saveProject,
30
+ } from './engine.ts'
31
+ import { rewriteChapterStream } from './engine.ts'
32
+
33
+ /** History file name inside the output dir. */
34
+ export const ASSISTANT_HISTORY_FILE = 'novel-assistant.jsonl'
35
+
36
+ /** Max tool-call rounds per user turn (safety bound). */
37
+ const MAX_TOOL_ROUNDS = 6
38
+
39
+ /** Max history messages kept in context (older ones summarized away). */
40
+ const MAX_HISTORY_MESSAGES = 24
41
+
42
+ // ------------------------------------------------------------------ history
43
+
44
+ /** Load the persisted conversation (empty when none). */
45
+ export function loadAssistantHistory(outputDir: string): AssistantMessage[] {
46
+ const file = join(outputDir, ASSISTANT_HISTORY_FILE)
47
+ if (!existsSync(file)) return []
48
+ const messages: AssistantMessage[] = []
49
+ try {
50
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
51
+ if (line.trim() === '') continue
52
+ try {
53
+ const parsed = JSON.parse(line) as AssistantMessage
54
+ if (typeof parsed.role === 'string' && typeof parsed.content === 'string') messages.push(parsed)
55
+ } catch { /* skip malformed line */ }
56
+ }
57
+ } catch { /* unreadable history -> start fresh */ }
58
+ return messages
59
+ }
60
+
61
+ /** Append one message to the persisted history. */
62
+ function appendHistory(outputDir: string, message: AssistantMessage): void {
63
+ mkdirSync(outputDir, { recursive: true })
64
+ appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(message) + '\n', 'utf8')
65
+ }
66
+
67
+ // ----------------------------------------------------------------- context
68
+
69
+ /** Render the project snapshot the assistant sees. */
70
+ function renderProjectSnapshot(project: ProjectState): string {
71
+ const sections: string[] = []
72
+ sections.push(`书名:${project.bookName}`)
73
+ if (project.bible !== undefined) {
74
+ const bible = project.bible
75
+ sections.push('【设定圣经】')
76
+ if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
77
+ if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
78
+ if (bible.characters.length > 0) {
79
+ sections.push('角色卡:')
80
+ for (const card of bible.characters) {
81
+ const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
82
+ sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}`)
83
+ }
84
+ }
85
+ if (bible.redLines.length > 0) sections.push('写作红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
86
+ }
87
+ if (project.volumes !== undefined && project.volumes.length > 0) {
88
+ sections.push('【卷结构】')
89
+ for (const v of project.volumes) {
90
+ sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)
91
+ }
92
+ }
93
+ if (project.chapters.length > 0) {
94
+ sections.push('【章节计划与进度】')
95
+ for (const c of project.chapters) {
96
+ const statusText = { pending: '待生成', generating: '生成中', written: '待审稿', reviewing: '审稿中', approved: '已通过', rejected: '待修订', error: '失败' }[c.status]
97
+ sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== undefined ? ` ${c.chars}字` : ''}${c.summary !== undefined && c.summary !== '' ? ` 摘要:${c.summary}` : ''}`)
98
+ }
99
+ }
100
+ if (project.foreshadows.length > 0) {
101
+ sections.push('【伏笔】')
102
+ for (const f of project.foreshadows) {
103
+ sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
104
+ }
105
+ }
106
+ return sections.join('\n')
107
+ }
108
+
109
+ /** The assistant system prompt. */
110
+ function assistantSystemPrompt(project: ProjectState): string {
111
+ return [
112
+ '你是这部小说的 AI 编辑助理,负责陪作者讨论剧情、人设、世界观,并把讨论结果落实到项目里。',
113
+ '==================== 当前项目快照 ====================',
114
+ renderProjectSnapshot(project),
115
+ '==================== 快照结束 ====================',
116
+ '',
117
+ '你可以:',
118
+ '1. 与作者讨论剧情走向、人物动机、爽点节奏、伏笔安排等,给出专业建议(直接文字回答)。',
119
+ '2. 讨论达成一致后,用动作指令实际修改内容。动作指令格式(放在回复末尾单独一行):',
120
+ ' <dsh-action name="工具名">{"参数名": 值}</dsh-action>',
121
+ '',
122
+ '可用工具:',
123
+ '- outline_text:无参数。返回当前大纲全文。',
124
+ '- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在大纲中替换一段文字(old 必须能在大纲中找到)。',
125
+ '- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改设定圣经的世界规则。',
126
+ '- bible_set_redline:同上,修改写作红线。',
127
+ '- chapter_text:{"no": 章节号}。返回该章正文。',
128
+ '- chapter_rewrite:{"no": 章节号, "instructions": "修改要求", "target": "原文片段(可选,留空整章)"}。按讨论结果修订章节;给了 target 只改该自然段。',
129
+ '- chapter_generate:{"no": 章节号}。重新生成该章。',
130
+ '- chapter_review:{"no": 章节号}。对该章执行 AI 审稿。',
131
+ '- foreshadow_add:{"description": "伏笔描述", "targetChapter": 预计回收章(可选)}。新增伏笔。',
132
+ '- foreshadow_update:{"id": "伏笔id", "status": "planned|planted|progressing|resolved|abandoned"}。更新伏笔状态。',
133
+ '- export_txt:无参数。导出全本 TXT。',
134
+ '- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
135
+ '- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
136
+ '- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
137
+ '- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)"}。新增反 AI 规则。',
138
+ '',
139
+ '使用规则(非常重要):',
140
+ '- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
141
+ ' 正确示例:<dsh-action name="outline_replace">{"old":"要替换的原文","new":"新文本"}</dsh-action>',
142
+ ' 正确示例:<dsh-action name="chapter_text">{"no":1}</dsh-action>',
143
+ ' 错误示例(绝对不要这样回复):"好的,我先看一下大纲,马上改。" ← 这只是文字,不会执行任何操作',
144
+ '- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。',
145
+ '- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。',
146
+ '- 需要先看大纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
147
+ '- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。',
148
+ '- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
149
+ '- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
150
+ '- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
151
+ '- 严格忠于设定圣经与大刚;不得自行发明与既有设定冲突的内容。',
152
+ '- 用中文回复。',
153
+ ].join('\n')
154
+ }
155
+
156
+ // ------------------------------------------------------------- action exec
157
+
158
+ /** Execute one action directive. Returns a text result (or throws). */
159
+ /**
160
+ * Execute one action directive as an async generator: yields live progress
161
+ * text (chapter text being generated/rewritten), then yields the final result
162
+ * string. Throws on failure.
163
+ */
164
+ export async function* executeAction(
165
+ ctx: Context,
166
+ config: NovelConfig,
167
+ project: ProjectState,
168
+ outputDir: string,
169
+ name: string,
170
+ args: Record<string, unknown>,
171
+ ): AsyncGenerator<string, string, unknown> {
172
+ const str = (value: unknown): string => typeof value === 'string' ? value : ''
173
+ const num = (value: unknown): number | undefined => typeof value === 'number' ? value : undefined
174
+
175
+ /** Forward live text deltas from a streaming chapter job (text only). */
176
+ const forward = async function* (stream: AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown>): AsyncGenerator<string, void, unknown> {
177
+ for await (const step of stream) {
178
+ if (step.frame === 'delta') yield step.text
179
+ }
180
+ }
181
+
182
+ switch (name) {
183
+ case 'outline_text': {
184
+ return project.outline
185
+ }
186
+ case 'outline_replace': {
187
+ const old = str(args.old)
188
+ const next = str(args.new)
189
+ if (old === '' || !project.outline.includes(old)) {
190
+ throw new Error(`大纲中未找到片段「${old.slice(0, 40)}…」`)
191
+ }
192
+ project.outline = project.outline.replace(old, next)
193
+ project.updatedAt = new Date().toISOString()
194
+ saveProject(outputDir, project)
195
+ return `大纲已修改:替换了 ${old.length} 字符的片段。`
196
+ }
197
+ case 'bible_set_rule': {
198
+ if (project.bible === undefined) throw new Error('尚无设定圣经,请先提炼')
199
+ const index = num(args.index)
200
+ if (index !== undefined) {
201
+ project.bible.worldRules[index] = str(args.text)
202
+ } else if (str(args.append) !== '') {
203
+ project.bible.worldRules.push(str(args.append))
204
+ } else {
205
+ throw new Error('bible_set_rule 需要 index+text 或 append')
206
+ }
207
+ project.updatedAt = new Date().toISOString()
208
+ saveProject(outputDir, project)
209
+ return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`
210
+ }
211
+ case 'bible_set_redline': {
212
+ if (project.bible === undefined) throw new Error('尚无设定圣经,请先提炼')
213
+ const index = num(args.index)
214
+ if (index !== undefined) {
215
+ project.bible.redLines[index] = str(args.text)
216
+ } else if (str(args.append) !== '') {
217
+ project.bible.redLines.push(str(args.append))
218
+ } else {
219
+ throw new Error('bible_set_redline 需要 index+text 或 append')
220
+ }
221
+ project.updatedAt = new Date().toISOString()
222
+ saveProject(outputDir, project)
223
+ return `写作红线已更新(当前 ${project.bible.redLines.length} 条)。`
224
+ }
225
+ case 'chapter_text': {
226
+ const no = num(args.no)
227
+ if (no === undefined) throw new Error('chapter_text 需要 no')
228
+ const chapter = project.chapters.find(c => c.no === no)
229
+ if (chapter === undefined) throw new Error(`章节 ${no} 不存在`)
230
+ const body = readChapterFile(outputDir, chapter)
231
+ if (body === undefined) throw new Error(`章节 ${no} 尚未生成`)
232
+ return body
233
+ }
234
+ case 'chapter_rewrite': {
235
+ const no = num(args.no)
236
+ if (no === undefined) throw new Error('chapter_rewrite 需要 no')
237
+ const instructions = str(args.instructions)
238
+ const target = str(args.target)
239
+ for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === '' ? undefined : target))) {
240
+ yield chunk
241
+ }
242
+ // Summarize + re-review so the assistant can report quality.
243
+ yield '(正在生成章节摘要…)'
244
+ try {
245
+ await summarizeChapter(ctx, config, project, outputDir, no)
246
+ } catch { /* summary is best-effort */ }
247
+ yield '(正在 AI 审稿…)'
248
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
249
+ return `章节 ${no} 已${target === '' ? '整章' : '局部'}修订完成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。重新审稿:${report.score} 分 — ${report.verdict}`
250
+ }
251
+ case 'chapter_generate': {
252
+ const no = num(args.no)
253
+ if (no === undefined) throw new Error('chapter_generate 需要 no')
254
+ for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) {
255
+ yield chunk
256
+ }
257
+ yield '(正在生成章节摘要…)'
258
+ try {
259
+ await summarizeChapter(ctx, config, project, outputDir, no)
260
+ } catch { /* best-effort */ }
261
+ yield '(正在 AI 审稿…)'
262
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
263
+ return `章节 ${no} 已生成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。审稿:${report.score} 分 — ${report.verdict}`
264
+ }
265
+ case 'chapter_review': {
266
+ const no = num(args.no)
267
+ if (no === undefined) throw new Error('chapter_review 需要 no')
268
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
269
+ const issues = report.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
270
+ return `章节 ${no} 审稿:${report.score} 分 — ${report.verdict}\n${issues}`
271
+ }
272
+ case 'foreshadow_add': {
273
+ const description = str(args.description)
274
+ if (description === '') throw new Error('foreshadow_add 需要 description')
275
+ const targetChapter = num(args.targetChapter)
276
+ project.foreshadows.push({
277
+ id: `fs-${Date.now().toString(36)}`,
278
+ description,
279
+ targetChapter,
280
+ status: 'planned',
281
+ })
282
+ project.updatedAt = new Date().toISOString()
283
+ saveProject(outputDir, project)
284
+ return `已新增伏笔:「${description.slice(0, 50)}」`
285
+ }
286
+ case 'foreshadow_update': {
287
+ const id = str(args.id)
288
+ const status = str(args.status) as 'planned' | 'planted' | 'progressing' | 'resolved' | 'abandoned'
289
+ const target = project.foreshadows.find(f => f.id === id)
290
+ if (target === undefined) throw new Error(`伏笔 ${id} 不存在`)
291
+ if (!['planned', 'planted', 'progressing', 'resolved', 'abandoned'].includes(status)) {
292
+ throw new Error(`非法状态 ${status}`)
293
+ }
294
+ target.status = status
295
+ project.updatedAt = new Date().toISOString()
296
+ saveProject(outputDir, project)
297
+ return `伏笔已更新为 ${status}:「${target.description.slice(0, 50)}」`
298
+ }
299
+ case 'export_txt': {
300
+ const result = exportBook(outputDir, project, 'txt')
301
+ return `已导出 TXT:${result.file}(${result.chars} 字,${result.chapters} 章)`
302
+ }
303
+ case 'assets_status': {
304
+ const assets = project.assets
305
+ if (assets === undefined) return '本书尚未配置写作资产。'
306
+ const parts: string[] = []
307
+ if (assets.genre !== undefined) parts.push(`题材:${assets.genre.name}`)
308
+ if (assets.primaryProgression !== undefined) parts.push(`主推进:${assets.primaryProgression.name}`)
309
+ if (assets.auxiliaryProgressions.length > 0) parts.push(`辅助推进:${assets.auxiliaryProgressions.map(m => m.name).join('、')}`)
310
+ if (assets.antiAiRules.length > 0) parts.push(`自定义反AI规则:${assets.antiAiRules.map(r => r.name).join('、')}`)
311
+ if (assets.styleAssets.length > 0) parts.push(`写法资产:${assets.styleAssets.map(s => s.name).join('、')}`)
312
+ return parts.length > 0 ? parts.join('\n') : '本书尚未配置写作资产。'
313
+ }
314
+ case 'assets_set_genre': {
315
+ const name = str(args.name)
316
+ const description = str(args.description)
317
+ if (name === '') throw new Error('assets_set_genre 需要 name')
318
+ if (project.assets === undefined) project.assets = emptyProjectAssets()
319
+ project.assets.genre = { name, description, children: [] }
320
+ project.assets.updatedAt = new Date().toISOString()
321
+ project.updatedAt = new Date().toISOString()
322
+ saveProject(outputDir, project)
323
+ return `题材已设为「${name}」`
324
+ }
325
+ case 'assets_set_progression': {
326
+ const name = str(args.name)
327
+ const driver = str(args.driver)
328
+ const primary = args.primary !== false
329
+ if (name === '') throw new Error('assets_set_progression 需要 name')
330
+ if (project.assets === undefined) project.assets = emptyProjectAssets()
331
+ const mode = {
332
+ name,
333
+ driver: driver !== '' ? driver : name,
334
+ readerExpectation: str(args.readerExpectation),
335
+ payoffs: Array.isArray(args.payoffs) ? args.payoffs.filter((v): v is string => typeof v === 'string') : [],
336
+ risks: Array.isArray(args.risks) ? args.risks.filter((v): v is string => typeof v === 'string') : [],
337
+ primary,
338
+ }
339
+ if (primary) project.assets.primaryProgression = mode
340
+ else {
341
+ if (project.assets.auxiliaryProgressions === undefined) project.assets.auxiliaryProgressions = []
342
+ project.assets.auxiliaryProgressions.push(mode)
343
+ }
344
+ project.assets.updatedAt = new Date().toISOString()
345
+ project.updatedAt = new Date().toISOString()
346
+ saveProject(outputDir, project)
347
+ return `推进模式${primary ? '(主)' : '(辅助)'}已设置:「${name}」`
348
+ }
349
+ case 'assets_add_rule': {
350
+ const name = str(args.name)
351
+ const avoid = str(args.avoid)
352
+ if (avoid === '') throw new Error('assets_add_rule 需要 avoid(要避免的表达问题)')
353
+ if (project.assets === undefined) project.assets = emptyProjectAssets()
354
+ if (project.assets.antiAiRules === undefined) project.assets.antiAiRules = []
355
+ project.assets.antiAiRules.push({
356
+ name: name !== '' ? name : `自定义规则 ${project.assets.antiAiRules.length + 1}`,
357
+ avoid,
358
+ fix: str(args.fix),
359
+ })
360
+ project.assets.updatedAt = new Date().toISOString()
361
+ project.updatedAt = new Date().toISOString()
362
+ saveProject(outputDir, project)
363
+ return `已新增反 AI 规则「${name !== '' ? name : avoid.slice(0, 20)}」`
364
+ }
365
+ default:
366
+ throw new Error(`未知工具 ${name}`)
367
+ }
368
+ }
369
+
370
+ // ------------------------------------------------------------------- chat
371
+
372
+ /** Extract the first action directive from a reply. */
373
+ function extractAction(reply: string): { name: string; args: Record<string, unknown>; index: number } | undefined {
374
+ const match = /<dsh-action\s+name="([^"]+)"\s*>([\s\S]*?)<\/dsh-action>/.exec(reply)
375
+ if (match === null) return undefined
376
+ const rawArgs = match[2]?.trim() ?? ''
377
+ let args: Record<string, unknown>
378
+ try {
379
+ args = rawArgs === '' ? {} : JSON.parse(rawArgs) as Record<string, unknown>
380
+ } catch {
381
+ throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`)
382
+ }
383
+ return { name: match[1] ?? '', args, index: match.index }
384
+ }
385
+
386
+ /** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
387
+ function historyToMessages(history: AssistantMessage[]): Message[] {
388
+ const recent = history.slice(-MAX_HISTORY_MESSAGES)
389
+ const messages: Message[] = []
390
+ for (const entry of recent) {
391
+ if (entry.role === 'user') {
392
+ messages.push(createUserMessage({
393
+ content: [{ type: 'text', text: entry.content }],
394
+ source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
395
+ }))
396
+ } else if (entry.role === 'assistant') {
397
+ messages.push(createAssistantMessage({
398
+ content: [{ type: 'text', text: entry.content }],
399
+ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
400
+ }))
401
+ } else if (entry.role === 'tool') {
402
+ messages.push(createUserMessage({
403
+ content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${entry.content}` }],
404
+ source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
405
+ }))
406
+ }
407
+ }
408
+ return messages
409
+ }
410
+
411
+ /** One non-streaming LLM chat turn (used inside the tool loop). */
412
+ async function chatOnce(
413
+ ctx: Context,
414
+ config: NovelConfig,
415
+ system: string,
416
+ history: AssistantMessage[],
417
+ ): Promise<string> {
418
+ const messages = historyToMessages(history)
419
+ const request: GenerateOptions = {
420
+ provider: config.provider,
421
+ model: config.model,
422
+ messages,
423
+ system,
424
+ maxTokens: config.maxTokens,
425
+ temperature: 0.7,
426
+ }
427
+ const assembler = new BlockAssembler()
428
+ for await (const chunk of ctx.llm.stream(request)) {
429
+ assembler.push(chunk)
430
+ }
431
+ const finish = assembler.finish
432
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
433
+ throw new Error(`助手调用失败(${finish.kind}): ${finish.failure.message}`)
434
+ }
435
+ const blocks = assembler.blocks()
436
+ const textBlocks = blocks
437
+ .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
438
+ .map(block => block.text)
439
+ let text = textBlocks.join('\n').trim()
440
+ if (text === '') {
441
+ const reasoning = blocks
442
+ .filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
443
+ .map(block => block.text)
444
+ .join('\n')
445
+ .trim()
446
+ if (reasoning !== '') text = reasoning
447
+ }
448
+ return text
449
+ }
450
+
451
+ /** Run one user turn. Yields stream frames; persists history. */
452
+ export async function* runAssistantTurn(
453
+ ctx: Context,
454
+ config: NovelConfig,
455
+ project: ProjectState,
456
+ outputDir: string,
457
+ userMessage: string,
458
+ ): AsyncGenerator<
459
+ | { frame: 'delta'; text: string }
460
+ | { frame: 'tool'; name: string; status: 'start' | 'done' | 'error'; detail?: string }
461
+ | { frame: 'toolDelta'; name: string; text: string },
462
+ void,
463
+ unknown
464
+ > {
465
+ const history = loadAssistantHistory(outputDir)
466
+ const system = assistantSystemPrompt(project)
467
+
468
+ // Persist the user message.
469
+ const userEntry: AssistantMessage = { role: 'user', content: userMessage, ts: new Date().toISOString() }
470
+ history.push(userEntry)
471
+ appendHistory(outputDir, userEntry)
472
+
473
+ let round = 0
474
+ /** Whether we already nudged the model to emit an action tag (avoid loops). */
475
+ let nudged = false
476
+ for (;;) {
477
+ const reply = await chatOnce(ctx, config, system, history)
478
+ const action = extractAction(reply)
479
+
480
+ if (action === undefined) {
481
+ // No action tag. If the reply clearly intends to modify something but
482
+ // forgot the tag, nudge once and continue; otherwise it's plain prose.
483
+ const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply)
484
+ if (intendsAction && !nudged) {
485
+ nudged = true
486
+ const nudge = '你的上一条回复表达了想操作项目的意图(如查看/修改大纲、章节等),但没有输出动作指令标签,因此没有执行任何操作。请直接输出 <dsh-action name="工具名">{"参数":值}</dsh-action> 标签来执行,不要用文字描述意图。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
487
+ history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
488
+ appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
489
+ continue
490
+ }
491
+ // Plain prose reply — done.
492
+ const assistantEntry: AssistantMessage = { role: 'assistant', content: reply, ts: new Date().toISOString() }
493
+ history.push(assistantEntry)
494
+ appendHistory(outputDir, assistantEntry)
495
+ // Stream the prose (without any stray action markup).
496
+ yield { frame: 'delta', text: reply }
497
+ return
498
+ }
499
+
500
+ // Execute the action, then feed the result back and continue.
501
+ const { name, args, index } = action
502
+ const prose = reply.slice(0, index).trim()
503
+ yield { frame: 'tool', name, status: 'start' }
504
+ let result: string
505
+ try {
506
+ // executeAction is an async generator: it yields live progress text
507
+ // (chapter text being generated) and returns the final result string.
508
+ // Iterate manually so we see both the yielded deltas and the return.
509
+ const iterator = executeAction(ctx, config, project, outputDir, name, args)[Symbol.asyncIterator]()
510
+ result = ''
511
+ for (;;) {
512
+ const step = await iterator.next()
513
+ if (step.done === true) {
514
+ result = typeof step.value === 'string' ? step.value : ''
515
+ break
516
+ }
517
+ const chunk = step.value
518
+ if (typeof chunk === 'string' && chunk !== '') {
519
+ yield { frame: 'toolDelta', name, text: chunk }
520
+ }
521
+ }
522
+ yield { frame: 'tool', name, status: 'done', detail: result.slice(0, 200) }
523
+ } catch (error) {
524
+ result = `执行失败:${(error as Error).message}`
525
+ yield { frame: 'tool', name, status: 'error', detail: (error as Error).message }
526
+ }
527
+
528
+ // Persist assistant prose + tool result as history entries.
529
+ if (prose !== '') {
530
+ history.push({ role: 'assistant', content: prose, ts: new Date().toISOString() })
531
+ appendHistory(outputDir, { role: 'assistant', content: prose, ts: new Date().toISOString() })
532
+ }
533
+ history.push({ role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
534
+ appendHistory(outputDir, { role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
535
+
536
+ round++
537
+ if (round >= MAX_TOOL_ROUNDS) {
538
+ const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`
539
+ history.push({ role: 'assistant', content: message, ts: new Date().toISOString() })
540
+ appendHistory(outputDir, { role: 'assistant', content: message, ts: new Date().toISOString() })
541
+ yield { frame: 'delta', text: message }
542
+ return
543
+ }
544
+ }
545
+ }
546
+
547
+ export { chapterFileName }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3
+ * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { randomBytes } from 'node:crypto'
10
+ import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
11
+ import { loadProject } from './engine.ts'
12
+
13
+ /** 书架配置文件路径。 */
14
+ export function bookshelfFile(): string {
15
+ return join(homedir(), '.dsh', 'dsh-novel-forge-bookshelf.json')
16
+ }
17
+
18
+ interface BookshelfStore {
19
+ books: BookEntry[]
20
+ activeBookId: string | null
21
+ }
22
+
23
+ function defaultStore(): BookshelfStore {
24
+ return { books: [], activeBookId: null }
25
+ }
26
+
27
+ /** 读取书架(无则返回空)。 */
28
+ export function loadBookshelf(): BookshelfStore {
29
+ const file = bookshelfFile()
30
+ if (!existsSync(file)) return defaultStore()
31
+ try {
32
+ let raw = readFileSync(file, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const parsed = JSON.parse(raw) as BookshelfStore
35
+ if (!Array.isArray(parsed.books)) return defaultStore()
36
+ return { books: parsed.books, activeBookId: parsed.activeBookId ?? null }
37
+ } catch {
38
+ return defaultStore()
39
+ }
40
+ }
41
+
42
+ /** 持久化书架。 */
43
+ function saveBookshelf(store: BookshelfStore): void {
44
+ const file = bookshelfFile()
45
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
46
+ writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
47
+ }
48
+
49
+ /** 当前激活的书。 */
50
+ export function activeBook(store: BookshelfStore): BookEntry | undefined {
51
+ return store.books.find(b => b.id === store.activeBookId)
52
+ }
53
+
54
+ /** 书架快照(含每本书的进度摘要)。 */
55
+ export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
56
+ return {
57
+ books: store.books.map(book => {
58
+ const project = loadProject(book.outputDir)
59
+ const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
60
+ return {
61
+ ...book,
62
+ done,
63
+ total: project?.chapters.length ?? 0,
64
+ hasProject: project !== undefined,
65
+ }
66
+ }),
67
+ activeBookId: store.activeBookId,
68
+ }
69
+ }
70
+
71
+ /** 新建一本书(自动成为当前书)。 */
72
+ export function createBook(bookName: string, outputDir: string): BookEntry {
73
+ const store = loadBookshelf()
74
+ const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`
75
+ const now = new Date().toISOString()
76
+ const book: BookEntry = { id, bookName, outputDir, createdAt: now, updatedAt: now }
77
+ store.books.push(book)
78
+ store.activeBookId = id
79
+ saveBookshelf(store)
80
+ return book
81
+ }
82
+
83
+ /**
84
+ * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
85
+ * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
86
+ * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
87
+ * @returns 是否发生了播种。
88
+ */
89
+ export function seedBookshelfFromOutputDir(outputDir: string): boolean {
90
+ const store = loadBookshelf()
91
+ if (store.books.length > 0) return false
92
+ if (!existsSync(outputDir)) return false
93
+ // 有项目文件,或至少有章节文件,才视为"已有的书"。
94
+ const hasProject = existsSync(join(outputDir, 'novel-project.json'))
95
+ const hasChapters = existsSync(outputDir)
96
+ if (!hasProject && !hasChapters) return false
97
+ const project = loadProject(outputDir)
98
+ const bookName = project?.bookName ?? outputDir.split(/[\\/]/).pop() ?? '未命名小说'
99
+ createBook(bookName, outputDir)
100
+ return true
101
+ }
102
+
103
+ /** 激活一本书。 */
104
+ export function activateBook(id: string): BookEntry | undefined {
105
+ const store = loadBookshelf()
106
+ const book = store.books.find(b => b.id === id)
107
+ if (book === undefined) return undefined
108
+ store.activeBookId = id
109
+ book.updatedAt = new Date().toISOString()
110
+ saveBookshelf(store)
111
+ return book
112
+ }
113
+
114
+ /** 移除一本书。 */
115
+ export function removeBook(id: string): boolean {
116
+ const store = loadBookshelf()
117
+ const idx = store.books.findIndex(b => b.id === id)
118
+ if (idx === -1) return false
119
+ store.books.splice(idx, 1)
120
+ if (store.activeBookId === id) {
121
+ store.activeBookId = store.books[0]?.id ?? null
122
+ }
123
+ saveBookshelf(store)
124
+ return true
125
+ }
126
+
127
+ /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
128
+ export function activeBookOutputDir(): string | undefined {
129
+ const book = activeBook(loadBookshelf())
130
+ return book?.outputDir
131
+ }
132
+
133
+ /** 默认输出目录推断:桌面/书名。 */
134
+ export function defaultOutputDirFor(bookName: string): string {
135
+ const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
136
+ return join(homedir(), 'Desktop', clean)
137
+ }