@arcote.tech/arc-chat 0.7.20 → 0.7.21

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/README.md CHANGED
@@ -11,25 +11,45 @@ opisuje **mental model**, którego trzeba się trzymać przy każdej modyfikacji
11
11
 
12
12
  ## Mental model
13
13
 
14
- > **Live wartość treści asystenta żyje wyłącznie w pamięci serwera.**
15
- > **DB zna tylko stan finalny i tylko po zakończeniu tury.**
14
+ > **DB jest jedynym źródłem prawdy o strukturze konwersacji.**
15
+ > **Stream jest tylko overlayemulotnym podglądem trwającej generacji.**
16
+ > **Timeline jest czystą funkcją obu — nigdy mutowalnym stanem.**
16
17
 
17
- W trakcie generacji LLM streamuje chunki do `stream-registry` (in-memory,
18
- per `messageId`). Klient subskrybuje SSE po `messageId` i dostaje:
18
+ Serwer: w trakcie generacji LLM streamuje chunki do `stream-registry`
19
+ (in-memory, per `messageId`). Klient subskrybuje SSE po `messageId` i dostaje:
19
20
 
20
21
  1. `init` — snapshot aktualnego `currentBlocks` w momencie podłączenia
21
22
  2. live `text_delta` / `tool_call_*` — kolejne chunki
22
- 3. `done` — koniec turny
23
+ 3. `done` — koniec turnu (**advisory** — patrz niżej)
23
24
 
24
- Dopiero po `provider.streamComplete()` zwróci pełen wynik, listener wywołuje
25
+ Dopiero gdy `provider.streamComplete()` zwróci pełen wynik, listener wywołuje
25
26
  `completeAssistantTurn({ blocks })` — **jedyny zapis treści do DB w całej
26
- turze**. Następnie `finalize(messageId)` zamyka stream i po 5s grace okresie
27
- drop'uje go z mapy.
27
+ turze**, atomowo z flipem `isGenerating: false`. Następnie `finalize(messageId)`
28
+ zamyka stream i po 5 s grace okresie drop'uje go z mapy.
29
+
30
+ Klient: `chat-component.tsx` NIE merguje kanałów imperatywnie. Trzy elementy:
31
+
32
+ - **liveQuery `getByScope`** — struktura: wiadomości, finalne blocks,
33
+ `isGenerating` / `interrupted` / `error`, tool_results,
34
+ - **`useAssistantOverlays`** — per generujący row utrzymuje SSE i buduje
35
+ `overlay { blocks, status }` przez **shared reducer**
36
+ (`applyStreamEvent` — dokładnie ten sam kod, którym serwer akumuluje
37
+ `currentBlocks`),
38
+ - **`deriveTimeline(history, overlays, optimistic...)`** — czysta funkcja
39
+ poza komponentem. Row `isGenerating` + overlay → renderuj overlay;
40
+ bez overlaya → placeholder; `interrupted` → retry; zamknięty → finalne
41
+ blocks z DB. Wyniki tooli zawsze z DB.
42
+
43
+ **Autorytatywny koniec turnu to flip `isGenerating: false` w DB** (przychodzi
44
+ liveQuery razem z finalnymi blocks, atomowo w jednym rzędzie). SSE `done` i
45
+ `error` tylko zdejmują caret wcześniej. Dzięki temu kolejność dostarczenia
46
+ (done przed/po flipie DB, zgubione done, martwy socket) **nie ma znaczenia** —
47
+ derywacja zawsze liczy się od najnowszego stanu obu źródeł.
28
48
 
29
49
  **To NIE jest event-sourcing dla streamingu.** Snapshoty częściowej treści
30
50
  do DB były anti-pattern (niepotrzebny narzut, dublowanie stanu). Stream-registry
31
51
  to autorytatywne źródło live wartości; DB to autorytatywne źródło stanu po
32
- zamknięciu turny.
52
+ zamknięciu turnu.
33
53
 
34
54
  ---
35
55
 
@@ -37,14 +57,21 @@ zamknięciu turny.
37
57
 
