@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 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.10",
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.10",
14
- "@arcote.tech/arc-ai": "^0.7.10",
15
- "@arcote.tech/arc-ai-voice": "^0.7.10",
16
- "@arcote.tech/arc-auth": "^0.7.10",
17
- "@arcote.tech/arc-ds": "^0.7.10",
18
- "@arcote.tech/platform": "^0.7.10",
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
- // Partial update on the SAME row fills `blocks`, flips
178
- // `isGenerating` to false, optionally records `previousResponseId`,
179
- // `usage`, or `error`. Czyści `partialBlocks` / `partialLastSeq`.
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
@@ -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 { createAiGenerationListener, createAiResumeListener } from "./listeners/ai-generation-listener";
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 { broadcast, endStream, hasActiveStream, subscribe } from "./streaming/stream-registry";
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 { createAiGenerationListener } from "./listeners/ai-generation-listener";
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 ---