broapp 0.4.6 → 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 +389 -235
- package/src/ai/react/ai.css +48 -0
- 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
|
@@ -337,6 +337,54 @@
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
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;
|
|
386
|
+
}
|
|
387
|
+
|
|
340
388
|
/*
|
|
341
389
|
* Always visible once a provider is chosen. "Where do my notes go" is not a
|
|
342
390
|
* question a user should have to open a menu to answer.
|
|
@@ -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';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model reference: one string that names a model and, optionally, the
|
|
3
|
+
* provider that runs it.
|
|
4
|
+
*
|
|
5
|
+
* Written `<providerId>:<modelId>` — `ollama:qwen3:27b`,
|
|
6
|
+
* `openrouter:anthropic/claude-opus-5` — and split on the **first** colon,
|
|
7
|
+
* the convention the AI SDK's provider registry uses. The part before it
|
|
8
|
+
* qualifies the reference only when it is the id of a provider this build
|
|
9
|
+
* has; anything else is the whole string, a model of the provider in use,
|
|
10
|
+
* exactly as a bare id always was. So `qwen3:27b` with Ollama in use is still
|
|
11
|
+
* Ollama's `qwen3:27b`, and nothing stored before references existed changes
|
|
12
|
+
* meaning.
|
|
13
|
+
*
|
|
14
|
+
* One consequence, stated rather than hidden: a model of the provider in use
|
|
15
|
+
* whose own id begins with another provider's id and a colon cannot be reached
|
|
16
|
+
* by its bare id, because the prefix is read as that other provider. Written
|
|
17
|
+
* qualified — `<its own provider>:<id>` — it is reached, since only the first
|
|
18
|
+
* colon splits.
|
|
19
|
+
*
|
|
20
|
+
* Shared code, importing nothing, so the browser's pickers and the host's
|
|
21
|
+
* resolver read a reference the same way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A reference, split. `provider` is null when the reference names none. */
|
|
25
|
+
export interface ModelRef {
|
|
26
|
+
provider: string | null;
|
|
27
|
+
modelId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split a reference into the provider it names and the provider's own model
|
|
32
|
+
* id. `providerIds` are the ids of the providers this build has; a prefix that
|
|
33
|
+
* is not one of them, or an empty half on either side of the colon, leaves the
|
|
34
|
+
* reference unqualified and whole.
|
|
35
|
+
*/
|
|
36
|
+
export function parseModelRef(ref: string, providerIds: readonly string[]): ModelRef {
|
|
37
|
+
const colon = ref.indexOf(':');
|
|
38
|
+
if (colon <= 0 || colon === ref.length - 1) return { provider: null, modelId: ref };
|
|
39
|
+
const provider = ref.slice(0, colon);
|
|
40
|
+
if (!providerIds.includes(provider)) return { provider: null, modelId: ref };
|
|
41
|
+
return { provider, modelId: ref.slice(colon + 1) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a reference that names its provider. `parseModelRef` reads it back. */
|
|
45
|
+
export function formatModelRef(provider: string, modelId: string): string {
|
|
46
|
+
return `${provider}:${modelId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What {@link findModel} found for a reference. */
|
|
50
|
+
export interface FoundModel<M> {
|
|
51
|
+
/** The provider the reference runs on: the one it names, else the one in use. */
|
|
52
|
+
provider: string | null;
|
|
53
|
+
/** The provider's own id for the model. */
|
|
54
|
+
modelId: string;
|
|
55
|
+
/** The listed model, or `null` when the list does not offer it. */
|
|
56
|
+
model: M | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find a stored reference in a model list.
|
|
61
|
+
*
|
|
62
|
+
* Qualified, it matches that provider's model; bare, it matches the provider
|
|
63
|
+
* in use — the one in use *now*, so a bare id stored while another provider
|
|
64
|
+
* was in use never matches that other provider's model by accident. Every
|
|
65
|
+
* place that shows a stored reference goes through here, so a picker, a tier
|
|
66
|
+
* row and a task row cannot disagree about what it names.
|
|
67
|
+
*
|
|
68
|
+
* `providerIds` are every provider in the build, so a reference to one that is
|
|
69
|
+
* off (and so absent from the list) still reads as naming it. Without them,
|
|
70
|
+
* the providers the list and the one in use name are all that is known.
|
|
71
|
+
*/
|
|
72
|
+
export function findModel<M extends { provider: string; modelId: string }>(
|
|
73
|
+
ref: string,
|
|
74
|
+
models: readonly M[],
|
|
75
|
+
activeProvider: string | null,
|
|
76
|
+
providerIds: readonly string[] = [],
|
|
77
|
+
): FoundModel<M> {
|
|
78
|
+
const known = new Set(providerIds);
|
|
79
|
+
for (const model of models) known.add(model.provider);
|
|
80
|
+
if (activeProvider !== null) known.add(activeProvider);
|
|
81
|
+
const parsed = parseModelRef(ref, [...known]);
|
|
82
|
+
const provider = parsed.provider ?? activeProvider;
|
|
83
|
+
const model = models.find((entry) => entry.provider === provider && entry.modelId === parsed.modelId) ?? null;
|
|
84
|
+
return { provider, modelId: parsed.modelId, model };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where a provider runs, as the browser learns it from `ai.providersList`. */
|
|
88
|
+
export interface ProviderPlace {
|
|
89
|
+
id: string;
|
|
90
|
+
label: string;
|
|
91
|
+
local: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The words that say where a provider runs: `on this computer`, or
|
|
96
|
+
* `sent to <label>`. Words, not a colour or an icon alone, wherever a model is
|
|
97
|
+
* chosen or named.
|
|
98
|
+
*/
|
|
99
|
+
export function whereItRuns(provider: Pick<ProviderPlace, 'label' | 'local'>): string {
|
|
100
|
+
return provider.local ? 'on this computer' : `sent to ${provider.label}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A stored reference, described for a person. */
|
|
104
|
+
export interface ModelDescription {
|
|
105
|
+
/** The model's name when the list has it, else the reference's own model id. */
|
|
106
|
+
name: string;
|
|
107
|
+
/** `on this computer` or `sent to <label>`, or `null` when the provider is not known. */
|
|
108
|
+
where: string | null;
|
|
109
|
+
/** Said after the name when it cannot run: `not offered`, or `<label> is off`. */
|
|
110
|
+
problem: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Describe a stored reference: its name, where it runs, and what is wrong with
|
|
115
|
+
* it, if anything. `enabled` lists the providers turned on in Settings.
|
|
116
|
+
*/
|
|
117
|
+
export function describeModel<M extends { provider: string; modelId: string; label: string }>(
|
|
118
|
+
ref: string,
|
|
119
|
+
context: {
|
|
120
|
+
readonly models: readonly M[];
|
|
121
|
+
readonly providers: readonly ProviderPlace[];
|
|
122
|
+
readonly enabled: readonly string[];
|
|
123
|
+
readonly activeProvider: string | null;
|
|
124
|
+
},
|
|
125
|
+
): ModelDescription {
|
|
126
|
+
const found = findModel(
|
|
127
|
+
ref,
|
|
128
|
+
context.models,
|
|
129
|
+
context.activeProvider,
|
|
130
|
+
context.providers.map((provider) => provider.id),
|
|
131
|
+
);
|
|
132
|
+
const place = context.providers.find((provider) => provider.id === found.provider) ?? null;
|
|
133
|
+
const name = found.model?.label ?? found.modelId;
|
|
134
|
+
const where = place === null ? null : whereItRuns(place);
|
|
135
|
+
if (place !== null && !context.enabled.includes(place.id)) {
|
|
136
|
+
return { name, where, problem: `${place.label} is off` };
|
|
137
|
+
}
|
|
138
|
+
return { name, where, problem: found.model === null ? 'not offered' : null };
|
|
139
|
+
}
|
|
@@ -18,8 +18,10 @@ import type {
|
|
|
18
18
|
ChatEvent,
|
|
19
19
|
ChatFile,
|
|
20
20
|
ProviderInfo,
|
|
21
|
+
ProviderSettings,
|
|
21
22
|
StoredMessage,
|
|
22
23
|
Thread,
|
|
24
|
+
UnavailableProvider,
|
|
23
25
|
} from './types.ts';
|
|
24
26
|
|
|
25
27
|
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
@@ -29,6 +31,12 @@ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ?
|
|
|
29
31
|
const settingsMatch: Equal<OperationOutput<AiContract, 'ai.settingsGet'>, AiSettings> = true;
|
|
30
32
|
void settingsMatch;
|
|
31
33
|
|
|
34
|
+
const providerSettingsMatch: Equal<
|
|
35
|
+
OperationOutput<AiContract, 'ai.settingsGet'>['providers'][number],
|
|
36
|
+
ProviderSettings
|
|
37
|
+
> = true;
|
|
38
|
+
void providerSettingsMatch;
|
|
39
|
+
|
|
32
40
|
const settingsUpdateReturnsSettings: Equal<
|
|
33
41
|
OperationOutput<AiContract, 'ai.settingsUpdate'>,
|
|
34
42
|
AiSettings
|
|
@@ -41,6 +49,12 @@ const modelMatch: Equal<
|
|
|
41
49
|
> = true;
|
|
42
50
|
void modelMatch;
|
|
43
51
|
|
|
52
|
+
const unavailableMatch: Equal<
|
|
53
|
+
OperationOutput<AiContract, 'ai.modelsList'>['unavailable'][number],
|
|
54
|
+
UnavailableProvider
|
|
55
|
+
> = true;
|
|
56
|
+
void unavailableMatch;
|
|
57
|
+
|
|
44
58
|
const providerMatch: Equal<
|
|
45
59
|
OperationOutput<AiContract, 'ai.providersList'>['providers'][number],
|
|
46
60
|
ProviderInfo
|
package/src/ai/shared/types.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface BroappModel {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A provider whose models are missing from `ai.modelsList`, or cut short, and
|
|
30
|
+
* why, in a sentence a person can read.
|
|
31
|
+
*/
|
|
32
|
+
export interface UnavailableProvider {
|
|
33
|
+
provider: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/** A provider compiled into this application, as the browser sees it. */
|
|
29
38
|
export interface ProviderInfo {
|
|
30
39
|
id: string;
|
|
@@ -38,7 +47,33 @@ export interface ProviderInfo {
|
|
|
38
47
|
defaultBaseUrl: string | null;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* One provider's own settings, whether or not it is the one in use. Never
|
|
52
|
+
* contains the key itself.
|
|
53
|
+
*/
|
|
54
|
+
export interface ProviderSettings {
|
|
55
|
+
id: string;
|
|
56
|
+
baseUrl: string | null;
|
|
57
|
+
modelId: string | null;
|
|
58
|
+
/** Whether it may be sent anything. The provider in use always may. */
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
hasKey: boolean;
|
|
61
|
+
keyHint: string | null;
|
|
62
|
+
/**
|
|
63
|
+
* True when its key and address are what it needs: a model reference naming
|
|
64
|
+
* it would run, given a model, once it is enabled. Neither the model nor
|
|
65
|
+
* `enabled` is part of this; each has its own field.
|
|
66
|
+
*/
|
|
67
|
+
configured: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the settings route returns. Never contains a key itself.
|
|
72
|
+
*
|
|
73
|
+
* The top-level fields are the provider in use — the one a turn runs on when
|
|
74
|
+
* nothing names another — so an application written against one provider
|
|
75
|
+
* reads what it always read.
|
|
76
|
+
*/
|
|
42
77
|
export interface AiSettings {
|
|
43
78
|
provider: string | null;
|
|
44
79
|
modelId: string | null;
|
|
@@ -46,10 +81,12 @@ export interface AiSettings {
|
|
|
46
81
|
hasKey: boolean;
|
|
47
82
|
/** Last four characters of the key, for the UI to show which key is set. */
|
|
48
83
|
keyHint: string | null;
|
|
49
|
-
/** False means
|
|
84
|
+
/** False means every key is held in memory only and forgotten on exit. */
|
|
50
85
|
remember: boolean;
|
|
51
86
|
/** True when provider and model are both set and the provider's needs are met. */
|
|
52
87
|
configured: boolean;
|
|
88
|
+
/** Every provider in this build, in the build's order. */
|
|
89
|
+
providers: ProviderSettings[];
|
|
53
90
|
}
|
|
54
91
|
|
|
55
92
|
/** How much ceremony a tool call needs before it runs. */
|
|
@@ -86,10 +123,13 @@ export interface ChatFile {
|
|
|
86
123
|
/**
|
|
87
124
|
* A stored conversation, without its messages.
|
|
88
125
|
*
|
|
89
|
-
* `modelId` is null for a conversation that follows Settings, and a model
|
|
90
|
-
* for one that has been pinned to a model of its own
|
|
91
|
-
*
|
|
92
|
-
*
|
|
126
|
+
* `modelId` is null for a conversation that follows Settings, and a model
|
|
127
|
+
* reference for one that has been pinned to a model of its own: bare, a model
|
|
128
|
+
* of the provider in use; `<provider>:<model>`, a model of any provider the
|
|
129
|
+
* person turned on (see `model-ref.ts`). It once could not name a provider,
|
|
130
|
+
* because changing provider changes which key is used and whether anything
|
|
131
|
+
* leaves the computer. That reason stands, and is why wherever a model is
|
|
132
|
+
* chosen the person must be told whether it runs on this computer.
|
|
93
133
|
*/
|
|
94
134
|
export interface Thread {
|
|
95
135
|
id: string;
|