@arcote.tech/arc-chat 0.7.13 → 0.7.15

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.13",
4
+ "version": "0.7.15",
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.13",
14
- "@arcote.tech/arc-ai": "^0.7.13",
15
- "@arcote.tech/arc-ai-voice": "^0.7.13",
16
- "@arcote.tech/arc-auth": "^0.7.13",
17
- "@arcote.tech/arc-ds": "^0.7.13",
18
- "@arcote.tech/platform": "^0.7.13",
13
+ "@arcote.tech/arc": "^0.7.15",
14
+ "@arcote.tech/arc-ai": "^0.7.15",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.15",
16
+ "@arcote.tech/arc-auth": "^0.7.15",
17
+ "@arcote.tech/arc-ds": "^0.7.15",
18
+ "@arcote.tech/platform": "^0.7.15",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -625,6 +625,18 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
625
625
  const provider = resolveProvider(model, scopeId);
626
626
  if (!provider) return;
627
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
+
628
640
  const dbMessages = await ctx
629
641
  .query(messageElement)
630
642
  .getByScope({ scopeId });
@@ -733,6 +745,14 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
733
745
  .handle(async (ctx, event) => {
734
746
  const { sessionId, scopeId, toolCallId } = event.payload;
735
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
+
736
756
  const dbMessages = await ctx
737
757
  .query(messageElement)
738
758
  .getByScope({ scopeId });
@@ -830,6 +850,9 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
830
850
  model: modelName,
831
851
  } = event.payload as any;
832
852
 
853
+ // Otwórz in-memory stream przed 1. awaitem — patrz listener generation.
854
+ if (assistantMsgId) startStream(assistantMsgId);
855
+
833
856
  const dbMessages = await ctx
834
857
  .query(messageElement)
835
858
  .getByScope({ scopeId });
@@ -554,21 +554,30 @@ export function createChatComponent(
554
554
 
555
555
  (async () => {
556
556
  try {
557
- const res = await fetch(
558
- `/route/chat/${chatName}/stream/${messageId}`,
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
- if (res.status === 410) {
566
- // Stream nie istnieje proces zrestartował się mid-stream
567
- // (in-memory state utracony). Mark messageId jako interrupted,
568
- // klient pokaże retry UI.
569
- setInterruptedIds((prev) => new Set(prev).add(messageId));
570
- setIsStreaming(false);
571
- return;
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
 
@@ -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
- streams.set(messageId, {
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);