@arcote.tech/arc-chat 0.7.23 → 0.7.24

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.23",
4
+ "version": "0.7.24",
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.23",
14
- "@arcote.tech/arc-ai": "^0.7.23",
15
- "@arcote.tech/arc-ai-voice": "^0.7.23",
16
- "@arcote.tech/arc-auth": "^0.7.23",
17
- "@arcote.tech/arc-ds": "^0.7.23",
18
- "@arcote.tech/platform": "^0.7.23",
13
+ "@arcote.tech/arc": "^0.7.24",
14
+ "@arcote.tech/arc-ai": "^0.7.24",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.24",
16
+ "@arcote.tech/arc-auth": "^0.7.24",
17
+ "@arcote.tech/arc-ds": "^0.7.24",
18
+ "@arcote.tech/platform": "^0.7.24",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -83,6 +83,11 @@ export const createMessageAggregate = <
83
83
  * tylko ulotnym sygnałem advisory.
84
84
  */
85
85
  error: string().optional(),
86
+ /**
87
+ * Assistant rows: stabilny kod błędu (np. "insufficient_credits") — pozwala
88
+ * UI pokazać dedykowany komunikat + CTA zamiast surowego `error`.
89
+ */
90
+ errorCode: string().optional(),
86
91
  /**
87
92
  * Assistant rows: generacja przerwana bez wyniku (restart serwera
88
93
  * mid-stream). Ustawiane przez `generationInterrupted` (lazy repair w
@@ -172,6 +177,7 @@ export const createMessageAggregate = <
172
177
  previousResponseId: string().optional(),
173
178
  usage: string().optional(),
174
179
  error: string().optional(),
180
+ errorCode: string().optional(),
175
181
  },
176
182
  async (ctx, event) => {
177
183
  const p = event.payload;
@@ -180,6 +186,7 @@ export const createMessageAggregate = <
180
186
  previousResponseId: p.previousResponseId,
181
187
  usage: p.usage,
182
188
  error: p.error,
189
+ errorCode: p.errorCode,
183
190
  isGenerating: false,
184
191
  } as any);
185
192
  },
@@ -370,6 +377,7 @@ export const createMessageAggregate = <
370
377
  previousResponseId: string().optional(),
371
378
  usage: string().optional(),
372
379
  error: string().optional(),
380
+ errorCode: string().optional(),
373
381
  }).handle(
374
382
  ONLY_SERVER &&
375
383
  (async (ctx, params) => {
@@ -379,6 +387,7 @@ export const createMessageAggregate = <
379
387
  previousResponseId: params.previousResponseId,
380
388
  usage: params.usage,
381
389
  error: params.error,
390
+ errorCode: params.errorCode,
382
391
  });
383
392
  return { ok: true };
384
393
  }),
@@ -51,6 +51,16 @@ export interface ChatReactComponentOptions {
51
51
  * advancement bars. Scrolls with messages.
52
52
  */
53
53
  footer?: ReactNode;
54
+ /**
55
+ * Slot na cały composer (input + przycisk + UI interaktywnych narzędzi).
56
+ * Patrz `ChatComponentConfig.renderComposer` — pozwala zastąpić obszar inputu
57
+ * własnym UI (np. „brak kredytów"). Historia wiadomości zostaje widoczna.
58
+ */
59
+ renderComposer?: (props: {
60
+ isDisabled: boolean;
61
+ isWaitingInteractive: boolean;
62
+ children: ReactNode;
63
+ }) => ReactNode;
54
64
  }
55
65
 
56
66
  // ─── Chat Data ──────────────────────────────────────────────────
@@ -410,6 +420,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
410
420
  alias: aliasOverride ?? name,
411
421
  recordUsage: aiConfig.recordUsage,
412
422
  billTo: billTo ?? undefined,
423
+ assertCredits: aiConfig.assertCredits,
413
424
  attachments: attachmentsBridge,
414
425
  };
415
426
 
@@ -419,7 +430,12 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
419
430
 
420
431
  const streamRoute = createChatStreamRoute({
421
432
  name,
422
- userToken,
433
+ // Spójnie z message aggregate (linia ~306): gdy consumer zawęża czat
434
+ // przez `.protectBy(Y)`, scope klienta trzyma token Y i tym tokenem
435
+ // posługują się query/mutate. Stream route musi chronić TYM SAMYM
436
+ // tokenem — inaczej route wymaga bazowego `userToken` (X), klient
437
+ // wysyła Y i dostaje 403 mimo ważnego logowania.
438
+ userToken: protectByToken ?? userToken,
423
439
  messageElement: Message,
424
440
  });
