@waterwx/dsh-novel-forge 1.1.8 → 1.2.0-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 (126) hide show
  1. package/README.md +231 -349
  2. package/lib/client.js +54833 -9926
  3. package/lib/index.js +12137 -4734
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +18 -3
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +153 -9
  11. package/lib/types/client/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +33 -0
  14. package/lib/types/client/locales.d.ts +34 -9
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  36. package/lib/types/client/panel/RunPanel.d.ts +5 -0
  37. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  38. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  39. package/lib/types/client/panel/SubPage.d.ts +27 -0
  40. package/lib/types/client/panel/toast.d.ts +2 -0
  41. package/lib/types/client/panel/views.d.ts +25 -7
  42. package/lib/types/client/run-status.d.ts +19 -0
  43. package/lib/types/client/text.d.ts +9 -0
  44. package/lib/types/docx-core.d.ts +16 -0
  45. package/lib/types/docx.d.ts +2 -3
  46. package/lib/types/engine.d.ts +152 -40
  47. package/lib/types/global-assets.d.ts +6 -0
  48. package/lib/types/index.d.ts +20 -2
  49. package/lib/types/llm-live.d.ts +44 -0
  50. package/lib/types/market-radar-scan.d.ts +19 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/novel-context.d.ts +80 -0
  54. package/lib/types/prompt-utils.d.ts +35 -0
  55. package/lib/types/prompting.d.ts +10 -0
  56. package/lib/types/protocol.d.ts +1106 -123
  57. package/lib/types/review-policy.d.ts +97 -0
  58. package/lib/types/routes.d.ts +4 -0
  59. package/lib/types/run.d.ts +34 -0
  60. package/lib/types/stage-contract.d.ts +60 -0
  61. package/package.json +52 -27
  62. package/scripts/patch-schemastery.mjs +47 -0
  63. package/src/action-guard.ts +127 -0
  64. package/src/ai-scan.ts +179 -0
  65. package/src/assets.ts +1774 -520
  66. package/src/assistant.ts +1082 -770
  67. package/src/author-assets.ts +149 -0
  68. package/src/bookshelf.ts +187 -151
  69. package/src/client/api.ts +684 -387
  70. package/src/client/docx.ts +37 -69
  71. package/src/client/index.ts +3 -1
  72. package/src/client/llmLive.ts +101 -0
  73. package/src/client/locales.ts +418 -367
  74. package/src/client/mount.tsx +1 -1
  75. package/src/client/panel/AdaptModeView.tsx +535 -0
  76. package/src/client/panel/AiTag.tsx +7 -0
  77. package/src/client/panel/AssetsTab.tsx +448 -439
  78. package/src/client/panel/AssistantTab.tsx +355 -301
  79. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  80. package/src/client/panel/AuthorHome.tsx +309 -0
  81. package/src/client/panel/BookAnalysisView.tsx +63 -0
  82. package/src/client/panel/CmdPalette.tsx +102 -0
  83. package/src/client/panel/CreateBookView.tsx +366 -348
  84. package/src/client/panel/DirectorView.tsx +200 -0
  85. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  86. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  87. package/src/client/panel/ImportModal.tsx +303 -0
  88. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  89. package/src/client/panel/LiveFeedLog.tsx +67 -0
  90. package/src/client/panel/MarketRadarView.tsx +377 -0
  91. package/src/client/panel/ModelManager.tsx +205 -0
  92. package/src/client/panel/NovelPanel.tsx +5403 -4294
  93. package/src/client/panel/PageHeader.tsx +37 -0
  94. package/src/client/panel/ProgressConsole.tsx +63 -0
  95. package/src/client/panel/ReaderView.tsx +306 -306
  96. package/src/client/panel/ReasoningSection.tsx +31 -0
  97. package/src/client/panel/RunPanel.tsx +223 -0
  98. package/src/client/panel/SettingsView.tsx +305 -0
  99. package/src/client/panel/ShelfView.tsx +266 -235
  100. package/src/client/panel/SubPage.tsx +42 -0
  101. package/src/client/panel/WorldTab.tsx +202 -202
  102. package/src/client/panel/panel.module.css +5764 -2817
  103. package/src/client/panel/toast.ts +51 -0
  104. package/src/client/panel/views.tsx +401 -314
  105. package/src/client/run-status.ts +143 -0
  106. package/src/client/sidebar-entry.ts +1 -1
  107. package/src/client/text.ts +28 -0
  108. package/src/docx-core.ts +53 -0
  109. package/src/docx.ts +44 -83
  110. package/src/engine.ts +4231 -2746
  111. package/src/global-assets.ts +59 -0
  112. package/src/index.ts +311 -246
  113. package/src/llm-live.ts +68 -0
  114. package/src/market-radar-scan.ts +41 -0
  115. package/src/market-radar-sources.ts +173 -0
  116. package/src/migrate-output.ts +76 -0
  117. package/src/novel-context.ts +286 -0
  118. package/src/prompting.ts +26 -0
  119. package/src/protocol.ts +2072 -1104
  120. package/src/review-policy.ts +161 -0
  121. package/src/routes.ts +3405 -2050
  122. package/src/run.ts +342 -0
  123. package/src/stage-contract.ts +189 -0
  124. package/lib/client.js.map +0 -1
  125. package/lib/index.js.map +0 -1
  126. package/src/client/panel/BookshelfBar.tsx +0 -170
