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.
@@ -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 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';
@@ -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
@@ -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
- /** What the settings route returns. Never contains the key itself. */
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 the key is held in memory only and forgotten on exit. */
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 id
90
- * for one that has been pinned to a model of its own. The provider is never
91
- * part of a conversation: it is a Settings decision, because changing it
92
- * changes which key is used and whether anything leaves the computer.
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;