broapp 0.4.1 → 0.4.3
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 +4 -4
- package/src/ai/host/create-ai.ts +48 -11
- package/src/ai/host/index.ts +1 -0
- package/src/ai/host/run.ts +273 -9
- package/src/ai/host/threads.ts +198 -1
- package/src/ai/react/use-ai-chat.ts +10 -4
- package/src/ai/shared/contract.ts +3 -0
- package/src/ai/shared/types.ts +6 -0
- package/src/host/runtime.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "broapp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"./package.json": "./package.json"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@brobridgejs/client": "^0.2.
|
|
53
|
-
"@brobridgejs/core": "^0.2.
|
|
54
|
-
"brobridge": "^0.2.
|
|
52
|
+
"@brobridgejs/client": "^0.2.2",
|
|
53
|
+
"@brobridgejs/core": "^0.2.2",
|
|
54
|
+
"brobridge": "^0.2.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"react": ">=18",
|
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { createPendingApprovals, createReservedHostApp } from '../../host/index.
|
|
|
22
22
|
import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
|
|
23
23
|
import { publicError } from '../../shared/errors.ts';
|
|
24
24
|
import { aiContract, type AiContract } from '../shared/contract.ts';
|
|
25
|
-
import type { ProviderInfo } from '../shared/types.ts';
|
|
25
|
+
import type { ChatTurn, ProviderInfo } from '../shared/types.ts';
|
|
26
26
|
|
|
27
27
|
import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
|
|
28
28
|
import { createRegistry, type Registry } from './registry.ts';
|
|
@@ -64,6 +64,23 @@ export interface InProcessTurn {
|
|
|
64
64
|
readonly message: string;
|
|
65
65
|
/** The model for this turn, within the configured provider. */
|
|
66
66
|
readonly modelId?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Earlier turns, as a browser would send them. An assistant turn naming a
|
|
69
|
+
* run this layer kept a transcript for is expanded exactly as on `ai.chat`.
|
|
70
|
+
*/
|
|
71
|
+
readonly history?: readonly ChatTurn[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One question put to an in-process turn's stand-in. */
|
|
75
|
+
export interface InProcessQuestion {
|
|
76
|
+
readonly tool: string;
|
|
77
|
+
readonly input: unknown;
|
|
78
|
+
/** The approval table's key, `<runId>:<callId>`. */
|
|
79
|
+
readonly requestId: string;
|
|
80
|
+
/** The call the question is about, as `ai.chatConfirm` names it with the run id. */
|
|
81
|
+
readonly callId: string;
|
|
82
|
+
/** When the question stops waiting, when the gate said. */
|
|
83
|
+
readonly expiresAt?: number;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
/** How {@link Ai.turn} is answered and stopped. */
|
|
@@ -73,8 +90,14 @@ export interface InProcessTurnOptions {
|
|
|
73
90
|
*
|
|
74
91
|
* The caller is the person's stand-in, so it decides exactly as the person
|
|
75
92
|
* would have: the gate still asks, and still records the answer.
|
|
93
|
+
*
|
|
94
|
+
* `'defer'` answers nothing. The question stays in the approval table for
|
|
95
|
+
* somebody else to answer by its request id, over `ai.chatConfirm` as a
|
|
96
|
+
* person in a chat would, and the gate's own window still ends it. That is
|
|
97
|
+
* for a stand-in that answers some questions itself and brings the rest to a
|
|
98
|
+
* person: whoever answers, each question is answered once.
|
|
76
99
|
*/
|
|
77
|
-
readonly answer: (question:
|
|
100
|
+
readonly answer: (question: InProcessQuestion) => boolean | 'defer';
|
|
78
101
|
/** Aborting it cancels the turn, as a browser's cancel would. */
|
|
79
102
|
readonly signal?: AbortSignal;
|
|
80
103
|
readonly onEvent?: (event: ChatEvent) => void;
|
|
@@ -291,6 +314,14 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
291
314
|
}
|
|
292
315
|
});
|
|
293
316
|
|
|
317
|
+
// Opened on the first conversation route and not before: an application
|
|
318
|
+
// whose user never opens the panel should not find a database in its data
|
|
319
|
+
// directory, and `createAi` is built unconditionally by every application
|
|
320
|
+
// that offers AI at all. A turn opens it too, to keep its transcript.
|
|
321
|
+
let threads: ThreadStore | null = null;
|
|
322
|
+
const threadStore = (): ThreadStore =>
|
|
323
|
+
(threads ??= openThreads(options.dataDir, options.logger === undefined ? {} : { logger: options.logger }));
|
|
324
|
+
|
|
294
325
|
const approvals = createPendingApprovals(options.logger);
|
|
295
326
|
const runDeps: RunDeps = {
|
|
296
327
|
registry,
|
|
@@ -303,6 +334,12 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
303
334
|
approvals,
|
|
304
335
|
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
305
336
|
...(options.onContext === undefined ? {} : { onContext: options.onContext }),
|
|
337
|
+
transcripts: {
|
|
338
|
+
save: (runId, messages) => {
|
|
339
|
+
threadStore().saveTranscript(runId, messages);
|
|
340
|
+
},
|
|
341
|
+
read: (runId) => threadStore().transcript(runId),
|
|
342
|
+
},
|
|
306
343
|
logger: options.logger ?? console,
|
|
307
344
|
};
|
|
308
345
|
|
|
@@ -315,13 +352,6 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
315
352
|
approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
|
|
316
353
|
}));
|
|
317
354
|
|
|
318
|
-
// Opened on the first conversation route and not before: an application
|
|
319
|
-
// whose user never opens the panel should not find a database in its data
|
|
320
|
-
// directory, and `createAi` is built unconditionally by every application
|
|
321
|
-
// that offers AI at all.
|
|
322
|
-
let threads: ThreadStore | null = null;
|
|
323
|
-
const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
|
|
324
|
-
|
|
325
355
|
host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
|
|
326
356
|
host.operation('ai.threadsCreate', (input) => threadStore().create(input));
|
|
327
357
|
host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
|
|
@@ -386,7 +416,14 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
386
416
|
// The gate's request id is `<runId>:<callId>`, which is what an
|
|
387
417
|
// event without one would have named.
|
|
388
418
|
const requestId = event.requestId ?? `${turn.runId}:${event.callId}`;
|
|
389
|
-
|
|
419
|
+
const answer = turnOptions.answer({
|
|
420
|
+
tool: event.tool ?? '',
|
|
421
|
+
input: event.input,
|
|
422
|
+
requestId,
|
|
423
|
+
callId: event.callId ?? '',
|
|
424
|
+
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
425
|
+
});
|
|
426
|
+
if (answer !== 'defer') void settle(requestId, answer);
|
|
390
427
|
}
|
|
391
428
|
return Promise.resolve();
|
|
392
429
|
},
|
|
@@ -400,7 +437,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
400
437
|
runId: turn.runId,
|
|
401
438
|
message: turn.message,
|
|
402
439
|
refs: [],
|
|
403
|
-
history: [],
|
|
440
|
+
history: turn.history === undefined ? [] : [...turn.history],
|
|
404
441
|
...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
|
|
405
442
|
},
|
|
406
443
|
sink,
|
package/src/ai/host/index.ts
CHANGED
package/src/ai/host/run.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { ApprovalQuestion, Approver } from '../../host/gate.ts';
|
|
|
22
22
|
import type { Effect } from '../../shared/contract.ts';
|
|
23
23
|
import { fromTransportError, isPublicError, publicError } from '../../shared/errors.ts';
|
|
24
24
|
import type { ToolPermission } from '../shared/types.ts';
|
|
25
|
+
import type { ChatTurn } from '../shared/types.ts';
|
|
25
26
|
import type { ChatEvent, StreamChatParams } from './run-types.ts';
|
|
26
27
|
|
|
27
28
|
import { AdapterError } from './adapter.ts';
|
|
@@ -29,6 +30,18 @@ import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
|
29
30
|
import type { Registry } from './registry.ts';
|
|
30
31
|
import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
|
|
31
32
|
import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
|
|
33
|
+
import type { ResponseMessage } from './threads.ts';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a turn's own transcript is kept and read back.
|
|
37
|
+
*
|
|
38
|
+
* Both sides are the host's: `save` is handed what the AI SDK produced, and
|
|
39
|
+
* `read` returns only what `save` wrote. A browser never supplies either.
|
|
40
|
+
*/
|
|
41
|
+
export interface RunTranscripts {
|
|
42
|
+
save(runId: string, messages: readonly ResponseMessage[]): void;
|
|
43
|
+
read(runId: string): readonly ResponseMessage[] | null;
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
/** What the run loop needs from the `Ai` that owns it. */
|
|
34
47
|
export interface RunDeps {
|
|
@@ -62,6 +75,11 @@ export interface RunDeps {
|
|
|
62
75
|
) => void;
|
|
63
76
|
/** Called once per turn with what the model is about to be given. */
|
|
64
77
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
78
|
+
/**
|
|
79
|
+
* The turn transcripts. Absent, every history turn is text and nothing is
|
|
80
|
+
* written, which is exactly the layer before transcripts existed.
|
|
81
|
+
*/
|
|
82
|
+
readonly transcripts?: RunTranscripts;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/** What a turn counts as it goes, for {@link RunEndDetail}. */
|
|
@@ -217,6 +235,128 @@ async function assembleContext(
|
|
|
217
235
|
return fitToBudget(documents, deps.contextBudgetChars);
|
|
218
236
|
}
|
|
219
237
|
|
|
238
|
+
/** The bounds on history expanded from transcripts. */
|
|
239
|
+
export interface HistoryLimits {
|
|
240
|
+
/** Assistant turns expanded, newest first. */
|
|
241
|
+
readonly turns: number;
|
|
242
|
+
/** Characters of one tool call's input, as JSON. */
|
|
243
|
+
readonly inputChars: number;
|
|
244
|
+
/** Characters of one tool result's output, as JSON. */
|
|
245
|
+
readonly outputChars: number;
|
|
246
|
+
/** Characters of every expanded message together, as JSON. */
|
|
247
|
+
readonly totalChars: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The bounds a turn uses. */
|
|
251
|
+
export const HISTORY_LIMITS: HistoryLimits = {
|
|
252
|
+
turns: 6,
|
|
253
|
+
inputChars: 1_000,
|
|
254
|
+
outputChars: 2_000,
|
|
255
|
+
totalChars: 60_000,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The head of a long string, and how much was left out. Never a summary. */
|
|
259
|
+
function cut(text: string, max: number): string {
|
|
260
|
+
return text.length <= max ? text : `${text.slice(0, max)}<omitted ${String(text.length - max)} chars>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
264
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A tool result's output, bounded.
|
|
269
|
+
*
|
|
270
|
+
* An `error` is kept whole wherever it is — an error-typed output, or the
|
|
271
|
+
* `{ error }` a failed tool returns — because it is the one thing a model
|
|
272
|
+
* needs verbatim to avoid repeating the call that caused it.
|
|
273
|
+
*/
|
|
274
|
+
function boundOutput(output: unknown, max: number): unknown {
|
|
275
|
+
if (!isRecord(output)) return output;
|
|
276
|
+
const type = output['type'];
|
|
277
|
+
if (type === 'error-text' || type === 'error-json' || type === 'execution-denied') return output;
|
|
278
|
+
if (type === 'text' && typeof output['value'] === 'string') {
|
|
279
|
+
return { type: 'text', value: cut(output['value'], max) };
|
|
280
|
+
}
|
|
281
|
+
const json = JSON.stringify(type === 'json' ? output['value'] : output) ?? '';
|
|
282
|
+
if (json.length <= max) return output;
|
|
283
|
+
const value = type === 'json' ? output['value'] : undefined;
|
|
284
|
+
if (isRecord(value) && 'error' in value) {
|
|
285
|
+
const { error, ...rest } = value;
|
|
286
|
+
return { type: 'json', value: { error, rest: cut(JSON.stringify(rest), max) } };
|
|
287
|
+
}
|
|
288
|
+
return { type: 'text', value: cut(json, max) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* One transcript message with every tool input and output bounded.
|
|
293
|
+
*
|
|
294
|
+
* A cut input stays an object, `{ truncated: "<head><omitted N chars>" }`,
|
|
295
|
+
* rather than becoming the string itself: an OpenAI-compatible provider sends
|
|
296
|
+
* a call's input as its `arguments`, and Ollama refuses a whole request whose
|
|
297
|
+
* arguments are not a JSON object ("invalid tool call arguments"). The first
|
|
298
|
+
* 12j evaluation lost a turn to exactly that.
|
|
299
|
+
*/
|
|
300
|
+
function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMessage {
|
|
301
|
+
if (!Array.isArray(message.content)) return message;
|
|
302
|
+
const content = (message.content as readonly unknown[]).map((part) => {
|
|
303
|
+
if (!isRecord(part)) return part;
|
|
304
|
+
if (part['type'] === 'tool-call') {
|
|
305
|
+
const json = JSON.stringify(part['input']) ?? '';
|
|
306
|
+
return json.length <= limits.inputChars ? part : { ...part, input: { truncated: cut(json, limits.inputChars) } };
|
|
307
|
+
}
|
|
308
|
+
if (part['type'] === 'tool-result') return { ...part, output: boundOutput(part['output'], limits.outputChars) };
|
|
309
|
+
return part;
|
|
310
|
+
});
|
|
311
|
+
return { ...message, content } as ModelMessage;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* History as the model is given it.
|
|
316
|
+
*
|
|
317
|
+
* Walking from the newest turn back, an assistant turn whose `runId` names a
|
|
318
|
+
* transcript the host holds is replaced by that transcript — its own tool calls
|
|
319
|
+
* and results, bounded — while fewer than `limits.turns` have been and the total
|
|
320
|
+
* stays under `limits.totalChars`. The first turn that would cross the total
|
|
321
|
+
* stays text, and so does every turn older than it. Every other turn is its text,
|
|
322
|
+
* exactly as before. User turns keep their place.
|
|
323
|
+
*/
|
|
324
|
+
export function expandHistory(
|
|
325
|
+
history: readonly ChatTurn[],
|
|
326
|
+
read: (runId: string) => readonly ResponseMessage[] | null,
|
|
327
|
+
limits: HistoryLimits = HISTORY_LIMITS,
|
|
328
|
+
): ModelMessage[] {
|
|
329
|
+
const segments: ModelMessage[][] = [];
|
|
330
|
+
let expanded = 0;
|
|
331
|
+
let total = 0;
|
|
332
|
+
let full = false;
|
|
333
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
334
|
+
const turn = history[index];
|
|
335
|
+
if (turn === undefined) continue;
|
|
336
|
+
const text: ModelMessage[] = [{ role: turn.role, content: turn.content }];
|
|
337
|
+
if (turn.role !== 'assistant' || turn.runId === undefined || full || expanded >= limits.turns) {
|
|
338
|
+
segments.push(text);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const transcript = read(turn.runId);
|
|
342
|
+
if (transcript === null || transcript.length === 0) {
|
|
343
|
+
segments.push(text);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const bounded = transcript.map((message) => boundMessage(message, limits));
|
|
347
|
+
const chars = JSON.stringify(bounded).length;
|
|
348
|
+
if (total + chars > limits.totalChars) {
|
|
349
|
+
full = true;
|
|
350
|
+
segments.push(text);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
total += chars;
|
|
354
|
+
expanded += 1;
|
|
355
|
+
segments.push(bounded);
|
|
356
|
+
}
|
|
357
|
+
return segments.reverse().flat();
|
|
358
|
+
}
|
|
359
|
+
|
|
220
360
|
/**
|
|
221
361
|
* A message the model may see, without whatever a caller invented.
|
|
222
362
|
*
|
|
@@ -224,13 +364,21 @@ async function assembleContext(
|
|
|
224
364
|
* are strings by contract, so an earlier turn's picture is already a
|
|
225
365
|
* `[image: name]` line the browser put there — the alternative, resending
|
|
226
366
|
* every image on every turn, would cost the user the same upload again on each
|
|
227
|
-
* question.
|
|
367
|
+
* question. An assistant turn that names its run is expanded from the host's
|
|
368
|
+
* own transcript by {@link expandHistory}.
|
|
228
369
|
*/
|
|
229
|
-
function toModelMessages(params: StreamChatParams): ModelMessage[] {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
370
|
+
function toModelMessages(params: StreamChatParams, transcripts: RunTranscripts | undefined, logger: HostLogger): ModelMessage[] {
|
|
371
|
+
const read = (runId: string): readonly ResponseMessage[] | null => {
|
|
372
|
+
if (transcripts === undefined) return null;
|
|
373
|
+
try {
|
|
374
|
+
return transcripts.read(runId);
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
// A store that cannot be read has no transcript to give; the turn is text.
|
|
377
|
+
logger.error(`[broapp] ai could not read the transcript of run ${runId}: ${String(cause instanceof Error ? cause.message : cause)}`);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const messages: ModelMessage[] = expandHistory(params.history, read);
|
|
234
382
|
const files = params.files ?? [];
|
|
235
383
|
if (files.length === 0) {
|
|
236
384
|
messages.push({ role: 'user', content: params.message });
|
|
@@ -366,6 +514,7 @@ function buildTools(
|
|
|
366
514
|
deps: RunDeps,
|
|
367
515
|
sink: StreamSink<ChatEvent>,
|
|
368
516
|
approver: Approver,
|
|
517
|
+
recorder: TranscriptRecorder,
|
|
369
518
|
): ToolSet {
|
|
370
519
|
const tools: ToolSet = {};
|
|
371
520
|
for (const [name, definition] of Object.entries(deps.tools)) {
|
|
@@ -374,6 +523,7 @@ function buildTools(
|
|
|
374
523
|
inputSchema: jsonSchema(definition.inputSchema),
|
|
375
524
|
execute: async (input: unknown, options: { toolCallId: string }): Promise<unknown> => {
|
|
376
525
|
const callId = options.toolCallId;
|
|
526
|
+
recorder.called(callId, name, input);
|
|
377
527
|
await sink.emit({
|
|
378
528
|
type: 'tool-call',
|
|
379
529
|
callId,
|
|
@@ -409,12 +559,14 @@ function buildTools(
|
|
|
409
559
|
output: DECLINED,
|
|
410
560
|
denied: true,
|
|
411
561
|
});
|
|
562
|
+
recorder.answered(callId, DECLINED);
|
|
412
563
|
return DECLINED;
|
|
413
564
|
}
|
|
414
565
|
// One tool failing is not the turn failing. The model gets the
|
|
415
566
|
// reason and can carry on or explain.
|
|
416
567
|
output = { error: safeToolMessage(cause, name, deps.logger) };
|
|
417
568
|
}
|
|
569
|
+
recorder.answered(callId, output);
|
|
418
570
|
await sink.emit({ type: 'tool-result', callId, tool: name, output });
|
|
419
571
|
return output;
|
|
420
572
|
},
|
|
@@ -442,6 +594,59 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
|
|
|
442
594
|
return 'The tool failed.';
|
|
443
595
|
}
|
|
444
596
|
|
|
597
|
+
/**
|
|
598
|
+
* What a turn has produced so far, for a turn that does not reach `finish`.
|
|
599
|
+
*
|
|
600
|
+
* A finished turn's transcript is the AI SDK's own `responseMessages`. A turn
|
|
601
|
+
* that is stopped never gets one, and a stopped turn is exactly the one whose
|
|
602
|
+
* "continue" needs it most; so the steps the SDK finished are kept as it gave
|
|
603
|
+
* them, and the step in flight is kept from its text and from the calls this
|
|
604
|
+
* layer ran. Storage removes a call that never got its result.
|
|
605
|
+
*/
|
|
606
|
+
class TranscriptRecorder {
|
|
607
|
+
/** Set once the model has been asked; before that there is nothing to keep. */
|
|
608
|
+
started = false;
|
|
609
|
+
private readonly finished: ResponseMessage[] = [];
|
|
610
|
+
private text = '';
|
|
611
|
+
private calls = new Map<string, { name: string; input: unknown; output?: { value: unknown } }>();
|
|
612
|
+
|
|
613
|
+
stepEnded(messages: readonly ResponseMessage[]): void {
|
|
614
|
+
this.finished.push(...messages);
|
|
615
|
+
this.text = '';
|
|
616
|
+
this.calls = new Map();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
wrote(text: string): void {
|
|
620
|
+
this.text += text;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
called(callId: string, name: string, input: unknown): void {
|
|
624
|
+
this.calls.set(callId, { name, input });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
answered(callId: string, output: unknown): void {
|
|
628
|
+
const call = this.calls.get(callId);
|
|
629
|
+
if (call !== undefined) call.output = { value: output };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Every finished step, then the step in flight. */
|
|
633
|
+
sofar(): ResponseMessage[] {
|
|
634
|
+
const assistant: Array<{ type: 'text'; text: string } | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }> = [];
|
|
635
|
+
if (this.text !== '') assistant.push({ type: 'text', text: this.text });
|
|
636
|
+
const results: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'json'; value: never } }> = [];
|
|
637
|
+
for (const [toolCallId, call] of this.calls) {
|
|
638
|
+
assistant.push({ type: 'tool-call', toolCallId, toolName: call.name, input: call.input });
|
|
639
|
+
if (call.output !== undefined) {
|
|
640
|
+
results.push({ type: 'tool-result', toolCallId, toolName: call.name, output: { type: 'json', value: call.output.value as never } });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const out = [...this.finished];
|
|
644
|
+
if (assistant.length > 0) out.push({ role: 'assistant', content: assistant });
|
|
645
|
+
if (results.length > 0) out.push({ role: 'tool', content: results });
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
445
650
|
/** How much of the person's message stands in for the whole turn. */
|
|
446
651
|
const SUMMARY_CHARS = 200;
|
|
447
652
|
|
|
@@ -457,6 +662,18 @@ export async function runChat(
|
|
|
457
662
|
let ended = false;
|
|
458
663
|
const started = Date.now();
|
|
459
664
|
const tally: TurnTally = { steps: 0 };
|
|
665
|
+
const recorder = new TranscriptRecorder();
|
|
666
|
+
const transcript = new TranscriptWriter(params.runId, deps);
|
|
667
|
+
// A turn that never reaches `finish` — stopped, failed, or cut off by the
|
|
668
|
+
// provider — still leaves what it did. A stop is written the moment it
|
|
669
|
+
// happens rather than when the loop notices: the AI SDK waits for a running
|
|
670
|
+
// tool before it ends the stream, and what the turn did is what had come back
|
|
671
|
+
// when the person stopped it, which is also what the browser was shown.
|
|
672
|
+
// Written once: a finished turn has already written the SDK's own messages.
|
|
673
|
+
const keepSoFar = (): void => {
|
|
674
|
+
if (recorder.started) transcript.write(recorder.sofar());
|
|
675
|
+
};
|
|
676
|
+
sink.signal.addEventListener('abort', keepSoFar, { once: true });
|
|
460
677
|
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
461
678
|
if (ended) return;
|
|
462
679
|
ended = true;
|
|
@@ -472,11 +689,43 @@ export async function runChat(
|
|
|
472
689
|
);
|
|
473
690
|
};
|
|
474
691
|
try {
|
|
475
|
-
await runTurn(params, sink, deps, end, tally);
|
|
692
|
+
await runTurn(params, sink, deps, end, tally, recorder, transcript);
|
|
476
693
|
end(sink.signal.aborted ? 'cancelled' : 'succeeded');
|
|
477
694
|
} catch (cause) {
|
|
478
695
|
end(sink.signal.aborted ? 'cancelled' : 'failed');
|
|
479
696
|
throw cause;
|
|
697
|
+
} finally {
|
|
698
|
+
sink.signal.removeEventListener('abort', keepSoFar);
|
|
699
|
+
keepSoFar();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Writes one turn's transcript, once, without ever failing the turn.
|
|
705
|
+
*
|
|
706
|
+
* A store that cannot write loses the transcript, not the turn: the error is
|
|
707
|
+
* logged, `done` still goes out, and the next turn's history for this run is
|
|
708
|
+
* its text.
|
|
709
|
+
*/
|
|
710
|
+
class TranscriptWriter {
|
|
711
|
+
private written = false;
|
|
712
|
+
|
|
713
|
+
constructor(
|
|
714
|
+
private readonly runId: string,
|
|
715
|
+
private readonly deps: RunDeps,
|
|
716
|
+
) {}
|
|
717
|
+
|
|
718
|
+
write(messages: readonly ResponseMessage[]): void {
|
|
719
|
+
const transcripts = this.deps.transcripts;
|
|
720
|
+
if (this.written || transcripts === undefined) return;
|
|
721
|
+
this.written = true;
|
|
722
|
+
try {
|
|
723
|
+
transcripts.save(this.runId, messages);
|
|
724
|
+
} catch (cause) {
|
|
725
|
+
this.deps.logger.error(
|
|
726
|
+
`[broapp] ai could not keep the transcript of run ${this.runId}: ${String(cause instanceof Error ? cause.message : cause)}`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
480
729
|
}
|
|
481
730
|
}
|
|
482
731
|
|
|
@@ -487,6 +736,8 @@ async function runTurn(
|
|
|
487
736
|
deps: RunDeps,
|
|
488
737
|
end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
|
|
489
738
|
tally: TurnTally,
|
|
739
|
+
recorder: TranscriptRecorder,
|
|
740
|
+
transcript: TranscriptWriter,
|
|
490
741
|
): Promise<void> {
|
|
491
742
|
// Throws a PublicError when nothing is configured. `runStream` in host/app.ts
|
|
492
743
|
// turns that into the right thing on the wire, so it is not caught here.
|
|
@@ -535,19 +786,29 @@ async function runTurn(
|
|
|
535
786
|
);
|
|
536
787
|
}
|
|
537
788
|
|
|
789
|
+
// From here the model has been asked, so there is a transcript to keep
|
|
790
|
+
// however the turn ends. A turn refused before this point has none.
|
|
791
|
+
recorder.started = true;
|
|
538
792
|
const result = streamText({
|
|
539
793
|
// Always a model *instance*. A string here would be resolved by the AI
|
|
540
794
|
// SDK's gateway, over the global fetch, to a Vercel host — see
|
|
541
795
|
// reports/01-spike.md. Nothing in this layer may pass one.
|
|
542
796
|
model: resolved.adapter.model(resolved.config, resolved.modelId),
|
|
543
797
|
system,
|
|
544
|
-
messages: toModelMessages(params),
|
|
545
|
-
tools: buildTools(params, deps, sink, approver),
|
|
798
|
+
messages: toModelMessages(params, deps.transcripts, deps.logger),
|
|
799
|
+
tools: buildTools(params, deps, sink, approver, recorder),
|
|
546
800
|
stopWhen: stepCountIs(deps.maxSteps),
|
|
547
801
|
abortSignal: sink.signal,
|
|
548
802
|
// The default handler prints the error; this layer reports it as an event
|
|
549
803
|
// and decides for itself what is safe to say.
|
|
550
804
|
onError: () => undefined,
|
|
805
|
+
// Both from the SDK's own pipeline rather than from the loop below, so a
|
|
806
|
+
// step's text and the step's end are seen in the order they happened even
|
|
807
|
+
// when the loop is behind, waiting on a slow socket.
|
|
808
|
+
onChunk: ({ chunk }) => {
|
|
809
|
+
if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
|
|
810
|
+
},
|
|
811
|
+
onStepEnd: (step) => recorder.stepEnded(step.response.messages),
|
|
551
812
|
});
|
|
552
813
|
|
|
553
814
|
for await (const part of result.fullStream) {
|
|
@@ -569,6 +830,9 @@ async function runTurn(
|
|
|
569
830
|
outputTokens: part.totalUsage.outputTokens ?? 0,
|
|
570
831
|
};
|
|
571
832
|
tally.usage = usage;
|
|
833
|
+
// Before `done`: a client that saves the conversation on `done` and
|
|
834
|
+
// sends it back at once must find the run's transcript already there.
|
|
835
|
+
transcript.write(await result.responseMessages);
|
|
572
836
|
await sink.emit({ type: 'usage', ...usage });
|
|
573
837
|
await sink.emit({ type: 'done' });
|
|
574
838
|
break;
|
package/src/ai/host/threads.ts
CHANGED
|
@@ -14,6 +14,10 @@ import { Database } from 'bun:sqlite';
|
|
|
14
14
|
import { mkdirSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
+
import type { AssistantModelMessage, ToolModelMessage } from 'ai';
|
|
18
|
+
|
|
19
|
+
import type { HostLogger } from '../../host/app.ts';
|
|
20
|
+
import { canonicalJson } from '../../host/gate.ts';
|
|
17
21
|
import { publicError } from '../../shared/errors.ts';
|
|
18
22
|
import type { StoredMessage, Thread } from '../shared/types.ts';
|
|
19
23
|
|
|
@@ -58,8 +62,40 @@ const MIGRATIONS: readonly string[] = [
|
|
|
58
62
|
OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
|
|
59
63
|
);
|
|
60
64
|
CREATE INDEX threads_seq ON threads (seq DESC);`,
|
|
65
|
+
/*
|
|
66
|
+
* What the model itself saw and did on each turn, by run id.
|
|
67
|
+
*
|
|
68
|
+
* Written by the host from the AI SDK's own response messages, never from
|
|
69
|
+
* anything a browser saved: a history turn that names a run gets these back
|
|
70
|
+
* in place of its text. Not tied to a thread, because the host does not know
|
|
71
|
+
* which conversation a run belongs to; the age and count caps are the bound.
|
|
72
|
+
*/
|
|
73
|
+
`CREATE TABLE transcripts (
|
|
74
|
+
run_id TEXT PRIMARY KEY,
|
|
75
|
+
messages TEXT NOT NULL,
|
|
76
|
+
chars INTEGER NOT NULL,
|
|
77
|
+
created_at INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX transcripts_created_at ON transcripts (created_at);`,
|
|
61
80
|
];
|
|
62
81
|
|
|
82
|
+
/**
|
|
83
|
+
* One message a turn produced: what `StreamTextResult.responseMessages` holds.
|
|
84
|
+
*
|
|
85
|
+
* `ai` declares `ResponseMessage` as exactly this union but does not export the
|
|
86
|
+
* name, so it is spelled out here from the two types it does export.
|
|
87
|
+
*/
|
|
88
|
+
export type ResponseMessage = AssistantModelMessage | ToolModelMessage;
|
|
89
|
+
|
|
90
|
+
/** The most characters one transcript may be; a longer turn stays text. */
|
|
91
|
+
export const MAX_TRANSCRIPT_CHARS = 200_000;
|
|
92
|
+
|
|
93
|
+
/** How long a transcript is kept. */
|
|
94
|
+
const TRANSCRIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
95
|
+
|
|
96
|
+
/** How many transcripts are kept, newest first. */
|
|
97
|
+
const TRANSCRIPT_MAX_COUNT = 2_000;
|
|
98
|
+
|
|
63
99
|
/** What a conversation is called until it has been named. */
|
|
64
100
|
export const DEFAULT_THREAD_TITLE = 'New conversation';
|
|
65
101
|
|
|
@@ -100,9 +136,104 @@ export interface ThreadStore {
|
|
|
100
136
|
remove(id: string): boolean;
|
|
101
137
|
/** Every conversation. Returns how many were deleted. */
|
|
102
138
|
clear(): number;
|
|
139
|
+
/**
|
|
140
|
+
* Keep one turn's response messages under its run id.
|
|
141
|
+
*
|
|
142
|
+
* Unpaired tool calls are removed first, provider metadata is dropped, and a
|
|
143
|
+
* transcript over {@link MAX_TRANSCRIPT_CHARS} is not written. Returns whether
|
|
144
|
+
* a row was written. Throws when the store cannot write.
|
|
145
|
+
*/
|
|
146
|
+
saveTranscript(runId: string, messages: readonly ResponseMessage[]): boolean;
|
|
147
|
+
/** A transcript this store wrote, or null when it holds none that reads back whole. */
|
|
148
|
+
transcript(runId: string): readonly ResponseMessage[] | null;
|
|
149
|
+
/** Delete transcripts older than 30 days or beyond the newest 2,000. Returns how many went. */
|
|
150
|
+
retainTranscripts(): number;
|
|
103
151
|
close(): void;
|
|
104
152
|
}
|
|
105
153
|
|
|
154
|
+
/** Options for {@link openThreads}. */
|
|
155
|
+
export interface OpenThreadsOptions {
|
|
156
|
+
/** Where an oversized or unreadable transcript is reported. Default `console`. */
|
|
157
|
+
readonly logger?: HostLogger;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A plain object, for reading a stored or SDK-given value without trusting its type. */
|
|
161
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
162
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A copy of an object without the provider's own fields, which no later prompt needs. */
|
|
166
|
+
function withoutProviderFields(value: Record<string, unknown>): Record<string, unknown> {
|
|
167
|
+
const out: Record<string, unknown> = {};
|
|
168
|
+
for (const [key, member] of Object.entries(value)) {
|
|
169
|
+
if (key === 'providerOptions' || key === 'providerMetadata' || key === 'providerExecuted') continue;
|
|
170
|
+
out[key] = member;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A turn's response messages as they are kept.
|
|
177
|
+
*
|
|
178
|
+
* Two edits and nothing else. A tool call with no result — a turn stopped while
|
|
179
|
+
* the tool ran — is removed, because a provider refuses a prompt that has a call
|
|
180
|
+
* without its answer; a message that edit leaves empty goes with it. Provider
|
|
181
|
+
* metadata is dropped at the message, part and output level: it belongs to the
|
|
182
|
+
* request it came from, and tool inputs and outputs are left exactly as given.
|
|
183
|
+
*/
|
|
184
|
+
export function transcriptForStorage(messages: readonly ResponseMessage[]): ResponseMessage[] {
|
|
185
|
+
const answered = new Set<string>();
|
|
186
|
+
for (const message of messages) {
|
|
187
|
+
if (!Array.isArray(message.content)) continue;
|
|
188
|
+
for (const part of message.content as readonly unknown[]) {
|
|
189
|
+
if (isRecord(part) && part['type'] === 'tool-result' && typeof part['toolCallId'] === 'string') {
|
|
190
|
+
answered.add(part['toolCallId']);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const out: ResponseMessage[] = [];
|
|
195
|
+
for (const message of messages) {
|
|
196
|
+
if (!Array.isArray(message.content)) {
|
|
197
|
+
out.push(withoutProviderFields(message as unknown as Record<string, unknown>) as unknown as ResponseMessage);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const content: unknown[] = [];
|
|
201
|
+
for (const part of message.content as readonly unknown[]) {
|
|
202
|
+
if (!isRecord(part)) continue;
|
|
203
|
+
if (part['type'] === 'tool-call' && !answered.has(String(part['toolCallId']))) continue;
|
|
204
|
+
const kept = withoutProviderFields(part);
|
|
205
|
+
if (isRecord(kept['output'])) kept['output'] = withoutProviderFields(kept['output']);
|
|
206
|
+
content.push(kept);
|
|
207
|
+
}
|
|
208
|
+
if (content.length === 0) continue;
|
|
209
|
+
out.push({ ...withoutProviderFields(message as unknown as Record<string, unknown>), content } as unknown as ResponseMessage);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a stored value reads back as response messages.
|
|
216
|
+
*
|
|
217
|
+
* The row was written by this process, but a file can be edited or damaged, and
|
|
218
|
+
* what comes back goes into a prompt. Only an assistant or tool message with a
|
|
219
|
+
* string or an array of typed parts passes; a `system` role never does.
|
|
220
|
+
*/
|
|
221
|
+
function isTranscript(value: unknown): value is ResponseMessage[] {
|
|
222
|
+
if (!Array.isArray(value)) return false;
|
|
223
|
+
return value.every((message) => {
|
|
224
|
+
if (!isRecord(message)) return false;
|
|
225
|
+
const role = message['role'];
|
|
226
|
+
const content = message['content'];
|
|
227
|
+
if (role === 'assistant') {
|
|
228
|
+
return typeof content === 'string' || (Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string'));
|
|
229
|
+
}
|
|
230
|
+
if (role === 'tool') {
|
|
231
|
+
return Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string');
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
106
237
|
/** A row of `threads`, joined with its message count. */
|
|
107
238
|
interface ThreadRow {
|
|
108
239
|
id: string;
|
|
@@ -181,7 +312,8 @@ function derivedTitle(messages: readonly StoredMessage[]): string | null {
|
|
|
181
312
|
}
|
|
182
313
|
|
|
183
314
|
/** Open the conversation store for one data directory, migrating it as needed. */
|
|
184
|
-
export function openThreads(dataDir: string): ThreadStore {
|
|
315
|
+
export function openThreads(dataDir: string, options: OpenThreadsOptions = {}): ThreadStore {
|
|
316
|
+
const logger = options.logger ?? console;
|
|
185
317
|
const directory = join(dataDir, 'ai');
|
|
186
318
|
// The same mode the settings store uses: this directory holds what somebody
|
|
187
319
|
// wrote to their assistant, which is nobody else's business.
|
|
@@ -235,8 +367,32 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
235
367
|
remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
|
|
236
368
|
count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
|
|
237
369
|
clear: db.query<unknown, []>('DELETE FROM threads'),
|
|
370
|
+
saveTranscript: db.query<unknown, [string, string, number, number]>(
|
|
371
|
+
`INSERT INTO transcripts (run_id, messages, chars, created_at) VALUES (?, ?, ?, ?)
|
|
372
|
+
ON CONFLICT (run_id) DO UPDATE SET messages = excluded.messages, chars = excluded.chars,
|
|
373
|
+
created_at = excluded.created_at`,
|
|
374
|
+
),
|
|
375
|
+
transcript: db.query<{ messages: string }, [string]>('SELECT messages FROM transcripts WHERE run_id = ?'),
|
|
376
|
+
expireTranscripts: db.query<{ run_id: string }, [number]>(
|
|
377
|
+
'DELETE FROM transcripts WHERE created_at < ? RETURNING run_id',
|
|
378
|
+
),
|
|
379
|
+
capTranscripts: db.query<{ run_id: string }, [number]>(
|
|
380
|
+
`DELETE FROM transcripts WHERE rowid NOT IN (
|
|
381
|
+
SELECT rowid FROM transcripts ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
382
|
+
) RETURNING run_id`,
|
|
383
|
+
),
|
|
238
384
|
};
|
|
239
385
|
|
|
386
|
+
/** One unreadable transcript is reported once, not on every turn that names it. */
|
|
387
|
+
const reported = new Set<string>();
|
|
388
|
+
|
|
389
|
+
/** Both caps, in one transaction, so a crash never leaves one applied without the other. */
|
|
390
|
+
const retain = (): number =>
|
|
391
|
+
db.transaction(() => {
|
|
392
|
+
const expired = statements.expireTranscripts.all(Date.now() - TRANSCRIPT_MAX_AGE_MS).length;
|
|
393
|
+
return expired + statements.capTranscripts.all(TRANSCRIPT_MAX_COUNT).length;
|
|
394
|
+
})();
|
|
395
|
+
|
|
240
396
|
/** The row, or the sentence a browser shows when a conversation is gone. */
|
|
241
397
|
function mustGet(id: string): ThreadRow {
|
|
242
398
|
const row = statements.byId.get(id);
|
|
@@ -244,6 +400,10 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
244
400
|
return row;
|
|
245
401
|
}
|
|
246
402
|
|
|
403
|
+
// Retention runs on open as well as on every save, so a store that is only
|
|
404
|
+
// read still sheds what has aged out.
|
|
405
|
+
retain();
|
|
406
|
+
|
|
247
407
|
const store: ThreadStore = {
|
|
248
408
|
path,
|
|
249
409
|
|
|
@@ -318,6 +478,43 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
318
478
|
return before;
|
|
319
479
|
},
|
|
320
480
|
|
|
481
|
+
saveTranscript(runId, messages) {
|
|
482
|
+
const kept = transcriptForStorage(messages);
|
|
483
|
+
const json = canonicalJson(kept);
|
|
484
|
+
if (json.length > MAX_TRANSCRIPT_CHARS) {
|
|
485
|
+
logger.warn(
|
|
486
|
+
`[broapp] ai transcript for run ${runId} is ${String(json.length)} characters, over ${String(MAX_TRANSCRIPT_CHARS)}; the turn stays text`,
|
|
487
|
+
);
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
db.transaction(() => {
|
|
491
|
+
statements.saveTranscript.run(runId, json, json.length, Date.now());
|
|
492
|
+
retain();
|
|
493
|
+
})();
|
|
494
|
+
return true;
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
transcript(runId) {
|
|
498
|
+
const row = statements.transcript.get(runId);
|
|
499
|
+
if (row === null) return null;
|
|
500
|
+
let parsed: unknown;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(row.messages);
|
|
503
|
+
} catch {
|
|
504
|
+
parsed = undefined;
|
|
505
|
+
}
|
|
506
|
+
if (!isTranscript(parsed) || row.messages.length > MAX_TRANSCRIPT_CHARS) {
|
|
507
|
+
if (!reported.has(runId)) {
|
|
508
|
+
reported.add(runId);
|
|
509
|
+
logger.warn(`[broapp] ai transcript for run ${runId} does not read back as messages; the turn stays text`);
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return parsed;
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
retainTranscripts: retain,
|
|
517
|
+
|
|
321
518
|
close() {
|
|
322
519
|
// Checkpointing folds the WAL back into the main file, so what is left
|
|
323
520
|
// behind is one complete database rather than one that needs its
|
|
@@ -85,6 +85,8 @@ export type ChatMessage =
|
|
|
85
85
|
readonly content: string;
|
|
86
86
|
readonly toolCalls: ToolCallState[];
|
|
87
87
|
readonly pending: boolean;
|
|
88
|
+
/** The run that wrote it, sent back in `history` so the host can use its transcript. */
|
|
89
|
+
readonly runId: string;
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
/** What {@link useAiChat} returns. */
|
|
@@ -107,12 +109,16 @@ function newRunId(): string {
|
|
|
107
109
|
return crypto.randomUUID().replace(/-/g, '');
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
/** Every completed turn, as the model should see it. */
|
|
111
|
-
function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
|
|
112
|
+
/** Every completed turn, as the model should see it. Exported for its test; not part of `broapp/ai/react`. */
|
|
113
|
+
export function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
|
|
112
114
|
const turns: ChatTurn[] = [];
|
|
113
115
|
for (const message of messages) {
|
|
114
116
|
if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
|
|
115
|
-
turns.push(
|
|
117
|
+
turns.push(
|
|
118
|
+
message.role === 'assistant'
|
|
119
|
+
? { role: 'assistant', content: message.content, runId: message.runId }
|
|
120
|
+
: { role: 'user', content: message.content },
|
|
121
|
+
);
|
|
116
122
|
}
|
|
117
123
|
return turns.slice(-MAX_HISTORY);
|
|
118
124
|
}
|
|
@@ -267,7 +273,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
267
273
|
setMessages((current) => [
|
|
268
274
|
...current,
|
|
269
275
|
{ id: `${id}-user`, role: 'user', content: trimmed },
|
|
270
|
-
{ id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
|
|
276
|
+
{ id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true, runId: id },
|
|
271
277
|
]);
|
|
272
278
|
|
|
273
279
|
try {
|
|
@@ -53,6 +53,9 @@ const settings = s.object({
|
|
|
53
53
|
const chatTurn = s.object({
|
|
54
54
|
role: s.enum(['user', 'assistant']),
|
|
55
55
|
content: s.string({ max: 20_000 }),
|
|
56
|
+
// Names a run whose transcript the host wrote. It only ever selects
|
|
57
|
+
// something the host already holds; an id it does not hold is ignored.
|
|
58
|
+
runId: s.optional(runId),
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
/**
|
package/src/ai/shared/types.ts
CHANGED
|
@@ -59,6 +59,12 @@ export type ToolPermission = 'read' | 'confirm';
|
|
|
59
59
|
export interface ChatTurn {
|
|
60
60
|
role: 'user' | 'assistant';
|
|
61
61
|
content: string;
|
|
62
|
+
/**
|
|
63
|
+
* On an assistant turn: the run that produced it. The host expands a turn
|
|
64
|
+
* whose run it holds a transcript for into that turn's own tool calls and
|
|
65
|
+
* results; any other turn, and any run it does not hold, is `content`.
|
|
66
|
+
*/
|
|
67
|
+
runId?: string;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/**
|
package/src/host/runtime.ts
CHANGED
|
@@ -76,6 +76,15 @@ export interface RunningApp {
|
|
|
76
76
|
* looking attached for that whole minute.
|
|
77
77
|
*/
|
|
78
78
|
readonly attached: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* A fresh address for this application, carrying a new one-time launch token.
|
|
81
|
+
*
|
|
82
|
+
* Brobridge mints the token; each address works once and expires on the
|
|
83
|
+
* bridge's launch-token lifetime. Call it on the host's own decision — a
|
|
84
|
+
* person's click in an authenticated tab, a local process that already holds
|
|
85
|
+
* a credential — never because a browser asked. Do not log the result.
|
|
86
|
+
*/
|
|
87
|
+
launchUrl(): string;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
const POLL_INTERVAL_MS = 1_000;
|
|
@@ -215,5 +224,6 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
|
|
|
215
224
|
get attached() {
|
|
216
225
|
return isAttached();
|
|
217
226
|
},
|
|
227
|
+
launchUrl: () => bridge.launchUrl(),
|
|
218
228
|
};
|
|
219
229
|
}
|