@arcote.tech/arc-chat 0.7.23 → 0.7.25
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.
|
|
4
|
+
"version": "0.7.25",
|
|
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.25",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.7.25",
|
|
15
|
+
"@arcote.tech/arc-ai-voice": "^0.7.25",
|
|
16
|
+
"@arcote.tech/arc-auth": "^0.7.25",
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.25",
|
|
18
|
+
"@arcote.tech/platform": "^0.7.25",
|
|
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
|
}),
|
package/src/chat-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
| {
|
|
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
|