@arcote.tech/arc-chat 0.7.10 → 0.7.12
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 +80 -6
- package/src/index.ts +14 -2
- package/src/listeners/ai-generation-listener.ts +234 -179
- 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.12",
|
|
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.12",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.12",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.12",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.12",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.12",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.12",
|
|
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";
|
|
@@ -51,6 +55,22 @@ export interface ChatReactComponentOptions {
|
|
|
51
55
|
|
|
52
56
|
// ─── Chat Data ──────────────────────────────────────────────────
|
|
53
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Map snapshot token params (decoded payload of the token that protects this
|
|
60
|
+
* chat) to the scopeId we charge for an AI call. Consumers wire this via
|
|
61
|
+
* `.billTo(fn)`. Examples for typical setups:
|
|
62
|
+
*
|
|
63
|
+
* .billTo(p => p.accountId) // per-user billing
|
|
64
|
+
* .billTo(p => p.workspaceId) // per-workspace billing
|
|
65
|
+
*
|
|
66
|
+
* Called inside the ai-generation-listener with `ctx.$auth.params` (which
|
|
67
|
+
* is the decoded payload of the token snapshotted at `messageSent` emit
|
|
68
|
+
* time — i.e. whatever token the chat's `.protectBy(...)` is configured
|
|
69
|
+
* with). The returned string becomes the `_id` of the ledger row that gets
|
|
70
|
+
* debited.
|
|
71
|
+
*/
|
|
72
|
+
export type BillToFn = (tokenParams: Record<string, any>) => string;
|
|
73
|
+
|
|
54
74
|
export interface ArcChatData {
|
|
55
75
|
name: string;
|
|
56
76
|
identifyBy: ArcId<any> | null;
|
|
@@ -63,6 +83,8 @@ export interface ArcChatData {
|
|
|
63
83
|
tools: ArcToolAny[];
|
|
64
84
|
maxExecutionCount: number;
|
|
65
85
|
toolChoice: "auto" | "required" | { type: "function"; name: string };
|
|
86
|
+
alias: string | null;
|
|
87
|
+
billTo: BillToFn | null;
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
const defaultChatData = {
|
|
@@ -77,6 +99,8 @@ const defaultChatData = {
|
|
|
77
99
|
tools: [],
|
|
78
100
|
maxExecutionCount: 10,
|
|
79
101
|
toolChoice: "auto" as const,
|
|
102
|
+
alias: null,
|
|
103
|
+
billTo: null,
|
|
80
104
|
} as const satisfies ArcChatData;
|
|
81
105
|
|
|
82
106
|
type DefaultChatData = typeof defaultChatData;
|
|
@@ -160,6 +184,41 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
160
184
|
} as any);
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Billing alias for this chat — written to the `usageRecorded` event
|
|
189
|
+
* payload (see `@arcote.tech/arc-ai`). Defaults to chat `name`. Override
|
|
190
|
+
* when you want consistent reporting across renames or to group multiple
|
|
191
|
+
* chats under one alias for admin SQL reports.
|
|
192
|
+
*
|
|
193
|
+
* chat("identityConsultation").alias("chat-identity")...
|
|
194
|
+
*/
|
|
195
|
+
alias<const A extends string>(alias: A) {
|
|
196
|
+
return new ArcChat<Merge<Data, { alias: A }>>({
|
|
197
|
+
...this.data,
|
|
198
|
+
alias,
|
|
199
|
+
} as any);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Decide which scopeId to bill for an AI call made from this chat.
|
|
204
|
+
*
|
|
205
|
+
* Called with the decoded params of the token snapshotted at `messageSent`
|
|
206
|
+
* emit time (i.e. the token used in `.protectBy(...)`). The returned string
|
|
207
|
+
* becomes the `_id` of the row in `creditLedger` that gets debited.
|
|
208
|
+
*
|
|
209
|
+
* .billTo(p => p.accountId) // per-user billing
|
|
210
|
+
* .billTo(p => p.workspaceId) // per-workspace billing
|
|
211
|
+
*
|
|
212
|
+
* Required when `.ai(...)` config has billing wired — `build()` throws
|
|
213
|
+
* otherwise. Without billing, this method is a no-op.
|
|
214
|
+
*/
|
|
215
|
+
billTo(fn: BillToFn) {
|
|
216
|
+
return new ArcChat<Merge<Data, { billTo: BillToFn }>>({
|
|
217
|
+
...this.data,
|
|
218
|
+
billTo: fn,
|
|
219
|
+
} as any);
|
|
220
|
+
}
|
|
221
|
+
|
|
163
222
|
createTool<const N extends string>(name: N) {
|
|
164
223
|
type IdType = Data["identifyBy"] extends ArcId<any>
|
|
165
224
|
? $type<Data["identifyBy"]>
|
|
@@ -191,6 +250,8 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
191
250
|
tools,
|
|
192
251
|
maxExecutionCount,
|
|
193
252
|
toolChoice,
|
|
253
|
+
alias: aliasOverride,
|
|
254
|
+
billTo,
|
|
194
255
|
} = this.data;
|
|
195
256
|
|
|
196
257
|
if (!name) throw new Error("ArcChat: name is required");
|
|
@@ -198,6 +259,13 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
198
259
|
if (!accountId) throw new Error("ArcChat: accountId is required");
|
|
199
260
|
if (!userToken) throw new Error("ArcChat: userToken is required");
|
|
200
261
|
if (!aiConfig) throw new Error("ArcChat: ai is required");
|
|
262
|
+
// Billing wired but no `.billTo(...)` would silently skip recordUsage —
|
|
263
|
+
// forbid that. Consumer must explicitly decide which scopeId to charge.
|
|
264
|
+
if (aiConfig.recordUsage && !billTo) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`ArcChat "${name}": ai() factory has billing wired but chat is missing .billTo(...) — declare how to map snapshot token params to a billing scope, e.g. .billTo(p => p.accountId).`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
201
269
|
|
|
202
270
|
const messageId = createMessageId({ name });
|
|
203
271
|
|
|
@@ -240,13 +308,14 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
240
308
|
const serverTools = tools.filter((t) => t.isServerTool);
|
|
241
309
|
const interactiveTools = tools.filter((t) => t.isInteractiveTool);
|
|
242
310
|
|
|
243
|
-
// Add
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
311
|
+
// Add usage-registry aggregate to listener mutate deps so `recordUsage`
|
|
312
|
+
// (which calls `ctx.mutate(registry).recordUsage(...)`) compiles and runs.
|
|
313
|
+
// Ledger view is a read-only projection — consumer queries it from React,
|
|
314
|
+
// listener never writes to it directly.
|
|
315
|
+
if (aiConfig.usageRegistry) {
|
|
316
|
+
for (const el of aiConfig.usageRegistry.elements) {
|
|
247
317
|
if (!allMutationElements.includes(el)) allMutationElements.push(el);
|
|
248
318
|
if (!allQueryElements.includes(el)) allQueryElements.push(el);
|
|
249
|
-
billingElements.push(el);
|
|
250
319
|
}
|
|
251
320
|
}
|
|
252
321
|
|
|
@@ -261,10 +330,14 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
261
330
|
allMutationElements,
|
|
262
331
|
maxExecutionCount,
|
|
263
332
|
toolChoice: toolChoice !== "auto" ? toolChoice : undefined,
|
|
333
|
+
alias: aliasOverride ?? name,
|
|
334
|
+
recordUsage: aiConfig.recordUsage,
|
|
335
|
+
billTo: billTo ?? undefined,
|
|
264
336
|
};
|
|
265
337
|
|
|
266
338
|
const aiListener = createAiGenerationListener(listenerConfig);
|
|
267
339
|
const aiResumeListener = createAiResumeListener(listenerConfig);
|
|
340
|
+
const aiRetryListener = createAiRetryListener(listenerConfig);
|
|
268
341
|
|
|
269
342
|
const streamRoute = createChatStreamRoute({
|
|
270
343
|
name,
|
|
@@ -275,6 +348,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
275
348
|
Message,
|
|
276
349
|
aiListener,
|
|
277
350
|
aiResumeListener,
|
|
351
|
+
aiRetryListener,
|
|
278
352
|
streamRoute,
|
|
279
353
|
];
|
|
280
354
|
|
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 ---
|