@arcote.tech/arc-chat 0.7.19 → 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 +175 -101
- package/package.json +7 -7
- package/src/aggregates/message.ts +83 -2
- package/src/chat-builder.ts +1 -0
- package/src/listeners/ai-generation-listener.ts +18 -3
- package/src/ordering.test.ts +118 -0
- package/src/ordering.ts +88 -0
- package/src/react/chat-component.tsx +189 -770
- package/src/react/derive-timeline.test.ts +654 -0
- package/src/react/derive-timeline.ts +416 -0
- package/src/react/use-assistant-overlays.ts +269 -0
- package/src/routes/chat-stream-route.ts +19 -5
- package/src/streaming/blocks-reducer.test.ts +126 -0
- package/src/streaming/blocks-reducer.ts +88 -0
- package/src/streaming/stream-registry.test.ts +64 -0
- package/src/streaming/stream-registry.ts +21 -49
- package/src/tools/ask-questions.tsx +7 -4
- package/src/react/use-chat.ts +0 -1
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
|
-
> **
|
|
15
|
-
> **
|
|
14
|
+
> **DB jest jedynym źródłem prawdy o strukturze konwersacji.**
|
|
15
|
+
> **Stream jest tylko overlayem — ulotnym podglądem trwającej generacji.**
|
|
16
|
+
> **Timeline jest czystą funkcją obu — nigdy mutowalnym stanem.**
|
|
16
17
|
|
|
17
|
-
|
|
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
|
|
23
|
+
3. `done` — koniec turnu (**advisory** — patrz niżej)
|
|
23
24
|
|
|
24
|
-
Dopiero
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
-
├─ routes/chat-stream-route.ts
|
|
44
|
-
|
|
45
|
-
├─
|
|
46
|
-
├─
|
|
47
|
-
└─
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
├─
|
|
106
|
+
├─ currentBlocks = applyStreamEvent(currentBlocks, event) ← shared reducer
|
|
92
107
|
└─ broadcast SSE do wszystkich subscribers
|
|
93
|
-
Klient
|
|
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
|
|
104
|
-
├─ close controllery
|
|
119
|
+
├─ broadcast done (advisory — zdejmuje caret)
|
|
105
120
|
└─ setTimeout(delete, 5s) — grace dla late subscribers
|
|
106
121
|
│
|
|
107
122
|
▼
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
└─
|
|
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.
|
|
122
|
-
2. `
|
|
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.
|
|
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
|
|
132
|
-
HTTP 410.
|
|
165
|
+
`isGenerating=true`, ale `subscribe(messageId)` zwraca `null` → 410.
|
|
133
166
|
|
|
134
|
-
1.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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`
|
|
151
|
-
|
|
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.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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) //
|
|
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
|
-
-
|
|
193
|
-
|
|
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
|
|
208
|
-
w mapie (poza grace window)
|
|
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.
|
|
217
|
-
|
|
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
|
|
242
|
-
trafiają do klienta przez
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
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.
|
|
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.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
|
-
|
|
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({
|
|
638
|
+
ctx.$query.find({
|
|
639
|
+
where: { scopeId: params.scopeId },
|
|
640
|
+
orderBy: { createdAt: "asc" },
|
|
641
|
+
}),
|
|
561
642
|
),
|
|
562
643
|
)
|
|
563
644
|
|
package/src/chat-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|