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.
- 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 +452 -157
- package/src/ai/react/ai.css +386 -10
- 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,228 +1,523 @@
|
|
|
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.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
const urlValue = 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
|
|
175
|
+
await write({ apiKey: key });
|
|
42
176
|
setKey('');
|
|
177
|
+
setReplacing(false);
|
|
43
178
|
};
|
|
44
179
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
className="
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
273
|
+
Save
|
|
135
274
|
</button>
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
286
|
+
Cancel
|
|
147
287
|
</button>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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>
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
420
|
+
{own.map((model) => (
|
|
181
421
|
<option key={model.modelId} value={model.modelId}>
|
|
182
422
|
{model.label}
|
|
183
423
|
</option>
|
|
184
424
|
))}
|
|
185
|
-
</
|
|
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={
|
|
190
|
-
onClick={() => void
|
|
457
|
+
disabled={pending}
|
|
458
|
+
onClick={() => void (async () => setInUseResult(await test()))()}
|
|
191
459
|
>
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
</p>
|
|
199
|
-
)}
|
|
200
|
-
</div>
|
|
472
|
+
</>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
201
475
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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’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>
|
|
226
521
|
</section>
|
|
227
522
|
);
|
|
228
523
|
}
|