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.
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * The settings panel.
3
3
  *
4
- * Two things here are deliberate and worth keeping. The key input is
4
+ * One panel, in two parts. **In use** is the provider a conversation and a
5
+ * task run on when nothing says otherwise, its model, and where it sends
6
+ * things. **Providers** is every provider in the build, each with its own
7
+ * address, key, test and switch, because a model reference may name any
8
+ * provider that is turned on, and each keeps its own settings.
9
+ *
10
+ * Two things here are deliberate and worth keeping. A key input is
5
11
  * write-only — it starts empty, is never filled in from the host, and is
6
12
  * cleared after a save — because a key that can be read back out of the
7
13
  * interface is a key that can be read by anything that can reach the
8
- * interface. And the data notice is always visible once a provider is chosen,
9
- * because "where do my notes go" is not a question a user should have to open
10
- * a menu to answer.
14
+ * interface. And where things are sent is always visible once a provider is
15
+ * chosen, because "where do my notes go" is not a question a user should have
16
+ * to open a menu to answer.
11
17
  *
12
18
  * Every control carries two classes: the application's own (`input`, `button`)
13
19
  * so a host that styles those still reaches it, and an `ai-settings__` one that
@@ -15,8 +21,10 @@
15
21
  */
16
22
  import * as React from 'react';
17
23
 
24
+ import type { ProviderInfo, ProviderSettings } from '../shared/types.ts';
25
+
18
26
  import { useAiModels } from './use-ai-models.ts';
19
- import { useAiSettings, type ConnectionResult } from './use-ai-settings.ts';
27
+ import { useAiSettings, type ConnectionResult, type UpdatePatch } from './use-ai-settings.ts';
20
28
 
21
29
  /** Shown while nothing is chosen. */
22
30
  const NOT_SET_UP = 'Not set up';
@@ -83,32 +91,88 @@ function Select(props: React.ComponentProps<'select'>): React.ReactElement {
83
91
  );
84
92
  }
85
93
 