38
58
  ```
39
59
  src/
40
- ├─ aggregates/message.ts Aggregate: pola, eventy, mutacje
60
+ ├─ aggregates/message.ts Aggregate: pola, eventy, mutacje
61
+ ├─ ordering.ts Kanoniczna kolejność rzędów (klient + serwer)
41
62
  ├─ listeners/
42
- │ └─ ai-generation-listener.ts Generation loop + 3 listenery (gen/resume/retry)
43
- ├─ routes/chat-stream-route.ts GET /chat/:name/stream/:messageId (SSE)
44
- ├─ streaming/stream-registry.ts In-memory per-messageId MessageStream
45
- ├─ react/chat-component.tsx UI: auto-subscribe SSE + timeline rebuild z DB
46
- ├─ tools/ask-questions.tsx Reusable interactive tool
47
- └─ chat-builder.ts chat().identifyBy(...).ai(...).build()
63
+ │ └─ ai-generation-listener.ts Generation loop + 3 listenery (gen/resume/retry)
64
+ ├─ routes/chat-stream-route.ts GET /chat/:name/stream/:messageId (SSE)
65
+ │ + lazy repair osieroconych rzędów przy 410
66
+ ├─ streaming/
67
+ ├─ blocks-reducer.ts SHARED reducer eventy→blocks (serwer + klient)
68
+ └─ stream-registry.ts In-memory per-messageId MessageStream
69
+ ├─ react/
70
+ │ ├─ derive-timeline.ts Czysta derywacja: (DB, overlays) → timeline + busy
71
+ │ ├─ use-assistant-overlays.ts SSE per generujący row → overlay map
72
+ │ └─ chat-component.tsx Cienki komponent: liveQuery + hook + derive + render
73
+ ├─ tools/ask-questions.tsx Reusable interactive tool
74
+ └─ chat-builder.ts chat().identifyBy(...).ai(...).build()
48
75
  ```
49
76
 
50
77
  ---
@@ -55,6 +82,7 @@ src/
55
82
  USER wpisuje "Cześć", klika Send
56
83
 
57
84
 
85
+ Klient: pendingSends += optimistic user message (busy=true od razu)
58
86
  sendMessage mutation (atomowo)
59
87
  ├─ emit assistantTurnStarted → projection: set empty assistant row
60
88
  │ (isGenerating=true, brak blocks)
@@ -62,107 +90,126 @@ sendMessage mutation (atomowo)
62
90
  → triggeruje aiGenerationListener (async)
63
91
 
64
92
 
65
- DB query getByScope() pushuje obie wiadomości do klienta
93
+ liveQuery getByScope() pushuje obie wiadomości do klienta
94
+ ├─ pendingSend settled → drop optimistic
95
+ └─ generatingIds = [assistantMsgId] → useAssistantOverlays otwiera SSE
66
96
 
67
97
 
68
- React effect widzi isGenerating=true assistant row
69
- activeGeneratingMessageId = id assistanta
70
- useChatMessageStream auto-otwiera SSE
71
-
72
- ▼ ◀───┐
73
- fetch /route/chat/:name/stream/:messageId │
74
- │ │
75
- ├─ subscribe(messageId) │
76
- │ ├─ Brak streamu → 410 ────┐ │
77
- │ │ │ │
78
- │ │ ▼ │
79
- │ │ UI: "Interrupted" │
80
- │ │ + Retry button │
81
- │ │ │ │
82
- │ │ ▼ │
83
- │ │ retryGeneration │
84
- │ │ └──────────────┘
85
- │ │
86
- │ └─ Stream istnieje → init z currentBlocks snapshot
87
-
98
+ fetch /route/chat/:name/stream/:messageId
99
+ ├─ subscribe(messageId) init z currentBlocks snapshot
100
+ │ └─ Brak streamu → 410 → retry ×4 z backoffem
101
+ ├─ route: markInterrupted (lazy repair, jeśli row stary)
102
+ │ └─ po wyczerpaniu → overlay.status="gone" → UI "Interrupted"+Retry
88
103
 
89
104
  Listener: startStream() → provider.streamComplete(onChunk)
90
105
  onChunk → publish(messageId, event)
91
- ├─ mutuje currentBlocks (text append / push tool_call / set args)
106
+ ├─ currentBlocks = applyStreamEvent(currentBlocks, event) ← shared reducer
92
107
  └─ broadcast SSE do wszystkich subscribers
93
- Klient SSE processEvent setTimeline
108
+ Klient: overlay.blocks = applyStreamEvent(overlay.blocks, event) ← TEN SAM reducer
109
+ deriveTimeline renderuje overlay (caret na ostatnim bloku)
94
110
 
95
111
 
96
112
  streamComplete zwraca pełen result.blocks
97
113
 
98
114
 
99
- completeAssistantTurn({ blocks }) ← jedyny zapis treści do DB
100
-
115
+ completeAssistantTurn({ blocks, error? }) ← jedyny zapis treści do DB
116
+ (atomowo: blocks + isGenerating=false)
101
117
 
