@arcote.tech/arc-chat 0.7.7 → 0.7.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/package.json +7 -6
- package/src/aggregates/message.ts +131 -7
- package/src/chat-builder.ts +8 -1
- package/src/listeners/ai-generation-listener.ts +176 -36
- package/src/react/chat-component.tsx +283 -164
- package/src/routes/chat-stream-route.ts +7 -2
- package/src/streaming/stream-registry.ts +24 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-chat",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.9",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Chat module with AI integration for Arc framework",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
"type-check": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@arcote.tech/arc": "^0.7.
|
|
14
|
-
"@arcote.tech/arc-ai": "^0.7.
|
|
15
|
-
"@arcote.tech/arc-
|
|
16
|
-
"@arcote.tech/arc-
|
|
17
|
-
"@arcote.tech/
|
|
13
|
+
"@arcote.tech/arc": "^0.7.9",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.9",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.9",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.9",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.9",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.9",
|
|
18
19
|
"lucide-react": ">=0.400.0",
|
|
19
20
|
"react": ">=18.0.0",
|
|
20
21
|
"typescript": "^5.0.0"
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
boolean,
|
|
5
5
|
date,
|
|
6
6
|
id,
|
|
7
|
+
number,
|
|
7
8
|
string,
|
|
8
9
|
type ArcId,
|
|
9
10
|
} from "@arcote.tech/arc";
|
|
@@ -78,6 +79,18 @@ export const createMessageAggregate = <
|
|
|
78
79
|
previousResponseId: string().optional(),
|
|
79
80
|
isGenerating: boolean().optional(),
|
|
80
81
|
usage: string().optional(),
|
|
82
|
+
/**
|
|
83
|
+
* Partial snapshot blocks (JSON-serialized AssistantContentBlock[])
|
|
84
|
+
* zapisywane w trakcie streamingu co kilka chunków. Pozwala klientowi
|
|
85
|
+
* po reload przeglądarki przywrócić stan i kontynuować SSE od
|
|
86
|
+
* `partialLastSeq`. Czyszczone po `assistantTurnCompleted`.
|
|
87
|
+
*/
|
|
88
|
+
partialBlocks: string().optional(),
|
|
89
|
+
/**
|
|
90
|
+
* Ostatni seq SSE event'u zaaplikowany do `partialBlocks`. Klient
|
|
91
|
+
* wysyła `?afterSeq=partialLastSeq` przy SSE resume.
|
|
92
|
+
*/
|
|
93
|
+
partialLastSeq: number().optional(),
|
|
81
94
|
createdAt: date(),
|
|
82
95
|
})
|
|
83
96
|
|
|
@@ -92,6 +105,14 @@ export const createMessageAggregate = <
|
|
|
92
105
|
content: string(),
|
|
93
106
|
model: string().optional(),
|
|
94
107
|
isGenerating: boolean().optional(),
|
|
108
|
+
/**
|
|
109
|
+
* Pre-utworzone messageId pustego assistant row'a który zostaje
|
|
110
|
+
* stworzony w tej samej mutacji (atomowo z `messageSent`). Listener
|
|
111
|
+
* AI generation używa go zamiast wołać `startAssistantTurn`. Dzięki
|
|
112
|
+
* temu klient widzi assistant row natychmiast (przez useQuery push),
|
|
113
|
+
* otwiera SSE i streaming jest visible od pierwszego chunka.
|
|
114
|
+
*/
|
|
115
|
+
assistantMessageId: messageId.optional(),
|
|
95
116
|
},
|
|
96
117
|
async (ctx, event) => {
|
|
97
118
|
const p = event.payload;
|
|
@@ -132,10 +153,30 @@ export const createMessageAggregate = <
|
|
|
132
153
|
},
|
|
133
154
|
)
|
|
134
155
|
|
|
156
|
+
// ─── assistantTurnProgressSnapshot — checkpoint w trakcie streamingu ─
|
|
157
|
+
// Listener emituje co N chunków lub T sekund — klient po reload czyta
|
|
158
|
+
// `partialBlocks` + `partialLastSeq` i kontynuuje SSE od miejsca w
|
|
159
|
+
// którym był.
|
|
160
|
+
.publicEvent(
|
|
161
|
+
"assistantTurnProgressSnapshot",
|
|
162
|
+
{
|
|
163
|
+
messageId,
|
|
164
|
+
partialBlocks: string(),
|
|
165
|
+
partialLastSeq: number(),
|
|
166
|
+
},
|
|
167
|
+
async (ctx, event) => {
|
|
168
|
+
const p = event.payload;
|
|
169
|
+
await ctx.modify(p.messageId, {
|
|
170
|
+
partialBlocks: p.partialBlocks,
|
|
171
|
+
partialLastSeq: p.partialLastSeq,
|
|
172
|
+
} as any);
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
135
176
|
// ─── assistantTurnCompleted — finalize an in-progress turn row ───
|
|
136
177
|
// Partial update on the SAME row — fills `blocks`, flips
|
|
137
178
|
// `isGenerating` to false, optionally records `previousResponseId`,
|
|
138
|
-
// `usage`, or `error`.
|
|
179
|
+
// `usage`, or `error`. Czyści `partialBlocks` / `partialLastSeq`.
|
|
139
180
|
.publicEvent(
|
|
140
181
|
"assistantTurnCompleted",
|
|
141
182
|
{
|
|
@@ -152,6 +193,8 @@ export const createMessageAggregate = <
|
|
|
152
193
|
previousResponseId: p.previousResponseId,
|
|
153
194
|
usage: p.usage,
|
|
154
195
|
isGenerating: false,
|
|
196
|
+
partialBlocks: undefined,
|
|
197
|
+
partialLastSeq: undefined,
|
|
155
198
|
} as any);
|
|
156
199
|
},
|
|
157
200
|
)
|
|
@@ -192,6 +235,8 @@ export const createMessageAggregate = <
|
|
|
192
235
|
toolName: string(),
|
|
193
236
|
toolCallId: string(),
|
|
194
237
|
content: string(),
|
|
238
|
+
/** Patrz dokumentacja `messageSent.assistantMessageId`. */
|
|
239
|
+
assistantMessageId: messageId.optional(),
|
|
195
240
|
},
|
|
196
241
|
async (ctx, event) => {
|
|
197
242
|
const p = event.payload;
|
|
@@ -208,6 +253,11 @@ export const createMessageAggregate = <
|
|
|
208
253
|
)
|
|
209
254
|
|
|
210
255
|
// ─── sendMessage — user sends message, creates session ──────
|
|
256
|
+
// Emit'uje DWA eventy w jednej transakcji: messageSent (user row) +
|
|
257
|
+
// assistantTurnStarted (empty assistant row z isGenerating=true). Dzięki
|
|
258
|
+
// temu klient widzi placeholder asystenta natychmiast (przez useQuery
|
|
259
|
+
// push) i otwiera SSE zanim AI listener zacznie emit chunków → streaming
|
|
260
|
+
// od pierwszego znaku visible.
|
|
211
261
|
.mutateMethod(
|
|
212
262
|
"sendMessage",
|
|
213
263
|
(fn) => fn.withParams({
|
|
@@ -218,8 +268,23 @@ export const createMessageAggregate = <
|
|
|
218
268
|
ONLY_SERVER &&
|
|
219
269
|
(async (ctx, params) => {
|
|
220
270
|
const userMsgId = messageId.generate();
|
|
271
|
+
const assistantMsgId = messageId.generate();
|
|
221
272
|
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
222
273
|
|
|
274
|
+
// KOLEJNOŚĆ EMIT WAŻNA: assistantTurnStarted PRZED messageSent.
|
|
275
|
+
// aiGenerationListener listens to `messageSent` (async). Async
|
|
276
|
+
// listeners w arc fire'ują się "synchronicznie" w trakcie publish
|
|
277
|
+
// (handler startuje, suspendsna pierwszym await). Gdybyśmy emit'owali
|
|
278
|
+
// messageSent jako pierwsze, listener mógłby zacząć pracować zanim
|
|
279
|
+
// assistantTurnStarted skomituje assistant row do DB → listener
|
|
280
|
+
// tries to use messageId którego nie ma jeszcze w stores.
|
|
281
|
+
await ctx.assistantTurnStarted.emit({
|
|
282
|
+
messageId: assistantMsgId,
|
|
283
|
+
scopeId: params.scopeId,
|
|
284
|
+
sessionId,
|
|
285
|
+
model: params.model,
|
|
286
|
+
});
|
|
287
|
+
|
|
223
288
|
await ctx.messageSent.emit({
|
|
224
289
|
messageId: userMsgId,
|
|
225
290
|
scopeId: params.scopeId,
|
|
@@ -227,8 +292,10 @@ export const createMessageAggregate = <
|
|
|
227
292
|
role: "user",
|
|
228
293
|
content: params.content,
|
|
229
294
|
model: params.model,
|
|
295
|
+
assistantMessageId: assistantMsgId,
|
|
230
296
|
});
|
|
231
|
-
|
|
297
|
+
|
|
298
|
+
return { messageId: userMsgId, sessionId, assistantMessageId: assistantMsgId };
|
|
232
299
|
}),
|
|
233
300
|
),
|
|
234
301
|
)
|
|
@@ -258,6 +325,26 @@ export const createMessageAggregate = <
|
|
|
258
325
|
),
|
|
259
326
|
)
|
|
260
327
|
|
|
328
|
+
// ─── saveProgressSnapshot — zapis partial JSON w trakcie streamingu ─
|
|
329
|
+
.mutateMethod(
|
|
330
|
+
"saveProgressSnapshot",
|
|
331
|
+
(fn) => fn.withParams({
|
|
332
|
+
messageId,
|
|
333
|
+
partialBlocks: string(),
|
|
334
|
+
partialLastSeq: number(),
|
|
335
|
+
}).handle(
|
|
336
|
+
ONLY_SERVER &&
|
|
337
|
+
(async (ctx, params) => {
|
|
338
|
+
await ctx.assistantTurnProgressSnapshot.emit({
|
|
339
|
+
messageId: params.messageId,
|
|
340
|
+
partialBlocks: params.partialBlocks,
|
|
341
|
+
partialLastSeq: params.partialLastSeq,
|
|
342
|
+
});
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}),
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
|
|
261
348
|
// ─── completeAssistantTurn — partial update of the open turn row ─
|
|
262
349
|
.mutateMethod(
|
|
263
350
|
"completeAssistantTurn",
|
|
@@ -311,6 +398,9 @@ export const createMessageAggregate = <
|
|
|
311
398
|
)
|
|
312
399
|
|
|
313
400
|
// ─── respondToTool — user answers interactive tool ──────────
|
|
401
|
+
// Patrz `sendMessage` — analogicznie tworzy assistant row w tej samej
|
|
402
|
+
// transakcji, żeby resume listener wypełnił istniejący row a klient
|
|
403
|
+
// widział streaming live.
|
|
314
404
|
.mutateMethod(
|
|
315
405
|
"respondToTool",
|
|
316
406
|
(fn) => fn.withParams({
|
|
@@ -322,8 +412,16 @@ export const createMessageAggregate = <
|
|
|
322
412
|
ONLY_SERVER &&
|
|
323
413
|
(async (ctx, params) => {
|
|
324
414
|
const msgId = messageId.generate();
|
|
415
|
+
const assistantMsgId = messageId.generate();
|
|
325
416
|
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
326
417
|
|
|
418
|
+
// KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
|
|
419
|
+
await ctx.assistantTurnStarted.emit({
|
|
420
|
+
messageId: assistantMsgId,
|
|
421
|
+
scopeId: params.scopeId,
|
|
422
|
+
sessionId,
|
|
423
|
+
});
|
|
424
|
+
|
|
327
425
|
await ctx.userResponded.emit({
|
|
328
426
|
messageId: msgId,
|
|
329
427
|
scopeId: params.scopeId,
|
|
@@ -331,8 +429,10 @@ export const createMessageAggregate = <
|
|
|
331
429
|
toolName: params.toolName,
|
|
332
430
|
toolCallId: params.toolCallId,
|
|
333
431
|
content: params.result,
|
|
432
|
+
assistantMessageId: assistantMsgId,
|
|
334
433
|
});
|
|
335
|
-
|
|
434
|
+
|
|
435
|
+
return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
|
|
336
436
|
}),
|
|
337
437
|
),
|
|
338
438
|
)
|
|
@@ -351,7 +451,17 @@ export const createMessageAggregate = <
|
|
|
351
451
|
ONLY_SERVER &&
|
|
352
452
|
(async (ctx, params) => {
|
|
353
453
|
const msgId = messageId.generate();
|
|
454
|
+
const assistantMsgId = messageId.generate();
|
|
354
455
|
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
456
|
+
const model = params.model ?? "gpt-5";
|
|
457
|
+
|
|
458
|
+
// KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
|
|
459
|
+
await ctx.assistantTurnStarted.emit({
|
|
460
|
+
messageId: assistantMsgId,
|
|
461
|
+
scopeId: params.scopeId,
|
|
462
|
+
sessionId,
|
|
463
|
+
model,
|
|
464
|
+
});
|
|
355
465
|
|
|
356
466
|
await ctx.messageSent.emit({
|
|
357
467
|
messageId: msgId,
|
|
@@ -359,9 +469,11 @@ export const createMessageAggregate = <
|
|
|
359
469
|
sessionId,
|
|
360
470
|
role: "system",
|
|
361
471
|
content: "Rozpocznij ten etap. Przywitaj się i zadaj pierwsze pytanie.",
|
|
362
|
-
model
|
|
472
|
+
model,
|
|
473
|
+
assistantMessageId: assistantMsgId,
|
|
363
474
|
});
|
|
364
|
-
|
|
475
|
+
|
|
476
|
+
return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
|
|
365
477
|
}),
|
|
366
478
|
),
|
|
367
479
|
)
|
|
@@ -382,7 +494,17 @@ export const createMessageAggregate = <
|
|
|
382
494
|
ONLY_SERVER &&
|
|
383
495
|
(async (ctx, params) => {
|
|
384
496
|
const msgId = messageId.generate();
|
|
497
|
+
const assistantMsgId = messageId.generate();
|
|
385
498
|
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
499
|
+
const model = params.model ?? "gpt-5";
|
|
500
|
+
|
|
501
|
+
// KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
|
|
502
|
+
await ctx.assistantTurnStarted.emit({
|
|
503
|
+
messageId: assistantMsgId,
|
|
504
|
+
scopeId: params.scopeId,
|
|
505
|
+
sessionId,
|
|
506
|
+
model,
|
|
507
|
+
});
|
|
386
508
|
|
|
387
509
|
await ctx.messageSent.emit({
|
|
388
510
|
messageId: msgId,
|
|
@@ -390,9 +512,11 @@ export const createMessageAggregate = <
|
|
|
390
512
|
sessionId,
|
|
391
513
|
role: "system",
|
|
392
514
|
content: params.content,
|
|
393
|
-
model
|
|
515
|
+
model,
|
|
516
|
+
assistantMessageId: assistantMsgId,
|
|
394
517
|
});
|
|
395
|
-
|
|
518
|
+
|
|
519
|
+
return { messageId: msgId, sessionId, assistantMessageId: assistantMsgId };
|
|
396
520
|
}),
|
|
397
521
|
),
|
|
398
522
|
)
|
package/src/chat-builder.ts
CHANGED
|
@@ -17,7 +17,7 @@ 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";
|
|
19
19
|
import { createChatComponent } from "./react/chat-component";
|
|
20
|
-
import type { ChatLabels } from "@arcote.tech/arc-ds";
|
|
20
|
+
import type { ChatInputTextareaSlotProps, ChatLabels } from "@arcote.tech/arc-ds";
|
|
21
21
|
import type { ComponentType, ReactNode } from "react";
|
|
22
22
|
|
|
23
23
|
export interface ChatReactComponentOptions {
|
|
@@ -33,6 +33,12 @@ export interface ChatReactComponentOptions {
|
|
|
33
33
|
onClick: () => void;
|
|
34
34
|
disabled: boolean;
|
|
35
35
|
}) => ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Slot na pole tekstowe ChatInput. Pozwala podpiąć `VoiceTextarea` z
|
|
38
|
+
* `@arcote.tech/arc-ai-voice` żeby włączyć dyktowanie głosowe w chacie.
|
|
39
|
+
* Bez tego propsa używany jest domyślny `TextareaField`.
|
|
40
|
+
*/
|
|
41
|
+
renderTextarea?: (props: ChatInputTextareaSlotProps) => ReactNode;
|
|
36
42
|
/** Partial overrides for chat i18n labels. Falls back to English defaults. */
|
|
37
43
|
labels?: Partial<ChatLabels>;
|
|
38
44
|
/**
|
|
@@ -282,6 +288,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
282
288
|
showModelSelector: options.showModelSelector,
|
|
283
289
|
showWebSearch: options.showWebSearch,
|
|
284
290
|
renderSendButton: options.renderSendButton,
|
|
291
|
+
renderTextarea: options.renderTextarea,
|
|
285
292
|
labels: options.labels,
|
|
286
293
|
footer: options.footer,
|
|
287
294
|
});
|
|
@@ -3,6 +3,7 @@ import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech
|
|
|
3
3
|
import type {
|
|
4
4
|
ArcToolAny,
|
|
5
5
|
AssistantContentBlock,
|
|
6
|
+
ChatStreamEvent,
|
|
6
7
|
Conversation,
|
|
7
8
|
ConversationTurn,
|
|
8
9
|
LLMProvider,
|
|
@@ -187,6 +188,13 @@ interface RunLoopConfig {
|
|
|
187
188
|
maxExecutionCount: number;
|
|
188
189
|
toolChoice?: "auto" | "required" | { type: "function"; name: string };
|
|
189
190
|
instruction?: ArcFunction<any>;
|
|
191
|
+
/** ID pustego assistant row'a utworzonego synchronicznie w mutacji
|
|
192
|
+
* triggerującej generację (`sendMessage`/`systemMessage`/`startStage`/
|
|
193
|
+
* `respondToTool`). Listener używa go w PIERWSZEJ iteracji zamiast
|
|
194
|
+
* wołać `startAssistantTurn`. Dzięki temu klient widzi assistant row
|
|
195
|
+
* natychmiast po mutacji i otwiera SSE zanim chunki zaczną lecieć.
|
|
196
|
+
* Następne iteracje (multi-turn po server tool exec) tworzą fresh rows. */
|
|
197
|
+
preCreatedAssistantMessageId?: string;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
async function runGenerationLoop(config: RunLoopConfig) {
|
|
@@ -208,11 +216,34 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
208
216
|
let history = config.history;
|
|
209
217
|
let newTurnsStartIdx = config.initialNewTurnsStartIdx;
|
|
210
218
|
let executionCount = 0;
|
|
211
|
-
/** The in-progress assistant row for the CURRENT iteration.
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
|
|
219
|
+
/** The in-progress assistant row for the CURRENT iteration. Pre-set z
|
|
220
|
+
* `preCreatedAssistantMessageId` dla pierwszej iteracji (atomowo utworzony
|
|
221
|
+
* w mutacji). Wartość `undefined` przy iteracjach 2+ → loop wywoła
|
|
222
|
+
* `startAssistantTurn` jak wcześniej. Closed at the bottom via
|
|
223
|
+
* `completeAssistantTurn`. Error handler używa do mark open turn jako
|
|
224
|
+
* failed. */
|
|
225
|
+
let currentTurnId: string | undefined = config.preCreatedAssistantMessageId;
|
|
226
|
+
/** True gdy w bieżącej iteracji `currentTurnId` był pre-utworzony przez
|
|
227
|
+
* mutację. Wtedy skipujemy ponowne `startAssistantTurn`. */
|
|
228
|
+
let usingPreCreatedTurn = config.preCreatedAssistantMessageId != null;
|
|
229
|
+
/** Monotonicznie rosnący sequence number na całą sesję — klient po stronie
|
|
230
|
+
* React trzyma `lastSeq` i dedupuje. */
|
|
231
|
+
let seqCounter = 0;
|
|
232
|
+
/** Wrapper na broadcast — wstrzykuje seq + messageId (gdy znany). */
|
|
233
|
+
const send = (
|
|
234
|
+
evt: Omit<ChatStreamEvent, "seq" | "sessionId"> & {
|
|
235
|
+
seq?: number;
|
|
236
|
+
sessionId?: string;
|
|
237
|
+
},
|
|
238
|
+
) => {
|
|
239
|
+
seqCounter += 1;
|
|
240
|
+
broadcast(sessionId, {
|
|
241
|
+
...evt,
|
|
242
|
+
sessionId,
|
|
243
|
+
seq: seqCounter,
|
|
244
|
+
messageId: evt.messageId ?? currentTurnId,
|
|
245
|
+
} as ChatStreamEvent);
|
|
246
|
+
};
|
|
216
247
|
|
|
217
248
|
try {
|
|
218
249
|
while (executionCount <= maxExecutionCount) {
|
|
@@ -236,10 +267,50 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
236
267
|
// Open a new in-progress assistant row before the stream starts. The
|
|
237
268
|
// frontend detects `isGenerating: true` on this row and subscribes to
|
|
238
269
|
// the SSE stream identified by `sessionId`.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
270
|
+
//
|
|
271
|
+
// Pierwsza iteracja: row już utworzony w mutacji triggerującej (przez
|
|
272
|
+
// `preCreatedAssistantMessageId`) → skipujemy. Kolejne iteracje
|
|
273
|
+
// (multi-turn po server tool exec): tworzymy fresh row.
|
|
274
|
+
if (usingPreCreatedTurn) {
|
|
275
|
+
usingPreCreatedTurn = false; // tylko dla 1. iteracji
|
|
276
|
+
} else {
|
|
277
|
+
const turnStart = await ctx
|
|
278
|
+
.mutate(messageElement)
|
|
279
|
+
.startAssistantTurn({ scopeId, sessionId, model });
|
|
280
|
+
currentTurnId = turnStart.messageId;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Snapshot policy — co N=20 chunków LUB co T=2s zapisujemy `partialBlocks`
|
|
284
|
+
// do DB. Page reload mid-stream → klient czyta partial + kontynuuje SSE.
|
|
285
|
+
let chunksSinceSnapshot = 0;
|
|
286
|
+
let lastSnapshotAt = Date.now();
|
|
287
|
+
const SNAPSHOT_EVERY_N = 20;
|
|
288
|
+
const SNAPSHOT_EVERY_MS = 2000;
|
|
289
|
+
/** Aktualnie budowane bloki — accumulator dla snapshotu. */
|
|
290
|
+
const liveBlocks: AssistantContentBlock[] = [];
|
|
291
|
+
const liveToolCalls = new Map<
|
|
292
|
+
string,
|
|
293
|
+
{ name: string; argumentsBuffer: string }
|
|
294
|
+
>();
|
|
295
|
+
const maybeSnapshot = async (force = false) => {
|
|
296
|
+
chunksSinceSnapshot += 1;
|
|
297
|
+
const due =
|
|
298
|
+
force ||
|
|
299
|
+
chunksSinceSnapshot >= SNAPSHOT_EVERY_N ||
|
|
300
|
+
Date.now() - lastSnapshotAt >= SNAPSHOT_EVERY_MS;
|
|
301
|
+
if (!due || !currentTurnId) return;
|
|
302
|
+
chunksSinceSnapshot = 0;
|
|
303
|
+
lastSnapshotAt = Date.now();
|
|
304
|
+
try {
|
|
305
|
+
await ctx.mutate(messageElement).saveProgressSnapshot({
|
|
306
|
+
messageId: currentTurnId,
|
|
307
|
+
partialBlocks: JSON.stringify(liveBlocks),
|
|
308
|
+
partialLastSeq: seqCounter,
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
// snapshot best-effort — pojawi się przy kolejnym chunku
|
|
312
|
+
}
|
|
313
|
+
};
|
|
243
314
|
|
|
244
315
|
const result = await provider.streamComplete(
|
|
245
316
|
{
|
|
@@ -248,20 +319,76 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
248
319
|
conversation,
|
|
249
320
|
tools: effectiveToolDefs,
|
|
250
321
|
toolChoice,
|
|
322
|
+
// Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
|
|
323
|
+
// step. Adaptery bez wsparcia ignorują.
|
|
324
|
+
reasoningEffort: "minimal",
|
|
251
325
|
},
|
|
252
326
|
(chunk) => {
|
|
253
|
-
if (chunk.type === "
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
327
|
+
if (chunk.type === "text_delta" && chunk.textDelta) {
|
|
328
|
+
// accumulate w liveBlocks (last text block lub nowy)
|
|
329
|
+
const last = liveBlocks[liveBlocks.length - 1];
|
|
330
|
+
if (last && last.type === "text") {
|
|
331
|
+
last.text += chunk.textDelta;
|
|
332
|
+
} else {
|
|
333
|
+
liveBlocks.push({ type: "text", text: chunk.textDelta });
|
|
334
|
+
}
|
|
335
|
+
send({ type: "text_delta", textDelta: chunk.textDelta });
|
|
336
|
+
void maybeSnapshot();
|
|
337
|
+
} else if (chunk.type === "tool_call_started" && chunk.toolCallId) {
|
|
338
|
+
liveToolCalls.set(chunk.toolCallId, {
|
|
339
|
+
name: chunk.toolCallName ?? "",
|
|
340
|
+
argumentsBuffer: "",
|
|
258
341
|
});
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
342
|
+
liveBlocks.push({
|
|
343
|
+
type: "tool_call",
|
|
344
|
+
id: chunk.toolCallId,
|
|
345
|
+
name: chunk.toolCallName ?? "",
|
|
346
|
+
arguments: {},
|
|
347
|
+
});
|
|
348
|
+
send({
|
|
349
|
+
type: "tool_call_pending",
|
|
350
|
+
toolCallId: chunk.toolCallId,
|
|
351
|
+
toolCallName: chunk.toolCallName,
|
|
352
|
+
});
|
|
353
|
+
void maybeSnapshot(true);
|
|
354
|
+
} else if (
|
|
355
|
+
chunk.type === "tool_call_arguments_delta" &&
|
|
356
|
+
chunk.toolCallId &&
|
|
357
|
+
chunk.argumentsDelta
|
|
358
|
+
) {
|
|
359
|
+
const tc = liveToolCalls.get(chunk.toolCallId);
|
|
360
|
+
if (tc) tc.argumentsBuffer += chunk.argumentsDelta;
|
|
361
|
+
send({
|
|
362
|
+
type: "tool_call_arguments_delta",
|
|
363
|
+
toolCallId: chunk.toolCallId,
|
|
364
|
+
argumentsDelta: chunk.argumentsDelta,
|
|
264
365
|
});
|
|
366
|
+
} else if (
|
|
367
|
+
chunk.type === "tool_call_arguments_complete" &&
|
|
368
|
+
chunk.toolCallId
|
|
369
|
+
) {
|
|
370
|
+
// update accumulated block z complete args
|
|
371
|
+
const args = chunk.arguments ?? {};
|
|
372
|
+
const block = liveBlocks.find(
|
|
373
|
+
(b): b is Extract<AssistantContentBlock, { type: "tool_call" }> =>
|
|
374
|
+
b.type === "tool_call" && b.id === chunk.toolCallId,
|
|
375
|
+
);
|
|
376
|
+
if (block) block.arguments = args;
|
|
377
|
+
// toolCallName z liveBlocks (provider zna nazwę od tool_call_started)
|
|
378
|
+
// — bez tego klient pushuje tool z `toolName: ""` i nie znajduje
|
|
379
|
+
// viewComponent w toolsMap → fallback do generic ChatToolLog
|
|
380
|
+
// ("Wykonuję..."), AskQuestionsView nigdy nie mountuje się.
|
|
381
|
+
const toolCallName =
|
|
382
|
+
liveToolCalls.get(chunk.toolCallId)?.name ?? block?.name;
|
|
383
|
+
send({
|
|
384
|
+
type: "tool_call_arguments_complete",
|
|
385
|
+
toolCallId: chunk.toolCallId,
|
|
386
|
+
toolCallName,
|
|
387
|
+
arguments: args,
|
|
388
|
+
});
|
|
389
|
+
void maybeSnapshot(true);
|
|
390
|
+
} else if (chunk.type === "usage_update") {
|
|
391
|
+
send({ type: "usage_update", usage: chunk.usage });
|
|
265
392
|
}
|
|
266
393
|
},
|
|
267
394
|
);
|
|
@@ -297,12 +424,12 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
297
424
|
currentTurnId = undefined;
|
|
298
425
|
|
|
299
426
|
if (!hasToolCalls) {
|
|
300
|
-
|
|
427
|
+
send({
|
|
301
428
|
type: "done",
|
|
302
|
-
sessionId,
|
|
303
429
|
usage: result.usage,
|
|
304
430
|
finishReason: result.finishReason,
|
|
305
431
|
executionCount,
|
|
432
|
+
lastSeq: seqCounter,
|
|
306
433
|
});
|
|
307
434
|
endStream(sessionId);
|
|
308
435
|
return;
|
|
@@ -316,10 +443,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
316
443
|
// Execute server tools — append each result to history as a separate turn
|
|
317
444
|
const newToolResults: ConversationTurn[] = [];
|
|
318
445
|
for (const tc of serverCalls) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
446
|
+
// `tool_call_pending` poszło już ze streamingu (przy `started`).
|
|
447
|
+
// Teraz `executing` po stronie servera.
|
|
448
|
+
send({
|
|
449
|
+
type: "tool_call_arguments_complete",
|
|
450
|
+
toolCallId: tc.id,
|
|
451
|
+
toolCallName: tc.name,
|
|
452
|
+
arguments: tc.arguments,
|
|
323
453
|
executionCount,
|
|
324
454
|
});
|
|
325
455
|
|
|
@@ -352,10 +482,10 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
352
482
|
isError,
|
|
353
483
|
});
|
|
354
484
|
|
|
355
|
-
|
|
356
|
-
type: "
|
|
357
|
-
|
|
358
|
-
|
|
485
|
+
send({
|
|
486
|
+
type: "tool_call_executed",
|
|
487
|
+
toolCallId: tc.id,
|
|
488
|
+
toolCallName: tc.name,
|
|
359
489
|
toolResult: {
|
|
360
490
|
toolCallId: tc.id,
|
|
361
491
|
name: tc.name,
|
|
@@ -378,9 +508,8 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
378
508
|
// The assistant turn (with the interactive tool_call) is already
|
|
379
509
|
// persisted above. Listener B will resume.
|
|
380
510
|
if (interactiveCalls.length > 0) {
|
|
381
|
-
|
|
511
|
+
send({
|
|
382
512
|
type: "interactive_tool_request",
|
|
383
|
-
sessionId,
|
|
384
513
|
toolCalls: interactiveCalls,
|
|
385
514
|
executionCount,
|
|
386
515
|
});
|
|
@@ -397,9 +526,8 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
397
526
|
}
|
|
398
527
|
} catch (err) {
|
|
399
528
|
const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
|
|
400
|
-
|
|
529
|
+
send({
|
|
401
530
|
type: "error",
|
|
402
|
-
sessionId,
|
|
403
531
|
error: errorMsg,
|
|
404
532
|
executionCount,
|
|
405
533
|
});
|
|
@@ -451,9 +579,11 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
451
579
|
scopeId,
|
|
452
580
|
content: userContent,
|
|
453
581
|
model: modelName,
|
|
454
|
-
|
|
582
|
+
role,
|
|
583
|
+
assistantMessageId,
|
|
584
|
+
} = event.payload as any;
|
|
455
585
|
|
|
456
|
-
const model = modelName ?? "gpt-5
|
|
586
|
+
const model = modelName ?? "gpt-5";
|
|
457
587
|
const provider = resolveProvider(model, scopeId);
|
|
458
588
|
if (!provider) return;
|
|
459
589
|
|
|
@@ -483,6 +613,12 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
483
613
|
maxExecutionCount,
|
|
484
614
|
toolChoice: config.toolChoice,
|
|
485
615
|
instruction,
|
|
616
|
+
// Pre-utworzony empty assistant row z mutacji `sendMessage`/
|
|
617
|
+
// `systemMessage`/`startStage` — pierwsza iteracja używa go zamiast
|
|
618
|
+
// wołać `startAssistantTurn`.
|
|
619
|
+
preCreatedAssistantMessageId: (
|
|
620
|
+
event.payload as { assistantMessageId?: string }
|
|
621
|
+
).assistantMessageId,
|
|
486
622
|
});
|
|
487
623
|
});
|
|
488
624
|
}
|
|
@@ -549,7 +685,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
549
685
|
const lastAssistantRow = [...dbMessages]
|
|
550
686
|
.reverse()
|
|
551
687
|
.find((m: any) => m.role === "assistant" && m.model);
|
|
552
|
-
const model = lastAssistantRow?.model ?? "gpt-5
|
|
688
|
+
const model = lastAssistantRow?.model ?? "gpt-5";
|
|
553
689
|
|
|
554
690
|
const provider = resolveProvider(model, scopeId);
|
|
555
691
|
if (!provider) return;
|
|
@@ -572,6 +708,10 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
572
708
|
maxExecutionCount,
|
|
573
709
|
toolChoice: config.toolChoice,
|
|
574
710
|
instruction,
|
|
711
|
+
// Pre-utworzony empty assistant row z mutacji `respondToTool`.
|
|
712
|
+
preCreatedAssistantMessageId: (
|
|
713
|
+
event.payload as { assistantMessageId?: string }
|
|
714
|
+
).assistantMessageId,
|
|
575
715
|
});
|
|
576
716
|
});
|
|
577
717
|
}
|