@arcote.tech/arc-chat 0.7.11 → 0.7.13

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.11",
4
+ "version": "0.7.13",
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.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",
13
+ "@arcote.tech/arc": "^0.7.13",
14
+ "@arcote.tech/arc-ai": "^0.7.13",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.13",
16
+ "@arcote.tech/arc-auth": "^0.7.13",
17
+ "@arcote.tech/arc-ds": "^0.7.13",
18
+ "@arcote.tech/platform": "^0.7.13",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -100,6 +100,13 @@ export const createMessageAggregate = <
100
100
  * otwiera SSE i streaming jest visible od pierwszego chunka.
101
101
  */
102
102
  assistantMessageId: messageId.optional(),
103
+ /**
104
+ * JSON-encoded `string[]` z fileId'ami arc-files attachmentów do tej
105
+ * konkretnej wiadomości. Listener AI generation ładuje `ArcFile`
106
+ * records i przekazuje je w `CompletionRequest.files`. Nie zapisujemy
107
+ * w aggregate fields — flow jest read-once przez listener z event.
108
+ */
109
+ attachmentsJson: string().optional(),
103
110
  },
104
111
  async (ctx, event) => {
105
112
  const p = event.payload;
@@ -253,6 +260,12 @@ export const createMessageAggregate = <
253
260
  scopeId,
254
261
  content: string().minLength(1),
255
262
  model: string(),
263
+ /**
264
+ * Opcjonalna JSON-encoded `string[]` z fileId'ami arc-files
265
+ * dołączonymi do user message. Listener AI generation ładuje
266
+ * pełne records po stronie serwera.
267
+ */
268
+ attachmentsJson: string().optional(),
256
269
  }).handle(
257
270
  ONLY_SERVER &&
258
271
  (async (ctx, params) => {
@@ -282,6 +295,7 @@ export const createMessageAggregate = <
282
295
  content: params.content,
283
296
  model: params.model,
284
297
  assistantMessageId: assistantMsgId,
298
+ attachmentsJson: params.attachmentsJson,
285
299
  });
286
300
 
287
301
  return { messageId: userMsgId, sessionId, assistantMessageId: assistantMsgId };
@@ -55,6 +55,22 @@ export interface ChatReactComponentOptions {
55
55
 
56
56
  // ─── Chat Data ──────────────────────────────────────────────────
57
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
+
58
74
  export interface ArcChatData {
59
75
  name: string;
60
76
  identifyBy: ArcId<any> | null;
@@ -67,6 +83,19 @@ export interface ArcChatData {
67
83
  tools: ArcToolAny[];
68
84
  maxExecutionCount: number;
69
85
  toolChoice: "auto" | "required" | { type: "function"; name: string };
86
+ alias: string | null;
87
+ billTo: BillToFn | null;
88
+ /**
89
+ * `arc-files` aggregate dla file attachmentów (PDF/DOCX/...) do user
90
+ * messages. Gdy ustawione, listener AI generation:
91
+ * - ładuje `ArcFile` records po fileId'ach z `attachmentsJson` user msg,
92
+ * - przekazuje je w `CompletionRequest.files`,
93
+ * - po complete cachuje `boundProviderFiles` w aggregate'cie (lazy
94
+ * upload do OpenAI Files API → file_id zapisany w `providerFileIdsJson`).
95
+ *
96
+ * Consumer wstrzykuje przez `.attachFiles({ File })`.
97
+ */
98
+ attachFiles: { File: any } | null;
70
99
  }
71
100
 
72
101
  const defaultChatData = {
@@ -81,6 +110,9 @@ const defaultChatData = {
81
110
  tools: [],
82
111
  maxExecutionCount: 10,
83
112
  toolChoice: "auto" as const,
113
+ alias: null,
114
+ billTo: null,
115
+ attachFiles: null,
84
116
  } as const satisfies ArcChatData;
85
117
 
86
118
  type DefaultChatData = typeof defaultChatData;
@@ -164,6 +196,41 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
164
196
  } as any);
165
197
  }
166
198
 
199
+ /**
200
+ * Billing alias for this chat — written to the `usageRecorded` event
201
+ * payload (see `@arcote.tech/arc-ai`). Defaults to chat `name`. Override
202
+ * when you want consistent reporting across renames or to group multiple
203
+ * chats under one alias for admin SQL reports.
204
+ *
205
+ * chat("identityConsultation").alias("chat-identity")...
206
+ */
207
+ alias<const A extends string>(alias: A) {
208
+ return new ArcChat<Merge<Data, { alias: A }>>({
209
+ ...this.data,
210
+ alias,
211
+ } as any);
212
+ }
213
+
214
+ /**
215
+ * Decide which scopeId to bill for an AI call made from this chat.
216
+ *
217
+ * Called with the decoded params of the token snapshotted at `messageSent`
218
+ * emit time (i.e. the token used in `.protectBy(...)`). The returned string
219
+ * becomes the `_id` of the row in `creditLedger` that gets debited.
220
+ *
221
+ * .billTo(p => p.accountId) // per-user billing
222
+ * .billTo(p => p.workspaceId) // per-workspace billing
223
+ *
224
+ * Required when `.ai(...)` config has billing wired — `build()` throws
225
+ * otherwise. Without billing, this method is a no-op.
226
+ */
227
+ billTo(fn: BillToFn) {
228
+ return new ArcChat<Merge<Data, { billTo: BillToFn }>>({
229
+ ...this.data,
230
+ billTo: fn,
231
+ } as any);
232
+ }
233
+
167
234
  createTool<const N extends string>(name: N) {
168
235
  type IdType = Data["identifyBy"] extends ArcId<any>
169
236
  ? $type<Data["identifyBy"]>
@@ -182,6 +249,22 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
182
249
  } as any);