102
118
  finalize(messageId, { usage, finishReason })
103
- ├─ broadcast done do subscriberów
104
- ├─ close controllery
119
+ ├─ broadcast done (advisory — zdejmuje caret)
105
120
  └─ setTimeout(delete, 5s) — grace dla late subscribers
106
121
 
107
122
 
108
- Klient SSE: done setIsStreaming(false)
109
- DB query update: isGenerating=false, blocks=...
110
- └─ historySig refire timeline rebuild z DB final blocks
123
+ liveQuery update: isGenerating=false + blocks w JEDNYM rzędzie
124
+ ├─ overlay GC (hook)
125
+ └─ deriveTimeline renderuje finalne blocks z DB
126
+ (te same klucze itemów co overlay → zero remount-flasha)
111
127
  ```
112
128
 
113
129
  ---
114
130
 
131
+ ## Porządkowanie rzędów (`ordering.ts`)
132
+
133
+ Mutacje `sendMessage` / `respondToTool` / `startStage` / `systemMessage`
134
+ emitują `assistantTurnStarted` **przed** rzędem triggerującym (wymóg
135
+ async listenera), więc pre-utworzony placeholder asystenta ma `createdAt`
136
+ **wcześniejszy** niż pytanie, na które odpowiada. Samo `orderBy createdAt`
137
+ ustawiłoby odpowiedź przed pytaniem.
138
+
139
+ `orderMessages()` daje kanoniczną kolejność: `createdAt` → tie-break rolą
140
+ (user/system → tool_result → assistant) → `_id` → fix-up przesuwający
141
+ pierwszy assistant row sesji za jej trigger row. Używają go **oba** końce:
142
+ `deriveTimeline` (klient) i `buildHistory` (serwer — historia dla LLM).
143
+ Jeśli zmieniasz emit-order w mutacjach albo timestampy — zacznij od tego pliku.
144
+
145
+ ---
146
+
115
147
  ## Edge cases
116
148
 
117
149
  ### Graceful reload mid-stream (F5)
118
150
 
119
151
  Serwer i listener nadal generują. Klient po refresh:
120
152
 
121
- 1. DB query zwraca assistant row z `isGenerating=true`
122
- 2. `activeGeneratingMessageId` ustawia się → hook otwiera SSE
153
+ 1. liveQuery zwraca assistant row z `isGenerating=true`
154
+ 2. `generatingIds` zawiera ten row → hook otwiera SSE
123
155
  3. `subscribe(messageId)` zwraca aktualny `currentBlocks` w `init` event
124
- 4. Klient renderuje to, co już zostało wygenerowane + kontynuuje live
156
+ 4. deriveTimeline renderuje to, co już wygenerowane + kontynuuje live
125
157
 
126
158
  **Bez duplikacji** — brak replay buffer'a chunków, jest jeden snapshot.
159
+ Reconnect (visibility / heartbeat / BFCache) działa identycznie: zabij
160
+ połączenie, otwórz nowe, `init` resetuje bazę overlaya.
127
161
 
128
162
  ### Server restart mid-stream
129
163
 
130
164
  Proces ginie z `currentBlocks` w pamięci → utrata. DB ma row
131
- `isGenerating=true` ale `subscribe(messageId)` zwraca `null` → route oddaje
132
- HTTP 410.
165
+ `isGenerating=true`, ale `subscribe(messageId)` zwraca `null` → 410.
133
166
 
134
- 1. React hook: `res.status === 410` `setInterruptedIds(prev.add(messageId))`
135
- 2. Timeline pokazuje TimelineItem `"interrupted"` + Retry button
136
- 3. Klik Retry `retryGeneration({ messageId })`:
137
- - mutation emit `assistantTurnStarted` (fresh row) + `retryRequested`
138
- (projection usuwa interrupted row)
139
- - `aiRetryListener` reaguje, odpala `runGenerationLoop` z fresh
140
- `preCreatedAssistantMessageId`
167
+ 1. Route przy 410 woła `markInterrupted` (lazy repair): jeśli row ma
168
+ `isGenerating=true` i jest starszy niż 10 s → emit `generationInterrupted`
169
+ projection ustawia `isGenerating=false, interrupted=true`. **Trwałe
170
+ i cross-client** każdy klient (też po F5) widzi interrupted z DB.
171
+ 2. Równolegle klient po wyczerpaniu retry ustawia `overlay.status="gone"` —
172
+ natychmiastowy lokalny stan, zanim repair przejdzie przez liveQuery.
173
+ 3. Klik Retry → `retryGeneration({ messageId })` (akceptuje `isGenerating`
174
+ ORAZ `interrupted` rows): emit `assistantTurnStarted` (fresh row) +
175
+ `retryRequested` (projection usuwa interrupted row) → `aiRetryListener`
176
+ odpala `runGenerationLoop`.
177
+
178
+ ### Błąd generacji
179
+
180
+ Error path listenera zapisuje `error` w rzędzie (projection
181
+ `assistantTurnCompleted` persystuje pole). Derywacja renderuje go z DB —
182
+ **przeżywa F5 i reconnect**. SSE `error` event jest tylko advisory.
141
183
 
142
184
  ### Server tool call w środku tury
143
185
 
144
186
  Po `streamComplete` z `finishReason="tool_call"`:
145
187
 
146
- 1. `completeAssistantTurn(blocks)` — assistant row finalizowany (blocks
147
- zawiera tool_call w properOrder)
188
+ 1. `completeAssistantTurn(blocks)` — assistant row finalizowany
148
189
  2. `finalize(messageId)` — stream zamknięty
149
190
  3. Każdy server tool: `saveToolResult` → tool_result row w DB
150
- 4. **Następna iteracja loop'a**: `startAssistantTurn` tworzy nowy assistant
151
- row (`isGenerating=true`) → nowy `messageId` → klient widzi go w DB
152
- query update → nowy SSE stream → drugi turn streamuje
191
+ 4. **Następna iteracja loop'a**: `startAssistantTurn` nowy row
192
+ (`isGenerating=true`) → nowy `messageId` → nowy stream
153
193
 
154
- Każda iteracja loop'a = **osobny `messageId` = osobny stream**.
194
+ Każda iteracja loop'a = **osobny `messageId` = osobny stream**. Input
195
+ pozostaje disabled przez całą pętlę dzięki regule **busy** w derywacji
196
+ (z DB, nie z lokalnego flagu): row `isGenerating` LUB ostatni assistant
197
+ row ma server-tool call bez wyniku LUB ostatni row to świeży tool_result
198
+ server-toola. Klauzule mają staleness cutoff (120 s) — gdy listener padł
199
+ między zapisami, busy degraduje się do enabled zamiast wisieć wiecznie.
155
200
 
156
201
  ### Interactive tool (np. askQuestions)
157
202
 
158
- Po `streamComplete` z interactive tool calls:
159
-
160
203
  1. `completeAssistantTurn` + `finalize` — pierwsza tura zamknięta
161
204
  2. Listener returns (loop break)
162
- 3. Klient widzi tool w timeline (status=pending), `ChatInput` disabled
163
- 4. User klika answer → `respondToTool` mutation (atomowo emit
164
- `assistantTurnStarted` + `userResponded`)
165
- 5. `aiResumeListener` reaguje kolejny turn streamuje
205
+ 3. deriveTimeline: tool bez wyniku + nie-server → status `pending`,
206
+ `hasWaitingInteractive=true` input override
207
+ 4. User odpowiada → optimistic `pendingToolResults` (answer-view OD RAZU)
208
+ + `respondToTool` mutation (atomowo `assistantTurnStarted` + `userResponded`)
209
+ 5. tool_result row dociera liveQuery → optimistic entry GC, DB wygrywa
210
+ 6. `aiResumeListener` → kolejny turn streamuje — a answered tool pozostaje
211
+ w answer-view, bo derywacja zawsze widzi resultMap z DB (nie ma guardu
212
+ "w trakcie streamowania")
166
213
 
167
214
  ---
168
215
 
@@ -170,7 +217,7 @@ Po `streamComplete` z interactive tool calls:
170
217
 
171
218
  ```ts
