@flamingo-stack/openframe-frontend-core 0.0.194 → 0.0.195-snapshot.20260519151313
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/dist/{chunk-PJ5KFD2V.js → chunk-4ML3NA2L.js} +75 -1
- package/dist/{chunk-PJ5KFD2V.js.map → chunk-4ML3NA2L.js.map} +1 -1
- package/dist/{chunk-C5VTN2SB.js → chunk-BXPH5SOL.js} +748 -359
- package/dist/chunk-BXPH5SOL.js.map +1 -0
- package/dist/{chunk-56JS47IQ.cjs → chunk-E6AWU7EI.cjs} +794 -405
- package/dist/chunk-E6AWU7EI.cjs.map +1 -0
- package/dist/{chunk-CVMSC7M4.cjs → chunk-OII2IERE.cjs} +77 -3
- package/dist/chunk-OII2IERE.cjs.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +1 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +15 -0
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +43 -0
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +4 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -0
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/index.cjs +5 -3
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +4 -2
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/ui/index.cjs +5 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/dist/nats/index.cjs +73 -0
- package/dist/nats/index.cjs.map +1 -1
- package/dist/nats/index.js +73 -0
- package/dist/nats/index.js.map +1 -1
- package/dist/nats/nats.d.ts +22 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +474 -0
- package/src/components/chat/types/api.types.ts +45 -0
- package/src/components/chat/types/network.types.ts +4 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +28 -0
- package/src/components/chat/utils/process-historical-messages.ts +23 -5
- package/src/nats/nats.ts +117 -0
- package/dist/chunk-56JS47IQ.cjs.map +0 -1
- package/dist/chunk-C5VTN2SB.js.map +0 -1
- package/dist/chunk-CVMSC7M4.cjs.map +0 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
createNatsClient,
|
|
6
|
+
type JetStreamSubscriptionHandle,
|
|
7
|
+
type NatsClient,
|
|
8
|
+
} from '../../../nats'
|
|
9
|
+
import {
|
|
10
|
+
type UseJetStreamDialogSubscriptionOptions,
|
|
11
|
+
type UseJetStreamDialogSubscriptionReturn,
|
|
12
|
+
NETWORK_CONFIG,
|
|
13
|
+
} from '../types'
|
|
14
|
+
|
|
15
|
+
type SharedConnection = {
|
|
16
|
+
wsUrl: string
|
|
17
|
+
client: NatsClient
|
|
18
|
+
connectPromise: Promise<void> | null
|
|
19
|
+
refCount: number
|
|
20
|
+
closeTimer: ReturnType<typeof setTimeout> | null
|
|
21
|
+
retryTimer: ReturnType<typeof setTimeout> | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let shared: SharedConnection | null = null
|
|
25
|
+
|
|
26
|
+
const DEFAULT_INACTIVE_THRESHOLD_MS = 5 * 60_000
|
|
27
|
+
const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subscribe to a chat dialog stream via a JetStream **ephemeral OrderedConsumer**.
|
|
31
|
+
*
|
|
32
|
+
* - Subject: `chat.{dialogId}.{topic}`
|
|
33
|
+
* - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
|
|
34
|
+
* (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
|
|
35
|
+
* (`DeliverPolicy.New`).
|
|
36
|
+
* - On reconnect, the consumer is recreated starting from the highest stream
|
|
37
|
+
* sequence we've already observed + 1, so no chunk is replayed or skipped.
|
|
38
|
+
* - Consumer is ephemeral with `AckPolicy.None` and a 5-minute inactivity
|
|
39
|
+
* threshold (overridable via `inactiveThresholdMs`).
|
|
40
|
+
*/
|
|
41
|
+
export function useJetStreamDialogSubscription({
|
|
42
|
+
enabled,
|
|
43
|
+
dialogId,
|
|
44
|
+
streamName = DEFAULT_STREAM_NAME,
|
|
45
|
+
topic,
|
|
46
|
+
optStartSeq,
|
|
47
|
+
onEvent,
|
|
48
|
+
onConnect,
|
|
49
|
+
onDisconnect,
|
|
50
|
+
onSubscribed,
|
|
51
|
+
onBeforeReconnect,
|
|
52
|
+
getNatsWsUrl,
|
|
53
|
+
clientConfig = {},
|
|
54
|
+
reconnectionBackoff,
|
|
55
|
+
inactiveThresholdMs,
|
|
56
|
+
}: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
|
|
57
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
58
|
+
const [isSubscribed, setIsSubscribed] = useState(false)
|
|
59
|
+
const [reconnectionCount, setReconnectionCount] = useState(0)
|
|
60
|
+
const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
|
|
61
|
+
|
|
62
|
+
const clientRef = useRef<NatsClient | null>(null)
|
|
63
|
+
const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
|
|
64
|
+
const highestStreamSeqRef = useRef<number | null>(null)
|
|
65
|
+
|
|
66
|
+
const onEventRef = useRef(onEvent)
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onEventRef.current = onEvent
|
|
69
|
+
}, [onEvent])
|
|
70
|
+
|
|
71
|
+
const onConnectRef = useRef(onConnect)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
onConnectRef.current = onConnect
|
|
74
|
+
}, [onConnect])
|
|
75
|
+
|
|
76
|
+
const onDisconnectRef = useRef(onDisconnect)
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
onDisconnectRef.current = onDisconnect
|
|
79
|
+
}, [onDisconnect])
|
|
80
|
+
|
|
81
|
+
const onSubscribedRef = useRef(onSubscribed)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
onSubscribedRef.current = onSubscribed
|
|
84
|
+
}, [onSubscribed])
|
|
85
|
+
|
|
86
|
+
const onBeforeReconnectRef = useRef(onBeforeReconnect)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onBeforeReconnectRef.current = onBeforeReconnect
|
|
89
|
+
}, [onBeforeReconnect])
|
|
90
|
+
|
|
91
|
+
const getNatsWsUrlRef = useRef(getNatsWsUrl)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
getNatsWsUrlRef.current = getNatsWsUrl
|
|
94
|
+
}, [getNatsWsUrl])
|
|
95
|
+
|
|
96
|
+
const reconnectionBackoffRef = useRef(reconnectionBackoff)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
reconnectionBackoffRef.current = reconnectionBackoff
|
|
99
|
+
}, [reconnectionBackoff])
|
|
100
|
+
|
|
101
|
+
const optStartSeqRef = useRef<number | null | undefined>(optStartSeq)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
optStartSeqRef.current = optStartSeq
|
|
104
|
+
}, [optStartSeq])
|
|
105
|
+
|
|
106
|
+
const inactiveThresholdRef = useRef<number | undefined>(inactiveThresholdMs)
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
inactiveThresholdRef.current = inactiveThresholdMs
|
|
109
|
+
}, [inactiveThresholdMs])
|
|
110
|
+
|
|
111
|
+
const hadConnectionBeforeRef = useRef(false)
|
|
112
|
+
|
|
113
|
+
const acquireClient = useCallback(
|
|
114
|
+
(url: string): SharedConnection => {
|
|
115
|
+
if (shared?.wsUrl !== url) {
|
|
116
|
+
if (shared) {
|
|
117
|
+
if (shared.closeTimer) clearTimeout(shared.closeTimer)
|
|
118
|
+
const old = shared
|
|
119
|
+
shared = null
|
|
120
|
+
void old.client.close().catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { name = 'openframe-frontend-jetstream', user = 'machine', pass = '' } = clientConfig
|
|
124
|
+
|
|
125
|
+
const client = createNatsClient({
|
|
126
|
+
servers: url,
|
|
127
|
+
name,
|
|
128
|
+
user,
|
|
129
|
+
pass,
|
|
130
|
+
connectTimeoutMs: NETWORK_CONFIG.CONNECT_TIMEOUT_MS,
|
|
131
|
+
reconnect: false,
|
|
132
|
+
pingIntervalMs: NETWORK_CONFIG.PING_INTERVAL_MS,
|
|
133
|
+
maxPingOut: NETWORK_CONFIG.MAX_PING_OUT,
|
|
134
|
+
})
|
|
135
|
+
shared = {
|
|
136
|
+
wsUrl: url,
|
|
137
|
+
client,
|
|
138
|
+
connectPromise: null,
|
|
139
|
+
refCount: 0,
|
|
140
|
+
closeTimer: null,
|
|
141
|
+
retryTimer: null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
shared.refCount += 1
|
|
146
|
+
if (shared.closeTimer) {
|
|
147
|
+
clearTimeout(shared.closeTimer)
|
|
148
|
+
shared.closeTimer = null
|
|
149
|
+
}
|
|
150
|
+
return shared
|
|
151
|
+
},
|
|
152
|
+
[clientConfig],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const releaseClient = useCallback((url: string) => {
|
|
156
|
+
if (!shared || shared.wsUrl !== url) return
|
|
157
|
+
|
|
158
|
+
shared.refCount = Math.max(0, shared.refCount - 1)
|
|
159
|
+
if (shared.refCount > 0) return
|
|
160
|
+
|
|
161
|
+
shared.closeTimer = setTimeout(() => {
|
|
162
|
+
const s = shared
|
|
163
|
+
shared = null
|
|
164
|
+
if (s) {
|
|
165
|
+
if (s.retryTimer) {
|
|
166
|
+
clearTimeout(s.retryTimer)
|
|
167
|
+
s.retryTimer = null
|
|
168
|
+
}
|
|
169
|
+
void s.client.close().catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
}, NETWORK_CONFIG.SHARED_CLOSE_DELAY_MS)
|
|
172
|
+
}, [])
|
|
173
|
+
|
|
174
|
+
const currentWsUrlRef = useRef<string>('')
|
|
175
|
+
|
|
176
|
+
// Connection lifecycle: acquire/release the shared client based on enabled + URL.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const wsUrl = getNatsWsUrl()
|
|
179
|
+
if (!enabled || !wsUrl) {
|
|
180
|
+
if (currentWsUrlRef.current && clientRef.current) {
|
|
181
|
+
releaseClient(currentWsUrlRef.current)
|
|
182
|
+
clientRef.current = null
|
|
183
|
+
currentWsUrlRef.current = ''
|
|
184
|
+
setIsConnected(false)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
wsUrl === currentWsUrlRef.current &&
|
|
191
|
+
clientRef.current &&
|
|
192
|
+
clientRef.current.isConnected()
|
|
193
|
+
) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
currentWsUrlRef.current &&
|
|
199
|
+
currentWsUrlRef.current !== wsUrl &&
|
|
200
|
+
clientRef.current
|
|
201
|
+
) {
|
|
202
|
+
releaseClient(currentWsUrlRef.current)
|
|
203
|
+
clientRef.current = null
|
|
204
|
+
setIsConnected(false)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
currentWsUrlRef.current = wsUrl
|
|
208
|
+
const sharedConn = acquireClient(wsUrl)
|
|
209
|
+
const client = sharedConn.client
|
|
210
|
+
|
|
211
|
+
clientRef.current = client
|
|
212
|
+
setIsConnected(false)
|
|
213
|
+
|
|
214
|
+
let closed = false
|
|
215
|
+
let retryAttempt = 0
|
|
216
|
+
|
|
217
|
+
function scheduleRetry() {
|
|
218
|
+
if (closed) return
|
|
219
|
+
if (shared !== sharedConn) return
|
|
220
|
+
|
|
221
|
+
if (sharedConn.retryTimer) {
|
|
222
|
+
clearTimeout(sharedConn.retryTimer)
|
|
223
|
+
sharedConn.retryTimer = null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cfg = reconnectionBackoffRef.current ?? {}
|
|
227
|
+
const fastRetries = cfg.fastRetries ?? 0
|
|
228
|
+
const fastDelay = cfg.fastRetryDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
|
|
229
|
+
const baseDelay = cfg.initialDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
|
|
230
|
+
const maxDelay = cfg.maxDelayMs ?? NETWORK_CONFIG.RETRY_MAX_DELAY_MS
|
|
231
|
+
const multiplier = cfg.multiplier ?? NETWORK_CONFIG.RETRY_BACKOFF_MULTIPLIER
|
|
232
|
+
|
|
233
|
+
const delay =
|
|
234
|
+
retryAttempt < fastRetries
|
|
235
|
+
? fastDelay
|
|
236
|
+
: Math.min(baseDelay * multiplier ** (retryAttempt - fastRetries), maxDelay)
|
|
237
|
+
const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
|
|
238
|
+
retryAttempt++
|
|
239
|
+
|
|
240
|
+
sharedConn.retryTimer = setTimeout(async () => {
|
|
241
|
+
sharedConn.retryTimer = null
|
|
242
|
+
if (closed) return
|
|
243
|
+
if (shared !== sharedConn) return
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await onBeforeReconnectRef.current?.()
|
|
247
|
+
} catch {
|
|
248
|
+
// Token refresh failed; still try to reconnect.
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (closed) return
|
|
252
|
+
if (shared !== sharedConn) return
|
|
253
|
+
|
|
254
|
+
const freshUrl = getNatsWsUrlRef.current()
|
|
255
|
+
if (freshUrl !== wsUrl) return
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
sharedConn.connectPromise = null
|
|
259
|
+
sharedConn.connectPromise = client.connect()
|
|
260
|
+
await sharedConn.connectPromise
|
|
261
|
+
if (!closed && shared === sharedConn) {
|
|
262
|
+
retryAttempt = 0
|
|
263
|
+
setIsConnected(true)
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
sharedConn.connectPromise = null
|
|
267
|
+
if (!closed && shared === sharedConn) {
|
|
268
|
+
scheduleRetry()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}, jitteredDelay)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const unsubscribeStatus = client.onStatus((event) => {
|
|
275
|
+
const connected = event.status === 'connected'
|
|
276
|
+
// `error` is a protocol-level signal (e.g. -ERR Permissions Violation when
|
|
277
|
+
// CONSUMER.CREATE is denied) that does NOT close the WebSocket. Treating
|
|
278
|
+
// it as a disconnect causes scheduleRetry() to fire on every -ERR, which
|
|
279
|
+
// re-runs onBeforeReconnect (auth refresh / `/api/me`) on a loop. Real
|
|
280
|
+
// transport loss arrives separately as `disconnected` or `closed`.
|
|
281
|
+
const disconnected = event.status === 'closed' || event.status === 'disconnected'
|
|
282
|
+
if (connected) {
|
|
283
|
+
setIsConnected(true)
|
|
284
|
+
if (hadConnectionBeforeRef.current) {
|
|
285
|
+
setReconnectionCount((c) => c + 1)
|
|
286
|
+
}
|
|
287
|
+
hadConnectionBeforeRef.current = true
|
|
288
|
+
retryAttempt = 0
|
|
289
|
+
onConnectRef.current?.()
|
|
290
|
+
}
|
|
291
|
+
if (event.status === 'error') {
|
|
292
|
+
// Subscription-level failures (e.g. consumer.get rejected by JetStream
|
|
293
|
+
// ACLs) already surface to the subscribe effect via the rejected
|
|
294
|
+
// promise; log here for diagnostics and let the existing WS connection
|
|
295
|
+
// keep running.
|
|
296
|
+
console.warn('[JetStream] NATS protocol error:', event.data)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (disconnected) {
|
|
300
|
+
setIsConnected(false)
|
|
301
|
+
setIsSubscribed(false)
|
|
302
|
+
|
|
303
|
+
if (subscriptionRef.current) {
|
|
304
|
+
try {
|
|
305
|
+
subscriptionRef.current.unsubscribe()
|
|
306
|
+
} catch {
|
|
307
|
+
// ignore
|
|
308
|
+
}
|
|
309
|
+
subscriptionRef.current = null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
onDisconnectRef.current?.()
|
|
313
|
+
scheduleRetry()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
;(async () => {
|
|
318
|
+
try {
|
|
319
|
+
sharedConn.connectPromise ||= client.connect()
|
|
320
|
+
await sharedConn.connectPromise
|
|
321
|
+
if (!closed) {
|
|
322
|
+
setIsConnected(true)
|
|
323
|
+
hadConnectionBeforeRef.current = true
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
sharedConn.connectPromise = null
|
|
327
|
+
if (!closed) {
|
|
328
|
+
setIsConnected(false)
|
|
329
|
+
onDisconnectRef.current?.()
|
|
330
|
+
scheduleRetry()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})()
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
closed = true
|
|
337
|
+
setIsConnected(false)
|
|
338
|
+
setIsSubscribed(false)
|
|
339
|
+
unsubscribeStatus()
|
|
340
|
+
|
|
341
|
+
if (sharedConn.retryTimer) {
|
|
342
|
+
clearTimeout(sharedConn.retryTimer)
|
|
343
|
+
sharedConn.retryTimer = null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (subscriptionRef.current) {
|
|
347
|
+
try {
|
|
348
|
+
subscriptionRef.current.unsubscribe()
|
|
349
|
+
} catch {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
subscriptionRef.current = null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (clientRef.current && currentWsUrlRef.current) {
|
|
356
|
+
releaseClient(currentWsUrlRef.current)
|
|
357
|
+
clientRef.current = null
|
|
358
|
+
currentWsUrlRef.current = ''
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, [enabled, getNatsWsUrl, acquireClient, releaseClient])
|
|
362
|
+
|
|
363
|
+
// Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
|
|
364
|
+
// we transition into a connected state for a dialog, and whenever the dialog
|
|
365
|
+
// changes. On reconnect we resume from highestStreamSeq + 1.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!enabled || !dialogId || !isConnected) {
|
|
368
|
+
if (subscriptionRef.current) {
|
|
369
|
+
try {
|
|
370
|
+
subscriptionRef.current.unsubscribe()
|
|
371
|
+
} catch {
|
|
372
|
+
// ignore
|
|
373
|
+
}
|
|
374
|
+
subscriptionRef.current = null
|
|
375
|
+
}
|
|
376
|
+
setIsSubscribed(false)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const client = clientRef.current
|
|
381
|
+
if (!client) return
|
|
382
|
+
|
|
383
|
+
const abortController = new AbortController()
|
|
384
|
+
const decoder = new TextDecoder()
|
|
385
|
+
const filterSubject = `chat.${dialogId}.${topic}`
|
|
386
|
+
|
|
387
|
+
const resumeSeq = highestStreamSeqRef.current
|
|
388
|
+
const initialOptStart = optStartSeqRef.current
|
|
389
|
+
const startSeq =
|
|
390
|
+
resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined
|
|
391
|
+
|
|
392
|
+
let cancelled = false
|
|
393
|
+
|
|
394
|
+
void (async () => {
|
|
395
|
+
try {
|
|
396
|
+
const handle = await client.subscribeJetStreamOrdered(
|
|
397
|
+
(msg) => {
|
|
398
|
+
if (cancelled) return
|
|
399
|
+
const streamSeq = msg.info.streamSequence
|
|
400
|
+
if (typeof streamSeq === 'number') {
|
|
401
|
+
if (
|
|
402
|
+
highestStreamSeqRef.current == null ||
|
|
403
|
+
streamSeq > highestStreamSeqRef.current
|
|
404
|
+
) {
|
|
405
|
+
highestStreamSeqRef.current = streamSeq
|
|
406
|
+
setCurrentStreamSeq(streamSeq)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const cb = onEventRef.current
|
|
410
|
+
if (!cb) return
|
|
411
|
+
try {
|
|
412
|
+
const parsed = JSON.parse(decoder.decode(msg.data)) as Record<string, unknown>
|
|
413
|
+
if (typeof streamSeq === 'number') {
|
|
414
|
+
;(parsed as { streamSeq?: number }).streamSeq = streamSeq
|
|
415
|
+
}
|
|
416
|
+
cb(parsed, topic)
|
|
417
|
+
} catch {
|
|
418
|
+
// Ignore malformed payloads.
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
streamName,
|
|
423
|
+
filterSubject,
|
|
424
|
+
deliverPolicy: startSeq != null ? 'byStartSequence' : 'new',
|
|
425
|
+
optStartSeq: startSeq,
|
|
426
|
+
inactiveThresholdMs: inactiveThresholdRef.current ?? DEFAULT_INACTIVE_THRESHOLD_MS,
|
|
427
|
+
signal: abortController.signal,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (cancelled) {
|
|
432
|
+
try {
|
|
433
|
+
handle.unsubscribe()
|
|
434
|
+
} catch {
|
|
435
|
+
// ignore
|
|
436
|
+
}
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
subscriptionRef.current = handle
|
|
441
|
+
setIsSubscribed(true)
|
|
442
|
+
onSubscribedRef.current?.()
|
|
443
|
+
} catch {
|
|
444
|
+
if (!cancelled) {
|
|
445
|
+
setIsSubscribed(false)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
})()
|
|
449
|
+
|
|
450
|
+
return () => {
|
|
451
|
+
cancelled = true
|
|
452
|
+
abortController.abort()
|
|
453
|
+
if (subscriptionRef.current) {
|
|
454
|
+
try {
|
|
455
|
+
subscriptionRef.current.unsubscribe()
|
|
456
|
+
} catch {
|
|
457
|
+
// ignore
|
|
458
|
+
}
|
|
459
|
+
subscriptionRef.current = null
|
|
460
|
+
}
|
|
461
|
+
setIsSubscribed(false)
|
|
462
|
+
}
|
|
463
|
+
}, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount])
|
|
464
|
+
|
|
465
|
+
// Reset the highest-seen sequence whenever the dialog changes so a new dialog
|
|
466
|
+
// starts from optStartSeq (or DeliverPolicy.New) rather than the previous
|
|
467
|
+
// dialog's offset.
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
highestStreamSeqRef.current = null
|
|
470
|
+
setCurrentStreamSeq(null)
|
|
471
|
+
}, [dialogId])
|
|
472
|
+
|
|
473
|
+
return { isConnected, isSubscribed, reconnectionCount, currentStreamSeq }
|
|
474
|
+
}
|
|
@@ -90,6 +90,51 @@ export interface UseNatsDialogSubscriptionReturn {
|
|
|
90
90
|
reconnectionCount: number
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export interface UseJetStreamDialogSubscriptionOptions {
|
|
94
|
+
enabled: boolean
|
|
95
|
+
dialogId: string | null
|
|
96
|
+
/** JetStream stream name. Default: 'CHAT_CHUNKS'. */
|
|
97
|
+
streamName?: string
|
|
98
|
+
/** Single topic to subscribe to. */
|
|
99
|
+
topic: NatsMessageType
|
|
100
|
+
/**
|
|
101
|
+
* Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
|
|
102
|
+
* DeliverPolicy.New (live tail only).
|
|
103
|
+
*/
|
|
104
|
+
optStartSeq?: number | null
|
|
105
|
+
onEvent?: (payload: unknown, messageType: NatsMessageType) => void
|
|
106
|
+
onConnect?: () => void
|
|
107
|
+
onDisconnect?: () => void
|
|
108
|
+
onSubscribed?: () => void
|
|
109
|
+
/** Called on disconnect, before reconnect attempt. Use to refresh auth. */
|
|
110
|
+
onBeforeReconnect?: () => Promise<void> | void
|
|
111
|
+
/** Build the NATS WebSocket URL (or null when not yet available). */
|
|
112
|
+
getNatsWsUrl: () => string | null
|
|
113
|
+
clientConfig?: {
|
|
114
|
+
name?: string
|
|
115
|
+
user?: string
|
|
116
|
+
pass?: string
|
|
117
|
+
}
|
|
118
|
+
reconnectionBackoff?: {
|
|
119
|
+
fastRetries?: number
|
|
120
|
+
fastRetryDelayMs?: number
|
|
121
|
+
initialDelayMs?: number
|
|
122
|
+
maxDelayMs?: number
|
|
123
|
+
multiplier?: number
|
|
124
|
+
}
|
|
125
|
+
/** Consumer inactivity threshold in ms before NATS auto-cleans it. Default: 5 minutes. */
|
|
126
|
+
inactiveThresholdMs?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface UseJetStreamDialogSubscriptionReturn {
|
|
130
|
+
isConnected: boolean
|
|
131
|
+
isSubscribed: boolean
|
|
132
|
+
/** Incremented each time the underlying NATS connection reconnects. Starts at 0. */
|
|
133
|
+
reconnectionCount: number
|
|
134
|
+
/** Highest JetStream stream sequence observed so far (null before first delivery). */
|
|
135
|
+
currentStreamSeq: number | null
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
export interface SegmentsUpdateMetadata {
|
|
94
139
|
/** Segments should be appended to the last assistant message */
|
|
95
140
|
append?: boolean
|
|
@@ -34,6 +34,10 @@ export type NetworkConfig = typeof NETWORK_CONFIG
|
|
|
34
34
|
|
|
35
35
|
export interface ChunkData {
|
|
36
36
|
sequenceId?: number
|
|
37
|
+
/** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
|
|
38
|
+
streamSeq?: number
|
|
39
|
+
/** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
|
|
40
|
+
streamState?: 'IDLE' | 'STREAMING'
|
|
37
41
|
type: string
|
|
38
42
|
text?: string
|
|
39
43
|
integratedToolType?: string
|
|
@@ -161,6 +161,9 @@ export class MessageSegmentAccumulator {
|
|
|
161
161
|
|
|
162
162
|
const toolKey = execId || `${toolData.integratedToolType}-${toolData.toolFunction}`
|
|
163
163
|
|
|
164
|
+
// A tool only runs after its approval gate was granted. Resolve it here.
|
|
165
|
+
this.resolvePendingApprovalForExecution()
|
|
166
|
+
|
|
164
167
|
if (toolData.type === 'EXECUTING_TOOL') {
|
|
165
168
|
this.executingTools.set(toolKey, {
|
|
166
169
|
integratedToolType: toolData.integratedToolType,
|
|
@@ -239,6 +242,31 @@ export class MessageSegmentAccumulator {
|
|
|
239
242
|
return matched
|
|
240
243
|
}
|
|
241
244
|
|
|
245
|
+
/**
|
|
246
|
+
* A tool only ever runs after its approval gate was granted. The legacy /
|
|
247
|
+
* single `approval_request` segment carries no `toolExecutionRequestId` to
|
|
248
|
+
* correlate with the execution, and an observer (e.g. a technician
|
|
249
|
+
* mirroring the client chat) may never receive an `APPROVAL_RESULT` chunk —
|
|
250
|
+
* only the tool's `EXECUTING_TOOL` / `EXECUTED_TOOL` events. Treat the
|
|
251
|
+
* arrival of a tool execution as implicit approval of the most recent
|
|
252
|
+
* still-pending gate so the card does not stay stuck `pending` in realtime.
|
|
253
|
+
*
|
|
254
|
+
* The agent stays paused while an approval is outstanding, so there is at
|
|
255
|
+
* most one relevant gate; flipping only the latest pending one is safe and
|
|
256
|
+
* monotonic (never downgrades, can't make a correct state wrong — an
|
|
257
|
+
* unapproved tool cannot execute). `approval_batch` is handled separately by
|
|
258
|
+
* `applyExecutionToBatch` and is intentionally left untouched here.
|
|
259
|
+
*/
|
|
260
|
+
private resolvePendingApprovalForExecution(): void {
|
|
261
|
+
for (let i = this.segments.length - 1; i >= 0; i--) {
|
|
262
|
+
const seg = this.segments[i]
|
|
263
|
+
if (seg.type === 'approval_request' && seg.status === 'pending') {
|
|
264
|
+
this.segments[i] = { ...seg, status: 'approved' }
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
242
270
|
/**
|
|
243
271
|
* Track a pending approval request
|
|
244
272
|
*/
|
|
@@ -284,11 +284,29 @@ function processMessageData(
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
} else {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
// The resolution may already be known to the consumer (realtime
|
|
288
|
+
// flipped it and fed it back via `approvalStatuses`) while the
|
|
289
|
+
// matching APPROVAL_RESULT row is absent from the fetched history
|
|
290
|
+
// pages. Without this, the single-approval path tracks it as
|
|
291
|
+
// pending and `flushPendingApprovals()` resurrects it as a stale
|
|
292
|
+
// sticky card on every history re-process. Mirror the batch path
|
|
293
|
+
// and honor `approvalStatuses`.
|
|
294
|
+
const resolvedStatus = approvalStatuses[data.approvalRequestId] as ChatApprovalStatus | undefined
|
|
295
|
+
if (resolvedStatus === 'approved' || resolvedStatus === 'rejected') {
|
|
296
|
+
accumulator.addApprovalRequest(
|
|
297
|
+
data.approvalRequestId,
|
|
298
|
+
data.command || '',
|
|
299
|
+
data.explanation,
|
|
300
|
+
approvalType,
|
|
301
|
+
resolvedStatus,
|
|
302
|
+
)
|
|
303
|
+
} else {
|
|
304
|
+
accumulator.trackApprovalRequest(data.approvalRequestId, {
|
|
305
|
+
command: data.command || '',
|
|
306
|
+
explanation: data.explanation,
|
|
307
|
+
approvalType,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
292
310
|
}
|
|
293
311
|
} else {
|
|
294
312
|
escalatedApprovals?.set(data.approvalRequestId, {
|