@waterwx/dsh-novel-forge 1.2.0 → 1.2.1-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.
Files changed (125) hide show
  1. package/README.md +237 -349
  2. package/lib/client.js +54447 -10560
  3. package/lib/index.js +12008 -5071
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +18 -3
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +147 -9
  11. package/lib/types/client/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +33 -0
  14. package/lib/types/client/locales.d.ts +28 -11
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  36. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  37. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  38. package/lib/types/client/panel/SubPage.d.ts +27 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +25 -7
  41. package/lib/types/client/run-status.d.ts +19 -0
  42. package/lib/types/client/text.d.ts +9 -0
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +146 -40
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/index.d.ts +18 -2
  48. package/lib/types/llm-live.d.ts +44 -0
  49. package/lib/types/market-radar-scan.d.ts +19 -0
  50. package/lib/types/market-radar-sources.d.ts +31 -0
  51. package/lib/types/migrate-output.d.ts +25 -0
  52. package/lib/types/novel-context.d.ts +80 -0
  53. package/lib/types/prompt-utils.d.ts +35 -0
  54. package/lib/types/prompting.d.ts +10 -0
  55. package/lib/types/protocol.d.ts +1029 -123
  56. package/lib/types/review-policy.d.ts +97 -0
  57. package/lib/types/routes.d.ts +4 -0
  58. package/lib/types/run.d.ts +3 -1
  59. package/lib/types/stage-contract.d.ts +60 -0
  60. package/package.json +51 -27
  61. package/scripts/patch-schemastery.mjs +47 -0
  62. package/src/action-guard.ts +127 -0
  63. package/src/ai-scan.ts +179 -0
  64. package/src/assets.ts +1774 -520
  65. package/src/assistant.ts +1082 -770
  66. package/src/author-assets.ts +149 -0
  67. package/src/bookshelf.ts +187 -151
  68. package/src/client/api.ts +684 -410
  69. package/src/client/docx.ts +37 -69
  70. package/src/client/index.ts +3 -1
  71. package/src/client/llmLive.ts +101 -0
  72. package/src/client/locales.ts +418 -383
  73. package/src/client/mount.tsx +1 -1
  74. package/src/client/panel/AdaptModeView.tsx +535 -0
  75. package/src/client/panel/AiTag.tsx +7 -0
  76. package/src/client/panel/AssetsTab.tsx +448 -458
  77. package/src/client/panel/AssistantTab.tsx +355 -301
  78. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  79. package/src/client/panel/AuthorHome.tsx +309 -0
  80. package/src/client/panel/BookAnalysisView.tsx +63 -0
  81. package/src/client/panel/CmdPalette.tsx +102 -0
  82. package/src/client/panel/CreateBookView.tsx +366 -348
  83. package/src/client/panel/DirectorView.tsx +200 -0
  84. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  85. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  86. package/src/client/panel/ImportModal.tsx +303 -0
  87. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  88. package/src/client/panel/LiveFeedLog.tsx +67 -0
  89. package/src/client/panel/MarketRadarView.tsx +377 -0
  90. package/src/client/panel/ModelManager.tsx +205 -0
  91. package/src/client/panel/NovelPanel.tsx +5403 -4597
  92. package/src/client/panel/PageHeader.tsx +37 -0
  93. package/src/client/panel/ProgressConsole.tsx +63 -0
  94. package/src/client/panel/ReaderView.tsx +306 -306
  95. package/src/client/panel/ReasoningSection.tsx +31 -0
  96. package/src/client/panel/RunPanel.tsx +223 -204
  97. package/src/client/panel/SettingsView.tsx +305 -0
  98. package/src/client/panel/ShelfView.tsx +266 -236
  99. package/src/client/panel/SubPage.tsx +42 -0
  100. package/src/client/panel/WorldTab.tsx +202 -202
  101. package/src/client/panel/panel.module.css +5764 -2941
  102. package/src/client/panel/toast.ts +51 -0
  103. package/src/client/panel/views.tsx +401 -314
  104. package/src/client/run-status.ts +143 -0
  105. package/src/client/sidebar-entry.ts +1 -1
  106. package/src/client/text.ts +28 -0
  107. package/src/docx-core.ts +53 -0
  108. package/src/docx.ts +44 -83
  109. package/src/engine.ts +4231 -2787
  110. package/src/global-assets.ts +59 -0
  111. package/src/index.ts +311 -252
  112. package/src/llm-live.ts +68 -0
  113. package/src/market-radar-scan.ts +41 -0
  114. package/src/market-radar-sources.ts +173 -0
  115. package/src/migrate-output.ts +76 -0
  116. package/src/novel-context.ts +286 -0
  117. package/src/prompting.ts +26 -0
  118. package/src/protocol.ts +2072 -1182
  119. package/src/review-policy.ts +161 -0
  120. package/src/routes.ts +3405 -2151
  121. package/src/run.ts +342 -330
  122. package/src/stage-contract.ts +189 -0
  123. package/lib/client.js.map +0 -1
  124. package/lib/index.js.map +0 -1
  125. package/src/client/panel/BookshelfBar.tsx +0 -170
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LLM 实况 feed(对齐上游「AI 创作实况 / LIVE LOG」的轻量实现)。
3
+ *
4
+ * 以「每次 LLM 调用」为粒度,把生命周期事件(开始/流式正文/阶段变化/完成)推给订阅者,
5
+ * 并提供环形缓冲供新连上的客户端重放。不持久化(与上游缓存不同,如需成 cache 再扩展)。
6
+ */
7
+
8
+ export type LlmLivePhase = 'requesting' | 'streaming' | 'completed' | 'failed'
9
+
10
+ export interface LlmLiveFrame {
11
+ type: 'session_started' | 'output_delta' | 'reasoning_delta' | 'phase_changed' | 'session_completed'
12
+ /** 由服务端生成,形如 ll-<n>。 */
13
+ sessionId: string
14
+ /** 本次调用用途(如 正文生成/审稿/章节规划)。 */
15
+ label?: string
16
+ /** 使用模型(用于展示)。 */
17
+ model?: string
18
+ /** 事件时间(ISO)。 */
19
+ at: string
20
+ /** session_started / 部分帧:交互上下文。 */
21
+ context?: { taskId?: string; interactionId: string }
22
+ /** output_delta / reasoning_delta:本次增量文本。 */
23
+ content?: string
24
+ /** session 累计字符数。 */
25
+ totalChars?: number
26
+ /** reasoning 累计字符数。 */
27
+ totalReasoningChars?: number
28
+ /** phase_changed:新阶段。 */
29
+ phase?: LlmLivePhase
30
+ /** phase_changed:阶段描述。 */
31
+ phaseMessage?: string
32
+ /** session_completed:最终预览(截断)。 */
33
+ preview?: string
34
+ /** session_completed:失败信息。 */
35
+ error?: string
36
+ }
37
+
38
+ const MAX_BUFFER = 400
39
+ let buffer: LlmLiveFrame[] = []
40
+ type Listener = (frame: LlmLiveFrame) => void
41
+ let listeners = new Set<Listener>()
42
+ let seq = 0
43
+
44
+ export function nextSessionId(): string {
45
+ seq++
46
+ return `ll-${Date.now().toString(36)}-${seq}`
47
+ }
48
+
49
+ export function subscribeLiveFeed(listener: Listener): () => void {
50
+ // 先重放现有缓冲(新连接能立即看到最近调用),再进入实时。
51
+ for (const frame of buffer) listener(frame)
52
+ listeners.add(listener)
53
+ return () => { listeners.delete(listener) }
54
+ }
55
+
56
+ export function emitLive(frame: LlmLiveFrame): void {
57
+ buffer.push(frame)
58
+ if (buffer.length > MAX_BUFFER) buffer = buffer.slice(-MAX_BUFFER)
59
+ for (const l of listeners) l(frame)
60
+ }
61
+
62
+ export function liveLatest(): LlmLiveFrame[] {
63
+ return [...buffer]
64
+ }
65
+
66
+ export function clearLiveFeed(): void {
67
+ buffer = []
68
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 热门题材雷达:真实榜单扫榜(聚合各平台榜单源,容错)。
3
+ */
4
+ import { collectMarketSource, MARKET_RADAR_SOURCES, type CollectedRankingItem, type MarketRadarPlatform, type MarketRadarListSource } from './market-radar-sources.ts'
5
+
6
+ export interface MarketRadarGroup {
7
+ platform: MarketRadarPlatform
8
+ platformLabel: string
9
+ listKey: string
10
+ listLabel: string
11
+ status: 'ok' | 'error'
12
+ error?: string
13
+ items: CollectedRankingItem[]
14
+ }
15
+
16
+ export interface MarketScanResult {
17
+ scannedAt: string
18
+ groups: MarketRadarGroup[]
19
+ }
20
+
21
+ let lastScan: MarketScanResult | null = null
22
+
23
+ export function getLastMarketScan(): MarketScanResult | null {
24
+ return lastScan
25
+ }
26
+
27
+ export async function scanMarketRanking(platforms?: string[]): Promise<MarketScanResult> {
28
+ const wanted = new Set<string>(platforms?.length ? platforms : ['fanqie', 'qidian', 'jinjiang'])
29
+ const sources = MARKET_RADAR_SOURCES.filter(s => wanted.has(s.platform))
30
+ const groups: MarketRadarGroup[] = []
31
+ await Promise.all(sources.map(async (source: MarketRadarListSource) => {
32
+ try {
33
+ const items = await collectMarketSource(source)
34
+ groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'ok', items })
35
+ } catch (error) {
36
+ groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'error', error: (error as Error).message, items: [] })
37
+ }
38
+ }))
39
+ lastScan = { scannedAt: new Date().toISOString(), groups }
40
+ return lastScan
41
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 「热门题材雷达」真实榜单抓取源(移植自 AI-Novel-Writing-Assistant marketRadarSources)。
3
+ * 用全局 fetch 抓取公开榜单移动版页面,正则抽取元数据;无第三方依赖。
4
+ */
5
+ export type MarketRadarPlatform = 'fanqie' | 'qidian' | 'jinjiang'
6
+
7
+ export interface MarketRadarListSource {
8
+ platform: MarketRadarPlatform
9
+ platformLabel: string
10
+ listKey: string
11
+ listLabel: string
12
+ channel: string
13
+ sourceUrl: string
14
+ }
15
+
16
+ export interface CollectedRankingItem {
17
+ rank: number
18
+ title: string
19
+ author?: string
20
+ category?: string
21
+ tags: string[]
22
+ synopsis?: string
23
+ heatLabel?: string
24
+ serialStatus?: string
25
+ sourceUrl: string
26
+ }
27
+
28
+ export const MARKET_RADAR_SOURCES: MarketRadarListSource[] = [
29
+ { platform: 'fanqie', platformLabel: '番茄小说', listKey: 'reading', listLabel: '阅读榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank' },
30
+ { platform: 'fanqie', platformLabel: '番茄小说', listKey: 'new_book', listLabel: '新书榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank/1_1' },
31
+ { platform: 'qidian', platformLabel: '起点中文网', listKey: 'hotsales', listLabel: '畅销榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/hotsales/' },
32
+ { platform: 'qidian', platformLabel: '起点中文网', listKey: 'monthly_ticket', listLabel: '月票榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/yuepiao/' },
33
+ { platform: 'qidian', platformLabel: '起点中文网', listKey: 'new_book', listLabel: '新书榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/' },
34
+ { platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'monthly', listLabel: '月度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/5' },
35
+ { platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'quarterly', listLabel: '季度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/6' },
36
+ { platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'new_author', listLabel: '新晋作者榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/29' },
37
+ ]
38
+
39
+ const NAMED_ENTITIES: Record<string, string> = {
40
+ amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"',
41
+ }
42
+
43
+ function decodeEntities(value: string): string {
44
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_, key: string) => {
45
+ if (key.startsWith('#')) {
46
+ const hexadecimal = key[1]?.toLowerCase() === 'x'
47
+ const code = Number.parseInt(key.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10)
48
+ return Number.isFinite(code) ? String.fromCodePoint(code) : ''
49
+ }
50
+ return NAMED_ENTITIES[key.toLowerCase()] ?? ''
51
+ })
52
+ }
53
+
54
+ function plainText(value: string | undefined): string {
55
+ return decodeEntities((value ?? '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
56
+ }
57
+
58
+ function absoluteUrl(base: string, value: string): string {
59
+ return new URL(value.startsWith('//') ? `https:${value}` : value, base).toString()
60
+ }
61
+
62
+ function extractTags(synopsis: string): string[] {
63
+ const bracketed = synopsis.match(/^[【〖\[]([^】〗\]]+)[】〗\]]/g) ?? []
64
+ return bracketed.flatMap((part) => plainText(part).replace(/^[【〖\[]|[】〗\]]$/g, '').split(/[++、|]/))
65
+ .map((tag) => tag.trim()).filter(Boolean).slice(0, 12)
66
+ }
67
+
68
+ export function hasPrivateUseCharacters(value: string | null | undefined): boolean {
69
+ return /[\uE000-\uF8FF]/u.test(value ?? '')
70
+ }
71
+
72
+ export function parseFanqieRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
73
+ const blocks = html.match(/<div class="rank-book-item">[\s\S]*?(?=<div class="rank-book-item">|<\/main>|<footer|<\/body>)/g) ?? []
74
+ return blocks.slice(0, 30).flatMap((block, index) => {
75
+ const titleMatch = block.match(/<div class="title">\s*<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
76
+ if (!titleMatch) return []
77
+ const synopsis = plainText(block.match(/<div class="desc abstract[^>]*>([\s\S]*?)<\/div>/)?.[1])
78
+ const footer = plainText(block.match(/<div class="book-item-footer">([\s\S]*?)<\/div>/)?.[1])
79
+ return [{
80
+ rank: Number(block.match(/book-item-index"><h1>(\d+)<\/h1>/)?.[1] ?? index + 1),
81
+ title: plainText(titleMatch[2]),
82
+ author: plainText(block.match(/<div class="author">[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/)?.[1]),
83
+ tags: extractTags(synopsis),
84
+ synopsis: synopsis.slice(0, 800),
85
+ heatLabel: footer.match(/在读[^\s,。]*/)?.[0],
86
+ serialStatus: footer.match(/连载中|已完结/)?.[0],
87
+ sourceUrl: absoluteUrl(source.sourceUrl, titleMatch[1]),
88
+ }]
89
+ })
90
+ }
91
+
92
+ export function parseFanqieDetail(html: string, item: CollectedRankingItem): CollectedRankingItem {
93
+ const title = plainText(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)?.[1])
94
+ const author = plainText(html.match(/author-name-text[^>]*>([\s\S]*?)<\/a>/)?.[1])
95
+ const synopsis = plainText(html.match(/<div class="page-abstract-content"[^>]*>\s*<p[^>]*>([\s\S]*?)<\/p>/)?.[1])
96
+ if (!title || hasPrivateUseCharacters(title)) throw new Error('作品详情页未提供可读书名')
97
+ return {
98
+ ...item,
99
+ title,
100
+ author: author && !hasPrivateUseCharacters(author) ? author : undefined,
101
+ synopsis: synopsis && !hasPrivateUseCharacters(synopsis) ? synopsis.slice(0, 800) : undefined,
102
+ tags: synopsis && !hasPrivateUseCharacters(synopsis) ? extractTags(synopsis) : [],
103
+ }
104
+ }
105
+
106
+ export function parseQidianRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
107
+ const escapedLabel = source.listLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
108
+ const itemPattern = new RegExp(`<a[^>]+href="([^"]*\\/book\\/[^"?]+)[^"]*"[^>]*>[\\s\\S]*?<h2[^>]+title="${escapedLabel}第(\\d+)位"[^>]*>([\\s\\S]*?)<\\/h2>[\\s\\S]*?<p[^>]*class="[^"]*subTitle[^"]*"[^>]*>([\\s\\S]*?)<\\/p>[\\s\\S]*?<\\/a>`, 'g')
109
+ return Array.from(html.matchAll(itemPattern)).slice(0, 30).map((match) => {
110
+ const subTitle = plainText(match[4])
111
+ const parts = subTitle.split('·').map((part) => part.trim()).filter(Boolean)
112
+ return {
113
+ rank: Number(match[2]),
114
+ title: plainText(match[3]),
115
+ author: parts[0],
116
+ category: parts[1],
117
+ tags: parts[1] ? [parts[1]] : [],
118
+ heatLabel: parts[2],
119
+ sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
120
+ }
121
+ })
122
+ }
123
+
124
+ export function parseJinjiangRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
125
+ const items = Array.from(html.matchAll(/<li[^>]*>\s*<a href="(\/book2\/\d+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/li>/g))
126
+ return items.slice(0, 30).map((match, index) => ({
127
+ rank: index + 1,
128
+ title: plainText(match[2]),
129
+ tags: [],
130
+ sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
131
+ }))
132
+ }
133
+
134
+ async function fetchHtml(url: string): Promise<string> {
135
+ const response = await fetch(url, {
136
+ headers: { 'user-agent': 'Mozilla/5.0 (compatible; AI-Novel-Market-Radar/1.0; public-ranking-metadata-only)' },
137
+ signal: AbortSignal.timeout(20_000),
138
+ })
139
+ if (!response.ok) throw new Error(`榜单页面返回 HTTP ${response.status}`)
140
+ const bytes = await response.arrayBuffer()
141
+ const headerCharset = response.headers.get('content-type')?.match(/charset=([^;]+)/i)?.[1]?.trim()
142
+ const metaProbe = new TextDecoder('latin1').decode(bytes.slice(0, 4096))
143
+ const metaCharset = metaProbe.match(/charset\s*=\s*["']?([\w-]+)/i)?.[1]
144
+ const charset = headerCharset || metaCharset || 'utf-8'
145
+ return new TextDecoder(charset).decode(bytes)
146
+ }
147
+
148
+ async function hydrateFanqieItems(items: CollectedRankingItem[]): Promise<CollectedRankingItem[]> {
149
+ const hydrated: CollectedRankingItem[] = []
150
+ for (let offset = 0; offset < items.length; offset += 4) {
151
+ const batch = await Promise.all(items.slice(offset, offset + 4).map(async (item) => {
152
+ const containsObfuscatedText = [item.title, item.author, item.synopsis].some(hasPrivateUseCharacters)
153
+ if (!containsObfuscatedText) return item
154
+ try { return parseFanqieDetail(await fetchHtml(item.sourceUrl), item) }
155
+ catch { return null }
156
+ }))
157
+ hydrated.push(...batch.filter((item): item is CollectedRankingItem => item !== null))
158
+ }
159
+ return hydrated
160
+ }
161
+
162
+ export async function collectMarketSource(source: MarketRadarListSource): Promise<CollectedRankingItem[]> {
163
+ const html = await fetchHtml(source.sourceUrl)
164
+ const parsers: Record<MarketRadarPlatform, (value: string, item: MarketRadarListSource) => CollectedRankingItem[]> = {
165
+ fanqie: parseFanqieRanking,
166
+ qidian: parseQidianRanking,
167
+ jinjiang: parseJinjiangRanking,
168
+ }
169
+ const parsed = parsers[source.platform](html, source)
170
+ const items = source.platform === 'fanqie' ? await hydrateFanqieItems(parsed) : parsed
171
+ if (items.length === 0) throw new Error('榜单页面结构可能已变化,未识别到公开作品元数据')
172
+ return items
173
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 输出目录迁移(Migrate Output Dir)— 把一本书/默认输出目录的内容搬到新位置。
3
+ *
4
+ * 安全纪律(数据零丢失排序):
5
+ * 1. copy 全部内容到新目录(源目录始终原样保留);
6
+ * 2. 调用方完成配置写入 / 书架重指向后,最后一步才删源目录;
7
+ * 3. 中途任何失败,最坏情况只是新目录多一份副本,源数据零丢失。
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readdirSync, cpSync, rmSync, statSync } from 'node:fs'
11
+ import { join, resolve, sep } from 'node:path'
12
+
13
+ /** 目录内容清单(相对路径 + 总字节)。 */
14
+ export interface DirContents {
15
+ files: string[]
16
+ bytes: number
17
+ }
18
+
19
+ /** 规范化路径用于比较(统一分隔符,Windows 忽略大小写)。 */
20
+ export function normalizeDir(dir: string): string {
21
+ const r = resolve(dir.trim())
22
+ return process.platform === 'win32' ? r.toLowerCase() : r
23
+ }
24
+
25
+ /** 列出目录全部内容(递归;子目录以 `name/` 形式占位列出)。 */
26
+ export function listDirContents(dir: string): DirContents {
27
+ const files: string[] = []
28
+ let bytes = 0
29
+ const walk = (rel: string): void => {
30
+ const abs = rel === '' ? dir : join(dir, rel)
31
+ for (const name of readdirSync(abs, { withFileTypes: true })) {
32
+ const childRel = rel === '' ? name.name : `${rel}/${name.name}`
33
+ if (name.isDirectory()) {
34
+ files.push(`${childRel}/`)
35
+ walk(childRel)
36
+ } else {
37
+ files.push(childRel)
38
+ try { bytes += statSync(join(dir, childRel)).size } catch { /* 竞态删除则跳过 */ }
39
+ }
40
+ }
41
+ }
42
+ if (existsSync(dir)) walk('')
43
+ return { files, bytes }
44
+ }
45
+
46
+ /**
47
+ * 把 from 全部内容复制到 to(源目录保留,删除由调用方在收尾执行)。
48
+ * 目标必须不存在或为空目录(防覆盖无关数据);禁止互相嵌套的目录对。
49
+ * @returns 复制的顶层条目数。
50
+ */
51
+ export function copyDirContents(from: string, to: string): number {
52
+ if (!existsSync(from)) throw new Error(`源目录不存在:${from}`)
53
+ const nf = normalizeDir(from)
54
+ const nt = normalizeDir(to)
55
+ if (nf === nt) throw new Error('新目录与当前目录相同')
56
+ // 嵌套防护:copy 进入自身会无限递归 / 自我复制。
57
+ if (nt.startsWith(nf + sep.toLowerCase()) || nf.startsWith(nt + sep.toLowerCase()) ||
58
+ nt.startsWith(nf + '/') || nf.startsWith(nt + '/')) {
59
+ throw new Error('新目录不能是当前目录的子目录(或反之)')
60
+ }
61
+ if (existsSync(to)) {
62
+ const entries = readdirSync(to)
63
+ if (entries.length > 0) throw new Error(`目标目录非空(${entries.length} 个条目),为防覆盖已中止:${to}`)
64
+ } else {
65
+ mkdirSync(to, { recursive: true })
66
+ }
67
+ const entries = readdirSync(from)
68
+ if (entries.length === 0) return 0
69
+ cpSync(from, to, { recursive: true, errorOnExist: true, force: false })
70
+ return entries.length
71
+ }
72
+
73
+ /** 删除源目录(仅复制 + 配置/书架重指向全部成功后调用)。 */
74
+ export function removeDir(dir: string): void {
75
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
76
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * 统一上下文组装与相关设定检索
3
+ *
4
+ * 取代各阶段各自拼装 prompt,消除「生成看得到、审稿看不到」的断层。
5
+ * 所有阶段通过 buildChapterContext 获取同一来源的事实与设定。
6
+ */
7
+
8
+ import type { ProjectState, ChapterPlan, StoryBible, RoleRecord, ChapterFact, Foreshadow, Plotline } from './protocol'
9
+ import { readFileSync, existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export interface ChapterContext {
13
+ /** 平台合规红线(永远全量) */
14
+ complianceRedLines: string[]
15
+ /** 本书写作红线(永远全量) */
16
+ bookRedLines: string[]
17
+ /** 当前章相关角色卡(全量信息) */
18
+ relevantRoles: Array<{ name: string; role: string; traits: string[]; goals?: string; knowledge?: string[] }>
19
+ /** 当前章相关道藏规则 */
20
+ relevantWorldRules: string[]
21
+ /** 活跃剧情线 */
22
+ activePlotlines: Plotline[]
23
+ /** 未回收伏笔 */
24
+ activeForeshadows: Foreshadow[]
25
+ /** 上一章结尾原文 */
26
+ prevChapterTail: string
27
+ /** 上一章摘要 */
28
+ prevChapterSummary?: string
29
+ /** 最近事实(近因记忆,top20) */
30
+ recentFacts: string[]
31
+ /** 相关旧事实(按本章 beats 检索,top15) */
32
+ relatedFacts: string[]
33
+ /** 当前卷大纲 */
34
+ currentVolumeOutline: string
35
+ /** 本章 beats */
36
+ beats: string
37
+ /** 本章标题 */
38
+ chapterTitle: string
39
+ }
40
+
41
+ export interface ContextBuildOptions {
42
+ /** 阶段:writing/review/revise/polish/plan */
43
+ stage?: 'writing' | 'review' | 'revise' | 'polish' | 'plan'
44
+ /** 是否注入完整角色卡(false 时只注入角色名+定位) */
45
+ fullRoleCards?: boolean
46
+ /** 相关事实检索上限 */
47
+ relatedFactsLimit?: number
48
+ /** 最近事实数量 */
49
+ recentFactsLimit?: number
50
+ }
51
+
52
+ /**
53
+ * 相关事实检索:trigram 重合度 + 角色名命中加权 + 近因加权
54
+ * 从 generateChapterStream 抽取,扩展为通用函数。
55
+ */
56
+ export function retrieveRelatedFacts(
57
+ facts: ChapterFact[],
58
+ beatsText: string,
59
+ roleNames: string[],
60
+ limit = 15,
61
+ ): string[] {
62
+ if (facts.length === 0) return []
63
+
64
+ const trigrams = (s: string): Set<string> => {
65
+ const out = new Set<string>()
66
+ for (let i = 0; i + 3 <= s.length; i++) {
67
+ const tri = s.slice(i, i + 3)
68
+ if (tri.trim() !== '') out.add(tri)
69
+ }
70
+ return out
71
+ }
72
+
73
+ const beatsTri = trigrams(beatsText)
74
+ const beatRoles = roleNames.filter(n => beatsText.includes(n))
75
+
76
+ const recentTexts = new Set(facts.slice(-20).map(f => f.text))
77
+
78
+ return facts
79
+ .map((f, idx) => {
80
+ const head = f.text.slice(0, 80)
81
+ let score = 0
82
+ for (const tri of trigrams(head)) if (beatsTri.has(tri)) score += 1
83
+ if (beatRoles.length > 0) {
84
+ for (const n of beatRoles) if (head.includes(n)) score += 8
85
+ }
86
+ // 近因加权:越新越优先(封顶 40 章)
87
+ score += Math.min(idx, 40) / 10
88
+ return { f, score }
89
+ })
90
+ .filter(x => x.score >= 3)
91
+ .sort((a, b) => b.score - a.score)
92
+ .slice(0, limit)
93
+ .map(x => `[第${x.f.chapterNo}章] ${x.f.text}`)
94
+ .filter(t => !recentTexts.has(t.slice(t.indexOf(']') + 2)))
95
+ }
96
+
97
+ /**
98
+ * 相关角色检索:beats 中出现的角色 + 主角/主要反派永远全量
99
+ */
100
+ export function retrieveRelevantRoles(
101
+ bible: StoryBible | undefined,
102
+ roles: RoleRecord[] | undefined,
103
+ beatsText: string,
104
+ ): ChapterContext['relevantRoles'] {
105
+ const result: ChapterContext['relevantRoles'] = []
106
+ const seen = new Set<string>()
107
+
108
+ // 主角/主要反派永远全量
109
+ const alwaysRoles = (bible?.characters ?? []).filter(c =>
110
+ c.role === 'protagonist' || c.role === 'antagonist'
111
+ )
112
+ for (const card of alwaysRoles) {
113
+ if (seen.has(card.name)) continue
114
+ seen.add(card.name)
115
+ result.push({
116
+ name: card.name,
117
+ role: card.role,
118
+ traits: card.traits,
119
+ goals: card.goals !== '' ? card.goals : undefined,
120
+ knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : undefined,
121
+ })
122
+ }
123
+
124
+ // beats 中出现的角色
125
+ const beatRoles = (bible?.characters ?? []).filter(c => beatsText.includes(c.name))
126
+ for (const card of beatRoles) {
127
+ if (seen.has(card.name)) continue
128
+ seen.add(card.name)
129
+ result.push({
130
+ name: card.name,
131
+ role: card.role,
132
+ traits: card.traits,
133
+ goals: card.goals !== '' ? card.goals : undefined,
134
+ knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : undefined,
135
+ })
136
+ }
137
+
138
+ // 角色库补充(不在道藏里的)
139
+ for (const r of roles ?? []) {
140
+ if (seen.has(r.name)) continue
141
+ if (!beatsText.includes(r.name) && r.roleLabel !== 'protagonist' && r.roleLabel !== 'antagonist') continue
142
+ seen.add(r.name)
143
+ result.push({
144
+ name: r.name,
145
+ role: r.roleLabel,
146
+ traits: Array.isArray(r.traits) ? r.traits : [],
147
+ goals: r.goals !== undefined && r.goals !== '' ? r.goals : undefined,
148
+ })
149
+ }
150
+
151
+ return result
152
+ }
153
+
154
+ /**
155
+ * 构建章节统一上下文
156
+ *
157
+ * 各阶段调用此函数获取同一来源的事实与设定,消除上下文断层。
158
+ * 检索结果不足时自动回退全量,保证不丢失关键信息。
159
+ */
160
+ export function buildChapterContext(
161
+ project: ProjectState,
162
+ chapter: ChapterPlan,
163
+ outputDir: string,
164
+ options: ContextBuildOptions = {},
165
+ ): ChapterContext {
166
+ const {
167
+ fullRoleCards = true,
168
+ relatedFactsLimit = 15,
169
+ recentFactsLimit = 20,
170
+ } = options
171
+
172
+ const bible = project.bible
173
+ const allFacts = project.facts ?? []
174
+ const roleNames = (project.roles ?? []).map(r => r.name).filter((n): n is string => typeof n === 'string' && n !== '')
175
+
176
+ // 上一章结尾 + 摘要
177
+ let prevChapterTail = ''
178
+ let prevChapterSummary: string | undefined
179
+ const prev = project.chapters.find(c => c.no === chapter.no - 1)
180
+ if (prev?.file !== undefined) {
181
+ try {
182
+ const prevPath = join(outputDir, prev.file)
183
+ if (existsSync(prevPath)) {
184
+ const text = readFileSync(prevPath, 'utf8')
185
+ prevChapterTail = text.replace(/^#\s+.*$/m, '').trim().slice(-900)
186
+ }
187
+ } catch { /* 文件缺失时忽略 */ }
188
+ }
189
+ prevChapterSummary = prev?.summary
190
+
191
+ // 事实:最近 + 相关
192
+ const recentFacts = allFacts.slice(-recentFactsLimit).map(f => `[第${f.chapterNo}章] ${f.text}`)
193
+ const relatedFacts = retrieveRelatedFacts(allFacts, chapter.beats, roleNames, relatedFactsLimit)
194
+
195
+ // 角色:相关角色
196
+ const relevantRoles = retrieveRelevantRoles(bible, project.roles, chapter.beats)
197
+
198
+ // 道藏规则:全部(规则数量通常不多,全量注入更安全)
199
+ const relevantWorldRules = bible?.worldRules ?? []
200
+
201
+ // 活跃剧情线
202
+ const activePlotlines = (project.plotlines ?? []).filter(l =>
203
+ l.status === 'active' || l.status === 'paused'
204
+ )
205
+
206
+ // 未回收伏笔
207
+ const activeForeshadows = (project.foreshadows ?? []).filter(f =>
208
+ f.status === 'planted' || f.status === 'progressing'
209
+ )
210
+
211
+ // 当前卷大纲
212
+ const volumeNo = chapter.volume ?? 0
213
+ const volume = project.volumes?.find(v => v.no === volumeNo)
214
+ const currentVolumeOutline = volume !== undefined
215
+ ? `第${volume.no}卷《${volume.title}》:${volume.summary}`
216
+ : ''
217
+
218
+ return {
219
+ complianceRedLines: [], // 由调用方注入 COMPLIANCE_REDLINES
220
+ bookRedLines: bible?.redLines ?? [],
221
+ relevantRoles: fullRoleCards ? relevantRoles : relevantRoles.map(r => ({ name: r.name, role: r.role, traits: [] })),
222
+ relevantWorldRules,
223
+ activePlotlines,
224
+ activeForeshadows,
225
+ prevChapterTail,
226
+ prevChapterSummary,
227
+ recentFacts,
228
+ relatedFacts,
229
+ currentVolumeOutline,
230
+ beats: chapter.beats,
231
+ chapterTitle: chapter.title,
232
+ }
233
+ }
234
+
235
+ /**
236
+ * 将上下文渲染为 prompt 文本块
237
+ * 各阶段可按需选择注入哪些块。
238
+ */
239
+ export function renderContextBlocks(ctx: ChapterContext): {
240
+ rolesBlock: string
241
+ worldRulesBlock: string
242
+ factsBlock: string
243
+ plotlinesBlock: string
244
+ foreshadowsBlock: string
245
+ continuityBlock: string
246
+ redLinesBlock: string
247
+ } {
248
+ const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他', female_lead: '女主', female_support: '女配', support: '配角', extra: '路人' } as Record<string, string>
249
+
250
+ const rolesBlock = ctx.relevantRoles.length > 0
251
+ ? '==================== 相关角色卡 ====================\n' +
252
+ ctx.relevantRoles.map(r =>
253
+ `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join('、')}${r.goals ? `;目标:${r.goals}` : ''}${r.knowledge ? `\n 已知信息:${r.knowledge.join(';')}(未列出的该角色不知道)` : ''}`
254
+ ).join('\n')
255
+ : ''
256
+
257
+ const worldRulesBlock = ctx.relevantWorldRules.length > 0
258
+ ? '==================== 世界规则 ====================\n' + ctx.relevantWorldRules.map(r => `- ${r}`).join('\n')
259
+ : ''
260
+
261
+ const factsBlock = (ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0)
262
+ ? '==================== 事实库(最近 + 相关) ====================\n' +
263
+ (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join('\n')}` : '') +
264
+ (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join('\n')}` : '')
265
+ : ''
266
+
267
+ const plotlinesBlock = ctx.activePlotlines.length > 0
268
+ ? '==================== 活跃剧情线 ====================\n' +
269
+ ctx.activePlotlines.map(l => `- [${l.kind}${l.status === 'paused' ? '·暂停' : ''}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ''}`).join('\n')
270
+ : ''
271
+
272
+ const foreshadowsBlock = ctx.activeForeshadows.length > 0
273
+ ? '==================== 活跃伏笔 ====================\n' +
274
+ ctx.activeForeshadows.map(f => `- [${f.status === 'planted' ? '已埋设' : '推进中'}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ''}`).join('\n')
275
+ : ''
276
+
277
+ const continuityBlock = ctx.prevChapterTail !== ''
278
+ ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}`
279
+ : ''
280
+
281
+ const redLinesBlock = ctx.bookRedLines.length > 0
282
+ ? '==================== 本书红线 ====================\n' + ctx.bookRedLines.map(r => `- ${r}`).join('\n')
283
+ : ''
284
+
285
+ return { rolesBlock, worldRulesBlock, factsBlock, plotlinesBlock, foreshadowsBlock, continuityBlock, redLinesBlock }
286
+ }
@@ -0,0 +1,26 @@
1
+ /** 章节写作约束:官方 writer 骨架渲染成可附加到现有系统提示词末尾的必达/禁止/自查块。 */
2
+ export function renderOfficialChapterWriterSkeleton(meta: {
3
+ targetChars: number
4
+ minChars: number
5
+ maxChars: number
6
+ pov?: string
7
+ tonePreference?: string
8
+ endingHookPreference?: string
9
+ antiAiRules?: string
10
+ }): string {
11
+ const pov = meta.pov ?? '第三人称有限视角,严格跟随主角所见所知。'
12
+ const tone = meta.tonePreference ?? '文风贴合本书设定,用具体细节与动作推进。'
13
+ const hook = meta.endingHookPreference ?? '章末留一个明确的钩子(新信息、新风险或未闭合的选择)。'
14
+ const antiAi = meta.antiAiRules ?? ''
15
+ return [
16
+ '==================== 官方生成骨架(必达 / 禁止 / 输出前自查) ====================',
17
+ '【叙事视角】' + pov,
18
+ '【本章必达】本章必须发生实质变化(局面、关系、信息、风险、决策至少一项);开头迅速进入情境,禁止复述上一章。',
19
+ '【禁止事项】不得写总结/复盘/解释性段落为主;不得引入新的核心角色或与上下文冲突的设定;不得为空凑字数。',
20
+ '【篇幅】目标 ' + meta.targetChars + ' 字,区间 ' + meta.minChars + '-' + meta.maxChars + ' 字;不够就继续推进有效情节,禁止草草收尾。',
21
+ '【结尾】' + hook,
22
+ '【表达】优先用具体动作、对话与可感知细节推进;' + tone,
23
+ '【反 AI】' + antiAi,
24
+ '【输出前自查】先确认:读者回报、关键转折、章末净变化是否可见,旧钩子责任是否回应,人物硬事实是否违背;确认通过后再输出,正文中不要输出核查结果。',
25
+ ].join('\n')
26
+ }