@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  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 +24 -9
  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 +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,348 +1,366 @@
1
- /**
2
- * 开书向导:独立页面视图 —— 书名 + 大纲(选择 docx / 拖拽 / 粘贴),
3
- * 实时书名识别与题材提示,开书即建项目并进入工作台。
4
- * 另含「想法 → AI 大纲」:输入一句话想法生成 2-3 个方案,可暂留换批,选中后回填大纲框。
5
- */
6
- import { useRef, useState } from 'react'
7
- import type { NovelApi } from '../api.ts'
8
- import type { OutlineCandidate } from '../../protocol.ts'
9
- import { extractDocxTextFromBuffer } from '../docx.ts'
10
- import css from './panel.module.css'
11
-
12
- /** 从大纲首行推断书名(与服务端 inferBookName 一致,供实时预览)。 */
13
- function inferBookNamePreview(outline: string): string {
14
- const line = outline.split('\n').map(l => l.trim()).find(l => l.length > 0)
15
- if (line === undefined) return ''
16
- return line.replace(/^《/, '').replace(/》.*$/, '').slice(0, 40)
17
- }
18
-
19
- /** 简单题材识别(提示用)。 */
20
- function guessGenre(outline: string): string | null {
21
- const map: Array<[string, string[]]> = [
22
- ['仙侠修真', ['仙', '修', '灵根', '元婴', '宗门', '飞升']],
23
- ['都市', ['都市', '公司', '外卖', '职场', '总裁']],
24
- ['玄幻', ['斗气', '魂力', '大陆', '斗罗', '神']],
25
- ['悬疑', ['悬疑', '密室', '案件', '推理', '凶']],
26
- ['科幻', ['机甲', '星舰', 'AI', '未来', '星际']],
27
- ['历史', ['朝代', '皇帝', '将军', '古代', '王朝']],
28
- ['游戏', ['游戏', '副本', '装备', '等级', '职业']],
29
- ]
30
- for (const [genre, keywords] of map) {
31
- if (keywords.some(k => outline.includes(k))) return genre
32
- }
33
- return null
34
- }
35
-
36
- /** 开书向导页。 */
37
- export function CreateBookView({
38
- api,
39
- onBack,
40
- onCreated,
41
- }: {
42
- api: NovelApi
43
- /** 返回书架。 */
44
- onBack: () => void
45
- /** 开书成功:进入新书工作台。 */
46
- onCreated: (id: string) => void
47
- }) {
48
- const [name, setName] = useState('')
49
- const [outlineText, setOutlineText] = useState('')
50
- const [outlineName, setOutlineName] = useState('')
51
- const [dragActive, setDragActive] = useState(false)
52
- const [busy, setBusy] = useState(false)
53
- const [error, setError] = useState('')
54
- const outlineFileRef = useRef<HTMLInputElement | null>(null)
55
-
56
- // ---- 想法 → AI 大纲状态 ----
57
- const [ideaOpen, setIdeaOpen] = useState(false)
58
- const [idea, setIdea] = useState('')
59
- const [suggesting, setSuggesting] = useState(false)
60
- const [candidates, setCandidates] = useState<OutlineCandidate[]>([])
61
- const [pinned, setPinned] = useState<string[]>([])
62
- /** 回填后提示(如「已填入方案A,可继续修改」)。 */
63
- const [fillNotice, setFillNotice] = useState('')
64
-
65
- const autoName = inferBookNamePreview(outlineText)
66
- const effectiveName = name.trim() !== '' ? name.trim() : autoName
67
- const genre = guessGenre(outlineText)
68
-
69
- const handlePickOutlineFile = async (file: File | undefined): Promise<void> => {
70
- if (file === undefined) return
71
- try {
72
- const buffer = await file.arrayBuffer()
73
- const text = extractDocxTextFromBuffer(buffer)
74
- if (text.length < 50) {
75
- setError('大纲内容过短(<50 字符),请检查文件')
76
- return
77
- }
78
- setOutlineText(text)
79
- setOutlineName(file.name)
80
- if (name.trim() === '') setName(inferBookNamePreview(text))
81
- setError('')
82
- } catch (err) {
83
- setError(`读取大纲失败:${(err as Error).message}`)
84
- }
85
- }
86
-
87
- /** 生成/换批:只补未暂留的空槽;exclude 传已暂留方案的卖点方向。 */
88
- const handleSuggest = async (): Promise<void> => {
89
- if (idea.trim().length < 10) {
90
- setError('想法太短(<10 字):至少写一句完整想法,如「男主穿越修仙界靠做菜无敌」')
91
- return
92
- }
93
- setSuggesting(true)
94
- setError('')
95
- try {
96
- const exclude = candidates
97
- .filter(c => pinned.includes(c.id))
98
- .map(c => `${c.bookName}:${c.sellingPoint}${c.genre !== '' ? `(${c.genre})` : ''}`)
99
- const count = Math.max(1, 3 - pinned.length)
100
- const result = await api.outlineSuggest(idea.trim(), count, exclude)
101
- // 换批合并:已暂留的原地保留,新生成的补充进空槽。
102
- setCandidates(prev => {
103
- const kept = prev.filter(c => pinned.includes(c.id))
104
- return [...kept, ...result.candidates].slice(0, 3)
105
- })
106
- setFillNotice('')
107
- } catch (err) {
108
- setError((err as Error).message)
109
- } finally {
110
- setSuggesting(false)
111
- }
112
- }
113
-
114
- /** 暂留/取消暂留。 */
115
- const togglePin = (id: string): void => {
116
- setPinned(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
117
- }
118
-
119
- /** 选中方案:回填大纲框 + 书名,滚动到上方。 */
120
- const handlePick = (candidate: OutlineCandidate): void => {
121
- setOutlineText(candidate.outline)
122
- setName(candidate.bookName)
123
- setFillNotice(`已填入方案《${candidate.bookName}》(${candidate.genre}),可继续修改后开书`)
124
- setIdeaOpen(false)
125
- window.scrollTo({ top: 0, behavior: 'smooth' })
126
- }
127
-
128
- const handleCreate = async (): Promise<void> => {
129
- if (effectiveName === '') {
130
- setError('请填写书名,或提供大纲自动识别')
131
- return
132
- }
133
- setBusy(true)
134
- setError('')
135
- try {
136
- const snapshot = await api.bookCreate(effectiveName, undefined, outlineText.trim() !== '' ? outlineText.trim() : undefined)
137
- const created = snapshot.books.find(b => b.id === snapshot.activeBookId)
138
- if (created !== undefined) {
139
- onCreated(created.id)
140
- } else {
141
- setError('开书失败:未找到新书')
142
- }
143
- } catch (err) {
144
- setError((err as Error).message)
145
- } finally {
146
- setBusy(false)
147
- }
148
- }
149
-
150
- return (
151
- <div className={css.createBookView}>
152
- <div className={css.createBookTop}>
153
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>
154
- ← 书架
155
- </button>
156
- </div>
157
-
158
- <div className={css.createBookCard}>
159
- <span className={css.createBookIcon}>✒️</span>
160
- <h2 className={css.createBookTitle}>开书向导</h2>
161
- <span className={css.meta}>把一份大纲「编译」成一本完整的小说</span>
162
-
163
- {error !== '' && (
164
- <div className={css.card} style={{ borderColor: 'var(--nf-error)', padding: '8px 12px' }}>
165
- <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{error}</span>
166
- </div>
167
- )}
168
-
169
- {fillNotice !== '' && (
170
- <div className={css.card} style={{ borderColor: 'var(--nf-success)', padding: '8px 12px' }}>
171
- <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>✅ {fillNotice}</span>
172
- </div>
173
- )}
174
-
175
- <div className={css.field}>
176
- <label className={css.fieldLabel}>书名</label>
177
- <input
178
- className={css.input}
179
- placeholder={autoName !== '' ? `自动识别:${autoName}` : '输入书名(提供大纲后自动识别)'}
180
- value={name}
181
- onChange={e => { setName(e.target.value) }}
182
- onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
183
- autoFocus
184
- />
185
- </div>
186
-
187
- <div className={css.field}>
188
- <label className={css.fieldLabel}>大纲</label>
189
- <div
190
- className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
191
- onClick={() => { outlineFileRef.current?.click() }}
192
- onDragOver={e => { e.preventDefault(); setDragActive(true) }}
193
- onDragLeave={() => { setDragActive(false) }}
194
- onDrop={e => {
195
- e.preventDefault()
196
- setDragActive(false)
197
- void handlePickOutlineFile(e.dataTransfer.files?.[0])
198
- }}
199
- >
200
- <span className={css.dropzoneIcon}>📄</span>
201
- <span>{outlineName !== '' ? `已选择:${outlineName}` : '点击选择 docx 大纲,或将文件拖到这里'}</span>
202
- <span className={css.meta}>推荐提供大纲:开书即建立项目,书名自动识别</span>
203
- <input
204
- ref={outlineFileRef}
205
- type="file"
206
- accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
207
- style={{ display: 'none' }}
208
- onChange={e => {
209
- void handlePickOutlineFile(e.target.files?.[0])
210
- e.target.value = ''
211
- }}
212
- />
213
- </div>
214
- <textarea
215
- className={css.textarea}
216
- style={{ minHeight: 130 }}
217
- placeholder="或直接粘贴大纲文本(50 字以上)…"
218
- value={outlineText}
219
- onChange={e => { setOutlineText(e.target.value) }}
220
- spellCheck={false}
221
- />
222
- </div>
223
-
224
- {(outlineText.trim().length > 0 || effectiveName !== '') && (
225
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
226
- {outlineText.trim().length > 0 && <span className={css.meta}>大纲 {outlineText.length} 字</span>}
227
- {effectiveName !== '' && <span className={css.meta}>书名:{effectiveName}</span>}
228
- {genre !== null && <span className={css.meta}>题材:{genre}</span>}
229
- </div>
230
- )}
231
-
232
- {/* 想法 → AI 大纲(可折叠) */}
233
- <div className={css.ideaCard}>
234
- <button
235
- type="button"
236
- className={css.ideaToggle}
237
- onClick={() => { setIdeaOpen(v => !v) }}
238
- aria-expanded={ideaOpen}
239
- >
240
- ✨ {ideaOpen ? '▾' : '▸'} 没有大纲?用一句话想法让 AI 生成
241
- </button>
242
- {ideaOpen && (
243
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
244
- <textarea
245
- className={css.textarea}
246
- style={{ minHeight: 60 }}
247
- placeholder="例如:现代外卖员被雷劈穿越到修仙世界,靠祖传古玉捡漏发育,苟着苟着成了大佬…(≥50 字)"
248
- value={idea}
249
- onChange={e => { setIdea(e.target.value) }}
250
- spellCheck={false}
251
- />
252
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
253
- <button
254
- type="button"
255
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
256
- disabled={suggesting || idea.trim().length < 10}
257
- onClick={() => { void handleSuggest() }}
258
- title="生成 3 个方向不同的大纲方案供选择(约消耗 6-8k token)"
259
- >
260
- {suggesting ? '⏳ 生成中…' : candidates.length === 0 ? '✨ 生成大纲方案' : `↻ 换一批(${Math.max(1, 3 - pinned.length)} 个)`}
261
- </button>
262
- {candidates.length > 0 && (
263
- <span className={css.meta}>
264
- 已暂留 {pinned.length}/3 · 换批保留已暂留,只补新方案
265
- </span>
266
- )}
267
- </div>
268
-
269
- {/* 方案卡 */}
270
- {candidates.length > 0 && (
271
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
272
- {candidates.map(candidate => {
273
- const isPinned = pinned.includes(candidate.id)
274
- return (
275
- <div
276
- key={candidate.id}
277
- className={css.ideaCandidate}
278
- style={isPinned ? { borderColor: 'var(--nf-accent)', boxShadow: '0 0 0 2px var(--nf-accent-soft)' } : undefined}
279
- >
280
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 }}>
281
- <span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
282
- <b>《{candidate.bookName}》</b>
283
- {candidate.genre !== '' && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{candidate.genre}</span>}
284
- {isPinned && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>★ 已暂留</span>}
285
- </span>
286
- <span style={{ display: 'flex', gap: 6 }}>
287
- <button
288
- type="button"
289
- className={`${css.button} ${css.buttonSmall} ${isPinned ? '' : css.buttonPrimary}`}
290
- onClick={() => { togglePin(candidate.id) }}
291
- title={isPinned ? '取消暂留:下次换批会覆盖此槽位' : '暂留此方案:换批时保留,继续对比新方案'}
292
- >
293
- {isPinned ? '★ 取消暂留' : '☆ 暂留'}
294
- </button>
295
- <button
296
- type="button"
297
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
298
- onClick={() => { handlePick(candidate) }}
299
- title="将这份大纲填入上方大纲框(可继续修改后开书)"
300
- >
301
- 选这个
302
- </button>
303
- </span>
304
- </div>
305
- {candidate.sellingPoint !== '' && (
306
- <div className={css.meta}><b>卖点:</b>{candidate.sellingPoint}</div>
307
- )}
308
- <div
309
- className={css.meta}
310
- style={{
311
- display: '-webkit-box',
312
- WebkitLineClamp: 4,
313
- WebkitBoxOrient: 'vertical',
314
- overflow: 'hidden',
315
- whiteSpace: 'pre-wrap',
316
- }}
317
- title={candidate.outline}
318
- >
319
- {candidate.outline}
320
- </div>
321
- </div>
322
- )
323
- })}
324
- {pinned.length >= 3 && (
325
- <span className={css.meta}>已暂留全部方案——直接挑一个「选这个」即可;换批需先取消某个暂留。</span>
326
- )}
327
- </div>
328
- )}
329
- </div>
330
- )}
331
- </div>
332
-
333
- <button
334
- type="button"
335
- className={`${css.button} ${css.buttonPrimary}`}
336
- style={{ width: '100%', padding: '10px 0', fontSize: 14 }}
337
- disabled={busy || effectiveName === ''}
338
- onClick={() => { void handleCreate() }}
339
- >
340
- ✨ 开书并进入工作台
341
- </button>
342
- <span className={css.meta} style={{ textAlign: 'center' }}>
343
- 未提供大纲也能开书,稍后可在大纲页导入
344
- </span>
345
- </div>
346
- </div>
347
- )
348
- }
1
+ /**
2
+ * 开书向导:独立页面视图 —— 书名 + 大纲(选择 docx / 拖拽 / 粘贴),
3
+ * 实时书名识别与题材提示,开书即建项目并进入工作台。
4
+ * 另含「想法 → AI 大纲」:输入一句话想法生成 2-3 个方案,可暂留换批,选中后回填大纲框。
5
+ */
6
+ import { useRef, useState } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import { setCurrentBook } from '../api.ts'
9
+ import type { OutlineCandidate } from '../../protocol.ts'
10
+ import { extractDocxTextFromBuffer } from '../docx.ts'
11
+ import css from './panel.module.css'
12
+ import { SubPage } from './SubPage.tsx'
13
+
14
+ /** 从大纲首行推断书名(与服务端 inferBookName 一致,供实时预览)。 */
15
+ function inferBookNamePreview(outline: string): string {
16
+ const line = outline.split('\n').map(l => l.trim()).find(l => l.length > 0)
17
+ if (line === undefined) return ''
18
+ return line.replace(/^《/, '').replace(/》.*$/, '').slice(0, 40)
19
+ }
20
+
21
+ /** 简单题材识别(提示用)。 */
22
+ function guessGenre(outline: string): string | null {
23
+ const map: Array<[string, string[]]> = [
24
+ ['仙侠修真', ['仙', '修', '灵根', '元婴', '宗门', '飞升']],
25
+ ['都市', ['都市', '公司', '外卖', '职场', '总裁']],
26
+ ['玄幻', ['斗气', '魂力', '大陆', '斗罗', '神']],
27
+ ['悬疑', ['悬疑', '密室', '案件', '推理', '凶']],
28
+ ['科幻', ['机甲', '星舰', 'AI', '未来', '星际']],
29
+ ['历史', ['朝代', '皇帝', '将军', '古代', '王朝']],
30
+ ['游戏', ['游戏', '副本', '装备', '等级', '职业']],
31
+ ]
32
+ for (const [genre, keywords] of map) {
33
+ if (keywords.some(k => outline.includes(k))) return genre
34
+ }
35
+ return null
36
+ }
37
+
38
+ /** 开书向导页。 */
39
+ export function CreateBookView({
40
+ api,
41
+ onBack,
42
+ onCreated,
43
+ initialIdea,
44
+ initialName,
45
+ }: {
46
+ api: NovelApi
47
+ /** 返回书架。 */
48
+ onBack: () => void
49
+ /** 开书成功:进入新书工作台。 */
50
+ onCreated: (id: string) => void
51
+ /** 从「创意灵感」带过来的起步想法(预填到「一句话想法」输入框)。 */
52
+ initialIdea?: string
53
+ /** 从「创意灵感」带过来的初始书名。 */
54
+ initialName?: string
55
+ }) {
56
+ const [name, setName] = useState(initialName ?? '')
57
+ const [outlineText, setOutlineText] = useState('')
58
+ const [outlineName, setOutlineName] = useState('')
59
+ const [dragActive, setDragActive] = useState(false)
60
+ const [busy, setBusy] = useState(false)
61
+ const [error, setError] = useState('')
62
+ const outlineFileRef = useRef<HTMLInputElement | null>(null)
63
+
64
+ // ---- 想法 → AI 大纲状态 ----
65
+ const hasPrefill = initialIdea !== undefined && initialIdea.trim() !== ''
66
+ const [ideaOpen, setIdeaOpen] = useState(hasPrefill)
67
+ const [idea, setIdea] = useState(initialIdea ?? '')
68
+ const [suggesting, setSuggesting] = useState(false)
69
+ const [candidates, setCandidates] = useState<OutlineCandidate[]>([])
70
+ const [pinned, setPinned] = useState<string[]>([])
71
+ /** 回填后提示(如「已填入方案A,可继续修改」)。 */
72
+ const [fillNotice, setFillNotice] = useState('')
73
+ /** 选定方案自带的题材(创建后自动预填到本书创作资产)。 */
74
+ const [selectedGenre, setSelectedGenre] = useState('')
75
+
76
+ const autoName = inferBookNamePreview(outlineText)
77
+ const effectiveName = name.trim() !== '' ? name.trim() : autoName
78
+ const genre = guessGenre(outlineText)
79
+
80
+ const handlePickOutlineFile = async (file: File | undefined): Promise<void> => {
81
+ if (file === undefined) return
82
+ try {
83
+ const buffer = await file.arrayBuffer()
84
+ const text = extractDocxTextFromBuffer(buffer)
85
+ if (text.length < 50) {
86
+ setError('大纲内容过短(<50 字符),请检查文件')
87
+ return
88
+ }
89
+ setOutlineText(text)
90
+ setOutlineName(file.name)
91
+ if (name.trim() === '') setName(inferBookNamePreview(text))
92
+ setError('')
93
+ } catch (err) {
94
+ setError(`读取大纲失败:${(err as Error).message}`)
95
+ }
96
+ }
97
+
98
+ /** 生成/换批:只补未暂留的空槽;exclude 传已暂留方案的卖点方向。 */
99
+ const handleSuggest = async (): Promise<void> => {
100
+ if (idea.trim().length < 10) {
101
+ setError('想法太短(<10 字):至少写一句完整想法,如「男主穿越修仙界靠做菜无敌」')
102
+ return
103
+ }
104
+ setSuggesting(true)
105
+ setError('')
106
+ try {
107
+ const exclude = candidates
108
+ .filter(c => pinned.includes(c.id))
109
+ .map(c => `${c.bookName}:${c.sellingPoint}${c.genre !== '' ? `(${c.genre})` : ''}`)
110
+ const count = Math.max(1, 3 - pinned.length)
111
+ const result = await api.outlineSuggest(idea.trim(), count, exclude)
112
+ // 换批合并:已暂留的原地保留,新生成的补充进空槽。
113
+ setCandidates(prev => {
114
+ const kept = prev.filter(c => pinned.includes(c.id))
115
+ return [...kept, ...result.candidates].slice(0, 3)
116
+ })
117
+ setFillNotice('')
118
+ } catch (err) {
119
+ setError((err as Error).message)
120
+ } finally {
121
+ setSuggesting(false)
122
+ }
123
+ }
124
+
125
+ /** 暂留/取消暂留。 */
126
+ const togglePin = (id: string): void => {
127
+ setPinned(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
128
+ }
129
+
130
+ /** 选中方案:回填大纲框 + 书名,滚动到上方。 */
131
+ const handlePick = (candidate: OutlineCandidate): void => {
132
+ setOutlineText(candidate.outline)
133
+ setName(candidate.bookName)
134
+ setSelectedGenre(candidate.genre ?? '')
135
+ setFillNotice(`已填入方案《${candidate.bookName}》(${candidate.genre}),可继续修改后开书`)
136
+ setIdeaOpen(false)
137
+ window.scrollTo({ top: 0, behavior: 'smooth' })
138
+ }
139
+
140
+ const handleCreate = async (): Promise<void> => {
141
+ if (effectiveName === '') {
142
+ setError('请填写书名,或提供大纲自动识别')
143
+ return
144
+ }
145
+ setBusy(true)
146
+ setError('')
147
+ try {
148
+ const snapshot = await api.bookCreate(effectiveName, undefined, outlineText.trim() !== '' ? outlineText.trim() : undefined)
149
+ const created = snapshot.books.find(b => b.id === snapshot.activeBookId)
150
+ if (created !== undefined) {
151
+ // 选定方案自带题材,或从大纲关键词识别到题材 → 创建后自动预填到本书创作资产(失败不阻断开书)。
152
+ const detectedGenre = selectedGenre.trim() !== '' ? selectedGenre.trim() : (guessGenre(outlineText) ?? '')
153
+ if (detectedGenre !== '') {
154
+ try {
155
+ setCurrentBook(created.id)
156
+ await api.patchAssets({ genre: { name: detectedGenre, description: '', children: [] } })
157
+ } catch { /* ignore */ }
158
+ }
159
+ onCreated(created.id)
160
+ } else {
161
+ setError('开书失败:未找到新书')
162
+ }
163
+ } catch (err) {
164
+ setError((err as Error).message)
165
+ } finally {
166
+ setBusy(false)
167
+ }
168
+ }
169
+
170
+ return (
171
+ <SubPage
172
+ title="开书向导"
173
+ meta="把一份大纲「编译」成一本完整的小说"
174
+ back={{ label: '书架', onClick: onBack }}
175
+ >
176
+ <div className={css.createBookInner}>
177
+ {error !== '' && (
178
+ <div className={css.card} style={{ borderColor: 'var(--nf-error)', padding: 'var(--nf-space-8) var(--nf-space-12)' }}>
179
+ <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{error}</span>
180
+ </div>
181
+ )}
182
+
183
+ {fillNotice !== '' && (
184
+ <div className={css.card} style={{ borderColor: 'var(--nf-success)', padding: 'var(--nf-space-8) var(--nf-space-12)' }}>
185
+ <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)' }}> {fillNotice}</span>
186
+ </div>
187
+ )}
188
+
189
+ <div className={css.field}>
190
+ <label className={css.fieldLabel}>书名</label>
191
+ <input
192
+ className={css.input}
193
+ placeholder={autoName !== '' ? `自动识别:${autoName}` : '输入书名(提供大纲后自动识别)'}
194
+ value={name}
195
+ onChange={e => { setName(e.target.value) }}
196
+ onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
197
+ autoFocus
198
+ />
199
+ </div>
200
+
201
+ <div className={css.field}>
202
+ <label className={css.fieldLabel}>大纲</label>
203
+ <div
204
+ className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
205
+ role="button"
206
+ tabIndex={0}
207
+ aria-label="选择或拖入 docx 大纲文件"
208
+ onClick={() => { outlineFileRef.current?.click() }}
209
+ onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); outlineFileRef.current?.click() } }}
210
+ onDragOver={e => { e.preventDefault(); setDragActive(true) }}
211
+ onDragLeave={() => { setDragActive(false) }}
212
+ onDrop={e => {
213
+ e.preventDefault()
214
+ setDragActive(false)
215
+ void handlePickOutlineFile(e.dataTransfer.files?.[0])
216
+ }}
217
+ >
218
+ <span className={css.dropzoneIcon}></span>
219
+ <span>{outlineName !== '' ? `已选择:${outlineName}` : '点击选择 docx 大纲,或将文件拖到这里'}</span>
220
+ <span className={css.meta}>推荐提供大纲:开书即建立项目,书名自动识别</span>
221
+ <input
222
+ ref={outlineFileRef}
223
+ type="file"
224
+ accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
225
+ style={{ display: 'none' }}
226
+ onChange={e => {
227
+ void handlePickOutlineFile(e.target.files?.[0])
228
+ e.target.value = ''
229
+ }}
230
+ />
231
+ </div>
232
+ <textarea
233
+ className={css.textarea}
234
+ style={{ minHeight: 130 }}
235
+ placeholder="或直接粘贴大纲文本(50 字以上)…"
236
+ value={outlineText}
237
+ onChange={e => { setOutlineText(e.target.value) }}
238
+ spellCheck={false}
239
+ />
240
+ </div>
241
+
242
+ {(outlineText.trim().length > 0 || effectiveName !== '') && (
243
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
244
+ {outlineText.trim().length > 0 && <span className={css.meta}>大纲 {outlineText.length} 字</span>}
245
+ {effectiveName !== '' && <span className={css.meta}>书名:{effectiveName}</span>}
246
+ {genre !== null && <span className={css.meta}>题材:{genre}</span>}
247
+ </div>
248
+ )}
249
+
250
+ {/* 想法 → AI 大纲(可折叠) */}
251
+ <div className={css.ideaCard}>
252
+ <button
253
+ type="button"
254
+ className={css.ideaToggle}
255
+ onClick={() => { setIdeaOpen(v => !v) }}
256
+ aria-expanded={ideaOpen}
257
+ >
258
+ {ideaOpen ? '▾' : '▸'} 没有大纲?用一句话想法让 生成
259
+ </button>
260
+ {ideaOpen && (
261
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
262
+ <textarea
263
+ className={css.textarea}
264
+ style={{ minHeight: 60 }}
265
+ placeholder="例如:现代外卖员被雷劈穿越到修仙世界,靠祖传古玉捡漏发育,苟着苟着成了大佬…(≥50 字)"
266
+ value={idea}
267
+ onChange={e => { setIdea(e.target.value) }}
268
+ spellCheck={false}
269
+ />
270
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
271
+ <button
272
+ type="button"
273
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
274
+ disabled={suggesting || idea.trim().length < 10}
275
+ onClick={() => { void handleSuggest() }}
276
+ title="生成 3 个方向不同的大纲方案供选择(约消耗 6-8k token)"
277
+ >
278
+ {suggesting ? '⏳ 生成中…' : candidates.length === 0 ? ' 生成大纲方案' : `↻ 换一批(${Math.max(1, 3 - pinned.length)} 个)`}
279
+ </button>
280
+ {candidates.length > 0 && (
281
+ <span className={css.meta}>
282
+ 已暂留 {pinned.length}/3 · 换批保留已暂留,只补新方案
283
+ </span>
284
+ )}
285
+ </div>
286
+
287
+ {/* 方案卡 */}
288
+ {candidates.length > 0 && (
289
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
290
+ {candidates.map(candidate => {
291
+ const isPinned = pinned.includes(candidate.id)
292
+ return (
293
+ <div
294
+ key={candidate.id}
295
+ className={css.ideaCandidate}
296
+ style={isPinned ? { borderColor: 'var(--nf-accent)', boxShadow: '0 0 0 2px var(--nf-accent-soft)' } : undefined}
297
+ >
298
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
299
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
300
+ <b>《{candidate.bookName}》</b>
301
+ {candidate.genre !== '' && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{candidate.genre}</span>}
302
+ {isPinned && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>★ 已暂留</span>}
303
+ </span>
304
+ <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
305
+ <button
306
+ type="button"
307
+ className={`${css.button} ${css.buttonSmall} ${isPinned ? '' : css.buttonPrimary}`}
308
+ onClick={() => { togglePin(candidate.id) }}
309
+ title={isPinned ? '取消暂留:下次换批会覆盖此槽位' : '暂留此方案:换批时保留,继续对比新方案'}
310
+ >
311
+ {isPinned ? '★ 取消暂留' : '☆ 暂留'}
312
+ </button>
313
+ <button
314
+ type="button"
315
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
316
+ onClick={() => { handlePick(candidate) }}
317
+ title="将这份大纲填入上方大纲框(可继续修改后开书)"
318
+ >
319
+ 选这个
320
+ </button>
321
+ </span>
322
+ </div>
323
+ {candidate.sellingPoint !== '' && (
324
+ <div className={css.meta}><b>卖点:</b>{candidate.sellingPoint}</div>
325
+ )}
326
+ <div
327
+ className={css.meta}
328
+ style={{
329
+ display: '-webkit-box',
330
+ WebkitLineClamp: 4,
331
+ WebkitBoxOrient: 'vertical',
332
+ overflow: 'hidden',
333
+ whiteSpace: 'pre-wrap',
334
+ }}
335
+ title={candidate.outline}
336
+ >
337
+ {candidate.outline}
338
+ </div>
339
+ </div>
340
+ )
341
+ })}
342
+ {pinned.length >= 3 && (
343
+ <span className={css.meta}>已暂留全部方案——直接挑一个「选这个」即可;换批需先取消某个暂留。</span>
344
+ )}
345
+ </div>
346
+ )}
347
+ </div>
348
+ )}
349
+ </div>
350
+
351
+ <button
352
+ type="button"
353
+ className={`${css.button} ${css.buttonPrimary}`}
354
+ style={{ width: '100%', padding: 'var(--nf-space-10) 0', fontSize: 'var(--nf-fs-14)' }}
355
+ disabled={busy || effectiveName === ''}
356
+ onClick={() => { void handleCreate() }}
357
+ >
358
+ 开书并进入工作台
359
+ </button>
360
+ <span className={css.meta} style={{ textAlign: 'center' }}>
361
+ 未提供大纲也能开书,稍后可在大纲页导入
362
+ </span>
363
+ </div>
364
+ </SubPage>
365
+ )
366
+ }