@waterwx/dsh-novel-forge 2.0.0-alpha.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 (206) hide show
  1. package/README.md +286 -285
  2. package/lib/client.js +19561 -19439
  3. package/lib/index.js +8159 -4456
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  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 +136 -27
  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/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +15 -70
  15. package/lib/types/client/mount.d.ts +0 -2
  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/AuthorHome.d.ts +13 -11
  19. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  20. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  21. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  22. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  25. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  31. package/lib/types/client/panel/SettingsView.d.ts +12 -9
  32. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  33. package/lib/types/client/panel/SubPage.d.ts +27 -0
  34. package/lib/types/client/panel/TensionView.d.ts +18 -0
  35. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  36. package/lib/types/client/panel/helpers.d.ts +5 -3
  37. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  38. package/lib/types/client/panel/toast.d.ts +2 -0
  39. package/lib/types/client/panel/views.d.ts +21 -35
  40. package/lib/types/client/run-status.d.ts +21 -0
  41. package/lib/types/client/sidebar-entry.d.ts +0 -2
  42. package/lib/types/docx-core.d.ts +16 -0
  43. package/lib/types/docx.d.ts +2 -3
  44. package/lib/types/engine.d.ts +78 -103
  45. package/lib/types/global-assets.d.ts +6 -0
  46. package/lib/types/home.d.ts +11 -0
  47. package/lib/types/index.d.ts +26 -12
  48. package/lib/types/llm-live.d.ts +70 -0
  49. package/lib/types/llm-retry.d.ts +38 -0
  50. package/lib/types/market-radar-scan.d.ts +18 -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/model-capability.d.ts +93 -0
  54. package/lib/types/output-budget.d.ts +86 -0
  55. package/lib/types/prompt-slots.d.ts +57 -0
  56. package/lib/types/prompting.d.ts +10 -0
  57. package/lib/types/protocol.d.ts +816 -601
  58. package/lib/types/revision.d.ts +76 -0
  59. package/lib/types/routes.d.ts +9 -3
  60. package/lib/types/run.d.ts +23 -1
  61. package/lib/types/settings-persist.d.ts +59 -0
  62. package/lib/types/snapshots.d.ts +46 -0
  63. package/lib/types/stage-contract.d.ts +58 -0
  64. package/lib/types/tension.d.ts +53 -0
  65. package/lib/types/timeline.d.ts +37 -0
  66. package/package.json +50 -25
  67. package/src/action-guard.ts +127 -0
  68. package/src/ai-scan.ts +0 -24
  69. package/src/assets.ts +1801 -879
  70. package/src/assistant.ts +1098 -770
  71. package/src/author-assets.ts +131 -147
  72. package/src/bookshelf.ts +193 -167
  73. package/src/client/api.ts +767 -617
  74. package/src/client/chip-prefs.ts +121 -0
  75. package/src/client/docx.ts +32 -69
  76. package/src/client/index.ts +2 -0
  77. package/src/client/llmLive.ts +111 -0
  78. package/src/client/locales.ts +15 -274
  79. package/src/client/mount.tsx +3 -2
  80. package/src/client/panel/AdaptModeView.tsx +535 -520
  81. package/src/client/panel/AiTag.tsx +7 -0
  82. package/src/client/panel/AssetsTab.tsx +448 -458
  83. package/src/client/panel/AssistantTab.tsx +355 -301
  84. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  85. package/src/client/panel/AuthorHome.tsx +309 -204
  86. package/src/client/panel/BookAnalysisView.tsx +63 -0
  87. package/src/client/panel/CmdPalette.tsx +102 -0
  88. package/src/client/panel/CreateBookView.tsx +366 -352
  89. package/src/client/panel/DirectorView.tsx +200 -0
  90. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  91. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  92. package/src/client/panel/ImportModal.tsx +302 -302
  93. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  94. package/src/client/panel/LiveFeedLog.tsx +204 -0
  95. package/src/client/panel/MarketRadarView.tsx +415 -0
  96. package/src/client/panel/ModelManager.tsx +205 -205
  97. package/src/client/panel/NovelPanel.tsx +3144 -2205
  98. package/src/client/panel/PageHeader.tsx +37 -0
  99. package/src/client/panel/ProgressConsole.tsx +63 -64
  100. package/src/client/panel/PromptSlotsView.tsx +97 -0
  101. package/src/client/panel/ReaderView.tsx +306 -306
  102. package/src/client/panel/RunPanel.tsx +56 -24
  103. package/src/client/panel/SettingsView.tsx +175 -106
  104. package/src/client/panel/ShelfView.tsx +63 -39
  105. package/src/client/panel/SubPage.tsx +42 -0
  106. package/src/client/panel/TensionView.tsx +212 -0
  107. package/src/client/panel/TimelineView.tsx +208 -0
  108. package/src/client/panel/WorldTab.tsx +202 -202
  109. package/src/client/panel/helpers.ts +7 -5
  110. package/src/client/panel/market-radar-state.ts +79 -0
  111. package/src/client/panel/panel.module.css +5767 -4360
  112. package/src/client/panel/toast.ts +51 -0
  113. package/src/client/panel/views.tsx +316 -314
  114. package/src/client/run-status.ts +171 -0
  115. package/src/client/sidebar-entry.ts +3 -3
  116. package/src/docx-core.ts +53 -0
  117. package/src/docx.ts +44 -83
  118. package/src/engine.ts +1197 -1638
  119. package/src/global-assets.ts +58 -0
  120. package/src/home.ts +33 -0
  121. package/src/index.ts +181 -101
  122. package/src/llm-live.ts +245 -0
  123. package/src/llm-retry.ts +77 -0
  124. package/src/market-radar-scan.ts +35 -0
  125. package/src/market-radar-sources.ts +173 -0
  126. package/src/migrate-output.ts +76 -0
  127. package/src/model-capability.ts +215 -0
  128. package/src/novel-context.ts +286 -287
  129. package/src/output-budget.ts +147 -0
  130. package/src/prompt-slots.ts +125 -0
  131. package/src/prompting.ts +26 -0
  132. package/src/protocol.ts +835 -614
  133. package/src/revision.ts +283 -0
  134. package/src/routes.ts +1286 -910
  135. package/src/run.ts +113 -17
  136. package/src/settings-persist.ts +114 -0
  137. package/src/snapshots.ts +174 -0
  138. package/src/stage-contract.ts +186 -0
  139. package/src/tension.ts +251 -0
  140. package/src/timeline.ts +159 -0
  141. package/assets/styles/arcane-thick-3d.webp +0 -0
  142. package/assets/styles/chinese-ink.webp +0 -0
  143. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  144. package/assets/styles/dark-ghibli.webp +0 -0
  145. package/assets/styles/dark-gothic-3d.webp +0 -0
  146. package/assets/styles/film-photo.webp +0 -0
  147. package/assets/styles/guofeng-heavy.webp +0 -0
  148. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  149. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  150. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  151. package/assets/styles/horror-game-3d.webp +0 -0
  152. package/assets/styles/hyperreal-3d.webp +0 -0
  153. package/assets/styles/japan-cel-dark.webp +0 -0
  154. package/assets/styles/korean-webtoon.webp +0 -0
  155. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  156. package/assets/styles/nextgen-cg.webp +0 -0
  157. package/assets/styles/noir-film.webp +0 -0
  158. package/assets/styles/openworld-survival.webp +0 -0
  159. package/assets/styles/paper-cut-3d.webp +0 -0
  160. package/assets/styles/pixar-adult-3d.webp +0 -0
  161. package/assets/styles/pop-marvel-2d.webp +0 -0
  162. package/assets/styles/sandiao-qban.webp +0 -0
  163. package/assets/styles/shinkai-anime.webp +0 -0
  164. package/assets/styles/tactical-stealth.webp +0 -0
  165. package/assets/styles/urban-lightreal-3d.webp +0 -0
  166. package/assets/styles/vaporwave-3d.webp +0 -0
  167. package/assets/styles/vhs90.webp +0 -0
  168. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  169. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  170. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  171. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  172. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  173. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  174. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  175. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  176. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  177. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  178. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  179. package/lib/types/comic-presets.d.ts +0 -12
  180. package/lib/types/manga-genre-rules.d.ts +0 -22
  181. package/lib/types/prompt-utils.d.ts +0 -35
  182. package/lib/types/review-policy.d.ts +0 -97
  183. package/lib/types/shot-language.d.ts +0 -46
  184. package/lib/types/story-beat-language.d.ts +0 -35
  185. package/lib/types/style-library.d.ts +0 -33
  186. package/skills/chapter-batch/SKILL.md +0 -81
  187. package/src/client/mount.tsx._new +0 -97
  188. package/src/client/mount.tsx._nf +0 -97
  189. package/src/client/mount.tsx._tmp +0 -97
  190. package/src/client/panel/BookshelfBar.tsx +0 -170
  191. package/src/client/panel/FlowGuide.tsx +0 -84
  192. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  193. package/src/client/panel/MangaWorkspace.tsx +0 -715
  194. package/src/client/panel/ProgressHomeView.tsx +0 -87
  195. package/src/client/panel/PropLibrary.tsx +0 -175
  196. package/src/client/panel/SceneLibrary.tsx +0 -453
  197. package/src/client/panel/StoryboardTab.tsx +0 -472
  198. package/src/client/panel/StyleCard.tsx +0 -90
  199. package/src/client/panel/StyleGallery.tsx +0 -58
  200. package/src/comic-presets.ts +0 -67
  201. package/src/manga-genre-rules.ts +0 -87
  202. package/src/prompt-utils.ts +0 -73
  203. package/src/review-policy.ts +0 -161
  204. package/src/shot-language.ts +0 -181
  205. package/src/story-beat-language.ts +0 -130
  206. package/src/style-library.ts +0 -84
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 右上角「小说工坊生成状态」芯片的显示偏好(纯客户端、localStorage)。
3
+ *
4
+ * 为什么是 localStorage 而不是插件配置:这是纯界面 chrome,与显示模式 / 界面密度 /
5
+ * 编辑器字号 / 芯片自身的位置记忆同类;哪个浏览器想清屏就哪个浏览器清,不需要跨端同步。
6
+ * 将来若要跨端,把键名沿用成 config 字段 `showRunChip` 即可平滑升级。
7
+ *
8
+ * 三态:
9
+ * off 关闭(同时停止 4 秒轮询,不白跑)
10
+ * active 只在生产单**运行中或已暂停**时显示(不想错过生成,也不想留残影)
11
+ * always 只要该目录有生产单状态就显示(默认,保持历史行为)
12
+ */
13
+
14
+ import type { RunState } from '../protocol.ts'
15
+
16
+ export type RunChipMode = 'off' | 'active' | 'always'
17
+
18
+ /** localStorage 键(与芯片位置记忆同一命名空间)。 */
19
+ export const RUN_CHIP_MODE_KEY = 'dsh-novel-forge.runChip.mode'
20
+
21
+ /** 同页变更通知用的自定义事件名(跨标签页走原生 storage 事件)。 */
22
+ export const RUN_CHIP_MODE_EVENT = 'dsh-novel-forge:runchip-mode'
23
+
24
+ /** 默认值与历史行为一致:有生产单状态就显示。 */
25
+ export const DEFAULT_RUN_CHIP_MODE: RunChipMode = 'always'
26
+
27
+ /** 设置页下拉用的三态选项(顺序即用户看到的顺序)。 */
28
+ export const RUN_CHIP_MODE_OPTIONS: ReadonlyArray<{ value: RunChipMode; label: string }> = [
29
+ { value: 'always', label: '总是显示' },
30
+ { value: 'active', label: '仅生成中 / 暂停时显示' },
31
+ { value: 'off', label: '关闭' },
32
+ ]
33
+
34
+ const ALL_MODES: readonly RunChipMode[] = ['off', 'active', 'always']
35
+
36
+ function isMode(value: unknown): value is RunChipMode {
37
+ return typeof value === 'string' && (ALL_MODES as readonly string[]).includes(value)
38
+ }
39
+
40
+ /** 安全拿 localStorage:隐私模式 / 非浏览器环境下为 undefined。 */
41
+ function storage(): Storage | undefined {
42
+ try {
43
+ return typeof localStorage === 'undefined' ? undefined : localStorage
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ /** 读取偏好(缺失 / 非法 / 存储不可用 → 默认值)。 */
50
+ export function readRunChipMode(): RunChipMode {
51
+ try {
52
+ const raw = storage()?.getItem(RUN_CHIP_MODE_KEY)
53
+ return isMode(raw) ? raw : DEFAULT_RUN_CHIP_MODE
54
+ } catch {
55
+ return DEFAULT_RUN_CHIP_MODE
56
+ }
57
+ }
58
+
59
+ /** 已注册的同页监听者(storage 事件只覆盖跨标签页,同页要靠这里)。 */
60
+ const listeners = new Set<(mode: RunChipMode) => void>()
61
+
62
+ /** 写入偏好并通知所有监听者;存储不可用时仍通知(本次会话内生效)。 */
63
+ export function writeRunChipMode(mode: RunChipMode): void {
64
+ try {
65
+ storage()?.setItem(RUN_CHIP_MODE_KEY, mode)
66
+ } catch {
67
+ /* 存储不可用:本次会话内仍然生效 */
68
+ }
69
+ for (const listener of [...listeners]) {
70
+ try {
71
+ listener(mode)
72
+ } catch {
73
+ /* 单个监听者出错不影响其他人 */
74
+ }
75
+ }
76
+ try {
77
+ if (typeof window !== 'undefined' && typeof CustomEvent === 'function') {
78
+ window.dispatchEvent(new CustomEvent(RUN_CHIP_MODE_EVENT, { detail: mode }))
79
+ }
80
+ } catch {
81
+ /* 事件派发失败无关紧要 */
82
+ }
83
+ }
84
+
85
+ /** 订阅偏好变化(同页 + 跨标签页 storage);返回退订函数。 */
86
+ export function subscribeRunChipMode(listener: (mode: RunChipMode) => void): () => void {
87
+ listeners.add(listener)
88
+ const onStorage = (event: StorageEvent): void => {
89
+ if (event.key !== null && event.key !== RUN_CHIP_MODE_KEY) return
90
+ listener(readRunChipMode())
91
+ }
92
+ let attached = false
93
+ try {
94
+ if (typeof window !== 'undefined') {
95
+ window.addEventListener('storage', onStorage)
96
+ attached = true
97
+ }
98
+ } catch {
99
+ attached = false
100
+ }
101
+ return () => {
102
+ listeners.delete(listener)
103
+ if (!attached) return
104
+ try {
105
+ window.removeEventListener('storage', onStorage)
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 给定偏好与当前生产单状态,判断芯片是否应可见。
114
+ * null(没有生产单状态)永远不显示——这是渲染层与测试共用的唯一判据。
115
+ */
116
+ export function isRunChipVisible(mode: RunChipMode, state: RunState | null | undefined): boolean {
117
+ if (state === null || state === undefined) return false
118
+ if (mode === 'off') return false
119
+ if (mode === 'active') return state.status === 'running' || state.status === 'paused'
120
+ return true
121
+ }
@@ -1,69 +1,32 @@
1
- /**
2
- * Browser-side docx outline extraction: a .docx is a zip whose
3
- * word/document.xml holds the body text in <w:t> runs inside <w:p> paragraphs.
4
- * Uses fflate (inlined into the client bundle) so the user can pick or drag a
5
- * docx without any server upload.
6
- *
7
- * Import from 'fflate/browser' (not 'fflate'): the default entry resolves to
8
- * the Node build (esm/index.mjs), which calls module.createRequire() for the
9
- * optional worker_threads path — inlining that into the browser bundle leaves
10
- * a bare require("module") the client-modules table cannot answer.
11
- */
12
-
13
- import { unzipSync, strFromU8 } from 'fflate/browser'
14
-
15
- /** Decode the handful of XML entities docx bodies actually use. */
16
- function decodeEntities(text: string): string {
17
- return text
18
- .replace(/&lt;/g, '<')
19
- .replace(/&gt;/g, '>')
20
- .replace(/&quot;/g, '"')
21
- .replace(/&apos;/g, "'")
22
- .replace(/&amp;/g, '&')
23
- .replace(/&nbsp;/g, ' ')
24
- }
25
-
26
- /** Extract plain text from a docx buffer: one line per <w:p> paragraph. */
27
- export function extractDocxTextFromBuffer(buffer: ArrayBuffer | Uint8Array): string {
28
- const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
29
- let files: ReturnType<typeof unzipSync>
30
- try {
31
- files = unzipSync(bytes)
32
- } catch (error) {
33
- throw new Error(`不是有效的 docx(zip 解压失败):${(error as Error).message}`)
34
- }
35
- const document = files['word/document.xml']
36
- if (document === undefined) {
37
- throw new Error('不是有效的 docx(缺少 word/document.xml)')
38
- }
39
- const xml = strFromU8(document)
40
-
41
- const paragraphs: string[] = []
42
- const parts = xml.split(/<w:p\b[^>]*>/)
43
- for (let i = 1; i < parts.length; i++) {
44
- const segment = parts[i]!
45
- const runs: string[] = []
46
- const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g
47
- for (const match of segment.matchAll(runRe)) {
48
- if (match[0].startsWith('<w:tab')) {
49
- runs.push('\t')
50
- } else if (match[0].startsWith('<w:br')) {
51
- runs.push('\n')
52
- } else {
53
- runs.push(decodeEntities(match[1] ?? ''))
54
- }
55
- }
56
- paragraphs.push(runs.join('').replace(/\u00a0/g, ' ').trimEnd())
57
- }
58
-
59
- const text = paragraphs.join('\n').replace(/\n{3,}/g, '\n\n').trim()
60
- if (text.length === 0) {
61
- throw new Error('docx 中没有可提取的文本')
62
- }
63
- return text
64
- }
65
-
66
- /** Read a File as ArrayBuffer. */
67
- export function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
68
- return file.arrayBuffer()
69
- }
1
+ /**
2
+ * Browser-side docx outline extraction: a .docx is a zip whose
3
+ * word/document.xml holds the body text in <w:t> runs inside <w:p> paragraphs.
4
+ * Uses fflate (inlined into the client bundle) so the user can pick or drag a
5
+ * docx without any server upload.
6
+ *
7
+ * Import from 'fflate/browser' (not 'fflate'): the default entry resolves to
8
+ * the Node build (esm/index.mjs), which calls module.createRequire() for the
9
+ * optional worker_threads path — inlining that into the browser bundle leaves
10
+ * a bare require("module") the client-modules table cannot answer.
11
+ *
12
+ * XML 解析共享核心在 ../docx-core.ts(与服务端同源)。
13
+ */
14
+
15
+ import { unzipSync, strFromU8 } from 'fflate/browser'
16
+ import { extractDocxTextFromXml } from '../docx-core.ts'
17
+
18
+ /** Extract plain text from a docx buffer: one line per <w:p> paragraph. */
19
+ export function extractDocxTextFromBuffer(buffer: ArrayBuffer | Uint8Array): string {
20
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
21
+ let files: ReturnType<typeof unzipSync>
22
+ try {
23
+ files = unzipSync(bytes)
24
+ } catch (error) {
25
+ throw new Error(`不是有效的 docx(zip 解压失败):${(error as Error).message}`)
26
+ }
27
+ const document = files['word/document.xml']
28
+ if (document === undefined) {
29
+ throw new Error('不是有效的 docx(缺少 word/document.xml)')
30
+ }
31
+ return extractDocxTextFromXml(strFromU8(document))
32
+ }
@@ -9,6 +9,7 @@ import { NovelApi } from './api.ts'
9
9
  import { mountPanel } from './mount.tsx'
10
10
  import { PanelController } from './panel/controller.ts'
11
11
  import { mountSidebarEntry } from './sidebar-entry.ts'
12
+ import { mountRunStatus } from './run-status.ts'
12
13
 
13
14
  /** Required services (fiber inject waiting). */
14
15
  export const inject = ['slots', 'locale']
@@ -24,6 +25,7 @@ export function apply(ctx: ClientContext): void {
24
25
  try {
25
26
  disposers.push(mountSidebarEntry(controller))
26
27
  disposers.push(mountPanel(controller, api))
28
+ disposers.push(mountRunStatus(controller, api))
27
29
  } catch (error) {
28
30
  // DOM failures degrade the panel, never the GUI.
29
31
  console.warn('[dsh-novel-forge] mount failed:', error)
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 浏览器端「AI 创作实况」钩子:连接 /llm-live/stream (SSE),按 LLM 调用分组会话。
3
+ * 轻量实现,不持久化缓存(对齐上游 useLlmLiveFeed 的核心行为)。
4
+ */
5
+
6
+ import { useEffect, useMemo, useState } from 'react'
7
+ import { NOVEL_API, type LlmLiveFrame, type LlmTokenUsage } from '../protocol.ts'
8
+
9
+ // 帧结构定义在 protocol.ts(宿主与浏览器共用同一份线上形状),此处只再导出。
10
+ export type { LlmLiveFrame }
11
+
12
+ export interface LlmLiveSession {
13
+ sessionId: string
14
+ label: string
15
+ model?: string
16
+ phase: 'requesting' | 'streaming' | 'completed' | 'failed'
17
+ phaseMessage: string
18
+ preview: string
19
+ totalChars: number
20
+ startedAt: string
21
+ updatedAt: string
22
+ /** 本次调用的 token 用量(供应商未上报时缺省)。 */
23
+ usage?: LlmTokenUsage
24
+ /** 本次调用耗时(毫秒)。 */
25
+ elapsedMs?: number
26
+ /** 首字耗时(毫秒)。 */
27
+ firstTokenMs?: number
28
+ /** 是否保存了可查看的 Prompt。 */
29
+ hasPrompt?: boolean
30
+ /** Prompt 字符数(system + user)。 */
31
+ promptChars?: number
32
+ }
33
+
34
+ const MAX_PREVIEW_CHARS = 20_000
35
+
36
+ function applyFrame(current: Record<string, LlmLiveSession>, frame: LlmLiveFrame): Record<string, LlmLiveSession> {
37
+ const next = { ...current }
38
+ const id = frame.sessionId
39
+ let session = next[id]
40
+ if (frame.type === 'session_started') {
41
+ session = {
42
+ sessionId: id,
43
+ label: frame.label ?? 'LLM 调用',
44
+ model: frame.model,
45
+ phase: 'requesting',
46
+ phaseMessage: '正在连接模型',
47
+ preview: '',
48
+ totalChars: 0,
49
+ startedAt: frame.at,
50
+ updatedAt: frame.at,
51
+ hasPrompt: frame.hasPrompt,
52
+ promptChars: frame.promptChars,
53
+ }
54
+ next[id] = session
55
+ return next
56
+ }
57
+ if (session === undefined) return next
58
+ if (frame.type === 'output_delta') {
59
+ const preview = (session.preview + (frame.content ?? '')).slice(-MAX_PREVIEW_CHARS)
60
+ session = { ...session, phase: session.phase === 'requesting' ? 'streaming' : session.phase, phaseMessage: session.phase === 'requesting' ? '模型正在返回内容' : session.phaseMessage, preview, totalChars: frame.totalChars ?? session.totalChars + (frame.content?.length ?? 0), updatedAt: frame.at }
61
+ next[id] = session
62
+ } else if (frame.type === 'reasoning_delta') {
63
+ session = { ...session, phase: session.phase === 'requesting' ? 'streaming' : session.phase, updatedAt: frame.at }
64
+ next[id] = session
65
+ } else if (frame.type === 'phase_changed') {
66
+ session = { ...session, phase: frame.phase ?? session.phase, phaseMessage: frame.phaseMessage ?? session.phaseMessage, updatedAt: frame.at }
67
+ next[id] = session
68
+ } else if (frame.type === 'session_completed') {
69
+ session = {
70
+ ...session,
71
+ phase: frame.phase ?? 'completed',
72
+ phaseMessage: frame.phase === 'failed' ? (frame.error ?? '调用失败') : '模型结果已准备完成',
73
+ totalChars: frame.totalChars ?? session.totalChars,
74
+ preview: session.preview || (frame.preview ?? ''),
75
+ updatedAt: frame.at,
76
+ usage: frame.usage ?? session.usage,
77
+ elapsedMs: frame.elapsedMs ?? session.elapsedMs,
78
+ firstTokenMs: frame.firstTokenMs ?? session.firstTokenMs,
79
+ }
80
+ next[id] = session
81
+ }
82
+ return next
83
+ }
84
+
85
+ export function useLlmLiveFeed(enabled = true): { connected: boolean; sessions: LlmLiveSession[] } {
86
+ const [sessions, setSessions] = useState<Record<string, LlmLiveSession>>({})
87
+ const [connected, setConnected] = useState(false)
88
+ useEffect(() => {
89
+ if (!enabled) {
90
+ setSessions({})
91
+ setConnected(false)
92
+ return
93
+ }
94
+ const es = new EventSource(NOVEL_API.llmLive)
95
+ es.onopen = () => setConnected(true)
96
+ es.onmessage = (ev) => {
97
+ try {
98
+ const frame = JSON.parse(ev.data as string) as LlmLiveFrame
99
+ setSessions(prev => applyFrame(prev, frame))
100
+ } catch { /* ignore malformed frame */ }
101
+ }
102
+ // EventSource 断线会自动重连;这里只在断线瞬间标记未连接,重连成功后会再次 onopen。
103
+ es.onerror = () => setConnected(false)
104
+ return () => {
105
+ es.close()
106
+ setConnected(false)
107
+ }
108
+ }, [enabled])
109
+ const list = useMemo(() => Object.values(sessions).sort((a, b) => a.startedAt.localeCompare(b.startedAt)), [sessions])
110
+ return { connected, sessions: list }
111
+ }