@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.
- package/README.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/mobile/messages.ts
DELETED
|
@@ -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
|
-
}
|