183
250
  }
184
251
 
252
+ /**
253
+ * Włącza obsługę file attachments dla user messages — listener AI
254
+ * generation będzie ładował `ArcFile` records, przekazywał je w
255
+ * `CompletionRequest.files`, i cachował lazy-uploadowane `file_id`'y
256
+ * provider'ów (OpenAI Files API).
257
+ *
258
+ * const files = arcFiles({ name: "ndt", scopeId: workspaceId, ... });
259
+ * chat("identity").attachFiles({ File: files.File }).build();
260
+ */
261
+ attachFiles(config: { File: any }): ArcChat<Data> {
262
+ return new ArcChat<Data>({
263
+ ...this.data,
264
+ attachFiles: config,
265
+ } as any);
266
+ }
267
+
185
268
  build() {
186
269
  const {
187
270
  name,
@@ -195,6 +278,9 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
195
278
  tools,
196
279
  maxExecutionCount,
197
280
  toolChoice,
281
+ alias: aliasOverride,
282
+ billTo,
283
+ attachFiles,
198
284
  } = this.data;
199
285
 
200
286
  if (!name) throw new Error("ArcChat: name is required");
@@ -202,6 +288,13 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
202
288
  if (!accountId) throw new Error("ArcChat: accountId is required");
203
289
  if (!userToken) throw new Error("ArcChat: userToken is required");
204
290
  if (!aiConfig) throw new Error("ArcChat: ai is required");
291
+ // Billing wired but no `.billTo(...)` would silently skip recordUsage —
292
+ // forbid that. Consumer must explicitly decide which scopeId to charge.
293
+ if (aiConfig.recordUsage && !billTo) {
294
+ throw new Error(
295
+ `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).`,
296
+ );
297
+ }
205
298
 
206
299
  const messageId = createMessageId({ name });
207
300
 
@@ -244,16 +337,65 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
244
337
  const serverTools = tools.filter((t) => t.isServerTool);
245
338
  const interactiveTools = tools.filter((t) => t.isInteractiveTool);
246
339
 
247
- // Add ledger element to mutation deps if billing configured
248
- const billingElements: ArcContextElement<any>[] = [];
249
- if (aiConfig.billing) {
250
- for (const el of aiConfig.billing.ledger.elements) {
340
+ // Add usage-registry aggregate to listener mutate deps so `recordUsage`
341
+ // (which calls `ctx.mutate(registry).recordUsage(...)`) compiles and runs.
342
+ // Ledger view is a read-only projection — consumer queries it from React,
343
+ // listener never writes to it directly.
344
+ if (aiConfig.usageRegistry) {
345
+ for (const el of aiConfig.usageRegistry.elements) {
251
346
  if (!allMutationElements.includes(el)) allMutationElements.push(el);
252
347
  if (!allQueryElements.includes(el)) allQueryElements.push(el);
253
- billingElements.push(el);
254
348
  }
255
349
  }
256
350
 
351
+ // Attachments bridge — gdy chat woła `.attachFiles({File})`, dodajemy
352
+ // File aggregate do query/mutation deps i budujemy callbacks używane
353
+ // przez listener do: (a) load ArcFile records dla user attachmentów,
354
+ // (b) cache providerFileId po lazy upload.
355
+ let attachmentsBridge: any;
356
+ if (attachFiles) {
357
+ const FileAgg = attachFiles.File;
358
+ if (!allQueryElements.includes(FileAgg)) allQueryElements.push(FileAgg);
359
+ if (!allMutationElements.includes(FileAgg))
360
+ allMutationElements.push(FileAgg);
361
+ attachmentsBridge = {
362
+ loadAttachments: async (ctx: any, fileIds: string[]) => {
363
+ const refs: any[] = [];
364
+ for (const fileId of fileIds) {
365
+ const rec = await ctx
366
+ .query(FileAgg)
367
+ .getById({ _id: fileId });
368
+ if (!rec) continue;
369
+ let providerFileIds: Record<string, string> = {};
370
+ if (typeof rec.providerFileIdsJson === "string") {
371
+ try {
372
+ const parsed = JSON.parse(rec.providerFileIdsJson);
373
+ if (parsed && typeof parsed === "object") providerFileIds = parsed;
374
+ } catch {}
375
+ }
376
+ refs.push({
377
+ fileId,
378
+ name: rec.name,
379
+ mime: rec.mime,
380
+ s3Key: rec.s3Key,
381
+ providerFileIds,
382
+ });
383
+ }
384
+ return refs;
385
+ },
386
+ bindProviderFileId: async (
387
+ ctx: any,
388
+ fileId: string,
389
+ provider: string,
390
+ providerFileId: string,
391
+ ) => {
392
+ await ctx
393
+ .mutate(FileAgg)
394
+ .bindProviderFileId({ _id: fileId, provider, providerFileId });
395
+ },
396
+ };
397
+ }
398
+
257
399
  const listenerConfig = {
258
400
  name,
259
401
  messageElement: Message,
@@ -265,6 +407,10 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
265
407
  allMutationElements,
266
408
  maxExecutionCount,
267
409
  toolChoice: toolChoice !== "auto" ? toolChoice : undefined,
410
+ alias: aliasOverride ?? name,
411
+ recordUsage: aiConfig.recordUsage,
412
+ billTo: billTo ?? undefined,
413
+ attachments: attachmentsBridge,
268
414
  };
269
415
 
270
416
  const aiListener = createAiGenerationListener(listenerConfig);
@@ -1,8 +1,10 @@
1
1
  /// <reference path="../arc.d.ts" />
2
2
  import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech/arc";
3
3
  import type {
4
+ ArcFileRef,
4
5
  ArcToolAny,
5
6
  AssistantContentBlock,
7
+ BoundProviderFile,
6
8
  Conversation,
7
9
  ConversationTurn,
8
10
  LLMProvider,
@@ -28,6 +30,60 @@ export interface AiGenerationListenerConfig {
28
30
  allMutationElements: ArcContextElement<any>[];
29
31
  maxExecutionCount: number;
30
32
  toolChoice?: "auto" | "required" | { type: "function"; name: string };
33
+ /**
34
+ * Billing alias for this chat — written to the `usageRecorded` event
35
+ * payload so admin reports can attribute cost back to which chat (e.g.
36
+ * `"chat-identity"`, `"chat-create-content"`). Defaults to `name` if the
37
+ * builder didn't override via `.alias(...)`.
38
+ */
39
+ alias?: string;
40
+ /**
41
+ * Optional billing hook from `ai()` factory. Called after every
42
+ * `completeAssistantTurn` (each turn, including those that close with
43
+ * tool calls) so the credit ledger view sees consistent usage events.
44
+ * No-op when undefined (ai() built without `billing` config).
45
+ */
46
+ recordUsage?: (
47
+ ctx: any,
48
+ params: {
49
+ scopeId: string;
50
+ alias: string;
51
+ model: string;
52
+ usage: import("@arcote.tech/arc-ai").TokenUsage;
53
+ metadata?: Record<string, unknown>;
54
+ },
55
+ ) => Promise<void>;
56
+ /**
57
+ * Consumer-supplied function from chat-builder's `.billTo(...)` — maps
58
+ * decoded params of the chat's protection token (snapshotted at
59
+ * `messageSent` emit time) to the ledger scopeId we charge. Required when
60
+ * `recordUsage` is set (chat-builder enforces this at build time), so the
61
+ * listener can treat the pair as always-present in the call site.
62
+ */
63
+ billTo?: (tokenParams: Record<string, any>) => string;
64
+ /**
65
+ * Wiązanie z fragmentem `arc-files` — wstrzykiwany przez chat-builder gdy
66
+ * consumer woła `.attachFiles({ File })`.
67
+ *
68
+ * - `loadAttachments(fileIds)` — ładuje `ArcFile` records po fileId'ach
69
+ * (dla `request.files`); zwracamy ArcFileRef z aktualnymi
70
+ * `providerFileIds` z DB.
71
+ * - `bindProviderFileId(fileId, provider, providerFileId)` — wywoływane
72
+ * dla każdego `BoundProviderFile` zwróconego przez adapter (cache
73
+ * lazy uploadu — przy następnej generacji pomijamy upload).
74
+ */
75
+ attachments?: {
76
+ loadAttachments(
77
+ ctx: any,
78
+ fileIds: string[],
79
+ ): Promise<ArcFileRef[]>;
80
+ bindProviderFileId(
81
+ ctx: any,
82
+ fileId: string,
83
+ provider: string,
84
+ providerFileId: string,
85
+ ): Promise<void>;
86
+ };
31
87
  }
32
88
 
33
89
  // ─── History reconstruction ─────────────────────────────────────
@@ -195,6 +251,18 @@ interface RunLoopConfig {
195
251
  * lecieć. Następne iteracje (multi-turn po server tool exec) tworzą fresh
196
252
  * rows. */
197
253
  preCreatedAssistantMessageId?: string;
254
+ /** Billing alias — written to `usageRecorded` event payload. Optional;
255
+ * no-op when paired `recordUsage` is undefined. */
256
+ alias?: string;
257
+ /** Billing hook — see `AiGenerationListenerConfig.recordUsage`. */
258
+ recordUsage?: AiGenerationListenerConfig["recordUsage"];
259
+ /** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
260
+ billTo?: AiGenerationListenerConfig["billTo"];
261
+ /** Attachments bridge — see `AiGenerationListenerConfig.attachments`. */
262
+ attachments?: AiGenerationListenerConfig["attachments"];
263
+ /** ArcFile records dla attachmentów ostatniego user msg (resolved przed
264
+ * generation start; reused per loop iteration). */
265
+ initialAttachments?: ArcFileRef[];
198
266
  }
199
267
 
200
268
  async function runGenerationLoop(config: RunLoopConfig) {
@@ -272,6 +340,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
272
340
  // robi nic.
273
341
  startStream(currentTurnId!);
274
342
 
343
+ // Files lecą TYLKO w pierwszej iteracji. Provider z continuation
344
+ // (OpenAI Responses) trzyma context server-side; provider w full
345
+ // mode (Claude/Gemini) ignoruje files z warningiem — i tak nie
346
+ // ma sensu duplikować attachmentów w każdym tool_result roundzie.
347
+ const filesForRequest =
348
+ executionCount === 0 ? config.initialAttachments : undefined;
349
+
275
350
  const result = await provider.streamComplete(
276
351
  {
277
352
  model,
@@ -282,6 +357,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
282
357
  // Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
283
358
  // step. Adaptery bez wsparcia ignorują.
284
359
  reasoningEffort: "minimal",
360
+ ...(filesForRequest && filesForRequest.length > 0
361
+ ? { files: filesForRequest }
362
+ : {}),
285
363
  },
286
364
  (chunk) => {
287
365
  if (chunk.type === "text_delta" && chunk.textDelta) {
@@ -318,6 +396,32 @@ async function runGenerationLoop(config: RunLoopConfig) {
318
396
  },
319
397
  );
320
398
 
399
+ // Cache lazy-uploaded provider file IDs w ArcFile aggregate — żeby
400
+ // kolejne generacje (i odnowiona historia) pomijały re-upload.
401
+ if (
402
+ config.attachments &&
403
+ result.boundProviderFiles &&
404
+ result.boundProviderFiles.length > 0
405
+ ) {
406
+ for (const bound of result.boundProviderFiles) {
407
+ try {
408
+ await config.attachments.bindProviderFileId(
409
+ ctx,
410
+ bound.fileId,
411
+ bound.provider,
412
+ bound.providerFileId,
413
+ );
414
+ } catch (err) {
415
+ // Best-effort cache — failure tu nie psuje turn'u, tylko
416
+ // następny request uploadowi plik ponownie.
417
+ console.warn(
418
+ "[arc-chat] attachments.bindProviderFileId failed:",
419
+ err,
420
+ );
421
+ }
422
+ }
423
+ }
424
+
321
425
  // Append to local history so the next iteration sees this turn.
322
426
  const assistantTurn: ConversationTurn = {
323
427
  role: "assistant",
@@ -339,15 +443,46 @@ async function runGenerationLoop(config: RunLoopConfig) {
339
443
 
340
444
  // Close the turn row — same row that was opened above. Final blocks
341
445
  // are the SINGLE persistent write of message content in this turn.
342
- // Intermediate turns (gdy mamy tool calls) carry blocks + responseId;
343
- // ostatni turn nosi też usage.
446
+ // Usage zapisywane ZAWSZE (intermediate tool turns też zużywają tokeny
447
+ // i muszą być rozliczone w `recordUsage` poniżej; klient dostaje pełen
448
+ // history of cost per turn from message rows).
344
449
  await ctx.mutate(messageElement).completeAssistantTurn({
345
450
  messageId: currentTurnId!,
346
451
  blocks: JSON.stringify(result.blocks),
347
452
  previousResponseId: result.responseId,
348
- usage: hasToolCalls ? undefined : JSON.stringify(result.usage),
453
+ usage: JSON.stringify(result.usage),
349
454
  });
350
455
 
456
+ // Billing hook — emit usageRecorded event for the credit ledger view.
457
+ // Called after `completeAssistantTurn` so the message row exists in DB
458
+ // before its cost is attributed.
459
+ //
460
+ // chat-builder enforces `.billTo()` is present whenever `recordUsage`
461
+ // is wired, so the only legitimate "skip" path is "billing not
462
+ // configured at all" — both undefined. We still guard defensively.
463
+ if (config.recordUsage && config.alias && config.billTo) {
464
+ const billingScopeId = config.billTo(
465
+ ((ctx as any).$auth?.params as Record<string, any>) ?? {},
466
+ );
467
+ try {
468
+ await config.recordUsage(ctx, {
469
+ scopeId: billingScopeId,
470
+ alias: config.alias,
471
+ model,
472
+ usage: result.usage,
473
+ metadata: {
474
+ messageId: currentTurnId!,
475
+ sessionId,
476
+ turnIndex: executionCount,
477
+ chatScopeId: scopeId,
478
+ },
479
+ });
480
+ } catch (err) {
481
+ // Best-effort: billing failure shouldn't break generation.
482
+ console.error("[arc-chat] recordUsage failed:", err);
483
+ }
484
+ }
485
+
351
486
  // Tear down the in-memory stream: broadcast `done` do subscriberów,
352
487
  // close controllery, drop registry entry po grace window. Klient z
353
488
  // `done` flippuje isStreaming=false i renderuje final blocks z DB.
@@ -501,6 +636,14 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
501
636
  const newTurnsStartIdx = history.length;
502
637
  history.push({ role: "user", content: userContent });
503
638
 
639
+ // Załaduj ArcFile records dla attachmentów ostatniego user msg
640
+ // (jeśli consumer wired `attachments` bridge przez `.attachFiles({File})`).
641
+ const initialAttachments = await resolveAttachments(
642
+ ctx,
643
+ config.attachments,
644
+ (event.payload as { attachmentsJson?: string }).attachmentsJson,
645
+ );
646
+
504
647
  await runGenerationLoop({
505
648
  ctx,
506
649
  messageElement,
@@ -516,6 +659,11 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
516
659
  maxExecutionCount,
517
660
  toolChoice: config.toolChoice,
518
661
  instruction,
662
+ alias: config.alias,
663
+ recordUsage: config.recordUsage,
664
+ billTo: config.billTo,
665
+ attachments: config.attachments,
666
+ initialAttachments,
519
667
  preCreatedAssistantMessageId: (
520
668
  event.payload as { assistantMessageId?: string }
521
669
  ).assistantMessageId,
@@ -523,6 +671,36 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
523
671
  });
524
672
  }
525
673
 
674
+ /**
675
+ * Parsuje `attachmentsJson` (string[] fileIds) i deleguje do
676
+ * `attachments.loadAttachments(...)`. Brak attachments bridge ALBO brak
677
+ * JSON ALBO pusta lista → zwraca undefined (request bez `files`).
678
+ */
679
+ async function resolveAttachments(
680
+ ctx: any,
681
+ attachments: AiGenerationListenerConfig["attachments"],
682
+ attachmentsJson: string | undefined,
683
+ ): Promise<ArcFileRef[] | undefined> {
684
+ if (!attachments || !attachmentsJson) return undefined;
685
+ let fileIds: string[] = [];
686
+ try {
687
+ const parsed = JSON.parse(attachmentsJson);
688
+ if (Array.isArray(parsed)) {
689
+ fileIds = parsed.filter((x): x is string => typeof x === "string");
690
+ }
691
+ } catch {
692
+ return undefined;
693
+ }
694
+ if (fileIds.length === 0) return undefined;
695
+ try {
696
+ const refs = await attachments.loadAttachments(ctx, fileIds);
697
+ return refs.length > 0 ? refs : undefined;
698
+ } catch (err) {
699
+ console.warn("[arc-chat] attachments.loadAttachments failed:", err);
700
+ return undefined;
701
+ }
702
+ }
703
+
526
704
  // ─── Listener B: userResponded → AI resume ──────────────────────
527
705
 
528
706
  export function createAiResumeListener(config: AiGenerationListenerConfig) {
@@ -596,6 +774,10 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
596
774
  maxExecutionCount,
597
775
  toolChoice: config.toolChoice,
598
776
  instruction,
777
+ alias: config.alias,
778
+ recordUsage: config.recordUsage,
779
+ billTo: config.billTo,
780
+ attachments: config.attachments,
599
781
  preCreatedAssistantMessageId: (
600
782
  event.payload as { assistantMessageId?: string }
601
783
  ).assistantMessageId,
@@ -688,6 +870,9 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
688
870
  maxExecutionCount,
689
871
  toolChoice: config.toolChoice,
690
872
  instruction,
873
+ alias: config.alias,
874
+ recordUsage: config.recordUsage,
875
+ billTo: config.billTo,
691
876
  preCreatedAssistantMessageId: assistantMsgId,
692
877
  });
693
878
  });
@@ -622,6 +622,9 @@ export function createChatComponent(
622
622
  scopeId,
623
623
  content,
624
624
  model: options.model,
625
+ ...(options.attachments && options.attachments.length > 0
626
+ ? { attachmentsJson: JSON.stringify(options.attachments) }
627
+ : {}),
625
628
  });
626
629
  // Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
627
630
  // emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant