@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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * 导入小说弹窗:两种模式 ——
3
+ * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
+ * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
+ * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
+ */
7
+ import { useEffect, useRef, useState } from 'react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { BookImportTextPreviewResponse } from '../../protocol.ts'
10
+ import { readFileTextSmart } from '../text.ts'
11
+ import css from './panel.module.css'
12
+
13
+ type ImportMode = 'dir' | 'text'
14
+
15
+ /** 导入结果(成功态展示)。 */
16
+ interface ImportResult {
17
+ kind: 'dir' | 'text'
18
+ bookName: string
19
+ existed?: boolean
20
+ chapters?: number
21
+ skipped?: string[]
22
+ }
23
+
24
+ export function ImportModal({
25
+ api,
26
+ onClose,
27
+ onImported,
28
+ }: {
29
+ api: NovelApi
30
+ onClose: () => void
31
+ /** 导入成功并已激活该书后回调(刷新书架)。 */
32
+ onImported: () => void | Promise<void>
33
+ }) {
34
+ const [mode, setMode] = useState<ImportMode>('dir')
35
+ // Mode A
36
+ const [dir, setDir] = useState('')
37
+ // Mode B:文件选择 + 预览
38
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
39
+ const [fileName, setFileName] = useState('')
40
+ const [fileText, setFileText] = useState<string | null>(null)
41
+ const [previewing, setPreviewing] = useState(false)
42
+ const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
43
+ // Mode B:服务器本地路径(高级)
44
+ const [showPath, setShowPath] = useState(false)
45
+ const [filePath, setFilePath] = useState('')
46
+ // 通用
47
+ const [outDir, setOutDir] = useState('')
48
+ const [busy, setBusy] = useState(false)
49
+ const [error, setError] = useState('')
50
+ const [result, setResult] = useState<ImportResult | null>(null)
51
+
52
+ /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
53
+ const onPickFile = async (file: File | undefined) => {
54
+ if (file === undefined) return
55
+ setError('')
56
+ setResult(null)
57
+ setPreview(null)
58
+ setFileName(file.name)
59
+ setPreviewing(true)
60
+ try {
61
+ const text = await readFileTextSmart(file)
62
+ setFileText(text)
63
+ const p = await api.bookImportTextPreview(text, file.name)
64
+ setPreview(p)
65
+ } catch (err) {
66
+ setError((err as Error).message)
67
+ } finally {
68
+ setPreviewing(false)
69
+ }
70
+ }
71
+
72
+ const runImport = async () => {
73
+ setError('')
74
+ setResult(null)
75
+ if (mode === 'dir') {
76
+ const d = dir.trim()
77
+ if (d === '') { setError('请输入项目目录(含 novel-project.json 的文件夹)'); return }
78
+ setBusy(true)
79
+ try {
80
+ const r = await api.bookImportDir(d)
81
+ setResult({ kind: 'dir', bookName: r.book.bookName, existed: r.existed })
82
+ await onImported()
83
+ } catch (err) {
84
+ setError((err as Error).message)
85
+ } finally {
86
+ setBusy(false)
87
+ }
88
+ return
89
+ }
90
+ // Mode B:优先上传内容;未选文件时退回服务器路径
91
+ if (fileText !== null && fileText.length > 0) {
92
+ setBusy(true)
93
+ try {
94
+ const o = outDir.trim()
95
+ const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
96
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
97
+ await onImported()
98
+ } catch (err) {
99
+ setError((err as Error).message)
100
+ } finally {
101
+ setBusy(false)
102
+ }
103
+ return
104
+ }
105
+ const f = filePath.trim()
106
+ if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
107
+ setBusy(true)
108
+ try {
109
+ const o = outDir.trim()
110
+ const r = await api.bookImportText(f, o !== '' ? o : undefined)
111
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
112
+ await onImported()
113
+ } catch (err) {
114
+ setError((err as Error).message)
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ }
119
+
120
+ const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
121
+
122
+ // Esc 关闭弹窗。
123
+ useEffect(() => {
124
+ const onKey = (e: KeyboardEvent): void => {
125
+ if (e.key === 'Escape') onClose()
126
+ }
127
+ window.addEventListener('keydown', onKey)
128
+ return () => { window.removeEventListener('keydown', onKey) }
129
+ }, [onClose])
130
+
131
+ return (
132
+ <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="导入小说" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
133
+ <div className={css.importModal}>
134
+ <div className={css.importModalHead}>
135
+ <span className={css.panelTitle} style={{ margin: 0 }}> 导入小说</span>
136
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose} title="关闭">✕</button>
137
+ </div>
138
+
139
+ <div className={css.importModalTabs} role="tablist">
140
+ <button
141
+ type="button"
142
+ role="tab"
143
+ aria-selected={mode === 'dir'}
144
+ className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
145
+ onClick={() => { switchMode('dir') }}
146
+ >
147
+ A · 已有项目目录
148
+ </button>
149
+ <button
150
+ type="button"
151
+ role="tab"
152
+ aria-selected={mode === 'text'}
153
+ className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
154
+ onClick={() => { switchMode('text') }}
155
+ >
156
+ B · txt/md 全本
157
+ </button>
158
+ </div>
159
+
160
+ <div className={css.importModalBody}>
161
+ {mode === 'dir' ? (
162
+ <>
163
+ <label className={css.importField}>
164
+ <span>项目目录(绝对路径)</span>
165
+ <input
166
+ className={css.input}
167
+ type="text"
168
+ placeholder="例如 D:\novels\我的小说"
169
+ value={dir}
170
+ onChange={e => { setDir(e.target.value) }}
171
+ />
172
+ </label>
173
+ <div className={css.importHint}>
174
+ 目录需含 novel-project.json。已在书架中的目录会直接切换激活,不会重复登记。
175
+ </div>
176
+ </>
177
+ ) : (
178
+ <>
179
+ {/* 文件选择 + 预览 */}
180
+ <input
181
+ ref={fileInputRef}
182
+ type="file"
183
+ accept=".txt,.md,text/plain,text/markdown"
184
+ style={{ display: 'none' }}
185
+ onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
186
+ />
187
+ <div className={css.importField}>
188
+ <span>全本文本文件(txt / md)</span>
189
+ <button
190
+ type="button"
191
+ className={`${css.button} ${css.buttonPrimary}`}
192
+ onClick={() => { fileInputRef.current?.click() }}
193
+ >
194
+ 选择文件
195
+ </button>
196
+ </div>
197
+ {fileName !== '' && (
198
+ <div className={css.importFileInfo}>
199
+ <span>已选择:{fileName}</span>
200
+ {previewing && <span className={css.meta}>正在拆章预览…</span>}
201
+ </div>
202
+ )}
203
+ {preview !== null && (
204
+ <div className={css.importPreview}>
205
+ <span> 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
206
+ {preview.chapters.length > 0 && (
207
+ <ul className={css.importPreviewList}>
208
+ {preview.chapters.slice(0, 5).map(c => (
209
+ <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
210
+ ))}
211
+ {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
212
+ </ul>
213
+ )}
214
+ {preview.skipped.length > 0 && (
215
+ <span className={css.meta}>
216
+ 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
217
+ {preview.skipped.length > 5 ? ' 等' : ''}
218
+ </span>
219
+ )}
220
+ </div>
221
+ )}
222
+
223
+ {/* 输出目录 + 高级路径选项 */}
224
+ <label className={css.importField}>
225
+ <span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
226
+ <input
227
+ className={css.input}
228
+ type="text"
229
+ placeholder="留空使用默认目录"
230
+ value={outDir}
231
+ onChange={e => { setOutDir(e.target.value) }}
232
+ />
233
+ </label>
234
+ <button
235
+ type="button"
236
+ className={`${css.button} ${css.buttonSmall}`}
237
+ onClick={() => { setShowPath(!showPath) }}
238
+ >
239
+ {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
240
+ </button>
241
+ {showPath && (
242
+ <label className={css.importField}>
243
+ <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
244
+ <input
245
+ className={css.input}
246
+ type="text"
247
+ placeholder="例如 D:\novels\全本.txt"
248
+ value={filePath}
249
+ onChange={e => { setFilePath(e.target.value) }}
250
+ />
251
+ </label>
252
+ )}
253
+ <div className={css.importHint}>
254
+ 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
255
+ </div>
256
+ </>
257
+ )}
258
+
259
+ {error !== '' && <div className={css.importError}>{error}</div>}
260
+
261
+ {result !== null && (
262
+ <div className={css.importResult}>
263
+ {result.kind === 'dir' ? (
264
+ <>
265
+ <span> 已{result.existed === true ? '重新激活' : '登记'}《{result.bookName}》</span>
266
+ <span className={css.meta}>
267
+ {result.existed === true
268
+ ? '该目录已在书架中,现已切换为当前书;工作台数据按该书目录加载。'
269
+ : '已加入书架并设为当前书,进入工作台即可看到全部模块。'
270
+ }
271
+ </span>
272
+ </>
273
+ ) : (
274
+ <>
275
+ <span> 《{result.bookName}》导入完成:{result.chapters} 章</span>
276
+ {(result.skipped ?? []).length > 0 && (
277
+ <span className={css.meta}>
278
+ 跳过 {result.skipped!.length} 个过短章节:{result.skipped!.slice(0, 8).join('、')}
279
+ {result.skipped!.length > 8 ? ` 等 ${result.skipped!.length} 个` : ''}
280
+ </span>
281
+ )}
282
+ <span className={css.meta}>章节已按「written」登记,进入工作台后逐章审稿/补设定即可。</span>
283
+ </>
284
+ )}
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ <div className={css.importModalActions}>
290
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={onClose}>关闭</button>
291
+ <button
292
+ type="button"
293
+ className={`${css.button} ${css.buttonPrimary}`}
294
+ disabled={busy}
295
+ onClick={runImport}
296
+ >
297
+ {busy ? '导入中…' : '开始导入'}
298
+ </button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ )
303
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 书内知识库:添加/删除参考资料,生成/规划时自动检索注入。
3
+ * T2 主从布局:左列文档列表 + 新建入口,右侧文档详情 / 新建表单。
4
+ */
5
+ import { useEffect, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import type { KnowledgeDoc } from '../../protocol.ts'
8
+ import css from './panel.module.css'
9
+
10
+ export default function KnowledgeBaseView({ api }: { api: NovelApi }): JSX.Element {
11
+ const [docs, setDocs] = useState<KnowledgeDoc[]>([])
12
+ const [title, setTitle] = useState('')
13
+ const [content, setContent] = useState('')
14
+ const [busy, setBusy] = useState(false)
15
+ const [notice, setNotice] = useState('')
16
+ const [error, setError] = useState('')
17
+ /** 左列选中的文档 id;null 且 addMode=false 时显示空态。 */
18
+ const [selectedId, setSelectedId] = useState<string | null>(null)
19
+ /** 新建模式:右栏显示添加表单。 */
20
+ const [addMode, setAddMode] = useState(false)
21
+
22
+ const load = async (): Promise<void> => {
23
+ try {
24
+ const r = await api.knowledgeList()
25
+ setDocs(r.docs)
26
+ } catch (e) { setError((e as Error).message) }
27
+ }
28
+ useEffect(() => { void load() }, [])
29
+
30
+ const add = async (): Promise<void> => {
31
+ if (title.trim() === '') { setError('标题不能为空'); return }
32
+ setBusy(true); setError(''); setNotice('')
33
+ try {
34
+ const r = await api.knowledgeAdd({ title: title.trim(), content: content.trim() })
35
+ setDocs(r.docs); setTitle(''); setContent(''); setNotice(' 已添加')
36
+ setAddMode(false)
37
+ const added = r.docs[r.docs.length - 1]
38
+ if (added !== undefined) setSelectedId(added.id)
39
+ } catch (e) { setError((e as Error).message) } finally { setBusy(false) }
40
+ }
41
+
42
+ const remove = async (id: string): Promise<void> => {
43
+ setBusy(true); setError('')
44
+ try {
45
+ const r = await api.knowledgeRemove(id)
46
+ setDocs(r.docs)
47
+ if (selectedId === id) setSelectedId(null)
48
+ } catch (e) { setError((e as Error).message) } finally { setBusy(false) }
49
+ }
50
+
51
+ const selected = docs.find(d => d.id === selectedId)
52
+
53
+ return (
54
+ <div className={css.authorPageBodyFlow}>
55
+ <div className={css.card} style={{ padding: 0, overflow: 'hidden' }}>
56
+ <div className={css.t2Master} style={{ minHeight: 420 }}>
57
+ <aside className={css.t2Rail} aria-label="知识库文档">
58
+ <div className={css.t2RailHead}> 书内知识库</div>
59
+ <button
60
+ type="button"
61
+ className={css.t2RailBtn}
62
+ data-active={addMode ? '' : undefined}
63
+ onClick={() => { setAddMode(true); setSelectedId(null) }}
64
+ title="添加一篇参考文档(生成时按章节检索注入)"
65
+ >
66
+ <span aria-hidden>+</span><span>新建文档</span>
67
+ </button>
68
+ <div className={css.t2RailSep} />
69
+ {docs.length === 0 ? (
70
+ <div className={css.t2RailMeta}>暂无文档</div>
71
+ ) : docs.map(d => (
72
+ <button
73
+ key={d.id}
74
+ type="button"
75
+ role="tab"
76
+ aria-selected={!addMode && selectedId === d.id}
77
+ className={css.t2RailBtn}
78
+ data-active={!addMode && selectedId === d.id ? '' : undefined}
79
+ title={d.content.slice(0, 80)}
80
+ onClick={() => { setAddMode(false); setSelectedId(d.id) }}
81
+ >
82
+ <span aria-hidden></span>
83
+ <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</span>
84
+ </button>
85
+ ))}
86
+ <div className={css.t2RailSep} />
87
+ <div className={css.t2RailMeta}>共 {docs.length} 篇 · 正文生成时按当前章节检索注入</div>
88
+ </aside>
89
+ <div className={css.t2Content}>
90
+ {addMode ? (
91
+ <>
92
+ <span className={css.cardTitleLg}>+ 新建文档</span>
93
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', maxWidth: 'var(--nf-measure, 720px)' }}>
94
+ <input className={css.input} value={title} onChange={e => setTitle(e.target.value)} placeholder="标题(如:天机锁设定 / 止水驿关系表)" />
95
+ <textarea className={css.input} style={{ minHeight: 160, resize: 'vertical' }} value={content} onChange={e => setContent(e.target.value)} placeholder="内容(将被检索注入生成提示)" />
96
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
97
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void add() }}>添加</button>
98
+ <button type="button" className={css.button} onClick={() => { setAddMode(false) }}>取消</button>
99
+ {notice !== '' && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-success)' }}>{notice}</span>}
100
+ </div>
101
+ </div>
102
+ </>
103
+ ) : selected !== undefined ? (
104
+ <>
105
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
106
+ <span className={css.cardTitleLg}> {selected.title}</span>
107
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void remove(selected.id) }}>删除</button>
108
+ </div>
109
+ <div className={`${css.planBeats} ${css.docsSurface}`}>
110
+ <div style={{ fontSize: 'var(--nf-fs-14)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{selected.content !== '' ? selected.content : '(空文档)'}</div>
111
+ </div>
112
+ </>
113
+ ) : (
114
+ <div className={css.planDetailEmpty} style={{ minHeight: 300 }}>
115
+ <span className={css.meta}>← 从左侧选择文档,或「+ 新建文档」添加参考资料。</span>
116
+ </div>
117
+ )}
118
+ {error !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-14)' }}> {error}</div>}
119
+ </div>
120
+ </div>
121
+ </div>
122
+ <span className={css.meta}>自由参考文档:零散设定/资料/碎片放这里;结构化设定(世界观/角色/境界/红线)请放「本书设定」。</span>
123
+ </div>
124
+ )
125
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * 「实时调用 / LIVE」—— 内嵌到 AI 进度窗的下半区(详情层),不再独立浮窗。
3
+ * 连接 /llm-live/stream,按 LLM 调用分组展示模型实时输出。
4
+ *
5
+ * 除实时输出外,这里还承担两件与「花了多少」有关的事:
6
+ * 1) 每次调用显示 token 用量(输入↑ / 输出↓ / 思考)与耗时、首字耗时;
7
+ * 2) 「查看 Prompt」按 sessionId 拉取这次调用**实际发送**的 system / user。
8
+ * 顶部另有一条「本次运行」累计条(进程内,重启归零)。
9
+ *
10
+ * 状态色全部走 panel.module.css 的 .lfPill / .lfDot(主题令牌 + --nf-streaming),
11
+ * 不再内联硬编码色;布局性 inline style 保留(均为令牌引用或动态值)。
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useState } from 'react'
15
+ import { useLlmLiveFeed, type LlmLiveSession } from '../llmLive.ts'
16
+ import { NOVEL_API, type LlmPromptRecord, type LlmUsageSummary } from '../../protocol.ts'
17
+ import css from './panel.module.css'
18
+
19
+ /** 毫秒 → 紧凑可读(840ms / 1.2s)。 */
20
+ function fmtMs(ms: number | undefined): string {
21
+ if (ms === undefined || ms <= 0) return ''
22
+ return ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's'
23
+ }
24
+
25
+ /** token 数 → 紧凑(12.3k)。 */
26
+ function fmtTokens(n: number | undefined): string {
27
+ if (n === undefined || n <= 0) return '0'
28
+ return n < 1000 ? String(n) : (n / 1000).toFixed(1) + 'k'
29
+ }
30
+
31
+ /** 一次调用的用量摘要(输入↑ / 输出↓ / 思考 / 缓存)。 */
32
+ function usageText(s: LlmLiveSession): string {
33
+ const u = s.usage
34
+ if (u === undefined) return '用量未上报'
35
+ const parts = ['↑' + fmtTokens(u.inputTokens), '↓' + fmtTokens(u.outputTokens)]
36
+ if ((u.reasoningTokens ?? 0) > 0) parts.push('思考 ' + fmtTokens(u.reasoningTokens))
37
+ if ((u.cacheReadTokens ?? 0) > 0) parts.push('缓存 ' + fmtTokens(u.cacheReadTokens))
38
+ return parts.join(' · ')
39
+ }
40
+
41
+ function PhasePill({ s }: { s: LlmLiveSession }): JSX.Element {
42
+ return (
43
+ <span className={css.lfPill} data-phase={s.phase}>
44
+ {s.phase === 'requesting' ? '请求中' : s.phase === 'streaming' ? '生成中' : s.phase === 'failed' ? '失败' : '完成'}
45
+ </span>
46
+ )
47
+ }
48
+
49
+ /** 「查看 Prompt」弹层:拉取这次调用实际发送的 system + user。 */
50
+ function PromptDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }): JSX.Element {
51
+ const [record, setRecord] = useState<LlmPromptRecord | null>(null)
52
+ const [error, setError] = useState('')
53
+ const [tab, setTab] = useState<'user' | 'system'>('user')
54
+ useEffect(() => {
55
+ let cancelled = false
56
+ void (async () => {
57
+ try {
58
+ const res = await fetch(NOVEL_API.llmPrompt + '?sessionId=' + encodeURIComponent(sessionId))
59
+ if (!res.ok) {
60
+ const body = await res.json().catch(() => ({})) as { error?: string }
61
+ throw new Error(body.error ?? 'HTTP ' + res.status)
62
+ }
63
+ const data = await res.json() as LlmPromptRecord
64
+ if (!cancelled) setRecord(data)
65
+ } catch (err) {
66
+ if (!cancelled) setError((err as Error).message)
67
+ }
68
+ })()
69
+ return () => { cancelled = true }
70
+ }, [sessionId])
71
+ const text = record === null ? '' : (tab === 'user' ? (record.user ?? '') : (record.system ?? ''))
72
+ return (
73
+ <div
74
+ style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
75
+ onClick={onClose}
76
+ >
77
+ <div
78
+ onClick={e => e.stopPropagation()}
79
+ style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(880px, 96vw)', maxHeight: '86vh', display: 'flex', flexDirection: 'column', padding: 12, gap: 8 }}
80
+ >
81
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
82
+ <b style={{ flex: 1, fontSize: 13 }}>实际发送的 Prompt{record?.label !== undefined ? ' · ' + record.label : ''}</b>
83
+ {record !== null && <span className={css.meta}>共 {record.chars.toLocaleString()} 字符{record.truncated === true ? '(记录已截断)' : ''}</span>}
84
+ <button type="button" className={css.iconButton} title="关闭" onClick={onClose}>×</button>
85
+ </div>
86
+ <div style={{ display: 'flex', gap: 6 }}>
87
+ <button type="button" className={css.button + (tab === 'user' ? ' ' + css.buttonPrimary : '')} onClick={() => setTab('user')}>User / 输入</button>
88
+ <button type="button" className={css.button + (tab === 'system' ? ' ' + css.buttonPrimary : '')} onClick={() => setTab('system')}>System / 系统提示</button>
89
+ </div>
90
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{error}</span>}
91
+ {record === null && error === '' && <span className={css.meta}>加载中…</span>}
92
+ {record !== null && (
93
+ <pre style={{ flex: 1, minHeight: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.55, background: 'var(--nf-bg-inset)', borderRadius: 8, padding: 8, margin: 0 }}>
94
+ {text === '' ? '(空)' : text}
95
+ </pre>
96
+ )}
97
+ </div>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function SessionCard({ s, brief, onShowPrompt }: { s: LlmLiveSession; brief: boolean; onShowPrompt: (sessionId: string) => void }): JSX.Element {
103
+ const active = s.phase === 'requesting' || s.phase === 'streaming'
104
+ const dotState = active ? 'streaming' : s.phase
105
+ return (
106
+ <div style={{ borderBottom: '1px solid var(--nf-border)', padding: '7px 6px' }}>
107
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
108
+ <span className={css.lfDot} data-state={dotState} />
109
+ <b style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</b>
110
+ {s.model !== undefined && <span style={{ color: 'var(--nf-text-2)', fontSize: 10, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.model}</span>}
111
+ <PhasePill s={s} />
112
+ </div>
113
+ <div style={{ fontSize: 10, color: 'var(--nf-text-2)', marginTop: 2, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
114
+ <span>{s.totalChars} 字符</span>
115
+ <span title="输入 / 输出 token">{usageText(s)}</span>
116
+ {s.elapsedMs !== undefined && <span title="总耗时">用时 {fmtMs(s.elapsedMs)}</span>}
117
+ {s.firstTokenMs !== undefined && <span title="首个字返回耗时">首字 {fmtMs(s.firstTokenMs)}</span>}
118
+ {s.phaseMessage !== '' && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.phaseMessage}</span>}
119
+ {s.hasPrompt === true && (
120
+ <button
121
+ type="button"
122
+ title="查看这次调用实际发送的 Prompt"
123
+ onClick={() => onShowPrompt(s.sessionId)}
124
+ style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '0 5px', cursor: 'pointer' }}
125
+ >查看 Prompt</button>
126
+ )}
127
+ </div>
128
+ {!brief && s.preview !== '' && (
129
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, lineHeight: 1.5, color: 'var(--nf-text)', backgroundColor: 'var(--nf-bg-inset)', borderRadius: 8, padding: 6, marginTop: 6, marginBottom: 0 }}>{s.preview}</pre>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export default function LiveFeedLog(): JSX.Element {
136
+ const [brief, setBrief] = useState(true)
137
+ const [cleared, setCleared] = useState(false)
138
+ const [promptId, setPromptId] = useState<string | null>(null)
139
+ const { connected, sessions } = useLlmLiveFeed(true)
140
+ const active = sessions.filter(s => s.phase === 'requesting' || s.phase === 'streaming').length
141
+ const shown = useMemo(() => (cleared ? sessions.slice(-8) : sessions), [sessions, cleared])
142
+
143
+ // 本次运行用量(进程内累计,来自 /status.usage)。
144
+ const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
145
+ const loadUsage = useCallback(async () => {
146
+ try {
147
+ const res = await fetch(NOVEL_API.status + '?slim=1')
148
+ if (!res.ok) return
149
+ const data = await res.json() as { usage?: LlmUsageSummary }
150
+ setUsage(data.usage ?? null)
151
+ } catch { /* 用量是尽力而为,不影响实况 */ }
152
+ }, [])
153
+ useEffect(() => {
154
+ void loadUsage()
155
+ const timer = window.setInterval(() => { void loadUsage() }, 15_000)
156
+ return () => { window.clearInterval(timer) }
157
+ }, [loadUsage])
158
+
159
+ const resetUsage = useCallback(async (): Promise<void> => {
160
+ try {
161
+ await fetch(NOVEL_API.usageReset, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
162
+ await loadUsage()
163
+ } catch { /* ignore */ }
164
+ }, [loadUsage])
165
+
166
+ return (
167
+ <div style={{ borderTop: '1px solid var(--nf-border)', marginTop: 8, paddingTop: 6, flex: '1 1 auto', minHeight: 140, display: 'flex', flexDirection: 'column' }}>
168
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '2px 4px' }}>
169
+ <span className={css.lfDot} data-size="lg" data-state={connected ? 'ok' : 'idle'} />
170
+ <b style={{ flex: 1 }}>实时调用 / LIVE</b>
171
+ <span className={css.lfPill} data-phase={active > 0 ? 'streaming' : 'requesting'}>
172
+ {active > 0 ? `${active} 项进行中` : connected ? '等待生成' : '正在连接'}
173
+ </span>
174
+ <button type="button" title={brief ? '详细' : '简略'} onClick={() => setBrief(b => !b)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>{brief ? '详细' : '简略'}</button>
175
+ <button type="button" title="清空当前窗口" onClick={() => setCleared(true)} style={{ border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}>清空</button>
176
+ </div>
177
+ {usage !== null && usage.calls > 0 && (
178
+ <div
179
+ title="本次运行累计(进程内,重启归零)"
180
+ style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', fontSize: 10, color: 'var(--nf-text-2)', padding: '3px 4px', borderTop: '1px dashed var(--nf-border)' }}
181
+ >
182
+ <span>本次运行:{usage.calls} 次调用{usage.failed > 0 ? `(失败 ${usage.failed})` : ''}</span>
183
+ <span>↑{fmtTokens(usage.inputTokens)}</span>
184
+ <span>↓{fmtTokens(usage.outputTokens)}</span>
185
+ {usage.reasoningTokens > 0 && <span>思考 {fmtTokens(usage.reasoningTokens)}</span>}
186
+ <span>用时 {fmtMs(usage.elapsedMs)}</span>
187
+ {usage.callsWithoutUsage > 0 && <span>({usage.callsWithoutUsage} 次未上报用量)</span>}
188
+ <button
189
+ type="button"
190
+ title="清零本次运行用量"
191
+ onClick={() => { void resetUsage() }}
192
+ style={{ marginLeft: 'auto', border: '1px solid var(--nf-border)', background: 'var(--nf-bg)', color: 'var(--nf-text)', borderRadius: 6, fontSize: 10, padding: '1px 6px', cursor: 'pointer' }}
193
+ >重新计数</button>
194
+ </div>
195
+ )}
196
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
197
+ {shown.length === 0 ? (
198
+ <div style={{ padding: 10, fontSize: 12, color: 'var(--nf-text-2)' }}>{connected ? '暂无实时调用。开始一次生成/审稿/计划即可看到。' : '正在连接 AI 实况服务…'}</div>
199
+ ) : shown.map(s => <SessionCard key={s.sessionId} s={s} brief={brief} onShowPrompt={setPromptId} />)}
200
+ </div>
201
+ {promptId !== null && <PromptDialog sessionId={promptId} onClose={() => setPromptId(null)} />}
202
+ </div>
203
+ )
204
+ }