broapp 0.4.5 → 0.4.7

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.
@@ -9,23 +9,380 @@
9
9
  * components are still legible in an application that defines none.
10
10
  */
11
11
 
12
+ /*
13
+ * The settings panel.
14
+ *
15
+ * One height, one radius and one border for every control, so a select, a
16
+ * field, the saved-key row and a button beside them sit on one line and read
17
+ * as one set. The frame — a card, or none — is the application's to draw.
18
+ *
19
+ * The lengths follow Autoapp's control tokens where an application has them,
20
+ * so this panel and a form the renderer draws on the same page are one set of
21
+ * controls; each falls back to that token's default, so an application with no
22
+ * renderer gets the same panel. An `--ai-*` property set on `.ai-settings` or
23
+ * above it beats both.
24
+ */
25
+ .ai-settings {
26
+ --ai-control-height: 2.5rem;
27
+ --ai-control-radius: var(--autoapp-radius-sm, 7px);
28
+ --ai-border-width: var(--autoapp-border-width, 1px);
29
+ --ai-label-size: var(--autoapp-font-size-small, 0.85rem);
30
+ --ai-label-weight: var(--autoapp-weight-strong, 600);
31
+ --ai-control-size: var(--autoapp-font-size-note, 0.9rem);
32
+ --ai-hint-size: var(--autoapp-font-size-caption, 0.8rem);
33
+ --ai-focus-ring: var(--autoapp-focus-ring, var(--accent, #1f5f4f));
34
+ --ai-focus-ring-width: var(--autoapp-focus-ring-width, 2px);
35
+ --ai-focus-ring-offset: var(--autoapp-focus-ring-offset, 1px);
36
+
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 1.1rem;
40
+ }
41
+
42
+ .ai-settings__header {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 0.2rem;
46
+ }
47
+
48
+ .ai-settings__title {
49
+ margin: 0;
50
+ }
51
+
52
+ .ai-settings__lede {
53
+ margin: 0;
54
+ color: var(--text-muted, #6b6862);
55
+ }
56
+
57
+ .ai-settings__field {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 0.4rem;
61
+ margin: 0;
62
+ }
63
+
64
+ .ai-settings__label-row {
65
+ display: flex;
66
+ gap: 0.75rem;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ }
70
+
71
+ .ai-settings__label {
72
+ margin: 0;
73
+ font-size: var(--ai-label-size);
74
+ font-weight: var(--ai-label-weight);
75
+ color: var(--text, #1b1a18);
76
+ }
77
+
78
+ .ai-settings__meta {
79
+ font-size: var(--ai-hint-size);
80
+ color: var(--text-muted, #6b6862);
81
+ }
82
+
83
+ .ai-settings__meta--ok {
84
+ color: var(--ok, #1f5f4f);
85
+ }
86
+
87
+ .ai-settings__hint {
88
+ margin: 0;
89
+ font-size: var(--ai-hint-size);
90
+ line-height: 1.45;
91
+ color: var(--text-muted, #6b6862);
92
+ }
93
+
94
+ .ai-settings__icon {
95
+ flex: none;
96
+ width: 1rem;
97
+ height: 1rem;
98
+ }
99
+
100
+ .ai-settings__input,
101
+ .ai-settings__saved-key {
102
+ width: 100%;
103
+ min-width: 0;
104
+ height: var(--ai-control-height);
105
+ padding: 0 0.75rem;
106
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
107
+ border-radius: var(--ai-control-radius);
108
+ background: var(--surface, #ffffff);
109
+ font: inherit;
110
+ font-size: var(--ai-control-size);
111
+ color: var(--text, #1b1a18);
112
+ }
113
+
114
+ .ai-settings__input::placeholder {
115
+ color: var(--text-muted, #6b6862);
116
+ opacity: 0.8;
117
+ }
118
+
119
+ .ai-settings__input:hover:not(:disabled) {
120
+ border-color: var(--text-muted, #6b6862);
121
+ }
122
+
123
+ .ai-settings__input:focus-visible,
124
+ .ai-settings__button:focus-visible,
125
+ .ai-settings__inline-action:focus-visible,
126
+ .ai-settings__switch:focus-visible {
127
+ outline: var(--ai-focus-ring-width) solid var(--ai-focus-ring);
128
+ outline-offset: var(--ai-focus-ring-offset);
129
+ }
130
+
131
+ .ai-settings__input:disabled {
132
+ opacity: 0.6;
133
+ }
134
+
135
+ .ai-settings__select {
136
+ position: relative;
137
+ display: block;
138
+ }
139
+
140
+ .ai-settings__select select {
141
+ appearance: none;
142
+ padding-right: 2.25rem;
143
+ text-overflow: ellipsis;
144
+ }
145
+
146
+ .ai-settings__select .ai-settings__icon {
147
+ position: absolute;
148
+ top: 50%;
149
+ right: 0.75rem;
150
+ translate: 0 -50%;
151
+ color: var(--text-muted, #6b6862);
152
+ pointer-events: none;
153
+ }
154
+
12
155
  .ai-settings__key {
13
156
  display: flex;
14
157
  gap: 0.5rem;
15
158
  align-items: center;
16
159
  }
17
160
 
18
- .ai-settings__key .input {
161
+ /* The saved key: what a field looks like when there is nothing to type. */
162
+ .ai-settings__saved-key {
163
+ display: flex;
164
+ gap: 0.6rem;
165
+ align-items: center;
166
+ padding-right: 0.25rem;
167
+ }
168
+
169
+ .ai-settings__saved-key .ai-settings__icon {
170
+ color: var(--text-muted, #6b6862);
171
+ }
172
+
173
+ .ai-settings__key-hint {
19
174
  flex: 1 1 auto;
20
175
  min-width: 0;
176
+ overflow: hidden;
177
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
178
+ font-size: 0.85rem;
179
+ letter-spacing: 0.04em;
180
+ white-space: nowrap;
181
+ }
182
+
183
+ .ai-settings__sr {
184
+ position: absolute;
185
+ width: 1px;
186
+ height: 1px;
187
+ overflow: hidden;
188
+ clip-path: inset(50%);
189
+ white-space: nowrap;
190
+ }
191
+
192
+ /* A quiet action inside a row: Replace, Remove, Refresh. */
193
+ .ai-settings__inline-action {
194
+ display: inline-flex;
195
+ flex: none;
196
+ gap: 0.35rem;
197
+ align-items: center;
198
+ padding: 0.3rem 0.55rem;
199
+ border: 0;
200
+ border-radius: 6px;
201
+ background: transparent;
202
+ font: inherit;
203
+ font-size: 0.85rem;
204
+ font-weight: 500;
205
+ color: var(--text-muted, #6b6862);
206
+ cursor: pointer;
207
+ }
208
+
209
+ .ai-settings__inline-action:hover:not(:disabled) {
210
+ background: var(--bg, #fbfbfa);
211
+ color: var(--text, #1b1a18);
21
212
  }
22
213
 
23
- .ai-settings__remember {
214
+ .ai-settings__inline-action:disabled {
215
+ opacity: 0.5;
216
+ cursor: default;
217
+ }
218
+
219
+ .ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action {
220
+ position: relative;
221
+ margin-left: 0.3rem;
222
+ }
223
+
224
+ .ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action::before {
225
+ position: absolute;
226
+ top: 20%;
227
+ bottom: 20%;
228
+ left: -0.2rem;
229
+ width: 1px;
230
+ background: var(--border, #e3e2df);
231
+ content: '';
232
+ }
233
+
234
+ /* Pulled up so the label row is no taller than one without a button. */
235
+ .ai-settings__inline-action--icon {
236
+ margin: -0.3rem -0.55rem -0.3rem 0;
237
+ }
238
+
239
+ .ai-settings__button {
240
+ flex: none;
241
+ height: var(--ai-control-height);
242
+ padding: 0 1rem;
243
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
244
+ border-radius: var(--ai-control-radius);
245
+ background: var(--surface, #ffffff);
246
+ font: inherit;
247
+ font-size: var(--ai-control-size);
248
+ font-weight: var(--ai-label-weight);
249
+ color: var(--text, #1b1a18);
250
+ cursor: pointer;
251
+ transition: filter 150ms ease-out;
252
+ }
253
+
254
+ .ai-settings__button--primary {
255
+ border-color: transparent;
256
+ background: var(--accent, #1f5f4f);
257
+ color: var(--accent-contrast, #ffffff);
258
+ }
259
+
260
+ .ai-settings__button--block {
261
+ width: 100%;
262
+ }
263
+
264
+ .ai-settings__button:hover:not(:disabled) {
265
+ filter: brightness(1.08);
266
+ }
267
+
268
+ .ai-settings__button:active:not(:disabled) {
269
+ filter: brightness(0.95);
270
+ }
271
+
272
+ .ai-settings__button:disabled {
273
+ opacity: 0.5;
274
+ cursor: default;
275
+ }
276
+
277
+ .ai-settings__switch-row {
24
278
  display: flex;
25
- gap: 0.5rem;
279
+ gap: 1rem;
26
280
  align-items: center;
27
- margin-top: 0.75rem;
28
- font-weight: 400;
281
+ justify-content: space-between;
282
+ cursor: pointer;
283
+ }
284
+
285
+ /* A checkbox, drawn as the switch it is announced as. */
286
+ .ai-settings__switch {
287
+ position: relative;
288
+ flex: none;
289
+ width: 2.25rem;
290
+ height: 1.3rem;
291
+ margin: 0;
292
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
293
+ border-radius: 999px;
294
+ background: var(--bg, #fbfbfa);
295
+ appearance: none;
296
+ cursor: pointer;
297
+ transition:
298
+ background-color 150ms ease-out,
299
+ border-color 150ms ease-out;
300
+ }
301
+
302
+ .ai-settings__switch::after {
303
+ position: absolute;
304
+ top: 50%;
305
+ left: 0.15rem;
306
+ width: 0.9rem;
307
+ height: 0.9rem;
308
+ border-radius: 50%;
309
+ background: var(--text-muted, #6b6862);
310
+ content: '';
311
+ translate: 0 -50%;
312
+ transition:
313
+ translate 150ms cubic-bezier(0.25, 1, 0.5, 1),
314
+ background-color 150ms ease-out;
315
+ }
316
+
317
+ .ai-settings__switch:checked {
318
+ border-color: transparent;
319
+ background: var(--accent, #1f5f4f);
320
+ }
321
+
322
+ .ai-settings__switch:checked::after {
323
+ background: var(--accent-contrast, #ffffff);
324
+ translate: 0.95rem -50%;
325
+ }
326
+
327
+ .ai-settings__switch:disabled {
328
+ opacity: 0.5;
329
+ cursor: default;
330
+ }
331
+
332
+ @media (prefers-reduced-motion: reduce) {
333
+ .ai-settings__switch,
334
+ .ai-settings__switch::after,
335
+ .ai-settings__button {
336
+ transition: none;
337
+ }
338
+ }
339
+
340
+ /*
341
+ * The two parts of the panel, "In use" and "Providers". Each provider is a
342
+ * `<details>` whose summary says where it runs and whether it is on, so the
343
+ * list reads without opening anything.
344
+ */
345
+ .ai-settings__section {
346
+ display: flex;
347
+ flex-direction: column;
348
+ gap: 0.9rem;
349
+ }
350
+
351
+ .ai-settings__section-title {
352
+ margin: 0;
353
+ font-size: var(--ai-label-size);
354
+ font-weight: var(--ai-label-weight);
355
+ color: var(--text, #1b1a18);
356
+ }
357
+
358
+ .ai-settings__providers {
359
+ display: flex;
360
+ flex-direction: column;
361
+ border: var(--ai-border-width) solid var(--border, #e3e2df);
362
+ border-radius: var(--ai-control-radius);
363
+ }
364
+
365
+ .ai-settings__provider + .ai-settings__provider {
366
+ border-top: var(--ai-border-width) solid var(--border, #e3e2df);
367
+ }
368
+
369
+ .ai-settings__provider-summary {
370
+ padding: 0.6rem 0.75rem;
371
+ font-size: var(--ai-control-size);
372
+ color: var(--text, #1b1a18);
373
+ cursor: pointer;
374
+ }
375
+
376
+ .ai-settings__provider-summary:focus-visible {
377
+ outline: var(--ai-focus-ring-width) solid var(--ai-focus-ring);
378
+ outline-offset: var(--ai-focus-ring-offset);
379
+ }
380
+
381
+ .ai-settings__provider-body {
382
+ display: flex;
383
+ flex-direction: column;
384
+ gap: 0.9rem;
385
+ padding: 0.25rem 0.75rem 0.9rem;
29
386
  }
30
387
 
31
388
  /*
@@ -33,12 +390,31 @@
33
390
  * question a user should have to open a menu to answer.
34
391
  */
35
392
  .ai-settings__notice {
36
- margin: 0.75rem 0;
37
- padding: 0.6rem 0.75rem;
38
- border: 1px solid var(--border, #e3e2df);
39
- border-radius: var(--radius, 10px);
393
+ display: flex;
394
+ gap: 0.6rem;
395
+ align-items: flex-start;
396
+ margin: 0;
40
397
  color: var(--text-muted, #6b6862);
41
- font-size: 0.9rem;
398
+ font-size: 0.85rem;
399
+ line-height: 1.45;
400
+ }
401
+
402
+ .ai-settings__notice .ai-settings__icon {
403
+ margin-top: 0.15rem;
404
+ }
405
+
406
+ .ai-settings__message {
407
+ margin: 0;
408
+ font-size: 0.85rem;
409
+ line-height: 1.45;
410
+ }
411
+
412
+ .ai-settings__message--error {
413
+ color: var(--bad, #96311f);
414
+ }
415
+
416
+ .ai-settings__message--ok {
417
+ color: var(--ok, #1f5f4f);
42
418
  }
43
419
 
44
420
  .ai-chat__messages {
@@ -1,21 +1,23 @@
1
1
  /**
2
- * The models the configured provider offers.
2
+ * The models every provider turned on in Settings offers, in one list.
3
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.
4
+ * Refetched whenever something that would change the answer changes — which
5
+ * providers are on, any of their addresses, or whether each has a key. Not on
6
+ * every settings write: choosing a model must not send the application back to
7
+ * every provider to ask what the models are.
8
8
  */
9
9
  import * as React from 'react';
10
10
 
11
11
  import { BroappError } from '../../shared/errors.ts';
12
- import type { BroappModel } from '../shared/types.ts';
12
+ import type { BroappModel, UnavailableProvider } from '../shared/types.ts';
13
13
 
14
14
  import { useAiContext } from './provider.tsx';
15
15
 
16
16
  /** What {@link useAiModels} returns. */
17
17
  export interface AiModelsHook {
18
18
  readonly models: BroappModel[];
19
+ /** The providers that could not be read, or were cut short, each with its sentence. */
20
+ readonly unavailable: UnavailableProvider[];
19
21
  readonly pending: boolean;
20
22
  readonly error: BroappError | null;
21
23
  refresh(): Promise<void>;
@@ -24,17 +26,24 @@ export interface AiModelsHook {
24
26
  export function useAiModels(): AiModelsHook {
25
27
  const shared = useAiContext();
26
28
  const [models, setModels] = React.useState<BroappModel[]>([]);
29
+ const [unavailable, setUnavailable] = React.useState<UnavailableProvider[]>([]);
27
30
  const [pending, setPending] = React.useState(false);
28
31
  const [error, setError] = React.useState<BroappError | null>(null);
29
32
  const generation = React.useRef(0);
30
33
 
31
34
  const provider = shared.settings?.provider ?? null;
32
- const baseUrl = shared.settings?.baseUrl ?? null;
33
- const hasKey = shared.settings?.hasKey ?? false;
35
+ // One string for everything the answer depends on, so the effect runs when
36
+ // one of them changes and not when a model is chosen.
37
+ const reach = JSON.stringify(
38
+ (shared.settings?.providers ?? [])
39
+ .filter((entry) => entry.enabled)
40
+ .map((entry) => [entry.id, entry.baseUrl, entry.hasKey]),
41
+ );
34
42
 
35
43
  const refresh = React.useCallback(async (): Promise<void> => {
36
44
  if (provider === null) {
37
45
  setModels([]);
46
+ setUnavailable([]);
38
47
  return;
39
48
  }
40
49
  const mine = (generation.current += 1);
@@ -43,13 +52,15 @@ export function useAiModels(): AiModelsHook {
43
52
  try {
44
53
  const connected = await shared.client();
45
54
  const result = await connected.call('ai.modelsList', undefined);
46
- // A slow answer for a provider the user has since changed must not
55
+ // A slow answer for providers the user has since changed must not
47
56
  // replace the list they are looking at now.
48
57
  if (generation.current !== mine) return;
49
58
  setModels(result.models);
59
+ setUnavailable(result.unavailable);
50
60
  } catch (cause) {
51
61
  if (generation.current !== mine) return;
52
62
  setModels([]);
63
+ setUnavailable([]);
53
64
  setError(
54
65
  cause instanceof BroappError
55
66
  ? cause
@@ -62,9 +73,9 @@ export function useAiModels(): AiModelsHook {
62
73
 
63
74
  React.useEffect(() => {
64
75
  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]);
76
+ // `reach` is not used inside `refresh`; it is here because changing it
77
+ // changes what the providers will answer.
78
+ }, [refresh, reach]);
68
79
 
69
- return { models, pending, error, refresh };
80
+ return { models, unavailable, pending, error, refresh };
70
81
  }
@@ -32,7 +32,8 @@ export interface AiSettingsHook {
32
32
  readonly pending: boolean;
33
33
  readonly error: BroappError | null;
34
34
  update(patch: UpdatePatch): Promise<void>;
35
- test(): Promise<ConnectionResult | null>;
35
+ /** Test the provider in use, or, given an id, that provider with its own address and key. */
36
+ test(provider?: string): Promise<ConnectionResult | null>;
36
37
  refresh(): Promise<void>;
37
38
  }
38
39
 
@@ -47,21 +48,25 @@ export function useAiSettings(): AiSettingsHook {
47
48
  const [pending, setPending] = React.useState(false);
48
49
  const [error, setError] = React.useState<BroappError | null>(null);
49
50
 
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);
51
+ // The providers cannot change while the application runs — they are what
52
+ // was compiled in — but whether each runs on this computer is a property of
53
+ // its address, so the list is read again when an address changes.
54
+ const addresses = JSON.stringify((shared.settings?.providers ?? []).map((entry) => [entry.id, entry.baseUrl]));
53
55
  React.useEffect(() => {
54
- if (fetched.current) return;
55
- fetched.current = true;
56
+ let current = true;
56
57
  void (async () => {
57
58
  try {
58
59
  const connected = await shared.client();
59
- setProviders((await connected.call('ai.providersList', undefined)).providers);
60
+ const listed = (await connected.call('ai.providersList', undefined)).providers;
61
+ if (current) setProviders(listed);
60
62
  } catch (cause) {
61
- setError(asBroappError(cause, 'The provider list could not be read.'));
63
+ if (current) setError(asBroappError(cause, 'The provider list could not be read.'));
62
64
  }
63
65
  })();
64
- }, [shared]);
66
+ return () => {
67
+ current = false;
68
+ };
69
+ }, [shared, addresses]);
65
70
 
66
71
  const update = React.useCallback(
67
72
  async (patch: UpdatePatch): Promise<void> => {
@@ -79,12 +84,14 @@ export function useAiSettings(): AiSettingsHook {
79
84
  [shared],
80
85
  );
81
86
 
82
- const test = React.useCallback(async (): Promise<ConnectionResult | null> => {
87
+ const test = React.useCallback(async (provider?: string): Promise<ConnectionResult | null> => {
83
88
  setPending(true);
84
89
  setError(null);
85
90
  try {
86
91
  const connected = await shared.client();
87
- return await connected.call('ai.connectionTest', undefined);
92
+ return provider === undefined
93
+ ? await connected.call('ai.connectionTest', undefined)
94
+ : await connected.call('ai.providerTest', { provider });
88
95
  } catch (cause) {
89
96
  setError(asBroappError(cause, 'The connection could not be tested.'));
90
97
  return null;
@@ -40,6 +40,21 @@ const providerInfo = s.object({
40
40
  defaultBaseUrl: s.nullable(s.string()),
41
41
  });
42
42
 
43
+ /** A provider whose models are missing from the list, and the sentence that says why. */
44
+ const unavailableProvider = s.object({ provider: s.string(), message: s.string() });
45
+
46
+ const connectionResult = s.object({ ok: s.boolean(), message: s.string(), latencyMs: s.number() });
47
+
48
+ const providerSettings = s.object({
49
+ id: s.string(),
50
+ baseUrl: s.nullable(s.string()),
51
+ modelId: s.nullable(s.string()),
52
+ enabled: s.boolean(),
53
+ hasKey: s.boolean(),
54
+ keyHint: s.nullable(s.string()),
55
+ configured: s.boolean(),
56
+ });
57
+
43
58
  const settings = s.object({
44
59
  provider: s.nullable(s.string()),
45
60
  modelId: s.nullable(s.string()),
@@ -48,6 +63,7 @@ const settings = s.object({
48
63
  keyHint: s.nullable(s.string()),
49
64
  remember: s.boolean(),
50
65
  configured: s.boolean(),
66
+ providers: s.array(providerSettings, { max: 50 }),
51
67
  });
52
68
 
53
69
  const chatTurn = s.object({
@@ -145,12 +161,18 @@ export const aiContract = defineContract({
145
161
  },
146
162
  'ai.settingsUpdate': {
147
163
  input: s.object({
164
+ // Makes this provider the one in use. Sent with `target`, only the same.
148
165
  provider: s.optional(s.string({ max: 64 })),
166
+ // The provider the fields below apply to; absent, the one in use.
167
+ target: s.optional(s.string({ max: 64 })),
149
168
  modelId: s.optional(s.string({ max: 200 })),
150
169
  baseUrl: s.optional(s.nullable(s.string({ max: 2000 }))),
151
170
  // Null clears the stored key; a string replaces it. It goes to the
152
171
  // secret store and is never read back out to the browser.
153
172
  apiKey: s.optional(s.nullable(s.string({ max: 4000 }))),
173
+ // Whether the target may be sent anything. The one in use cannot be
174
+ // turned off.
175
+ enabled: s.optional(s.boolean()),
154
176
  remember: s.optional(s.boolean()),
155
177
  }),
156
178
  output: settings,
@@ -163,14 +185,24 @@ export const aiContract = defineContract({
163
185
  },
164
186
  'ai.modelsList': {
165
187
  input: s.void(),
166
- output: s.object({ models: s.array(model, { max: 1000 }) }),
167
- summary: 'The models the configured provider offers.',
188
+ output: s.object({
189
+ // Every enabled provider's models, in the build's provider order.
190
+ models: s.array(model, { max: 1000 }),
191
+ // The providers that could not be read, or were cut short, and why.
192
+ unavailable: s.array(unavailableProvider, { max: 50 }),
193
+ }),
194
+ summary: 'The models every provider turned on in Settings offers.',
168
195
  },
169
196
  'ai.connectionTest': {
170
197
  input: s.void(),
171
- output: s.object({ ok: s.boolean(), message: s.string(), latencyMs: s.number() }),
198
+ output: connectionResult,
172
199
  summary: 'Try the configured provider once and report what happened.',
173
200
  },
201
+ 'ai.providerTest': {
202
+ input: s.object({ provider: s.string({ max: 64 }) }),
203
+ output: connectionResult,
204
+ summary: 'Try one provider with its own address and key, whether or not it is turned on.',
205
+ },
174
206
  'ai.chatConfirm': {
175
207
  input: s.object({ runId, callId: s.string({ max: 200 }), approve: s.boolean() }),
176
208
  output: s.object({ accepted: s.boolean() }),
@@ -237,10 +269,10 @@ export const aiContract = defineContract({
237
269
  // Images travel with the turn they arrive on. History keeps a
238
270
  // placeholder instead, because a transcript of base64 would not fit.
239
271
  files: s.optional(s.array(chatFile, { max: 4 })),
240
- // The model for this turn only, and only *within* the configured
241
- // provider. A provider is never overridden per turn: a different
242
- // provider means a different key and a different answer to "does this
243
- // leave my computer", and that stays a Settings decision.
272
+ // The model for this turn only, as a model reference: bare, a model
273
+ // of the provider in use; `<provider>:<model>`, a model of a provider
274
+ // the person turned on in Settings, with that provider's own key and
275
+ // address. A provider that is off is sent nothing.
244
276
  modelId: s.optional(s.string({ max: 200 })),
245
277
  }),
246
278
  event: chatEvent,
@@ -6,6 +6,8 @@
6
6
  */
7
7
  export { aiContract } from './contract.ts';
8
8
  export type { AiContract } from './contract.ts';
9
+ export { describeModel, findModel, formatModelRef, parseModelRef, whereItRuns } from './model-ref.ts';
10
+ export type { FoundModel, ModelDescription, ModelRef, ProviderPlace } from './model-ref.ts';
9
11
  export type {
10
12
  AiSettings,
11
13
  BroappModel,
@@ -13,7 +15,9 @@ export type {
13
15
  ChatFile,
14
16
  ChatTurn,
15
17
  ProviderInfo,
18
+ ProviderSettings,
16
19
  StoredMessage,
17
20
  Thread,
18
21
  ToolPermission,
22
+ UnavailableProvider,
19
23
  } from './types.ts';