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.
- package/package.json +1 -1
- package/src/ai/host/create-ai.ts +151 -15
- package/src/ai/host/registry.ts +167 -57
- package/src/ai/host/settings.ts +69 -20
- package/src/ai/react/AiSettings.tsx +452 -157
- package/src/ai/react/ai.css +386 -10
- package/src/ai/react/use-ai-models.ts +24 -13
- package/src/ai/react/use-ai-settings.ts +18 -11
- package/src/ai/shared/contract.ts +39 -7
- package/src/ai/shared/index.ts +4 -0
- package/src/ai/shared/model-ref.ts +139 -0
- package/src/ai/shared/types.check.ts +14 -0
- package/src/ai/shared/types.ts +46 -6
package/src/ai/react/ai.css
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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:
|
|
279
|
+
gap: 1rem;
|
|
26
280
|
align-items: center;
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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
|
|
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 —
|
|
5
|
-
*
|
|
6
|
-
* choosing a model must not send the application back to
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
// `
|
|
66
|
-
//
|
|
67
|
-
}, [refresh,
|
|
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
|
-
|
|
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
|
|
51
|
-
// was compiled in —
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
fetched.current = true;
|
|
56
|
+
let current = true;
|
|
56
57
|
void (async () => {
|
|
57
58
|
try {
|
|
58
59
|
const connected = await shared.client();
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
167
|
-
|
|
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:
|
|
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,
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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,
|
package/src/ai/shared/index.ts
CHANGED
|
@@ -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';
|