@arcote.tech/arc-chat 0.7.12 → 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.12",
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.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",
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 };
@@ -85,6 +85,17 @@ export interface ArcChatData {
85
85
  toolChoice: "auto" | "required" | { type: "function"; name: string };
86
86
  alias: string | null;
87
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;
88
99
  }
89
100
 
90
101
  const defaultChatData = {
@@ -101,6 +112,7 @@ const defaultChatData = {
101
112
  toolChoice: "auto" as const,
102
113
  alias: null,
103
114
  billTo: null,
115
+ attachFiles: null,
104
116
  } as const satisfies ArcChatData;
105
117
 
106
118
  type DefaultChatData = typeof defaultChatData;
@@ -237,6 +249,22 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
237
249
  } as any);
238
250
  }
239
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
+
240
268
  build() {
241
269
  const {
242
270
  name,
@@ -252,6 +280,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
252
280
  toolChoice,
253
281
  alias: aliasOverride,
254
282
  billTo,
283
+ attachFiles,
255
284
  } = this.data;
256
285
 
257
286
  if (!name) throw new Error("ArcChat: name is required");
@@ -319,6 +348,54 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
319
348
  }
320
349
  }
321
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
+
322
399
  const listenerConfig = {
323
400
  name,
324
401
  messageElement: Message,
@@ -333,6 +410,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
333
410
  alias: aliasOverride ?? name,
334
411
  recordUsage: aiConfig.recordUsage,
335
412
  billTo: billTo ?? undefined,
413
+ attachments: attachmentsBridge,
336
414
  };
337
415
 
338
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,
@@ -59,6 +61,29 @@ export interface AiGenerationListenerConfig {
59
61
  * listener can treat the pair as always-present in the call site.
60
62
  */
61
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
+ };
62
87
  }
63
88
 
64
89
  // ─── History reconstruction ─────────────────────────────────────
@@ -233,6 +258,11 @@ interface RunLoopConfig {
233
258
  recordUsage?: AiGenerationListenerConfig["recordUsage"];
234
259
  /** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
235
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[];
236
266
  }
237
267
 
238
268
  async function runGenerationLoop(config: RunLoopConfig) {
@@ -310,6 +340,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
310
340
  // robi nic.
311
341
  startStream(currentTurnId!);
312
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
+
313
350
  const result = await provider.streamComplete(
314
351
  {
315
352
  model,
@@ -320,6 +357,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
320
357
  // Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
321
358
  // step. Adaptery bez wsparcia ignorują.
322
359
  reasoningEffort: "minimal",
360
+ ...(filesForRequest && filesForRequest.length > 0
361
+ ? { files: filesForRequest }
362
+ : {}),
323
363
  },
324
364
  (chunk) => {
325
365
  if (chunk.type === "text_delta" && chunk.textDelta) {
@@ -356,6 +396,32 @@ async function runGenerationLoop(config: RunLoopConfig) {
356
396
  },
357
397
  );
358
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
+
359
425
  // Append to local history so the next iteration sees this turn.
360
426
  const assistantTurn: ConversationTurn = {
361
427
  role: "assistant",
@@ -570,6 +636,14 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
570
636
  const newTurnsStartIdx = history.length;
571
637
  history.push({ role: "user", content: userContent });
572
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
+
573
647
  await runGenerationLoop({
574
648
  ctx,
575
649
  messageElement,
@@ -588,6 +662,8 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
588
662
  alias: config.alias,
589
663
  recordUsage: config.recordUsage,
590
664
  billTo: config.billTo,
665
+ attachments: config.attachments,
666
+ initialAttachments,
591
667
  preCreatedAssistantMessageId: (
592
668
  event.payload as { assistantMessageId?: string }
593
669
  ).assistantMessageId,
@@ -595,6 +671,36 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
595
671
  });
596
672
  }
597
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
+
598
704
  // ─── Listener B: userResponded → AI resume ──────────────────────
599
705
 
600
706
  export function createAiResumeListener(config: AiGenerationListenerConfig) {
@@ -671,6 +777,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
671
777
  alias: config.alias,
672
778
  recordUsage: config.recordUsage,
673
779
  billTo: config.billTo,
780
+ attachments: config.attachments,
674
781
  preCreatedAssistantMessageId: (
675
782
  event.payload as { assistantMessageId?: string }
676
783
  ).assistantMessageId,
@@ -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