@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,685 +0,0 @@
1
- /**
2
- * Message fold: collapse a session event stream into a renderable, ordered
3
- * message list for the mobile surface.
4
- *
5
- * The mobile page is an independent, self-contained bundle, so this module has
6
- * no value imports: it re-declares a local `WireEvent` (a loose envelope with a
7
- * typed `type` and a wide `data`) and folds it into {@link RenderMessage} rows.
8
- * It is a pure, side-effect-free fold — callers hold the rendered list and feed
9
- * the next batch of events in to get the next list. Caller-supplied `existing`
10
- * messages are never mutated: every change builds a fresh message object.
11
- *
12
- * The data shapes follow the host session protocol (see the dsh session
13
- * `types.ts` / `surface.ts` and the llm `types.ts` sources the reference audit
14
- * read):
15
- *
16
- * - `user/message` data = `{ id, role, content: ContentBlock[], source }`
17
- * - `assistant/message` data = `{ turn, step, message: { id, content }, usage? }`
18
- * - `assistant/chunk` data = `{ turn, step, chunk: { type: 'text-delta' | 'reasoning-delta', text } }`
19
- * - `turn/start` data = `{ turn }`
20
- * - `turn/end` data = `{ turn, reason: { kind: 'error' | ... } }`
21
- * - `tool/call` data = `{ turn, step, callId, name, arguments }`
22
- * - `session/end-seed` empty data (skipped)
23
- *
24
- * Assistant content blocks (`text` vs `reasoning`) fold into two separate
25
- * fields — `text` and `reasoning` — so the surface can show reasoning behind
26
- * a collapsed disclosure instead of dumping it into the message body. Tool
27
- * calls accumulate ordered details (`tools`) in addition to the plain
28
- * `toolSummary` name list.
29
- *
30
- * The mobile message-level aliases `message/chunk`, `message/update` and
31
- * `message/delete` are also accepted (assumed shapes documented below).
32
- *
33
- * Design notes:
34
- * - Events are applied in ascending `seq` order.
35
- * - A `seq` watermark is derived from `existing` (the max already-rendered
36
- * message seq). Events whose seq is already at or below the watermark are
37
- * skipped, which makes re-applying the same batch idempotent without
38
- * double-folding streamed chunk text.
39
- * - Create events additionally dedupe by message id, so a repeated
40
- * `user/message` / `assistant/message` replaces in place instead of duplicating.
41
- * - A pending assistant message (alive while `assistant/chunk`-style deltas
42
- * keep arriving, `pending: true`) is finalized by the matching
43
- * `assistant/message` (same id or `(turn, step)`) or closed by `turn/end`.
44
- */
45
- export interface RenderMessage {
46
- /** Stable message identity — the wire id when present, else the event seq. */
47
- readonly id: string
48
- readonly kind: 'user' | 'assistant'
49
- /** The fully folded text (assistant chunks aggregate into their message). */
50
- readonly text: string
51
- /**
52
- * Folded reasoning text, kept separate from `text` so the surface can
53
- * hide it behind a collapsed Think disclosure (web-UI parity).
54
- */
55
- readonly reasoning?: string
56
- /**
57
- * Ordered tool calls of this assistant message, in first-seen order,
58
- * driving the collapsible tool disclosure (name + raw arguments).
59
- */
60
- readonly tools?: ToolCallInfo[]
61
- /** Seq of the latest event that touched this message (used for loadOlder). */
62
- readonly seq: number
63
- /** Epoch ms of the latest touch. */
64
- readonly time: number
65
- /** True while an assistant message is still receiving chunks (not yet closed). */
66
- readonly pending?: boolean
67
- /** Plain-text tool call summary for this assistant message, e.g. "使用 bash / read". */
68
- readonly toolSummary?: string
69
- /** Set when the owning turn ended in an error. */
70
- readonly failed?: boolean
71
- /**
72
- * Token usage reported by the final assistant event. cacheReadTokens and
73
- * cacheWriteTokens are only attached when the wire carried finite values.
74
- */
75
- readonly usage?: {
76
- inputTokens: number
77
- outputTokens: number
78
- cacheReadTokens?: number
79
- cacheWriteTokens?: number
80
- }
81
- /** Context window for the model that produced this message (from request/context). */
82
- readonly contextWindow?: number
83
- /** Wire source.kind of a user message (e.g. plugin or user). */
84
- readonly sourceKind?: string
85
- }
86
-
87
- /** One tool call attached to an assistant message (callId dedupes repeats). */
88
- export interface ToolCallInfo {
89
- /** Tool-call id (synthetic `${name}#${seq}` when the wire omitted it). */
90
- readonly callId: string
91
- /** Tool name, e.g. "bash". */
92
- readonly name: string
93
- /** Raw arguments JSON, when the event carried it. */
94
- readonly arguments?: string
95
- }
96
-
97
- /**
98
- * The session event envelope as the mobile fold sees it. `data` is kept wide
99
- * (unknown) so the fold reads fields defensively; `surfaceOp` / `sourceEventSeqs`
100
- * are envelope metadata unrelated to message rendering and are ignored here.
101
- */
102
- export interface WireEvent {
103
- readonly type: string
104
- readonly seq: number
105
- readonly time: number
106
- readonly data: unknown
107
- readonly sourceEventSeqs?: number[]
108
- readonly surfaceOp?: unknown
109
- readonly ignorable?: true
110
- }
111
-
112
- /** Runtime shape guard for the lossless-JSON `data` of a `WireEvent`. */
113
- function isRecord(value: unknown): value is Record<string, unknown> {
114
- return typeof value === 'object' && value !== null && !Array.isArray(value)
115
- }
116
-
117
- function pickString(value: unknown): string | undefined {
118
- return typeof value === 'string' ? value : undefined
119
- }
120
-
121
- function pickNumber(value: unknown): number | undefined {
122
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined
123
- }
124
-
125
- /** Fallback message id for events without a stable wire id. */
126
- function syntheticId(prefix: string, seq: number): string {
127
- return `${prefix}#${String(seq)}`
128
- }
129
-
130
- /** Concatenate the plain text of every `text` content block. */
131
- function textFromContent(content: unknown): string {
132
- return blocksOfType(content, 'text')
133
- }
134
-
135
- /** Concatenate the plain text of every `reasoning` content block. */
136
- function reasoningFromContent(content: unknown): string {
137
- return blocksOfType(content, 'reasoning')
138
- }
139
-
140
- /** Concatenate the plain text of every content block of one type. */
141
- function blocksOfType(content: unknown, type: string): string {
142
- if (!Array.isArray(content)) return ''
143
- let out = ''
144
- for (const block of content) {
145
- if (!isRecord(block)) continue
146
- if (block['type'] !== type) continue
147
- const text = pickString(block['text'])
148
- if (text !== undefined) out += text
149
- }
150
- return out
151
- }
152
-
153
- /**
154
- * Extract a text-chunk target from `assistant/chunk` or the mobile alias
155
- * `message/chunk`.
156
- *
157
- * - DSH shape: `data.chunk = { type: 'text-delta', text }` keyed by
158
- * `(turn, step)`; the pending message is created/aggregated on the owning
159
- * step and later finalized by the matching `assistant/message`.
160
- * - Mobile shape: `data.text` with an optional `messageId` binding the delta
161
- * to a specific assistant message.
162
- *
163
- * Returns null for non-text chunk variants (usage / finish / block-start).
164
- */
165
- function chunkTarget(data: unknown): { text: string; kind: 'text' | 'reasoning'; id?: string; turn?: number; step?: number } | null {
166
- if (!isRecord(data)) return null
167
- let text: string | undefined
168
- let kind: 'text' | 'reasoning' = 'text'
169
- let idValue: string | undefined
170
- let turn: number | undefined
171
- let step: number | undefined
172
- const chunk = data['chunk']
173
- if (isRecord(chunk)) {
174
- if (chunk['type'] !== 'text-delta' && chunk['type'] !== 'reasoning-delta') return null
175
- text = pickString(chunk['text'])
176
- kind = chunk['type'] === 'reasoning-delta' ? 'reasoning' : 'text'
177
- turn = pickNumber(data['turn'])
178
- step = pickNumber(data['step'])
179
- } else {
180
- text = pickString(data['text'])
181
- kind = pickString(data['kind']) === 'reasoning' ? 'reasoning' : 'text'
182
- idValue = pickString(data['messageId']) ?? pickString(data['id'])
183
- turn = pickNumber(data['turn'])
184
- step = pickNumber(data['step'])
185
- }
186
- if (text === undefined) return null
187
- const result: { text: string; kind: 'text' | 'reasoning'; id?: string; turn?: number; step?: number } = { text, kind }
188
- if (idValue !== undefined) result.id = idValue
189
- if (turn !== undefined) result.turn = turn
190
- if (step !== undefined) result.step = step
191
- return result
192
- }
193
-
194
- /** Mutable fold state; message objects are immutable and swapped on change. */
195
- interface FoldState {
196
- messages: RenderMessage[]
197
- byId: Map<string, RenderMessage>
198
- /** Pending assistant message per `${turn}.${step}`, awaiting finalization. */
199
- pendingByTurnStep: Map<string, RenderMessage>
200
- /** Latest assistant message per `${turn}.${step}` (pending or finalized). */
201
- turnStepMessage: Map<string, RenderMessage>
202
- /** Owning turn per assistant message id (for turn/end targeting). */
203
- messageTurn: Map<string, number>
204
- /** Deduped tool names per assistant message id. */
205
- toolNames: Map<string, Set<string>>
206
- /** Context window for the current model (from request/context). */
207
- contextWindow?: number
208
- /** Highest seq folded so far; the replay/watermark gate. */
209
- maxSeq: number
210
- }
211
-
212
- function createState(existing: readonly RenderMessage[] | undefined): FoldState {
213
- const messages = existing === undefined ? [] : [...existing]
214
- const state: FoldState = {
215
- messages,
216
- byId: new Map(),
217
- pendingByTurnStep: new Map(),
218
- turnStepMessage: new Map(),
219
- messageTurn: new Map(),
220
- toolNames: new Map(),
221
- maxSeq: -1,
222
- }
223
- for (const message of messages) {
224
- if (message.seq > state.maxSeq) state.maxSeq = message.seq
225
- state.byId.set(message.id, message)
226
- if (message.kind !== 'assistant') continue
227
- // Rebuild the (turn, step) and turn index maps lost when `existing` was
228
- // handed back to us as plain rows.
229
- const decoded = decodePendingTurnStep(message.id)
230
- const key = decoded === undefined ? undefined : tsKey(decoded.turn, decoded.step)
231
- if (message.pending === true && key !== undefined) {
232
- state.pendingByTurnStep.set(key, message)
233
- state.turnStepMessage.set(key, message)
234
- }
235
- if (decoded !== undefined) {
236
- state.messageTurn.set(message.id, decoded.turn)
237
- }
238
- }
239
- return state
240
- }
241
-
242
- function tsKey(turn: number | undefined, step: number | undefined): string | undefined {
243
- return turn === undefined || step === undefined ? undefined : `${turn}.${step}`
244
- }
245
-
246
- /**
247
- * Recover the `(turn, step)` a pending assistant message was created under from
248
- * its synthetic id (`assistant,<turn>.<step>#<seq>`), so an incremental fold
249
- * over an `existing` list can re-attach index maps that were lost across calls.
250
- */
251
- function decodePendingTurnStep(id: string): { turn: number; step: number } | undefined {
252
- if (!id.startsWith('assistant,')) return undefined
253
- const rest = id.slice('assistant,'.length)
254
- const hash = rest.indexOf('#')
255
- const tsPart = hash === -1 ? rest : rest.slice(0, hash)
256
- const dot = tsPart.indexOf('.')
257
- if (dot <= 0 || dot === tsPart.length - 1) return undefined
258
- const turn = Number(tsPart.slice(0, dot))
259
- const step = Number(tsPart.slice(dot + 1))
260
- if (!Number.isInteger(turn) || !Number.isInteger(step)) return undefined
261
- return { turn, step }
262
- }
263
-
264
- /**
265
- * Swap in a replacement message object at the old message's position and
266
- * re-index it. Immutable: `next` is a fresh object; the old one is untouched.
267
- */
268
- function replaceMessage(state: FoldState, oldMessage: RenderMessage, next: RenderMessage): void {
269
- const index = state.messages.indexOf(oldMessage)
270
- if (index !== -1) state.messages[index] = next
271
- state.byId.delete(oldMessage.id)
272
- state.byId.set(next.id, next)
273
- }
274
-
275
- /** Bundle the maps keyed per `(turn, step)` over to a newly swapped message. */
276
- function retargetTurnStep(state: FoldState, key: string | undefined, oldMessage: RenderMessage, next: RenderMessage): void {
277
- if (key === undefined) return
278
- if (state.pendingByTurnStep.get(key) === oldMessage) state.pendingByTurnStep.set(key, next)
279
- if (state.turnStepMessage.get(key) === oldMessage) state.turnStepMessage.set(key, next)
280
- }
281
-
282
- /** Fold one event into the working state. Assumes the event passes the watermark. */
283
- function applyEvent(state: FoldState, event: WireEvent): void {
284
- if (event.seq > state.maxSeq) state.maxSeq = event.seq
285
- switch (event.type) {
286
- case 'user/message':
287
- applyUserMessage(state, event)
288
- break
289
- case 'assistant/message':
290
- applyAssistantMessage(state, event)
291
- break
292
- case 'assistant/chunk':
293
- case 'message/chunk':
294
- applyChunk(state, event)
295
- break
296
- case 'message/update':
297
- applyUpdate(state, event)
298
- break
299
- case 'message/delete':
300
- applyDelete(state, event)
301
- break
302
- case 'turn/end':
303
- applyTurnEnd(state, event)
304
- break
305
- case 'tool/call':
306
- applyToolCall(state, event)
307
- break
308
- case 'request/context': {
309
- // Wire shape: { provider, model, contextWindow? }. A present finite
310
- // contextWindow seeds every later assistant message that reports usage.
311
- const data = isRecord(event.data) ? event.data : {}
312
- const window = pickNumber(data['contextWindow'])
313
- if (window !== undefined) state.contextWindow = window
314
- break
315
- }
316
- // turn/start, session/end-seed, and every other/unknown type render nothing.
317
- default:
318
- break
319
- }
320
- }
321
-
322
- function applyUserMessage(state: FoldState, event: WireEvent): void {
323
- const data = isRecord(event.data) ? event.data : {}
324
- const id = pickString(data['id']) ?? syntheticId('user', event.seq)
325
- const text = textFromContent(data['content'])
326
- const source = isRecord(data['source']) ? data['source'] : {}
327
- const sourceKind = pickString(source['kind'])
328
- const existing = state.byId.get(id)
329
- if (existing !== undefined) {
330
- // Idempotent replace (replayed events update in place, never duplicate).
331
- replaceMessage(state, existing, {
332
- ...existing,
333
- ...(sourceKind !== undefined ? { sourceKind } : {}),
334
- text,
335
- seq: event.seq,
336
- time: event.time,
337
- })
338
- return
339
- }
340
- const message: RenderMessage = {
341
- id,
342
- kind: 'user',
343
- text,
344
- ...(sourceKind !== undefined ? { sourceKind } : {}),
345
- seq: event.seq,
346
- time: event.time,
347
- }
348
- state.messages.push(message)
349
- state.byId.set(id, message)
350
- }
351
-
352
- function applyAssistantMessage(state: FoldState, event: WireEvent): void {
353
- const data = isRecord(event.data) ? event.data : {}
354
- const messageData = isRecord(data['message']) ? data['message'] : data
355
- const id = pickString(messageData['id']) ?? pickString(data['id']) ?? syntheticId('assistant', event.seq)
356
- const turn = pickNumber(data['turn'])
357
- const step = pickNumber(data['step'])
358
- const finalText = textFromContent(messageData['content'])
359
- const finalReasoning = reasoningFromContent(messageData['content'])
360
- const key = tsKey(turn, step)
361
- const usage = usageFromData(data)
362
- const contextWindow = state.contextWindow
363
-
364
- // Finalize the matching assistant message (by id, or by turn/step for the
365
- // streaming partial that chunks built before the final event arrived).
366
- let target = state.byId.get(id)
367
- if (target === undefined && key !== undefined) target = state.pendingByTurnStep.get(key)
368
-
369
- if (target !== undefined) {
370
- const next: RenderMessage = {
371
- ...target,
372
- id,
373
- text: finalText,
374
- // The final content block list is authoritative; an adapter that omits
375
- // reasoning from the final message keeps the streamed reasoning text.
376
- ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
377
- ...(usage !== undefined ? { usage } : {}),
378
- ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
379
- seq: event.seq,
380
- time: event.time,
381
- pending: false,
382
- }
383
- replaceMessage(state, target, next)
384
- retargetTurnStep(state, key, target, next)
385
- if (turn !== undefined) state.messageTurn.set(next.id, turn)
386
- return
387
- }
388
-
389
- const message: RenderMessage = {
390
- id,
391
- kind: 'assistant',
392
- text: finalText,
393
- ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
394
- ...(usage !== undefined ? { usage } : {}),
395
- ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
396
- seq: event.seq,
397
- time: event.time,
398
- }
399
- state.messages.push(message)
400
- state.byId.set(id, message)
401
- if (key !== undefined) {
402
- state.pendingByTurnStep.delete(key)
403
- state.turnStepMessage.set(key, message)
404
- }
405
- if (turn !== undefined) state.messageTurn.set(id, turn)
406
- }
407
-
408
- /**
409
- * Extract token usage from an assistant event payload. Only attaches when the
410
- * wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
411
- * included only for finite numbers.
412
- */
413
- function usageFromData(data: Record<string, unknown>): { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined {
414
- const usageData = data['usage']
415
- if (!isRecord(usageData)) return undefined
416
- const inputTokens = pickNumber(usageData['inputTokens'])
417
- const outputTokens = pickNumber(usageData['outputTokens'])
418
- if (inputTokens === undefined || outputTokens === undefined) return undefined
419
- const usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } = { inputTokens, outputTokens }
420
- const cacheReadTokens = pickNumber(usageData['cacheReadTokens'])
421
- const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens'])
422
- if (cacheReadTokens !== undefined) usage.cacheReadTokens = cacheReadTokens
423
- if (cacheWriteTokens !== undefined) usage.cacheWriteTokens = cacheWriteTokens
424
- return usage
425
- }
426
-
427
- function applyChunk(state: FoldState, event: WireEvent): void {
428
- const target = chunkTarget(event.data)
429
- if (target === null) return
430
- const key = tsKey(target.turn, target.step)
431
- let message: RenderMessage | undefined
432
- if (target.id !== undefined) {
433
- message = state.byId.get(target.id)
434
- } else if (key !== undefined) {
435
- message = state.pendingByTurnStep.get(key) ?? state.turnStepMessage.get(key)
436
- }
437
-
438
- if (message !== undefined && message.kind === 'assistant') {
439
- const next: RenderMessage = target.kind === 'reasoning'
440
- ? { ...message, reasoning: (message.reasoning ?? '') + target.text, seq: event.seq, time: event.time }
441
- : { ...message, text: message.text + target.text, seq: event.seq, time: event.time }
442
- replaceMessage(state, message, next)
443
- retargetTurnStep(state, key, message, next)
444
- return
445
- }
446
-
447
- const id = target.id
448
- ?? (key !== undefined ? syntheticId(`assistant,${key}`, event.seq) : syntheticId('assistant', event.seq))
449
- const created: RenderMessage = target.kind === 'reasoning'
450
- ? { id, kind: 'assistant', text: '', reasoning: target.text, seq: event.seq, time: event.time, pending: true }
451
- : { id, kind: 'assistant', text: target.text, seq: event.seq, time: event.time, pending: true }
452
- state.messages.push(created)
453
- state.byId.set(id, created)
454
- if (key !== undefined) {
455
- state.pendingByTurnStep.set(key, created)
456
- state.turnStepMessage.set(key, created)
457
- }
458
- if (target.turn !== undefined) state.messageTurn.set(id, target.turn)
459
- }
460
-
461
- function findByIdOrSeq(state: FoldState, event: WireEvent): RenderMessage | undefined {
462
- const data = isRecord(event.data) ? event.data : {}
463
- const id = pickString(data['id'])
464
- if (id !== undefined) {
465
- const byId = state.byId.get(id)
466
- if (byId !== undefined) return byId
467
- }
468
- const seq = pickNumber(data['seq'] ?? data['messageSeq'])
469
- if (seq !== undefined) {
470
- return state.messages.find(message => message.seq === seq)
471
- }
472
- return undefined
473
- }
474
-
475
- function applyUpdate(state: FoldState, event: WireEvent): void {
476
- const message = findByIdOrSeq(state, event)
477
- if (message === undefined) return
478
- const data = isRecord(event.data) ? event.data : {}
479
- const text = pickString(data['text'])
480
- const next: RenderMessage = {
481
- ...message,
482
- ...(text !== undefined ? { text } : {}),
483
- seq: event.seq,
484
- time: event.time,
485
- }
486
- replaceMessage(state, message, next)
487
- }
488
-
489
- function removeMessage(state: FoldState, message: RenderMessage): void {
490
- const index = state.messages.indexOf(message)
491
- if (index !== -1) state.messages.splice(index, 1)
492
- state.byId.delete(message.id)
493
- state.messageTurn.delete(message.id)
494
- state.toolNames.delete(message.id)
495
- for (const [key, candidate] of state.turnStepMessage) {
496
- if (candidate === message) state.turnStepMessage.delete(key)
497
- }
498
- for (const [key, candidate] of state.pendingByTurnStep) {
499
- if (candidate === message) state.pendingByTurnStep.delete(key)
500
- }
501
- }
502
-
503
- function applyDelete(state: FoldState, event: WireEvent): void {
504
- const message = findByIdOrSeq(state, event)
505
- if (message === undefined) return
506
- removeMessage(state, message)
507
- }
508
-
509
- function applyToolCall(state: FoldState, event: WireEvent): void {
510
- const data = isRecord(event.data) ? event.data : {}
511
- const name = pickString(data['name'])
512
- if (name === undefined) return
513
- const turn = pickNumber(data['turn'])
514
- const step = pickNumber(data['step'])
515
- const key = tsKey(turn, step)
516
-
517
- let target = key === undefined ? undefined : state.turnStepMessage.get(key)
518
- if (target === undefined && turn !== undefined) {
519
- for (let i = state.messages.length - 1; i >= 0; i--) {
520
- const candidate = state.messages[i]
521
- if (candidate !== undefined && candidate.kind === 'assistant' && state.messageTurn.get(candidate.id) === turn) {
522
- target = candidate
523
- break
524
- }
525
- }
526
- }
527
- if (target === undefined) {
528
- for (let i = state.messages.length - 1; i >= 0; i--) {
529
- const candidate = state.messages[i]
530
- if (candidate !== undefined && candidate.kind === 'assistant') {
531
- target = candidate
532
- break
533
- }
534
- }
535
- }
536
- if (target === undefined) return
537
-
538
- const names = state.toolNames.get(target.id) ?? new Set<string>()
539
- const isNewName = !names.has(name)
540
- if (isNewName) {
541
- names.add(name)
542
- state.toolNames.set(target.id, names)
543
- }
544
- const callId = pickString(data['callId']) ?? `${name}#${String(event.seq)}`
545
- const args = pickString(data['arguments'])
546
- const tools = target.tools ?? []
547
- const existingIndex = tools.findIndex(tool => tool.callId === callId)
548
- const isNewCall = existingIndex === -1
549
- const nextTools: ToolCallInfo[] = isNewCall
550
- ? [...tools, { callId, name, ...(args !== undefined ? { arguments: args } : {}) }]
551
- : tools.map((tool, index) => index === existingIndex
552
- ? { ...tool, ...(args !== undefined ? { arguments: args } : {}) }
553
- : tool)
554
- const next: RenderMessage = {
555
- ...target,
556
- ...(isNewName ? { toolSummary: `使用 ${[...names].join(' / ')}` } : {}),
557
- ...(isNewCall || args !== undefined ? { tools: nextTools } : {}),
558
- seq: event.seq,
559
- time: event.time,
560
- }
561
- replaceMessage(state, target, next)
562
- retargetTurnStep(state, key, target, next)
563
- }
564
-
565
- function applyTurnEnd(state: FoldState, event: WireEvent): void {
566
- const data = isRecord(event.data) ? event.data : {}
567
- const turn = pickNumber(data['turn'])
568
- const reason = isRecord(data['reason']) ? data['reason'] : {}
569
- const failed = reason['kind'] === 'error'
570
-
571
- let targets: RenderMessage[]
572
- if (turn !== undefined) {
573
- targets = state.messages.filter(message => message.kind === 'assistant' && state.messageTurn.get(message.id) === turn)
574
- } else {
575
- targets = state.messages.filter(message => message.kind === 'assistant')
576
- }
577
- if (targets.length === 0) {
578
- for (let i = state.messages.length - 1; i >= 0; i--) {
579
- const candidate = state.messages[i]
580
- if (candidate !== undefined && candidate.kind === 'assistant') {
581
- targets = [candidate]
582
- break
583
- }
584
- }
585
- }
586
- for (const message of targets) {
587
- const wasPending = message.pending === true
588
- replaceMessage(state, message, {
589
- ...message,
590
- ...(wasPending ? { pending: false } : {}),
591
- ...(failed ? { failed: true } : {}),
592
- // Preserve each step's own final-event seq. Collapsing every message
593
- // onto turn/end makes same-turn ordering depend on arbitrary ids.
594
- time: event.time,
595
- })
596
- }
597
- }
598
-
599
- /**
600
- * Fold a batch of session events into a renderable message list.
601
- *
602
- * @param events - events to apply, in any order (folded by ascending seq).
603
- * @param existing - the previously rendered list (live-stream incremental tail).
604
- * @returns messages sorted by seq.
605
- */
606
- export function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[] {
607
- return new EventFolder(existing).fold(events)
608
- }
609
-
610
- /**
611
- * Incremental folder for one message stream. Live chat folds one event at a
612
- * time; rebuilding the five index maps by scanning every message per event
613
- * made that path O(n) per event (O(n * events) per turn). A folder keeps the
614
- * indexes alive across folds, applies each event in (near-)O(1) map
615
- * operations, and returns the previous snapshot identity unchanged when
616
- * nothing applied, so React skips the re-render entirely. Replayed events
617
- * are no-ops: the maxSeq watermark advanced by the first application skips
618
- * them, which also makes a double-invoked React state updater harmless.
619
- *
620
- * Cost contract: every fold that applies at least one event rebuilds the
621
- * snapshot as one fresh O(messages) array copy (React needs the new
622
- * identity), and replacement paths locate rows by an O(messages) identity
623
- * scan. Both are amortized by the consumer folding bursts as ONE batch on a
624
- * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
625
- * id-to-index map was measured and deliberately rejected: at realistic
626
- * message counts the Map maintenance cost exceeds the identity-scan cost.
627
- */
628
- export class EventFolder {
629
- private state: FoldState
630
- private snapshotList: RenderMessage[] | undefined
631
-
632
- /** @param initial - seed rows (history tail load); omit for an empty stream. */
633
- constructor(initial?: readonly RenderMessage[]) {
634
- this.state = createState(initial)
635
- }
636
-
637
- /** Fold one batch incrementally; returns the current snapshot list. */
638
- fold(events: readonly WireEvent[]): RenderMessage[] {
639
- const sorted = [...events].sort((a, b) => a.seq - b.seq)
640
- let applied = false
641
- for (const event of sorted) {
642
- if (event.seq <= this.state.maxSeq) continue
643
- applyEvent(this.state, event)
644
- applied = true
645
- }
646
- if (!applied && this.snapshotList !== undefined) return this.snapshotList
647
- this.snapshotList = snapshotOf(this.state)
648
- return this.snapshotList
649
- }
650
-
651
- /** Replace the whole stream (history reload / session switch). */
652
- seed(messages: readonly RenderMessage[]): void {
653
- this.state = createState(messages)
654
- this.snapshotList = undefined
655
- }
656
-
657
- /** Prepend an older history page (exact seam; no overlapping seqs). */
658
- prepend(older: readonly RenderMessage[]): void {
659
- this.state = createState([...older, ...this.state.messages])
660
- this.snapshotList = undefined
661
- }
662
-
663
- /** Current snapshot list; a fresh copy whenever the folder changed. */
664
- snapshot(): RenderMessage[] {
665
- if (this.snapshotList !== undefined) return this.snapshotList
666
- this.snapshotList = snapshotOf(this.state)
667
- return this.snapshotList
668
- }
669
- }
670
-
671
- /** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
672
- function snapshotOf(state: FoldState): RenderMessage[] {
673
- const out = [...state.messages]
674
- let ordered = true
675
- for (let index = 1; index < out.length; index += 1) {
676
- const prev = out[index - 1]!
677
- const current = out[index]!
678
- if (prev.seq > current.seq) {
679
- ordered = false
680
- break
681
- }
682
- }
683
- // Array.sort is stable: equal-seq rows keep their event insertion order.
684
- return ordered ? out : out.sort((a, b) => a.seq - b.seq)
685
- }