172
219
  startStream(messageId) // idempotent. Listener woła przed publish
173
- publish(messageId, event) // mutuje currentBlocks + broadcast SSE
220
+ publish(messageId, event) // applyStreamEvent + broadcast SSE
174
221
  subscribe(messageId): { // route handler. null → 410
175
222
  stream, currentBlocks
176
223
  } | null
@@ -186,26 +233,46 @@ getCurrentBlocks(messageId) // debug/test, readonly
186
233
 
187
234
  ## Key invariants
188
235
 
236
+ **Derywacja (klient):**
237
+ - Timeline jest CZYSTĄ funkcją `(history, overlays, optimistic) → items`.
238
+ **Nigdy nie pisz do timeline'u imperatywnie** — każdy merge dwóch kanałów
239
+ przez mutowalny stan + flagę trybu kończył się produkcyjnymi race'ami
240
+ (watchdogi/backstopy/nonce w git log to historia tych prób).
241
+ - SSE reader aktualizuje WYŁĄCZNIE overlay (nigdy timeline) i robi to
242
+ wyłącznie przez `applyStreamEvent`.
243
+ - Klucze itemów (`${msgId}_t${n}`, `toolCallId`) są IDENTYCZNE dla overlaya
244
+ i finalnych blocks — przejście stream→DB nie remountuje elementów.
245
+ - Stale overlay dla zamkniętego rowa jest ignorowany przez derywację
246
+ (GC w hooku to optymalizacja, nie poprawność).
247
+
189
248
  **Live wartość:**