425
441
 
@@ -444,6 +460,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
444
460
  renderTextarea: options.renderTextarea,
445
461
  labels: options.labels,
446
462
  footer: options.footer,
463
+ renderComposer: options.renderComposer,
447
464
  });
448
465
  }
449
466
 
@@ -10,6 +10,7 @@ import type {
10
10
  LLMProvider,
11
11
  ToolCall,
12
12
  } from "@arcote.tech/arc-ai";
13
+ import { InsufficientCreditsError } from "@arcote.tech/arc-ai";
13
14
  import {
14
15
  finalize,
15
16
  publish,
@@ -62,6 +63,13 @@ export interface AiGenerationListenerConfig {
62
63
  * listener can treat the pair as always-present in the call site.
63
64
  */
64
65
  billTo?: (tokenParams: Record<string, any>) => string;
66
+ /**
67
+ * Pre-flight gate z `ai()` factory (`ai.assertCredits`). Wołane RAZ przed
68
+ * pierwszym `provider.streamComplete` — rzuca `InsufficientCreditsError` gdy
69
+ * scope nie ma kredytów. No-op gdy undefined / billing bez readera salda.
70
+ * Wymaga `billTo` (chat-builder wymusza je razem z recordUsage).
71
+ */
72
+ assertCredits?: (ctx: any, scopeId: string) => Promise<void>;
65
73
  /**
66
74
  * Wiązanie z fragmentem `arc-files` — wstrzykiwany przez chat-builder gdy
67
75
  * consumer woła `.attachFiles({ File })`.
@@ -273,6 +281,8 @@ interface RunLoopConfig {
273
281
  recordUsage?: AiGenerationListenerConfig["recordUsage"];
274
282
  /** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
275
283
  billTo?: AiGenerationListenerConfig["billTo"];
284
+ /** Pre-flight gate — see `AiGenerationListenerConfig.assertCredits`. */
285
+ assertCredits?: AiGenerationListenerConfig["assertCredits"];
276
286
  /** Attachments bridge — see `AiGenerationListenerConfig.attachments`. */
277
287
  attachments?: AiGenerationListenerConfig["attachments"];
278
288
  /** ArcFile records dla attachmentów ostatniego user msg (resolved przed
@@ -362,6 +372,16 @@ async function runGenerationLoop(config: RunLoopConfig) {
362
372
  const filesForRequest =
363
373
  executionCount === 0 ? config.initialAttachments : undefined;
364
374
 
375
+ // Pre-flight gate — RAZ, przed pierwszym wywołaniem providera. Rzuca
376
+ // InsufficientCreditsError gdy scope nie ma kredytów; łapie catch poniżej
377
+ // i zamyka turn z errorCode "insufficient_credits" (UI pokazuje CTA).
378
+ if (executionCount === 0 && config.assertCredits && config.billTo) {
379
+ const billingScopeId = config.billTo(
380
+ ((ctx as any).$auth?.params as Record<string, any>) ?? {},
381
+ );
382
+ await config.assertCredits(ctx, billingScopeId);
383
+ }
384
+
365
385
  const result = await provider.streamComplete(
366
386
  {
367
387
  model,
@@ -580,11 +600,20 @@ async function runGenerationLoop(config: RunLoopConfig) {
580
600
  executionCount++;
581
601
  }
582
602
  } catch (err) {
583
- const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
603
+ // Brak kredytów rozróżnialny errorCode, by UI pokazało komunikat + CTA
604
+ // „Dokup kredyty" zamiast generycznego błędu.
605
+ const isCredits =
606
+ err instanceof InsufficientCreditsError ||
607
+ (err as any)?.code === "insufficient_credits";
608
+ const errorMsg = isCredits
609
+ ? "Brak kredytów AI"
610
+ : `AI error: ${err instanceof Error ? err.message : String(err)}`;
611
+ const errorCode = isCredits ? "insufficient_credits" : undefined;
584
612
  if (currentTurnId) {
585
613
  publish(currentTurnId, {
586
614
  type: "error",
587
615
  error: errorMsg,
616
+ errorCode,
588
617
  executionCount,
589
618
  });
590
619
  try {
@@ -592,6 +621,7 @@ async function runGenerationLoop(config: RunLoopConfig) {
592
621
  messageId: currentTurnId,
593
622
  blocks: "[]",
594
623
  error: errorMsg,
624
+ errorCode,
595
625
  });
596
626
  } catch {}
597
627
  finalize(currentTurnId, { error: errorMsg, executionCount });
@@ -689,6 +719,7 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
689
719
  alias: config.alias,
690
720
  recordUsage: config.recordUsage,
691
721
  billTo: config.billTo,
722
+ assertCredits: config.assertCredits,
692
723
  attachments: config.attachments,
693
724
  initialAttachments,
694
725
  preCreatedAssistantMessageId: (
@@ -812,6 +843,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
812
843
  alias: config.alias,
813
844
  recordUsage: config.recordUsage,
814
845
  billTo: config.billTo,
846
+ assertCredits: config.assertCredits,
815
847
  attachments: config.attachments,
816
848
  preCreatedAssistantMessageId: (
817
849
  event.payload as { assistantMessageId?: string }
@@ -911,6 +943,7 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
911
943
  alias: config.alias,
912
944
  recordUsage: config.recordUsage,
913
945
  billTo: config.billTo,
946
+ assertCredits: config.assertCredits,
914
947
  preCreatedAssistantMessageId: assistantMsgId,
915
948
  });
916
949
  });
@@ -76,6 +76,18 @@ interface ChatComponentConfig {
76
76
  * after the last message/tool. Scrolls with messages.
77
77
  */
78
78
  footer?: ReactNode;
79
+ /**
80
+ * Slot na CAŁY composer (pole tekstowe + przycisk wysłania + UI odpowiedzi
81
+ * interaktywnych narzędzi). Gdy ustawiony, konsument decyduje co wyrenderować
82
+ * w miejscu inputu — np. komunikat „brak kredytów" zamiast inputu. Otrzymuje
83
+ * `children` (domyślny composer) do warunkowego użycia. Historia wiadomości
84
+ * (timeline) renderuje się zawsze, niezależnie od tego slotu.
85
+ */
86
+ renderComposer?: (props: {
87
+ isDisabled: boolean;
88
+ isWaitingInteractive: boolean;
89
+ children: ReactNode;
90
+ }) => ReactNode;
79
91
  }
80
92
 
81
93
  export function createChatComponent(
@@ -91,6 +103,7 @@ export function createChatComponent(
91
103
  renderTextarea,
92
104
  labels,
93
105
  footer,
106
+ renderComposer,
94
107
  } = config;
95
108
  const toolsMap = new Map(tools.map((t) => [t.name, t]));
96
109
  const isServerTool = (toolName: string) =>
@@ -140,7 +153,10 @@ export function createChatComponent(
140
153
  .map((m: any) => m._id as string),
141
154
  [historyData],
142
155
  );
143
- const overlays = useAssistantOverlays(chatName, generatingIds);
156
+ // Token scope'u czatu jawnie do streamu — stream-fetch nie przechodzi
157
+ // przez wire, więc bez tego nie ma nagłówka Authorization → 401.
158
+ const streamToken = scope.useToken?.() ?? null;
159
+ const overlays = useAssistantOverlays(chatName, generatingIds, streamToken);
144
160
 
145
161
  // ─── Derywacja ────────────────────────────────────────────────
146
162
  const derived = useMemo(
@@ -362,6 +378,39 @@ export function createChatComponent(
362
378
  );
363
379
  }
364
380
 
381
+ const isDisabled = derived.busy || derived.hasWaitingInteractive;
382
+ const composerContent = createElement(Chat, {
383
+ messages: [],
384
+ models: [{ value: "gpt-5", label: "GPT-5" }],
385
+ defaultModel: "gpt-5",
386
+ onSend: handleSend,
387
+ showModelSelector,
388
+ showWebSearch,
389
+ renderSendButton,
390
+ // Default = VoiceTextarea (chat z dyktowaniem out-of-the-box).
391
+ // Konsument może podać własny `renderTextarea` (np. czysty
392
+ // TextareaField gdy świadomie wyłącza voice).
393
+ renderTextarea:
394
+ renderTextarea ??
395
+ (({ value, onChange, placeholder, rows }) =>
396
+ createElement(VoiceTextarea, {
397
+ value,
398
+ onChange,
399
+ placeholder,
400
+ rows,
401
+ })),
402
+ disabled: isDisabled,
403
+ });
404
+ // Slot na cały composer — konsument może zastąpić input własnym UI (np.
405
+ // komunikat „brak kredytów"). Timeline powyżej renderuje się zawsze.
406
+ const composer = renderComposer
407
+ ? renderComposer({
408
+ isDisabled,
409
+ isWaitingInteractive: derived.hasWaitingInteractive,
410
+ children: composerContent,
411
+ })
412
+ : composerContent;
413
+
365
414
  return createElement(
366
415
  ChatInputProvider,
367
416
  null,
@@ -374,28 +423,7 @@ export function createChatComponent(
374
423
  ...timelineElements,
375
424
  footer,
376
425
  ),
377
- createElement(Chat, {
378
- messages: [],
379
- models: [{ value: "gpt-5", label: "GPT-5" }],
380
- defaultModel: "gpt-5",
381
- onSend: handleSend,
382
- showModelSelector,
383
- showWebSearch,
384
- renderSendButton,
385
- // Default = VoiceTextarea (chat z dyktowaniem out-of-the-box).
386
- // Konsument może podać własny `renderTextarea` (np. czysty
387
- // TextareaField gdy świadomie wyłącza voice).
388
- renderTextarea:
389
- renderTextarea ??
390
- (({ value, onChange, placeholder, rows }) =>
391
- createElement(VoiceTextarea, {
392
- value,
393
- onChange,
394
- placeholder,
395
- rows,
396
- })),
397
- disabled: derived.busy || derived.hasWaitingInteractive,
398
- }),
426
+ composer,
399
427
  ),
400
428
  );
401
429
  }
