@waterwx/dsh-novel-forge 2.0.1-alpha → 2.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 +368 -362
- package/lib/client.js +20340 -19622
- package/lib/index.js +11313 -7285
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +20 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +136 -27
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +15 -70
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorHome.d.ts +13 -11
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/SettingsView.d.ts +12 -9
- package/lib/types/client/panel/ShelfView.d.ts +10 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/outline-markdown.d.ts +112 -0
- package/lib/types/client/panel/status-badge.d.ts +13 -0
- package/lib/types/client/panel/storage-parse.d.ts +46 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/usePersistentState.d.ts +17 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/digest-parse.d.ts +29 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +150 -103
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +26 -12
- package/lib/types/llm-live.d.ts +70 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +18 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +119 -0
- package/lib/types/model-request.d.ts +36 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/plotline-align.d.ts +35 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +841 -601
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run-selection.d.ts +51 -0
- package/lib/types/run.d.ts +32 -1
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/stream-fallback.d.ts +60 -0
- package/lib/types/strip-headings.d.ts +33 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +50 -27
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/skills/chapter-batch/SKILL.md +0 -81
- package/src/ai-scan.ts +0 -179
- package/src/assets.ts +0 -879
- package/src/assistant.ts +0 -770
- package/src/author-assets.ts +0 -147
- package/src/bookshelf.ts +0 -167
- package/src/client/api.ts +0 -617
- package/src/client/css-modules.d.ts +0 -8
- package/src/client/docx.ts +0 -69
- package/src/client/index.ts +0 -34
- package/src/client/locales.ts +0 -418
- package/src/client/mount.tsx +0 -97
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/AdaptModeView.tsx +0 -520
- package/src/client/panel/AssetsTab.tsx +0 -458
- package/src/client/panel/AssistantTab.tsx +0 -301
- package/src/client/panel/AuthorAssetsView.tsx +0 -208
- package/src/client/panel/AuthorHome.tsx +0 -204
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/CreateBookView.tsx +0 -352
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/GlobalAssetLibraryView.tsx +0 -172
- package/src/client/panel/ImportModal.tsx +0 -303
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/ModelManager.tsx +0 -205
- package/src/client/panel/NovelPanel.tsx +0 -4620
- package/src/client/panel/ProgressConsole.tsx +0 -64
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/ReaderView.tsx +0 -306
- package/src/client/panel/ReasoningSection.tsx +0 -31
- package/src/client/panel/RunPanel.tsx +0 -204
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/SettingsView.tsx +0 -286
- package/src/client/panel/ShelfView.tsx +0 -242
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/client/panel/WorldTab.tsx +0 -202
- package/src/client/panel/controller.ts +0 -45
- package/src/client/panel/helpers.ts +0 -67
- package/src/client/panel/panel.module.css +0 -4360
- package/src/client/panel/views.tsx +0 -314
- package/src/client/sidebar-entry.ts +0 -122
- package/src/client/text.ts +0 -28
- package/src/comic-presets.ts +0 -67
- package/src/docx.ts +0 -83
- package/src/engine.ts +0 -5200
- package/src/index.ts +0 -329
- package/src/manga-genre-rules.ts +0 -87
- package/src/novel-context.ts +0 -287
- package/src/prompt-utils.ts +0 -73
- package/src/protocol.ts +0 -2233
- package/src/review-policy.ts +0 -161
- package/src/routes.ts +0 -3419
- package/src/run.ts +0 -331
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
package/src/assistant.ts
DELETED
|
@@ -1,770 +0,0 @@
|
|
|
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
|
-
} from './engine.ts'
|
|
33
|
-
import { rewriteChapterStream } from './engine.ts'
|
|
34
|
-
|
|
35
|
-
/** History file name inside the output dir. */
|
|
36
|
-
export const ASSISTANT_HISTORY_FILE = 'novel-assistant.jsonl'
|
|
37
|
-
|
|
38
|
-
/** Max tool-call rounds per user turn (safety bound). */
|
|
39
|
-
const MAX_TOOL_ROUNDS = 6
|
|
40
|
-
|
|
41
|
-
/** Max history messages kept in context (older ones summarized away). */
|
|
42
|
-
const MAX_HISTORY_MESSAGES = 18
|
|
43
|
-
|
|
44
|
-
// ------------------------------------------------------------------ history
|
|
45
|
-
|
|
46
|
-
/** Load the persisted conversation (empty when none). */
|
|
47
|
-
export function loadAssistantHistory(outputDir: string): AssistantMessage[] {
|
|
48
|
-
const file = join(outputDir, ASSISTANT_HISTORY_FILE)
|
|
49
|
-
if (!existsSync(file)) return []
|
|
50
|
-
const messages: AssistantMessage[] = []
|
|
51
|
-
try {
|
|
52
|
-
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
53
|
-
if (line.trim() === '') continue
|
|
54
|
-
try {
|
|
55
|
-
const parsed = JSON.parse(line) as AssistantMessage
|
|
56
|
-
if (typeof parsed.role === 'string' && typeof parsed.content === 'string') messages.push(parsed)
|
|
57
|
-
} catch { /* skip malformed line */ }
|
|
58
|
-
}
|
|
59
|
-
} catch { /* unreadable history -> start fresh */ }
|
|
60
|
-
return messages
|
|
61
|
-
}
|
|
62
|
-
|
|
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. */
|
|
66
|
-
function appendHistory(outputDir: string, message: AssistantMessage): void {
|
|
67
|
-
mkdirSync(outputDir, { recursive: true })
|
|
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 })
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ----------------------------------------------------------------- context
|
|
81
|
-
|
|
82
|
-
/** Render the project snapshot the assistant sees. */
|
|
83
|
-
function renderProjectSnapshot(project: ProjectState): string {
|
|
84
|
-
const sections: string[] = []
|
|
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(' · ')}`)
|
|
94
|
-
if (project.bible !== undefined) {
|
|
95
|
-
const bible = project.bible
|
|
96
|
-
sections.push('【道藏】')
|
|
97
|
-
if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
|
|
98
|
-
if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
|
|
99
|
-
if (bible.characters.length > 0) {
|
|
100
|
-
sections.push('角色卡:')
|
|
101
|
-
for (const card of bible.characters) {
|
|
102
|
-
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
|
|
103
|
-
sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}`)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (bible.redLines.length > 0) sections.push('写作红线:\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
|
|
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
|
-
}
|
|
115
|
-
if (project.volumes !== undefined && project.volumes.length > 0) {
|
|
116
|
-
sections.push('【卷结构】')
|
|
117
|
-
for (const v of project.volumes) {
|
|
118
|
-
sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (project.chapters.length > 0) {
|
|
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) {
|
|
127
|
-
const statusText = { pending: '待生成', generating: '生成中', written: '待审稿', reviewing: '审稿中', approved: '已通过', rejected: '待修订', error: '失败' }[c.status]
|
|
128
|
-
sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== undefined ? ` ${c.chars}字` : ''}${c.summary !== undefined && c.summary !== '' ? ` 摘要:${c.summary}` : ''}`)
|
|
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
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (project.foreshadows.length > 0) {
|
|
144
|
-
sections.push('【暗线】')
|
|
145
|
-
for (const f of project.foreshadows) {
|
|
146
|
-
sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计 ${f.targetChapter} 章回收)` : ''}`)
|
|
147
|
-
}
|
|
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
|
-
}
|
|
158
|
-
return sections.join('\n')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The assistant system prompt. */
|
|
162
|
-
function assistantSystemPrompt(project: ProjectState): string {
|
|
163
|
-
return [
|
|
164
|
-
'你是「编辑老师」——服务这本书作者的资深中文网文编辑。',
|
|
165
|
-
'人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。',
|
|
166
|
-
'座右铭:「书是你的,但坑我替你盯着。」',
|
|
167
|
-
'职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。',
|
|
168
|
-
'==================== 模块正式名称(回复作者时一律使用,禁止使用括号里的旧称) ====================',
|
|
169
|
-
'总纲 = 总纲;道藏 = 道藏;暗线 = 暗线;卷首语 = 卷首语;编年录 = 编年录。',
|
|
170
|
-
'==================== 当前项目快照 ====================',
|
|
171
|
-
renderProjectSnapshot(project),
|
|
172
|
-
'==================== 快照结束 ====================',
|
|
173
|
-
'',
|
|
174
|
-
'工作规则(严格遵守):',
|
|
175
|
-
'1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。',
|
|
176
|
-
'2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
|
|
177
|
-
'3. 连锁维护:改动可能波及其它位置(其它章节、设定、编年录、卷首语)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
|
|
178
|
-
'4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
|
|
179
|
-
'5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
|
|
180
|
-
'6. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
|
|
181
|
-
'7. 中文回复,简洁有干货。',
|
|
182
|
-
'',
|
|
183
|
-
'可用工具:',
|
|
184
|
-
'- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
|
|
185
|
-
'- facts_query:{"keyword": "关键词"}。从编年录按关键词检索相关事实(如灵石、境界名、人物名)。',
|
|
186
|
-
'- impact_analysis:{"change": "要做的修改描述"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/编年录)。',
|
|
187
|
-
'- outline_text:无参数。返回当前总纲全文。',
|
|
188
|
-
'- outline_replace:{"old": "要替换的原文片段", "new": "新文本"}。在总纲中替换一段文字(old 必须能在总纲中找到)。',
|
|
189
|
-
'- bible_set_rule:{"index": 序号(0起), "text": "新规则文本"} 或 {"append": "追加的规则"}。修改道藏的世界规则。',
|
|
190
|
-
'- bible_set_redline:同上,修改写作红线。',
|
|
191
|
-
'- chapter_text:{"no": 章节号}。返回该章正文。',
|
|
192
|
-
'- chapter_rewrite:{"no": 章节号, "instructions": "修改要求", "target": "原文片段(可选,留空整章)"}。按讨论结果修订章节;给了 target 只改该自然段。',
|
|
193
|
-
'- chapter_generate:{"no": 章节号}。重新生成该章。',
|
|
194
|
-
'- chapter_review:{"no": 章节号}。对该章执行 AI 审稿。',
|
|
195
|
-
'- foreshadow_add:{"description": "暗线描述", "targetChapter": 预计回收章(可选)}。新增暗线。',
|
|
196
|
-
'- foreshadow_update:{"id": "暗线id", "status": "planned|planted|progressing|resolved|abandoned"}。更新暗线状态。',
|
|
197
|
-
'- export_txt:无参数。导出全本 TXT。',
|
|
198
|
-
'- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。',
|
|
199
|
-
'- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
|
|
200
|
-
'- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
|
|
201
|
-
'- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
|
|
202
|
-
'',
|
|
203
|
-
'回答质量要求(非常重要):',
|
|
204
|
-
'- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
|
|
205
|
-
'- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。',
|
|
206
|
-
'- 主动:作者说"改一下",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。',
|
|
207
|
-
'- 忠于设定:以总纲、道藏、编年录为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。',
|
|
208
|
-
'- 中文回复;文字量适中,别啰嗦。',
|
|
209
|
-
'',
|
|
210
|
-
'使用规则(非常重要):',
|
|
211
|
-
'- 写操作(chapter_generate / chapter_rewrite / chapter_review / outline_replace / bible_set_* / foreshadow_* / assets_set_* / export_txt)只有在作者明确要求时才能调用——例如作者说"生成第 120 章""把第 105 章结尾改一下""帮我审一下第 88 章"。作者只是提问、闲聊、查信息时,一律用文字回答,禁止调用任何写操作,也不要先斩后奏(如"为了回答你,我先把第 X 章生成了")。',
|
|
212
|
-
'- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说"我要去改",直接输出标签):',
|
|
213
|
-
' 正确示例:<dsh-action name="outline_replace">{"old":"要替换的原文","new":"新文本"}</dsh-action>',
|
|
214
|
-
' 正确示例:<dsh-action name="chapter_text">{"no":1}</dsh-action>',
|
|
215
|
-
' 错误示例(绝对不要这样回复):"好的,我先看一下总纲,马上改。" ← 这只是文字,不会执行任何操作',
|
|
216
|
-
' 错误示例(绝对不要这样回复):"先拉完整上下文确认改动落地情况,避免继续空转。" ← 没有动作标签,不会执行任何操作',
|
|
217
|
-
' 错误示例(绝对不要这样回复):"现在处理暗线库,我先看当前列表定位 id。" ← 没有动作标签,不会执行任何操作',
|
|
218
|
-
'- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释"我要做什么"、禁止铺垫——直接输出标签。',
|
|
219
|
-
'- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。',
|
|
220
|
-
'- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。',
|
|
221
|
-
'- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。',
|
|
222
|
-
'- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
|
|
223
|
-
'- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。',
|
|
224
|
-
'- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
|
|
225
|
-
'- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。',
|
|
226
|
-
'- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。',
|
|
227
|
-
'- 严格忠于道藏与总纲;不得自行发明与既有设定冲突的内容。',
|
|
228
|
-
'- 用中文回复。',
|
|
229
|
-
].join('\n')
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ------------------------------------------------------------- action exec
|
|
233
|
-
|
|
234
|
-
/** Execute one action directive. Returns a text result (or throws). */
|
|
235
|
-
/**
|
|
236
|
-
* Execute one action directive as an async generator: yields live progress
|
|
237
|
-
* text (chapter text being generated/rewritten), then yields the final result
|
|
238
|
-
* string. Throws on failure.
|
|
239
|
-
*/
|
|
240
|
-
export async function* executeAction(
|
|
241
|
-
ctx: Context,
|
|
242
|
-
config: NovelConfig,
|
|
243
|
-
project: ProjectState,
|
|
244
|
-
outputDir: string,
|
|
245
|
-
name: string,
|
|
246
|
-
args: Record<string, unknown>,
|
|
247
|
-
): AsyncGenerator<string, string, unknown> {
|
|
248
|
-
const str = (value: unknown): string => typeof value === 'string' ? value : ''
|
|
249
|
-
const num = (value: unknown): number | undefined => typeof value === 'number' ? value : undefined
|
|
250
|
-
|
|
251
|
-
/** Forward live text deltas from a streaming chapter job (text only). */
|
|
252
|
-
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> {
|
|
253
|
-
for await (const step of stream) {
|
|
254
|
-
if (step.frame === 'delta') yield step.text
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
switch (name) {
|
|
259
|
-
case 'book_overview': {
|
|
260
|
-
// 全书上下文包(分片:默认最近 30 章;scope=full 全量;scope=volume:N 指定卷)。
|
|
261
|
-
const scopeArg = str(args.scope)
|
|
262
|
-
const scope = scopeArg === 'full'
|
|
263
|
-
? 'full' as const
|
|
264
|
-
: /^volume:(\d+)$/.test(scopeArg)
|
|
265
|
-
? Number(scopeArg.slice(7))
|
|
266
|
-
: 'recent' as const
|
|
267
|
-
return bookOverview(project, scope)
|
|
268
|
-
}
|
|
269
|
-
case 'facts_query': {
|
|
270
|
-
// 从编年录按关键词检索相关事实。
|
|
271
|
-
const keyword = str(args.keyword).trim()
|
|
272
|
-
if (keyword === '') throw new Error('facts_query 需要 keyword')
|
|
273
|
-
const hits = (project.facts ?? []).filter(f => f.text.includes(keyword)).slice(-30)
|
|
274
|
-
if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`
|
|
275
|
-
return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map(f => `- [第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
276
|
-
}
|
|
277
|
-
case 'impact_analysis': {
|
|
278
|
-
const change = str(args.change)
|
|
279
|
-
if (change === '') throw new Error('impact_analysis 需要 change(要做的修改描述)')
|
|
280
|
-
const items = await analyzeImpact(ctx, config, project, outputDir, change)
|
|
281
|
-
if (items.length === 0) return '影响分析:未发现需要同步修改的位置。'
|
|
282
|
-
const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}(${it.kind === 'must' ? '必须同步' : it.kind === 'optional' ? '建议' : '备注'})`)
|
|
283
|
-
return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join('\n')}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`
|
|
284
|
-
}
|
|
285
|
-
case 'outline_text': {
|
|
286
|
-
return project.outline
|
|
287
|
-
}
|
|
288
|
-
case 'outline_replace': {
|
|
289
|
-
const old = str(args.old)
|
|
290
|
-
const next = str(args.new)
|
|
291
|
-
if (old === '' || !project.outline.includes(old)) {
|
|
292
|
-
throw new Error(`总纲中未找到片段「${old.slice(0, 40)}…」`)
|
|
293
|
-
}
|
|
294
|
-
project.outline = project.outline.replace(old, next)
|
|
295
|
-
project.updatedAt = new Date().toISOString()
|
|
296
|
-
saveProject(outputDir, project)
|
|
297
|
-
return `总纲已修改:替换了 ${old.length} 字符的片段。`
|
|
298
|
-
}
|
|
299
|
-
case 'bible_set_rule': {
|
|
300
|
-
if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
|
|
301
|
-
const index = num(args.index)
|
|
302
|
-
if (index !== undefined) {
|
|
303
|
-
project.bible.worldRules[index] = str(args.text)
|
|
304
|
-
} else if (str(args.append) !== '') {
|
|
305
|
-
project.bible.worldRules.push(str(args.append))
|
|
306
|
-
} else {
|
|
307
|
-
throw new Error('bible_set_rule 需要 index+text 或 append')
|
|
308
|
-
}
|
|
309
|
-
project.updatedAt = new Date().toISOString()
|
|
310
|
-
saveProject(outputDir, project)
|
|
311
|
-
return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`
|
|
312
|
-
}
|
|
313
|
-
case 'bible_set_redline': {
|
|
314
|
-
if (project.bible === undefined) throw new Error('尚无道藏,请先提炼')
|
|
315
|
-
const index = num(args.index)
|
|
316
|
-
if (index !== undefined) {
|
|
317
|
-
project.bible.redLines[index] = str(args.text)
|
|
318
|
-
} else if (str(args.append) !== '') {
|
|
319
|
-
project.bible.redLines.push(str(args.append))
|
|
320
|
-
} else {
|
|
321
|
-
throw new Error('bible_set_redline 需要 index+text 或 append')
|
|
322
|
-
}
|
|
323
|
-
project.updatedAt = new Date().toISOString()
|
|
324
|
-
saveProject(outputDir, project)
|
|
325
|
-
return `写作红线已更新(当前 ${project.bible.redLines.length} 条)。`
|
|
326
|
-
}
|
|
327
|
-
case 'chapter_text': {
|
|
328
|
-
const no = num(args.no)
|
|
329
|
-
if (no === undefined) throw new Error('chapter_text 需要 no')
|
|
330
|
-
const chapter = project.chapters.find(c => c.no === no)
|
|
331
|
-
if (chapter === undefined) throw new Error(`章节 ${no} 不存在`)
|
|
332
|
-
const body = readChapterFile(outputDir, chapter)
|
|
333
|
-
if (body === undefined) throw new Error(`章节 ${no} 尚未生成`)
|
|
334
|
-
return body
|
|
335
|
-
}
|
|
336
|
-
case 'chapter_rewrite': {
|
|
337
|
-
const no = num(args.no)
|
|
338
|
-
if (no === undefined) throw new Error('chapter_rewrite 需要 no')
|
|
339
|
-
const instructions = str(args.instructions)
|
|
340
|
-
const target = str(args.target)
|
|
341
|
-
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === '' ? undefined : target))) {
|
|
342
|
-
yield chunk
|
|
343
|
-
}
|
|
344
|
-
// Draft mode: the assistant acts on the user's explicit instruction, so
|
|
345
|
-
// apply the draft immediately — summarize/review must see the new body.
|
|
346
|
-
const chapter = project.chapters.find(c => c.no === no)
|
|
347
|
-
const draft = chapter?.pendingDraft
|
|
348
|
-
if (chapter === undefined || draft === undefined || draft === '') {
|
|
349
|
-
throw new Error(`章节 ${no} 修订后没有产出草稿`)
|
|
350
|
-
}
|
|
351
|
-
const fileName = chapterFileName(chapter)
|
|
352
|
-
mkdirSync(outputDir, { recursive: true })
|
|
353
|
-
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
354
|
-
chapter.pendingDraft = undefined
|
|
355
|
-
chapter.status = 'written'
|
|
356
|
-
chapter.chars = draft.length
|
|
357
|
-
chapter.file = fileName
|
|
358
|
-
chapter.review = undefined
|
|
359
|
-
chapter.error = undefined
|
|
360
|
-
project.updatedAt = new Date().toISOString()
|
|
361
|
-
saveProject(outputDir, project)
|
|
362
|
-
yield '(已采纳修订稿,正在生成章节摘要与编年录…)'
|
|
363
|
-
try {
|
|
364
|
-
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
365
|
-
} catch { /* best-effort */ }
|
|
366
|
-
yield '(正在 AI 审稿…)'
|
|
367
|
-
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
368
|
-
return `章节 ${no} 已${target === '' ? '整章' : '局部'}修订完成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。重新审稿:${report.score} 分 — ${report.verdict}`
|
|
369
|
-
}
|
|
370
|
-
case 'chapter_generate': {
|
|
371
|
-
const no = num(args.no)
|
|
372
|
-
if (no === undefined) throw new Error('chapter_generate 需要 no')
|
|
373
|
-
for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) {
|
|
374
|
-
yield chunk
|
|
375
|
-
}
|
|
376
|
-
yield '(正在生成章节摘要与编年录…)'
|
|
377
|
-
try {
|
|
378
|
-
await summarizeAndExtractFacts(ctx, config, project, outputDir, no)
|
|
379
|
-
} catch { /* best-effort */ }
|
|
380
|
-
yield '(正在 AI 审稿…)'
|
|
381
|
-
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
382
|
-
return `章节 ${no} 已生成(${project.chapters.find(c => c.no === no)?.chars ?? '?'} 字)。审稿:${report.score} 分 — ${report.verdict}`
|
|
383
|
-
}
|
|
384
|
-
case 'chapter_review': {
|
|
385
|
-
const no = num(args.no)
|
|
386
|
-
if (no === undefined) throw new Error('chapter_review 需要 no')
|
|
387
|
-
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
388
|
-
const issues = report.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
|
|
389
|
-
return `章节 ${no} 审稿:${report.score} 分 — ${report.verdict}\n${issues}`
|
|
390
|
-
}
|
|
391
|
-
case 'foreshadow_add': {
|
|
392
|
-
const description = str(args.description)
|
|
393
|
-
if (description === '') throw new Error('foreshadow_add 需要 description')
|
|
394
|
-
const targetChapter = num(args.targetChapter)
|
|
395
|
-
project.foreshadows.push({
|
|
396
|
-
id: `fs-${Date.now().toString(36)}`,
|
|
397
|
-
description,
|
|
398
|
-
targetChapter,
|
|
399
|
-
status: 'planned',
|
|
400
|
-
})
|
|
401
|
-
project.updatedAt = new Date().toISOString()
|
|
402
|
-
saveProject(outputDir, project)
|
|
403
|
-
return `已新增暗线:「${description.slice(0, 50)}」`
|
|
404
|
-
}
|
|
405
|
-
case 'foreshadow_update': {
|
|
406
|
-
const id = str(args.id)
|
|
407
|
-
const status = str(args.status) as 'planned' | 'planted' | 'progressing' | 'resolved' | 'abandoned'
|
|
408
|
-
const target = project.foreshadows.find(f => f.id === id)
|
|
409
|
-
if (target === undefined) throw new Error(`暗线 ${id} 不存在`)
|
|
410
|
-
if (!['planned', 'planted', 'progressing', 'resolved', 'abandoned'].includes(status)) {
|
|
411
|
-
throw new Error(`非法状态 ${status}`)
|
|
412
|
-
}
|
|
413
|
-
target.status = status
|
|
414
|
-
project.updatedAt = new Date().toISOString()
|
|
415
|
-
saveProject(outputDir, project)
|
|
416
|
-
return `暗线已更新为 ${status}:「${target.description.slice(0, 50)}」`
|
|
417
|
-
}
|
|
418
|
-
case 'export_txt': {
|
|
419
|
-
const result = exportBook(outputDir, project, 'txt')
|
|
420
|
-
return `已导出 TXT:${result.file}(${result.chars} 字,${result.chapters} 章)`
|
|
421
|
-
}
|
|
422
|
-
case 'assets_status': {
|
|
423
|
-
const assets = project.assets
|
|
424
|
-
if (assets === undefined) return '本书尚未配置写作资产。'
|
|
425
|
-
const parts: string[] = []
|
|
426
|
-
if (assets.genre !== undefined) parts.push(`题材:${assets.genre.name}`)
|
|
427
|
-
if (assets.primaryProgression !== undefined) parts.push(`主推进:${assets.primaryProgression.name}`)
|
|
428
|
-
if (assets.auxiliaryProgressions.length > 0) parts.push(`辅助推进:${assets.auxiliaryProgressions.map(m => m.name).join('、')}`)
|
|
429
|
-
if (assets.antiAiRules.length > 0) parts.push(`自定义反AI规则:${assets.antiAiRules.map(r => r.name).join('、')}`)
|
|
430
|
-
if (assets.styleAssets.length > 0) parts.push(`写法资产:${assets.styleAssets.map(s => s.name).join('、')}`)
|
|
431
|
-
return parts.length > 0 ? parts.join('\n') : '本书尚未配置写作资产。'
|
|
432
|
-
}
|
|
433
|
-
case 'assets_set_genre': {
|
|
434
|
-
const name = str(args.name)
|
|
435
|
-
const description = str(args.description)
|
|
436
|
-
if (name === '') throw new Error('assets_set_genre 需要 name')
|
|
437
|
-
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
438
|
-
project.assets.genre = { name, description, children: [] }
|
|
439
|
-
project.assets.updatedAt = new Date().toISOString()
|
|
440
|
-
project.updatedAt = new Date().toISOString()
|
|
441
|
-
saveProject(outputDir, project)
|
|
442
|
-
return `题材已设为「${name}」`
|
|
443
|
-
}
|
|
444
|
-
case 'assets_set_progression': {
|
|
445
|
-
const name = str(args.name)
|
|
446
|
-
const driver = str(args.driver)
|
|
447
|
-
const primary = args.primary !== false
|
|
448
|
-
if (name === '') throw new Error('assets_set_progression 需要 name')
|
|
449
|
-
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
450
|
-
const mode = {
|
|
451
|
-
name,
|
|
452
|
-
driver: driver !== '' ? driver : name,
|
|
453
|
-
readerExpectation: str(args.readerExpectation),
|
|
454
|
-
payoffs: Array.isArray(args.payoffs) ? args.payoffs.filter((v): v is string => typeof v === 'string') : [],
|
|
455
|
-
risks: Array.isArray(args.risks) ? args.risks.filter((v): v is string => typeof v === 'string') : [],
|
|
456
|
-
primary,
|
|
457
|
-
}
|
|
458
|
-
if (primary) project.assets.primaryProgression = mode
|
|
459
|
-
else {
|
|
460
|
-
if (project.assets.auxiliaryProgressions === undefined) project.assets.auxiliaryProgressions = []
|
|
461
|
-
project.assets.auxiliaryProgressions.push(mode)
|
|
462
|
-
}
|
|
463
|
-
project.assets.updatedAt = new Date().toISOString()
|
|
464
|
-
project.updatedAt = new Date().toISOString()
|
|
465
|
-
saveProject(outputDir, project)
|
|
466
|
-
return `推进模式${primary ? '(主)' : '(辅助)'}已设置:「${name}」`
|
|
467
|
-
}
|
|
468
|
-
case 'assets_add_rule': {
|
|
469
|
-
const name = str(args.name)
|
|
470
|
-
const avoid = str(args.avoid)
|
|
471
|
-
if (avoid === '') throw new Error('assets_add_rule 需要 avoid(要避免的表达问题)')
|
|
472
|
-
if (project.assets === undefined) project.assets = emptyProjectAssets()
|
|
473
|
-
if (project.assets.antiAiRules === undefined) project.assets.antiAiRules = []
|
|
474
|
-
project.assets.antiAiRules.push({
|
|
475
|
-
name: name !== '' ? name : `自定义规则 ${project.assets.antiAiRules.length + 1}`,
|
|
476
|
-
avoid,
|
|
477
|
-
fix: str(args.fix),
|
|
478
|
-
})
|
|
479
|
-
project.assets.updatedAt = new Date().toISOString()
|
|
480
|
-
project.updatedAt = new Date().toISOString()
|
|
481
|
-
saveProject(outputDir, project)
|
|
482
|
-
return `已新增反 AI 规则「${name !== '' ? name : avoid.slice(0, 20)}」`
|
|
483
|
-
}
|
|
484
|
-
default:
|
|
485
|
-
throw new Error(`未知工具 ${name}`)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// ------------------------------------------------------------------- chat
|
|
490
|
-
|
|
491
|
-
/** Extract the first action directive from a reply (tolerant to common tag misspellings). */
|
|
492
|
-
function extractAction(reply: string): { name: string; args: Record<string, unknown>; index: number } | undefined {
|
|
493
|
-
// 容错:dsh-action 的常见误拼(dash-action / dsah-action / dhs-action 等)。
|
|
494
|
-
const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply)
|
|
495
|
-
if (match === null) return undefined
|
|
496
|
-
const rawArgs = match[3]?.trim() ?? ''
|
|
497
|
-
let args: Record<string, unknown>
|
|
498
|
-
try {
|
|
499
|
-
args = rawArgs === '' ? {} : JSON.parse(rawArgs) as Record<string, unknown>
|
|
500
|
-
} catch {
|
|
501
|
-
throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`)
|
|
502
|
-
}
|
|
503
|
-
return { name: match[2] ?? '', args, index: match.index }
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
507
|
-
function historyToMessages(history: AssistantMessage[]): Message[] {
|
|
508
|
-
const recent = history.slice(-MAX_HISTORY_MESSAGES)
|
|
509
|
-
const messages: Message[] = []
|
|
510
|
-
for (const entry of recent) {
|
|
511
|
-
if (entry.role === 'user') {
|
|
512
|
-
messages.push(createUserMessage({
|
|
513
|
-
content: [{ type: 'text', text: entry.content }],
|
|
514
|
-
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
515
|
-
}))
|
|
516
|
-
} else if (entry.role === 'assistant') {
|
|
517
|
-
messages.push(createAssistantMessage({
|
|
518
|
-
content: [{ type: 'text', text: entry.content }],
|
|
519
|
-
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
|
520
|
-
}))
|
|
521
|
-
} else if (entry.role === 'tool') {
|
|
522
|
-
// 工具结果(book_overview/总纲全文等可能很大)截断后进上下文,
|
|
523
|
-
// 避免多轮对话上下文翻倍膨胀触发「上下文到限制」。
|
|
524
|
-
const body = entry.content.length > 2000
|
|
525
|
-
? entry.content.slice(0, 2000) + '\n…(结果过长已截断,需要完整内容请重新调用工具)'
|
|
526
|
-
: entry.content
|
|
527
|
-
messages.push(createUserMessage({
|
|
528
|
-
content: [{ type: 'text', text: `【工具 ${entry.tool ?? ''} 的执行结果】\n${body}` }],
|
|
529
|
-
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
530
|
-
}))
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return messages
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
537
|
-
async function chatOnce(
|
|
538
|
-
ctx: Context,
|
|
539
|
-
config: NovelConfig,
|
|
540
|
-
system: string,
|
|
541
|
-
history: AssistantMessage[],
|
|
542
|
-
): Promise<string> {
|
|
543
|
-
const messages = historyToMessages(history)
|
|
544
|
-
// 纪律提醒贴在最后一条消息(当前用户输入)末尾:紧邻模型要生成回复的位置,
|
|
545
|
-
// 比放在 system 里更不容易被长上下文稀释(位置效应)。
|
|
546
|
-
// 注意:dsh-llm 的消息/内容块对象是只读的,必须重建新对象,不能直接改属性。
|
|
547
|
-
const last = messages[messages.length - 1]
|
|
548
|
-
if (last?.role === 'user' && Array.isArray(last.content)) {
|
|
549
|
-
const blocks = last.content as ContentBlock[]
|
|
550
|
-
const idx = blocks.findIndex(b => b.type === 'text')
|
|
551
|
-
if (idx !== -1) {
|
|
552
|
-
const textBlock = blocks[idx] as { type: 'text'; text: string }
|
|
553
|
-
const newBlocks: ContentBlock[] = blocks.map((b, i) => i === idx
|
|
554
|
-
? { ...textBlock, text: textBlock.text + '\n\n(回复格式提醒:如果你需要执行任何操作,你的回复必须【只包含】一个 <dsh-action name="工具名">{"参数":值}</dsh-action> 标签,禁止先说话、禁止解释、禁止铺垫;如果你只是在回答或讨论,正常回复即可,不要输出标签。)' }
|
|
555
|
-
: b)
|
|
556
|
-
messages[messages.length - 1] = { ...last, content: newBlocks }
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
const request: GenerateOptions = {
|
|
560
|
-
provider: config.provider,
|
|
561
|
-
model: config.model,
|
|
562
|
-
messages,
|
|
563
|
-
system,
|
|
564
|
-
// v4-flash 推理模型:reasoning channel 占预算,给足避免回复被截断。
|
|
565
|
-
maxTokens: Math.max(config.maxTokens, 16000),
|
|
566
|
-
temperature: 0.7,
|
|
567
|
-
}
|
|
568
|
-
const assembler = new BlockAssembler()
|
|
569
|
-
for await (const chunk of ctx.llm.stream(request)) {
|
|
570
|
-
assembler.push(chunk)
|
|
571
|
-
}
|
|
572
|
-
const finish = assembler.finish
|
|
573
|
-
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
574
|
-
throw new Error(`助手调用失败(${finish.kind}): ${finish.failure.message}`)
|
|
575
|
-
}
|
|
576
|
-
const blocks = assembler.blocks()
|
|
577
|
-
const textBlocks = blocks
|
|
578
|
-
.filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
|
|
579
|
-
.map(block => block.text)
|
|
580
|
-
let text = textBlocks.join('\n').trim()
|
|
581
|
-
if (text === '') {
|
|
582
|
-
const reasoning = blocks
|
|
583
|
-
.filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
|
|
584
|
-
.map(block => block.text)
|
|
585
|
-
.join('\n')
|
|
586
|
-
.trim()
|
|
587
|
-
if (reasoning !== '') text = reasoning
|
|
588
|
-
}
|
|
589
|
-
return text
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Run one user turn. Yields stream frames; persists history. */
|
|
593
|
-
export async function* runAssistantTurn(
|
|
594
|
-
ctx: Context,
|
|
595
|
-
config: NovelConfig,
|
|
596
|
-
project: ProjectState,
|
|
597
|
-
outputDir: string,
|
|
598
|
-
userMessage: string,
|
|
599
|
-
): AsyncGenerator<
|
|
600
|
-
| { frame: 'delta'; text: string }
|
|
601
|
-
| { frame: 'tool'; name: string; status: 'start' | 'done' | 'error'; detail?: string }
|
|
602
|
-
| { frame: 'toolDelta'; name: string; text: string },
|
|
603
|
-
void,
|
|
604
|
-
unknown
|
|
605
|
-
> {
|
|
606
|
-
const history = loadAssistantHistory(outputDir)
|
|
607
|
-
const system = assistantSystemPrompt(project)
|
|
608
|
-
|
|
609
|
-
// Persist the user message.
|
|
610
|
-
const userEntry: AssistantMessage = { role: 'user', content: userMessage, ts: new Date().toISOString() }
|
|
611
|
-
history.push(userEntry)
|
|
612
|
-
appendHistory(outputDir, userEntry)
|
|
613
|
-
|
|
614
|
-
let round = 0
|
|
615
|
-
/** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
|
|
616
|
-
let nudged = 0
|
|
617
|
-
const MAX_NUDGES = 6
|
|
618
|
-
/** 连续收到 hex 乱码回复的次数(≥2 次判定 LLM 侧异常,放弃本轮避免死循环)。 */
|
|
619
|
-
let garbleCount = 0
|
|
620
|
-
|
|
621
|
-
// ---- 写操作意图守卫 ------------------------------------------------
|
|
622
|
-
// 写类工具会改动项目数据/正文文件:仅当用户消息(含最近一轮上下文)里
|
|
623
|
-
// 出现与该工具对应的明确修改动词时才放行;否则拒绝执行,防止"随口一问"
|
|
624
|
-
// 被模型误判成创作指令(如问角色是否登场 → 自主生成整章)。
|
|
625
|
-
const WRITE_TOOL_KEYS: Record<string, RegExp> = {
|
|
626
|
-
chapter_generate: /(生成|写第\s*\d+\s*章|写一[章篇]|新写|续写|接着写|继续写|开始写|写正文|写书|创作)/,
|
|
627
|
-
chapter_rewrite: /(重写|改写|修订|修改|改一下|调整|替换|润色|优化|修正|完善|回炉|换一种|从头)/,
|
|
628
|
-
chapter_review: /(审|检查|校验|点评|评估|把关|质量|怎么样|如何)/,
|
|
629
|
-
outline_replace: /(大纲|总纲|简介)/,
|
|
630
|
-
bible_set_rule: /(道藏|设定|规则|红线|世界|金手指)/,
|
|
631
|
-
bible_set_redline: /(道藏|设定|红线)/,
|
|
632
|
-
foreshadow_add: /(暗线|伏笔|埋)/,
|
|
633
|
-
foreshadow_update: /(暗线|伏笔)/,
|
|
634
|
-
export_txt: /(导出|打包|下载|txt)/,
|
|
635
|
-
assets_set_genre: /(题材)/,
|
|
636
|
-
assets_set_progression: /(推进)/,
|
|
637
|
-
assets_add_rule: /(规则|文戒|反AI)/,
|
|
638
|
-
}
|
|
639
|
-
/** 本轮已放行过写操作:后续写操作(生成→审稿→修订闭环)不再逐个拦截。 */
|
|
640
|
-
let writeUnlocked = false
|
|
641
|
-
const guardWrite = (name: string, userMessage: string): boolean => {
|
|
642
|
-
if (writeUnlocked) return true
|
|
643
|
-
const key = WRITE_TOOL_KEYS[name]
|
|
644
|
-
if (key === undefined) return true // 只读工具 / 未登记工具一律放行
|
|
645
|
-
// 近两轮用户消息合并判断(支持"继续"类延续指令)。
|
|
646
|
-
const recentUsers = history
|
|
647
|
-
.filter(m => m.role === 'user')
|
|
648
|
-
.slice(-2)
|
|
649
|
-
.map(m => m.content)
|
|
650
|
-
.join('\n')
|
|
651
|
-
if (key.test(recentUsers)) {
|
|
652
|
-
writeUnlocked = true
|
|
653
|
-
return true
|
|
654
|
-
}
|
|
655
|
-
return false
|
|
656
|
-
}
|
|
657
|
-
for (;;) {
|
|
658
|
-
if (round++ > 20) break
|
|
659
|
-
const reply = await chatOnce(ctx, config, system, history)
|
|
660
|
-
|
|
661
|
-
// 异常输出防护:全 hex/二进制乱码(模型偶发把回复编码成十六进制)——丢弃重试一次。
|
|
662
|
-
const hexLike = reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2000))
|
|
663
|
-
if (hexLike) {
|
|
664
|
-
garbleCount++
|
|
665
|
-
if (garbleCount >= 2) {
|
|
666
|
-
const garbleEntry: AssistantMessage = { role: 'assistant', content: '(模型本次返回了异常编码内容,已忽略;请重新描述你的问题。)', ts: new Date().toISOString() }
|
|
667
|
-
history.push(garbleEntry)
|
|
668
|
-
appendHistory(outputDir, garbleEntry)
|
|
669
|
-
yield { frame: 'delta', text: garbleEntry.content }
|
|
670
|
-
return
|
|
671
|
-
}
|
|
672
|
-
continue
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const action = extractAction(reply)
|
|
676
|
-
|
|
677
|
-
if (action === undefined) {
|
|
678
|
-
// No parseable action tag. If the reply clearly intends to modify
|
|
679
|
-
// something (or contains a malformed action tag), nudge — repeatedly,
|
|
680
|
-
// with escalating firmness, until the model emits a tag or gives up.
|
|
681
|
-
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成|定位|处理|转轨|检查|确认|搜索|找一下|列一下|查一下|查一遍|查一查|再查|查查|核实|核对|清点|盘点|看一下|看下|继续)/.test(reply)
|
|
682
|
-
// 兜底:回复提到任何可操作对象名词(即使动词是"看/查/说"的变体)也视为有操作意图,
|
|
683
|
-
// 避免"先查一遍编年录"这类措辞漏网被静默当作纯聊天。
|
|
684
|
-
const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply)
|
|
685
|
-
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
|
|
686
|
-
if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
|
|
687
|
-
// 用户消息本身没有写意图时,引导模型直接文字回答(不要被逼出写操作标签)。
|
|
688
|
-
const userWriteIntent = Object.values(WRITE_TOOL_KEYS).some(re => re.test(userMessage))
|
|
689
|
-
const nudge = nudged === 0
|
|
690
|
-
? (userWriteIntent
|
|
691
|
-
? '你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name="工具名">{"参数":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。'
|
|
692
|
-
: '你刚才的回复看起来在讨论项目内容,但没有必要执行任何操作。如果用户只是在提问或闲聊,请直接以文字回答即可,不要输出动作标签,也不要自行调用任何写操作(生成/修订/删除等只有用户明确要求时才允许)。若确实需要先查看数据,最多使用只读工具(outline_text / chapter_text / book_overview / facts_query)。')
|
|
693
|
-
: (userWriteIntent
|
|
694
|
-
? `你第 ${nudged + 1} 次表达了操作意图但没有输出动作标签,因此仍未执行任何操作。铁律:你的【整个回复】现在必须只包含一个 <dsh-action> 标签(例如 <dsh-action name="chapter_text">{"no":1}</dsh-action>),禁止任何解释、铺垫或"我这就去"之类的文字。若你其实不打算执行任何操作,请明确回复「不执行」。`
|
|
695
|
-
: `你第 ${nudged + 1} 次回复仍不需要执行操作。再次强调:用户没有要求修改,请直接给出文字回答(可以简短引用项目数据),不要输出动作标签。写操作只会在用户明确要求时被允许。`)
|
|
696
|
-
nudged++
|
|
697
|
-
history.push({ role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
698
|
-
appendHistory(outputDir, { role: 'tool', content: nudge, tool: 'format-hint', ts: new Date().toISOString() })
|
|
699
|
-
continue
|
|
700
|
-
}
|
|
701
|
-
// Plain prose reply — done.
|
|
702
|
-
const assistantEntry: AssistantMessage = { role: 'assistant', content: reply, ts: new Date().toISOString() }
|
|
703
|
-
history.push(assistantEntry)
|
|
704
|
-
appendHistory(outputDir, assistantEntry)
|
|
705
|
-
// Stream the prose (without any stray action markup).
|
|
706
|
-
yield { frame: 'delta', text: reply }
|
|
707
|
-
return
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// Execute the action, then feed the result back and continue.
|
|
711
|
-
const { name, args, index } = action
|
|
712
|
-
const prose = reply.slice(0, index).trim()
|
|
713
|
-
// 写操作意图守卫:用户没明确要求修改时,拒绝执行并提示(不写盘、不消耗生成额度)。
|
|
714
|
-
if (!guardWrite(name, userMessage)) {
|
|
715
|
-
const denied = `【操作被拒绝】${name} 是写操作(会修改正文/项目数据),但你当前的消息里没有明确要求执行该修改。如果需要,请明确说明(如「生成第 120 章」「把第 105 章结尾改一下」)。我不会擅自修改你的作品。`
|
|
716
|
-
history.push({ role: 'tool', content: denied, tool: name, ts: new Date().toISOString() })
|
|
717
|
-
appendHistory(outputDir, { role: 'tool', content: denied, tool: name, ts: new Date().toISOString() })
|
|
718
|
-
yield { frame: 'tool', name, status: 'error', detail: denied }
|
|
719
|
-
// 拒绝后结束本轮:等作者重新明确指令。
|
|
720
|
-
const assistantEntry: AssistantMessage = { role: 'assistant', content: denied, ts: new Date().toISOString() }
|
|
721
|
-
history.push(assistantEntry)
|
|
722
|
-
appendHistory(outputDir, assistantEntry)
|
|
723
|
-
yield { frame: 'delta', text: denied }
|
|
724
|
-
return
|
|
725
|
-
}
|
|
726
|
-
yield { frame: 'tool', name, status: 'start' }
|
|
727
|
-
let result: string
|
|
728
|
-
try {
|
|
729
|
-
// executeAction is an async generator: it yields live progress text
|
|
730
|
-
// (chapter text being generated) and returns the final result string.
|
|
731
|
-
// Iterate manually so we see both the yielded deltas and the return.
|
|
732
|
-
const iterator = executeAction(ctx, config, project, outputDir, name, args)[Symbol.asyncIterator]()
|
|
733
|
-
result = ''
|
|
734
|
-
for (;;) {
|
|
735
|
-
const step = await iterator.next()
|
|
736
|
-
if (step.done === true) {
|
|
737
|
-
result = typeof step.value === 'string' ? step.value : ''
|
|
738
|
-
break
|
|
739
|
-
}
|
|
740
|
-
const chunk = step.value
|
|
741
|
-
if (typeof chunk === 'string' && chunk !== '') {
|
|
742
|
-
yield { frame: 'toolDelta', name, text: chunk }
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
yield { frame: 'tool', name, status: 'done', detail: result.slice(0, 200) }
|
|
746
|
-
} catch (error) {
|
|
747
|
-
result = `执行失败:${(error as Error).message}`
|
|
748
|
-
yield { frame: 'tool', name, status: 'error', detail: (error as Error).message }
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// Persist assistant prose + tool result as history entries.
|
|
752
|
-
if (prose !== '') {
|
|
753
|
-
history.push({ role: 'assistant', content: prose, ts: new Date().toISOString() })
|
|
754
|
-
appendHistory(outputDir, { role: 'assistant', content: prose, ts: new Date().toISOString() })
|
|
755
|
-
}
|
|
756
|
-
history.push({ role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
|
|
757
|
-
appendHistory(outputDir, { role: 'tool', content: result, tool: name, ts: new Date().toISOString() })
|
|
758
|
-
|
|
759
|
-
round++
|
|
760
|
-
if (round >= MAX_TOOL_ROUNDS) {
|
|
761
|
-
const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`
|
|
762
|
-
history.push({ role: 'assistant', content: message, ts: new Date().toISOString() })
|
|
763
|
-
appendHistory(outputDir, { role: 'assistant', content: message, ts: new Date().toISOString() })
|
|
764
|
-
yield { frame: 'delta', text: message }
|
|
765
|
-
return
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
export { chapterFileName }
|