@waterwx/dsh-novel-forge 1.10.1 → 2.0.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.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  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 +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  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/AuthorHome.d.ts +13 -9
  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/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  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 +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -1,352 +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: 'var(--nf-space-8) var(--nf-space-12)' }}>
165
- <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{error}</span>
166
- </div>
167
- )}
168
-
169
- {fillNotice !== '' && (
170
- <div className={css.card} style={{ borderColor: 'var(--nf-success)', padding: 'var(--nf-space-8) var(--nf-space-12)' }}>
171
- <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-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
- role="button"
192
- tabIndex={0}
193
- aria-label="选择或拖入 docx 大纲文件"
194
- onClick={() => { outlineFileRef.current?.click() }}
195
- onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); outlineFileRef.current?.click() } }}
196
- onDragOver={e => { e.preventDefault(); setDragActive(true) }}
197
- onDragLeave={() => { setDragActive(false) }}
198
- onDrop={e => {
199
- e.preventDefault()
200
- setDragActive(false)
201
- void handlePickOutlineFile(e.dataTransfer.files?.[0])
202
- }}
203
- >
204
- <span className={css.dropzoneIcon}>📄</span>
205
- <span>{outlineName !== '' ? `已选择:${outlineName}` : '点击选择 docx 大纲,或将文件拖到这里'}</span>
206
- <span className={css.meta}>推荐提供大纲:开书即建立项目,书名自动识别</span>
207
- <input
208
- ref={outlineFileRef}
209
- type="file"
210
- accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
211
- style={{ display: 'none' }}
212
- onChange={e => {
213
- void handlePickOutlineFile(e.target.files?.[0])
214
- e.target.value = ''
215
- }}
216
- />
217
- </div>
218
- <textarea
219
- className={css.textarea}
220
- style={{ minHeight: 130 }}
221
- placeholder="或直接粘贴大纲文本(50 字以上)…"
222
- value={outlineText}
223
- onChange={e => { setOutlineText(e.target.value) }}
224
- spellCheck={false}
225
- />
226
- </div>
227
-
228
- {(outlineText.trim().length > 0 || effectiveName !== '') && (
229
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
230
- {outlineText.trim().length > 0 && <span className={css.meta}>大纲 {outlineText.length} 字</span>}
231
- {effectiveName !== '' && <span className={css.meta}>书名:{effectiveName}</span>}
232
- {genre !== null && <span className={css.meta}>题材:{genre}</span>}
233
- </div>
234
- )}
235
-
236
- {/* 想法 → AI 大纲(可折叠) */}
237
- <div className={css.ideaCard}>
238
- <button
239
- type="button"
240
- className={css.ideaToggle}
241
- onClick={() => { setIdeaOpen(v => !v) }}
242
- aria-expanded={ideaOpen}
243
- >
244
- ✨ {ideaOpen ? '▾' : '▸'} 没有大纲?用一句话想法让 AI 生成
245
- </button>
246
- {ideaOpen && (
247
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
248
- <textarea
249
- className={css.textarea}
250
- style={{ minHeight: 60 }}
251
- placeholder="例如:现代外卖员被雷劈穿越到修仙世界,靠祖传古玉捡漏发育,苟着苟着成了大佬…(≥50 字)"
252
- value={idea}
253
- onChange={e => { setIdea(e.target.value) }}
254
- spellCheck={false}
255
- />
256
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
257
- <button
258
- type="button"
259
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
260
- disabled={suggesting || idea.trim().length < 10}
261
- onClick={() => { void handleSuggest() }}
262
- title="生成 3 个方向不同的大纲方案供选择(约消耗 6-8k token)"
263
- >
264
- {suggesting ? '⏳ 生成中…' : candidates.length === 0 ? '✨ 生成大纲方案' : `↻ 换一批(${Math.max(1, 3 - pinned.length)} 个)`}
265
- </button>
266
- {candidates.length > 0 && (
267
- <span className={css.meta}>
268
- 已暂留 {pinned.length}/3 · 换批保留已暂留,只补新方案
269
- </span>
270
- )}
271
- </div>
272
-
273
- {/* 方案卡 */}
274
- {candidates.length > 0 && (
275
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
276
- {candidates.map(candidate => {
277
- const isPinned = pinned.includes(candidate.id)
278
- return (
279
- <div
280
- key={candidate.id}
281
- className={css.ideaCandidate}
282
- style={isPinned ? { borderColor: 'var(--nf-accent)', boxShadow: '0 0 0 2px var(--nf-accent-soft)' } : undefined}
283
- >
284
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
285
- <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
286
- <b>《{candidate.bookName}》</b>
287
- {candidate.genre !== '' && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{candidate.genre}</span>}
288
- {isPinned && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>★ 已暂留</span>}
289
- </span>
290
- <span style={{ display: 'flex', gap: 'var(--nf-space-6)' }}>
291
- <button
292
- type="button"
293
- className={`${css.button} ${css.buttonSmall} ${isPinned ? '' : css.buttonPrimary}`}
294
- onClick={() => { togglePin(candidate.id) }}
295
- title={isPinned ? '取消暂留:下次换批会覆盖此槽位' : '暂留此方案:换批时保留,继续对比新方案'}
296
- >
297
- {isPinned ? '★ 取消暂留' : '☆ 暂留'}
298
- </button>
299
- <button
300
- type="button"
301
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
302
- onClick={() => { handlePick(candidate) }}
303
- title="将这份大纲填入上方大纲框(可继续修改后开书)"
304
- >
305
- 选这个
306
- </button>
307
- </span>
308
- </div>
309
- {candidate.sellingPoint !== '' && (
310
- <div className={css.meta}><b>卖点:</b>{candidate.sellingPoint}</div>
311
- )}
312
- <div
313
- className={css.meta}
314
- style={{
315
- display: '-webkit-box',
316
- WebkitLineClamp: 4,
317
- WebkitBoxOrient: 'vertical',
318
- overflow: 'hidden',
319
- whiteSpace: 'pre-wrap',
320
- }}
321
- title={candidate.outline}
322
- >
323
- {candidate.outline}
324
- </div>
325
- </div>
326
- )
327
- })}
328
- {pinned.length >= 3 && (
329
- <span className={css.meta}>已暂留全部方案——直接挑一个「选这个」即可;换批需先取消某个暂留。</span>
330
- )}
331
- </div>
332
- )}
333
- </div>
334
- )}
335
- </div>
336
-
337
- <button
338
- type="button"
339
- className={`${css.button} ${css.buttonPrimary}`}
340
- style={{ width: '100%', padding: 'var(--nf-space-10) 0', fontSize: 'var(--nf-fs-14)' }}
341
- disabled={busy || effectiveName === ''}
342
- onClick={() => { void handleCreate() }}
343
- >
344
- ✨ 开书并进入工作台
345
- </button>
346
- <span className={css.meta} style={{ textAlign: 'center' }}>
347
- 未提供大纲也能开书,稍后可在大纲页导入
348
- </span>
349
- </div>
350
- </div>
351
- )
352
- }
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
+ }