190
249
  - `currentBlocks` w stream-registry jest jedynym źródłem prawdy dla treści
191
- in-progress assistanta
192
- - `partialBlocks`/`partialLastSeq` **NIE ISTNIEJĄ** jeśli pojawi się PR
193
- dodający je, odrzuć
250
+ in-progress assistanta; jedyna semantyka akumulacji to `applyStreamEvent`
251
+ (`blocks-reducer.ts`) zmiana TYLKO tam, inaczej klient i serwer się rozjadą
252
+ - inwariant reconnectu: snapshot w punkcie K + replay od K == pełny replay
253
+ (test w `blocks-reducer.test.ts`)
254
+ - `partialBlocks`/`partialLastSeq`/sekwencery **NIE ISTNIEJĄ** — `init`
255
+ snapshot załatwia reconnect; jeśli pojawi się PR dodający je, odrzuć
194
256
 
195
257
  **DB:**
196
258
  - Assistant row z `isGenerating=true` ma `blocks=undefined`
197
259
  - Po `assistantTurnCompleted` row ma `isGenerating=false` + `blocks` final
260
+ + ewentualny `error` — **atomowo, w jednym rzędzie** (na tym stoi cała
261
+ derywacja: klient nigdy nie zobaczy flipa bez finalnej treści)
198
262
  - Treść NIGDY nie ląduje w DB chunk po chunku
263
+ - `interrupted=true` ustawia wyłącznie `generationInterrupted` (lazy repair
264
+ w route przy 410, próg wieku 10 s)
199
265
 
200
266
  **Stream lifecycle:**
201
- - `startStream(messageId)` PRZED pierwszym `publish` (listener gwarantuje)
267
+ - `startStream(messageId)` PRZED pierwszym `publish` (listener gwarantuje,
268
+ synchronicznie przed 1. awaitem)
202
269
  - `finalize(messageId)` PO `completeAssistantTurn` (DB → in-memory order)
203
270
  - Każda iteracja generation loop'a → osobny `messageId` → osobny stream
204
271
 
205
272
  **Subscribe:**
206
273
  - Pierwszy event po `subscribe()` to ZAWSZE `init`
207
- - `subscribe()` zwraca `null` (→ 410 HTTP) **tylko gdy** stream nie istnieje
208
- w mapie (poza grace window). Klient interpretuje 410 jako "interrupted".
274
+ - `subscribe()` zwraca `null` (→ 410) **tylko gdy** stream nie istnieje
275
+ w mapie (poza grace window)
209
276
 
210
277
  ---
211
278
 
@@ -213,18 +280,9 @@ getCurrentBlocks(messageId) // debug/test, readonly
213
280
 
214
281
  **`assistantTurnStarted` emit'owany PRZED `messageSent`/`userResponded`/
215
282
  `retryRequested` w jednej mutacji.** Powód: async listener reaguje na
216
- to drugie i potrzebuje, żeby assistant row już istniał w DB. Patrz komentarz
217
- w `sendMessage` mutation.
218
-
219
- **`historySig` w chat-component zależy od `_id:isGenerating:blocks:contentLen`.**
220
- Nie dodawaj tu pól typu `updatedAt` — useEffect refireuje dla każdego DB
221
- update, ale rebuild timeline nie może się fire'ować w trakcie streamingu
222
- (reset bubble caret). Strategia: rebuild fire tylko gdy `isStreaming === false`,
223
- SSE flippuje to dopiero w `done`.
224
-
225
- **`activeGeneratingMessageId` derived z `historyData` + `interruptedIds`.**
226
- Jeśli zmieniasz logikę detekcji "który row trzeba subskrybować", trzymaj
227
- ją w tym `useMemo` — auto-subscribe effect odpali się sam.
283
+ to drugie i potrzebuje, żeby assistant row już istniał w DB. Konsekwencja:
284
+ placeholder ma wcześniejszy `createdAt` niż trigger — dlatego istnieje
285
+ fix-up w `ordering.ts`. Zmieniasz jedno → sprawdź drugie.
228
286
 
