@arcote.tech/arc-chat 0.7.12 → 0.7.14
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
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.14",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Chat module with AI integration for Arc framework",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -10,12 +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-ai-voice": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-auth": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
18
|
-
"@arcote.tech/platform": "^0.7.
|
|
13
|
+
"@arcote.tech/arc": "^0.7.14",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.14",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.14",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.14",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.14",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.14",
|
|
19
19
|
"lucide-react": ">=0.400.0",
|
|
20
20
|
"react": ">=18.0.0",
|
|
21
21
|
"typescript": "^5.0.0"
|
|
@@ -100,6 +100,13 @@ export const createMessageAggregate = <
|
|
|
100
100
|
* otwiera SSE i streaming jest visible od pierwszego chunka.
|
|
101
101
|
*/
|
|
102
102
|
assistantMessageId: messageId.optional(),
|
|
103
|
+
/**
|
|
104
|
+
* JSON-encoded `string[]` z fileId'ami arc-files attachmentów do tej
|
|
105
|
+
* konkretnej wiadomości. Listener AI generation ładuje `ArcFile`
|
|
106
|
+
* records i przekazuje je w `CompletionRequest.files`. Nie zapisujemy
|
|
107
|
+
* w aggregate fields — flow jest read-once przez listener z event.
|
|
108
|
+
*/
|
|
109
|
+
attachmentsJson: string().optional(),
|
|
103
110
|
},
|
|
104
111
|
async (ctx, event) => {
|
|
105
112
|
const p = event.payload;
|
|
@@ -253,6 +260,12 @@ export const createMessageAggregate = <
|
|
|
253
260
|
scopeId,
|
|
254
261
|
content: string().minLength(1),
|
|
255
262
|
model: string(),
|
|
263
|
+
/**
|
|
264
|
+
* Opcjonalna JSON-encoded `string[]` z fileId'ami arc-files
|
|
265
|
+
* dołączonymi do user message. Listener AI generation ładuje
|
|
266
|
+
* pełne records po stronie serwera.
|
|
267
|
+
*/
|
|
268
|
+
attachmentsJson: string().optional(),
|
|
256
269
|
}).handle(
|
|
257
270
|
ONLY_SERVER &&
|
|
258
271
|
(async (ctx, params) => {
|
|
@@ -282,6 +295,7 @@ export const createMessageAggregate = <
|
|
|
282
295
|
content: params.content,
|
|
283
296
|
model: params.model,
|
|
284
297
|
assistantMessageId: assistantMsgId,
|
|
298
|
+
attachmentsJson: params.attachmentsJson,
|
|
285
299
|
});
|
|
286
300
|
|
|
287
301
|
return { messageId: userMsgId, sessionId, assistantMessageId: assistantMsgId };
|
package/src/chat-builder.ts
CHANGED
|
@@ -85,6 +85,17 @@ export interface ArcChatData {
|
|
|
85
85
|
toolChoice: "auto" | "required" | { type: "function"; name: string };
|
|
86
86
|
alias: string | null;
|
|
87
87
|
billTo: BillToFn | null;
|
|
88
|
+
/**
|
|
89
|
+
* `arc-files` aggregate dla file attachmentów (PDF/DOCX/...) do user
|
|
90
|
+
* messages. Gdy ustawione, listener AI generation:
|
|
91
|
+
* - ładuje `ArcFile` records po fileId'ach z `attachmentsJson` user msg,
|
|
92
|
+
* - przekazuje je w `CompletionRequest.files`,
|
|
93
|
+
* - po complete cachuje `boundProviderFiles` w aggregate'cie (lazy
|
|
94
|
+
* upload do OpenAI Files API → file_id zapisany w `providerFileIdsJson`).
|
|
95
|
+
*
|
|
96
|
+
* Consumer wstrzykuje przez `.attachFiles({ File })`.
|
|
97
|
+
*/
|
|
98
|
+
attachFiles: { File: any } | null;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
const defaultChatData = {
|
|
@@ -101,6 +112,7 @@ const defaultChatData = {
|
|
|
101
112
|
toolChoice: "auto" as const,
|
|
102
113
|
alias: null,
|
|
103
114
|
billTo: null,
|
|
115
|
+
attachFiles: null,
|
|
104
116
|
} as const satisfies ArcChatData;
|
|
105
117
|
|
|
106
118
|
type DefaultChatData = typeof defaultChatData;
|
|
@@ -237,6 +249,22 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
237
249
|
} as any);
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Włącza obsługę file attachments dla user messages — listener AI
|
|
254
|
+
* generation będzie ładował `ArcFile` records, przekazywał je w
|
|
255
|
+
* `CompletionRequest.files`, i cachował lazy-uploadowane `file_id`'y
|
|
256
|
+
* provider'ów (OpenAI Files API).
|
|
257
|
+
*
|
|
258
|
+
* const files = arcFiles({ name: "ndt", scopeId: workspaceId, ... });
|
|
259
|
+
* chat("identity").attachFiles({ File: files.File }).build();
|
|
260
|
+
*/
|
|
261
|
+
attachFiles(config: { File: any }): ArcChat<Data> {
|
|
262
|
+
return new ArcChat<Data>({
|
|
263
|
+
...this.data,
|
|
264
|
+
attachFiles: config,
|
|
265
|
+
} as any);
|
|
266
|
+
}
|
|
267
|
+
|
|
240
268
|
build() {
|
|
241
269
|
const {
|
|
242
270
|
name,
|
|
@@ -252,6 +280,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
252
280
|
toolChoice,
|
|
253
281
|
alias: aliasOverride,
|
|
254
282
|
billTo,
|
|
283
|
+
attachFiles,
|
|
255
284
|
} = this.data;
|
|
256
285
|
|
|
257
286
|
if (!name) throw new Error("ArcChat: name is required");
|
|
@@ -319,6 +348,54 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
319
348
|
}
|
|
320
349
|
}
|
|
321
350
|
|
|
351
|
+
// Attachments bridge — gdy chat woła `.attachFiles({File})`, dodajemy
|
|
352
|
+
// File aggregate do query/mutation deps i budujemy callbacks używane
|
|
353
|
+
// przez listener do: (a) load ArcFile records dla user attachmentów,
|
|
354
|
+
// (b) cache providerFileId po lazy upload.
|
|
355
|
+
let attachmentsBridge: any;
|
|
356
|
+
if (attachFiles) {
|
|
357
|
+
const FileAgg = attachFiles.File;
|
|
358
|
+
if (!allQueryElements.includes(FileAgg)) allQueryElements.push(FileAgg);
|
|
359
|
+
if (!allMutationElements.includes(FileAgg))
|
|
360
|
+
allMutationElements.push(FileAgg);
|
|
361
|
+
attachmentsBridge = {
|
|
362
|
+
loadAttachments: async (ctx: any, fileIds: string[]) => {
|
|
363
|
+
const refs: any[] = [];
|
|
364
|
+
for (const fileId of fileIds) {
|
|
365
|
+
const rec = await ctx
|
|
366
|
+
.query(FileAgg)
|
|
367
|
+
.getById({ _id: fileId });
|
|
368
|
+
if (!rec) continue;
|
|
369
|
+
let providerFileIds: Record<string, string> = {};
|
|
370
|
+
if (typeof rec.providerFileIdsJson === "string") {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(rec.providerFileIdsJson);
|
|
373
|
+
if (parsed && typeof parsed === "object") providerFileIds = parsed;
|
|
374
|
+
} catch {}
|
|
375
|
+
}
|
|
376
|
+
refs.push({
|
|
377
|
+
fileId,
|
|
378
|
+
name: rec.name,
|
|
379
|
+
mime: rec.mime,
|
|
380
|
+
s3Key: rec.s3Key,
|
|
381
|
+
providerFileIds,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return refs;
|
|
385
|
+
},
|
|
386
|
+
bindProviderFileId: async (
|
|
387
|
+
ctx: any,
|
|
388
|
+
fileId: string,
|
|
389
|
+
provider: string,
|
|
390
|
+
providerFileId: string,
|
|
391
|
+
) => {
|
|
392
|
+
await ctx
|
|
393
|
+
.mutate(FileAgg)
|
|
394
|
+
.bindProviderFileId({ _id: fileId, provider, providerFileId });
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
322
399
|
const listenerConfig = {
|
|
323
400
|
name,
|
|
324
401
|
messageElement: Message,
|
|
@@ -333,6 +410,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
333
410
|
alias: aliasOverride ?? name,
|
|
334
411
|
recordUsage: aiConfig.recordUsage,
|
|
335
412
|
billTo: billTo ?? undefined,
|
|
413
|
+
attachments: attachmentsBridge,
|
|
336
414
|
};
|
|
337
415
|
|
|
338
416
|
const aiListener = createAiGenerationListener(listenerConfig);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/// <reference path="../arc.d.ts" />
|
|
2
2
|
import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech/arc";
|
|
3
3
|
import type {
|
|
4
|
+
ArcFileRef,
|
|
4
5
|
ArcToolAny,
|
|
5
6
|
AssistantContentBlock,
|
|
7
|
+
BoundProviderFile,
|
|
6
8
|
Conversation,
|
|
7
9
|
ConversationTurn,
|
|
8
10
|
LLMProvider,
|
|
@@ -59,6 +61,29 @@ export interface AiGenerationListenerConfig {
|
|
|
59
61
|
* listener can treat the pair as always-present in the call site.
|
|
60
62
|
*/
|
|
61
63
|
billTo?: (tokenParams: Record<string, any>) => string;
|
|
64
|
+
/**
|
|
65
|
+
* Wiązanie z fragmentem `arc-files` — wstrzykiwany przez chat-builder gdy
|
|
66
|
+
* consumer woła `.attachFiles({ File })`.
|
|
67
|
+
*
|
|
68
|
+
* - `loadAttachments(fileIds)` — ładuje `ArcFile` records po fileId'ach
|
|
69
|
+
* (dla `request.files`); zwracamy ArcFileRef z aktualnymi
|
|
70
|
+
* `providerFileIds` z DB.
|
|
71
|
+
* - `bindProviderFileId(fileId, provider, providerFileId)` — wywoływane
|
|
72
|
+
* dla każdego `BoundProviderFile` zwróconego przez adapter (cache
|
|
73
|
+
* lazy uploadu — przy następnej generacji pomijamy upload).
|
|
74
|
+
*/
|
|
75
|
+
attachments?: {
|
|
76
|
+
loadAttachments(
|
|
77
|
+
ctx: any,
|
|
78
|
+
fileIds: string[],
|
|
79
|
+
): Promise<ArcFileRef[]>;
|
|
80
|
+
bindProviderFileId(
|
|
81
|
+
ctx: any,
|
|
82
|
+
fileId: string,
|
|
83
|
+
provider: string,
|
|
84
|
+
providerFileId: string,
|
|
85
|
+
): Promise<void>;
|
|
86
|
+
};
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
// ─── History reconstruction ─────────────────────────────────────
|
|
@@ -233,6 +258,11 @@ interface RunLoopConfig {
|
|
|
233
258
|
recordUsage?: AiGenerationListenerConfig["recordUsage"];
|
|
234
259
|
/** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
|
|
235
260
|
billTo?: AiGenerationListenerConfig["billTo"];
|
|
261
|
+
/** Attachments bridge — see `AiGenerationListenerConfig.attachments`. */
|
|
262
|
+
attachments?: AiGenerationListenerConfig["attachments"];
|
|
263
|
+
/** ArcFile records dla attachmentów ostatniego user msg (resolved przed
|
|
264
|
+
* generation start; reused per loop iteration). */
|
|
265
|
+
initialAttachments?: ArcFileRef[];
|
|
236
266
|
}
|
|
237
267
|
|
|
238
268
|
async function runGenerationLoop(config: RunLoopConfig) {
|
|
@@ -310,6 +340,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
310
340
|
// robi nic.
|
|
311
341
|
startStream(currentTurnId!);
|
|
312
342
|
|
|
343
|
+
// Files lecą TYLKO w pierwszej iteracji. Provider z continuation
|
|
344
|
+
// (OpenAI Responses) trzyma context server-side; provider w full
|
|
345
|
+
// mode (Claude/Gemini) ignoruje files z warningiem — i tak nie
|
|
346
|
+
// ma sensu duplikować attachmentów w każdym tool_result roundzie.
|
|
347
|
+
const filesForRequest =
|
|
348
|
+
executionCount === 0 ? config.initialAttachments : undefined;
|
|
349
|
+
|
|
313
350
|
const result = await provider.streamComplete(
|
|
314
351
|
{
|
|
315
352
|
model,
|
|
@@ -320,6 +357,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
320
357
|
// Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
|
|
321
358
|
// step. Adaptery bez wsparcia ignorują.
|
|
322
359
|
reasoningEffort: "minimal",
|
|
360
|
+
...(filesForRequest && filesForRequest.length > 0
|
|
361
|
+
? { files: filesForRequest }
|
|
362
|
+
: {}),
|
|
323
363
|
},
|
|
324
364
|
(chunk) => {
|
|
325
365
|
if (chunk.type === "text_delta" && chunk.textDelta) {
|
|
@@ -356,6 +396,32 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
356
396
|
},
|
|
357
397
|
);
|
|
358
398
|
|
|
399
|
+
// Cache lazy-uploaded provider file IDs w ArcFile aggregate — żeby
|
|
400
|
+
// kolejne generacje (i odnowiona historia) pomijały re-upload.
|
|
401
|
+
if (
|
|
402
|
+
config.attachments &&
|
|
403
|
+
result.boundProviderFiles &&
|
|
404
|
+
result.boundProviderFiles.length > 0
|
|
405
|
+
) {
|
|
406
|
+
for (const bound of result.boundProviderFiles) {
|
|
407
|
+
try {
|
|
408
|
+
await config.attachments.bindProviderFileId(
|
|
409
|
+
ctx,
|
|
410
|
+
bound.fileId,
|
|
411
|
+
bound.provider,
|
|
412
|
+
bound.providerFileId,
|
|
413
|
+
);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
// Best-effort cache — failure tu nie psuje turn'u, tylko
|
|
416
|
+
// następny request uploadowi plik ponownie.
|
|
417
|
+
console.warn(
|
|
418
|
+
"[arc-chat] attachments.bindProviderFileId failed:",
|
|
419
|
+
err,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
359
425
|
// Append to local history so the next iteration sees this turn.
|
|
360
426
|
const assistantTurn: ConversationTurn = {
|
|
361
427
|
role: "assistant",
|
|
@@ -559,6 +625,18 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
559
625
|
const provider = resolveProvider(model, scopeId);
|
|
560
626
|
if (!provider) return;
|
|
561
627
|
|
|
628
|
+
// Otwórz in-memory stream PRZED pierwszym awaitem (getByScope niżej +
|
|
629
|
+
// buildInstructions w pętli). Async listenery w arc startują
|
|
630
|
+
// synchronicznie w trakcie emit i suspendują na 1. await, więc wpis
|
|
631
|
+
// powstaje ZANIM mutacja zwróci → klient subskrybujący zaraz po mutacji
|
|
632
|
+
// nie wyprzedzi startStream (to był główny powód 410 ~1/5, szczególnie
|
|
633
|
+
// dla stage'ów z wolnym buildInstructions). Idempotent; runGenerationLoop
|
|
634
|
+
// woła ponownie dla iteracji 2+ (multi-turn).
|
|
635
|
+
const preAssistantId = (
|
|
636
|
+
event.payload as { assistantMessageId?: string }
|
|
637
|
+
).assistantMessageId;
|
|
638
|
+
if (preAssistantId) startStream(preAssistantId);
|
|
639
|
+
|
|
562
640
|
const dbMessages = await ctx
|
|
563
641
|
.query(messageElement)
|
|
564
642
|
.getByScope({ scopeId });
|
|
@@ -570,6 +648,14 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
570
648
|
const newTurnsStartIdx = history.length;
|
|
571
649
|
history.push({ role: "user", content: userContent });
|
|
572
650
|
|
|
651
|
+
// Załaduj ArcFile records dla attachmentów ostatniego user msg
|
|
652
|
+
// (jeśli consumer wired `attachments` bridge przez `.attachFiles({File})`).
|
|
653
|
+
const initialAttachments = await resolveAttachments(
|
|
654
|
+
ctx,
|
|
655
|
+
config.attachments,
|
|
656
|
+
(event.payload as { attachmentsJson?: string }).attachmentsJson,
|
|
657
|
+
);
|
|
658
|
+
|
|
573
659
|
await runGenerationLoop({
|
|
574
660
|
ctx,
|
|
575
661
|
messageElement,
|
|
@@ -588,6 +674,8 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
588
674
|
alias: config.alias,
|
|
589
675
|
recordUsage: config.recordUsage,
|
|
590
676
|
billTo: config.billTo,
|
|
677
|
+
attachments: config.attachments,
|
|
678
|
+
initialAttachments,
|
|
591
679
|
preCreatedAssistantMessageId: (
|
|
592
680
|
event.payload as { assistantMessageId?: string }
|
|
593
681
|
).assistantMessageId,
|
|
@@ -595,6 +683,36 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
|
|
|
595
683
|
});
|
|
596
684
|
}
|
|
597
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Parsuje `attachmentsJson` (string[] fileIds) i deleguje do
|
|
688
|
+
* `attachments.loadAttachments(...)`. Brak attachments bridge ALBO brak
|
|
689
|
+
* JSON ALBO pusta lista → zwraca undefined (request bez `files`).
|
|
690
|
+
*/
|
|
691
|
+
async function resolveAttachments(
|
|
692
|
+
ctx: any,
|
|
693
|
+
attachments: AiGenerationListenerConfig["attachments"],
|
|
694
|
+
attachmentsJson: string | undefined,
|
|
695
|
+
): Promise<ArcFileRef[] | undefined> {
|
|
696
|
+
if (!attachments || !attachmentsJson) return undefined;
|
|
697
|
+
let fileIds: string[] = [];
|
|
698
|
+
try {
|
|
699
|
+
const parsed = JSON.parse(attachmentsJson);
|
|
700
|
+
if (Array.isArray(parsed)) {
|
|
701
|
+
fileIds = parsed.filter((x): x is string => typeof x === "string");
|
|
702
|
+
}
|
|
703
|
+
} catch {
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
if (fileIds.length === 0) return undefined;
|
|
707
|
+
try {
|
|
708
|
+
const refs = await attachments.loadAttachments(ctx, fileIds);
|
|
709
|
+
return refs.length > 0 ? refs : undefined;
|
|
710
|
+
} catch (err) {
|
|
711
|
+
console.warn("[arc-chat] attachments.loadAttachments failed:", err);
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
598
716
|
// ─── Listener B: userResponded → AI resume ──────────────────────
|
|
599
717
|
|
|
600
718
|
export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
@@ -627,6 +745,14 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
627
745
|
.handle(async (ctx, event) => {
|
|
628
746
|
const { sessionId, scopeId, toolCallId } = event.payload;
|
|
629
747
|
|
|
748
|
+
// Otwórz in-memory stream przed 1. awaitem — patrz listener generation.
|
|
749
|
+
// (provider sprawdzany niżej po await; rzadki misconfig sprząta
|
|
750
|
+
// MAX_STREAM_MS w stream-registry.)
|
|
751
|
+
const preAssistantId = (
|
|
752
|
+
event.payload as { assistantMessageId?: string }
|
|
753
|
+
).assistantMessageId;
|
|
754
|
+
if (preAssistantId) startStream(preAssistantId);
|
|
755
|
+
|
|
630
756
|
const dbMessages = await ctx
|
|
631
757
|
.query(messageElement)
|
|
632
758
|
.getByScope({ scopeId });
|
|
@@ -671,6 +797,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
|
|
|
671
797
|
alias: config.alias,
|
|
672
798
|
recordUsage: config.recordUsage,
|
|
673
799
|
billTo: config.billTo,
|
|
800
|
+
attachments: config.attachments,
|
|
674
801
|
preCreatedAssistantMessageId: (
|
|
675
802
|
event.payload as { assistantMessageId?: string }
|
|
676
803
|
).assistantMessageId,
|
|
@@ -723,6 +850,9 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
|
|
|
723
850
|
model: modelName,
|
|
724
851
|
} = event.payload as any;
|
|
725
852
|
|
|
853
|
+
// Otwórz in-memory stream przed 1. awaitem — patrz listener generation.
|
|
854
|
+
if (assistantMsgId) startStream(assistantMsgId);
|
|
855
|
+
|
|
726
856
|
const dbMessages = await ctx
|
|
727
857
|
.query(messageElement)
|
|
728
858
|
.getByScope({ scopeId });
|
|
@@ -554,21 +554,30 @@ export function createChatComponent(
|
|
|
554
554
|
|
|
555
555
|
(async () => {
|
|
556
556
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
557
|
+
// 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
|
|
558
|
+
// synchronicznie ze startem turnu (listener przed 1. awaitem), więc
|
|
559
|
+
// race "GET przed startStream" jest zamknięty — ale zostaje krótki
|
|
560
|
+
// residualny race i okno restartu serwera. Ponów kilka razy z
|
|
561
|
+
// backoffem zanim uznasz turn za przerwany: startStream / grace
|
|
562
|
+
// window zwykle dogania w tym czasie.
|
|
563
|
+
let res: Response | null = null;
|
|
564
|
+
const MAX_410_RETRIES = 4;
|
|
565
|
+
const RETRY_DELAY_MS = 300;
|
|
566
|
+
for (let attempt = 0; ; attempt++) {
|
|
567
|
+
res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
|
|
560
568
|
credentials: "include",
|
|
561
569
|
signal: ctrl.signal,
|
|
562
570
|
headers: { Accept: "text/event-stream" },
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
571
|
+
});
|
|
572
|
+
if (res.status !== 410) break;
|
|
573
|
+
if (attempt >= MAX_410_RETRIES) {
|
|
574
|
+
// Naprawdę nieosiągalny (restart mid-stream / poza grace window).
|
|
575
|
+
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
576
|
+
setIsStreaming(false);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
580
|
+
if (cancelled) return;
|
|
572
581
|
}
|
|
573
582
|
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
|
|
574
583
|
|
|
@@ -622,6 +631,9 @@ export function createChatComponent(
|
|
|
622
631
|
scopeId,
|
|
623
632
|
content,
|
|
624
633
|
model: options.model,
|
|
634
|
+
...(options.attachments && options.attachments.length > 0
|
|
635
|
+
? { attachmentsJson: JSON.stringify(options.attachments) }
|
|
636
|
+
: {}),
|
|
625
637
|
});
|
|
626
638
|
// Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
|
|
627
639
|
// emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant
|
|
@@ -33,6 +33,13 @@ interface MessageStream {
|
|
|
33
33
|
>;
|
|
34
34
|
subscribers: Set<ReadableStreamDefaultController<Uint8Array>>;
|
|
35
35
|
keepAliveInterval?: ReturnType<typeof setInterval>;
|
|
36
|
+
/**
|
|
37
|
+
* Hard safety cap. `startStream` schedules it; `finalize` clears it. If a
|
|
38
|
+
* generation never calls `finalize()` (listener threw before the loop, no
|
|
39
|
+
* provider, hung stream) this evicts the entry so the registry can't grow
|
|
40
|
+
* unbounded — the only TTL on a *live* (non-finalized) stream.
|
|
41
|
+
*/
|
|
42
|
+
maxLifetimeTimer?: ReturnType<typeof setTimeout>;
|
|
36
43
|
finalized: boolean;
|
|
37
44
|
finalEvent?: ChatStreamEvent;
|
|
38
45
|
}
|
|
@@ -40,6 +47,9 @@ interface MessageStream {
|
|
|
40
47
|
const streams = new Map<string, MessageStream>();
|
|
41
48
|
const FINALIZE_GRACE_MS = 5_000;
|
|
42
49
|
const KEEPALIVE_INTERVAL_MS = 5_000;
|
|
50
|
+
// Generous upper bound for a single turn's stream (one LLM response, even with
|
|
51
|
+
// tools). Far longer than any realistic generation; only trips on a leak.
|
|
52
|
+
const MAX_STREAM_MS = 10 * 60_000;
|
|
43
53
|
const encoder = new TextEncoder();
|
|
44
54
|
|
|
45
55
|
function encode(event: ChatStreamEvent): Uint8Array {
|
|
@@ -75,13 +85,40 @@ function ensureKeepAlive(s: MessageStream): void {
|
|
|
75
85
|
*/
|
|
76
86
|
export function startStream(messageId: string): void {
|
|
77
87
|
if (streams.has(messageId)) return;
|
|
78
|
-
|
|
88
|
+
const s: MessageStream = {
|
|
79
89
|
messageId,
|
|
80
90
|
currentBlocks: [],
|
|
81
91
|
toolCallsById: new Map(),
|
|
82
92
|
subscribers: new Set(),
|
|
83
93
|
finalized: false,
|
|
84
|
-
}
|
|
94
|
+
};
|
|
95
|
+
// Leak guard — see MAX_STREAM_MS. Cleared in finalize() on the normal path.
|
|
96
|
+
s.maxLifetimeTimer = setTimeout(() => evict(messageId), MAX_STREAM_MS);
|
|
97
|
+
streams.set(messageId, s);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Force-drop a stream that never finalized (safety net). Closes any subscribers
|
|
102
|
+
* and tears down timers. No-op if already gone.
|
|
103
|
+
*/
|
|
104
|
+
function evict(messageId: string): void {
|
|
105
|
+
const s = streams.get(messageId);
|
|
106
|
+
if (!s) return;
|
|
107
|
+
if (s.keepAliveInterval) {
|
|
108
|
+
clearInterval(s.keepAliveInterval);
|
|
109
|
+
s.keepAliveInterval = undefined;
|
|
110
|
+
}
|
|
111
|
+
if (s.maxLifetimeTimer) {
|
|
112
|
+
clearTimeout(s.maxLifetimeTimer);
|
|
113
|
+
s.maxLifetimeTimer = undefined;
|
|
114
|
+
}
|
|
115
|
+
for (const ctrl of s.subscribers) {
|
|
116
|
+
try {
|
|
117
|
+
ctrl.close();
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
s.subscribers.clear();
|
|
121
|
+
streams.delete(messageId);
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
/**
|
|
@@ -266,6 +303,12 @@ export function finalize(
|
|
|
266
303
|
clearInterval(s.keepAliveInterval);
|
|
267
304
|
s.keepAliveInterval = undefined;
|
|
268
305
|
}
|
|
306
|
+
// Normal teardown owns the lifecycle now — cancel the safety cap so it can't
|
|
307
|
+
// fire during/after the grace window.
|
|
308
|
+
if (s.maxLifetimeTimer) {
|
|
309
|
+
clearTimeout(s.maxLifetimeTimer);
|
|
310
|
+
s.maxLifetimeTimer = undefined;
|
|
311
|
+
}
|
|
269
312
|
|
|
270
313
|
setTimeout(() => {
|
|
271
314
|
streams.delete(messageId);
|