broapp 0.1.0 → 0.2.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/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +106 -0
- package/src/ai/host/create-ai.ts +200 -0
- package/src/ai/host/fake.ts +223 -0
- package/src/ai/host/from-contract.ts +85 -0
- package/src/ai/host/index.ts +32 -0
- package/src/ai/host/registry.ts +221 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +320 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/tool.ts +89 -0
- package/src/ai/react/AiChat.tsx +194 -0
- package/src/ai/react/AiSettings.tsx +222 -0
- package/src/ai/react/ai.css +152 -0
- package/src/ai/react/index.tsx +35 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +309 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +138 -0
- package/src/ai/shared/index.ts +16 -0
- package/src/ai/shared/types.check.ts +43 -0
- package/src/ai/shared/types.ts +87 -0
- package/src/host/app.ts +94 -30
- package/src/host/index.ts +5 -0
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +56 -2
- package/src/shared/errors.ts +10 -0
- package/src/shared/index.ts +8 -2
- package/src/shared/schema.ts +125 -28
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One conversation.
|
|
3
|
+
*
|
|
4
|
+
* `useStream` is not used here: it keeps only the most recent event, and a
|
|
5
|
+
* chat needs every one of them in order. What it does copy exactly is
|
|
6
|
+
* `useStream`'s unmount behaviour — cancelling the subscription — because the
|
|
7
|
+
* producer is a process on the user's own machine and a stream nobody cancels
|
|
8
|
+
* goes on running the model.
|
|
9
|
+
*/
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
import type { Subscription } from '../../client/client.ts';
|
|
13
|
+
import { BroappError } from '../../shared/errors.ts';
|
|
14
|
+
import type { ChatEvent, ChatTurn } from '../shared/types.ts';
|
|
15
|
+
|
|
16
|
+
import { useAiContext } from './provider.tsx';
|
|
17
|
+
|
|
18
|
+
/** What one tool call is doing. */
|
|
19
|
+
export interface ToolCallState {
|
|
20
|
+
readonly callId: string;
|
|
21
|
+
readonly tool: string;
|
|
22
|
+
readonly input: unknown;
|
|
23
|
+
readonly status: 'running' | 'awaiting-confirmation' | 'done' | 'denied';
|
|
24
|
+
readonly output?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One message in the transcript. */
|
|
28
|
+
export type ChatMessage =
|
|
29
|
+
| { readonly id: string; readonly role: 'user'; readonly content: string }
|
|
30
|
+
| {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly role: 'assistant';
|
|
33
|
+
readonly content: string;
|
|
34
|
+
readonly toolCalls: ToolCallState[];
|
|
35
|
+
readonly pending: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** What {@link useAiChat} returns. */
|
|
39
|
+
export interface AiChatHook {
|
|
40
|
+
readonly messages: ChatMessage[];
|
|
41
|
+
readonly status: 'idle' | 'streaming' | 'awaiting-confirmation' | 'error';
|
|
42
|
+
readonly error: string | null;
|
|
43
|
+
readonly usage: { inputTokens: number; outputTokens: number } | null;
|
|
44
|
+
send(text: string): Promise<void>;
|
|
45
|
+
cancel(): void;
|
|
46
|
+
confirm(callId: string, approve: boolean): Promise<void>;
|
|
47
|
+
clear(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The contract caps history at 100 turns; the oldest are dropped. */
|
|
51
|
+
const MAX_HISTORY = 100;
|
|
52
|
+
|
|
53
|
+
/** A run id matching the contract's pattern. */
|
|
54
|
+
function newRunId(): string {
|
|
55
|
+
return crypto.randomUUID().replace(/-/g, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every completed turn, as the model should see it. */
|
|
59
|
+
function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
|
|
60
|
+
const turns: ChatTurn[] = [];
|
|
61
|
+
for (const message of messages) {
|
|
62
|
+
if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
|
|
63
|
+
turns.push({ role: message.role, content: message.content });
|
|
64
|
+
}
|
|
65
|
+
return turns.slice(-MAX_HISTORY);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options for {@link useAiChat}. */
|
|
69
|
+
export interface AiChatOptions {
|
|
70
|
+
/** Records the user is looking at, sent with every message. */
|
|
71
|
+
readonly refs?: readonly string[];
|
|
72
|
+
/**
|
|
73
|
+
* Called when a tool call settles.
|
|
74
|
+
*
|
|
75
|
+
* An application uses it to refetch whatever the model just changed. It is
|
|
76
|
+
* called for denied calls too, so a panel can stop showing them as pending.
|
|
77
|
+
*/
|
|
78
|
+
onToolResult?(call: ToolCallState): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
82
|
+
const shared = useAiContext();
|
|
83
|
+
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
|
84
|
+
const [status, setStatus] = React.useState<AiChatHook['status']>('idle');
|
|
85
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
86
|
+
const [usage, setUsage] = React.useState<AiChatHook['usage']>(null);
|
|
87
|
+
const active = React.useRef<Subscription | null>(null);
|
|
88
|
+
const runId = React.useRef<string>('');
|
|
89
|
+
const mounted = React.useRef(true);
|
|
90
|
+
const refs = options.refs ?? [];
|
|
91
|
+
// Read inside the subscription callbacks, which are created once per send.
|
|
92
|
+
const refsRef = React.useRef<readonly string[]>(refs);
|
|
93
|
+
refsRef.current = refs;
|
|
94
|
+
const onToolResult = React.useRef<AiChatOptions['onToolResult']>(undefined);
|
|
95
|
+
onToolResult.current = options.onToolResult;
|
|
96
|
+
// The tool calls of the turn in progress, by call id. A state updater cannot
|
|
97
|
+
// be used to find one: React runs it when it chooses, so anything read out
|
|
98
|
+
// of it is read too late to hand to a callback.
|
|
99
|
+
const calls = React.useRef(new Map<string, ToolCallState>());
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
mounted.current = true;
|
|
103
|
+
return () => {
|
|
104
|
+
mounted.current = false;
|
|
105
|
+
active.current?.cancel();
|
|
106
|
+
active.current = null;
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
/** Change the assistant message this turn is writing into. */
|
|
111
|
+
const patchPending = React.useCallback(
|
|
112
|
+
(change: (message: Extract<ChatMessage, { role: 'assistant' }>) => ChatMessage): void => {
|
|
113
|
+
setMessages((current) => {
|
|
114
|
+
const index = current.length - 1;
|
|
115
|
+
const last = current[index];
|
|
116
|
+
if (last === undefined || last.role !== 'assistant') return current;
|
|
117
|
+
const next = [...current];
|
|
118
|
+
next[index] = change(last);
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
[],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const apply = React.useCallback(
|
|
126
|
+
(event: ChatEvent): void => {
|
|
127
|
+
switch (event.type) {
|
|
128
|
+
case 'text':
|
|
129
|
+
patchPending((message) => ({ ...message, content: message.content + (event.text ?? '') }));
|
|
130
|
+
break;
|
|
131
|
+
case 'tool-call':
|
|
132
|
+
calls.current.set(event.callId ?? '', {
|
|
133
|
+
callId: event.callId ?? '',
|
|
134
|
+
tool: event.tool ?? '',
|
|
135
|
+
input: event.input,
|
|
136
|
+
status: 'running',
|
|
137
|
+
});
|
|
138
|
+
patchPending((message) => ({
|
|
139
|
+
...message,
|
|
140
|
+
toolCalls: [
|
|
141
|
+
...message.toolCalls,
|
|
142
|
+
{
|
|
143
|
+
callId: event.callId ?? '',
|
|
144
|
+
tool: event.tool ?? '',
|
|
145
|
+
input: event.input,
|
|
146
|
+
status: 'running',
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
break;
|
|
151
|
+
case 'confirm':
|
|
152
|
+
setStatus('awaiting-confirmation');
|
|
153
|
+
patchPending((message) => ({
|
|
154
|
+
...message,
|
|
155
|
+
toolCalls: message.toolCalls.map((call) =>
|
|
156
|
+
call.callId === event.callId ? { ...call, status: 'awaiting-confirmation' } : call,
|
|
157
|
+
),
|
|
158
|
+
}));
|
|
159
|
+
break;
|
|
160
|
+
case 'tool-result': {
|
|
161
|
+
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
162
|
+
const started = calls.current.get(event.callId ?? '');
|
|
163
|
+
const settled: ToolCallState = {
|
|
164
|
+
callId: event.callId ?? '',
|
|
165
|
+
tool: event.tool ?? started?.tool ?? '',
|
|
166
|
+
input: started?.input,
|
|
167
|
+
status: event.denied === true ? 'denied' : 'done',
|
|
168
|
+
output: event.output,
|
|
169
|
+
};
|
|
170
|
+
calls.current.set(settled.callId, settled);
|
|
171
|
+
patchPending((message) => ({
|
|
172
|
+
...message,
|
|
173
|
+
toolCalls: message.toolCalls.map((call) =>
|
|
174
|
+
call.callId === settled.callId ? settled : call,
|
|
175
|
+
),
|
|
176
|
+
}));
|
|
177
|
+
// The application refetches whatever the model just changed.
|
|
178
|
+
onToolResult.current?.(settled);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case 'usage':
|
|
182
|
+
setUsage({
|
|
183
|
+
inputTokens: event.inputTokens ?? 0,
|
|
184
|
+
outputTokens: event.outputTokens ?? 0,
|
|
185
|
+
});
|
|
186
|
+
break;
|
|
187
|
+
case 'done':
|
|
188
|
+
active.current = null;
|
|
189
|
+
setStatus('idle');
|
|
190
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
191
|
+
break;
|
|
192
|
+
case 'error':
|
|
193
|
+
active.current = null;
|
|
194
|
+
setStatus('error');
|
|
195
|
+
setError(event.message ?? 'The AI provider returned an error.');
|
|
196
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[patchPending],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const send = React.useCallback(
|
|
204
|
+
async (text: string): Promise<void> => {
|
|
205
|
+
// A second question while the first is still being answered would need a
|
|
206
|
+
// second run and a second transcript. Ignored rather than queued.
|
|
207
|
+
if (active.current !== null || status === 'streaming' || status === 'awaiting-confirmation') {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const trimmed = text.trim();
|
|
211
|
+
if (trimmed === '') return;
|
|
212
|
+
|
|
213
|
+
const history = toHistory(messages);
|
|
214
|
+
calls.current.clear();
|
|
215
|
+
const id = newRunId();
|
|
216
|
+
runId.current = id;
|
|
217
|
+
setError(null);
|
|
218
|
+
setUsage(null);
|
|
219
|
+
setStatus('streaming');
|
|
220
|
+
setMessages((current) => [
|
|
221
|
+
...current,
|
|
222
|
+
{ id: `${id}-user`, role: 'user', content: trimmed },
|
|
223
|
+
{ id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const connected = await shared.client();
|
|
228
|
+
if (!mounted.current) return;
|
|
229
|
+
const subscription = await connected.subscribe(
|
|
230
|
+
'ai.chat',
|
|
231
|
+
{ runId: id, message: trimmed, refs: [...refsRef.current], history },
|
|
232
|
+
{
|
|
233
|
+
onEvent: (event) => {
|
|
234
|
+
if (mounted.current) apply(event);
|
|
235
|
+
},
|
|
236
|
+
onDone: () => {
|
|
237
|
+
if (!mounted.current) return;
|
|
238
|
+
active.current = null;
|
|
239
|
+
setStatus((current) => (current === 'error' ? current : 'idle'));
|
|
240
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
241
|
+
},
|
|
242
|
+
onError: (cause) => {
|
|
243
|
+
if (!mounted.current) return;
|
|
244
|
+
active.current = null;
|
|
245
|
+
setStatus('error');
|
|
246
|
+
setError(cause.message);
|
|
247
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
if (!mounted.current) {
|
|
252
|
+
subscription.cancel();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
active.current = subscription;
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
if (!mounted.current) return;
|
|
258
|
+
active.current = null;
|
|
259
|
+
setStatus('error');
|
|
260
|
+
setError(
|
|
261
|
+
cause instanceof BroappError ? cause.message : 'The conversation could not be started.',
|
|
262
|
+
);
|
|
263
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[shared, status, messages, apply, patchPending],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const cancel = React.useCallback((): void => {
|
|
270
|
+
if (active.current === null) return;
|
|
271
|
+
active.current.cancel();
|
|
272
|
+
active.current = null;
|
|
273
|
+
setStatus('idle');
|
|
274
|
+
// The text so far is kept: the user asked to stop, not to undo.
|
|
275
|
+
patchPending((message) => ({ ...message, pending: false }));
|
|
276
|
+
}, [patchPending]);
|
|
277
|
+
|
|
278
|
+
const confirm = React.useCallback(
|
|
279
|
+
async (callId: string, approve: boolean): Promise<void> => {
|
|
280
|
+
try {
|
|
281
|
+
const connected = await shared.client();
|
|
282
|
+
const result = await connected.call('ai.chatConfirm', {
|
|
283
|
+
runId: runId.current,
|
|
284
|
+
callId,
|
|
285
|
+
approve,
|
|
286
|
+
});
|
|
287
|
+
if (!result.accepted) {
|
|
288
|
+
// Nobody was waiting: the turn timed out or was cancelled while the
|
|
289
|
+
// question was on screen.
|
|
290
|
+
setError('That request has expired.');
|
|
291
|
+
}
|
|
292
|
+
} catch (cause) {
|
|
293
|
+
setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[shared],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const clear = React.useCallback((): void => {
|
|
300
|
+
active.current?.cancel();
|
|
301
|
+
active.current = null;
|
|
302
|
+
setMessages([]);
|
|
303
|
+
setStatus('idle');
|
|
304
|
+
setError(null);
|
|
305
|
+
setUsage(null);
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
return { messages, status, error, usage, send, cancel, confirm, clear };
|
|
309
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models the configured provider offers.
|
|
3
|
+
*
|
|
4
|
+
* Refetched whenever something that would change the answer changes — the
|
|
5
|
+
* provider, its address, or whether a key is set. Not on every settings write:
|
|
6
|
+
* choosing a model must not send the application back to the provider to ask
|
|
7
|
+
* what the models are.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
import { BroappError } from '../../shared/errors.ts';
|
|
12
|
+
import type { BroappModel } from '../shared/types.ts';
|
|
13
|
+
|
|
14
|
+
import { useAiContext } from './provider.tsx';
|
|
15
|
+
|
|
16
|
+
/** What {@link useAiModels} returns. */
|
|
17
|
+
export interface AiModelsHook {
|
|
18
|
+
readonly models: BroappModel[];
|
|
19
|
+
readonly pending: boolean;
|
|
20
|
+
readonly error: BroappError | null;
|
|
21
|
+
refresh(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useAiModels(): AiModelsHook {
|
|
25
|
+
const shared = useAiContext();
|
|
26
|
+
const [models, setModels] = React.useState<BroappModel[]>([]);
|
|
27
|
+
const [pending, setPending] = React.useState(false);
|
|
28
|
+
const [error, setError] = React.useState<BroappError | null>(null);
|
|
29
|
+
const generation = React.useRef(0);
|
|
30
|
+
|
|
31
|
+
const provider = shared.settings?.provider ?? null;
|
|
32
|
+
const baseUrl = shared.settings?.baseUrl ?? null;
|
|
33
|
+
const hasKey = shared.settings?.hasKey ?? false;
|
|
34
|
+
|
|
35
|
+
const refresh = React.useCallback(async (): Promise<void> => {
|
|
36
|
+
if (provider === null) {
|
|
37
|
+
setModels([]);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const mine = (generation.current += 1);
|
|
41
|
+
setPending(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
try {
|
|
44
|
+
const connected = await shared.client();
|
|
45
|
+
const result = await connected.call('ai.modelsList', undefined);
|
|
46
|
+
// A slow answer for a provider the user has since changed must not
|
|
47
|
+
// replace the list they are looking at now.
|
|
48
|
+
if (generation.current !== mine) return;
|
|
49
|
+
setModels(result.models);
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
if (generation.current !== mine) return;
|
|
52
|
+
setModels([]);
|
|
53
|
+
setError(
|
|
54
|
+
cause instanceof BroappError
|
|
55
|
+
? cause
|
|
56
|
+
: new BroappError('internal', 'The model list could not be read.', cause),
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
if (generation.current === mine) setPending(false);
|
|
60
|
+
}
|
|
61
|
+
}, [shared, provider]);
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
void refresh();
|
|
65
|
+
// `baseUrl` and `hasKey` are not used inside `refresh`; they are here
|
|
66
|
+
// because changing either changes what the provider will answer.
|
|
67
|
+
}, [refresh, baseUrl, hasKey]);
|
|
68
|
+
|
|
69
|
+
return { models, pending, error, refresh };
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and changing the AI settings.
|
|
3
|
+
*
|
|
4
|
+
* Every change is written straight through: there is no Save button, because
|
|
5
|
+
* there is nothing to batch and a half-saved provider configuration is a state
|
|
6
|
+
* worth not having. The result of each write replaces the shared settings, so
|
|
7
|
+
* the rest of the interface updates without a second round trip.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
import type { OperationInput } from '../../shared/contract.ts';
|
|
12
|
+
import { BroappError } from '../../shared/errors.ts';
|
|
13
|
+
import type { AiContract } from '../shared/contract.ts';
|
|
14
|
+
import type { AiSettings, ProviderInfo } from '../shared/types.ts';
|
|
15
|
+
|
|
16
|
+
import { useAiContext } from './provider.tsx';
|
|
17
|
+
|
|
18
|
+
/** What `ai.settingsUpdate` accepts. */
|
|
19
|
+
export type UpdatePatch = OperationInput<AiContract, 'ai.settingsUpdate'>;
|
|
20
|
+
|
|
21
|
+
/** The result of a connection test. */
|
|
22
|
+
export interface ConnectionResult {
|
|
23
|
+
readonly ok: boolean;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly latencyMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What {@link useAiSettings} returns. */
|
|
29
|
+
export interface AiSettingsHook {
|
|
30
|
+
readonly settings: AiSettings | null;
|
|
31
|
+
readonly providers: ProviderInfo[];
|
|
32
|
+
readonly pending: boolean;
|
|
33
|
+
readonly error: BroappError | null;
|
|
34
|
+
update(patch: UpdatePatch): Promise<void>;
|
|
35
|
+
test(): Promise<ConnectionResult | null>;
|
|
36
|
+
refresh(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asBroappError(cause: unknown, fallback: string): BroappError {
|
|
40
|
+
return cause instanceof BroappError ? cause : new BroappError('internal', fallback, cause);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The settings, the providers this build has, and the two ways to change them. */
|
|
44
|
+
export function useAiSettings(): AiSettingsHook {
|
|
45
|
+
const shared = useAiContext();
|
|
46
|
+
const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
|
|
47
|
+
const [pending, setPending] = React.useState(false);
|
|
48
|
+
const [error, setError] = React.useState<BroappError | null>(null);
|
|
49
|
+
|
|
50
|
+
// The provider list cannot change while the application runs — it is what
|
|
51
|
+
// was compiled in — so it is fetched once.
|
|
52
|
+
const fetched = React.useRef(false);
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (fetched.current) return;
|
|
55
|
+
fetched.current = true;
|
|
56
|
+
void (async () => {
|
|
57
|
+
try {
|
|
58
|
+
const connected = await shared.client();
|
|
59
|
+
setProviders((await connected.call('ai.providersList', undefined)).providers);
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
setError(asBroappError(cause, 'The provider list could not be read.'));
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
}, [shared]);
|
|
65
|
+
|
|
66
|
+
const update = React.useCallback(
|
|
67
|
+
async (patch: UpdatePatch): Promise<void> => {
|
|
68
|
+
setPending(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
const connected = await shared.client();
|
|
72
|
+
shared.put(await connected.call('ai.settingsUpdate', patch));
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
setError(asBroappError(cause, 'That setting could not be saved.'));
|
|
75
|
+
} finally {
|
|
76
|
+
setPending(false);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[shared],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const test = React.useCallback(async (): Promise<ConnectionResult | null> => {
|
|
83
|
+
setPending(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
try {
|
|
86
|
+
const connected = await shared.client();
|
|
87
|
+
return await connected.call('ai.connectionTest', undefined);
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
setError(asBroappError(cause, 'The connection could not be tested.'));
|
|
90
|
+
return null;
|
|
91
|
+
} finally {
|
|
92
|
+
setPending(false);
|
|
93
|
+
}
|
|
94
|
+
}, [shared]);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
settings: shared.settings,
|
|
98
|
+
providers,
|
|
99
|
+
pending: pending || shared.loading,
|
|
100
|
+
error: error ?? shared.error,
|
|
101
|
+
update,
|
|
102
|
+
test,
|
|
103
|
+
refresh: shared.refresh,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI contract.
|
|
3
|
+
*
|
|
4
|
+
* It is a contract like any application's, with one difference: it owns the
|
|
5
|
+
* reserved `ai` route group, and it is mounted as a *second* host app on the
|
|
6
|
+
* same bridge rather than merged into the application's own contract. That
|
|
7
|
+
* keeps an application's route table free of Broapp's routes and lets the AI
|
|
8
|
+
* layer be absent entirely when it is not enabled.
|
|
9
|
+
*
|
|
10
|
+
* Every bound here is a limit on what a browser may send. They are deliberate:
|
|
11
|
+
* an unbounded `message` or `history` is a way to make the host allocate.
|
|
12
|
+
*/
|
|
13
|
+
import { defineContract } from '../../shared/contract.ts';
|
|
14
|
+
import { s } from '../../shared/schema.ts';
|
|
15
|
+
|
|
16
|
+
/** A run identifier, chosen by the browser and echoed on every event. */
|
|
17
|
+
const runId = s.string({ pattern: /[A-Za-z0-9_-]{8,64}/ });
|
|
18
|
+
|
|
19
|
+
const capabilities = s.object({
|
|
20
|
+
tools: s.boolean(),
|
|
21
|
+
vision: s.boolean(),
|
|
22
|
+
structuredOutput: s.boolean(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const model = s.object({
|
|
26
|
+
provider: s.string(),
|
|
27
|
+
modelId: s.string(),
|
|
28
|
+
label: s.string(),
|
|
29
|
+
capabilities,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const providerInfo = s.object({
|
|
33
|
+
id: s.string(),
|
|
34
|
+
label: s.string(),
|
|
35
|
+
local: s.boolean(),
|
|
36
|
+
needs: s.object({
|
|
37
|
+
apiKey: s.boolean(),
|
|
38
|
+
baseUrl: s.enum(['required', 'optional', 'none']),
|
|
39
|
+
}),
|
|
40
|
+
defaultBaseUrl: s.nullable(s.string()),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const settings = s.object({
|
|
44
|
+
provider: s.nullable(s.string()),
|
|
45
|
+
modelId: s.nullable(s.string()),
|
|
46
|
+
baseUrl: s.nullable(s.string()),
|
|
47
|
+
hasKey: s.boolean(),
|
|
48
|
+
keyHint: s.nullable(s.string()),
|
|
49
|
+
remember: s.boolean(),
|
|
50
|
+
configured: s.boolean(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const chatTurn = s.object({
|
|
54
|
+
role: s.enum(['user', 'assistant']),
|
|
55
|
+
content: s.string({ max: 20_000 }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One stream event, flat because the validator has no unions.
|
|
60
|
+
*
|
|
61
|
+
* `input` and `output` are `unknown`: they carry whatever an application's own
|
|
62
|
+
* operation takes and returns, which this layer cannot describe in advance.
|
|
63
|
+
* They are host-controlled on the way out, which is the only place `unknown`
|
|
64
|
+
* is safe.
|
|
65
|
+
*/
|
|
66
|
+
const chatEvent = s.object({
|
|
67
|
+
type: s.enum(['text', 'tool-call', 'confirm', 'tool-result', 'usage', 'done', 'error']),
|
|
68
|
+
text: s.optional(s.string()),
|
|
69
|
+
callId: s.optional(s.string()),
|
|
70
|
+
tool: s.optional(s.string()),
|
|
71
|
+
input: s.optional(s.unknown()),
|
|
72
|
+
output: s.optional(s.unknown()),
|
|
73
|
+
denied: s.optional(s.boolean()),
|
|
74
|
+
permission: s.optional(s.enum(['read', 'confirm'])),
|
|
75
|
+
inputTokens: s.optional(s.number()),
|
|
76
|
+
outputTokens: s.optional(s.number()),
|
|
77
|
+
code: s.optional(s.string()),
|
|
78
|
+
message: s.optional(s.string()),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Broapp's AI routes. Applications may not declare the `ai` group themselves. */
|
|
82
|
+
export const aiContract = defineContract({
|
|
83
|
+
operations: {
|
|
84
|
+
'ai.settingsGet': {
|
|
85
|
+
input: s.void(),
|
|
86
|
+
output: settings,
|
|
87
|
+
summary: 'The current AI settings. Never includes the API key itself.',
|
|
88
|
+
},
|
|
89
|
+
'ai.settingsUpdate': {
|
|
90
|
+
input: s.object({
|
|
91
|
+
provider: s.optional(s.string({ max: 64 })),
|
|
92
|
+
modelId: s.optional(s.string({ max: 200 })),
|
|
93
|
+
baseUrl: s.optional(s.nullable(s.string({ max: 2000 }))),
|
|
94
|
+
// Null clears the stored key; a string replaces it. It goes to the
|
|
95
|
+
// secret store and is never read back out to the browser.
|
|
96
|
+
apiKey: s.optional(s.nullable(s.string({ max: 4000 }))),
|
|
97
|
+
remember: s.optional(s.boolean()),
|
|
98
|
+
}),
|
|
99
|
+
output: settings,
|
|
100
|
+
summary: 'Change one or more settings and return the result.',
|
|
101
|
+
},
|
|
102
|
+
'ai.providersList': {
|
|
103
|
+
input: s.void(),
|
|
104
|
+
output: s.object({ providers: s.array(providerInfo, { max: 50 }) }),
|
|
105
|
+
summary: 'The providers compiled into this application.',
|
|
106
|
+
},
|
|
107
|
+
'ai.modelsList': {
|
|
108
|
+
input: s.void(),
|
|
109
|
+
output: s.object({ models: s.array(model, { max: 1000 }) }),
|
|
110
|
+
summary: 'The models the configured provider offers.',
|
|
111
|
+
},
|
|
112
|
+
'ai.connectionTest': {
|
|
113
|
+
input: s.void(),
|
|
114
|
+
output: s.object({ ok: s.boolean(), message: s.string(), latencyMs: s.number() }),
|
|
115
|
+
summary: 'Try the configured provider once and report what happened.',
|
|
116
|
+
},
|
|
117
|
+
'ai.chatConfirm': {
|
|
118
|
+
input: s.object({ runId, callId: s.string({ max: 200 }), approve: s.boolean() }),
|
|
119
|
+
output: s.object({ accepted: s.boolean() }),
|
|
120
|
+
summary: 'Answer a confirm event. `accepted` is false when no run is waiting on that call.',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
streams: {
|
|
124
|
+
'ai.chat': {
|
|
125
|
+
params: s.object({
|
|
126
|
+
runId,
|
|
127
|
+
message: s.string({ min: 1, max: 20_000 }),
|
|
128
|
+
refs: s.array(s.string({ max: 200 }), { max: 50 }),
|
|
129
|
+
history: s.array(chatTurn, { max: 100 }),
|
|
130
|
+
}),
|
|
131
|
+
event: chatEvent,
|
|
132
|
+
summary: 'One chat turn. Emits text, tool calls, confirmations and usage.',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** The AI contract's type, for `HostApp` and client generics. */
|
|
138
|
+
export type AiContract = typeof aiContract;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `broapp/ai` — the AI layer's shared surface.
|
|
3
|
+
*
|
|
4
|
+
* Shared code only: the contract, the types derived from it, and nothing that
|
|
5
|
+
* knows how a provider is reached. The host half is `broapp/ai/host`.
|
|
6
|
+
*/
|
|
7
|
+
export { aiContract } from './contract.ts';
|
|
8
|
+
export type { AiContract } from './contract.ts';
|
|
9
|
+
export type {
|
|
10
|
+
AiSettings,
|
|
11
|
+
BroappModel,
|
|
12
|
+
ChatEvent,
|
|
13
|
+
ChatTurn,
|
|
14
|
+
ProviderInfo,
|
|
15
|
+
ToolPermission,
|
|
16
|
+
} from './types.ts';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level proof that the contract and the hand-written types agree.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here runs. It exists so that changing a schema in `contract.ts`
|
|
5
|
+
* without changing the matching interface in `types.ts` fails `tsc` instead of
|
|
6
|
+
* failing later, in the browser, as a shape that is almost right.
|
|
7
|
+
*/
|
|
8
|
+
import type { OperationInput, OperationOutput, StreamEvent } from '../../shared/contract.ts';
|
|
9
|
+
import type { AiContract } from './contract.ts';
|
|
10
|
+
import type { AiSettings, BroappModel, ChatEvent, ProviderInfo } from './types.ts';
|
|
11
|
+
|
|
12
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
13
|
+
? true
|
|
14
|
+
: false;
|
|
15
|
+
|
|
16
|
+
const settingsMatch: Equal<OperationOutput<AiContract, 'ai.settingsGet'>, AiSettings> = true;
|
|
17
|
+
void settingsMatch;
|
|
18
|
+
|
|
19
|
+
const settingsUpdateReturnsSettings: Equal<
|
|
20
|
+
OperationOutput<AiContract, 'ai.settingsUpdate'>,
|
|
21
|
+
AiSettings
|
|
22
|
+
> = true;
|
|
23
|
+
void settingsUpdateReturnsSettings;
|
|
24
|
+
|
|
25
|
+
const modelMatch: Equal<
|
|
26
|
+
OperationOutput<AiContract, 'ai.modelsList'>['models'][number],
|
|
27
|
+
BroappModel
|
|
28
|
+
> = true;
|
|
29
|
+
void modelMatch;
|
|
30
|
+
|
|
31
|
+
const providerMatch: Equal<
|
|
32
|
+
OperationOutput<AiContract, 'ai.providersList'>['providers'][number],
|
|
33
|
+
ProviderInfo
|
|
34
|
+
> = true;
|
|
35
|
+
void providerMatch;
|
|
36
|
+
|
|
37
|
+
const chatEventMatch: Equal<StreamEvent<AiContract, 'ai.chat'>, ChatEvent> = true;
|
|
38
|
+
void chatEventMatch;
|
|
39
|
+
|
|
40
|
+
// The update route is the only one that takes a partial: every field optional,
|
|
41
|
+
// so a browser can change one setting without restating the rest.
|
|
42
|
+
const updateAcceptsNothing: OperationInput<AiContract, 'ai.settingsUpdate'> = {};
|
|
43
|
+
void updateAcceptsNothing;
|