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
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The settings panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
9
|
-
* because "where do my notes go" is not a question a user should have
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
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
|
|
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’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="
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
</
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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-
|
|
163
|
-
|
|
400
|
+
<label className="form__label ai-settings__label" htmlFor="ai-model">
|
|
401
|
+
Model
|
|
164
402
|
</label>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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’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
|
-
|
|
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-
|
|
455
|
+
className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
|
|
309
456
|
type="button"
|
|
310
|
-
disabled={
|
|
311
|
-
onClick={() => void
|
|
457
|
+
disabled={pending}
|
|
458
|
+
onClick={() => void (async () => setInUseResult(await test()))()}
|
|
312
459
|
>
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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’s key. Saved in the app’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
|
}
|