@waterwx/dsh-novel-forge 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
@@ -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
+ }