@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.
- package/README.md +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- 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 拆章;正文过短(<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
|
+
}
|