@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.
|
|
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.
|
|
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.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 };
|
package/src/chat-builder.ts
CHANGED
|
@@ -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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
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:
|
|
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
|