@@ -56,6 +56,8 @@ const HEARTBEAT_TIMEOUT_MS = 12_000;
56
56
  export function useAssistantOverlays(
57
57
  chatName: string,
58
58
  generatingIds: readonly string[],
59
+ /** Bieżący token scope'u czatu — ten sam, którym chroni route `protectBy`. */
60
+ token?: string | null,
59
61
  ): ReadonlyMap<string, AssistantOverlay> {
60
62
  const [overlays, setOverlays] = useState<Map<string, AssistantOverlay>>(
61
63
  () => new Map(),
@@ -64,6 +66,14 @@ export function useAssistantOverlays(
64
66
  const [reopenNonce, setReopenNonce] = useState(0);
65
67
  const connsRef = useRef<Map<string, Connection>>(new Map());
66
68
 
69
+ // Token w refie: fetch streamu czyta zawsze świeżą wartość bez wpinania
70
+ // tokenu do deps effectu — odświeżenie/rotacja tokenu nie zrywa żywych
71
+ // połączeń. Stream to `fetch` (nie EventSource), więc może wysłać
72
+ // `Authorization` jawnie zamiast polegać na ciasteczku `arc_token`
73
+ // (które ustawia tylko OAuth callback) — inaczej route zwraca 401.
74
+ const tokenRef = useRef(token);
75
+ tokenRef.current = token;
76
+
67
77
  const idsKey = generatingIds.join("|");
68
78
 
69
79
  useEffect(() => {
@@ -120,10 +130,14 @@ export function useAssistantOverlays(
120
130
  // czasie mógł już zrobić lazy repair `interrupted` w DB).
121
131
  let res: Response | null = null;
122
132
  for (let attempt = 0; ; attempt++) {
133
+ const tok = tokenRef.current;
123
134
  res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
124
135
  credentials: "include",
125
136
  signal: ctrl.signal,
126
- headers: { Accept: "text/event-stream" },
137
+ headers: {
138
+ Accept: "text/event-stream",
139
+ ...(tok ? { Authorization: `Bearer ${tok}` } : {}),
140
+ },
127
141
  });
128
142
  if (res.status !== 410) break;
129
143
  if (attempt >= MAX_410_RETRIES) {
@@ -156,7 +156,13 @@ export type PublishableEvent =
156
156
  executionCount?: number;
157
157
  }
158
158
  | { type: "usage_update"; usage: TokenUsage }
159
- | { type: "error"; error: string; executionCount?: number };
159
+ | {
160
+ type: "error";
161
+ error: string;
162
+ /** Stabilny kod błędu (np. "insufficient_credits") dla UI/CTA. */
163
+ errorCode?: string;
164
+ executionCount?: number;
165
+ };
160
166
 
161
167
  /**
162
168
  * Apply the event to the in-memory `currentBlocks` (via the shared