broapp 0.2.0 → 0.3.0
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 +1 -1
- package/src/ai/host/adapter.ts +4 -1
- package/src/ai/host/create-ai.ts +72 -6
- package/src/ai/host/fake.ts +11 -4
- package/src/ai/host/from-contract.ts +35 -15
- package/src/ai/host/index.ts +6 -1
- package/src/ai/host/registry.ts +19 -8
- package/src/ai/host/run.ts +247 -27
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +59 -53
- package/src/ai/react/AiChat.tsx +49 -1
- package/src/ai/react/AiSettings.tsx +8 -2
- package/src/ai/react/ai.css +14 -0
- package/src/ai/react/index.tsx +3 -0
- package/src/ai/react/use-ai-chat.ts +9 -1
- package/src/ai/shared/contract.ts +110 -1
- package/src/ai/shared/index.ts +3 -0
- package/src/ai/shared/types.check.ts +30 -2
- package/src/ai/shared/types.ts +62 -2
- package/src/host/app.ts +99 -16
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +22 -2
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/shared/contract.ts +49 -6
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +56 -2
- package/src/shared/index.ts +6 -1
- package/src/shared/schema.ts +16 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where conversations live.
|
|
3
|
+
*
|
|
4
|
+
* `<dataDir>/ai/threads.sqlite`, beside the settings and the secrets. A
|
|
5
|
+
* conversation is the user's own writing, so nothing here asks whether a
|
|
6
|
+
* provider is configured: somebody who has just deleted their key still owns
|
|
7
|
+
* what they typed and must still be able to read and delete it.
|
|
8
|
+
*
|
|
9
|
+
* The host stores messages and never interprets them. Parts are the AI SDK's
|
|
10
|
+
* shape, written as JSON and handed back to the browser unread, with one
|
|
11
|
+
* deliberate exception on the way in — see {@link withoutImages}.
|
|
12
|
+
*/
|
|
13
|
+
import { Database } from 'bun:sqlite';
|
|
14
|
+
import { mkdirSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { publicError } from '../../shared/errors.ts';
|
|
18
|
+
import type { StoredMessage, Thread } from '../shared/types.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The migrations, in order.
|
|
22
|
+
*
|
|
23
|
+
* Each is applied once and `user_version` is set to its index. Adding a column
|
|
24
|
+
* later means appending here — never editing an entry that has shipped,
|
|
25
|
+
* because a database that already ran the old version will never run the new
|
|
26
|
+
* one.
|
|
27
|
+
*/
|
|
28
|
+
const MIGRATIONS: readonly string[] = [
|
|
29
|
+
`CREATE TABLE threads (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
title TEXT NOT NULL,
|
|
32
|
+
model_id TEXT,
|
|
33
|
+
created_at INTEGER NOT NULL,
|
|
34
|
+
updated_at INTEGER NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX threads_updated_at ON threads (updated_at DESC);
|
|
37
|
+
CREATE TABLE messages (
|
|
38
|
+
thread_id TEXT NOT NULL REFERENCES threads (id) ON DELETE CASCADE,
|
|
39
|
+
position INTEGER NOT NULL,
|
|
40
|
+
json TEXT NOT NULL,
|
|
41
|
+
PRIMARY KEY (thread_id, position)
|
|
42
|
+
);`,
|
|
43
|
+
/*
|
|
44
|
+
* A monotonic sequence, because a timestamp is not one.
|
|
45
|
+
*
|
|
46
|
+
* `updated_at` is milliseconds, and two writes inside one millisecond used
|
|
47
|
+
* to be ordered by whichever random id sorted higher — so a list could come
|
|
48
|
+
* back in a different order for the same history (report 07). `seq` is
|
|
49
|
+
* bumped by every write that touches a conversation, so "most recently
|
|
50
|
+
* changed first" means what it says. The backfill puts existing rows in the
|
|
51
|
+
* order they were last shown in, `rowid` breaking the ties the old query
|
|
52
|
+
* could not.
|
|
53
|
+
*/
|
|
54
|
+
`ALTER TABLE threads ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;
|
|
55
|
+
UPDATE threads SET seq = (
|
|
56
|
+
SELECT COUNT(*) FROM threads AS earlier
|
|
57
|
+
WHERE earlier.updated_at < threads.updated_at
|
|
58
|
+
OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX threads_seq ON threads (seq DESC);`,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** What a conversation is called until it has been named. */
|
|
64
|
+
export const DEFAULT_THREAD_TITLE = 'New conversation';
|
|
65
|
+
|
|
66
|
+
/** How much of the first message becomes the title. */
|
|
67
|
+
const TITLE_CHARS = 60;
|
|
68
|
+
|
|
69
|
+
/** The most conversations one listing returns, matching the contract's bound. */
|
|
70
|
+
const MAX_THREADS = 500;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The most JSON one save may write.
|
|
74
|
+
*
|
|
75
|
+
* The contract bounds the *number* of messages and parts but not their size,
|
|
76
|
+
* because a part is `unknown` by design. This is the bound on the amount: four
|
|
77
|
+
* megabytes is far more than a conversation of 200 messages needs and far less
|
|
78
|
+
* than a browser could use to fill somebody's disk.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_SAVE_CHARS = 4_000_000;
|
|
81
|
+
|
|
82
|
+
/** A conversation and everything in it. */
|
|
83
|
+
export interface ThreadStore {
|
|
84
|
+
readonly path: string;
|
|
85
|
+
/** Most recently changed first, capped at 500. */
|
|
86
|
+
list(): Thread[];
|
|
87
|
+
create(input: { title?: string | undefined; modelId?: string | null | undefined }): Thread;
|
|
88
|
+
get(id: string): { thread: Thread; messages: StoredMessage[] };
|
|
89
|
+
/** Replaces the messages whole and bumps `updatedAt`. */
|
|
90
|
+
save(input: {
|
|
91
|
+
id: string;
|
|
92
|
+
messages: readonly StoredMessage[];
|
|
93
|
+
title?: string | undefined;
|
|
94
|
+
}): Thread;
|
|
95
|
+
update(input: {
|
|
96
|
+
id: string;
|
|
97
|
+
title?: string | undefined;
|
|
98
|
+
modelId?: string | null | undefined;
|
|
99
|
+
}): Thread;
|
|
100
|
+
remove(id: string): boolean;
|
|
101
|
+
/** Every conversation. Returns how many were deleted. */
|
|
102
|
+
clear(): number;
|
|
103
|
+
close(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A row of `threads`, joined with its message count. */
|
|
107
|
+
interface ThreadRow {
|
|
108
|
+
id: string;
|
|
109
|
+
title: string;
|
|
110
|
+
model_id: string | null;
|
|
111
|
+
created_at: number;
|
|
112
|
+
updated_at: number;
|
|
113
|
+
message_count: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toThread(row: ThreadRow): Thread {
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
title: row.title,
|
|
120
|
+
modelId: row.model_id,
|
|
121
|
+
createdAt: row.created_at,
|
|
122
|
+
updatedAt: row.updated_at,
|
|
123
|
+
messageCount: row.message_count,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An id matching the contract's pattern: 32 hex characters. */
|
|
128
|
+
function newThreadId(): string {
|
|
129
|
+
return crypto.randomUUID().replace(/-/g, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True for a part the AI SDK would render as an attachment. */
|
|
133
|
+
function isFilePart(part: unknown): part is { type: 'file'; filename?: unknown; mediaType?: unknown } {
|
|
134
|
+
return typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'file';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A message with its images replaced by the line that names them.
|
|
139
|
+
*
|
|
140
|
+
* A `file` part carries a data URL, so storing one would put a second copy of
|
|
141
|
+
* the picture in SQLite that nobody asked to keep and nothing ever deletes.
|
|
142
|
+
* The placeholder is the same line the browser already puts in `history`, so
|
|
143
|
+
* a reloaded conversation says exactly what the model was told on the turns
|
|
144
|
+
* after the one the image arrived on.
|
|
145
|
+
*/
|
|
146
|
+
function withoutImages(message: StoredMessage): StoredMessage {
|
|
147
|
+
if (!message.parts.some(isFilePart)) return message;
|
|
148
|
+
const parts = message.parts.map((part) => {
|
|
149
|
+
if (!isFilePart(part)) return part;
|
|
150
|
+
const name =
|
|
151
|
+
typeof part.filename === 'string'
|
|
152
|
+
? part.filename
|
|
153
|
+
: typeof part.mediaType === 'string'
|
|
154
|
+
? part.mediaType
|
|
155
|
+
: 'image';
|
|
156
|
+
return { type: 'text', text: `[image: ${name}]` };
|
|
157
|
+
});
|
|
158
|
+
return message.metadata === undefined
|
|
159
|
+
? { id: message.id, role: message.role, parts }
|
|
160
|
+
: { id: message.id, role: message.role, parts, metadata: message.metadata };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The text of a message, for deriving a title. */
|
|
164
|
+
function textOf(message: StoredMessage): string {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (const part of message.parts) {
|
|
167
|
+
if (typeof part !== 'object' || part === null) continue;
|
|
168
|
+
const typed = part as { type?: unknown; text?: unknown };
|
|
169
|
+
if (typed.type === 'text' && typeof typed.text === 'string') lines.push(typed.text);
|
|
170
|
+
}
|
|
171
|
+
return lines.join(' ');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The first user message's opening words, or null when there is nothing to use. */
|
|
175
|
+
function derivedTitle(messages: readonly StoredMessage[]): string | null {
|
|
176
|
+
const first = messages.find((message) => message.role === 'user');
|
|
177
|
+
if (first === undefined) return null;
|
|
178
|
+
const collapsed = textOf(first).replace(/\s+/g, ' ').trim();
|
|
179
|
+
if (collapsed === '') return null;
|
|
180
|
+
return collapsed.slice(0, TITLE_CHARS);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Open the conversation store for one data directory, migrating it as needed. */
|
|
184
|
+
export function openThreads(dataDir: string): ThreadStore {
|
|
185
|
+
const directory = join(dataDir, 'ai');
|
|
186
|
+
// The same mode the settings store uses: this directory holds what somebody
|
|
187
|
+
// wrote to their assistant, which is nobody else's business.
|
|
188
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
189
|
+
const path = join(directory, 'threads.sqlite');
|
|
190
|
+
const db = new Database(path, { create: true, strict: true });
|
|
191
|
+
|
|
192
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
193
|
+
// Without this, `ON DELETE CASCADE` is decoration: SQLite does not enforce a
|
|
194
|
+
// foreign key unless it is asked to.
|
|
195
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
196
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
197
|
+
|
|
198
|
+
migrate(db);
|
|
199
|
+
|
|
200
|
+
const THREAD_COLUMNS = `t.id AS id, t.title AS title, t.model_id AS model_id,
|
|
201
|
+
t.created_at AS created_at, t.updated_at AS updated_at,
|
|
202
|
+
(SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count`;
|
|
203
|
+
|
|
204
|
+
const statements = {
|
|
205
|
+
list: db.query<ThreadRow, [number]>(
|
|
206
|
+
// `seq` only: it is unique and monotonic, so no tie-break is needed and
|
|
207
|
+
// none can disagree with the order the writes actually happened in.
|
|
208
|
+
`SELECT ${THREAD_COLUMNS} FROM threads t ORDER BY t.seq DESC LIMIT ?`,
|
|
209
|
+
),
|
|
210
|
+
byId: db.query<ThreadRow, [string]>(
|
|
211
|
+
`SELECT ${THREAD_COLUMNS} FROM threads t WHERE t.id = ?`,
|
|
212
|
+
),
|
|
213
|
+
/*
|
|
214
|
+
* Both writes take the next sequence in the same statement that changes
|
|
215
|
+
* the row, so the number a conversation is ordered by is decided inside
|
|
216
|
+
* whatever transaction is writing it — never by a second round trip that
|
|
217
|
+
* another write could interleave with.
|
|
218
|
+
*/
|
|
219
|
+
insert: db.query<unknown, [string, string, string | null, number, number]>(
|
|
220
|
+
`INSERT INTO threads (id, title, model_id, created_at, updated_at, seq)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads))`,
|
|
222
|
+
),
|
|
223
|
+
touch: db.query<unknown, [string, string | null, number, string]>(
|
|
224
|
+
`UPDATE threads SET title = ?, model_id = ?, updated_at = ?,
|
|
225
|
+
seq = (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads)
|
|
226
|
+
WHERE id = ?`,
|
|
227
|
+
),
|
|
228
|
+
messages: db.query<{ json: string }, [string]>(
|
|
229
|
+
'SELECT json FROM messages WHERE thread_id = ? ORDER BY position ASC',
|
|
230
|
+
),
|
|
231
|
+
deleteMessages: db.query<unknown, [string]>('DELETE FROM messages WHERE thread_id = ?'),
|
|
232
|
+
insertMessage: db.query<unknown, [string, number, string]>(
|
|
233
|
+
'INSERT INTO messages (thread_id, position, json) VALUES (?, ?, ?)',
|
|
234
|
+
),
|
|
235
|
+
remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
|
|
236
|
+
count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
|
|
237
|
+
clear: db.query<unknown, []>('DELETE FROM threads'),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** The row, or the sentence a browser shows when a conversation is gone. */
|
|
241
|
+
function mustGet(id: string): ThreadRow {
|
|
242
|
+
const row = statements.byId.get(id);
|
|
243
|
+
if (row === null) throw publicError.notFound('That conversation is gone.');
|
|
244
|
+
return row;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const store: ThreadStore = {
|
|
248
|
+
path,
|
|
249
|
+
|
|
250
|
+
list() {
|
|
251
|
+
return statements.list.all(MAX_THREADS).map(toThread);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
create({ title, modelId }) {
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const id = newThreadId();
|
|
257
|
+
statements.insert.run(id, title ?? DEFAULT_THREAD_TITLE, modelId ?? null, now, now);
|
|
258
|
+
return toThread(mustGet(id));
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
get(id) {
|
|
262
|
+
const thread = toThread(mustGet(id));
|
|
263
|
+
const messages = statements.messages.all(id).map((row) => {
|
|
264
|
+
// Written by this process, from a value the contract validated. A
|
|
265
|
+
// parse failure would mean the file was edited by hand or corrupted,
|
|
266
|
+
// and the conversation is unreadable either way.
|
|
267
|
+
return JSON.parse(row.json) as StoredMessage;
|
|
268
|
+
});
|
|
269
|
+
return { thread, messages };
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
save({ id, messages, title }) {
|
|
273
|
+
const row = mustGet(id);
|
|
274
|
+
const stored = messages.map(withoutImages);
|
|
275
|
+
const encoded = stored.map((message) => JSON.stringify(message));
|
|
276
|
+
const characters = encoded.reduce((total, json) => total + json.length, 0);
|
|
277
|
+
if (characters > MAX_SAVE_CHARS) {
|
|
278
|
+
throw publicError.invalidInput('That conversation is too large to save.');
|
|
279
|
+
}
|
|
280
|
+
// A title given wins; otherwise a conversation still carrying the
|
|
281
|
+
// default name takes one from what the person actually asked.
|
|
282
|
+
const named =
|
|
283
|
+
title ?? (row.title === DEFAULT_THREAD_TITLE ? (derivedTitle(stored) ?? row.title) : row.title);
|
|
284
|
+
const now = Date.now();
|
|
285
|
+
// One transaction: a save that failed half way would leave a
|
|
286
|
+
// conversation holding the first few messages of the new list and none
|
|
287
|
+
// of the old.
|
|
288
|
+
db.transaction(() => {
|
|
289
|
+
statements.deleteMessages.run(id);
|
|
290
|
+
for (let index = 0; index < encoded.length; index += 1) {
|
|
291
|
+
const json = encoded[index];
|
|
292
|
+
if (json === undefined) continue;
|
|
293
|
+
statements.insertMessage.run(id, index, json);
|
|
294
|
+
}
|
|
295
|
+
statements.touch.run(named, row.model_id, now, id);
|
|
296
|
+
})();
|
|
297
|
+
return toThread(mustGet(id));
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
update({ id, title, modelId }) {
|
|
301
|
+
const row = mustGet(id);
|
|
302
|
+
statements.touch.run(
|
|
303
|
+
title ?? row.title,
|
|
304
|
+
modelId === undefined ? row.model_id : modelId,
|
|
305
|
+
Date.now(),
|
|
306
|
+
id,
|
|
307
|
+
);
|
|
308
|
+
return toThread(mustGet(id));
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
remove(id) {
|
|
312
|
+
return statements.remove.get(id) !== null;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
clear() {
|
|
316
|
+
const before = statements.count.get()?.n ?? 0;
|
|
317
|
+
statements.clear.run();
|
|
318
|
+
return before;
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
close() {
|
|
322
|
+
// Checkpointing folds the WAL back into the main file, so what is left
|
|
323
|
+
// behind is one complete database rather than one that needs its
|
|
324
|
+
// sidecars to be readable.
|
|
325
|
+
try {
|
|
326
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
327
|
+
} catch {
|
|
328
|
+
// A checkpoint that fails is not a reason to leave the handle open.
|
|
329
|
+
}
|
|
330
|
+
db.close();
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return store;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function currentVersion(db: Database): number {
|
|
338
|
+
const row = db.query<{ user_version: number }, []>('PRAGMA user_version').get();
|
|
339
|
+
return row?.user_version ?? 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Bring the schema up to date.
|
|
344
|
+
*
|
|
345
|
+
* Each migration and its version bump happen in one transaction, so an
|
|
346
|
+
* interrupted upgrade leaves the database at the last version that fully
|
|
347
|
+
* applied — never half-way through one.
|
|
348
|
+
*/
|
|
349
|
+
function migrate(db: Database): void {
|
|
350
|
+
const from = currentVersion(db);
|
|
351
|
+
if (from > MIGRATIONS.length) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`the conversation store's schema version ${String(from)} is newer than this build understands (${String(MIGRATIONS.length)})`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
for (let version = from; version < MIGRATIONS.length; version += 1) {
|
|
357
|
+
const statement = MIGRATIONS[version];
|
|
358
|
+
if (statement === undefined) continue;
|
|
359
|
+
db.transaction(() => {
|
|
360
|
+
db.exec(statement);
|
|
361
|
+
// PRAGMA does not accept a bound parameter, so the value is interpolated.
|
|
362
|
+
// It is a loop index, not input.
|
|
363
|
+
db.exec(`PRAGMA user_version = ${String(version + 1)}`);
|
|
364
|
+
})();
|
|
365
|
+
}
|
|
366
|
+
}
|
package/src/ai/host/tool.ts
CHANGED
|
@@ -4,17 +4,72 @@
|
|
|
4
4
|
* Kept apart from `create-ai.ts` so that `from-contract.ts` and `run.ts` can
|
|
5
5
|
* share these without either importing the other's module graph.
|
|
6
6
|
*/
|
|
7
|
+
import type { Envelope, Gate } from '../../host/gate.ts';
|
|
8
|
+
import type { Effect } from '../../shared/contract.ts';
|
|
7
9
|
import type { JsonSchema } from '../../shared/schema.ts';
|
|
8
|
-
import type { ToolPermission } from '../shared/types.ts';
|
|
9
10
|
|
|
10
11
|
/** One thing a model may do. */
|
|
11
12
|
export interface AiTool {
|
|
12
13
|
readonly description: string;
|
|
13
14
|
/** JSON Schema for the input. Use `schema.toJsonSchema()` or write it by hand. */
|
|
14
15
|
readonly inputSchema: JsonSchema;
|
|
15
|
-
/**
|
|
16
|
-
readonly
|
|
17
|
-
|
|
16
|
+
/** What running it does to the world. The gate decides from this and the channel. */
|
|
17
|
+
readonly effect: Effect;
|
|
18
|
+
/**
|
|
19
|
+
* Run it.
|
|
20
|
+
*
|
|
21
|
+
* The envelope comes from the run loop, which built it from what it knows
|
|
22
|
+
* rather than from what the model said. An implementation passes it on; it
|
|
23
|
+
* does not invent one.
|
|
24
|
+
*/
|
|
25
|
+
execute(input: unknown, envelope: Envelope, signal: AbortSignal): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The brand that says a tool's `execute` reaches the gate.
|
|
30
|
+
*
|
|
31
|
+
* A hand-written tool is ordinary host code: nothing about its type says
|
|
32
|
+
* whether it asked anybody before doing what it does. Rather than trust that
|
|
33
|
+
* every application remembers, `createAi` refuses a tool without this symbol,
|
|
34
|
+
* and the only way to get one is {@link guardedTool}, which does the asking.
|
|
35
|
+
*/
|
|
36
|
+
export const GUARDED: unique symbol = Symbol('broapp.guarded');
|
|
37
|
+
|
|
38
|
+
/** An {@link AiTool} whose calls are known to pass the gate. */
|
|
39
|
+
export interface GuardedTool extends AiTool {
|
|
40
|
+
readonly [GUARDED]: true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What {@link guardedTool} needs to know about the thing it is wrapping. */
|
|
44
|
+
export interface GuardedToolDefinition {
|
|
45
|
+
/** The tool's name, which is also the route in the gate's records. */
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly description: string;
|
|
48
|
+
readonly inputSchema: JsonSchema;
|
|
49
|
+
readonly effect: Effect;
|
|
50
|
+
run(input: unknown, signal: AbortSignal): Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a hand-written tool so its calls pass the gate.
|
|
55
|
+
*
|
|
56
|
+
* This is the supported way to give a model something an application's
|
|
57
|
+
* contract does not describe — a search over a third-party index, a shell
|
|
58
|
+
* command, a mail send. The wrapping is the whole point: the model's request
|
|
59
|
+
* arrives with the run loop's envelope, the gate decides, and only then does
|
|
60
|
+
* `run` happen.
|
|
61
|
+
*/
|
|
62
|
+
export function guardedTool(gate: Gate, tool: GuardedToolDefinition): GuardedTool {
|
|
63
|
+
return {
|
|
64
|
+
[GUARDED]: true,
|
|
65
|
+
description: tool.description,
|
|
66
|
+
inputSchema: tool.inputSchema,
|
|
67
|
+
effect: tool.effect,
|
|
68
|
+
execute: (input, envelope) =>
|
|
69
|
+
gate.guard({ ...envelope, route: tool.name, effect: tool.effect, input }, (signal) =>
|
|
70
|
+
tool.run(input, signal),
|
|
71
|
+
),
|
|
72
|
+
};
|
|
18
73
|
}
|
|
19
74
|
|
|
20
75
|
/** A record the model may be shown, named but not loaded. */
|
|
@@ -38,52 +93,3 @@ export interface AiContextProviders {
|
|
|
38
93
|
/** Full content for named refs. Unknown refs are skipped, not errors. */
|
|
39
94
|
resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
|
|
40
95
|
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The table a waiting tool call and `ai.chatConfirm` meet in.
|
|
44
|
-
*
|
|
45
|
-
* One per `Ai`, because a confirmation belongs to a run, and a run belongs to
|
|
46
|
-
* a stream that may be one of several open at once.
|
|
47
|
-
*/
|
|
48
|
-
export interface Confirmations {
|
|
49
|
-
wait(runId: string, callId: string, timeoutMs: number, signal: AbortSignal): Promise<boolean>;
|
|
50
|
-
/** Called by `ai.chatConfirm`. Returns false when nobody is waiting. */
|
|
51
|
-
answer(runId: string, callId: string, approve: boolean): boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Build the confirmation table. */
|
|
55
|
-
export function createConfirmations(): Confirmations {
|
|
56
|
-
const waiting = new Map<string, (approved: boolean) => void>();
|
|
57
|
-
const key = (runId: string, callId: string): string => `${runId} ${callId}`;
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
wait(runId, callId, timeoutMs, signal) {
|
|
61
|
-
const id = key(runId, callId);
|
|
62
|
-
return new Promise<boolean>((resolve) => {
|
|
63
|
-
let settled = false;
|
|
64
|
-
const finish = (approved: boolean): void => {
|
|
65
|
-
if (settled) return;
|
|
66
|
-
settled = true;
|
|
67
|
-
waiting.delete(id);
|
|
68
|
-
clearTimeout(timer);
|
|
69
|
-
signal.removeEventListener('abort', onAbort);
|
|
70
|
-
resolve(approved);
|
|
71
|
-
};
|
|
72
|
-
// A question nobody answers is a denial, not a hung stream: the user
|
|
73
|
-
// may have closed the tab, and the tool must not run unattended.
|
|
74
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
75
|
-
const onAbort = (): void => finish(false);
|
|
76
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
77
|
-
if (signal.aborted) finish(false);
|
|
78
|
-
else waiting.set(id, finish);
|
|
79
|
-
});
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
answer(runId, callId, approve) {
|
|
83
|
-
const resolve = waiting.get(key(runId, callId));
|
|
84
|
-
if (resolve === undefined) return false;
|
|
85
|
-
resolve(approve);
|
|
86
|
-
return true;
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
}
|
package/src/ai/react/AiChat.tsx
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as React from 'react';
|
|
12
12
|
|
|
13
|
+
import { countdown, isUrgent } from '../../shared/countdown.ts';
|
|
14
|
+
|
|
13
15
|
import { useAiChat, type AiChatOptions, type ToolCallState } from './use-ai-chat.ts';
|
|
14
16
|
import { useAiSettings } from './use-ai-settings.ts';
|
|
15
17
|
|
|
@@ -21,6 +23,27 @@ export interface AiChatProps {
|
|
|
21
23
|
readonly emptyText?: string;
|
|
22
24
|
/** Called when a tool call settles, so the application can refetch. */
|
|
23
25
|
readonly onToolResult?: AiChatOptions['onToolResult'];
|
|
26
|
+
/**
|
|
27
|
+
* How many tool calls are waiting for the person, whenever that changes.
|
|
28
|
+
*
|
|
29
|
+
* The panel does not act on this itself: a tab that renamed itself or raised
|
|
30
|
+
* a notification without being asked would do it in every application that
|
|
31
|
+
* embeds a chat. The launcher asks, because its questions arrive after ten
|
|
32
|
+
* minutes of silence and the person is not looking at the tab.
|
|
33
|
+
*/
|
|
34
|
+
readonly onAwaiting?: (pending: number) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Re-render once a second while `active`, so a countdown counts. */
|
|
38
|
+
function useTick(active: boolean): number {
|
|
39
|
+
const [now, setNow] = React.useState(() => Date.now());
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!active) return undefined;
|
|
42
|
+
setNow(Date.now());
|
|
43
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
|
44
|
+
return () => clearInterval(timer);
|
|
45
|
+
}, [active]);
|
|
46
|
+
return now;
|
|
24
47
|
}
|
|
25
48
|
|
|
26
49
|
function ToolCall({
|
|
@@ -30,6 +53,9 @@ function ToolCall({
|
|
|
30
53
|
call: ToolCallState;
|
|
31
54
|
onConfirm: (callId: string, approve: boolean) => void;
|
|
32
55
|
}): React.ReactElement {
|
|
56
|
+
const waiting = call.status === 'awaiting-confirmation' && call.expiresAt !== undefined;
|
|
57
|
+
const now = useTick(waiting);
|
|
58
|
+
const urgent = call.expiresAt !== undefined && waiting && isUrgent(call.expiresAt, now);
|
|
33
59
|
return (
|
|
34
60
|
<div className="ai-chat__tool">
|
|
35
61
|
<details>
|
|
@@ -42,8 +68,15 @@ function ToolCall({
|
|
|
42
68
|
)}
|
|
43
69
|
</details>
|
|
44
70
|
{call.status !== 'awaiting-confirmation' ? null : (
|
|
45
|
-
<div
|
|
71
|
+
<div
|
|
72
|
+
className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
|
|
73
|
+
role="group"
|
|
74
|
+
aria-label={`Allow ${call.tool}?`}
|
|
75
|
+
>
|
|
46
76
|
<span>Allow this?</span>
|
|
77
|
+
{call.expiresAt === undefined ? null : (
|
|
78
|
+
<span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
|
|
79
|
+
)}
|
|
47
80
|
<button
|
|
48
81
|
className="button button--primary"
|
|
49
82
|
type="button"
|
|
@@ -65,6 +98,7 @@ export function AiChat({
|
|
|
65
98
|
placeholder,
|
|
66
99
|
emptyText,
|
|
67
100
|
onToolResult,
|
|
101
|
+
onAwaiting,
|
|
68
102
|
}: AiChatProps): React.ReactElement {
|
|
69
103
|
const { settings } = useAiSettings();
|
|
70
104
|
const chat = useAiChat({
|
|
@@ -75,6 +109,20 @@ export function AiChat({
|
|
|
75
109
|
const input = React.useRef<HTMLTextAreaElement | null>(null);
|
|
76
110
|
const busy = chat.status === 'streaming' || chat.status === 'awaiting-confirmation';
|
|
77
111
|
|
|
112
|
+
const waiting = chat.messages.reduce(
|
|
113
|
+
(count, message) =>
|
|
114
|
+
message.role === 'assistant'
|
|
115
|
+
? count +
|
|
116
|
+
message.toolCalls.filter((call) => call.status === 'awaiting-confirmation').length
|
|
117
|
+
: count,
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
const announce = React.useRef(onAwaiting);
|
|
121
|
+
announce.current = onAwaiting;
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
announce.current?.(waiting);
|
|
124
|
+
}, [waiting]);
|
|
125
|
+
|
|
78
126
|
// Back to the box when the turn ends, so a conversation can be carried on
|
|
79
127
|
// without reaching for the mouse.
|
|
80
128
|
React.useEffect(() => {
|
|
@@ -101,11 +101,17 @@ export function AiSettings(): React.ReactElement {
|
|
|
101
101
|
</div>
|
|
102
102
|
)}
|
|
103
103
|
|
|
104
|
-
{
|
|
104
|
+
{provider.needs.apiKey === 'none' ? null : (
|
|
105
105
|
<div className="form__row">
|
|
106
106
|
<label className="form__label" htmlFor="ai-key">
|
|
107
|
-
API key
|
|
107
|
+
API key{provider.needs.apiKey === 'optional' ? ' (optional)' : ''}
|
|
108
108
|
</label>
|
|
109
|
+
{provider.needs.apiKey === 'optional' ? (
|
|
110
|
+
<p className="form__hint">
|
|
111
|
+
Needed for a hosted service such as OpenRouter. Leave empty for a server on this
|
|
112
|
+
computer that does not ask for one.
|
|
113
|
+
</p>
|
|
114
|
+
) : null}
|
|
109
115
|
<div className="ai-settings__key">
|
|
110
116
|
<input
|
|
111
117
|
className="input"
|
package/src/ai/react/ai.css
CHANGED
|
@@ -109,6 +109,20 @@
|
|
|
109
109
|
color: var(--text, #1b1a18);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/* Under a minute left. Amber and a little heavier, so a card that is about to
|
|
113
|
+
expire is not the same shape as one with nine minutes on it. */
|
|
114
|
+
.ai-chat__confirm--urgent {
|
|
115
|
+
border-color: var(--warning, #b4690e);
|
|
116
|
+
background: var(--warning-bg, #fdf3e3);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ai-chat__expires {
|
|
120
|
+
margin-left: auto;
|
|
121
|
+
color: var(--text-muted, #6b6862);
|
|
122
|
+
font-size: 0.8rem;
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
.ai-chat__typing {
|
|
113
127
|
display: inline-block;
|
|
114
128
|
margin-left: 0.25rem;
|
package/src/ai/react/index.tsx
CHANGED
|
@@ -22,6 +22,8 @@ export interface ToolCallState {
|
|
|
22
22
|
readonly input: unknown;
|
|
23
23
|
readonly status: 'running' | 'awaiting-confirmation' | 'done' | 'denied';
|
|
24
24
|
readonly output?: unknown;
|
|
25
|
+
/** While awaiting confirmation: when the question stops waiting. */
|
|
26
|
+
readonly expiresAt?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** One message in the transcript. */
|
|
@@ -153,7 +155,13 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
153
155
|
patchPending((message) => ({
|
|
154
156
|
...message,
|
|
155
157
|
toolCalls: message.toolCalls.map((call) =>
|
|
156
|
-
call.callId === event.callId
|
|
158
|
+
call.callId === event.callId
|
|
159
|
+
? {
|
|
160
|
+
...call,
|
|
161
|
+
status: 'awaiting-confirmation',
|
|
162
|
+
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
163
|
+
}
|
|
164
|
+
: call,
|
|
157
165
|
),
|
|
158
166
|
}));
|
|
159
167
|
break;
|