86
- export function AiSettings(): React.ReactElement {
87
- const { settings, providers, pending, error, update, test } = useAiSettings();
88
- const models = useAiModels();
94
+ /** The host of an address, for a summary line; the address itself when it does not parse. */
95
+ function hostOf(address: string): string {
96
+ try {
97
+ return new URL(address).host;
98
+ } catch {
99
+ return address;
100
+ }
101
+ }
102
+
103
+ /** A provider's summary line: where it runs, then on, off, or what it is missing. */
104
+ export function providerSummary(info: ProviderInfo, entry: ProviderSettings | undefined, inUse: boolean): string {
105
+ const address = entry?.baseUrl ?? info.defaultBaseUrl;
106
+ const where = info.local
107
+ ? 'on this computer'
108
+ : address === null || address === ''
109
+ ? 'no address yet'
110
+ : `sent to ${hostOf(address)}`;
111
+ let state: string;
112
+ if (entry?.configured === false) {
113
+ state =
114
+ info.needs.apiKey === 'required' && entry.hasKey !== true
115
+ ? 'needs a key'
116
+ : info.needs.baseUrl === 'required'
117
+ ? 'needs an address'
118
+ : 'not ready';
119
+ } else {
120
+ state = inUse ? 'in use' : entry?.enabled === true ? 'on' : 'off';
121
+ }
122
+ return `${info.label} — ${where} · ${state}`;
123
+ }
124
+
125
+ /**
126
+ * The sentence under "In use" about the other providers: what is sent to
127
+ * them, or, when every provider that is on runs here, that nothing leaves.
128
+ */
129
+ export function othersSentence(
130
+ providers: readonly ProviderInfo[],
131
+ settings: { readonly provider: string | null; readonly providers: readonly ProviderSettings[] },
132
+ ): string | null {
133
+ const enabled = providers.filter(
134
+ (info) => info.id !== settings.provider && settings.providers.some((entry) => entry.id === info.id && entry.enabled),
135
+ );
136
+ if (enabled.length === 0) return null;
137
+ const remote = enabled.filter((info) => !info.local);
138
+ if (remote.length > 0) {
139
+ const labels = remote.map((info) => info.label);
140
+ const named = labels.length === 1 ? (labels[0] ?? '') : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1] ?? ''}`;
141
+ return `Tasks and conversations that choose a model from ${named} are sent there instead.`;
142
+ }
143
+ const inUse = providers.find((info) => info.id === settings.provider);
144
+ return inUse?.local === true
145
+ ? 'Every provider turned on runs on this computer. Nothing is sent over the internet.'
146
+ : null;
147
+ }
148
+
149
+ interface ProviderDetailsProps {
150
+ readonly info: ProviderInfo;
151
+ readonly entry: ProviderSettings | undefined;
152
+ readonly inUse: boolean;
153
+ readonly pending: boolean;
154
+ readonly defaultOpen: boolean;
155
+ update(patch: UpdatePatch): Promise<void>;
156
+ test(provider: string): Promise<ConnectionResult | null>;
157
+ }
158
+
159
+ /** One provider's own settings: address, key, test and whether its models are offered. */
160
+ function ProviderDetails({ info, entry, inUse, pending, defaultOpen, update, test }: ProviderDetailsProps): React.ReactElement {
89
161
  const [key, setKey] = React.useState('');
90
162
  const [replacing, setReplacing] = React.useState(false);
91
163
  const [baseUrl, setBaseUrl] = React.useState<string | null>(null);
92
164
  const [testing, setTesting] = React.useState(false);
93
165
  const [result, setResult] = React.useState<ConnectionResult | null>(null);
94
-
95
- const provider = providers.find((entry) => entry.id === settings?.provider) ?? null;
96
- const hasKey = settings?.hasKey === true;
97
- // The input tracks the saved value until the user types, at which point
98
- // their draft wins until it is saved on blur.
99
- const urlValue = baseUrl ?? settings?.baseUrl ?? '';
100
-
101
- const onProvider = async (id: string): Promise<void> => {
102
- setResult(null);
103
- setBaseUrl(null);
104
- setKey('');
105
- setReplacing(false);
106
- await update(id === '' ? { provider: undefined } : { provider: id });
107
- };
166
+ const hasKey = entry?.hasKey === true;
167
+ const id = info.id;
168
+ // Every control writes to this provider by name, never to "the one in use".
169
+ const write = (patch: Omit<UpdatePatch, 'target'>): Promise<void> => update({ ...patch, target: id });
170
+ const urlValue = baseUrl ?? entry?.baseUrl ?? '';
171
+ const field = (name: string): string => `ai-${name}-${id}`;
108
172
 
109
173
  const onSaveKey = async (): Promise<void> => {
110
174
  if (key === '') return;
111
- await update({ apiKey: key });
175
+ await write({ apiKey: key });
112
176
  setKey('');
113
177
  setReplacing(false);
114
178
  };
@@ -116,10 +180,177 @@ export function AiSettings(): React.ReactElement {
116
180
  const onTest = async (): Promise<void> => {
117
181
  setTesting(true);
118
182
  setResult(null);
119
- setResult(await test());
183
+ setResult(await test(id));
120
184
  setTesting(false);
121
185
  };
122
186
 
187
+ return (
188
+ <details className="ai-settings__provider" open={defaultOpen || undefined}>
189
+ <summary className="ai-settings__provider-summary">{providerSummary(info, entry, inUse)}</summary>
190
+ <div className="ai-settings__provider-body">
191
+ {info.needs.baseUrl === 'none' ? null : (
192
+ <div className="form__row ai-settings__field">
193
+ <div className="ai-settings__label-row">
194
+ <label className="form__label ai-settings__label" htmlFor={field('base-url')}>
195
+ Server address
196
+ </label>
197
+ {info.needs.baseUrl === 'required' ? (
198
+ <span className="ai-settings__meta" id={field('base-url-meta')}>
199
+ Required
200
+ </span>
201
+ ) : null}
202
+ </div>
203
+ <input
204
+ className="input ai-settings__input"
205
+ id={field('base-url')}
206
+ type="url"
207
+ autoComplete="off"
208
+ spellCheck={false}
209
+ disabled={pending}
210
+ aria-describedby={info.needs.baseUrl === 'required' ? field('base-url-meta') : undefined}
211
+ placeholder={info.defaultBaseUrl ?? 'http://127.0.0.1:11434/v1'}
212
+ value={urlValue}
213
+ onChange={(event) => setBaseUrl(event.target.value)}
214
+ onBlur={() => {
215
+ if (baseUrl === null) return;
216
+ const next = baseUrl.trim();
217
+ setBaseUrl(null);
218
+ void write({ baseUrl: next === '' ? null : next });
219
+ }}
220
+ />
221
+ </div>
222
+ )}
223
+
224
+ {info.needs.apiKey === 'none' ? null : (
225
+ <div className="form__row ai-settings__field">
226
+ <div className="ai-settings__label-row">
227
+ <label className="form__label ai-settings__label" htmlFor={field('key')} id={field('key-label')}>
228
+ API key
229
+ </label>
230
+ <span className={`ai-settings__meta${hasKey ? ' ai-settings__meta--ok' : ''}`} id={field('key-meta')}>
231
+ {hasKey ? 'Saved' : info.needs.apiKey === 'optional' ? 'Optional' : 'Required'}
232
+ </span>
233
+ </div>
234
+ {hasKey && !replacing ? (
235
+ // The saved key is never in the page: only its last characters.
236
+ <div className="ai-settings__saved-key" role="group" aria-labelledby={`${field('key-label')} ${field('key-meta')}`}>
237
+ <KeyIcon />
238
+ <span className="ai-settings__key-hint">
239
+ <span aria-hidden="true">•••••••••</span>
240
+ <span className="ai-settings__sr">A key ending in </span>
241
+ {entry?.keyHint ?? '…'}
242
+ </span>
243
+ <button className="ai-settings__inline-action" type="button" disabled={pending} onClick={() => setReplacing(true)}>
244
+ Replace
245
+ </button>
246
+ <button className="ai-settings__inline-action" type="button" disabled={pending} onClick={() => void write({ apiKey: null })}>
247
+ Remove
248
+ </button>
249
+ </div>
250
+ ) : (
251
+ <div className="ai-settings__key">
252
+ <input
253
+ className="input ai-settings__input"
254
+ id={field('key')}
255
+ type="password"
256
+ autoComplete="off"
257
+ spellCheck={false}
258
+ // Replace was just pressed: the field is why.
259
+ autoFocus={replacing}
260
+ disabled={pending}
261
+ aria-describedby={field('key-meta')}
262
+ placeholder={hasKey ? 'Paste the new key' : 'Paste the key'}
263
+ value={key}
264
+ onChange={(event) => setKey(event.target.value)}
265
+ onBlur={() => void onSaveKey()}
266
+ />
267
+ <button
268
+ className="button button--primary ai-settings__button ai-settings__button--primary"
269
+ type="button"
270
+ disabled={pending || key === ''}
271
+ onClick={() => void onSaveKey()}
272
+ >
273
+ Save
274
+ </button>
275
+ {replacing ? (
276
+ <button
277
+ className="button ai-settings__button"
278
+ type="button"
279
+ // Keeps the field's blur — which saves — from running first.
280
+ onMouseDown={(event) => event.preventDefault()}
281
+ onClick={() => {
282
+ setKey('');
283
+ setReplacing(false);
284
+ }}
285
+ >
286
+ Cancel
287
+ </button>
288
+ ) : null}
289
+ </div>
290
+ )}
291
+ {info.needs.apiKey === 'optional' ? (
292
+ <p className="form__hint ai-settings__hint">Required for hosted services. Optional for local servers.</p>
293
+ ) : null}
294
+ </div>
295
+ )}
296
+
297
+ <div className="form__row ai-settings__field">
298
+ <label className="ai-settings__switch-row" htmlFor={field('offer')}>
299
+ <span className="ai-settings__label">Offer this provider&rsquo;s models</span>
300
+ <input
301
+ className="ai-settings__switch"
302
+ id={field('offer')}
303
+ type="checkbox"
304
+ role="switch"
305
+ disabled={pending || inUse}
306
+ aria-describedby={inUse ? field('offer-hint') : undefined}
307
+ checked={inUse || entry?.enabled === true}
308
+ onChange={(event) => void write({ enabled: event.target.checked })}
309
+ />
310
+ </label>
311
+ {inUse ? (
312
+ <p className="form__hint ai-settings__hint" id={field('offer-hint')}>
313
+ In use
314
+ </p>
315
+ ) : null}
316
+ </div>
317
+
318
+ <div className="form__row ai-settings__field">
319
+ <button className="button ai-settings__button" type="button" disabled={pending} onClick={() => void onTest()}>
320
+ {testing ? 'Testing…' : 'Test'}
321
+ </button>
322
+ {result === null ? null : (
323
+ <p
324
+ className={`message ${result.ok ? 'message--ok' : 'message--error'} ai-settings__message ai-settings__message--${result.ok ? 'ok' : 'error'}`}
325
+ role="status"
326
+ >
327
+ {result.message}
328
+ {result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
329
+ </p>
330
+ )}
331
+ </div>
332
+ </div>
333
+ </details>
334
+ );
335
+ }
336
+
337
+ export function AiSettings(): React.ReactElement {
338
+ const { settings, providers, pending, error, update, test } = useAiSettings();
339
+ const models = useAiModels();
340
+ const [inUseResult, setInUseResult] = React.useState<ConnectionResult | null>(null);
341
+
342
+ const active = settings?.provider ?? null;
343
+ const provider = providers.find((entry) => entry.id === active) ?? null;
344
+ // The model select offers the provider in use only: the Settings model is a
345
+ // bare id, and lives inside its provider's entry.
346
+ const own = models.models.filter((model) => model.provider === active);
347
+ const ownUnavailable = models.unavailable.filter((entry) => entry.provider === active);
348
+ const others = settings === null ? null : othersSentence(providers, settings);
349
+
350
+ const onProvider = async (id: string): Promise<void> => {
351
+ await update(id === '' ? { provider: undefined } : { provider: id, target: id });
352
+ };
353
+
123
354
  return (
124
355
  <section className="card ai-settings" aria-labelledby="ai-settings-title">
125
356
  <header className="ai-settings__header">
@@ -135,235 +366,158 @@ export function AiSettings(): React.ReactElement {
135
366
  </p>
136
367
  ) : null}
137
368
 
138
- <div className="form__row ai-settings__field">
139
- <label className="form__label ai-settings__label" htmlFor="ai-provider">
140
- Provider
141
- </label>
142
- <Select
143
- id="ai-provider"
144
- disabled={pending}
145
- value={settings?.provider ?? ''}
146
- onChange={(event) => void onProvider(event.target.value)}
147
- >
148
- <option value="">{NOT_SET_UP}</option>
149
- {providers.map((entry) => (
150
- <option key={entry.id} value={entry.id}>
151
- {entry.label}
152
- </option>
153
- ))}
154
- </Select>
155
- </div>
369
+ <div className="ai-settings__section" role="group" aria-labelledby="ai-in-use-title">
370
+ <h3 className="ai-settings__section-title" id="ai-in-use-title">
371
+ In use
372
+ </h3>
373
+ <div className="form__row ai-settings__field">
374
+ <label className="form__label ai-settings__label" htmlFor="ai-provider">
375
+ Provider
376
+ </label>
377
+ <Select
378
+ id="ai-provider"
379
+ disabled={pending}
380
+ aria-describedby="ai-provider-hint"
381
+ value={active ?? ''}
382
+ onChange={(event) => void onProvider(event.target.value)}
383
+ >
384
+ <option value="">{NOT_SET_UP}</option>
385
+ {providers.map((entry) => (
386
+ <option key={entry.id} value={entry.id}>
387
+ {entry.label}
388
+ </option>
389
+ ))}
390
+ </Select>
391
+ <p className="form__hint ai-settings__hint" id="ai-provider-hint">
392
+ What a conversation and a task run on when nothing says otherwise.
393
+ </p>
394
+ </div>
156
395
 
157
- {provider === null ? null : (
158
- <>
159
- {provider.needs.baseUrl === 'none' ? null : (
396
+ {provider === null ? null : (
397
+ <>
160
398
  <div className="form__row ai-settings__field">
161
399
  <div className="ai-settings__label-row">
162
- <label className="form__label ai-settings__label" htmlFor="ai-base-url">
163
- Server address
400
+ <label className="form__label ai-settings__label" htmlFor="ai-model">
401
+ Model
164
402
  </label>
165
- {provider.needs.baseUrl === 'required' ? (
166
- <span className="ai-settings__meta" id="ai-base-url-meta">
167
- Required
168
- </span>
169
- ) : null}
403
+ <button
404
+ className="ai-settings__inline-action ai-settings__inline-action--icon"
405
+ type="button"
406
+ disabled={models.pending}
407
+ onClick={() => void models.refresh()}
408
+ >
409
+ <RefreshIcon />
410
+ Refresh
411
+ </button>
170
412
  </div>
171
- <input
172
- className="input ai-settings__input"
173
- id="ai-base-url"
174
- type="url"
175
- autoComplete="off"
176
- spellCheck={false}
177
- disabled={pending}
178
- aria-describedby={provider.needs.baseUrl === 'required' ? 'ai-base-url-meta' : undefined}
179
- placeholder={provider.defaultBaseUrl ?? 'http://127.0.0.1:11434/v1'}
180
- value={urlValue}
181
- onChange={(event) => setBaseUrl(event.target.value)}
182
- onBlur={() => {
183
- if (baseUrl === null) return;
184
- const next = baseUrl.trim();
185
- setBaseUrl(null);
186
- void update({ baseUrl: next === '' ? null : next });
187
- }}
188
- />
413
+ <Select
414
+ id="ai-model"
415
+ disabled={pending || models.pending}
416
+ value={settings?.modelId ?? ''}
417
+ onChange={(event) => void update({ modelId: event.target.value, target: provider.id })}
418
+ >
419
+ <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
420
+ {own.map((model) => (
421
+ <option key={model.modelId} value={model.modelId}>
422
+ {model.label}
423
+ </option>
424
+ ))}
425
+ </Select>
426
+ {models.error === null ? null : (
427
+ <p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
428
+ {models.error.message}
429
+ </p>
430
+ )}
431
+ {ownUnavailable.map((entry) => (
432
+ <p className="message message--error ai-settings__message ai-settings__message--error" key={entry.message} role="alert">
433
+ {entry.message}
434
+ </p>
435
+ ))}
189
436
  </div>
190
- )}
191
437
 
192
- {provider.needs.apiKey === 'none' ? null : (
193
- <>
194
- <div className="form__row ai-settings__field">
195
- <div className="ai-settings__label-row">
196
- <label className="form__label ai-settings__label" htmlFor="ai-key" id="ai-key-label">
197
- API key
198
- </label>
199
- <span
200
- className={`ai-settings__meta${hasKey ? ' ai-settings__meta--ok' : ''}`}
201
- id="ai-key-meta"
202
- >
203
- {hasKey ? 'Saved' : provider.needs.apiKey === 'optional' ? 'Optional' : 'Required'}
204
- </span>
205
- </div>
206
- {hasKey && !replacing ? (
207
- // The saved key is never in the page: only its last characters.
208
- <div className="ai-settings__saved-key" role="group" aria-labelledby="ai-key-label ai-key-meta">
209
- <KeyIcon />
210
- <span className="ai-settings__key-hint">
211
- <span aria-hidden="true">•••••••••</span>
212
- <span className="ai-settings__sr">A key ending in </span>
213
- {settings?.keyHint ?? '…'}
214
- </span>
215
- <button
216
- className="ai-settings__inline-action"
217
- type="button"
218
- disabled={pending}
219
- onClick={() => setReplacing(true)}
220
- >
221
- Replace
222
- </button>
223
- <button
224
- className="ai-settings__inline-action"
225
- type="button"
226
- disabled={pending}
227
- onClick={() => void update({ apiKey: null })}
228
- >
229
- Remove
230
- </button>
231
- </div>
232
- ) : (
233
- <div className="ai-settings__key">
234
- <input
235
- className="input ai-settings__input"
236
- id="ai-key"
237
- type="password"
238
- autoComplete="off"
239
- spellCheck={false}
240
- // Replace was just pressed: the field is why.
241
- autoFocus={replacing}
242
- disabled={pending}
243
- aria-describedby="ai-key-meta"
244
- placeholder={hasKey ? 'Paste the new key' : 'Paste the key'}
245
- value={key}
246
- onChange={(event) => setKey(event.target.value)}
247
- onBlur={() => void onSaveKey()}
248
- />
249
- <button
250
- className="button button--primary ai-settings__button ai-settings__button--primary"
251
- type="button"
252
- disabled={pending || key === ''}
253
- onClick={() => void onSaveKey()}
254
- >
255
- Save
256
- </button>
257
- {replacing ? (
258
- <button
259
- className="button ai-settings__button"
260
- type="button"
261
- // Keeps the field's blur — which saves — from running first.
262
- onMouseDown={(event) => event.preventDefault()}
263
- onClick={() => {
264
- setKey('');
265
- setReplacing(false);
266
- }}
267
- >
268
- Cancel
269
- </button>
270
- ) : null}
271
- </div>
438
+ <p className="ai-settings__notice" role="status">
439
+ <InfoIcon />
440
+ <span>
441
+ {provider.local
442
+ ? 'Runs on this computer. Nothing is sent over the internet.'
443
+ : `Messages, open documents and search results are sent to ${provider.label} to generate answers.`}
444
+ {others === null ? null : (
445
+ <>
446
+ {' '}
447
+ {others}
448
+ </>
272
449
  )}
273
- {provider.needs.apiKey === 'optional' ? (
274
- <p className="form__hint ai-settings__hint">
275
- Required for hosted services. Optional for local servers.
276
- </p>
277
- ) : null}
278
- </div>
279
-
280
- <div className="form__row ai-settings__field">
281
- <label className="ai-settings__switch-row" htmlFor="ai-remember">
282
- <span className="ai-settings__label">Remember key on this computer</span>
283
- <input
284
- className="ai-settings__switch"
285
- id="ai-remember"
286
- type="checkbox"
287
- role="switch"
288
- disabled={pending}
289
- aria-describedby="ai-remember-hint"
290
- checked={settings?.remember ?? true}
291
- onChange={(event) => void update({ remember: event.target.checked })}
292
- />
293
- </label>
294
- <p className="form__hint ai-settings__hint" id="ai-remember-hint">
295
- Saved in the app&rsquo;s data folder, accessible to your account. Turn off to keep
296
- the key only until the app closes.
297
- </p>
298
- </div>
299
- </>
300
- )}
450
+ </span>
451
+ </p>
301
452
 
302
- <div className="form__row ai-settings__field">
303
- <div className="ai-settings__label-row">
304
- <label className="form__label ai-settings__label" htmlFor="ai-model">
305
- Model
306
- </label>
453
+ <div className="form__row ai-settings__field">
307
454
  <button
308
- className="ai-settings__inline-action ai-settings__inline-action--icon"
455
+ className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
309
456
  type="button"
310
- disabled={models.pending}
311
- onClick={() => void models.refresh()}
457
+ disabled={pending}
458
+ onClick={() => void (async () => setInUseResult(await test()))()}
312
459
  >
313
- <RefreshIcon />
314
- Refresh
460
+ Test connection
315
461
  </button>
462
+ {inUseResult === null ? null : (
463
+ <p
464
+ className={`message ${inUseResult.ok ? 'message--ok' : 'message--error'} ai-settings__message ai-settings__message--${inUseResult.ok ? 'ok' : 'error'}`}
465
+ role="status"
466
+ >
467
+ {inUseResult.message}
468
+ {inUseResult.ok ? ` (${String(inUseResult.latencyMs)} ms)` : ''}
469
+ </p>
470
+ )}
316
471
  </div>
317
- <Select
318
- id="ai-model"
319
- disabled={pending || models.pending}
320
- value={settings?.modelId ?? ''}
321
- onChange={(event) => void update({ modelId: event.target.value })}
322
- >
323
- <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
324
- {models.models.map((model) => (
325
- <option key={model.modelId} value={model.modelId}>
326
- {model.label}
327
- </option>
328
- ))}
329
- </Select>
330
- {models.error === null ? null : (
331
- <p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
332
- {models.error.message}
333
- </p>
334
- )}
335
- </div>
472
+ </>
473
+ )}
474
+ </div>
336
475
 
337
- <p className="ai-settings__notice" role="status">
338
- <InfoIcon />
339
- <span>
340
- {provider.local
341
- ? 'Runs on this computer. Nothing is sent over the internet.'
342
- : `Messages, open documents and search results are sent to ${provider.label} to generate answers.`}
343
- </span>
476
+ {providers.length === 0 ? null : (
477
+ <div className="ai-settings__section" role="group" aria-labelledby="ai-providers-title">
478
+ <h3 className="ai-settings__section-title" id="ai-providers-title">
479
+ Providers
480
+ </h3>
481
+ <p className="form__hint ai-settings__hint">
482
+ A provider that is on offers its models to every conversation and task, and is sent what they send it.
483
+ One that is off is never contacted except by its own Test.
344
484
  </p>
345
-
346
- <div className="form__row ai-settings__field">
347
- <button
348
- className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
349
- type="button"
350
- disabled={pending}
351
- onClick={() => void onTest()}
352
- >
353
- {testing ? 'Testing…' : 'Test connection'}
354
- </button>
355
- {result === null ? null : (
356
- <p
357
- className={`message ${result.ok ? 'message--ok' : 'message--error'} ai-settings__message ai-settings__message--${result.ok ? 'ok' : 'error'}`}
358
- role="status"
359
- >
360
- {result.message}
361
- {result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
362
- </p>
363
- )}
485
+ <div className="ai-settings__providers">
486
+ {providers.map((info) => (
487
+ <ProviderDetails
488
+ defaultOpen={info.id === active}
489
+ entry={settings?.providers.find((entry) => entry.id === info.id)}
490
+ info={info}
491
+ inUse={info.id === active}
492
+ key={info.id}
493
+ pending={pending}
494
+ test={test}
495
+ update={update}
496
+ />
497
+ ))}
364
498
  </div>
365
- </>
499
+ </div>
366
500
  )}
501
+
502
+ <div className="form__row ai-settings__field">
503
+ <label className="ai-settings__switch-row" htmlFor="ai-remember">
504
+ <span className="ai-settings__label">Remember key on this computer</span>
505
+ <input
506
+ className="ai-settings__switch"
507
+ id="ai-remember"
508
+ type="checkbox"
509
+ role="switch"
510
+ disabled={pending}
511
+ aria-describedby="ai-remember-hint"
512
+ checked={settings?.remember ?? true}
513
+ onChange={(event) => void update({ remember: event.target.checked })}
514
+ />
515
+ </label>
516
+ <p className="form__hint ai-settings__hint" id="ai-remember-hint">
517
+ Applies to every provider&rsquo;s key. Saved in the app&rsquo;s data folder, accessible to your account. Turn
518
+ off to keep keys only until the app closes.
519
+ </p>
520
+ </div>
367
521
  </section>
368
522
  );
369
523
  }