@arcote.tech/arc-chat 0.7.10 → 0.7.11
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 +258 -0
- package/package.json +7 -7
- package/src/aggregates/message.ts +74 -58
- package/src/chat-builder.ts +7 -1
- package/src/index.ts +14 -2
- package/src/listeners/ai-generation-listener.ts +155 -178
- package/src/react/chat-component.tsx +241 -204
- package/src/routes/chat-stream-route.ts +21 -10
- package/src/streaming/stream-registry.ts +252 -118
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# @arcote.tech/arc-chat
|
|
2
|
+
|
|
3
|
+
Chat fragment dla Arc — Conversation/Message aggregate + AI generation listener
|
|
4
|
+
+ SSE streaming + React component. Builder API: `chat(name).identifyBy(...).ai(...).build()`.
|
|
5
|
+
|
|
6
|
+
Ten dokument tłumaczy **jak chat działa**. Nie powtarza tego, co jest w kodzie —
|
|
7
|
+
opisuje **mental model**, którego trzeba się trzymać przy każdej modyfikacji,
|
|
8
|
+
żeby nie zepsuć architektury.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Mental model
|
|
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.**
|
|
16
|
+
|
|
17
|
+
W trakcie generacji LLM streamuje chunki do `stream-registry` (in-memory,
|
|
18
|
+
per `messageId`). Klient subskrybuje SSE po `messageId` i dostaje:
|
|
19
|
+
|
|
20
|
+
1. `init` — snapshot aktualnego `currentBlocks` w momencie podłączenia
|
|
21
|
+
2. live `text_delta` / `tool_call_*` — kolejne chunki
|
|
22
|
+
3. `done` — koniec turny
|
|
23
|
+
|
|
24
|
+
Dopiero po `provider.streamComplete()` zwróci pełen wynik, listener wywołuje
|
|
25
|
+
`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.
|
|
28
|
+
|
|
29
|
+
**To NIE jest event-sourcing dla streamingu.** Snapshoty częściowej treści
|
|
30
|
+
do DB były anti-pattern (niepotrzebny narzut, dublowanie stanu). Stream-registry
|
|
31
|
+
to autorytatywne źródło live wartości; DB to autorytatywne źródło stanu po
|
|
32
|
+
zamknięciu turny.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Komponenty
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
src/
|
|
40
|
+
├─ aggregates/message.ts Aggregate: pola, eventy, mutacje
|
|
41
|
+
├─ 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()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Flow end-to-end
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
USER wpisuje "Cześć", klika Send
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
sendMessage mutation (atomowo)
|
|
59
|
+
├─ emit assistantTurnStarted → projection: set empty assistant row
|
|
60
|
+
│ (isGenerating=true, brak blocks)
|
|
61
|
+
└─ emit messageSent → projection: set user row
|
|
62
|
+
→ triggeruje aiGenerationListener (async)
|
|
63
|
+
│
|
|
64
|
+
▼
|
|
65
|
+
DB query getByScope() pushuje obie wiadomości do klienta
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
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
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
Listener: startStream() → provider.streamComplete(onChunk)
|
|
90
|
+
onChunk → publish(messageId, event)
|
|
91
|
+
├─ mutuje currentBlocks (text append / push tool_call / set args)
|
|
92
|
+
└─ broadcast SSE do wszystkich subscribers
|
|
93
|
+
Klient SSE → processEvent → setTimeline
|
|
94
|
+
│
|
|
95
|
+
▼
|
|
96
|
+
streamComplete zwraca pełen result.blocks
|
|
97
|
+
│
|
|
98
|
+
▼
|
|
99
|
+
completeAssistantTurn({ blocks }) ← jedyny zapis treści do DB
|
|
100
|
+
│
|
|
101
|
+
▼
|
|
102
|
+
finalize(messageId, { usage, finishReason })
|
|
103
|
+
├─ broadcast done do subscriberów
|
|
104
|
+
├─ close controllery
|
|
105
|
+
└─ setTimeout(delete, 5s) — grace dla late subscribers
|
|
106
|
+
│
|
|
107
|
+
▼
|
|
108
|
+
Klient SSE: done → setIsStreaming(false)
|
|
109
|
+
DB query update: isGenerating=false, blocks=...
|
|
110
|
+
└─ historySig refire → timeline rebuild z DB final blocks
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Edge cases
|
|
116
|
+
|
|
117
|
+
### Graceful reload mid-stream (F5)
|
|
118
|
+
|
|
119
|
+
Serwer i listener nadal generują. Klient po refresh:
|
|
120
|
+
|
|
121
|
+
1. DB query zwraca assistant row z `isGenerating=true`
|
|
122
|
+
2. `activeGeneratingMessageId` ustawia się → hook otwiera SSE
|
|
123
|
+
3. `subscribe(messageId)` zwraca aktualny `currentBlocks` w `init` event
|
|
124
|
+
4. Klient renderuje to, co już zostało wygenerowane + kontynuuje live
|
|
125
|
+
|
|
126
|
+
**Bez duplikacji** — brak replay buffer'a chunków, jest jeden snapshot.
|
|
127
|
+
|
|
128
|
+
### Server restart mid-stream
|
|
129
|
+
|
|
130
|
+
Proces ginie z `currentBlocks` w pamięci → utrata. DB ma row
|
|
131
|
+
`isGenerating=true` ale `subscribe(messageId)` zwraca `null` → route oddaje
|
|
132
|
+
HTTP 410.
|
|
133
|
+
|
|
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`
|
|
141
|
+
|
|
142
|
+
### Server tool call w środku tury
|
|
143
|
+
|
|
144
|
+
Po `streamComplete` z `finishReason="tool_call"`:
|
|
145
|
+
|
|
146
|
+
1. `completeAssistantTurn(blocks)` — assistant row finalizowany (blocks
|
|
147
|
+
zawiera tool_call w properOrder)
|
|
148
|
+
2. `finalize(messageId)` — stream zamknięty
|
|
149
|
+
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
|
|
153
|
+
|
|
154
|
+
Każda iteracja loop'a = **osobny `messageId` = osobny stream**.
|
|
155
|
+
|
|
156
|
+
### Interactive tool (np. askQuestions)
|
|
157
|
+
|
|
158
|
+
Po `streamComplete` z interactive tool calls:
|
|
159
|
+
|
|
160
|
+
1. `completeAssistantTurn` + `finalize` — pierwsza tura zamknięta
|
|
161
|
+
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
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Stream-registry API
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
startStream(messageId) // idempotent. Listener woła przed publish
|
|
173
|
+
publish(messageId, event) // mutuje currentBlocks + broadcast SSE
|
|
174
|
+
subscribe(messageId): { // route handler. null → 410
|
|
175
|
+
stream, currentBlocks
|
|
176
|
+
} | null
|
|
177
|
+
finalize(messageId, finalDetails?) // broadcast done, close, delete po 5s
|
|
178
|
+
isActive(messageId): boolean // health check
|
|
179
|
+
getCurrentBlocks(messageId) // debug/test, readonly
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`PublishableEvent` to subset `ChatStreamEvent` bez `init/done/messageId` —
|
|
183
|
+
`init/done` emit'uje registry, `messageId` wstrzykuje się automatycznie.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Key invariants
|
|
188
|
+
|
|
189
|
+
**Live wartość:**
|
|
190
|
+
- `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ć
|
|
194
|
+
|
|
195
|
+
**DB:**
|
|
196
|
+
- Assistant row z `isGenerating=true` ma `blocks=undefined`
|
|
197
|
+
- Po `assistantTurnCompleted` row ma `isGenerating=false` + `blocks` final
|
|
198
|
+
- Treść NIGDY nie ląduje w DB chunk po chunku
|
|
199
|
+
|
|
200
|
+
**Stream lifecycle:**
|
|
201
|
+
- `startStream(messageId)` PRZED pierwszym `publish` (listener gwarantuje)
|
|
202
|
+
- `finalize(messageId)` PO `completeAssistantTurn` (DB → in-memory order)
|
|
203
|
+
- Każda iteracja generation loop'a → osobny `messageId` → osobny stream
|
|
204
|
+
|
|
205
|
+
**Subscribe:**
|
|
206
|
+
- 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".
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Gotchas dla modyfikacji
|
|
213
|
+
|
|
214
|
+
**`assistantTurnStarted` emit'owany PRZED `messageSent`/`userResponded`/
|
|
215
|
+
`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.
|
|
228
|
+
|
|
229
|
+
**`buildHistory` w listenerze pomija `assistant` rows z `isGenerating=true
|
|
230
|
+
&& !blocks`.** Czyli interrupted rows (przed retryRequested projection)
|
|
231
|
+
oraz fresh rows w trakcie generacji nie trafiają do LLM history. Po retry
|
|
232
|
+
fresh row też jest skip'owany — historia kończy się na ostatniej user
|
|
233
|
+
message, LLM kontynuuje od niej.
|
|
234
|
+
|
|
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
|
+
**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.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Powiązane fragmenty
|
|
252
|
+
|
|
253
|
+
- `@arcote.tech/arc-ai` — provider abstraction, `StreamChunk`, `ChatStreamEvent`,
|
|
254
|
+
tool system, billing
|
|
255
|
+
- `@arcote.tech/arc-ai-{openai,claude,gemini}` — implementacje providerów
|
|
256
|
+
- `@arcote.tech/arc-ds` — DS components: Chat, ChatMessage, ChatInput,
|
|
257
|
+
ChatToolLog, ChatLabels
|
|
258
|
+
- `@arcote.tech/arc-ai-voice` — VoiceTextarea (dyktowanie głosowe out-of-the-box)
|
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.11",
|
|
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.11",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.11",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.11",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.11",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.11",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.11",
|
|
19
19
|
"lucide-react": ">=0.400.0",
|
|
20
20
|
"react": ">=18.0.0",
|
|
21
21
|
"typescript": "^5.0.0"
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
boolean,
|
|
5
5
|
date,
|
|
6
6
|
id,
|
|
7
|
-
number,
|
|
8
7
|
string,
|
|
9
8
|
type ArcId,
|
|
10
9
|
} from "@arcote.tech/arc";
|
|
@@ -79,18 +78,6 @@ export const createMessageAggregate = <
|
|
|
79
78
|
previousResponseId: string().optional(),
|
|
80
79
|
isGenerating: boolean().optional(),
|
|
81
80
|
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(),
|
|
94
81
|
createdAt: date(),
|
|
95
82
|
})
|
|
96
83
|
|
|
@@ -153,30 +140,10 @@ export const createMessageAggregate = <
|
|
|
153
140
|
},
|
|
154
141
|
)
|
|
155
142
|
|
|
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
|
-
|
|
176
143
|
// ─── assistantTurnCompleted — finalize an in-progress turn row ───
|
|
177
|
-
//
|
|
178
|
-
// `
|
|
179
|
-
//
|
|
144
|
+
// Jedyny zapis treści w trakcie turnu. Listener mutuje in-memory
|
|
145
|
+
// stream-registry per chunk; finalne `blocks` lądują w DB raz tutaj,
|
|
146
|
+
// gdy `provider.streamComplete` zwraca pełen wynik.
|
|
180
147
|
.publicEvent(
|
|
181
148
|
"assistantTurnCompleted",
|
|
182
149
|
{
|
|
@@ -193,8 +160,6 @@ export const createMessageAggregate = <
|
|
|
193
160
|
previousResponseId: p.previousResponseId,
|
|
194
161
|
usage: p.usage,
|
|
195
162
|
isGenerating: false,
|
|
196
|
-
partialBlocks: undefined,
|
|
197
|
-
partialLastSeq: undefined,
|
|
198
163
|
} as any);
|
|
199
164
|
},
|
|
200
165
|
)
|
|
@@ -252,6 +217,30 @@ export const createMessageAggregate = <
|
|
|
252
217
|
},
|
|
253
218
|
)
|
|
254
219
|
|
|
220
|
+
// ─── retryRequested — user retries an interrupted generation ─
|
|
221
|
+
// Stream-registry zniknął (server restart / proces crash) podczas
|
|
222
|
+
// generacji — interrupted assistant row siedzi w DB z `isGenerating=true`
|
|
223
|
+
// bez `blocks`. Klient widzi SSE 410 i wyświetla "Generation interrupted"
|
|
224
|
+
// + Retry button. Mutacja `retryGeneration` emituje DWA eventy:
|
|
225
|
+
// 1) `assistantTurnStarted` — tworzy fresh assistant row (jak w `sendMessage`)
|
|
226
|
+
// 2) `retryRequested` — usuwa interrupted row + triggeruje `aiRetryListener`
|
|
227
|
+
.publicEvent(
|
|
228
|
+
"retryRequested",
|
|
229
|
+
{
|
|
230
|
+
/** Fresh assistant row utworzony razem z tym eventem. */
|
|
231
|
+
messageId,
|
|
232
|
+
scopeId,
|
|
233
|
+
sessionId: string(),
|
|
234
|
+
/** Interrupted assistant row do usunięcia z DB. */
|
|
235
|
+
interruptedMessageId: messageId,
|
|
236
|
+
model: string().optional(),
|
|
237
|
+
},
|
|
238
|
+
async (ctx, event) => {
|
|
239
|
+
const p = event.payload;
|
|
240
|
+
await ctx.remove(p.interruptedMessageId);
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
|
|
255
244
|
// ─── sendMessage — user sends message, creates session ──────
|
|
256
245
|
// Emit'uje DWA eventy w jednej transakcji: messageSent (user row) +
|
|
257
246
|
// assistantTurnStarted (empty assistant row z isGenerating=true). Dzięki
|
|
@@ -325,26 +314,6 @@ export const createMessageAggregate = <
|
|
|
325
314
|
),
|
|
326
315
|
)
|
|
327
316
|
|
|
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
|
-
|
|
348
317
|
// ─── completeAssistantTurn — partial update of the open turn row ─
|
|
349
318
|
.mutateMethod(
|
|
350
319
|
"completeAssistantTurn",
|
|
@@ -437,6 +406,53 @@ export const createMessageAggregate = <
|
|
|
437
406
|
),
|
|
438
407
|
)
|
|
439
408
|
|
|
409
|
+
// ─── retryGeneration — re-run generation for an interrupted turn ─
|
|
410
|
+
// Wywoływane gdy klient widzi `isGenerating=true` row + 410 z SSE
|
|
411
|
+
// (proces zrestartował się mid-stream). Tworzy fresh assistant row i
|
|
412
|
+
// emituje `retryRequested` — `aiRetryListener` ponownie woła provider'a
|
|
413
|
+
// z aktualną historią (interrupted row jest usuwany przez projection).
|
|
414
|
+
.mutateMethod(
|
|
415
|
+
"retryGeneration",
|
|
416
|
+
(fn) => fn.withParams({
|
|
417
|
+
messageId,
|
|
418
|
+
}).handle(
|
|
419
|
+
ONLY_SERVER &&
|
|
420
|
+
(async (ctx, params) => {
|
|
421
|
+
const interrupted = await ctx.$query.findOne({
|
|
422
|
+
where: { _id: params.messageId },
|
|
423
|
+
});
|
|
424
|
+
if (!interrupted) {
|
|
425
|
+
throw new Error("retryGeneration: message not found");
|
|
426
|
+
}
|
|
427
|
+
if ((interrupted as any).role !== "assistant" || !(interrupted as any).isGenerating) {
|
|
428
|
+
throw new Error("retryGeneration: row is not an interrupted assistant turn");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const assistantMsgId = messageId.generate();
|
|
432
|
+
const newSessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
433
|
+
const model = (interrupted as any).model;
|
|
434
|
+
|
|
435
|
+
// KOLEJNOŚĆ EMIT — patrz komentarz w `sendMessage`.
|
|
436
|
+
await ctx.assistantTurnStarted.emit({
|
|
437
|
+
messageId: assistantMsgId,
|
|
438
|
+
scopeId: (interrupted as any).scopeId,
|
|
439
|
+
sessionId: newSessionId,
|
|
440
|
+
model,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await ctx.retryRequested.emit({
|
|
444
|
+
messageId: assistantMsgId,
|
|
445
|
+
scopeId: (interrupted as any).scopeId,
|
|
446
|
+
sessionId: newSessionId,
|
|
447
|
+
interruptedMessageId: params.messageId,
|
|
448
|
+
model,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return { messageId: assistantMsgId, sessionId: newSessionId };
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
|
|
440
456
|
// ─── startStage — initiate stage with a default priming prompt ─
|
|
441
457
|
// Stored as role="system" so the UI timeline hides it, but the AI
|
|
442
458
|
// generation listener still picks it up as a conversational turn
|
package/src/chat-builder.ts
CHANGED
|
@@ -14,7 +14,11 @@ import { tool as createToolFactory } from "@arcote.tech/arc-ai";
|
|
|
14
14
|
import type { ArcTokenAny } from "@arcote.tech/arc";
|
|
15
15
|
import type { ViewProtectionFn } from "@arcote.tech/arc";
|
|
16
16
|
import { createMessageId, createMessageAggregate } from "./aggregates/message";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createAiGenerationListener,
|
|
19
|
+
createAiResumeListener,
|
|
20
|
+
createAiRetryListener,
|
|
21
|
+
} from "./listeners/ai-generation-listener";
|
|
18
22
|
import { createChatStreamRoute } from "./routes/chat-stream-route";
|
|
19
23
|
import { createChatComponent } from "./react/chat-component";
|
|
20
24
|
import type { ChatInputTextareaSlotProps, ChatLabels } from "@arcote.tech/arc-ds";
|
|
@@ -265,6 +269,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
265
269
|
|
|
266
270
|
const aiListener = createAiGenerationListener(listenerConfig);
|
|
267
271
|
const aiResumeListener = createAiResumeListener(listenerConfig);
|
|
272
|
+
const aiRetryListener = createAiRetryListener(listenerConfig);
|
|
268
273
|
|
|
269
274
|
const streamRoute = createChatStreamRoute({
|
|
270
275
|
name,
|
|
@@ -275,6 +280,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
275
280
|
Message,
|
|
276
281
|
aiListener,
|
|
277
282
|
aiResumeListener,
|
|
283
|
+
aiRetryListener,
|
|
278
284
|
streamRoute,
|
|
279
285
|
];
|
|
280
286
|
|
package/src/index.ts
CHANGED
|
@@ -7,10 +7,22 @@ export { createMessageAggregate, createMessageId } from "./aggregates/message";
|
|
|
7
7
|
export type { MessageAggregate, MessageId } from "./aggregates/message";
|
|
8
8
|
|
|
9
9
|
// --- Streaming ---
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
startStream,
|
|
12
|
+
publish,
|
|
13
|
+
subscribe,
|
|
14
|
+
finalize,
|
|
15
|
+
isActive,
|
|
16
|
+
getCurrentBlocks,
|
|
17
|
+
} from "./streaming/stream-registry";
|
|
18
|
+
export type { PublishableEvent } from "./streaming/stream-registry";
|
|
11
19
|
|
|
12
20
|
// --- Listener ---
|
|
13
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
createAiGenerationListener,
|
|
23
|
+
createAiResumeListener,
|
|
24
|
+
createAiRetryListener,
|
|
25
|
+
} from "./listeners/ai-generation-listener";
|
|
14
26
|
export type { AiGenerationListenerConfig, InstructionResult } from "./listeners/ai-generation-listener";
|
|
15
27
|
|
|
16
28
|
// --- Routes ---
|