@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/comic-presets.ts +0 -67
package/src/assistant.ts
CHANGED
|
@@ -1,770 +1,1098 @@
|
|
|
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, rmSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
import { createUserMessage, createAssistantMessage, BlockAssembler, type GenerateOptions, type Message, type StreamChunk, type ContentBlock } 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
|
-
analyzeImpact,
|
|
24
|
-
bookOverview,
|
|
25
|
-
chapterFileName,
|
|
26
|
-
exportBook,
|
|
27
|
-
generateChapterStream,
|
|
28
|
-
readChapterFile,
|
|
29
|
-
reviewChapter,
|
|
30
|
-
saveProject,
|
|
31
|
-
summarizeAndExtractFacts,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
'
|
|
168
|
-
'
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return
|
|
404
|
-
}
|
|
405
|
-
case '
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
})
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
)
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
|
|
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, rmSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { createUserMessage, createAssistantMessage, BlockAssembler, type GenerateOptions, type Message, type StreamChunk, type ContentBlock } 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
|
+
analyzeImpact,
|
|
24
|
+
bookOverview,
|
|
25
|
+
chapterFileName,
|
|
26
|
+
exportBook,
|
|
27
|
+
generateChapterStream,
|
|
28
|
+
readChapterFile,
|
|
29
|
+
reviewChapter,
|
|
30
|
+
saveProject,
|
|
31
|
+
summarizeAndExtractFacts,
|
|
32
|
+
runBookAnalysis,
|
|
33
|
+
runDirectorAdvice,
|
|
34
|
+
breakdownBook,
|
|
35
|
+
auditBook,
|
|
36
|
+
generateBlurb,
|
|
37
|
+
} from './engine.ts'
|
|
38
|
+
import { rewriteChapterStream, countHanzi } from './engine.ts'
|
|
39
|
+
import { computeBookStage, isGenericContinue, renderStageContract, splitByStage, stageAllows, type BookStage } from './stage-contract.ts'
|
|
40
|
+
import { beginLiveCall, endLiveCall, markFirstToken } from './llm-live.ts'
|
|
41
|
+
import { withModelFallback } from './llm-retry.ts'
|
|
42
|
+
import { createSnapshot } from './snapshots.ts'
|
|
43
|
+
import { NovelActionError, classifyActionError, FailureLedger } from './action-guard.ts'
|
|
44
|
+
|
|
45
|
+
/** History file name inside the output dir. */
|
|
46
|
+
export const ASSISTANT_HISTORY_FILE = 'novel-assistant.jsonl'
|
|
47
|
+
|
|
48
|
+
/** Max tool-call rounds per user turn (safety bound). */
|
|
49
|
+
const MAX_TOOL_ROUNDS = 4
|
|
50
|
+
|
|
51
|
+
/** Max history messages kept in context (older ones summarized away). */
|
|
52
|
+
const MAX_HISTORY_MESSAGES = 18
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ action registry
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 写操作登记表:助手动作里会改动项目数据/正文文件的那些,以及判定「作者是否
|
|
58
|
+
* 明确点了这件事」的意图正则。只读工具不在此表内,任何时候都放行。
|
|
59
|
+
*/
|
|
60
|
+
const WRITE_TOOL_KEYS: Record<string, RegExp> = {
|
|
61
|
+
chapter_generate: /(生成|写第\s*\d+\s*章|写一[章篇]|新写|续写|接着写|继续写|开始写|写正文|写书|创作)/,
|
|
62
|
+
chapter_rewrite: /(重写|改写|修订|修改|改一下|调整|替换|润色|优化|修正|完善|回炉|换一种|从头)/,
|
|
63
|
+
chapter_review: /(审|检查|校验|点评|评估|把关|质量|怎么样|如何)/,
|
|
64
|
+
outline_replace: /(大纲|总纲|简介)/,
|
|
65
|
+
bible_set_rule: /(道藏|设定|规则|红线|世界|金手指)/,
|
|
66
|
+
bible_set_redline: /(道藏|设定|红线)/,
|
|
67
|
+
foreshadow_add: /(暗线|伏笔|埋)/,
|
|
68
|
+
foreshadow_update: /(暗线|伏笔)/,
|
|
69
|
+
export_txt: /(导出|打包|下载|txt)/,
|
|
70
|
+
assets_set_genre: /(题材)/,
|
|
71
|
+
assets_set_progression: /(推进)/,
|
|
72
|
+
assets_add_rule: /(规则|文戒|反AI)/,
|
|
73
|
+
knowledge_add: /(知识库|记住|补充|收进|参考|资料)/,
|
|
74
|
+
plotline_add: /(剧情线|长线|加入剧情线|线名)/,
|
|
75
|
+
director_todo_add: /(待办|风险|修复|记一下|记一条)/,
|
|
76
|
+
blurb: /(简介|封面|小说简介)/,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 只读工具:任何时候都可用,不受阶段白名单限制。 */
|
|
80
|
+
const READ_TOOLS: readonly string[] = [
|
|
81
|
+
'book_overview', 'facts_query', 'impact_analysis', 'outline_text',
|
|
82
|
+
'chapter_text', 'assets_status', 'knowledge_search', 'knowledge_list',
|
|
83
|
+
'plotline_list', 'director_todo_list',
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 助手动作说明书(原文照旧,只是从提示词字面量里提出来,便于按阶段裁剪)。
|
|
88
|
+
* 每行以 `- 工具名:` 开头,renderToolDocs 依赖这个约定识别工具归属。
|
|
89
|
+
*/
|
|
90
|
+
const ASSISTANT_TOOL_DOCS: readonly string[] = [
|
|
91
|
+
'- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
|
|
92
|
+
'- facts_query:{"keyword": "关键词"}。从编年录按关键词检索相关事实(如灵石、境界名、人物名)。',
|
|
93
|
+
'- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/编年录)。',
|
|
94
|
+
'- outline_text:无参数。返回当前总纲全文。',
|
|
95
|
+
'- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在总纲中替换一段文字(old 必须能在总纲中找到)。',
|
|
96
|
+
'- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改道藏的世界规则。',
|
|
97
|
+
'- bible_set_redline:同上,修改写作红线。',
|
|
98
|
+
'- chapter_text:{"no": 章节号}。返回该章正文。',
|
|
99
|
+
'- chapter_rewrite:{"no": 章节号, "instructions": "修改要求", "target": "原文片段(可选,留空整章)"}。按讨论结果修订章节;给了 target 只改该自然段。',
|
|
100
|
+
'- chapter_generate:{"no": 章节号}。重新生成该章。',
|
|
101
|
+
'- chapter_review:{"no": 章节号}。对该章执行 AI 审稿。',
|
|
102
|
+
'- foreshadow_add:{"description": "暗线描述", "targetChapter": 预计回收章(可选)}。新增暗线。',
|
|
103
|
+
'- foreshadow_update:{"id": "暗线id", "status": "planned|planted|progressing|resolved|abandoned"}。更新暗线状态。',
|
|
104
|
+
'- export_txt:无参数。导出全本 TXT。',
|
|
105
|
+
'- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
|
|
106
|
+
'- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
|
|
107
|
+
'- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
|
|
108
|
+
'- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
|
|
109
|
+
'- book_analysis:{"scope": "recent|full|volume:N"(可选)}。拆书:提炼本书卖点/结构/可借鉴/风险(分析当前书,不照搬外部作品)。',
|
|
110
|
+
'- director_advice:{"focus": "聚焦方向(可选)"}。自动编辑:基于全书给出下一阶段剧情节点/节奏板/风险/修复建议。',
|
|
111
|
+
'- knowledge_add:{"title": "标题", "content": "内容"}。往本书知识库加一条自由参考文档(生成时会被检索注入)。',
|
|
112
|
+
'- knowledge_search:{"query": "关键词"}。在本书知识库检索相关内容。',
|
|
113
|
+
'- plotline_list:无参数。查看本书当前剧情线。',
|
|
114
|
+
'- plotline_add:{"name": "线名", "goal": "目标", "kind": "main|branch|character|mystery(可选)"}。新增一条剧情线。',
|
|
115
|
+
'- director_todo_add:{"text": "待办内容", "source": "risk|fix(可选)"}。把一条风险/修复记成编辑待办。',
|
|
116
|
+
'- director_todo_list:无参数。查看本书编辑待办。',
|
|
117
|
+
'- knowledge_list:无参数。**列出本书全部知识库文档**(标题+内容)。作者说"收集/汇总/列出所有知识库"时调用它。',
|
|
118
|
+
'- breakdown:{"scope": "recent|all|volume:N"(可选,默认 recent), "preset": "quick|standard"(可选)}。书内拆书分析:对本书已写章节做结构/人物/文风/卖点体检。',
|
|
119
|
+
'- audit:无参数。全书一致性质检(分批扫描章节+设定+事实库,聚合矛盾)。',
|
|
120
|
+
'- blurb:{"partial": "已写开头(可选)"}。生成/补全小说简介并保存到本书。',
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/** 工具说明书行首约定:`- <tool>:...`。 */
|
|
124
|
+
const TOOL_DOC_PATTERN = /^-\s*([a-z][a-z_]*)\s*[::]/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 按阶段渲染动作说明书。
|
|
128
|
+
*
|
|
129
|
+
* - `strict=false`(作者点明了要做什么):全量说明书照给,只靠动作可见性段提示
|
|
130
|
+
* 哪些属于阶段外——作者有权指定任意动作。
|
|
131
|
+
* - `strict=true`(作者只说「继续/下一步」):阶段外的写操作说明书直接**从提示词里移除**,
|
|
132
|
+
* 和执行层的阶段白名单完全一致,模型不再有机会去试错撞守卫。
|
|
133
|
+
*
|
|
134
|
+
* @param docs 动作说明书行。
|
|
135
|
+
* @param stage 阶段契约。
|
|
136
|
+
* @param strict 是否为「阶段即唯一命令」模式。
|
|
137
|
+
* @returns 裁剪后的说明书行。
|
|
138
|
+
*/
|
|
139
|
+
export function renderToolDocs(docs: readonly string[], stage: BookStage, strict: boolean): string[] {
|
|
140
|
+
if (!strict) return [...docs]
|
|
141
|
+
const kept: string[] = []
|
|
142
|
+
const hidden: string[] = []
|
|
143
|
+
for (const line of docs) {
|
|
144
|
+
const tool = TOOL_DOC_PATTERN.exec(line)?.[1]
|
|
145
|
+
if (tool !== undefined && WRITE_TOOL_KEYS[tool] !== undefined && !stageAllows(stage, tool)) {
|
|
146
|
+
hidden.push(tool)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
kept.push(line)
|
|
150
|
+
}
|
|
151
|
+
if (hidden.length > 0) {
|
|
152
|
+
kept.push(`- (本轮不可用:${hidden.join('、')}——当前阶段是「${stage.label}」,作者只说了「继续」;作者明确点名时才可执行。)`)
|
|
153
|
+
}
|
|
154
|
+
return kept
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 渲染「动作可见性」:把写操作分成「本阶段可用」与「阶段外(仅作者点名时执行)」,
|
|
159
|
+
* 让模型不必靠试错去发现哪些动作会被拒绝。
|
|
160
|
+
*
|
|
161
|
+
* @param stage 阶段契约。
|
|
162
|
+
* @returns 多行文本。
|
|
163
|
+
*/
|
|
164
|
+
export function renderActionVisibility(stage: BookStage): string {
|
|
165
|
+
const { available, outside } = splitByStage(stage, Object.keys(WRITE_TOOL_KEYS))
|
|
166
|
+
return [
|
|
167
|
+
'==================== 动作可见性(宿主按当前阶段计算) ====================',
|
|
168
|
+
`- 本阶段可直接执行:${available.length > 0 ? available.join('、') : '无'}`,
|
|
169
|
+
`- 本阶段外(只有作者明确点名时才允许,否则会被宿主拒绝):${outside.length > 0 ? outside.join('、') : '无'}`,
|
|
170
|
+
`- 只读工具任何时候都可用:${READ_TOOLS.join('、')}`,
|
|
171
|
+
].join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ------------------------------------------------------------- failure class
|
|
175
|
+
// 失败分级规则本体在 ./action-guard.ts(纯逻辑、可离线单测);这里只做再导出,
|
|
176
|
+
// 保持既有调用方与测试的导入路径不变。
|
|
177
|
+
|
|
178
|
+
export { classifyActionError, actionRetryLimit, safeArgsKey, NovelActionError, FailureLedger } from './action-guard.ts'
|
|
179
|
+
export type { ActionErrorKind } from './action-guard.ts'
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------ history
|
|
182
|
+
|
|
183
|
+
/** Load the persisted conversation (empty when none). */
|
|
184
|
+
export function loadAssistantHistory(outputDir: string): AssistantMessage[] {
|
|
185
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE)
|
|
186
|
+
if (!existsSync(file)) return []
|
|
187
|
+
const messages: AssistantMessage[] = []
|
|
188
|
+
try {
|
|
189
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
190
|
+
if (line.trim() === '') continue
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(line) as AssistantMessage
|
|
193
|
+
if (typeof parsed.role === 'string' && typeof parsed.content === 'string') messages.push(parsed)
|
|
194
|
+
} catch { /* skip malformed line */ }
|
|
195
|
+
}
|
|
196
|
+
} catch { /* unreadable history -> start fresh */ }
|
|
197
|
+
return messages
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Append one message to the persisted history. Tool payloads (e.g. full
|
|
201
|
+
* outline / chapter text) are capped so the jsonl and later LLM context
|
|
202
|
+
* don't grow unboundedly. */
|
|
203
|
+
function appendHistory(outputDir: string, message: AssistantMessage): void {
|
|
204
|
+
mkdirSync(outputDir, { recursive: true })
|
|
205
|
+
const entry: AssistantMessage = message.role === 'tool' && message.content.length > 4000
|
|
206
|
+
? { ...message, content: message.content.slice(0, 4000) + '\n…(已截断,如需完整内容请重新调用工具)' }
|
|
207
|
+
: message
|
|
208
|
+
appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + '\n', 'utf8')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 清空助手对话记录(删除历史文件)。 */
|
|
212
|
+
export function clearAssistantHistory(outputDir: string): void {
|
|
213
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE)
|
|
214
|
+
if (existsSync(file)) rmSync(file, { force: true })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ----------------------------------------------------------------- context
|
|
218
|
+
|
|
219
|
+
/** Render the project snapshot the assistant sees. */
|
|
220
|
+
function renderProjectSnapshot(project: ProjectState): string {
|
|
221
|
+
const sections: string[] = []
|
|
222
|
+
sections.push(`书名:${project.bookName}`)
|
|
223
|
+
sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`)
|
|
224
|
+
// 写作资产名称化(占位小,需要详情用 assets_status)。
|
|
225
|
+
const assetNames: string[] = []
|
|
226
|
+
if (project.assets?.genre !== undefined) assetNames.push(`题材:${project.assets.genre.name}`)
|
|
227
|
+
if (project.assets?.primaryProgression !== undefined) assetNames.push(`主推进:${project.assets.primaryProgression.name}`)
|
|
228
|
+
if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets!.styleAssets!.map(s => s.name).join('、')}`)
|
|
229
|
+
if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets!.antiAiRules!.map(r => r.name).join('、')}`)
|
|
230
|
+
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(' · ')}`)
|
|
231
|
+
if (project.bible !== undefined) {
|
|
232
|
+
const bible = project.bible
|
|
233
|
+
sections.push('【道藏】')
|
|
234
|
+
if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
|
|
235
|
+
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
236
|
+
if (bible.characters.length > 0) {
|
|
237
|
+
sections.push('角色卡:')
|
|
238
|
+
for (const card of bible.characters) {
|
|
239
|
+
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
|
|
240
|
+
sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (bible.redLines.length > 0) sections.push('写作红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
244
|
+
}
|
|
245
|
+
if (project.world !== undefined) {
|
|
246
|
+
const world = project.world
|
|
247
|
+
sections.push('【大世界】')
|
|
248
|
+
if (world.realms.length > 0) sections.push('境界体系:' + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(' → '))
|
|
249
|
+
if (world.regions.length > 0) sections.push('区域:' + world.regions.map(r => r.name).join('、'))
|
|
250
|
+
if (world.factions.length > 0) sections.push('势力:' + world.factions.map(f => `${f.name}(${f.kind})`).join('、'))
|
|
251
|
+
}
|
|
252
|
+
if (project.volumes !== undefined && project.volumes.length > 0) {
|
|
253
|
+
sections.push('【卷结构】')
|
|
254
|
+
for (const v of project.volumes) {
|
|
255
|
+
sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (project.chapters.length > 0) {
|
|
259
|
+
// 快照只列最近 30 章(长书时防止上下文膨胀;更早章节可用
|
|
260
|
+
// book_overview scope=volume:N 或 chapter_text 按需查看)。
|
|
261
|
+
const shown = project.chapters.slice(-30)
|
|
262
|
+
sections.push(`【章节计划与进度(最近 ${shown.length} 章)】`)
|
|
263
|
+
for (const c of shown) {
|
|
264
|
+
const statusText = { pending: '待生成', generating: '生成中', written: '待审稿', reviewing: '审稿中', approved: '已通过', rejected: '待修订', error: '失败' }[c.status]
|
|
265
|
+
sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== undefined ? ` ${c.chars}字` : ''}${c.summary !== undefined && c.summary !== '' ? ` 摘要:${c.summary}` : ''}`)
|
|
266
|
+
}
|
|
267
|
+
if (project.chapters.length > shown.length) {
|
|
268
|
+
sections.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 book_overview scope=volume:N 查看)`)
|
|
269
|
+
}
|
|
270
|
+
// 最近两章正文节选(讨论细节时参考)
|
|
271
|
+
const written = project.chapters.filter(c => c.status === 'approved' || c.status === 'written')
|
|
272
|
+
const recent = written.slice(-2)
|
|
273
|
+
if (recent.length > 0) {
|
|
274
|
+
sections.push('【最近章节正文节选】')
|
|
275
|
+
for (const c of recent) {
|
|
276
|
+
sections.push(`第${c.no}章《${c.title}》(节选,如需全文用 chapter_text):${c.beats.slice(0, 300)}`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (project.foreshadows.length > 0) {
|
|
281
|
+
sections.push('【暗线】')
|
|
282
|
+
for (const f of project.foreshadows) {
|
|
283
|
+
sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const allFacts = project.facts ?? []
|
|
287
|
+
if (allFacts.length > 0) {
|
|
288
|
+
// 截断显式化:必须让模型知道"看到的不是全部",否则一致性判断会建立在不完整事实上。
|
|
289
|
+
const shown = allFacts.slice(-40)
|
|
290
|
+
const omitted = allFacts.length - shown.length
|
|
291
|
+
sections.push(omitted > 0
|
|
292
|
+
? `【已确立编年录(回答设定问题必须遵守):共 ${allFacts.length} 条,此处只显示最近 ${shown.length} 条,较早 ${omitted} 条未显示;需要更早的事实请用 facts_query 关键词检索】`
|
|
293
|
+
: `【已确立编年录(共 ${shown.length} 条,回答设定问题必须遵守)】`)
|
|
294
|
+
for (const f of shown) {
|
|
295
|
+
sections.push(`- [第${f.chapterNo}章] ${f.text}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (project.blurb !== undefined && project.blurb !== '') {
|
|
299
|
+
sections.push(`【卷首语】${project.blurb}`)
|
|
300
|
+
}
|
|
301
|
+
return sections.join('\n')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The assistant system prompt. */
|
|
305
|
+
function assistantSystemPrompt(project: ProjectState, strictStage = false): string {
|
|
306
|
+
const stage = computeBookStage(project)
|
|
307
|
+
return [
|
|
308
|
+
'你是「编辑老师」——服务这本书作者的资深中文网文编辑。',
|
|
309
|
+
'人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。',
|
|
310
|
+
'座右铭:「书是你的,但坑我替你盯着。」',
|
|
311
|
+
'职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。',
|
|
312
|
+
'==================== 模块正式名称(回复作者时一律使用,禁止使用括号里的旧称) ====================',
|
|
313
|
+
'总纲 = 总纲;道藏 = 道藏;暗线 = 暗线;卷首语 = 卷首语;编年录 = 编年录。',
|
|
314
|
+
'==================== 当前项目快照 ====================',
|
|
315
|
+
renderProjectSnapshot(project),
|
|
316
|
+
'==================== 快照结束 ====================',
|
|
317
|
+
'',
|
|
318
|
+
'工作规则(严格遵守):',
|
|
319
|
+
'1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。',
|
|
320
|
+
'2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
|
|
321
|
+
'3. 连锁维护(只用于大改动,不要滥用):改动**整段大纲/道藏规则/章节正文**这类可能冲突的大改动时,才主动调用 impact_analysis;**新增知识库文档、记待办、加剧情线、改暗线状态**这类轻量增改,做完直接一句话汇报即可,禁止再连锁调用其它工具。',
|
|
322
|
+
'4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
|
|
323
|
+
'5. 收敛执行(最重要):每轮只做用户明确要求的那一件事。执行完立即用一句话汇报并结束本轮;**禁止**重复调用已做过的工具、**禁止**为了"保险/确认/顺便"再调用无关工具。若用户说"记住这个设定/记一条待办/加一条剧情线",就调对应工具一次 → 汇报 → 停。',
|
|
324
|
+
'6. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
|
|
325
|
+
'7. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
|
|
326
|
+
'8. 中文回复,简洁有干货。',
|
|
327
|
+
'',
|
|
328
|
+
'可用工具:',
|
|
329
|
+
...renderToolDocs(ASSISTANT_TOOL_DOCS, stage, strictStage),
|
|
330
|
+
...(strictStage
|
|
331
|
+
? ['(注意:作者本轮只说了「继续/下一步」,因此上面只列出**本阶段可执行**的动作。阶段外动作本轮不可用,发出会被宿主拒绝;作者明确点名时才可执行。)']
|
|
332
|
+
: []),
|
|
333
|
+
'',
|
|
334
|
+
|
|
335
|
+
'回答质量要求(非常重要):',
|
|
336
|
+
'- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
|
|
337
|
+
'- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。',
|
|
338
|
+
'- 主动:作者说"改一下",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。',
|
|
339
|
+
'- 忠于设定:以总纲、道藏、编年录为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
|
|
340
|
+
'- 中文回复;文字量适中,别啰嗦。',
|
|
341
|
+
'',
|
|
342
|
+
'使用规则(非常重要):',
|
|
343
|
+
'- 写操作(chapter_generate / chapter_rewrite / chapter_review / outline_replace / bible_set_* / foreshadow_* / assets_set_* / export_txt)只有在作者明确要求时才能调用——例如作者说"生成第 120 章""把第 105 章结尾改一下""帮我审一下第 88 章"。作者只是提问、闲聊、查信息时,一律用文字回答,禁止调用任何写操作,也不要先斩后奏(如"为了回答你,我先把第 X 章生成了")。',
|
|
344
|
+
'- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
|
|
345
|
+
' 正确示例:<dsh-action name="outline_replace">{"old":"要替换的原文","new":"新文本"}</dsh-action>',
|
|
346
|
+
' 正确示例:<dsh-action name="chapter_text">{"no":1}</dsh-action>',
|
|
347
|
+
' 错误示例(绝对不要这样回复):"好的,我先看一下总纲,马上改。" ← 这只是文字,不会执行任何操作',
|
|
348
|
+
' 错误示例(绝对不要这样回复):"先拉完整上下文确认改动落地情况,避免继续空转。" ← 没有动作标签,不会执行任何操作',
|
|
349
|
+
' 错误示例(绝对不要这样回复):"现在处理暗线库,我先看当前列表定位 id。" ← 没有动作标签,不会执行任何操作',
|
|
350
|
+
'- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释"我要做什么"、禁止铺垫——直接输出标签。',
|
|
351
|
+
'- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。',
|
|
352
|
+
'- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。',
|
|
353
|
+
'- 每次回复最多调用 1 个动作;**完成用户本轮要求后立即用一句话汇报并停止,不要继续追加工具调用**(除非用户在下一轮明确要求)。',
|
|
354
|
+
'- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
|
|
355
|
+
'- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。',
|
|
356
|
+
'- 失败即停(重要):参数/契约类错误(参数缺失、片段未找到、章节不存在、非法取值)只允许**修正一次**,禁止原样重复同一个调用;同一调用第二次以同样参数失败时,宿主会直接停止本轮,不要再试探性重发。瞬时故障(网络/模型中断/磁盘占用)可重试一次,同样禁止无限重试。',
|
|
357
|
+
'- 状态诚实(重要):只有工具返回成功结果时才能说"已修改/已生成/已保存/已导出";工具报错时必须如实说明失败原因与当前真实状态,禁止声称操作已完成,也禁止把"草稿"说成"已定稿"。',
|
|
358
|
+
'- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
|
|
359
|
+
'- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
|
|
360
|
+
'- 严格忠于道藏与总纲;不得自行发明与既有设定冲突的内容。',
|
|
361
|
+
'- 用中文回复。',
|
|
362
|
+
'',
|
|
363
|
+
renderStageContract(stage),
|
|
364
|
+
renderActionVisibility(stage),
|
|
365
|
+
].join('\n')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ------------------------------------------------------------- action exec
|
|
369
|
+
|
|
370
|
+
/** Execute one action directive. Returns a text result (or throws). */
|
|
371
|
+
/**
|
|
372
|
+
* Execute one action directive as an async generator: yields live progress
|
|
373
|
+
* text (chapter text being generated/rewritten), then yields the final result
|
|
374
|
+
* string. Throws on failure.
|
|
375
|
+
*/
|
|
376
|
+
export async function* executeAction(
|
|
377
|
+
ctx: Context,
|
|
378
|
+
config: NovelConfig,
|
|
379
|
+
project: ProjectState,
|
|
380
|
+
outputDir: string,
|
|
381
|
+
name: string,
|
|
382
|
+
args: Record<string, unknown>,
|
|
383
|
+
): AsyncGenerator<string, string, unknown> {
|
|
384
|
+
const str = (value: unknown): string => typeof value === 'string' ? value : ''
|
|
385
|
+
const num = (value: unknown): number | undefined => typeof value === 'number' ? value : undefined
|
|
386
|
+
|
|
387
|
+
/** Forward live text deltas from a streaming chapter job (text only). */
|
|
388
|
+
const forward = async function* (stream: AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number } | { frame: 'drafted'; chars: number; draft: string }, void, unknown>): AsyncGenerator<string, void, unknown> {
|
|
389
|
+
for await (const step of stream) {
|
|
390
|
+
if (step.frame === 'delta') yield step.text
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
switch (name) {
|
|
395
|
+
case 'book_overview': {
|
|
396
|
+
// 全书上下文包(分片:默认最近 30 章;scope=full 全量;scope=volume:N 指定卷)。
|
|
397
|
+
const scopeArg = str(args.scope)
|
|
398
|
+
const scope = scopeArg === 'full'
|
|
399
|
+
? 'full' as const
|
|
400
|
+
: /^volume:(\d+)$/.test(scopeArg)
|
|
401
|
+
? Number(scopeArg.slice(7))
|
|
402
|
+
: 'recent' as const
|
|
403
|
+
return bookOverview(project, scope)
|
|
404
|
+
}
|
|
405
|
+
case 'facts_query': {
|
|
406
|
+
// 从编年录按关键词检索相关事实。
|
|
407
|
+
const keyword = str(args.keyword).trim()
|
|
408
|
+
if (keyword === '') throw new NovelActionError('contract', 'facts_query 需要 keyword')
|
|
409
|
+
const hits = (project.facts ?? []).filter(f => f.text.includes(keyword)).slice(-30)
|
|
410
|
+
if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`
|
|
411
|
+
return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map(f => `- [第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
412
|
+
}
|
|
413
|
+
case 'impact_analysis': {
|
|
414
|
+
const change = str(args.change)
|
|
415
|
+
if (change === '') throw new NovelActionError('contract', 'impact_analysis 需要 change(要做的修改描述)')
|
|
416
|
+
const items = await analyzeImpact(ctx, config, project, outputDir, change)
|
|
417
|
+
if (items.length === 0) return '影响分析:未发现需要同步修改的位置。'
|
|
418
|
+
const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}(${it.kind === 'must' ? '必须同步' : it.kind === 'optional' ? '建议' : '备注'})`)
|
|
419
|
+
return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join('\n')}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`
|
|
420
|
+
}
|
|
421
|
+
case 'outline_text': {
|
|
422
|
+
return project.outline
|
|
423
|
+
}
|
|
424
|
+
case 'outline_replace': {
|
|
425
|
+
const old = str(args.old)
|
|
426
|
+
const next = str(args.new)
|
|
427
|
+
if (old === '' || !project.outline.includes(old)) {
|
|
428
|
+
throw new NovelActionError('contract', `总纲中未找到片段「${old.slice(0, 40)}…」`)
|
|
429
|
+
}
|
|
430
|
+
project.outline = project.outline.replace(old, next)
|
|
431
|
+
project.updatedAt = new Date().toISOString()
|
|
432
|
+
saveProject(outputDir, project)
|
|
433
|
+
return `总纲已修改:替换了 ${old.length} 字符的片段。`
|
|
434
|
+
}
|
|
435
|
+
case 'bible_set_rule': {
|
|
436
|
+
if (project.bible === undefined) throw new NovelActionError('contract', '尚无道藏,请先提炼')
|
|
437
|
+
const index = num(args.index)
|
|
438
|
+
if (index !== undefined) {
|
|
439
|
+
project.bible.worldRules[index] = str(args.text)
|
|
440
|
+
} else if (str(args.append) !== '') {
|
|
441
|
+
project.bible.worldRules.push(str(args.append))
|
|
442
|
+
} else {
|
|
443
|
+
throw new NovelActionError('contract', 'bible_set_rule 需要 index+text 或 append')
|
|
444
|
+
}
|
|
445
|
+
project.updatedAt = new Date().toISOString()
|
|
446
|
+
saveProject(outputDir, project)
|
|
447
|
+
return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`
|
|
448
|
+
}
|
|
449
|
+
case 'bible_set_redline': {
|
|
450
|
+
if (project.bible === undefined) throw new NovelActionError('contract', '尚无道藏,请先提炼')
|
|
451
|
+
const index = num(args.index)
|
|
452
|
+
if (index !== undefined) {
|
|
453
|
+
project.bible.redLines[index] = str(args.text)
|
|
454
|
+
} else if (str(args.append) !== '') {
|
|
455
|
+
project.bible.redLines.push(str(args.append))
|
|
456
|
+
} else {
|
|
457
|
+
throw new NovelActionError('contract', 'bible_set_redline 需要 index+text 或 append')
|
|
458
|
+
}
|
|
459
|
+
project.updatedAt = new Date().toISOString()
|
|
460
|
+
saveProject(outputDir, project)
|
|
461
|
+
return `写作红线已更新(当前 ${project.bible.redLines.length} 条)。`
|
|
462
|
+
}
|
|
463
|
+
case 'chapter_text': {
|
|
464
|
+
const no = num(args.no)
|
|
465
|
+
if (no === undefined) throw new NovelActionError('contract', 'chapter_text 需要 no')
|
|
466
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
467
|
+
if (chapter === undefined) throw new NovelActionError('contract', `章节 ${no} 不存在`)
|
|
468
|
+
const body = readChapterFile(outputDir, chapter)
|
|
469
|
+
if (body === undefined) throw new NovelActionError('contract', `章节 ${no} 尚未生成`)
|
|
470
|
+
return body
|
|
471
|
+
}
|
|
472
|
+
case 'chapter_rewrite': {
|
|
473
|
+
const no = num(args.no)
|
|
474
|
+
if (no === undefined) throw new NovelActionError('contract', 'chapter_rewrite 需要 no')
|
|
475
|
+
const instructions = str(args.instructions)
|
|
476
|
+
const target = str(args.target)
|
|
477
|
+
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === '' ? undefined : target))) {
|
|
478
|
+
yield chunk
|
|
479
|
+
}
|
|
480
|
+
// Draft mode: the assistant acts on the user's explicit instruction, so
|
|
481
|
+
// apply the draft immediately — summarize/review must see the new body.
|
|
482
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
483
|
+
const draft = chapter?.pendingDraft
|
|
484
|
+
if (chapter === undefined || draft === undefined || draft === '') {
|
|
485
|
+
throw new NovelActionError('contract', `章节 ${no} 修订后没有产出草稿`)
|
|
486
|
+
}
|
|
487
|
+
// 覆盖前留存历史版本(助手直接修订会就地覆盖正文)。
|
|
488
|
+
if (chapter.file !== undefined && chapter.file !== '') createSnapshot(outputDir, chapter, '助手修订前')
|
|
489
|
+
const fileName = chapterFileName(chapter)
|
|
490
|
+
mkdirSync(outputDir, { recursive: true })
|
|
491
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
492
|
+
chapter.pendingDraft = undefined
|
|
493
|
+
chapter.status = 'written'
|
|
494
|
+
chapter.chars = countHanzi(draft)
|
|
495
|
+
chapter.file = fileName
|
|
496
|
+
chapter.review = undefined
|
|
497
|
+
chapter.error = undefined
|
|
498
|
+
project.updatedAt = new Date().toISOString()
|
|
499
|
+
saveProject(outputDir, project)
|
|
500
|
+
yield '(已采纳修订稿,正在生成章节摘要与编年录…)'
|
|
501
|
+
try {
|
|
502
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
503
|
+
} catch { /* best-effort */ }
|
|
504
|
+
yield '(正在 AI 审稿…)'
|
|
505
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
506
|
+
return `章节 ${no} 已${target === '' ? '整章' : '局部'}修订完成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。重新审稿:${report.score} 分 — ${report.verdict}`
|
|
507
|
+
}
|
|
508
|
+
case 'chapter_generate': {
|
|
509
|
+
const no = num(args.no)
|
|
510
|
+
if (no === undefined) throw new NovelActionError('contract', 'chapter_generate 需要 no')
|
|
511
|
+
for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) {
|
|
512
|
+
yield chunk
|
|
513
|
+
}
|
|
514
|
+
yield '(正在生成章节摘要与编年录…)'
|
|
515
|
+
try {
|
|
516
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
517
|
+
} catch { /* best-effort */ }
|
|
518
|
+
yield '(正在 AI 审稿…)'
|
|
519
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
520
|
+
return `章节 ${no} 已生成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。审稿:${report.score} 分 — ${report.verdict}`
|
|
521
|
+
}
|
|
522
|
+
case 'chapter_review': {
|
|
523
|
+
const no = num(args.no)
|
|
524
|
+
if (no === undefined) throw new NovelActionError('contract', 'chapter_review 需要 no')
|
|
525
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
526
|
+
const issues = report.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
527
|
+
return `章节 ${no} 审稿:${report.score} 分 — ${report.verdict}\n${issues}`
|
|
528
|
+
}
|
|
529
|
+
case 'foreshadow_add': {
|
|
530
|
+
const description = str(args.description)
|
|
531
|
+
if (description === '') throw new NovelActionError('contract', 'foreshadow_add 需要 description')
|
|
532
|
+
const targetChapter = num(args.targetChapter)
|
|
533
|
+
project.foreshadows.push({
|
|
534
|
+
id: `fs-${Date.now().toString(36)}`,
|
|
535
|
+
description,
|
|
536
|
+
targetChapter,
|
|
537
|
+
status: 'planned',
|
|
538
|
+
})
|
|
539
|
+
project.updatedAt = new Date().toISOString()
|
|
540
|
+
saveProject(outputDir, project)
|
|
541
|
+
return `已新增暗线:「${description.slice(0, 50)}」`
|
|
542
|
+
}
|
|
543
|
+
case 'foreshadow_update': {
|
|
544
|
+
const id = str(args.id)
|
|
545
|
+
const status = str(args.status) as 'planned' | 'planted' | 'progressing' | 'resolved' | 'abandoned'
|
|
546
|
+
const target = project.foreshadows.find(f => f.id === id)
|
|
547
|
+
if (target === undefined) throw new NovelActionError('contract', `暗线 ${id} 不存在`)
|
|
548
|
+
if (!['planned', 'planted', 'progressing', 'resolved', 'abandoned'].includes(status)) {
|
|
549
|
+
throw new NovelActionError('contract', `非法状态 ${status}`)
|
|
550
|
+
}
|
|
551
|
+
target.status = status
|
|
552
|
+
project.updatedAt = new Date().toISOString()
|
|
553
|
+
saveProject(outputDir, project)
|
|
554
|
+
return `暗线已更新为 ${status}:「${target.description.slice(0, 50)}」`
|
|
555
|
+
}
|
|
556
|
+
case 'export_txt': {
|
|
557
|
+
const result = exportBook(outputDir, project, 'txt')
|
|
558
|
+
return `已导出 TXT:${result.file}(${result.chars} 字,${result.chapters} 章)`
|
|
559
|
+
}
|
|
560
|
+
case 'assets_status': {
|
|
561
|
+
const assets = project.assets
|
|
562
|
+
if (assets === undefined) return '本书尚未配置写作资产。'
|
|
563
|
+
const parts: string[] = []
|
|
564
|
+
if (assets.genre !== undefined) parts.push(`题材:${assets.genre.name}`)
|
|
565
|
+
if (assets.primaryProgression !== undefined) parts.push(`主推进:${assets.primaryProgression.name}`)
|
|
566
|
+
if (assets.auxiliaryProgressions.length > 0) parts.push(`辅助推进:${assets.auxiliaryProgressions.map(m => m.name).join('、')}`)
|
|
567
|
+
if (assets.antiAiRules.length > 0) parts.push(`自定义反AI规则:${assets.antiAiRules.map(r => r.name).join('、')}`)
|
|
568
|
+
if (assets.styleAssets.length > 0) parts.push(`写法资产:${assets.styleAssets.map(s => s.name).join('、')}`)
|
|
569
|
+
return parts.length > 0 ? parts.join('\n') : '本书尚未配置写作资产。'
|
|
570
|
+
}
|
|
571
|
+
case 'assets_set_genre': {
|
|
572
|
+
const name = str(args.name)
|
|
573
|
+
const description = str(args.description)
|
|
574
|
+
if (name === '') throw new NovelActionError('contract', 'assets_set_genre 需要 name')
|
|
575
|
+
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
576
|
+
project.assets.genre = { name, description, children: [] }
|
|
577
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
578
|
+
project.updatedAt = new Date().toISOString()
|
|
579
|
+
saveProject(outputDir, project)
|
|
580
|
+
return `题材已设为「${name}」`
|
|
581
|
+
}
|
|
582
|
+
case 'assets_set_progression': {
|
|
583
|
+
const name = str(args.name)
|
|
584
|
+
const driver = str(args.driver)
|
|
585
|
+
const primary = args.primary !== false
|
|
586
|
+
if (name === '') throw new NovelActionError('contract', 'assets_set_progression 需要 name')
|
|
587
|
+
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
588
|
+
const mode = {
|
|
589
|
+
name,
|
|
590
|
+
driver: driver !== '' ? driver : name,
|
|
591
|
+
readerExpectation: str(args.readerExpectation),
|
|
592
|
+
payoffs: Array.isArray(args.payoffs) ? args.payoffs.filter((v): v is string => typeof v === 'string') : [],
|
|
593
|
+
risks: Array.isArray(args.risks) ? args.risks.filter((v): v is string => typeof v === 'string') : [],
|
|
594
|
+
primary,
|
|
595
|
+
}
|
|
596
|
+
if (primary) project.assets.primaryProgression = mode
|
|
597
|
+
else {
|
|
598
|
+
if (project.assets.auxiliaryProgressions === undefined) project.assets.auxiliaryProgressions = []
|
|
599
|
+
project.assets.auxiliaryProgressions.push(mode)
|
|
600
|
+
}
|
|
601
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
602
|
+
project.updatedAt = new Date().toISOString()
|
|
603
|
+
saveProject(outputDir, project)
|
|
604
|
+
return `推进模式${primary ? '(主)' : '(辅助)'}已设置:「${name}」`
|
|
605
|
+
}
|
|
606
|
+
case 'assets_add_rule': {
|
|
607
|
+
const name = str(args.name)
|
|
608
|
+
const avoid = str(args.avoid)
|
|
609
|
+
if (avoid === '') throw new NovelActionError('contract', 'assets_add_rule 需要 avoid(要避免的表达问题)')
|
|
610
|
+
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
611
|
+
if (project.assets.antiAiRules === undefined) project.assets.antiAiRules = []
|
|
612
|
+
project.assets.antiAiRules.push({
|
|
613
|
+
name: name !== '' ? name : `自定义规则 ${project.assets.antiAiRules.length + 1}`,
|
|
614
|
+
avoid,
|
|
615
|
+
fix: str(args.fix),
|
|
616
|
+
})
|
|
617
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
618
|
+
project.updatedAt = new Date().toISOString()
|
|
619
|
+
saveProject(outputDir, project)
|
|
620
|
+
return `已新增反 AI 规则「${name !== '' ? name : avoid.slice(0, 20)}」`
|
|
621
|
+
}
|
|
622
|
+
case 'book_analysis': {
|
|
623
|
+
// 拆书:分析当前书(默认最近30章要点)的卖点/结构/可借鉴/风险。
|
|
624
|
+
const scopeArg = str(args.scope)
|
|
625
|
+
const scope = scopeArg === 'full' ? 'full' as const : scopeArg === 'all' ? 'full' as const : (/^volume:(\d+)$/.test(scopeArg) ? Number(scopeArg.slice(7)) : 'recent' as const)
|
|
626
|
+
const text = bookOverview(project, scope)
|
|
627
|
+
const result = await runBookAnalysis(ctx, config, { text })
|
|
628
|
+
return [
|
|
629
|
+
'拆书结果:',
|
|
630
|
+
'卖点:',
|
|
631
|
+
...(result.sellingPoints ?? []).map(s => `- ${s}`),
|
|
632
|
+
'结构:',
|
|
633
|
+
...(result.structure ?? []).map(s => `- ${s}`),
|
|
634
|
+
'可借鉴:',
|
|
635
|
+
...(result.lessons ?? []).map(s => `- ${s}`),
|
|
636
|
+
'风险:',
|
|
637
|
+
...(result.risks ?? []).map(s => `- ${s}`),
|
|
638
|
+
].join('\n')
|
|
639
|
+
}
|
|
640
|
+
case 'director_advice': {
|
|
641
|
+
// 自动编辑:基于全书上下文的下一阶段编排 + 修复再平衡。
|
|
642
|
+
const focus = str(args.focus)
|
|
643
|
+
const result = await runDirectorAdvice(ctx, config, project, { focus })
|
|
644
|
+
return [
|
|
645
|
+
'自动编辑建议:',
|
|
646
|
+
`总体判断:${result.summary}`,
|
|
647
|
+
'下一阶段节点:',
|
|
648
|
+
...(result.nextArc ?? []).map(s => `- ${s}`),
|
|
649
|
+
`节奏板:${result.pacing}`,
|
|
650
|
+
'风险提示:',
|
|
651
|
+
...(result.risks ?? []).map(s => `- ${s}`),
|
|
652
|
+
'需要修复/再平衡:',
|
|
653
|
+
...(result.fixes ?? []).map(s => `- ${s}`),
|
|
654
|
+
].join('\n')
|
|
655
|
+
}
|
|
656
|
+
case 'knowledge_add': {
|
|
657
|
+
const title = str(args.title).trim()
|
|
658
|
+
const content = str(args.content).trim()
|
|
659
|
+
if (title === '' || content === '') throw new NovelActionError('contract', 'knowledge_add 需要 title 和 content')
|
|
660
|
+
project.knowledgeDocs ??= []
|
|
661
|
+
project.knowledgeDocs.push({ id: `kd-${Date.now().toString(36)}`, title, content, updatedAt: new Date().toISOString() })
|
|
662
|
+
project.updatedAt = new Date().toISOString()
|
|
663
|
+
saveProject(outputDir, project)
|
|
664
|
+
return `已加入知识库「${title}」(当前 ${project.knowledgeDocs.length} 篇)`
|
|
665
|
+
}
|
|
666
|
+
case 'knowledge_search': {
|
|
667
|
+
const q = str(args.query).trim()
|
|
668
|
+
if (q === '') throw new NovelActionError('contract', 'knowledge_search 需要 query')
|
|
669
|
+
const ql = q.toLowerCase()
|
|
670
|
+
const hits = (project.knowledgeDocs ?? []).filter(d => d.title.toLowerCase().includes(ql) || d.content.toLowerCase().includes(ql)).slice(-5)
|
|
671
|
+
if (hits.length === 0) return '知识库中未找到相关内容。'
|
|
672
|
+
return `知识库中与「${q}」相关(${hits.length} 篇):\n` + hits.map(d => `- 【${d.title}】\n${d.content.slice(0, 400)}`).join('\n')
|
|
673
|
+
}
|
|
674
|
+
case 'plotline_list': {
|
|
675
|
+
const lines = project.plotlines ?? []
|
|
676
|
+
if (lines.length === 0) return '本书还没有剧情线。'
|
|
677
|
+
return `本书剧情线(${lines.length} 条):\n` + lines.map(l => `- [${l.status}] ${l.name}(${l.kind}):${l.goal}${l.progress !== '' ? `|${l.progress}` : ''}`).join('\n')
|
|
678
|
+
}
|
|
679
|
+
case 'plotline_add': {
|
|
680
|
+
const name = str(args.name).trim()
|
|
681
|
+
const goal = str(args.goal).trim()
|
|
682
|
+
if (name === '' || goal === '') throw new NovelActionError('contract', 'plotline_add 需要 name 和 goal')
|
|
683
|
+
const kindArg = str(args.kind)
|
|
684
|
+
const kind = (['main', 'branch', 'character', 'mystery'].includes(kindArg) ? kindArg : 'branch') as 'main' | 'branch' | 'character' | 'mystery'
|
|
685
|
+
const statusArg = str(args.status)
|
|
686
|
+
const status = (['active', 'paused', 'resolved', 'abandoned'].includes(statusArg) ? statusArg : 'active') as 'active' | 'paused' | 'resolved' | 'abandoned'
|
|
687
|
+
project.plotlines ??= []
|
|
688
|
+
project.plotlines.push({ id: `pl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, name, kind, goal, progress: str(args.progress), status, chapters: Array.isArray(args.chapters) ? (args.chapters as number[]) : [], createdAt: new Date().toISOString() })
|
|
689
|
+
project.updatedAt = new Date().toISOString()
|
|
690
|
+
saveProject(outputDir, project)
|
|
691
|
+
return `已加入剧情线「${name}」(当前 ${project.plotlines.length} 条)`
|
|
692
|
+
}
|
|
693
|
+
case 'director_todo_add': {
|
|
694
|
+
const text = str(args.text).trim()
|
|
695
|
+
if (text === '') throw new NovelActionError('contract', 'director_todo_add 需要 text')
|
|
696
|
+
const source = args.source === 'fix' ? 'fix' as const : 'risk' as const
|
|
697
|
+
project.todos ??= []
|
|
698
|
+
project.todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text, source, done: false, createdAt: new Date().toISOString() })
|
|
699
|
+
project.updatedAt = new Date().toISOString()
|
|
700
|
+
saveProject(outputDir, project)
|
|
701
|
+
return `已加入编辑待办「${text.slice(0, 30)}」(当前 ${project.todos.length} 条)`
|
|
702
|
+
}
|
|
703
|
+
case 'director_todo_list': {
|
|
704
|
+
const todos = project.todos ?? []
|
|
705
|
+
if (todos.length === 0) return '还没有编辑待办。'
|
|
706
|
+
return `编辑待办(${todos.length} 条):\n` + todos.map(t => `- ${t.done ? '[已处理]' : '[待处理]'} ${t.text}`).join('\n')
|
|
707
|
+
}
|
|
708
|
+
case 'knowledge_list': {
|
|
709
|
+
const docs = project.knowledgeDocs ?? []
|
|
710
|
+
if (docs.length === 0) return '本书知识库目前为空。'
|
|
711
|
+
return `本书知识库(${docs.length} 篇):\n` + docs.map((d, i) => `[${i + 1}]《${d.title}》\n${d.content}`).join('\n\n')
|
|
712
|
+
}
|
|
713
|
+
case 'breakdown': {
|
|
714
|
+
// 书内拆书分析:对本书已写章节做结构/人物/文风/卖点体检。
|
|
715
|
+
const scope = str(args.scope) === 'all' ? 'all' : /^volume:\d+$/.test(str(args.scope)) ? str(args.scope) : 'recent'
|
|
716
|
+
const preset = str(args.preset) === 'standard' ? 'standard' as const : 'quick' as const
|
|
717
|
+
const result = await breakdownBook(ctx, config, project, outputDir, scope, preset)
|
|
718
|
+
const lines = [`拆书分析结果(扫描 ${result.chaptersScanned} 章):`]
|
|
719
|
+
for (const s of result.sections ?? []) {
|
|
720
|
+
lines.push(`\n【${s.title}】\n${s.markdown}`)
|
|
721
|
+
}
|
|
722
|
+
return lines.join('\n')
|
|
723
|
+
}
|
|
724
|
+
case 'audit': {
|
|
725
|
+
// 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库。
|
|
726
|
+
const issues = await auditBook(ctx, config, project, outputDir)
|
|
727
|
+
if (issues.length === 0) return '全书质检:未发现明显矛盾。'
|
|
728
|
+
return `全书质检发现 ${issues.length} 处问题:\n` + issues.map(it => `- [${it.severity}] 第${it.chapterNo}章:${it.item}${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}`).join('\n')
|
|
729
|
+
}
|
|
730
|
+
case 'blurb': {
|
|
731
|
+
// 小说简介:生成/补全并保存到项目。
|
|
732
|
+
const partial = str(args.partial)
|
|
733
|
+
const text = await generateBlurb(ctx, config, project, partial)
|
|
734
|
+
project.blurb = text
|
|
735
|
+
project.updatedAt = new Date().toISOString()
|
|
736
|
+
saveProject(outputDir, project)
|
|
737
|
+
return `简介已生成/更新:\n${text}`
|
|
738
|
+
}
|
|
739
|
+
default:
|
|
740
|
+
throw new NovelActionError('contract', `未知工具 ${name}`)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ------------------------------------------------------------------- chat
|
|
745
|
+
|
|
746
|
+
/** Extract the first action directive from a reply (tolerant to common tag misspellings). */
|
|
747
|
+
function extractAction(reply: string): { name: string; args: Record<string, unknown>; index: number } | undefined {
|
|
748
|
+
// 容错:dsh-action 的常见误拼(dash-action / dsah-action / dhs-action 等)。
|
|
749
|
+
const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply)
|
|
750
|
+
if (match === null) return undefined
|
|
751
|
+
const rawArgs = match[3]?.trim() ?? ''
|
|
752
|
+
let args: Record<string, unknown>
|
|
753
|
+
try {
|
|
754
|
+
args = rawArgs === '' ? {} : JSON.parse(rawArgs) as Record<string, unknown>
|
|
755
|
+
} catch {
|
|
756
|
+
throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`)
|
|
757
|
+
}
|
|
758
|
+
return { name: match[2] ?? '', args, index: match.index }
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
762
|
+
function historyToMessages(history: AssistantMessage[]): Message[] {
|
|
763
|
+
const recent = history.slice(-MAX_HISTORY_MESSAGES)
|
|
764
|
+
const messages: Message[] = []
|
|
765
|
+
for (const entry of recent) {
|
|
766
|
+
if (entry.role === 'user') {
|
|
767
|
+
messages.push(createUserMessage({
|
|
768
|
+
content: [{ type: 'text', text: entry.content }],
|
|
769
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
770
|
+
}))
|
|
771
|
+
} else if (entry.role === 'assistant') {
|
|
772
|
+
messages.push(createAssistantMessage({
|
|
773
|
+
content: [{ type: 'text', text: entry.content }],
|
|
774
|
+
source: { provider: 'deepseek-official', model: 'deepseek-flash' },
|
|
775
|
+
}))
|
|
776
|
+
} else if (entry.role === 'tool') {
|
|
777
|
+
// 工具结果(book_overview/总纲全文等可能很大)截断后进上下文,
|
|
778
|
+
// 避免多轮对话上下文翻倍膨胀触发「上下文到限制」。
|
|
779
|
+
const body = entry.content.length > 2000
|
|
780
|
+
? entry.content.slice(0, 2000) + '\n…(结果过长已截断,需要完整内容请重新调用工具)'
|
|
781
|
+
: entry.content
|
|
782
|
+
messages.push(createUserMessage({
|
|
783
|
+
content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${body}` }],
|
|
784
|
+
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
785
|
+
}))
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return messages
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
792
|
+
async function chatOnce(
|
|
793
|
+
ctx: Context,
|
|
794
|
+
config: NovelConfig,
|
|
795
|
+
system: string,
|
|
796
|
+
history: AssistantMessage[],
|
|
797
|
+
): Promise<string> {
|
|
798
|
+
const messages = historyToMessages(history)
|
|
799
|
+
// 纪律提醒贴在最后一条消息(当前用户输入)末尾:紧邻模型要生成回复的位置,
|
|
800
|
+
// 比放在 system 里更不容易被长上下文稀释(位置效应)。
|
|
801
|
+
// 注意:dsh-llm 的消息/内容块对象是只读的,必须重建新对象,不能直接改属性。
|
|
802
|
+
const last = messages[messages.length - 1]
|
|
803
|
+
if (last?.role === 'user' && Array.isArray(last.content)) {
|
|
804
|
+
const blocks = last.content as ContentBlock[]
|
|
805
|
+
const idx = blocks.findIndex(b => b.type === 'text')
|
|
806
|
+
if (idx !== -1) {
|
|
807
|
+
const textBlock = blocks[idx] as { type: 'text'; text: string }
|
|
808
|
+
const newBlocks: ContentBlock[] = blocks.map((b, i) => i === idx
|
|
809
|
+
? { ...textBlock, text: textBlock.text + '\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name="工具名">{"参数":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)' }
|
|
810
|
+
: b)
|
|
811
|
+
messages[messages.length - 1] = { ...last, content: newBlocks }
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** 用指定模型跑一次(含实况打点与 token 记账)。 */
|
|
815
|
+
const runOnce = async (model: string): Promise<string> => {
|
|
816
|
+
const request: GenerateOptions = {
|
|
817
|
+
provider: config.provider,
|
|
818
|
+
model,
|
|
819
|
+
messages,
|
|
820
|
+
system,
|
|
821
|
+
// 推理模型的 reasoning channel 占预算,给足避免回复被截断。
|
|
822
|
+
maxTokens: Math.max(config.maxTokens, 16000),
|
|
823
|
+
temperature: 0.7,
|
|
824
|
+
}
|
|
825
|
+
// 实况打点:助手对话同样计入用量账本,并能查看实际发送的 Prompt。
|
|
826
|
+
const live = beginLiveCall({ label: 'AI 编辑对话', model, system, user: messages.map(m => JSON.stringify(m.content)).join(' / ').slice(0, 8000) })
|
|
827
|
+
const assembler = new BlockAssembler()
|
|
828
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
829
|
+
if (chunk.type === 'text-delta') markFirstToken(live)
|
|
830
|
+
assembler.push(chunk)
|
|
831
|
+
}
|
|
832
|
+
const finish = assembler.finish
|
|
833
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
834
|
+
const message = `助手调用失败(${finish.kind}): ${finish.failure.message}`
|
|
835
|
+
endLiveCall(live, assembler, { phase: 'failed', error: message })
|
|
836
|
+
throw new Error(message)
|
|
837
|
+
}
|
|
838
|
+
const blocks = assembler.blocks()
|
|
839
|
+
const textBlocks = blocks
|
|
840
|
+
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
841
|
+
.map(block => block.text)
|
|
842
|
+
let text = textBlocks.join('\n').trim()
|
|
843
|
+
if (text === '') {
|
|
844
|
+
const reasoning = blocks
|
|
845
|
+
.filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
|
|
846
|
+
.map(block => block.text)
|
|
847
|
+
.join('\n')
|
|
848
|
+
.trim()
|
|
849
|
+
if (reasoning !== '') text = reasoning
|
|
850
|
+
}
|
|
851
|
+
endLiveCall(live, assembler, { chars: text.length, preview: text.slice(0, 320), phase: text === '' ? 'failed' : 'completed' })
|
|
852
|
+
return text
|
|
853
|
+
}
|
|
854
|
+
// 主模型失败(网络/限流/额度类)且配置了备用模型时,自动换模型重试一次。
|
|
855
|
+
return withModelFallback({ model: config.model, fallbackModel: config.fallbackModel }, runOnce)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Run one user turn. Yields stream frames; persists history. */
|
|
859
|
+
export async function* runAssistantTurn(
|
|
860
|
+
ctx: Context,
|
|
861
|
+
config: NovelConfig,
|
|
862
|
+
project: ProjectState,
|
|
863
|
+
outputDir: string,
|
|
864
|
+
userMessage: string,
|
|
865
|
+
): AsyncGenerator<
|
|
866
|
+
| { frame: 'delta'; text: string }
|
|
867
|
+
| { frame: 'tool'; name: string; status: 'start' | 'done' | 'error'; detail?: string }
|
|
868
|
+
| { frame: 'toolDelta'; name: string; text: string }
|
|
869
|
+
| { frame: 'toolResult'; name: string; text: string },
|
|
870
|
+
void,
|
|
871
|
+
unknown
|
|
872
|
+
> {
|
|
873
|
+
const history = loadAssistantHistory(outputDir)
|
|
874
|
+
// 阶段契约先算:它同时决定提示词(阶段外动作是否可见)与执行守卫。
|
|
875
|
+
const stage: BookStage = computeBookStage(project)
|
|
876
|
+
const strictStage = isGenericContinue(userMessage)
|
|
877
|
+
const system = assistantSystemPrompt(project, strictStage)
|
|
878
|
+
|
|
879
|
+
// Persist the user message.
|
|
880
|
+
const userEntry: AssistantMessage = { role: 'user', content: userMessage, ts: new Date().toISOString() }
|
|
881
|
+
history.push(userEntry)
|
|
882
|
+
appendHistory(outputDir, userEntry)
|
|
883
|
+
|
|
884
|
+
let round = 0
|
|
885
|
+
/** 最大迭代次数(防空转的总保险,与「已执行动作数 round」分开,避免计数混淆)。 */
|
|
886
|
+
let iterations = 0
|
|
887
|
+
/** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
|
|
888
|
+
let nudged = 0
|
|
889
|
+
const MAX_NUDGES = 6
|
|
890
|
+
/** 连续收到 hex 乱码回复的次数(≥2 次判定 LLM 侧异常,放弃本轮避免死循环)。 */
|
|
891
|
+
let garbleCount = 0
|
|
892
|
+
|
|
893
|
+
// ---- 写操作意图守卫(规则表在模块级 WRITE_TOOL_KEYS)----------------
|
|
894
|
+
// ---- 阶段契约 ------------------------------------------------------
|
|
895
|
+
// 宿主根据项目真实状态算出「当前阶段 → 本轮允许的动作」。作者只说了
|
|
896
|
+
// 「继续/下一步」这类没有具体对象的指令时,阶段白名单成为唯一命令;
|
|
897
|
+
// 作者显式点名某个操作时以作者指令为准(作者终审权)。
|
|
898
|
+
|
|
899
|
+
/** 本轮已放行过写操作:后续写操作(生成→审稿→修订闭环)不再逐个拦截。 */
|
|
900
|
+
let writeUnlocked = false
|
|
901
|
+
/** 守卫判定结果:拒绝时必须给出可执行的、阶段相关的理由。 */
|
|
902
|
+
const guardWrite = (name: string): { allowed: true } | { allowed: false; reason: string } => {
|
|
903
|
+
if (writeUnlocked) return { allowed: true }
|
|
904
|
+
const key = WRITE_TOOL_KEYS[name]
|
|
905
|
+
if (key === undefined) return { allowed: true } // 只读工具 / 未登记工具一律放行
|
|
906
|
+
// 近两轮用户消息合并判断(支持"继续"类延续指令)。
|
|
907
|
+
const recentUsers = history
|
|
908
|
+
.filter(m => m.role === 'user')
|
|
909
|
+
.slice(-2)
|
|
910
|
+
.map(m => m.content)
|
|
911
|
+
.join('\n')
|
|
912
|
+
if (strictStage) {
|
|
913
|
+
// 通用续写指令(「继续/下一步」):宿主算出的阶段就是唯一命令。
|
|
914
|
+
if (stageAllows(stage, name)) {
|
|
915
|
+
writeUnlocked = true
|
|
916
|
+
return { allowed: true }
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
allowed: false,
|
|
920
|
+
reason: `当前阶段是「${stage.label}」——${stage.reason}。本轮只说了「继续」,因此只允许:${stage.allow.length > 0 ? stage.allow.join('、') : '无写操作'};${name} 属于本阶段之外的写操作。若要强制执行,请明确点名该操作。`,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!key.test(recentUsers)) {
|
|
924
|
+
return { allowed: false, reason: `当前消息里没有明确要求执行「${name}」这类写操作` }
|
|
925
|
+
}
|
|
926
|
+
writeUnlocked = true
|
|
927
|
+
return { allowed: true }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** 同一调用签名(工具名 + 规范化参数)的失败记账:达到上限即 fail-stop。 */
|
|
931
|
+
const failures = new FailureLedger()
|
|
932
|
+
/** 动作标签本身不是合法 JSON 的次数:第一次喂回修正提示,第二次直接停止。 */
|
|
933
|
+
let malformedActionCount = 0
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 组装并落盘一条结束语(调用方负责 yield + return)。
|
|
937
|
+
*
|
|
938
|
+
* @param text 结束语正文。
|
|
939
|
+
* @returns 已落盘的历史条目。
|
|
940
|
+
*/
|
|
941
|
+
const makeStopEntry = (text: string): AssistantMessage => {
|
|
942
|
+
const entry: AssistantMessage = { role: 'assistant', content: text, ts: new Date().toISOString() }
|
|
943
|
+
history.push(entry)
|
|
944
|
+
appendHistory(outputDir, entry)
|
|
945
|
+
return entry
|
|
946
|
+
}
|
|
947
|
+
for (;;) {
|
|
948
|
+
if (iterations++ > 20) break
|
|
949
|
+
const reply = await chatOnce(ctx, config, system, history)
|
|
950
|
+
|
|
951
|
+
// 异常输出防护:全 hex/二进制乱码(模型偶发把回复编码成十六进制)——丢弃重试一次。
|
|
952
|
+
const hexLike = reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2000))
|
|
953
|
+
if (hexLike) {
|
|
954
|
+
garbleCount++
|
|
955
|
+
if (garbleCount >= 2) {
|
|
956
|
+
const garbleEntry: AssistantMessage = { role: 'assistant', content: '(模型本次返回了异常编码内容,已忽略;请重新描述你的问题。)', ts: new Date().toISOString() }
|
|
957
|
+
history.push(garbleEntry)
|
|
958
|
+
appendHistory(outputDir, garbleEntry)
|
|
959
|
+
yield { frame: 'delta', text: garbleEntry.content }
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
continue
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// 动作标签本身不是合法 JSON:第一次给模型一次修正机会,第二次直接停止
|
|
966
|
+
// (fail-stop:禁止反复用非法参数试探,白耗额度)。
|
|
967
|
+
let action: { name: string; args: Record<string, unknown>; index: number } | undefined
|
|
968
|
+
try {
|
|
969
|
+
action = extractAction(reply)
|
|
970
|
+
} catch (error) {
|
|
971
|
+
malformedActionCount++
|
|
972
|
+
const message = (error as Error).message
|
|
973
|
+
if (malformedActionCount >= 2) {
|
|
974
|
+
const stopped = makeStopEntry(`【已停止】动作参数连续两次不是合法 JSON,本轮不再重试。\n错误:${message}\n请把参数写成一段合法 JSON(字符串内不要有真实换行符),再重新发出指令。`)
|
|
975
|
+
yield { frame: 'tool', name: 'format-hint', status: 'error', detail: message }
|
|
976
|
+
yield { frame: 'delta', text: stopped.content }
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
const hint = `【参数错误】${message}\n修正要求:动作参数必须是一段合法 JSON,例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>;字符串值内不得有真实换行符。这是唯一一次修正机会,请勿原样重复同一调用。`
|
|
980
|
+
history.push({ role: 'tool', content: hint, tool: 'format-hint', ts: new Date().toISOString() })
|
|
981
|
+
appendHistory(outputDir, { role: 'tool', content: hint, tool: 'format-hint', ts: new Date().toISOString() })
|
|
982
|
+
yield { frame: 'tool', name: 'format-hint', status: 'error', detail: message }
|
|
983
|
+
continue
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (action === undefined) {
|
|
987
|
+
// No parseable action tag. If the reply clearly intends to modify
|
|
988
|
+
// something (or contains a malformed action tag), nudge — repeatedly,
|
|
989
|
+
// with escalating firmness, until the model emits a tag or gives up.
|
|
990
|
+
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply)
|
|
991
|
+
// 兜底:回复提到任何可操作对象名词(即使动词是"看/查/说"的变体)也视为有操作意图,
|
|
992
|
+
// 避免"先查一遍编年录"这类措辞漏网被静默当作纯聊天。
|
|
993
|
+
const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply)
|
|
994
|
+
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
|
|
995
|
+
// 只在「本轮还没执行过任何工具」时才逼它出标签;一旦已经执行过(round>0),
|
|
996
|
+
// 后续的纯文字回复一律当作收尾,绝不 nudge,避免「已加入…设定」被误判成还想操作而反复执行。
|
|
997
|
+
if ((intendsAction || mentionsTarget || strayTag) && round === 0 && nudged < MAX_NUDGES) {
|
|
998
|
+
// 用户消息本身没有写意图时,引导模型直接文字回答(不要被逼出写操作标签)。
|
|
999
|
+
const userWriteIntent = Object.values(WRITE_TOOL_KEYS).some(re => re.test(userMessage))
|
|
1000
|
+
const nudge = nudged === 0
|
|
1001
|
+
? (userWriteIntent
|
|
1002
|
+
? '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
|
|
1003
|
+
: '你刚才的回复看起来在讨论项目内容,但没有必要执行任何操作。如果用户只是在提问或闲聊,请直接以文字回答即可,不要输出动作标签,也不要自行调用任何写操作(生成/修订/删除等只有用户明确要求时才允许)。若确实需要先查看数据,最多使用只读工具(outline_text / chapter_text / book_overview / facts_query)。')
|
|
1004
|
+
: (userWriteIntent
|
|
1005
|
+
? `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`
|
|
1006
|
+
: `你第 ${nudged + 1} 次回复仍不需要执行操作。再次强调:用户没有要求修改,请直接给出文字回答(可以简短引用项目数据),不要输出动作标签。写操作只会在用户明确要求时被允许。`)
|
|
1007
|
+
nudged++
|
|
1008
|
+
history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
1009
|
+
appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
1010
|
+
continue
|
|
1011
|
+
}
|
|
1012
|
+
// Plain prose reply — done.
|
|
1013
|
+
const assistantEntry: AssistantMessage = { role: 'assistant', content: reply, ts: new Date().toISOString() }
|
|
1014
|
+
history.push(assistantEntry)
|
|
1015
|
+
appendHistory(outputDir, assistantEntry)
|
|
1016
|
+
// Stream the prose (without any stray action markup).
|
|
1017
|
+
yield { frame: 'delta', text: reply }
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Execute the action, then feed the result back and continue.
|
|
1022
|
+
const { name, args, index } = action
|
|
1023
|
+
const prose = reply.slice(0, index).trim()
|
|
1024
|
+
// 写操作守卫 + 阶段契约:授权不足、或阶段外的写操作一律拒绝并提示
|
|
1025
|
+
// (不写盘、不消耗生成额度)。
|
|
1026
|
+
const gate = guardWrite(name)
|
|
1027
|
+
if (!gate.allowed) {
|
|
1028
|
+
const denied = `【操作被拒绝】${name} 是写操作(会修改正文/项目数据),${gate.reason}。请作者明确说明要做什么(如「生成第 120 章」「把第 105 章结尾改一下」),我不会擅自修改你的作品。`
|
|
1029
|
+
history.push({ role: 'tool', content: denied, tool: name, ts: new Date().toISOString() })
|
|
1030
|
+
appendHistory(outputDir, { role: 'tool', content: denied, tool: name, ts: new Date().toISOString() })
|
|
1031
|
+
yield { frame: 'tool', name, status: 'error', detail: denied }
|
|
1032
|
+
// 拒绝后结束本轮:等作者重新明确指令。
|
|
1033
|
+
const assistantEntry = makeStopEntry(denied)
|
|
1034
|
+
yield { frame: 'delta', text: assistantEntry.content }
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
yield { frame: 'tool', name, status: 'start' }
|
|
1038
|
+
let result: string
|
|
1039
|
+
try {
|
|
1040
|
+
// executeAction is an async generator: it yields live progress text
|
|
1041
|
+
// (chapter text being generated) and returns the final result string.
|
|
1042
|
+
// Iterate manually so we see both the yielded deltas and the return.
|
|
1043
|
+
const iterator = executeAction(ctx, config, project, outputDir, name, args)[Symbol.asyncIterator]()
|
|
1044
|
+
result = ''
|
|
1045
|
+
for (;;) {
|
|
1046
|
+
const step = await iterator.next()
|
|
1047
|
+
if (step.done === true) {
|
|
1048
|
+
result = typeof step.value === 'string' ? step.value : ''
|
|
1049
|
+
break
|
|
1050
|
+
}
|
|
1051
|
+
const chunk = step.value
|
|
1052
|
+
if (typeof chunk === 'string' && chunk !== '') {
|
|
1053
|
+
yield { frame: 'toolDelta', name, text: chunk }
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
yield { frame: 'tool', name, status: 'done', detail: result.slice(0, 200) }
|
|
1057
|
+
yield { frame: 'toolResult', name, text: result.slice(0, 4000) }
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
// fail-stop:错误分级 + 同一调用签名去重。
|
|
1060
|
+
// - contract(参数/契约类):总尝试次数上限 1,即禁止原样重复同一调用;
|
|
1061
|
+
// - transient(瞬时故障):上限 2,即自动允许一次重试。
|
|
1062
|
+
const kind = classifyActionError(error)
|
|
1063
|
+
const message = (error as Error).message
|
|
1064
|
+
const { attempts, stop } = failures.record(name, args, kind)
|
|
1065
|
+
yield { frame: 'tool', name, status: 'error', detail: message }
|
|
1066
|
+
if (stop) {
|
|
1067
|
+
const reason = kind === 'contract'
|
|
1068
|
+
? `同一调用(${name},相同参数)已失败 ${attempts} 次,属于参数/契约类错误,宿主不再重试`
|
|
1069
|
+
: `${name} 连续失败 ${attempts} 次(瞬时故障),本轮不再重试`
|
|
1070
|
+
const stopped = makeStopEntry(`【已停止】${reason}。\n最后一次错误:${message}\n请修正参数或改用其它方式;需要继续时请重新明确说明你的要求。`)
|
|
1071
|
+
yield { frame: 'delta', text: stopped.content }
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
result = kind === 'contract'
|
|
1075
|
+
? `执行失败(参数/契约类错误,宿主不会重试同一调用):${message}\n注意:这是唯一一次修正机会,请调整参数后重试,禁止原样重复本次调用。`
|
|
1076
|
+
: `执行失败(瞬时故障,可再重试一次):${message}`
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Persist assistant prose + tool result as history entries.
|
|
1080
|
+
if (prose !== '') {
|
|
1081
|
+
history.push({ role: 'assistant', content: prose, ts: new Date().toISOString() })
|
|
1082
|
+
appendHistory(outputDir, { role: 'assistant', content: prose, ts: new Date().toISOString() })
|
|
1083
|
+
}
|
|
1084
|
+
history.push({ role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
|
|
1085
|
+
appendHistory(outputDir, { role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
|
|
1086
|
+
|
|
1087
|
+
round++
|
|
1088
|
+
if (round >= MAX_TOOL_ROUNDS) {
|
|
1089
|
+
const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`
|
|
1090
|
+
history.push({ role: 'assistant', content: message, ts: new Date().toISOString() })
|
|
1091
|
+
appendHistory(outputDir, { role: 'assistant', content: message, ts: new Date().toISOString() })
|
|
1092
|
+
yield { frame: 'delta', text: message }
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
export { chapterFileName }
|