229
287
  **`buildHistory` w listenerze pomija `assistant` rows z `isGenerating=true
230
288
  && !blocks`.** Czyli interrupted rows (przed retryRequested projection)
@@ -232,19 +290,35 @@ oraz fresh rows w trakcie generacji nie trafiają do LLM history. Po retry
232
290
  fresh row też jest skip'owany — historia kończy się na ostatniej user
233
291
  message, LLM kontynuuje od niej.
234
292
 
235
- **Stream-registry trzyma `toolCallsById` Map.** `publish("tool_call_pending")`
236
- tworzy block w `currentBlocks` ORAZ wpis w mapie. `tool_call_arguments_complete`
237
- update'uje args na tym samym block'u. Jeśli zmieniasz strukturę blocks
238
- asystenta, oba miejsca muszą być spójne.
239
-
240
293
  **Server-tool execution loop NIE używa stream-registry.** Po `finalize` dla
241
- tury z tool_calls, kolejne `publish` byłyby no-opem. Server tool results
242
- trafiają do klienta przez aggregate query update (`saveToolResult` → tool_result
243
- row w DB). To **świadome** — następna tura ma własny stream.
244
-
245
- **Brak retencji buforów eventów.** Klient który podłączy się 6s po `finalize`
246
- dostanie 410. Brak `?afterSeq`, brak replay. Po `done` klient ma final
247
- blocks z DB i nie potrzebuje SSE.
294
+ tury z tool_calls kolejne `publish` byłyby no-opem. Server tool results
295
+ trafiają do klienta przez liveQuery (`saveToolResult` → tool_result row).
296
+ To **świadome** — następna tura ma własny stream, a derywacja i tak czyta
297
+ wyniki tooli z DB.
298
+
299
+ **`queueMicrotask` w pętli readera SSE (use-assistant-overlays).** NIE
300
+ zamieniaj na `setTimeout(0)`: Chrome throttluje timeouty w kartach w tle
301
+ do ≥1 s — pętla zamienia się w 1-event/s freeze. Microtaski nie są
302
+ throttlowane.
303
+
304
+ **Brak retencji buforów eventów.** Klient, który podłączy się 6 s po
305
+ `finalize`, dostanie 410. Brak `?afterSeq`, brak replay. Po zamknięciu turnu
306
+ klient ma final blocks z DB i nie potrzebuje SSE; 410 dla świeżego rowa
307
+ obsługuje retry z backoffem + lazy repair.
308
+
309
+ **Pola tekstowe z JSON-em (`blocks`, `content`) mają DWA kształty.**
310
+ W świeżym in-memory store to stringi, ale adapter Postgresa auto-parsuje
311
+ kolumny tekstowe wyglądające jak JSON (`deserializeValue`) — po hydracji
312
+ store'a z bazy (restart serwera) te same pola przychodzą jako gotowe
313
+ tablice/obiekty, również do klienta przez liveQuery. Każde miejsce czytające
314
+ `msg.blocks` / `msg.content` musi tolerować oba kształty (`parseBlocks` w
315
+ derive-timeline, `buildHistory` w listenerze). Regresja "po odświeżeniu
316
+ znikają wiadomości asystenta" brała się dokładnie stąd.
317
+
318
+ **Reguła busy ma staleness cutoff.** Klauzule "tool bez wyniku" i "świeży
319
+ tool_result" wygasają po 120 s braku aktywności w DB — celowo: lepszy
320
+ przedwcześnie aktywny input (zachowanie sprzed redesignu) niż chat
321
+ zablokowany na zawsze po crashu listenera.
248
322
 
249
323
  ---
250
324
 
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.20",
4
+ "version": "0.7.21",
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.20",
14
- "@arcote.tech/arc-ai": "^0.7.20",
15
- "@arcote.tech/arc-ai-voice": "^0.7.20",
16
- "@arcote.tech/arc-auth": "^0.7.20",
17
- "@arcote.tech/arc-ds": "^0.7.20",
18
- "@arcote.tech/platform": "^0.7.20",
13
+ "@arcote.tech/arc": "^0.7.21",
14
+ "@arcote.tech/arc-ai": "^0.7.21",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.21",
16
+ "@arcote.tech/arc-auth": "^0.7.21",
17
+ "@arcote.tech/arc-ds": "^0.7.21",
18
+ "@arcote.tech/platform": "^0.7.21",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -77,6 +77,19 @@ export const createMessageAggregate = <
77
77
  */
