@arcote.tech/arc-chat 0.5.6 → 0.5.8
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/package.json +6 -6
- package/src/aggregates/message.ts +60 -61
- package/src/chat-builder.ts +12 -2
- package/src/index.ts +1 -6
- package/src/listeners/ai-generation-listener.ts +35 -50
- package/src/react/chat-component.tsx +33 -15
- package/src/streaming/stream-registry.ts +68 -49
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-chat",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.8",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Chat module with AI integration for Arc framework",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
"type-check": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@arcote.tech/arc": "^0.5.
|
|
14
|
-
"@arcote.tech/arc-ai": "^0.5.
|
|
15
|
-
"@arcote.tech/arc-auth": "^0.5.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.5.
|
|
17
|
-
"@arcote.tech/platform": "^0.5.
|
|
13
|
+
"@arcote.tech/arc": "^0.5.8",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.5.8",
|
|
15
|
+
"@arcote.tech/arc-auth": "^0.5.8",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.5.8",
|
|
17
|
+
"@arcote.tech/platform": "^0.5.8",
|
|
18
18
|
"lucide-react": ">=0.400.0",
|
|
19
19
|
"react": ">=18.0.0",
|
|
20
20
|
"typescript": "^5.0.0"
|
|
@@ -107,36 +107,55 @@ export const createMessageAggregate = <
|
|
|
107
107
|
},
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
-
// ───
|
|
111
|
-
// The
|
|
112
|
-
//
|
|
113
|
-
//
|
|
110
|
+
// ─── assistantTurnStarted — new assistant row, isGenerating=true ─
|
|
111
|
+
// Created at the start of each LLM turn. The row exists in DB without
|
|
112
|
+
// blocks; the frontend detects `isGenerating: true` and subscribes to the
|
|
113
|
+
// SSE stream identified by `sessionId`.
|
|
114
114
|
.publicEvent(
|
|
115
|
-
"
|
|
115
|
+
"assistantTurnStarted",
|
|
116
116
|
{
|
|
117
117
|
messageId,
|
|
118
118
|
scopeId,
|
|
119
119
|
sessionId: string(),
|
|
120
|
-
blocks: string(),
|
|
121
120
|
model: string().optional(),
|
|
122
|
-
previousResponseId: string().optional(),
|
|
123
|
-
isGenerating: boolean().optional(),
|
|
124
121
|
},
|
|
125
122
|
async (ctx, event) => {
|
|
126
123
|
const p = event.payload;
|
|
127
124
|
await ctx.set(p.messageId, {
|
|
128
125
|
scopeId: p.scopeId,
|
|
129
126
|
role: "assistant",
|
|
130
|
-
blocks: p.blocks,
|
|
131
127
|
model: p.model,
|
|
132
128
|
sessionId: p.sessionId,
|
|
133
|
-
|
|
134
|
-
isGenerating: p.isGenerating,
|
|
129
|
+
isGenerating: true,
|
|
135
130
|
createdAt: event.createdAt,
|
|
136
131
|
});
|
|
137
132
|
},
|
|
138
133
|
)
|
|
139
134
|
|
|
135
|
+
// ─── assistantTurnCompleted — finalize an in-progress turn row ───
|
|
136
|
+
// Partial update on the SAME row — fills `blocks`, flips
|
|
137
|
+
// `isGenerating` to false, optionally records `previousResponseId`,
|
|
138
|
+
// `usage`, or `error`.
|
|
139
|
+
.publicEvent(
|
|
140
|
+
"assistantTurnCompleted",
|
|
141
|
+
{
|
|
142
|
+
messageId,
|
|
143
|
+
blocks: string(),
|
|
144
|
+
previousResponseId: string().optional(),
|
|
145
|
+
usage: string().optional(),
|
|
146
|
+
error: string().optional(),
|
|
147
|
+
},
|
|
148
|
+
async (ctx, event) => {
|
|
149
|
+
const p = event.payload;
|
|
150
|
+
await ctx.modify(p.messageId, {
|
|
151
|
+
blocks: p.blocks,
|
|
152
|
+
previousResponseId: p.previousResponseId,
|
|
153
|
+
usage: p.usage,
|
|
154
|
+
isGenerating: false,
|
|
155
|
+
} as any);
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
140
159
|
// ─── toolExecuted — server tool returns result ──────────────
|
|
141
160
|
.publicEvent(
|
|
142
161
|
"toolExecuted",
|
|
@@ -188,25 +207,6 @@ export const createMessageAggregate = <
|
|
|
188
207
|
},
|
|
189
208
|
)
|
|
190
209
|
|
|
191
|
-
// ─── generationCompleted — AI loop finished ─────────────────
|
|
192
|
-
.publicEvent(
|
|
193
|
-
"generationCompleted",
|
|
194
|
-
{
|
|
195
|
-
messageId,
|
|
196
|
-
sessionId: string(),
|
|
197
|
-
usage: string().optional(),
|
|
198
|
-
},
|
|
199
|
-
async (ctx, event) => {
|
|
200
|
-
const p = event.payload;
|
|
201
|
-
// PARTIAL update — `ctx.set` replaces the whole row and would null
|
|
202
|
-
// out scopeId/role/blocks. Use `modify` to only flip isGenerating.
|
|
203
|
-
await ctx.modify(p.messageId, {
|
|
204
|
-
isGenerating: false,
|
|
205
|
-
usage: p.usage,
|
|
206
|
-
} as any);
|
|
207
|
-
},
|
|
208
|
-
)
|
|
209
|
-
|
|
210
210
|
// ─── sendMessage — user sends message, creates session ──────
|
|
211
211
|
.mutateMethod(
|
|
212
212
|
"sendMessage",
|
|
@@ -233,36 +233,55 @@ export const createMessageAggregate = <
|
|
|
233
233
|
),
|
|
234
234
|
)
|
|
235
235
|
|
|
236
|
-
// ───
|
|
237
|
-
//
|
|
238
|
-
//
|
|
236
|
+
// ─── startAssistantTurn — open an in-progress assistant row ────
|
|
237
|
+
// Generates a fresh messageId, emits `assistantTurnStarted`. The row
|
|
238
|
+
// exists with `isGenerating: true` and no `blocks` until
|
|
239
|
+
// `completeAssistantTurn` fills them in.
|
|
239
240
|
.mutateMethod(
|
|
240
|
-
"
|
|
241
|
+
"startAssistantTurn",
|
|
241
242
|
(fn) => fn.withParams({
|
|
242
243
|
scopeId,
|
|
243
244
|
sessionId: string(),
|
|
244
|
-
blocks: string(),
|
|
245
245
|
model: string().optional(),
|
|
246
|
-
previousResponseId: string().optional(),
|
|
247
|
-
isGenerating: boolean().optional(),
|
|
248
246
|
}).handle(
|
|
249
247
|
ONLY_SERVER &&
|
|
250
248
|
(async (ctx, params) => {
|
|
251
249
|
const msgId = messageId.generate();
|
|
252
|
-
await ctx.
|
|
250
|
+
await ctx.assistantTurnStarted.emit({
|
|
253
251
|
messageId: msgId,
|
|
254
252
|
scopeId: params.scopeId,
|
|
255
253
|
sessionId: params.sessionId,
|
|
256
|
-
blocks: params.blocks,
|
|
257
254
|
model: params.model,
|
|
258
|
-
previousResponseId: params.previousResponseId,
|
|
259
|
-
isGenerating: params.isGenerating,
|
|
260
255
|
});
|
|
261
256
|
return { messageId: msgId };
|
|
262
257
|
}),
|
|
263
258
|
),
|
|
264
259
|
)
|
|
265
260
|
|
|
261
|
+
// ─── completeAssistantTurn — partial update of the open turn row ─
|
|
262
|
+
.mutateMethod(
|
|
263
|
+
"completeAssistantTurn",
|
|
264
|
+
(fn) => fn.withParams({
|
|
265
|
+
messageId,
|
|
266
|
+
blocks: string(),
|
|
267
|
+
previousResponseId: string().optional(),
|
|
268
|
+
usage: string().optional(),
|
|
269
|
+
error: string().optional(),
|
|
270
|
+
}).handle(
|
|
271
|
+
ONLY_SERVER &&
|
|
272
|
+
(async (ctx, params) => {
|
|
273
|
+
await ctx.assistantTurnCompleted.emit({
|
|
274
|
+
messageId: params.messageId,
|
|
275
|
+
blocks: params.blocks,
|
|
276
|
+
previousResponseId: params.previousResponseId,
|
|
277
|
+
usage: params.usage,
|
|
278
|
+
error: params.error,
|
|
279
|
+
});
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
266
285
|
// ─── saveToolResult — server tool executed ──────────────────
|
|
267
286
|
.mutateMethod(
|
|
268
287
|
"saveToolResult",
|
|
@@ -291,26 +310,6 @@ export const createMessageAggregate = <
|
|
|
291
310
|
),
|
|
292
311
|
)
|
|
293
312
|
|
|
294
|
-
// ─── completeGeneration ─────────────────────────────────────
|
|
295
|
-
.mutateMethod(
|
|
296
|
-
"completeGeneration",
|
|
297
|
-
(fn) => fn.withParams({
|
|
298
|
-
generationMessageId: messageId,
|
|
299
|
-
sessionId: string(),
|
|
300
|
-
usage: string().optional(),
|
|
301
|
-
}).handle(
|
|
302
|
-
ONLY_SERVER &&
|
|
303
|
-
(async (ctx, params) => {
|
|
304
|
-
await ctx.generationCompleted.emit({
|
|
305
|
-
messageId: params.generationMessageId,
|
|
306
|
-
sessionId: params.sessionId,
|
|
307
|
-
usage: params.usage,
|
|
308
|
-
});
|
|
309
|
-
return { ok: true };
|
|
310
|
-
}),
|
|
311
|
-
),
|
|
312
|
-
)
|
|
313
|
-
|
|
314
313
|
// ─── respondToTool — user answers interactive tool ──────────
|
|
315
314
|
.mutateMethod(
|
|
316
315
|
"respondToTool",
|
package/src/chat-builder.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { AccountId, Token } from "@arcote.tech/arc-auth";
|
|
|
12
12
|
import type { AIConfig, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
13
13
|
import { tool as createToolFactory } from "@arcote.tech/arc-ai";
|
|
14
14
|
import type { ArcTokenAny } from "@arcote.tech/arc";
|
|
15
|
-
import type {
|
|
15
|
+
import type { ViewProtectionFn } from "@arcote.tech/arc";
|
|
16
16
|
import { createMessageId, createMessageAggregate } from "./aggregates/message";
|
|
17
17
|
import { createAiGenerationListener, createAiResumeListener } from "./listeners/ai-generation-listener";
|
|
18
18
|
import { createChatStreamRoute } from "./routes/chat-stream-route";
|
|
@@ -105,7 +105,17 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
105
105
|
} as any);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Restrict chat access by token + read filter on token params.
|
|
110
|
+
*
|
|
111
|
+
* The callback receives **raw token params** (not a `TokenInstance`),
|
|
112
|
+
* mirroring `aggregate.protectBy` semantics — the chat's underlying
|
|
113
|
+
* Message aggregate is filtered by the returned WHERE clause. This
|
|
114
|
+
* matches the runtime behaviour in `build()` below where
|
|
115
|
+
* `protectByCheck` is forwarded directly into the message aggregate's
|
|
116
|
+
* view protection.
|
|
117
|
+
*/
|
|
118
|
+
protectBy<T extends ArcTokenAny>(token: T, check: ViewProtectionFn<T>) {
|
|
109
119
|
return new ArcChat<Merge<Data, { protectBy: T; protectByCheck: typeof check }>>({
|
|
110
120
|
...this.data,
|
|
111
121
|
protectBy: token,
|
package/src/index.ts
CHANGED
|
@@ -7,12 +7,7 @@ export { createMessageAggregate, createMessageId } from "./aggregates/message";
|
|
|
7
7
|
export type { MessageAggregate, MessageId } from "./aggregates/message";
|
|
8
8
|
|
|
9
9
|
// --- Streaming ---
|
|
10
|
-
export {
|
|
11
|
-
createStreamSession,
|
|
12
|
-
getStreamSession,
|
|
13
|
-
deleteStreamSession,
|
|
14
|
-
} from "./streaming/stream-registry";
|
|
15
|
-
export type { StreamSession } from "./streaming/stream-registry";
|
|
10
|
+
export { broadcast, endStream, hasActiveStream, subscribe } from "./streaming/stream-registry";
|
|
16
11
|
|
|
17
12
|
// --- Listener ---
|
|
18
13
|
export { createAiGenerationListener } from "./listeners/ai-generation-listener";
|
|
@@ -182,7 +182,6 @@ interface RunLoopConfig {
|
|
|
182
182
|
toolDefs: any[] | undefined;
|
|
183
183
|
serverToolsMap: Map<string, ArcToolAny>;
|
|
184
184
|
interactiveToolNames: Set<string>;
|
|
185
|
-
generationMessageId: string;
|
|
186
185
|
scopeId: string;
|
|
187
186
|
sessionId: string;
|
|
188
187
|
maxExecutionCount: number;
|
|
@@ -199,7 +198,6 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
199
198
|
toolDefs,
|
|
200
199
|
serverToolsMap,
|
|
201
200
|
interactiveToolNames,
|
|
202
|
-
generationMessageId,
|
|
203
201
|
scopeId,
|
|
204
202
|
sessionId,
|
|
205
203
|
maxExecutionCount,
|
|
@@ -210,6 +208,11 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
210
208
|
let history = config.history;
|
|
211
209
|
let newTurnsStartIdx = config.initialNewTurnsStartIdx;
|
|
212
210
|
let executionCount = 0;
|
|
211
|
+
/** The in-progress assistant row for the CURRENT iteration. Set at the top
|
|
212
|
+
* of every iteration via `startAssistantTurn`; closed at the bottom via
|
|
213
|
+
* `completeAssistantTurn`. The error handler uses it to mark the open turn
|
|
214
|
+
* as failed. */
|
|
215
|
+
let currentTurnId: string | undefined;
|
|
213
216
|
|
|
214
217
|
try {
|
|
215
218
|
while (executionCount <= maxExecutionCount) {
|
|
@@ -230,6 +233,14 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
230
233
|
newTurnsStartIdx,
|
|
231
234
|
);
|
|
232
235
|
|
|
236
|
+
// Open a new in-progress assistant row before the stream starts. The
|
|
237
|
+
// frontend detects `isGenerating: true` on this row and subscribes to
|
|
238
|
+
// the SSE stream identified by `sessionId`.
|
|
239
|
+
const turnStart = await ctx
|
|
240
|
+
.mutate(messageElement)
|
|
241
|
+
.startAssistantTurn({ scopeId, sessionId, model });
|
|
242
|
+
currentTurnId = turnStart.messageId;
|
|
243
|
+
|
|
233
244
|
const result = await provider.streamComplete(
|
|
234
245
|
{
|
|
235
246
|
model,
|
|
@@ -255,17 +266,6 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
255
266
|
},
|
|
256
267
|
);
|
|
257
268
|
|
|
258
|
-
// Persist this turn's assistant blocks as a single message row.
|
|
259
|
-
if (result.blocks.length > 0) {
|
|
260
|
-
await ctx.mutate(messageElement).saveAssistantMessage({
|
|
261
|
-
scopeId,
|
|
262
|
-
sessionId,
|
|
263
|
-
blocks: JSON.stringify(result.blocks),
|
|
264
|
-
model,
|
|
265
|
-
previousResponseId: result.responseId,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
269
|
// Append to local history so the next iteration sees this turn.
|
|
270
270
|
const assistantTurn: ConversationTurn = {
|
|
271
271
|
role: "assistant",
|
|
@@ -285,12 +285,18 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
285
285
|
const hasToolCalls =
|
|
286
286
|
result.finishReason === "tool_call" && toolCalls.length > 0;
|
|
287
287
|
|
|
288
|
+
// Close the turn row — same row that was opened above. The final turn
|
|
289
|
+
// (no tool calls) carries the usage; intermediate turns carry only the
|
|
290
|
+
// blocks + responseId.
|
|
291
|
+
await ctx.mutate(messageElement).completeAssistantTurn({
|
|
292
|
+
messageId: currentTurnId!,
|
|
293
|
+
blocks: JSON.stringify(result.blocks),
|
|
294
|
+
previousResponseId: result.responseId,
|
|
295
|
+
usage: hasToolCalls ? undefined : JSON.stringify(result.usage),
|
|
296
|
+
});
|
|
297
|
+
currentTurnId = undefined;
|
|
298
|
+
|
|
288
299
|
if (!hasToolCalls) {
|
|
289
|
-
await ctx.mutate(messageElement).completeGeneration({
|
|
290
|
-
generationMessageId,
|
|
291
|
-
sessionId,
|
|
292
|
-
usage: JSON.stringify(result.usage),
|
|
293
|
-
});
|
|
294
300
|
broadcast(sessionId, {
|
|
295
301
|
type: "done",
|
|
296
302
|
sessionId,
|
|
@@ -390,18 +396,22 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
390
396
|
executionCount++;
|
|
391
397
|
}
|
|
392
398
|
} catch (err) {
|
|
399
|
+
const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
|
|
393
400
|
broadcast(sessionId, {
|
|
394
401
|
type: "error",
|
|
395
402
|
sessionId,
|
|
396
|
-
error:
|
|
403
|
+
error: errorMsg,
|
|
397
404
|
executionCount,
|
|
398
405
|
});
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
406
|
+
if (currentTurnId) {
|
|
407
|
+
try {
|
|
408
|
+
await ctx.mutate(messageElement).completeAssistantTurn({
|
|
409
|
+
messageId: currentTurnId,
|
|
410
|
+
blocks: "[]",
|
|
411
|
+
error: errorMsg,
|
|
412
|
+
});
|
|
413
|
+
} catch {}
|
|
414
|
+
}
|
|
405
415
|
endStream(sessionId);
|
|
406
416
|
}
|
|
407
417
|
}
|
|
@@ -458,18 +468,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
458
468
|
const newTurnsStartIdx = history.length;
|
|
459
469
|
history.push({ role: "user", content: userContent });
|
|
460
470
|
|
|
461
|
-
// Placeholder assistant message so the UI can render "AI is typing".
|
|
462
|
-
// Empty blocks; the real one is saved by the loop after streaming.
|
|
463
|
-
const generationResult = await ctx
|
|
464
|
-
.mutate(messageElement)
|
|
465
|
-
.saveAssistantMessage({
|
|
466
|
-
scopeId,
|
|
467
|
-
sessionId,
|
|
468
|
-
blocks: "[]",
|
|
469
|
-
model,
|
|
470
|
-
isGenerating: true,
|
|
471
|
-
});
|
|
472
|
-
|
|
473
471
|
await runGenerationLoop({
|
|
474
472
|
ctx,
|
|
475
473
|
messageElement,
|
|
@@ -480,7 +478,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
480
478
|
toolDefs,
|
|
481
479
|
serverToolsMap,
|
|
482
480
|
interactiveToolNames,
|
|
483
|
-
generationMessageId: generationResult.messageId,
|
|
484
481
|
scopeId,
|
|
485
482
|
sessionId,
|
|
486
483
|
maxExecutionCount,
|
|
@@ -557,17 +554,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
557
554
|
const provider = resolveProvider(model, scopeId);
|
|
558
555
|
if (!provider) return;
|
|
559
556
|
|
|
560
|
-
// Placeholder assistant message for "AI is typing"
|
|
561
|
-
const generationResult = await ctx
|
|
562
|
-
.mutate(messageElement)
|
|
563
|
-
.saveAssistantMessage({
|
|
564
|
-
scopeId,
|
|
565
|
-
sessionId,
|
|
566
|
-
blocks: "[]",
|
|
567
|
-
model,
|
|
568
|
-
isGenerating: true,
|
|
569
|
-
});
|
|
570
|
-
|
|
571
557
|
void toolName;
|
|
572
558
|
void toolResult;
|
|
573
559
|
|
|
@@ -581,7 +567,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
581
567
|
toolDefs,
|
|
582
568
|
serverToolsMap,
|
|
583
569
|
interactiveToolNames,
|
|
584
|
-
generationMessageId: generationResult.messageId,
|
|
585
570
|
scopeId,
|
|
586
571
|
sessionId,
|
|
587
572
|
maxExecutionCount,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
|
|
2
2
|
import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
3
3
|
import type { ChatLabels, ChatMessageData, SendMessageOptions } from "@arcote.tech/arc-ds";
|
|
4
4
|
import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
|
|
@@ -53,7 +53,6 @@ export function createChatComponent(
|
|
|
53
53
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
54
54
|
const sessionIdRef = useRef<string | null>(null);
|
|
55
55
|
const currentAssistantIdRef = useRef<string | null>(null);
|
|
56
|
-
const lastHistoryLenRef = useRef(0);
|
|
57
56
|
const resumedSessionRef = useRef<string | null>(null);
|
|
58
57
|
|
|
59
58
|
const queries = scope.useQuery();
|
|
@@ -68,11 +67,24 @@ export function createChatComponent(
|
|
|
68
67
|
const historyData = historyResult?.[0];
|
|
69
68
|
const historyLen = historyData?.length ?? 0;
|
|
70
69
|
|
|
70
|
+
// Stable signature of all messages — `[id]:[isGenerating]:[hasBlocks]:[contentLen]`.
|
|
71
|
+
// Changes on insert AND on partial update (e.g. ctx.modify flipping
|
|
72
|
+
// isGenerating to false), so the timeline-rebuild effect refires for
|
|
73
|
+
// both cases. `historyLen` alone misses updates that don't change count.
|
|
74
|
+
const historySig = useMemo(
|
|
75
|
+
() =>
|
|
76
|
+
historyData
|
|
77
|
+
?.map(
|
|
78
|
+
(m: any) =>
|
|
79
|
+
`${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
|
|
80
|
+
)
|
|
81
|
+
.join("|") ?? "",
|
|
82
|
+
[historyData],
|
|
83
|
+
);
|
|
84
|
+
|
|
71
85
|
// ─── Restore timeline from DB history ───────────────────────
|
|
72
86
|
useEffect(() => {
|
|
73
87
|
if (isStreaming || !historyData || historyLen === 0) return;
|
|
74
|
-
if (historyLen === lastHistoryLenRef.current) return;
|
|
75
|
-
lastHistoryLenRef.current = historyLen;
|
|
76
88
|
|
|
77
89
|
const resultIds = new Set<string>();
|
|
78
90
|
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
@@ -103,22 +115,30 @@ export function createChatComponent(
|
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
if (msg.role === "assistant") {
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
if (msg.isGenerating
|
|
118
|
+
// Open turn (in progress). The row exists with `isGenerating: true`
|
|
119
|
+
// and no blocks; the SSE stream identified by `sessionId` will
|
|
120
|
+
// populate this bubble live. Use msg._id as the bubble id so the
|
|
121
|
+
// next rebuild (after `assistantTurnCompleted` flips the flag and
|
|
122
|
+
// sets blocks) replaces this bubble naturally.
|
|
123
|
+
if (msg.isGenerating === true) {
|
|
112
124
|
if (msg.sessionId) sessionIdRef.current = msg.sessionId;
|
|
113
125
|
hasActiveGeneration = true;
|
|
126
|
+
items.push({
|
|
127
|
+
type: "message",
|
|
128
|
+
id: msg._id,
|
|
129
|
+
role: "assistant",
|
|
130
|
+
content: "",
|
|
131
|
+
isStreaming: true,
|
|
132
|
+
});
|
|
133
|
+
currentAssistantIdRef.current = msg._id;
|
|
114
134
|
continue;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
//
|
|
137
|
+
// Closed turn — render from blocks. Each TextBlock becomes a
|
|
118
138
|
// message item, each ToolCallBlock becomes a tool item paired with
|
|
119
139
|
// its result row.
|
|
120
140
|
const blocks =
|
|
121
|
-
(tryParseJson(
|
|
141
|
+
(tryParseJson(msg.blocks ?? "") as Array<
|
|
122
142
|
| { type: "text"; text: string }
|
|
123
143
|
| {
|
|
124
144
|
type: "tool_call";
|
|
@@ -154,8 +174,6 @@ export function createChatComponent(
|
|
|
154
174
|
}
|
|
155
175
|
blockIdx++;
|
|
156
176
|
}
|
|
157
|
-
|
|
158
|
-
if (msg.isGenerating === true) hasActiveGeneration = true;
|
|
159
177
|
}
|
|
160
178
|
}
|
|
161
179
|
|
|
@@ -163,7 +181,7 @@ export function createChatComponent(
|
|
|
163
181
|
if (!isStreaming && hasActiveGeneration) {
|
|
164
182
|
setIsStreaming(true);
|
|
165
183
|
}
|
|
166
|
-
}, [
|
|
184
|
+
}, [historySig, isStreaming]);
|
|
167
185
|
|
|
168
186
|
// ─── SSE stream consumer ────────────────────────────────────
|
|
169
187
|
// Reusable: handles fetch + read loop + processEvent dispatch.
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import type { ChatStreamEvent } from "@arcote.tech/arc-ai";
|
|
2
2
|
|
|
3
|
-
// ─── ChatStreamManager — per
|
|
3
|
+
// ─── ChatStreamManager — per session SSE registry with replay buffer ───
|
|
4
|
+
//
|
|
5
|
+
// Per-session state:
|
|
6
|
+
// - `streams[sessionId]` — live controllers currently subscribed
|
|
7
|
+
// - `buffers[sessionId]` — every event broadcast since the session started,
|
|
8
|
+
// so a late subscriber (e.g. after a page refresh mid-generation) gets
|
|
9
|
+
// the full prefix replayed before going live
|
|
10
|
+
// - `keepAliveIntervals[sessionId]` — heartbeat ping interval
|
|
4
11
|
|
|
5
12
|
const streams = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();
|
|
13
|
+
const buffers = new Map<string, ChatStreamEvent[]>();
|
|
6
14
|
const keepAliveIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
|
7
15
|
const encoder = new TextEncoder();
|
|
8
16
|
|
|
17
|
+
/** Hard cap on per-session buffer size. Each typical generation produces a
|
|
18
|
+
* few hundred chunks; 5000 is generous but bounds memory if a stream
|
|
19
|
+
* somehow runs without `endStream`. */
|
|
20
|
+
const MAX_BUFFER = 5000;
|
|
21
|
+
|
|
9
22
|
function encode(event: ChatStreamEvent): Uint8Array {
|
|
10
23
|
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
11
24
|
}
|
|
@@ -14,9 +27,18 @@ function encodePing(): Uint8Array {
|
|
|
14
27
|
return encoder.encode(`: ping\n\n`);
|
|
15
28
|
}
|
|
16
29
|
|
|
17
|
-
export function broadcast(
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
export function broadcast(sessionId: string, event: ChatStreamEvent): void {
|
|
31
|
+
// Append to the replay buffer first — even if no client is currently
|
|
32
|
+
// subscribed (initial connect race) the event survives for replay.
|
|
33
|
+
let buf = buffers.get(sessionId);
|
|
34
|
+
if (!buf) {
|
|
35
|
+
buf = [];
|
|
36
|
+
buffers.set(sessionId, buf);
|
|
37
|
+
}
|
|
38
|
+
if (buf.length < MAX_BUFFER) buf.push(event);
|
|
39
|
+
|
|
40
|
+
const controllers = streams.get(sessionId);
|
|
41
|
+
if (!controllers || controllers.size === 0) return;
|
|
20
42
|
const data = encode(event);
|
|
21
43
|
for (const controller of controllers) {
|
|
22
44
|
try {
|
|
@@ -27,42 +49,64 @@ export function broadcast(messageId: string, event: ChatStreamEvent): void {
|
|
|
27
49
|
}
|
|
28
50
|
}
|
|
29
51
|
|
|
30
|
-
export function subscribe(
|
|
52
|
+
export function subscribe(sessionId: string): ReadableStream<Uint8Array> {
|
|
31
53
|
return new ReadableStream<Uint8Array>({
|
|
32
54
|
start(controller) {
|
|
33
|
-
|
|
55
|
+
// Replay any buffered events before going live, so a client that
|
|
56
|
+
// connects mid-stream sees the full prefix.
|
|
57
|
+
const buf = buffers.get(sessionId);
|
|
58
|
+
if (buf) {
|
|
59
|
+
for (const e of buf) {
|
|
60
|
+
try {
|
|
61
|
+
controller.enqueue(encode(e));
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let set = streams.get(sessionId);
|
|
34
69
|
if (!set) {
|
|
35
70
|
set = new Set();
|
|
36
|
-
streams.set(
|
|
71
|
+
streams.set(sessionId, set);
|
|
37
72
|
}
|
|
38
73
|
set.add(controller);
|
|
39
74
|
|
|
40
75
|
// Start keep-alive if not running
|
|
41
|
-
if (!keepAliveIntervals.has(
|
|
76
|
+
if (!keepAliveIntervals.has(sessionId)) {
|
|
42
77
|
const interval = setInterval(() => {
|
|
43
|
-
const s = streams.get(
|
|
78
|
+
const s = streams.get(sessionId);
|
|
44
79
|
if (s && s.size > 0) {
|
|
45
80
|
const ping = encodePing();
|
|
46
81
|
for (const c of s) {
|
|
47
82
|
try { c.enqueue(ping); } catch { s.delete(c); }
|
|
48
83
|
}
|
|
49
|
-
} else {
|
|
50
|
-
|
|
84
|
+
} else if (!buffers.has(sessionId)) {
|
|
85
|
+
// Stream truly inactive: no live clients AND no buffer. Stop
|
|
86
|
+
// pinging. We never proactively drop the buffer here — that
|
|
87
|
+
// happens in `endStream` so a late re-subscribe still gets the
|
|
88
|
+
// full replay.
|
|
89
|
+
cleanup(sessionId);
|
|
51
90
|
}
|
|
52
91
|
}, 5000);
|
|
53
|
-
keepAliveIntervals.set(
|
|
92
|
+
keepAliveIntervals.set(sessionId, interval);
|
|
54
93
|
}
|
|
55
94
|
},
|
|
56
95
|
cancel() {
|
|
57
|
-
// One client disconnected — don't
|
|
96
|
+
// One client disconnected — don't tear down session state. The buffer
|
|
97
|
+
// and other subscribers (if any) remain.
|
|
58
98
|
},
|
|
59
99
|
});
|
|
60
100
|
}
|
|
61
101
|
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
/** Called by the AI generation listener when a turn finishes (success or
|
|
103
|
+
* error). Closes all live SSE streams and drops the replay buffer. After
|
|
104
|
+
* this, a fresh `subscribe(sessionId)` returns an empty stream — the
|
|
105
|
+
* client should fall back to reading the final `blocks` from DB. */
|
|
106
|
+
export function endStream(sessionId: string): void {
|
|
107
|
+
const controllers = streams.get(sessionId);
|
|
64
108
|
if (controllers) {
|
|
65
|
-
const done = encode({ type: "done", sessionId
|
|
109
|
+
const done = encode({ type: "done", sessionId } as any);
|
|
66
110
|
for (const controller of controllers) {
|
|
67
111
|
try {
|
|
68
112
|
controller.enqueue(done);
|
|
@@ -70,45 +114,20 @@ export function endStream(messageId: string): void {
|
|
|
70
114
|
} catch {}
|
|
71
115
|
}
|
|
72
116
|
}
|
|
73
|
-
cleanup(
|
|
117
|
+
cleanup(sessionId);
|
|
74
118
|
}
|
|
75
119
|
|
|
76
|
-
export function hasActiveStream(
|
|
77
|
-
const s = streams.get(
|
|
120
|
+
export function hasActiveStream(sessionId: string): boolean {
|
|
121
|
+
const s = streams.get(sessionId);
|
|
78
122
|
return !!s && s.size > 0;
|
|
79
123
|
}
|
|
80
124
|
|
|
81
|
-
function cleanup(
|
|
82
|
-
const interval = keepAliveIntervals.get(
|
|
125
|
+
function cleanup(sessionId: string): void {
|
|
126
|
+
const interval = keepAliveIntervals.get(sessionId);
|
|
83
127
|
if (interval) {
|
|
84
128
|
clearInterval(interval);
|
|
85
|
-
keepAliveIntervals.delete(
|
|
129
|
+
keepAliveIntervals.delete(sessionId);
|
|
86
130
|
}
|
|
87
|
-
streams.delete(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// ─── Legacy exports (for respondToTool compatibility) ───────────
|
|
91
|
-
// TODO: remove after full migration
|
|
92
|
-
|
|
93
|
-
export interface StreamSession {
|
|
94
|
-
readonly sessionId: string;
|
|
95
|
-
push(event: ChatStreamEvent): void;
|
|
96
|
-
close(): void;
|
|
97
|
-
isClosed(): boolean;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function createStreamSession(sessionId: string): StreamSession {
|
|
101
|
-
let closed = false;
|
|
102
|
-
return {
|
|
103
|
-
sessionId,
|
|
104
|
-
push(event) { broadcast(sessionId, event); },
|
|
105
|
-
close() { closed = true; },
|
|
106
|
-
isClosed() { return closed; },
|
|
107
|
-
};
|
|
131
|
+
streams.delete(sessionId);
|
|
132
|
+
buffers.delete(sessionId);
|
|
108
133
|
}
|
|
109
|
-
|
|
110
|
-
export function getStreamSession(sessionId: string): StreamSession | undefined {
|
|
111
|
-
return undefined;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function deleteStreamSession(sessionId: string): void {}
|