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.
@@ -1,228 +1,523 @@
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.
17
+ *
18
+ * Every control carries two classes: the application's own (`input`, `button`)
19
+ * so a host that styles those still reaches it, and an `ai-settings__` one that
20
+ * `ai.css` dresses, so the panel is whole in a host that styles neither.
11
21
  */
12
22
  import * as React from 'react';
13
23
 
24
+ import type { ProviderInfo, ProviderSettings } from '../shared/types.ts';
25
+
14
26
  import { useAiModels } from './use-ai-models.ts';
15
- import { useAiSettings, type ConnectionResult } from './use-ai-settings.ts';
27
+ import { useAiSettings, type ConnectionResult, type UpdatePatch } from './use-ai-settings.ts';
16
28
 
17
29
  /** Shown while nothing is chosen. */
18
30
  const NOT_SET_UP = 'Not set up';
19
31
 
20
- export function AiSettings(): React.ReactElement {
21
- const { settings, providers, pending, error, update, test } = useAiSettings();
22
- const models = useAiModels();
32
+ /*
33
+ * The icons are drawn here rather than imported: this package has no icon
34
+ * dependency, and a Broapp page may load nothing from off-origin.
35
+ */
36
+ function Icon({ children }: { children: React.ReactNode }): React.ReactElement {
37
+ return (
38
+ <svg
39
+ aria-hidden="true"
40
+ className="ai-settings__icon"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ strokeWidth="2"
46
+ viewBox="0 0 24 24"
47
+ >
48
+ {children}
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ const ChevronIcon = (): React.ReactElement => (
54
+ <Icon>
55
+ <path d="m6 9 6 6 6-6" />
56
+ </Icon>
57
+ );
58
+
59
+ const KeyIcon = (): React.ReactElement => (
60
+ <Icon>
61
+ <path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
62
+ <path d="m21 2-9.6 9.6" />
63
+ <circle cx="7.5" cy="15.5" r="5.5" />
64
+ </Icon>
65
+ );
66
+
67
+ const RefreshIcon = (): React.ReactElement => (
68
+ <Icon>
69
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
70
+ <path d="M21 3v5h-5" />
71
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
72
+ <path d="M8 16H3v5" />
73
+ </Icon>
74
+ );
75
+
76
+ const InfoIcon = (): React.ReactElement => (
77
+ <Icon>
78
+ <circle cx="12" cy="12" r="10" />
79
+ <path d="M12 16v-4" />
80
+ <path d="M12 8h.01" />
81
+ </Icon>
82
+ );
83
+
84
+ /** A select with the panel's own chevron: `ai.css` may not use `url()`. */
85
+ function Select(props: React.ComponentProps<'select'>): React.ReactElement {
86
+ return (
87
+ <span className="ai-settings__select">
88
+ <select {...props} className="input input--select ai-settings__input" />
89
+ <ChevronIcon />
90
+ </span>
91
+ );
92
+ }
93
+
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 {
23
161
  const [key, setKey] = React.useState('');
162
+ const [replacing, setReplacing] = React.useState(false);
24
163
  const [baseUrl, setBaseUrl] = React.useState<string | null>(null);
164
+ const [testing, setTesting] = React.useState(false);
25
165
  const [result, setResult] = React.useState<ConnectionResult | null>(null);
26
-
27
- const provider = providers.find((entry) => entry.id === settings?.provider) ?? null;
28
- // The input tracks the saved value until the user types, at which point
29
- // their draft wins until it is saved on blur.
30
- const urlValue = baseUrl ?? settings?.baseUrl ?? '';
31
-
32
- const onProvider = async (id: string): Promise<void> => {
33
- setResult(null);
34
- setBaseUrl(null);
35
- setKey('');
36
- await update(id === '' ? { provider: undefined } : { provider: id });
37
- };
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}`;
38
172
 
39
173
  const onSaveKey = async (): Promise<void> => {
40
174
  if (key === '') return;
41
- await update({ apiKey: key });
175
+ await write({ apiKey: key });
42
176
  setKey('');
177
+ setReplacing(false);
43
178
  };
44
179
 
45
- return (
46
- <section className="card ai-settings" aria-labelledby="ai-settings-title">
47
- <h2 className="card__title" id="ai-settings-title">
48
- AI
49
- </h2>
50
-
51
- {error !== null ? (
52
- <p className="message message--error" role="alert">
53
- {error.message}
54
- </p>
55
- ) : null}
56
-
57
- <div className="form__row">
58
- <label className="form__label" htmlFor="ai-provider">
59
- Provider
60
- </label>
61
- <select
62
- className="input input--select"
63
- id="ai-provider"
64
- disabled={pending}
65
- value={settings?.provider ?? ''}
66
- onChange={(event) => void onProvider(event.target.value)}
67
- >
68
- <option value="">{NOT_SET_UP}</option>
69
- {providers.map((entry) => (
70
- <option key={entry.id} value={entry.id}>
71
- {entry.label}
72
- </option>
73
- ))}
74
- </select>
75
- </div>
180
+ const onTest = async (): Promise<void> => {
181
+ setTesting(true);
182
+ setResult(null);
183
+ setResult(await test(id));
184
+ setTesting(false);
185
+ };
76
186
 
77
- {provider === null ? null : (
78
- <>
79
- {provider.needs.baseUrl === 'none' ? null : (
80
- <div className="form__row">
81
- <label className="form__label" htmlFor="ai-base-url">
82
- Server address{provider.needs.baseUrl === 'required' ? ' (required)' : ''}
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
83
196
  </label>
84
- <input
85
- className="input"
86
- id="ai-base-url"
87
- type="url"
88
- autoComplete="off"
89
- spellCheck={false}
90
- disabled={pending}
91
- placeholder={provider.defaultBaseUrl ?? 'http://127.0.0.1:11434/v1'}
92
- value={urlValue}
93
- onChange={(event) => setBaseUrl(event.target.value)}
94
- onBlur={() => {
95
- if (baseUrl === null) return;
96
- const next = baseUrl.trim();
97
- setBaseUrl(null);
98
- void update({ baseUrl: next === '' ? null : next });
99
- }}
100
- />
197
+ {info.needs.baseUrl === 'required' ? (
198
+ <span className="ai-settings__meta" id={field('base-url-meta')}>
199
+ Required
200
+ </span>
201
+ ) : null}
101
202
  </div>
102
- )}
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
+ )}
103
223
 
104
- {provider.needs.apiKey === 'none' ? null : (
105
- <div className="form__row">
106
- <label className="form__label" htmlFor="ai-key">
107
- API key{provider.needs.apiKey === 'optional' ? ' (optional)' : ''}
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
108
229
  </label>
109
- {provider.needs.apiKey === 'optional' ? (
110
- <p className="form__hint">
111
- Needed for a hosted service such as OpenRouter. Leave empty for a server on this
112
- computer that does not ask for one.
113
- </p>
114
- ) : null}
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
+ ) : (
115
251
  <div className="ai-settings__key">
116
252
  <input
117
- className="input"
118
- id="ai-key"
253
+ className="input ai-settings__input"
254
+ id={field('key')}
119
255
  type="password"
120
256
  autoComplete="off"
121
257
  spellCheck={false}
258
+ // Replace was just pressed: the field is why.
259
+ autoFocus={replacing}
122
260
  disabled={pending}
123
- placeholder={settings?.hasKey === true ? 'Replace the saved key' : 'Paste the key'}
261
+ aria-describedby={field('key-meta')}
262
+ placeholder={hasKey ? 'Paste the new key' : 'Paste the key'}
124
263
  value={key}
125
264
  onChange={(event) => setKey(event.target.value)}
126
265
  onBlur={() => void onSaveKey()}
127
266
  />
128
267
  <button
129
- className="button button--primary"
268
+ className="button button--primary ai-settings__button ai-settings__button--primary"
130
269
  type="button"
131
270
  disabled={pending || key === ''}
132
271
  onClick={() => void onSaveKey()}
133
272
  >
134
- Save key
273
+ Save
135
274
  </button>
136
- </div>
137
- {settings?.hasKey === true ? (
138
- <p className="form__hint">
139
- A key ending in {settings.keyHint ?? '…'} is saved.{' '}
275
+ {replacing ? (
140
276
  <button
141
- className="button"
277
+ className="button ai-settings__button"
142
278
  type="button"
143
- disabled={pending}
144
- onClick={() => void update({ apiKey: null })}
279
+ // Keeps the field's blur — which saves — from running first.
280
+ onMouseDown={(event) => event.preventDefault()}
281
+ onClick={() => {
282
+ setKey('');
283
+ setReplacing(false);
284
+ }}
145
285
  >
146
- Remove key
286
+ Cancel
147
287
  </button>
148
- </p>
149
- ) : null}
150
- <label className="form__label ai-settings__remember" htmlFor="ai-remember">
151
- <input
152
- id="ai-remember"
153
- type="checkbox"
154
- disabled={pending}
155
- checked={settings?.remember ?? true}
156
- onChange={(event) => void update({ remember: event.target.checked })}
157
- />
158
- Remember key on this computer
159
- </label>
160
- <p className="form__hint">
161
- Stored in this application&rsquo;s data folder, readable by your user account. Turn
162
- off to keep it only until the app closes.
163
- </p>
164
- </div>
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>
165
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
+
354
+ return (
355
+ <section className="card ai-settings" aria-labelledby="ai-settings-title">
356
+ <header className="ai-settings__header">
357
+ <h2 className="card__title ai-settings__title" id="ai-settings-title">
358
+ AI connection
359
+ </h2>
360
+ <p className="ai-settings__lede">Choose how your assistant connects.</p>
361
+ </header>
362
+
363
+ {error !== null ? (
364
+ <p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
365
+ {error.message}
366
+ </p>
367
+ ) : null}
368
+
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>
166
395
 
167
- <div className="form__row">
168
- <label className="form__label" htmlFor="ai-model">
169
- Model
170
- </label>
171
- <div className="ai-settings__key">
172
- <select
173
- className="input input--select"
396
+ {provider === null ? null : (
397
+ <>
398
+ <div className="form__row ai-settings__field">
399
+ <div className="ai-settings__label-row">
400
+ <label className="form__label ai-settings__label" htmlFor="ai-model">
401
+ Model
402
+ </label>
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>
412
+ </div>
413
+ <Select
174
414
  id="ai-model"
175
415
  disabled={pending || models.pending}
176
416
  value={settings?.modelId ?? ''}
177
- onChange={(event) => void update({ modelId: event.target.value })}
417
+ onChange={(event) => void update({ modelId: event.target.value, target: provider.id })}
178
418
  >
179
419
  <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
180
- {models.models.map((model) => (
420
+ {own.map((model) => (
181
421
  <option key={model.modelId} value={model.modelId}>
182
422
  {model.label}
183
423
  </option>
184
424
  ))}
185
- </select>
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
+ ))}
436
+ </div>
437
+
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
+ </>
449
+ )}
450
+ </span>
451
+ </p>
452
+
453
+ <div className="form__row ai-settings__field">
186
454
  <button
187
- className="button"
455
+ className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
188
456
  type="button"
189
- disabled={models.pending}
190
- onClick={() => void models.refresh()}
457
+ disabled={pending}
458
+ onClick={() => void (async () => setInUseResult(await test()))()}
191
459
  >
192
- Refresh
460
+ Test connection
193
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
+ )}
194
471
  </div>
195
- {models.error === null ? null : (
196
- <p className="message message--error" role="alert">
197
- {models.error.message}
198
- </p>
199
- )}
200
- </div>
472
+ </>
473
+ )}
474
+ </div>
201
475
 
202
- <p className="ai-settings__notice" role="status">
203
- {provider.local
204
- ? 'Runs on this computer. Nothing is sent over the internet.'
205
- : `Messages, the documents you are viewing, and search results are sent to ${provider.label} to generate answers.`}
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.
206
484
  </p>
207
-
208
- <div className="form__row">
209
- <button
210
- className="button button--primary"
211
- type="button"
212
- disabled={pending}
213
- onClick={() => void test().then(setResult)}
214
- >
215
- Test connection
216
- </button>
217
- {result === null ? null : (
218
- <p className={`message ${result.ok ? 'message--ok' : 'message--error'}`} role="status">
219
- {result.message}
220
- {result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
221
- </p>
222
- )}
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
+ ))}
223
498
  </div>
224
- </>
499
+ </div>
225
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>
226
521
  </section>
227
522
  );
228
523
  }