78
78
  previousResponseId: string().optional(),
79
79
  isGenerating: boolean().optional(),
80
+ /**
81
+ * Assistant rows: błąd generacji (provider error / wyjątek listenera).
82
+ * Persystowany żeby przeżył F5 i reconnect — SSE `error` event jest
83
+ * tylko ulotnym sygnałem advisory.
84
+ */
85
+ error: string().optional(),
86
+ /**
87
+ * Assistant rows: generacja przerwana bez wyniku (restart serwera
88
+ * mid-stream). Ustawiane przez `generationInterrupted` (lazy repair w
89
+ * stream route przy 410). Trwałe i cross-client — UI renderuje
90
+ * "interrupted" + Retry z samego stanu DB.
91
+ */
92
+ interrupted: boolean().optional(),
80
93
  usage: string().optional(),
81
94
  createdAt: date(),
82
95
  })
@@ -166,7 +179,27 @@ export const createMessageAggregate = <
166
179
  blocks: p.blocks,
167
180
  previousResponseId: p.previousResponseId,
168
181
  usage: p.usage,
182
+ error: p.error,
183
+ isGenerating: false,
184
+ } as any);
185
+ },
186
+ )
187
+
188
+ // ─── generationInterrupted — orphaned turn marked as interrupted ─
189
+ // Lazy repair: serwer zrestartował się mid-stream → in-memory stream
190
+ // zniknął, a assistant row wisi z `isGenerating=true` na zawsze. Stream
191
+ // route emituje ten event przy 410 dla wystarczająco starego rowa.
192
+ // Dzięki temu "interrupted" jest trwałe (przeżywa F5) i cross-client,
193
+ // a UI nie potrzebuje lokalnego stanu interruptedIds.
194
+ .publicEvent(
195
+ "generationInterrupted",
196
+ {
197
+ messageId,
198
+ },
199
+ async (ctx, event) => {
200
+ await ctx.modify(event.payload.messageId, {
169
201
  isGenerating: false,
202
+ interrupted: true,
170
203
  } as any);
171
204
  },
172
205
  )
@@ -438,7 +471,13 @@ export const createMessageAggregate = <
438
471
  if (!interrupted) {
439
472
  throw new Error("retryGeneration: message not found");
440
473
  }
441
- if ((interrupted as any).role !== "assistant" || !(interrupted as any).isGenerating) {
474
+ // Akceptujemy oba stany przerwania: `isGenerating=true` (klient
475
+ // widzi 410 zanim lazy repair przeszedł) oraz `interrupted=true`
476
+ // (row już naprawiony przez `generationInterrupted`).
477
+ if (
478
+ (interrupted as any).role !== "assistant" ||
479
+ (!(interrupted as any).isGenerating && !(interrupted as any).interrupted)
480
+ ) {
442
481
  throw new Error("retryGeneration: row is not an interrupted assistant turn");
443
482
  }
444
483
 
@@ -467,6 +506,40 @@ export const createMessageAggregate = <
467
506
  ),
468
507
  )
469
508
 
509
+ // ─── markInterrupted — lazy repair of an orphaned generating row ─
510
+ // Wołane przez stream route gdy `subscribe()` zwraca null (410), a row
511
+ // w DB nadal ma `isGenerating=true`. No-op (ok:false) zamiast wyjątku,
512
+ // bo route woła to przy KAŻDYM 410 — w tym podczas niewinnego wyścigu
513
+ // ze świeżym startem turnu (klient retry'uje 410 z backoffem zanim
514
+ // listener zdąży wywołać startStream). Stąd próg wieku rowa: świeże
515
+ // rowy zostawiamy w spokoju, naprawiamy tylko sieroty po restarcie.
516
+ .mutateMethod(
517
+ "markInterrupted",
518
+ (fn) => fn.withParams({
519
+ messageId,
520
+ }).handle(
521
+ ONLY_SERVER &&
522
+ (async (ctx, params) => {
523
+ const MIN_ORPHAN_AGE_MS = 10_000;
524
+ const row = await ctx.$query.findOne({
525
+ where: { _id: params.messageId },
526
+ });
527
+ if (!row) return { ok: false };
528
+ const r = row as any;
529
+ if (r.role !== "assistant" || !r.isGenerating || r.interrupted) {
530
+ return { ok: false };
531
+ }
532
+ const age = Date.now() - new Date(r.createdAt).getTime();
533
+ if (age < MIN_ORPHAN_AGE_MS) return { ok: false };
534
+
535
+ await ctx.generationInterrupted.emit({
536
+ messageId: params.messageId,
537
+ });
538
+ return { ok: true };
539
+ }),
540
+ ),
541
+ )
542
+
470
543
  // ─── startStage — initiate stage with a default priming prompt ─
