broapp 0.4.4 → 0.4.6
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 +10 -0
- package/src/ai/host/run.ts +18 -0
- package/src/ai/react/AiSettings.tsx +241 -100
- package/src/ai/react/ai.css +338 -10
package/package.json
CHANGED
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface RunEndDetail {
|
|
|
39
39
|
/** Tool round trips the turn made. */
|
|
40
40
|
readonly steps: number;
|
|
41
41
|
readonly ms: number;
|
|
42
|
+
/** The model the turn was sent to; absent when the turn ended before one was resolved. */
|
|
43
|
+
readonly modelId?: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -165,6 +167,13 @@ export interface CreateAiOptions {
|
|
|
165
167
|
* reason to fail it.
|
|
166
168
|
*/
|
|
167
169
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
170
|
+
/**
|
|
171
|
+
* Called after each completed model step of a turn, with the tokens its
|
|
172
|
+
* completed steps have used so far: what a running turn has cost, before
|
|
173
|
+
* `onRunEnd` says what it cost in all. A hook that throws is logged and
|
|
174
|
+
* ignored, like the others.
|
|
175
|
+
*/
|
|
176
|
+
readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
/**
|
|
@@ -334,6 +343,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
334
343
|
approvals,
|
|
335
344
|
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
336
345
|
...(options.onContext === undefined ? {} : { onContext: options.onContext }),
|
|
346
|
+
...(options.onUsageSoFar === undefined ? {} : { onUsageSoFar: options.onUsageSoFar }),
|
|
337
347
|
transcripts: {
|
|
338
348
|
save: (runId, messages) => {
|
|
339
349
|
threadStore().saveTranscript(runId, messages);
|
package/src/ai/host/run.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface RunDeps {
|
|
|
75
75
|
) => void;
|
|
76
76
|
/** Called once per turn with what the model is about to be given. */
|
|
77
77
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Called after each model step completes, with what the turn's completed
|
|
80
|
+
* steps have used so far. Not the SDK's `onStepEnd`, which this layer
|
|
81
|
+
* already hands to `streamText`: this is the turn's running subtotal, for
|
|
82
|
+
* somebody who wants to say what a turn has cost before it ends.
|
|
83
|
+
*/
|
|
84
|
+
readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
|
|
78
85
|
/**
|
|
79
86
|
* The turn transcripts. Absent, every history turn is text and nothing is
|
|
80
87
|
* written, which is exactly the layer before transcripts existed.
|
|
@@ -95,6 +102,8 @@ interface TurnTally {
|
|
|
95
102
|
stepsEnded: number;
|
|
96
103
|
stepInput: number;
|
|
97
104
|
stepOutput: number;
|
|
105
|
+
/** The model the turn was sent to, once it is resolved. */
|
|
106
|
+
modelId?: string;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
/**
|
|
@@ -798,6 +807,7 @@ export async function runChat(
|
|
|
798
807
|
steps: tally.steps,
|
|
799
808
|
ms: Date.now() - started,
|
|
800
809
|
...(tally.usage === undefined ? {} : { usage: tally.usage }),
|
|
810
|
+
...(tally.modelId === undefined ? {} : { modelId: tally.modelId }),
|
|
801
811
|
};
|
|
802
812
|
safely(deps.logger, 'onRunEnd', () =>
|
|
803
813
|
onRunEnd(params.runId, status, params.message.slice(0, SUMMARY_CHARS), detail),
|
|
@@ -860,6 +870,9 @@ async function runTurn(
|
|
|
860
870
|
// after the provider and key checks, so the vision check below and the model
|
|
861
871
|
// instance built later both follow it without a second code path.
|
|
862
872
|
const resolved = await deps.registry.resolve({ modelId: params.modelId });
|
|
873
|
+
// Known here and nowhere later: a listener writing down what the turn used
|
|
874
|
+
// is told which model used it, not left to guess from settings since changed.
|
|
875
|
+
tally.modelId = resolved.modelId;
|
|
863
876
|
|
|
864
877
|
// Both checks come before anything is emitted, so a turn that cannot carry
|
|
865
878
|
// its images fails as a whole rather than half-answering.
|
|
@@ -930,6 +943,11 @@ async function runTurn(
|
|
|
930
943
|
tally.stepsEnded += 1;
|
|
931
944
|
tally.stepInput += step.usage.inputTokens ?? 0;
|
|
932
945
|
tally.stepOutput += step.usage.outputTokens ?? 0;
|
|
946
|
+
const onUsageSoFar = deps.onUsageSoFar;
|
|
947
|
+
if (onUsageSoFar !== undefined) {
|
|
948
|
+
const soFar = { inputTokens: tally.stepInput, outputTokens: tally.stepOutput };
|
|
949
|
+
safely(deps.logger, 'onUsageSoFar', () => onUsageSoFar(params.runId, soFar));
|
|
950
|
+
}
|
|
933
951
|
},
|
|
934
952
|
});
|
|
935
953
|
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* interface. And the data notice is always visible once a provider is chosen,
|
|
9
9
|
* because "where do my notes go" is not a question a user should have to open
|
|
10
10
|
* a menu to answer.
|
|
11
|
+
*
|
|
12
|
+
* Every control carries two classes: the application's own (`input`, `button`)
|
|
13
|
+
* so a host that styles those still reaches it, and an `ai-settings__` one that
|
|
14
|
+
* `ai.css` dresses, so the panel is whole in a host that styles neither.
|
|
11
15
|
*/
|
|
12
16
|
import * as React from 'react';
|
|
13
17
|
|
|
@@ -17,14 +21,79 @@ import { useAiSettings, type ConnectionResult } from './use-ai-settings.ts';
|
|
|
17
21
|
/** Shown while nothing is chosen. */
|
|
18
22
|
const NOT_SET_UP = 'Not set up';
|
|
19
23
|
|
|
24
|
+
/*
|
|
25
|
+
* The icons are drawn here rather than imported: this package has no icon
|
|
26
|
+
* dependency, and a Broapp page may load nothing from off-origin.
|
|
27
|
+
*/
|
|
28
|
+
function Icon({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
29
|
+
return (
|
|
30
|
+
<svg
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
className="ai-settings__icon"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
strokeLinecap="round"
|
|
36
|
+
strokeLinejoin="round"
|
|
37
|
+
strokeWidth="2"
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ChevronIcon = (): React.ReactElement => (
|
|
46
|
+
<Icon>
|
|
47
|
+
<path d="m6 9 6 6 6-6" />
|
|
48
|
+
</Icon>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const KeyIcon = (): React.ReactElement => (
|
|
52
|
+
<Icon>
|
|
53
|
+
<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" />
|
|
54
|
+
<path d="m21 2-9.6 9.6" />
|
|
55
|
+
<circle cx="7.5" cy="15.5" r="5.5" />
|
|
56
|
+
</Icon>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const RefreshIcon = (): React.ReactElement => (
|
|
60
|
+
<Icon>
|
|
61
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
62
|
+
<path d="M21 3v5h-5" />
|
|
63
|
+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
64
|
+
<path d="M8 16H3v5" />
|
|
65
|
+
</Icon>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const InfoIcon = (): React.ReactElement => (
|
|
69
|
+
<Icon>
|
|
70
|
+
<circle cx="12" cy="12" r="10" />
|
|
71
|
+
<path d="M12 16v-4" />
|
|
72
|
+
<path d="M12 8h.01" />
|
|
73
|
+
</Icon>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/** A select with the panel's own chevron: `ai.css` may not use `url()`. */
|
|
77
|
+
function Select(props: React.ComponentProps<'select'>): React.ReactElement {
|
|
78
|
+
return (
|
|
79
|
+
<span className="ai-settings__select">
|
|
80
|
+
<select {...props} className="input input--select ai-settings__input" />
|
|
81
|
+
<ChevronIcon />
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
20
86
|
export function AiSettings(): React.ReactElement {
|
|
21
87
|
const { settings, providers, pending, error, update, test } = useAiSettings();
|
|
22
88
|
const models = useAiModels();
|
|
23
89
|
const [key, setKey] = React.useState('');
|
|
90
|
+
const [replacing, setReplacing] = React.useState(false);
|
|
24
91
|
const [baseUrl, setBaseUrl] = React.useState<string | null>(null);
|
|
92
|
+
const [testing, setTesting] = React.useState(false);
|
|
25
93
|
const [result, setResult] = React.useState<ConnectionResult | null>(null);
|
|
26
94
|
|
|
27
95
|
const provider = providers.find((entry) => entry.id === settings?.provider) ?? null;
|
|
96
|
+
const hasKey = settings?.hasKey === true;
|
|
28
97
|
// The input tracks the saved value until the user types, at which point
|
|
29
98
|
// their draft wins until it is saved on blur.
|
|
30
99
|
const urlValue = baseUrl ?? settings?.baseUrl ?? '';
|
|
@@ -33,6 +102,7 @@ export function AiSettings(): React.ReactElement {
|
|
|
33
102
|
setResult(null);
|
|
34
103
|
setBaseUrl(null);
|
|
35
104
|
setKey('');
|
|
105
|
+
setReplacing(false);
|
|
36
106
|
await update(id === '' ? { provider: undefined } : { provider: id });
|
|
37
107
|
};
|
|
38
108
|
|
|
@@ -40,26 +110,36 @@ export function AiSettings(): React.ReactElement {
|
|
|
40
110
|
if (key === '') return;
|
|
41
111
|
await update({ apiKey: key });
|
|
42
112
|
setKey('');
|
|
113
|
+
setReplacing(false);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onTest = async (): Promise<void> => {
|
|
117
|
+
setTesting(true);
|
|
118
|
+
setResult(null);
|
|
119
|
+
setResult(await test());
|
|
120
|
+
setTesting(false);
|
|
43
121
|
};
|
|
44
122
|
|
|
45
123
|
return (
|
|
46
124
|
<section className="card ai-settings" aria-labelledby="ai-settings-title">
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
125
|
+
<header className="ai-settings__header">
|
|
126
|
+
<h2 className="card__title ai-settings__title" id="ai-settings-title">
|
|
127
|
+
AI connection
|
|
128
|
+
</h2>
|
|
129
|
+
<p className="ai-settings__lede">Choose how your assistant connects.</p>
|
|
130
|
+
</header>
|
|
50
131
|
|
|
51
132
|
{error !== null ? (
|
|
52
|
-
<p className="message message--error" role="alert">
|
|
133
|
+
<p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
|
|
53
134
|
{error.message}
|
|
54
135
|
</p>
|
|
55
136
|
) : null}
|
|
56
137
|
|
|
57
|
-
<div className="form__row">
|
|
58
|
-
<label className="form__label" htmlFor="ai-provider">
|
|
138
|
+
<div className="form__row ai-settings__field">
|
|
139
|
+
<label className="form__label ai-settings__label" htmlFor="ai-provider">
|
|
59
140
|
Provider
|
|
60
141
|
</label>
|
|
61
|
-
<
|
|
62
|
-
className="input input--select"
|
|
142
|
+
<Select
|
|
63
143
|
id="ai-provider"
|
|
64
144
|
disabled={pending}
|
|
65
145
|
value={settings?.provider ?? ''}
|
|
@@ -71,23 +151,31 @@ export function AiSettings(): React.ReactElement {
|
|
|
71
151
|
{entry.label}
|
|
72
152
|
</option>
|
|
73
153
|
))}
|
|
74
|
-
</
|
|
154
|
+
</Select>
|
|
75
155
|
</div>
|
|
76
156
|
|
|
77
157
|
{provider === null ? null : (
|
|
78
158
|
<>
|
|
79
159
|
{provider.needs.baseUrl === 'none' ? null : (
|
|
80
|
-
<div className="form__row">
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
160
|
+
<div className="form__row ai-settings__field">
|
|
161
|
+
<div className="ai-settings__label-row">
|
|
162
|
+
<label className="form__label ai-settings__label" htmlFor="ai-base-url">
|
|
163
|
+
Server address
|
|
164
|
+
</label>
|
|
165
|
+
{provider.needs.baseUrl === 'required' ? (
|
|
166
|
+
<span className="ai-settings__meta" id="ai-base-url-meta">
|
|
167
|
+
Required
|
|
168
|
+
</span>
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
84
171
|
<input
|
|
85
|
-
className="input"
|
|
172
|
+
className="input ai-settings__input"
|
|
86
173
|
id="ai-base-url"
|
|
87
174
|
type="url"
|
|
88
175
|
autoComplete="off"
|
|
89
176
|
spellCheck={false}
|
|
90
177
|
disabled={pending}
|
|
178
|
+
aria-describedby={provider.needs.baseUrl === 'required' ? 'ai-base-url-meta' : undefined}
|
|
91
179
|
placeholder={provider.defaultBaseUrl ?? 'http://127.0.0.1:11434/v1'}
|
|
92
180
|
value={urlValue}
|
|
93
181
|
onChange={(event) => setBaseUrl(event.target.value)}
|
|
@@ -102,120 +190,173 @@ export function AiSettings(): React.ReactElement {
|
|
|
102
190
|
)}
|
|
103
191
|
|
|
104
192
|
{provider.needs.apiKey === 'none' ? null : (
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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>
|
|
272
|
+
)}
|
|
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}
|
|
136
278
|
</div>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
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"
|
|
143
288
|
disabled={pending}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
148
297
|
</p>
|
|
149
|
-
|
|
150
|
-
|
|
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’s data folder, readable by your user account. Turn
|
|
162
|
-
off to keep it only until the app closes.
|
|
163
|
-
</p>
|
|
164
|
-
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</>
|
|
165
300
|
)}
|
|
166
301
|
|
|
167
|
-
<div className="form__row">
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<select
|
|
173
|
-
className="input input--select"
|
|
174
|
-
id="ai-model"
|
|
175
|
-
disabled={pending || models.pending}
|
|
176
|
-
value={settings?.modelId ?? ''}
|
|
177
|
-
onChange={(event) => void update({ modelId: event.target.value })}
|
|
178
|
-
>
|
|
179
|
-
<option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
|
|
180
|
-
{models.models.map((model) => (
|
|
181
|
-
<option key={model.modelId} value={model.modelId}>
|
|
182
|
-
{model.label}
|
|
183
|
-
</option>
|
|
184
|
-
))}
|
|
185
|
-
</select>
|
|
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>
|
|
186
307
|
<button
|
|
187
|
-
className="
|
|
308
|
+
className="ai-settings__inline-action ai-settings__inline-action--icon"
|
|
188
309
|
type="button"
|
|
189
310
|
disabled={models.pending}
|
|
190
311
|
onClick={() => void models.refresh()}
|
|
191
312
|
>
|
|
313
|
+
<RefreshIcon />
|
|
192
314
|
Refresh
|
|
193
315
|
</button>
|
|
194
316
|
</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>
|
|
195
330
|
{models.error === null ? null : (
|
|
196
|
-
<p className="message message--error" role="alert">
|
|
331
|
+
<p className="message message--error ai-settings__message ai-settings__message--error" role="alert">
|
|
197
332
|
{models.error.message}
|
|
198
333
|
</p>
|
|
199
334
|
)}
|
|
200
335
|
</div>
|
|
201
336
|
|
|
202
337
|
<p className="ai-settings__notice" role="status">
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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>
|
|
206
344
|
</p>
|
|
207
345
|
|
|
208
|
-
<div className="form__row">
|
|
346
|
+
<div className="form__row ai-settings__field">
|
|
209
347
|
<button
|
|
210
|
-
className="button button--primary"
|
|
348
|
+
className="button button--primary ai-settings__button ai-settings__button--primary ai-settings__button--block"
|
|
211
349
|
type="button"
|
|
212
350
|
disabled={pending}
|
|
213
|
-
onClick={() => void
|
|
351
|
+
onClick={() => void onTest()}
|
|
214
352
|
>
|
|
215
|
-
Test connection
|
|
353
|
+
{testing ? 'Testing…' : 'Test connection'}
|
|
216
354
|
</button>
|
|
217
355
|
{result === null ? null : (
|
|
218
|
-
<p
|
|
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
|
+
>
|
|
219
360
|
{result.message}
|
|
220
361
|
{result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
|
|
221
362
|
</p>
|
package/src/ai/react/ai.css
CHANGED
|
@@ -9,23 +9,332 @@
|
|
|
9
9
|
* components are still legible in an application that defines none.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/*
|
|
13
|
+
* The settings panel.
|
|
14
|
+
*
|
|
15
|
+
* One height, one radius and one border for every control, so a select, a
|
|
16
|
+
* field, the saved-key row and a button beside them sit on one line and read
|
|
17
|
+
* as one set. The frame — a card, or none — is the application's to draw.
|
|
18
|
+
*
|
|
19
|
+
* The lengths follow Autoapp's control tokens where an application has them,
|
|
20
|
+
* so this panel and a form the renderer draws on the same page are one set of
|
|
21
|
+
* controls; each falls back to that token's default, so an application with no
|
|
22
|
+
* renderer gets the same panel. An `--ai-*` property set on `.ai-settings` or
|
|
23
|
+
* above it beats both.
|
|
24
|
+
*/
|
|
25
|
+
.ai-settings {
|
|
26
|
+
--ai-control-height: 2.5rem;
|
|
27
|
+
--ai-control-radius: var(--autoapp-radius-sm, 7px);
|
|
28
|
+
--ai-border-width: var(--autoapp-border-width, 1px);
|
|
29
|
+
--ai-label-size: var(--autoapp-font-size-small, 0.85rem);
|
|
30
|
+
--ai-label-weight: var(--autoapp-weight-strong, 600);
|
|
31
|
+
--ai-control-size: var(--autoapp-font-size-note, 0.9rem);
|
|
32
|
+
--ai-hint-size: var(--autoapp-font-size-caption, 0.8rem);
|
|
33
|
+
--ai-focus-ring: var(--autoapp-focus-ring, var(--accent, #1f5f4f));
|
|
34
|
+
--ai-focus-ring-width: var(--autoapp-focus-ring-width, 2px);
|
|
35
|
+
--ai-focus-ring-offset: var(--autoapp-focus-ring-offset, 1px);
|
|
36
|
+
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
gap: 1.1rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.ai-settings__header {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
gap: 0.2rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ai-settings__title {
|
|
49
|
+
margin: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.ai-settings__lede {
|
|
53
|
+
margin: 0;
|
|
54
|
+
color: var(--text-muted, #6b6862);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.ai-settings__field {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 0.4rem;
|
|
61
|
+
margin: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ai-settings__label-row {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 0.75rem;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: space-between;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ai-settings__label {
|
|
72
|
+
margin: 0;
|
|
73
|
+
font-size: var(--ai-label-size);
|
|
74
|
+
font-weight: var(--ai-label-weight);
|
|
75
|
+
color: var(--text, #1b1a18);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ai-settings__meta {
|
|
79
|
+
font-size: var(--ai-hint-size);
|
|
80
|
+
color: var(--text-muted, #6b6862);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.ai-settings__meta--ok {
|
|
84
|
+
color: var(--ok, #1f5f4f);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ai-settings__hint {
|
|
88
|
+
margin: 0;
|
|
89
|
+
font-size: var(--ai-hint-size);
|
|
90
|
+
line-height: 1.45;
|
|
91
|
+
color: var(--text-muted, #6b6862);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.ai-settings__icon {
|
|
95
|
+
flex: none;
|
|
96
|
+
width: 1rem;
|
|
97
|
+
height: 1rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.ai-settings__input,
|
|
101
|
+
.ai-settings__saved-key {
|
|
102
|
+
width: 100%;
|
|
103
|
+
min-width: 0;
|
|
104
|
+
height: var(--ai-control-height);
|
|
105
|
+
padding: 0 0.75rem;
|
|
106
|
+
border: var(--ai-border-width) solid var(--border, #e3e2df);
|
|
107
|
+
border-radius: var(--ai-control-radius);
|
|
108
|
+
background: var(--surface, #ffffff);
|
|
109
|
+
font: inherit;
|
|
110
|
+
font-size: var(--ai-control-size);
|
|
111
|
+
color: var(--text, #1b1a18);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.ai-settings__input::placeholder {
|
|
115
|
+
color: var(--text-muted, #6b6862);
|
|
116
|
+
opacity: 0.8;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ai-settings__input:hover:not(:disabled) {
|
|
120
|
+
border-color: var(--text-muted, #6b6862);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ai-settings__input:focus-visible,
|
|
124
|
+
.ai-settings__button:focus-visible,
|
|
125
|
+
.ai-settings__inline-action:focus-visible,
|
|
126
|
+
.ai-settings__switch:focus-visible {
|
|
127
|
+
outline: var(--ai-focus-ring-width) solid var(--ai-focus-ring);
|
|
128
|
+
outline-offset: var(--ai-focus-ring-offset);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.ai-settings__input:disabled {
|
|
132
|
+
opacity: 0.6;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ai-settings__select {
|
|
136
|
+
position: relative;
|
|
137
|
+
display: block;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.ai-settings__select select {
|
|
141
|
+
appearance: none;
|
|
142
|
+
padding-right: 2.25rem;
|
|
143
|
+
text-overflow: ellipsis;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.ai-settings__select .ai-settings__icon {
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: 50%;
|
|
149
|
+
right: 0.75rem;
|
|
150
|
+
translate: 0 -50%;
|
|
151
|
+
color: var(--text-muted, #6b6862);
|
|
152
|
+
pointer-events: none;
|
|
153
|
+
}
|
|
154
|
+
|
|
12
155
|
.ai-settings__key {
|
|
13
156
|
display: flex;
|
|
14
157
|
gap: 0.5rem;
|
|
15
158
|
align-items: center;
|
|
16
159
|
}
|
|
17
160
|
|
|
18
|
-
|
|
161
|
+
/* The saved key: what a field looks like when there is nothing to type. */
|
|
162
|
+
.ai-settings__saved-key {
|
|
163
|
+
display: flex;
|
|
164
|
+
gap: 0.6rem;
|
|
165
|
+
align-items: center;
|
|
166
|
+
padding-right: 0.25rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.ai-settings__saved-key .ai-settings__icon {
|
|
170
|
+
color: var(--text-muted, #6b6862);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.ai-settings__key-hint {
|
|
19
174
|
flex: 1 1 auto;
|
|
20
175
|
min-width: 0;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
178
|
+
font-size: 0.85rem;
|
|
179
|
+
letter-spacing: 0.04em;
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ai-settings__sr {
|
|
184
|
+
position: absolute;
|
|
185
|
+
width: 1px;
|
|
186
|
+
height: 1px;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
clip-path: inset(50%);
|
|
189
|
+
white-space: nowrap;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* A quiet action inside a row: Replace, Remove, Refresh. */
|
|
193
|
+
.ai-settings__inline-action {
|
|
194
|
+
display: inline-flex;
|
|
195
|
+
flex: none;
|
|
196
|
+
gap: 0.35rem;
|
|
197
|
+
align-items: center;
|
|
198
|
+
padding: 0.3rem 0.55rem;
|
|
199
|
+
border: 0;
|
|
200
|
+
border-radius: 6px;
|
|
201
|
+
background: transparent;
|
|
202
|
+
font: inherit;
|
|
203
|
+
font-size: 0.85rem;
|
|
204
|
+
font-weight: 500;
|
|
205
|
+
color: var(--text-muted, #6b6862);
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.ai-settings__inline-action:hover:not(:disabled) {
|
|
210
|
+
background: var(--bg, #fbfbfa);
|
|
211
|
+
color: var(--text, #1b1a18);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.ai-settings__inline-action:disabled {
|
|
215
|
+
opacity: 0.5;
|
|
216
|
+
cursor: default;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action {
|
|
220
|
+
position: relative;
|
|
221
|
+
margin-left: 0.3rem;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.ai-settings__saved-key .ai-settings__inline-action + .ai-settings__inline-action::before {
|
|
225
|
+
position: absolute;
|
|
226
|
+
top: 20%;
|
|
227
|
+
bottom: 20%;
|
|
228
|
+
left: -0.2rem;
|
|
229
|
+
width: 1px;
|
|
230
|
+
background: var(--border, #e3e2df);
|
|
231
|
+
content: '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Pulled up so the label row is no taller than one without a button. */
|
|
235
|
+
.ai-settings__inline-action--icon {
|
|
236
|
+
margin: -0.3rem -0.55rem -0.3rem 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.ai-settings__button {
|
|
240
|
+
flex: none;
|
|
241
|
+
height: var(--ai-control-height);
|
|
242
|
+
padding: 0 1rem;
|
|
243
|
+
border: var(--ai-border-width) solid var(--border, #e3e2df);
|
|
244
|
+
border-radius: var(--ai-control-radius);
|
|
245
|
+
background: var(--surface, #ffffff);
|
|
246
|
+
font: inherit;
|
|
247
|
+
font-size: var(--ai-control-size);
|
|
248
|
+
font-weight: var(--ai-label-weight);
|
|
249
|
+
color: var(--text, #1b1a18);
|
|
250
|
+
cursor: pointer;
|
|
251
|
+
transition: filter 150ms ease-out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.ai-settings__button--primary {
|
|
255
|
+
border-color: transparent;
|
|
256
|
+
background: var(--accent, #1f5f4f);
|
|
257
|
+
color: var(--accent-contrast, #ffffff);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.ai-settings__button--block {
|
|
261
|
+
width: 100%;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.ai-settings__button:hover:not(:disabled) {
|
|
265
|
+
filter: brightness(1.08);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.ai-settings__button:active:not(:disabled) {
|
|
269
|
+
filter: brightness(0.95);
|
|
21
270
|
}
|
|
22
271
|
|
|
23
|
-
.ai-
|
|
272
|
+
.ai-settings__button:disabled {
|
|
273
|
+
opacity: 0.5;
|
|
274
|
+
cursor: default;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.ai-settings__switch-row {
|
|
24
278
|
display: flex;
|
|
25
|
-
gap:
|
|
279
|
+
gap: 1rem;
|
|
26
280
|
align-items: center;
|
|
27
|
-
|
|
28
|
-
|
|
281
|
+
justify-content: space-between;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* A checkbox, drawn as the switch it is announced as. */
|
|
286
|
+
.ai-settings__switch {
|
|
287
|
+
position: relative;
|
|
288
|
+
flex: none;
|
|
289
|
+
width: 2.25rem;
|
|
290
|
+
height: 1.3rem;
|
|
291
|
+
margin: 0;
|
|
292
|
+
border: var(--ai-border-width) solid var(--border, #e3e2df);
|
|
293
|
+
border-radius: 999px;
|
|
294
|
+
background: var(--bg, #fbfbfa);
|
|
295
|
+
appearance: none;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
transition:
|
|
298
|
+
background-color 150ms ease-out,
|
|
299
|
+
border-color 150ms ease-out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.ai-settings__switch::after {
|
|
303
|
+
position: absolute;
|
|
304
|
+
top: 50%;
|
|
305
|
+
left: 0.15rem;
|
|
306
|
+
width: 0.9rem;
|
|
307
|
+
height: 0.9rem;
|
|
308
|
+
border-radius: 50%;
|
|
309
|
+
background: var(--text-muted, #6b6862);
|
|
310
|
+
content: '';
|
|
311
|
+
translate: 0 -50%;
|
|
312
|
+
transition:
|
|
313
|
+
translate 150ms cubic-bezier(0.25, 1, 0.5, 1),
|
|
314
|
+
background-color 150ms ease-out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.ai-settings__switch:checked {
|
|
318
|
+
border-color: transparent;
|
|
319
|
+
background: var(--accent, #1f5f4f);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.ai-settings__switch:checked::after {
|
|
323
|
+
background: var(--accent-contrast, #ffffff);
|
|
324
|
+
translate: 0.95rem -50%;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.ai-settings__switch:disabled {
|
|
328
|
+
opacity: 0.5;
|
|
329
|
+
cursor: default;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@media (prefers-reduced-motion: reduce) {
|
|
333
|
+
.ai-settings__switch,
|
|
334
|
+
.ai-settings__switch::after,
|
|
335
|
+
.ai-settings__button {
|
|
336
|
+
transition: none;
|
|
337
|
+
}
|
|
29
338
|
}
|
|
30
339
|
|
|
31
340
|
/*
|
|
@@ -33,12 +342,31 @@
|
|
|
33
342
|
* question a user should have to open a menu to answer.
|
|
34
343
|
*/
|
|
35
344
|
.ai-settings__notice {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
345
|
+
display: flex;
|
|
346
|
+
gap: 0.6rem;
|
|
347
|
+
align-items: flex-start;
|
|
348
|
+
margin: 0;
|
|
40
349
|
color: var(--text-muted, #6b6862);
|
|
41
|
-
font-size: 0.
|
|
350
|
+
font-size: 0.85rem;
|
|
351
|
+
line-height: 1.45;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.ai-settings__notice .ai-settings__icon {
|
|
355
|
+
margin-top: 0.15rem;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.ai-settings__message {
|
|
359
|
+
margin: 0;
|
|
360
|
+
font-size: 0.85rem;
|
|
361
|
+
line-height: 1.45;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.ai-settings__message--error {
|
|
365
|
+
color: var(--bad, #96311f);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.ai-settings__message--ok {
|
|
369
|
+
color: var(--ok, #1f5f4f);
|
|
42
370
|
}
|
|
43
371
|
|
|
44
372
|
.ai-chat__messages {
|