@@ -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,67 @@
1
+ /**
2
+ * 「实时调用 / LIVE」—— 内嵌到 AI 进度窗的下半区(详情层),不再独立浮窗。
3
+ * 连接 /llm-live/stream,按 LLM 调用分组展示模型实时输出。
4
+ *
5
+ * 状态色全部走 panel.module.css 的 .lfPill / .lfDot(主题令牌 + --nf-streaming),
6
+ * 不再内联硬编码色;布局性 inline style 保留(均为令牌引用或动态值)。
7
+ */
8
+
9
+ import { useMemo, useState } from 'react'
10
+ import { useLlmLiveFeed, type LlmLiveSession } from '../llmLive.ts'
11
+ import css from './panel.module.css'
12
+
13
+ function PhasePill({ s }: { s: LlmLiveSession }): JSX.Element {
14
+ return (
15
+ <span className={css.lfPill} data-phase={s.phase}>
16
+ {s.phase === 'requesting' ? '请求中' : s.phase === 'streaming' ? '生成中' : s.phase === 'failed' ? '失败' : '完成'}
17
+ </span>
18
+ )
19
+ }
20
+
21
+ function SessionCard({ s, brief }: { s: LlmLiveSession; brief: boolean }): JSX.Element {
22
+ const active = s.phase === 'requesting' || s.phase === 'streaming'
23
+ const dotState = active ? 'streaming' : s.phase
24
+ return (
25
+ <div style={{ borderBottom: '1px solid var(--nf-border)', padding: '7px 6px' }}>
26
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
27
+ <span className={css.lfDot} data-state={dotState} />
28
+ <b style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</b>
29
+ {s.model !== undefined && <span style={{ color: 'var(--nf-text-2)', fontSize: 10, maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.model}</span>}
30
+ <PhasePill s={s} />
31
+ </div>
32
+ <div style={{ fontSize: 10, color: 'var(--nf-text-2)', marginTop: 2, display: 'flex', gap: 8 }}>
33
+ <span>{s.totalChars} 字符</span>
34
+ {s.phaseMessage !== '' && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.phaseMessage}</span>}
35
+ </div>
36
+ {!brief && s.preview !== '' && (
37
+ <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>
38
+ )}
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export default function LiveFeedLog(): JSX.Element {
44
+ const [brief, setBrief] = useState(true)
45
+ const [cleared, setCleared] = useState(false)
46
+ const { connected, sessions } = useLlmLiveFeed(true)
47
+ const active = sessions.filter(s => s.phase === 'requesting' || s.phase === 'streaming').length
48
+ const shown = useMemo(() => (cleared ? sessions.slice(-8) : sessions), [sessions, cleared])
49
+ return (
50
+ <div style={{ borderTop: '1px solid var(--nf-border)', marginTop: 8, paddingTop: 6, flex: '1 1 auto', minHeight: 140, display: 'flex', flexDirection: 'column' }}>
51
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '2px 4px' }}>
52
+ <span className={css.lfDot} data-size="lg" data-state={connected ? 'ok' : 'idle'} />
53
+ <b style={{ flex: 1 }}>实时调用 / LIVE</b>
54
+ <span className={css.lfPill} data-phase={active > 0 ? 'streaming' : 'requesting'}>
55
+ {active > 0 ? `${active} 项进行中` : connected ? '等待生成' : '正在连接'}
56
+ </span>
57
+ <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>
58
+ <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>
59
+ </div>
60
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
61
+ {shown.length === 0 ? (
62
+ <div style={{ padding: 10, fontSize: 12, color: 'var(--nf-text-2)' }}>{connected ? '暂无实时调用。开始一次生成/审稿/计划即可看到。' : '正在连接 AI 实况服务…'}</div>
63
+ ) : shown.map(s => <SessionCard key={s.sessionId} s={s} brief={brief} />)}
64
+ </div>
65
+ </div>
66
+ )
67
+ }