471
544
  // Stored as role="system" so the UI timeline hides it, but the AI
472
545
  // generation listener still picks it up as a conversational turn
@@ -552,12 +625,20 @@ export const createMessageAggregate = <
552
625
  )
553
626
 
554
627
  // ─── getByScope ─────────────────────────────────────────────
628
+ // Jawny orderBy — bez niego Postgres zwraca kolejność heapu, którą
629
+ // UPDATE (np. completeAssistantTurn) fizycznie przemieszcza, a klient
630
+ // renderuje dokładnie w kolejności serwera. Tie-break w ramach tej
631
+ // samej sekundy (mutacje emitujące kilka rzędów naraz) robi derywacja
632
+ // timeline'u po stronie klienta (rola, potem _id).
555
633
  .clientQuery(
556
634
  "getByScope",
557
635
  (fn) => fn
558
636
  .withParams({ scopeId: string() })
559
637
  .handle(async (ctx, params) =>
560
- ctx.$query.find({ where: { scopeId: params.scopeId } }),
638
+ ctx.$query.find({
639
+ where: { scopeId: params.scopeId },
640
+ orderBy: { createdAt: "asc" },
641
+ }),
561
642
  ),
562
643
  )
563
644
 
@@ -420,6 +420,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
420
420
  const streamRoute = createChatStreamRoute({
421
421
  name,
422
422
  userToken,
423
+ messageElement: Message,
423
424
  });
424
425
 
425
426
  const elements: ArcContextElement<any>[] = [
@@ -16,6 +16,7 @@ import {
16
16
  startStream,
17
17
  type PublishableEvent,
18
18
  } from "../streaming/stream-registry";
19
+ import { orderMessages } from "../ordering";
19
20
 
20
21
  // ─── Config ─────────────────────────────────────────────────────
21
22
 
@@ -99,7 +100,10 @@ function buildHistory(
99
100
  ): ConversationTurn[] {
100
101
  const turns: ConversationTurn[] = [];
101
102
 
102
- for (const msg of messages) {
103
+ // Kanoniczna kolejność konwersacji patrz src/ordering.ts. Bez tego
104
+ // pre-utworzony assistant placeholder (emitowany PRZED user rowem) po
105
+ // ukończeniu turnu lądowałby w historii LLM przed pytaniem użytkownika.
106
+ for (const msg of orderMessages(messages)) {
103
107
  if (msg._id === skipMessageId) continue;
104
108
 
105
109
  // System messages are developer-injected priming prompts (stage welcome,
@@ -117,8 +121,14 @@ function buildHistory(
117
121
 
118
122
  if (msg.role === "assistant") {
119
123
  if (msg.isGenerating && !msg.blocks) continue;
124
+ // `blocks` to string w świeżym in-memory store, ale po hydracji
125
+ // z Postgresa adapter zwraca JUŻ sparsowaną tablicę (deserializeValue
126
+ // auto-parsuje text wyglądający jak JSON). Bez tolerancji obu kształtów
127
+ // historia LLM traciła całą treść asystenta po restarcie serwera.
120
128
  let blocks: AssistantContentBlock[] = [];
121
- if (typeof msg.blocks === "string" && msg.blocks.length > 0) {
129
+ if (Array.isArray(msg.blocks)) {
130
+ blocks = msg.blocks;
131
+ } else if (typeof msg.blocks === "string" && msg.blocks.length > 0) {
122
132
  try {
123
133
  blocks = JSON.parse(msg.blocks);
124
134
  } catch {
@@ -138,7 +148,12 @@ function buildHistory(
138
148
  role: "tool_result",
139
149
  toolCallId: msg.toolCallId,
140
150
  name: msg.toolName,
141
- content: msg.content ?? "",
151
+ // Tolerancja kształtu jak przy blocks — JSON-owy content wraca
152
+ // z Postgresa sparsowany, a provider oczekuje stringa.
153
+ content:
154
+ typeof msg.content === "string"
155
+ ? msg.content
156
+ : JSON.stringify(msg.content ?? ""),
142
157
  });
143
158
  }
144
159
  }