@waterwx/dsh-novel-forge 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +4563 -1093
- package/lib/client.js.map +1 -1
- package/lib/index.js +1138 -92
- package/lib/index.js.map +1 -1
- package/lib/types/assets.d.ts +2 -2
- package/lib/types/assistant.d.ts +2 -0
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +61 -2
- package/lib/types/client/locales.d.ts +24 -24
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +11 -0
- package/lib/types/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/engine.d.ts +67 -6
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +177 -0
- package/package.json +1 -13
- package/src/assets.ts +9 -7
- package/src/assistant.ts +152 -32
- package/src/bookshelf.ts +14 -0
- package/src/client/api.ts +80 -3
- package/src/client/locales.ts +24 -24
- package/src/client/panel/AssetsTab.tsx +139 -41
- package/src/client/panel/AssistantTab.tsx +136 -23
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/CreateBookView.tsx +189 -0
- package/src/client/panel/NovelPanel.tsx +1834 -201
- package/src/client/panel/ShelfView.tsx +112 -0
- package/src/client/panel/WorldTab.tsx +202 -0
- package/src/client/panel/panel.module.css +1210 -59
- package/src/engine.ts +618 -29
- package/src/index.ts +1 -1
- package/src/protocol.ts +189 -1
- package/src/routes.ts +511 -14
package/package.json
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
8
8
|
},
|
|
9
|
-
"publishConfig": {
|
|
10
|
-
"access": "public",
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"repository": {
|
|
14
|
-
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/watersxya/dsh-novel-forge.git"
|
|
16
|
-
},
|
|
17
|
-
"bugs": {
|
|
18
|
-
"url": "https://github.com/watersxya/dsh-novel-forge/issues"
|
|
19
|
-
},
|
|
20
|
-
"homepage": "https://github.com/watersxya/dsh-novel-forge",
|
|
21
9
|
"main": "lib/index.js",
|
|
22
10
|
"types": "lib/types/index.d.ts",
|
|
23
11
|
"exports": {
|
package/src/assets.ts
CHANGED
|
@@ -422,13 +422,14 @@ export function effectiveAntiAiRules(assets: ProjectAssets | undefined): AntiAiR
|
|
|
422
422
|
return [...BUILTIN_ANTI_AI_RULES.filter(r => !customNames.has(r.name)), ...custom]
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
/**
|
|
425
|
+
/** 把生效规则渲染成提示词块(压缩:avoid/fix 截断,省 token)。 */
|
|
426
426
|
export function renderAntiAiRules(assets: ProjectAssets | undefined): string {
|
|
427
427
|
const rules = effectiveAntiAiRules(assets)
|
|
428
428
|
if (rules.length === 0) return ''
|
|
429
|
+
const clip = (value: string, max: number): string => value.length > max ? value.slice(0, max) + '…' : value
|
|
429
430
|
return [
|
|
430
431
|
'==================== 反 AI 规则(写作时必须遵守的表达边界) ====================',
|
|
431
|
-
...rules.map(r => `- ${r.name}:避免——${r.avoid}
|
|
432
|
+
...rules.map(r => `- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== '' ? `;修正——${clip(r.fix, 50)}` : ''}`),
|
|
432
433
|
].join('\n')
|
|
433
434
|
}
|
|
434
435
|
|
|
@@ -464,17 +465,18 @@ export function renderGenreAndProgression(assets: ProjectAssets | undefined): st
|
|
|
464
465
|
return sections.join('\n')
|
|
465
466
|
}
|
|
466
467
|
|
|
467
|
-
/**
|
|
468
|
+
/** 渲染写法资产提示词块(规则去重,省 token)。 */
|
|
468
469
|
export function renderStyleAssets(assets: ProjectAssets | undefined): string {
|
|
469
470
|
const styles = assets?.styleAssets ?? []
|
|
470
471
|
if (styles.length === 0) return ''
|
|
471
472
|
const sections: string[] = ['==================== 写法资产(本书的叙事风格约束) ====================']
|
|
472
473
|
for (const style of styles) {
|
|
473
474
|
sections.push(`【${style.name}】`)
|
|
474
|
-
|
|
475
|
-
if (style.
|
|
476
|
-
if (style.
|
|
477
|
-
if (style.
|
|
475
|
+
const unique = (rules: string[]): string[] => [...new Set(rules)]
|
|
476
|
+
if (style.proseRules.length > 0) sections.push('叙述与节奏:\n' + unique(style.proseRules).map(r => `- ${r}`).join('\n'))
|
|
477
|
+
if (style.dialogueRules.length > 0) sections.push('台词风格:\n' + unique(style.dialogueRules).map(r => `- ${r}`).join('\n'))
|
|
478
|
+
if (style.descriptionRules.length > 0) sections.push('描写与情绪:\n' + unique(style.descriptionRules).map(r => `- ${r}`).join('\n'))
|
|
479
|
+
if (style.boundaries.length > 0) sections.push('表达边界:\n' + unique(style.boundaries).map(r => `- ${r}`).join('\n'))
|
|
478
480
|
}
|
|
479
481
|
return sections.join('\n')
|
|
480
482
|
}
|
package/src/assistant.ts
CHANGED
|
@@ -13,20 +13,22 @@
|
|
|
13
13
|
* result as a tool-role message, and continues the loop (bounded rounds).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { createUserMessage, createAssistantMessage, BlockAssembler, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
19
19
|
import type { Context } from '@deepseek-ai/cordis'
|
|
20
20
|
import type { AssistantMessage, NovelConfig, ProjectState } from './protocol.ts'
|
|
21
21
|
import { emptyProjectAssets } from './assets.ts'
|
|
22
22
|
import {
|
|
23
|
+
analyzeImpact,
|
|
24
|
+
bookOverview,
|
|
23
25
|
chapterFileName,
|
|
24
26
|
exportBook,
|
|
25
27
|
generateChapterStream,
|
|
26
28
|
readChapterFile,
|
|
27
29
|
reviewChapter,
|
|
28
|
-
summarizeChapter,
|
|
29
30
|
saveProject,
|
|
31
|
+
summarizeAndExtractFacts,
|
|
30
32
|
} from './engine.ts'
|
|
31
33
|
import { rewriteChapterStream } from './engine.ts'
|
|
32
34
|
|
|
@@ -58,10 +60,21 @@ export function loadAssistantHistory(outputDir: string): AssistantMessage[] {
|
|
|
58
60
|
return messages
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/** Append one message to the persisted history.
|
|
63
|
+
/** Append one message to the persisted history. Tool payloads (e.g. full
|
|
64
|
+
* outline / chapter text) are capped so the jsonl and later LLM context
|
|
65
|
+
* don't grow unboundedly. */
|
|
62
66
|
function appendHistory(outputDir: string, message: AssistantMessage): void {
|
|
63
67
|
mkdirSync(outputDir, { recursive: true })
|
|
64
|
-
|
|
68
|
+
const entry: AssistantMessage = message.role === 'tool' && message.content.length > 4000
|
|
69
|
+
? { ...message, content: message.content.slice(0, 4000) + '\n…(已截断,如需完整内容请重新调用工具)' }
|
|
70
|
+
: message
|
|
71
|
+
appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + '\n', 'utf8')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 清空助手对话记录(删除历史文件)。 */
|
|
75
|
+
export function clearAssistantHistory(outputDir: string): void {
|
|
76
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE)
|
|
77
|
+
if (existsSync(file)) rmSync(file, { force: true })
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
// ----------------------------------------------------------------- context
|
|
@@ -70,6 +83,14 @@ function appendHistory(outputDir: string, message: AssistantMessage): void {
|
|
|
70
83
|
function renderProjectSnapshot(project: ProjectState): string {
|
|
71
84
|
const sections: string[] = []
|
|
72
85
|
sections.push(`书名:${project.bookName}`)
|
|
86
|
+
sections.push(`大纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`)
|
|
87
|
+
// 写作资产名称化(占位小,需要详情用 assets_status)。
|
|
88
|
+
const assetNames: string[] = []
|
|
89
|
+
if (project.assets?.genre !== undefined) assetNames.push(`题材:${project.assets.genre.name}`)
|
|
90
|
+
if (project.assets?.primaryProgression !== undefined) assetNames.push(`主推进:${project.assets.primaryProgression.name}`)
|
|
91
|
+
if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets!.styleAssets!.map(s => s.name).join('、')}`)
|
|
92
|
+
if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets!.antiAiRules!.map(r => r.name).join('、')}`)
|
|
93
|
+
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(' · ')}`)
|
|
73
94
|
if (project.bible !== undefined) {
|
|
74
95
|
const bible = project.bible
|
|
75
96
|
sections.push('【设定圣经】')
|
|
@@ -84,6 +105,13 @@ function renderProjectSnapshot(project: ProjectState): string {
|
|
|
84
105
|
}
|
|
85
106
|
if (bible.redLines.length > 0) sections.push('写作红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
86
107
|
}
|
|
108
|
+
if (project.world !== undefined) {
|
|
109
|
+
const world = project.world
|
|
110
|
+
sections.push('【大世界】')
|
|
111
|
+
if (world.realms.length > 0) sections.push('境界体系:' + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(' → '))
|
|
112
|
+
if (world.regions.length > 0) sections.push('区域:' + world.regions.map(r => r.name).join('、'))
|
|
113
|
+
if (world.factions.length > 0) sections.push('势力:' + world.factions.map(f => `${f.name}(${f.kind})`).join('、'))
|
|
114
|
+
}
|
|
87
115
|
if (project.volumes !== undefined && project.volumes.length > 0) {
|
|
88
116
|
sections.push('【卷结构】')
|
|
89
117
|
for (const v of project.volumes) {
|
|
@@ -91,11 +119,26 @@ function renderProjectSnapshot(project: ProjectState): string {
|
|
|
91
119
|
}
|
|
92
120
|
}
|
|
93
121
|
if (project.chapters.length > 0) {
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
// 快照只列最近 30 章(长书时防止上下文膨胀;更早章节可用
|
|
123
|
+
// book_overview scope=volume:N 或 chapter_text 按需查看)。
|
|
124
|
+
const shown = project.chapters.slice(-30)
|
|
125
|
+
sections.push(`【章节计划与进度(最近 ${shown.length} 章)】`)
|
|
126
|
+
for (const c of shown) {
|
|
96
127
|
const statusText = { pending: '待生成', generating: '生成中', written: '待审稿', reviewing: '审稿中', approved: '已通过', rejected: '待修订', error: '失败' }[c.status]
|
|
97
128
|
sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== undefined ? ` ${c.chars}字` : ''}${c.summary !== undefined && c.summary !== '' ? ` 摘要:${c.summary}` : ''}`)
|
|
98
129
|
}
|
|
130
|
+
if (project.chapters.length > shown.length) {
|
|
131
|
+
sections.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 book_overview scope=volume:N 查看)`)
|
|
132
|
+
}
|
|
133
|
+
// 最近两章正文节选(讨论细节时参考)
|
|
134
|
+
const written = project.chapters.filter(c => c.status === 'approved' || c.status === 'written')
|
|
135
|
+
const recent = written.slice(-2)
|
|
136
|
+
if (recent.length > 0) {
|
|
137
|
+
sections.push('【最近章节正文节选】')
|
|
138
|
+
for (const c of recent) {
|
|
139
|
+
sections.push(`第${c.no}章《${c.title}》(节选,如需全文用 chapter_text):${c.beats.slice(0, 300)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
99
142
|
}
|
|
100
143
|
if (project.foreshadows.length > 0) {
|
|
101
144
|
sections.push('【伏笔】')
|
|
@@ -103,23 +146,42 @@ function renderProjectSnapshot(project: ProjectState): string {
|
|
|
103
146
|
sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
|
|
104
147
|
}
|
|
105
148
|
}
|
|
149
|
+
if ((project.facts ?? []).length > 0) {
|
|
150
|
+
sections.push('【已确立事实库(最近 40 条,回答设定问题必须遵守)】')
|
|
151
|
+
for (const f of (project.facts ?? []).slice(-40)) {
|
|
152
|
+
sections.push(`- [第${f.chapterNo}章] ${f.text}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (project.blurb !== undefined && project.blurb !== '') {
|
|
156
|
+
sections.push(`【小说简介】${project.blurb}`)
|
|
157
|
+
}
|
|
106
158
|
return sections.join('\n')
|
|
107
159
|
}
|
|
108
160
|
|
|
109
161
|
/** The assistant system prompt. */
|
|
110
162
|
function assistantSystemPrompt(project: ProjectState): string {
|
|
111
163
|
return [
|
|
112
|
-
'
|
|
164
|
+
'你是「编辑老师」——服务这本书作者的资深中文网文编辑。',
|
|
165
|
+
'人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。',
|
|
166
|
+
'座右铭:「书是你的,但坑我替你盯着。」',
|
|
167
|
+
'职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。',
|
|
113
168
|
'==================== 当前项目快照 ====================',
|
|
114
169
|
renderProjectSnapshot(project),
|
|
115
170
|
'==================== 快照结束 ====================',
|
|
116
171
|
'',
|
|
117
|
-
'
|
|
118
|
-
'1.
|
|
119
|
-
'2.
|
|
120
|
-
'
|
|
172
|
+
'工作规则(严格遵守):',
|
|
173
|
+
'1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(大纲全文/设定圣经/大世界/事实库/全部章节要点/伏笔/简介);需要某章正文用 chapter_text。',
|
|
174
|
+
'2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
|
|
175
|
+
'3. 连锁维护:改动可能波及其它位置(其它章节、设定、事实库、简介)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
|
|
176
|
+
'4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
|
|
177
|
+
'5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
|
|
178
|
+
'6. 设定忠诚:忠于大纲、设定圣经、大世界、事实库;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
|
|
179
|
+
'7. 中文回复,简洁有干货。',
|
|
121
180
|
'',
|
|
122
181
|
'可用工具:',
|
|
182
|
+
'- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(大纲/道藏/大世界/章节要点/事实库/伏笔/简介)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
|
|
183
|
+
'- facts_query:{"keyword": "关键词"}。从编年录(事实库)按关键词检索相关事实(如灵石、境界名、人物名)。',
|
|
184
|
+
'- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/事实库)。',
|
|
123
185
|
'- outline_text:无参数。返回当前大纲全文。',
|
|
124
186
|
'- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在大纲中替换一段文字(old 必须能在大纲中找到)。',
|
|
125
187
|
'- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改设定圣经的世界规则。',
|
|
@@ -134,7 +196,14 @@ function assistantSystemPrompt(project: ProjectState): string {
|
|
|
134
196
|
'- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
|
|
135
197
|
'- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
|
|
136
198
|
'- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
|
|
137
|
-
'- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)
|
|
199
|
+
'- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
|
|
200
|
+
'',
|
|
201
|
+
'回答质量要求(非常重要):',
|
|
202
|
+
'- 具体:回答必须引用项目里的真实内容(人名、境界、章节、伏笔、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
|
|
203
|
+
'- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。',
|
|
204
|
+
'- 主动:作者说"改一下",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。',
|
|
205
|
+
'- 忠于设定:以大纲、设定圣经、事实库为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
|
|
206
|
+
'- 中文回复;文字量适中,别啰嗦。',
|
|
138
207
|
'',
|
|
139
208
|
'使用规则(非常重要):',
|
|
140
209
|
'- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
|
|
@@ -148,7 +217,7 @@ function assistantSystemPrompt(project: ProjectState): string {
|
|
|
148
217
|
'- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
|
|
149
218
|
'- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
|
|
150
219
|
'- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
|
|
151
|
-
'-
|
|
220
|
+
'- 严格忠于设定圣经与大纲;不得自行发明与既有设定冲突的内容。',
|
|
152
221
|
'- 用中文回复。',
|
|
153
222
|
].join('\n')
|
|
154
223
|
}
|
|
@@ -173,13 +242,39 @@ export async function* executeAction(
|
|
|
173
242
|
const num = (value: unknown): number | undefined => typeof value === 'number' ? value : undefined
|
|
174
243
|
|
|
175
244
|
/** Forward live text deltas from a streaming chapter job (text only). */
|
|
176
|
-
const forward = async function* (stream: AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown>): AsyncGenerator<string, void, unknown> {
|
|
245
|
+
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> {
|
|
177
246
|
for await (const step of stream) {
|
|
178
247
|
if (step.frame === 'delta') yield step.text
|
|
179
248
|
}
|
|
180
249
|
}
|
|
181
250
|
|
|
182
251
|
switch (name) {
|
|
252
|
+
case 'book_overview': {
|
|
253
|
+
// 全书上下文包(分片:默认最近 30 章;scope=full 全量;scope=volume:N 指定卷)。
|
|
254
|
+
const scopeArg = str(args.scope)
|
|
255
|
+
const scope = scopeArg === 'full'
|
|
256
|
+
? 'full' as const
|
|
257
|
+
: /^volume:(\d+)$/.test(scopeArg)
|
|
258
|
+
? Number(scopeArg.slice(7))
|
|
259
|
+
: 'recent' as const
|
|
260
|
+
return bookOverview(project, scope)
|
|
261
|
+
}
|
|
262
|
+
case 'facts_query': {
|
|
263
|
+
// 从编年录(事实库)按关键词检索相关事实。
|
|
264
|
+
const keyword = str(args.keyword).trim()
|
|
265
|
+
if (keyword === '') throw new Error('facts_query 需要 keyword')
|
|
266
|
+
const hits = (project.facts ?? []).filter(f => f.text.includes(keyword)).slice(-30)
|
|
267
|
+
if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`
|
|
268
|
+
return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map(f => `- [第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
269
|
+
}
|
|
270
|
+
case 'impact_analysis': {
|
|
271
|
+
const change = str(args.change)
|
|
272
|
+
if (change === '') throw new Error('impact_analysis 需要 change(要做的修改描述)')
|
|
273
|
+
const items = await analyzeImpact(ctx, config, project, outputDir, change)
|
|
274
|
+
if (items.length === 0) return '影响分析:未发现需要同步修改的位置。'
|
|
275
|
+
const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}(${it.kind === 'must' ? '必须同步' : it.kind === 'optional' ? '建议' : '备注'})`)
|
|
276
|
+
return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join('\n')}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`
|
|
277
|
+
}
|
|
183
278
|
case 'outline_text': {
|
|
184
279
|
return project.outline
|
|
185
280
|
}
|
|
@@ -195,7 +290,7 @@ export async function* executeAction(
|
|
|
195
290
|
return `大纲已修改:替换了 ${old.length} 字符的片段。`
|
|
196
291
|
}
|
|
197
292
|
case 'bible_set_rule': {
|
|
198
|
-
if (project.bible === undefined) throw new Error('
|
|
293
|
+
if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
|
|
199
294
|
const index = num(args.index)
|
|
200
295
|
if (index !== undefined) {
|
|
201
296
|
project.bible.worldRules[index] = str(args.text)
|
|
@@ -209,7 +304,7 @@ export async function* executeAction(
|
|
|
209
304
|
return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`
|
|
210
305
|
}
|
|
211
306
|
case 'bible_set_redline': {
|
|
212
|
-
if (project.bible === undefined) throw new Error('
|
|
307
|
+
if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
|
|
213
308
|
const index = num(args.index)
|
|
214
309
|
if (index !== undefined) {
|
|
215
310
|
project.bible.redLines[index] = str(args.text)
|
|
@@ -239,11 +334,28 @@ export async function* executeAction(
|
|
|
239
334
|
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === '' ? undefined : target))) {
|
|
240
335
|
yield chunk
|
|
241
336
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
337
|
+
// Draft mode: the assistant acts on the user's explicit instruction, so
|
|
338
|
+
// apply the draft immediately — summarize/review must see the new body.
|
|
339
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
340
|
+
const draft = chapter?.pendingDraft
|
|
341
|
+
if (chapter === undefined || draft === undefined || draft === '') {
|
|
342
|
+
throw new Error(`章节 ${no} 修订后没有产出草稿`)
|
|
343
|
+
}
|
|
344
|
+
const fileName = chapterFileName(chapter)
|
|
345
|
+
mkdirSync(outputDir, { recursive: true })
|
|
346
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
347
|
+
chapter.pendingDraft = undefined
|
|
348
|
+
chapter.status = 'written'
|
|
349
|
+
chapter.chars = draft.length
|
|
350
|
+
chapter.file = fileName
|
|
351
|
+
chapter.review = undefined
|
|
352
|
+
chapter.error = undefined
|
|
353
|
+
project.updatedAt = new Date().toISOString()
|
|
354
|
+
saveProject(outputDir, project)
|
|
355
|
+
yield '(已采纳修订稿,正在生成章节摘要与编年录…)'
|
|
244
356
|
try {
|
|
245
|
-
await
|
|
246
|
-
} catch { /*
|
|
357
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
358
|
+
} catch { /* best-effort */ }
|
|
247
359
|
yield '(正在 AI 审稿…)'
|
|
248
360
|
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
249
361
|
return `章节 ${no} 已${target === '' ? '整章' : '局部'}修订完成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。重新审稿:${report.score} 分 — ${report.verdict}`
|
|
@@ -254,9 +366,9 @@ export async function* executeAction(
|
|
|
254
366
|
for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) {
|
|
255
367
|
yield chunk
|
|
256
368
|
}
|
|
257
|
-
yield '
|
|
369
|
+
yield '(正在生成章节摘要与编年录…)'
|
|
258
370
|
try {
|
|
259
|
-
await
|
|
371
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
260
372
|
} catch { /* best-effort */ }
|
|
261
373
|
yield '(正在 AI 审稿…)'
|
|
262
374
|
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
@@ -369,18 +481,19 @@ export async function* executeAction(
|
|
|
369
481
|
|
|
370
482
|
// ------------------------------------------------------------------- chat
|
|
371
483
|
|
|
372
|
-
/** Extract the first action directive from a reply. */
|
|
484
|
+
/** Extract the first action directive from a reply (tolerant to common tag misspellings). */
|
|
373
485
|
function extractAction(reply: string): { name: string; args: Record<string, unknown>; index: number } | undefined {
|
|
374
|
-
|
|
486
|
+
// 容错:dsh-action 的常见误拼(dash-action / dsah-action / dhs-action 等)。
|
|
487
|
+
const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply)
|
|
375
488
|
if (match === null) return undefined
|
|
376
|
-
const rawArgs = match[
|
|
489
|
+
const rawArgs = match[3]?.trim() ?? ''
|
|
377
490
|
let args: Record<string, unknown>
|
|
378
491
|
try {
|
|
379
492
|
args = rawArgs === '' ? {} : JSON.parse(rawArgs) as Record<string, unknown>
|
|
380
493
|
} catch {
|
|
381
494
|
throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`)
|
|
382
495
|
}
|
|
383
|
-
return { name: match[
|
|
496
|
+
return { name: match[2] ?? '', args, index: match.index }
|
|
384
497
|
}
|
|
385
498
|
|
|
386
499
|
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
@@ -399,8 +512,13 @@ function historyToMessages(history: AssistantMessage[]): Message[] {
|
|
|
399
512
|
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
|
400
513
|
}))
|
|
401
514
|
} else if (entry.role === 'tool') {
|
|
515
|
+
// 工具结果(book_overview/大纲全文等可能很大)截断后进上下文,
|
|
516
|
+
// 避免多轮对话上下文翻倍膨胀触发「上下文到限制」。
|
|
517
|
+
const body = entry.content.length > 2000
|
|
518
|
+
? entry.content.slice(0, 2000) + '\n…(结果过长已截断,需要完整内容请重新调用工具)'
|
|
519
|
+
: entry.content
|
|
402
520
|
messages.push(createUserMessage({
|
|
403
|
-
content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${
|
|
521
|
+
content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${body}` }],
|
|
404
522
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
405
523
|
}))
|
|
406
524
|
}
|
|
@@ -421,7 +539,8 @@ async function chatOnce(
|
|
|
421
539
|
model: config.model,
|
|
422
540
|
messages,
|
|
423
541
|
system,
|
|
424
|
-
|
|
542
|
+
// v4-flash 推理模型:reasoning channel 占预算,给足避免回复被截断。
|
|
543
|
+
maxTokens: Math.max(config.maxTokens, 16000),
|
|
425
544
|
temperature: 0.7,
|
|
426
545
|
}
|
|
427
546
|
const assembler = new BlockAssembler()
|
|
@@ -478,12 +597,13 @@ export async function* runAssistantTurn(
|
|
|
478
597
|
const action = extractAction(reply)
|
|
479
598
|
|
|
480
599
|
if (action === undefined) {
|
|
481
|
-
// No action tag. If the reply clearly intends to modify
|
|
482
|
-
//
|
|
600
|
+
// No parseable action tag. If the reply clearly intends to modify
|
|
601
|
+
// something (or contains a malformed action tag), nudge once.
|
|
483
602
|
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply)
|
|
484
|
-
|
|
603
|
+
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
|
|
604
|
+
if ((intendsAction || strayTag) && !nudged) {
|
|
485
605
|
nudged = true
|
|
486
|
-
const nudge = '
|
|
606
|
+
const nudge = '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
|
|
487
607
|
history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
488
608
|
appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
489
609
|
continue
|
package/src/bookshelf.ts
CHANGED
|
@@ -57,11 +57,14 @@ export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
|
|
|
57
57
|
books: store.books.map(book => {
|
|
58
58
|
const project = loadProject(book.outputDir)
|
|
59
59
|
const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
|
|
60
|
+
const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
|
|
60
61
|
return {
|
|
61
62
|
...book,
|
|
62
63
|
done,
|
|
63
64
|
total: project?.chapters.length ?? 0,
|
|
64
65
|
hasProject: project !== undefined,
|
|
66
|
+
hasCover,
|
|
67
|
+
blurb: project?.blurb,
|
|
65
68
|
}
|
|
66
69
|
}),
|
|
67
70
|
activeBookId: store.activeBookId,
|
|
@@ -80,6 +83,17 @@ export function createBook(bookName: string, outputDir: string): BookEntry {
|
|
|
80
83
|
return book
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
|
|
87
|
+
export function renameBook(id: string, bookName: string): boolean {
|
|
88
|
+
const store = loadBookshelf()
|
|
89
|
+
const book = store.books.find(b => b.id === id)
|
|
90
|
+
if (book === undefined) return false
|
|
91
|
+
book.bookName = bookName
|
|
92
|
+
book.updatedAt = new Date().toISOString()
|
|
93
|
+
saveBookshelf(store)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
/**
|
|
84
98
|
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
85
99
|
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
package/src/client/api.ts
CHANGED
|
@@ -108,6 +108,16 @@ export class NovelApi {
|
|
|
108
108
|
return readJson<ChapterResponse>(response)
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** 审查手动编辑的正文(不落盘)。 */
|
|
112
|
+
async chapterCheck(no: number, text: string): Promise<{ report: ReviewReport }> {
|
|
113
|
+
return postJson<{ report: ReviewReport }>(NOVEL_API.chapterCheck, { chapterNo: no, text })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 保存手动编辑的正文(自动备份 .bak;带报告则沿用落盘,否则保存后自动审稿)。 */
|
|
117
|
+
async chapterSave(no: number, text: string, report?: ReviewReport): Promise<import('../protocol.ts').ChapterSaveResponse> {
|
|
118
|
+
return postJson<import('../protocol.ts').ChapterSaveResponse>(NOVEL_API.chapterSave, { chapterNo: no, text, report })
|
|
119
|
+
}
|
|
120
|
+
|
|
111
121
|
async patchConfig(patch: ConfigPatch): Promise<{ config: NovelConfig }> {
|
|
112
122
|
return postJson<{ config: NovelConfig }>(NOVEL_API.config, patch)
|
|
113
123
|
}
|
|
@@ -122,9 +132,61 @@ export class NovelApi {
|
|
|
122
132
|
return readJson<import('../protocol.ts').BookshelfSnapshot>(response)
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
/**
|
|
126
|
-
async bookCreate(bookName: string, outputDir?: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
127
|
-
return postJson<import('../protocol.ts').BookshelfSnapshot>(NOVEL_API.bookshelf, { bookName, outputDir })
|
|
135
|
+
/** 新建书并激活(开书向导:可携带大纲文本,创建即建项目)。 */
|
|
136
|
+
async bookCreate(bookName: string, outputDir?: string, outline?: string): Promise<import('../protocol.ts').BookshelfSnapshot> {
|
|
137
|
+
return postJson<import('../protocol.ts').BookshelfSnapshot>(NOVEL_API.bookshelf, { bookName, outputDir, outline })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 重置项目(清空进度;可携带新大纲)。 */
|
|
141
|
+
async reset(outline?: string): Promise<{ ok: boolean; bookName: string }> {
|
|
142
|
+
return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.reset, { outline })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 全书一致性质检。 */
|
|
146
|
+
async audit(): Promise<import('../protocol.ts').AuditResponse> {
|
|
147
|
+
return postJson<import('../protocol.ts').AuditResponse>(NOVEL_API.audit, {})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 角色卡刷新(基于事实库聚合)。 */
|
|
151
|
+
async charactersRefresh(): Promise<{ cards: import('../protocol.ts').RoleStatusCard[] }> {
|
|
152
|
+
return postJson<{ cards: import('../protocol.ts').RoleStatusCard[] }>(NOVEL_API.charactersRefresh, {})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 事实库回填:对历史已生成章节批量抽取事实。 */
|
|
156
|
+
async factsBackfill(): Promise<{ ok: boolean; filled: number }> {
|
|
157
|
+
return postJson<{ ok: boolean; filled: number }>(NOVEL_API.factsBackfill, {})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 设定圣经局部修补。 */
|
|
161
|
+
async biblePatch(patch: import('../protocol.ts').BiblePatchRequest): Promise<{ bible: import('../protocol.ts').StoryBible }> {
|
|
162
|
+
return postJson<{ bible: import('../protocol.ts').StoryBible }>(NOVEL_API.biblePatch, patch)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 小说简介:AI 生成/补全(partial 留空 = 全量),或手动保存。 */
|
|
166
|
+
async blurb(action: 'generate' | 'save', text?: string, partial?: string): Promise<{ blurb: string }> {
|
|
167
|
+
return postJson<{ blurb: string }>(NOVEL_API.blurb, { action, text, partial })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 封面:读取(dataUrl;dir 指定某本书的输出目录,省略为当前书)。 */
|
|
171
|
+
async coverGet(dir?: string): Promise<import('../protocol.ts').CoverResponse> {
|
|
172
|
+
const query = dir !== undefined ? `?dir=${encodeURIComponent(dir)}` : ''
|
|
173
|
+
const response = await fetch(NOVEL_API.cover + query)
|
|
174
|
+
return readJson<import('../protocol.ts').CoverResponse>(response)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 封面:上传(base64 data URL)或移除。 */
|
|
178
|
+
async coverPost(action: 'upload' | 'remove', dataUrl?: string): Promise<{ ok: boolean; coverPath?: string | null }> {
|
|
179
|
+
return postJson<{ ok: boolean; coverPath?: string | null }>(NOVEL_API.cover, { action, dataUrl })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 重命名当前书(同步项目与书架条目)。 */
|
|
183
|
+
async rename(bookName: string): Promise<{ bookName: string }> {
|
|
184
|
+
return postJson<{ bookName: string }>(NOVEL_API.rename, { bookName })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 大世界:AI 提炼(generate)或手动保存(save)。 */
|
|
188
|
+
async world(action: 'generate' | 'save', world?: import('../protocol.ts').WorldState): Promise<{ world: import('../protocol.ts').WorldState }> {
|
|
189
|
+
return postJson<{ world: import('../protocol.ts').WorldState }>(NOVEL_API.world, { action, world })
|
|
128
190
|
}
|
|
129
191
|
|
|
130
192
|
/** 切换当前书。 */
|
|
@@ -210,6 +272,16 @@ export class NovelApi {
|
|
|
210
272
|
await this.streamJob(NOVEL_API.polish, { chapterNo }, onFrame)
|
|
211
273
|
}
|
|
212
274
|
|
|
275
|
+
/** 采纳待确认草稿(润色/重写产物),覆盖正文文件。 */
|
|
276
|
+
async draftApply(chapterNo: number): Promise<{ ok: boolean; chars: number; file: string }> {
|
|
277
|
+
return postJson<{ ok: boolean; chars: number; file: string }>(NOVEL_API.draftApply, { chapterNo })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
281
|
+
async draftDiscard(chapterNo: number): Promise<{ ok: boolean }> {
|
|
282
|
+
return postJson<{ ok: boolean }>(NOVEL_API.draftDiscard, { chapterNo })
|
|
283
|
+
}
|
|
284
|
+
|
|
213
285
|
/** Run one assistant turn (NDJSON stream). */
|
|
214
286
|
async assistant(message: string, onFrame: (frame: import('../protocol.ts').AssistantFrame) => void): Promise<void> {
|
|
215
287
|
const response = await fetch(NOVEL_API.assistant, {
|
|
@@ -251,4 +323,9 @@ export class NovelApi {
|
|
|
251
323
|
const body = await readJson<{ messages: import('../protocol.ts').AssistantMessage[] }>(response)
|
|
252
324
|
return body.messages
|
|
253
325
|
}
|
|
326
|
+
|
|
327
|
+
/** 清空助手对话记录。 */
|
|
328
|
+
async assistantClear(): Promise<{ ok: boolean }> {
|
|
329
|
+
return postJson<{ ok: boolean }>(NOVEL_API.assistantClear, {})
|
|
330
|
+
}
|
|
254
331
|
}
|