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