broapp 0.1.0 → 0.3.0
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/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +109 -0
- package/src/ai/host/create-ai.ts +266 -0
- package/src/ai/host/fake.ts +230 -0
- package/src/ai/host/from-contract.ts +105 -0
- package/src/ai/host/index.ts +37 -0
- package/src/ai/host/registry.ts +232 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +540 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +95 -0
- package/src/ai/react/AiChat.tsx +242 -0
- package/src/ai/react/AiSettings.tsx +228 -0
- package/src/ai/react/ai.css +166 -0
- package/src/ai/react/index.tsx +38 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +317 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +247 -0
- package/src/ai/shared/index.ts +19 -0
- package/src/ai/shared/types.check.ts +71 -0
- package/src/ai/shared/types.ts +147 -0
- package/src/host/app.ts +183 -36
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +25 -0
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +99 -2
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +66 -2
- package/src/shared/index.ts +14 -3
- package/src/shared/schema.ts +141 -28
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat panel.
|
|
3
|
+
*
|
|
4
|
+
* The assistant's text is rendered as text. No markdown library, no
|
|
5
|
+
* `dangerouslySetInnerHTML`: the content is written by a model that has just
|
|
6
|
+
* been shown documents from the user's own machine, and a renderer that turns
|
|
7
|
+
* some of that into markup is a way for a document to reach the page. A
|
|
8
|
+
* `<pre>` with `white-space: pre-wrap` keeps the line breaks, which is most of
|
|
9
|
+
* what markdown would have been used for anyway.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
|
|
13
|
+
import { countdown, isUrgent } from '../../shared/countdown.ts';
|
|
14
|
+
|
|
15
|
+
import { useAiChat, type AiChatOptions, type ToolCallState } from './use-ai-chat.ts';
|
|
16
|
+
import { useAiSettings } from './use-ai-settings.ts';
|
|
17
|
+
|
|
18
|
+
/** Props for {@link AiChat}. */
|
|
19
|
+
export interface AiChatProps {
|
|
20
|
+
/** Records the user is looking at, sent with every message. */
|
|
21
|
+
readonly refs?: readonly string[];
|
|
22
|
+
readonly placeholder?: string;
|
|
23
|
+
readonly emptyText?: string;
|
|
24
|
+
/** Called when a tool call settles, so the application can refetch. */
|
|
25
|
+
readonly onToolResult?: AiChatOptions['onToolResult'];
|
|
26
|
+
/**
|
|
27
|
+
* How many tool calls are waiting for the person, whenever that changes.
|
|
28
|
+
*
|
|
29
|
+
* The panel does not act on this itself: a tab that renamed itself or raised
|
|
30
|
+
* a notification without being asked would do it in every application that
|
|
31
|
+
* embeds a chat. The launcher asks, because its questions arrive after ten
|
|
32
|
+
* minutes of silence and the person is not looking at the tab.
|
|
33
|
+
*/
|
|
34
|
+
readonly onAwaiting?: (pending: number) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Re-render once a second while `active`, so a countdown counts. */
|
|
38
|
+
function useTick(active: boolean): number {
|
|
39
|
+
const [now, setNow] = React.useState(() => Date.now());
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!active) return undefined;
|
|
42
|
+
setNow(Date.now());
|
|
43
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
|
44
|
+
return () => clearInterval(timer);
|
|
45
|
+
}, [active]);
|
|
46
|
+
return now;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ToolCall({
|
|
50
|
+
call,
|
|
51
|
+
onConfirm,
|
|
52
|
+
}: {
|
|
53
|
+
call: ToolCallState;
|
|
54
|
+
onConfirm: (callId: string, approve: boolean) => void;
|
|
55
|
+
}): React.ReactElement {
|
|
56
|
+
const waiting = call.status === 'awaiting-confirmation' && call.expiresAt !== undefined;
|
|
57
|
+
const now = useTick(waiting);
|
|
58
|
+
const urgent = call.expiresAt !== undefined && waiting && isUrgent(call.expiresAt, now);
|
|
59
|
+
return (
|
|
60
|
+
<div className="ai-chat__tool">
|
|
61
|
+
<details>
|
|
62
|
+
<summary>
|
|
63
|
+
{call.status === 'denied' ? 'Declined' : 'Used'} {call.tool}
|
|
64
|
+
</summary>
|
|
65
|
+
<pre className="ai-chat__json">{JSON.stringify(call.input, null, 2)}</pre>
|
|
66
|
+
{call.output === undefined ? null : (
|
|
67
|
+
<pre className="ai-chat__json">{JSON.stringify(call.output, null, 2)}</pre>
|
|
68
|
+
)}
|
|
69
|
+
</details>
|
|
70
|
+
{call.status !== 'awaiting-confirmation' ? null : (
|
|
71
|
+
<div
|
|
72
|
+
className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
|
|
73
|
+
role="group"
|
|
74
|
+
aria-label={`Allow ${call.tool}?`}
|
|
75
|
+
>
|
|
76
|
+
<span>Allow this?</span>
|
|
77
|
+
{call.expiresAt === undefined ? null : (
|
|
78
|
+
<span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
|
|
79
|
+
)}
|
|
80
|
+
<button
|
|
81
|
+
className="button button--primary"
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => onConfirm(call.callId, true)}
|
|
84
|
+
>
|
|
85
|
+
Allow
|
|
86
|
+
</button>
|
|
87
|
+
<button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
|
|
88
|
+
Decline
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function AiChat({
|
|
97
|
+
refs,
|
|
98
|
+
placeholder,
|
|
99
|
+
emptyText,
|
|
100
|
+
onToolResult,
|
|
101
|
+
onAwaiting,
|
|
102
|
+
}: AiChatProps): React.ReactElement {
|
|
103
|
+
const { settings } = useAiSettings();
|
|
104
|
+
const chat = useAiChat({
|
|
105
|
+
...(refs === undefined ? {} : { refs }),
|
|
106
|
+
...(onToolResult === undefined ? {} : { onToolResult }),
|
|
107
|
+
});
|
|
108
|
+
const [draft, setDraft] = React.useState('');
|
|
109
|
+
const input = React.useRef<HTMLTextAreaElement | null>(null);
|
|
110
|
+
const busy = chat.status === 'streaming' || chat.status === 'awaiting-confirmation';
|
|
111
|
+
|
|
112
|
+
const waiting = chat.messages.reduce(
|
|
113
|
+
(count, message) =>
|
|
114
|
+
message.role === 'assistant'
|
|
115
|
+
? count +
|
|
116
|
+
message.toolCalls.filter((call) => call.status === 'awaiting-confirmation').length
|
|
117
|
+
: count,
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
const announce = React.useRef(onAwaiting);
|
|
121
|
+
announce.current = onAwaiting;
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
announce.current?.(waiting);
|
|
124
|
+
}, [waiting]);
|
|
125
|
+
|
|
126
|
+
// Back to the box when the turn ends, so a conversation can be carried on
|
|
127
|
+
// without reaching for the mouse.
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
if (chat.status === 'idle') input.current?.focus();
|
|
130
|
+
}, [chat.status]);
|
|
131
|
+
|
|
132
|
+
if (settings === null || settings.configured !== true) {
|
|
133
|
+
return (
|
|
134
|
+
<section className="card ai-chat" aria-labelledby="ai-chat-title">
|
|
135
|
+
<h2 className="card__title" id="ai-chat-title">
|
|
136
|
+
Assistant
|
|
137
|
+
</h2>
|
|
138
|
+
<p className="form__hint">
|
|
139
|
+
{/* Until the first settings read returns there is nothing to say yet,
|
|
140
|
+
and saying "not set up" would be a guess that is wrong as often as
|
|
141
|
+
it is right. */}
|
|
142
|
+
{settings === null
|
|
143
|
+
? 'Checking the AI settings…'
|
|
144
|
+
: 'AI is not set up. Open Settings to choose a provider.'}
|
|
145
|
+
</p>
|
|
146
|
+
</section>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const submit = (): void => {
|
|
151
|
+
const text = draft;
|
|
152
|
+
setDraft('');
|
|
153
|
+
void chat.send(text);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<section className="card ai-chat" aria-labelledby="ai-chat-title">
|
|
158
|
+
<h2 className="card__title" id="ai-chat-title">
|
|
159
|
+
Assistant
|
|
160
|
+
</h2>
|
|
161
|
+
|
|
162
|
+
<div className="ai-chat__messages" role="log" aria-live="polite">
|
|
163
|
+
{chat.messages.length === 0 ? (
|
|
164
|
+
<p className="form__hint">{emptyText ?? 'Ask a question about what you are looking at.'}</p>
|
|
165
|
+
) : null}
|
|
166
|
+
{chat.messages.map((message) =>
|
|
167
|
+
message.role === 'user' ? (
|
|
168
|
+
<div className="ai-chat__message ai-chat__message--user" key={message.id}>
|
|
169
|
+
<pre className="ai-chat__text">{message.content}</pre>
|
|
170
|
+
</div>
|
|
171
|
+
) : (
|
|
172
|
+
<div className="ai-chat__message ai-chat__message--assistant" key={message.id}>
|
|
173
|
+
{message.toolCalls.map((call) => (
|
|
174
|
+
<ToolCall
|
|
175
|
+
call={call}
|
|
176
|
+
key={call.callId}
|
|
177
|
+
onConfirm={(callId, approve) => void chat.confirm(callId, approve)}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
<pre className="ai-chat__text">{message.content}</pre>
|
|
181
|
+
{message.pending ? <span className="ai-chat__typing">…</span> : null}
|
|
182
|
+
</div>
|
|
183
|
+
),
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{chat.error === null ? null : (
|
|
188
|
+
<p className="message message--error" role="alert">
|
|
189
|
+
{chat.error}
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
192
|
+
{chat.usage === null ? null : (
|
|
193
|
+
<p className="form__hint ai-chat__usage">
|
|
194
|
+
{chat.usage.inputTokens} tokens in, {chat.usage.outputTokens} out
|
|
195
|
+
</p>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<div className="ai-chat__composer">
|
|
199
|
+
<label className="form__label" htmlFor="ai-chat-input">
|
|
200
|
+
Message
|
|
201
|
+
</label>
|
|
202
|
+
<textarea
|
|
203
|
+
className="input ai-chat__input"
|
|
204
|
+
id="ai-chat-input"
|
|
205
|
+
ref={input}
|
|
206
|
+
rows={3}
|
|
207
|
+
placeholder={placeholder ?? 'Ask about these notes'}
|
|
208
|
+
value={draft}
|
|
209
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
210
|
+
onKeyDown={(event) => {
|
|
211
|
+
// Enter sends; Shift+Enter is a newline. Anything with a modifier
|
|
212
|
+
// is left alone, so the platform shortcuts still work.
|
|
213
|
+
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) return;
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
if (!busy) submit();
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
<div className="ai-chat__actions">
|
|
219
|
+
<button
|
|
220
|
+
className="button button--primary"
|
|
221
|
+
type="button"
|
|
222
|
+
disabled={busy || draft.trim() === ''}
|
|
223
|
+
onClick={submit}
|
|
224
|
+
>
|
|
225
|
+
Send
|
|
226
|
+
</button>
|
|
227
|
+
<button className="button" type="button" disabled={!busy} onClick={chat.cancel}>
|
|
228
|
+
Stop
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
className="button"
|
|
232
|
+
type="button"
|
|
233
|
+
disabled={chat.messages.length === 0}
|
|
234
|
+
onClick={chat.clear}
|
|
235
|
+
>
|
|
236
|
+
Clear
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</section>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings panel.
|
|
3
|
+
*
|
|
4
|
+
* Two things here are deliberate and worth keeping. The key input is
|
|
5
|
+
* write-only — it starts empty, is never filled in from the host, and is
|
|
6
|
+
* cleared after a save — because a key that can be read back out of the
|
|
7
|
+
* 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.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
|
|
14
|
+
import { useAiModels } from './use-ai-models.ts';
|
|
15
|
+
import { useAiSettings, type ConnectionResult } from './use-ai-settings.ts';
|
|
16
|
+
|
|
17
|
+
/** Shown while nothing is chosen. */
|
|
18
|
+
const NOT_SET_UP = 'Not set up';
|
|
19
|
+
|
|
20
|
+
export function AiSettings(): React.ReactElement {
|
|
21
|
+
const { settings, providers, pending, error, update, test } = useAiSettings();
|
|
22
|
+
const models = useAiModels();
|
|
23
|
+
const [key, setKey] = React.useState('');
|
|
24
|
+
const [baseUrl, setBaseUrl] = React.useState<string | null>(null);
|
|
25
|
+
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
|
+
};
|
|
38
|
+
|
|
39
|
+
const onSaveKey = async (): Promise<void> => {
|
|
40
|
+
if (key === '') return;
|
|
41
|
+
await update({ apiKey: key });
|
|
42
|
+
setKey('');
|
|
43
|
+
};
|
|
44
|
+
|
|
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>
|
|
76
|
+
|
|
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)' : ''}
|
|
83
|
+
</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
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
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)' : ''}
|
|
108
|
+
</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}
|
|
115
|
+
<div className="ai-settings__key">
|
|
116
|
+
<input
|
|
117
|
+
className="input"
|
|
118
|
+
id="ai-key"
|
|
119
|
+
type="password"
|
|
120
|
+
autoComplete="off"
|
|
121
|
+
spellCheck={false}
|
|
122
|
+
disabled={pending}
|
|
123
|
+
placeholder={settings?.hasKey === true ? 'Replace the saved key' : 'Paste the key'}
|
|
124
|
+
value={key}
|
|
125
|
+
onChange={(event) => setKey(event.target.value)}
|
|
126
|
+
onBlur={() => void onSaveKey()}
|
|
127
|
+
/>
|
|
128
|
+
<button
|
|
129
|
+
className="button button--primary"
|
|
130
|
+
type="button"
|
|
131
|
+
disabled={pending || key === ''}
|
|
132
|
+
onClick={() => void onSaveKey()}
|
|
133
|
+
>
|
|
134
|
+
Save key
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
{settings?.hasKey === true ? (
|
|
138
|
+
<p className="form__hint">
|
|
139
|
+
A key ending in {settings.keyHint ?? '…'} is saved.{' '}
|
|
140
|
+
<button
|
|
141
|
+
className="button"
|
|
142
|
+
type="button"
|
|
143
|
+
disabled={pending}
|
|
144
|
+
onClick={() => void update({ apiKey: null })}
|
|
145
|
+
>
|
|
146
|
+
Remove key
|
|
147
|
+
</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’s data folder, readable by your user account. Turn
|
|
162
|
+
off to keep it only until the app closes.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
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"
|
|
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>
|
|
186
|
+
<button
|
|
187
|
+
className="button"
|
|
188
|
+
type="button"
|
|
189
|
+
disabled={models.pending}
|
|
190
|
+
onClick={() => void models.refresh()}
|
|
191
|
+
>
|
|
192
|
+
Refresh
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
{models.error === null ? null : (
|
|
196
|
+
<p className="message message--error" role="alert">
|
|
197
|
+
{models.error.message}
|
|
198
|
+
</p>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
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.`}
|
|
206
|
+
</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
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</>
|
|
225
|
+
)}
|
|
226
|
+
</section>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Styles for the AI panels.
|
|
3
|
+
*
|
|
4
|
+
* Ordinary CSS, no framework, no web font, no `@import` and no `url()`: a
|
|
5
|
+
* Broapp page loads nothing from off-origin, and `broapp build` fails the
|
|
6
|
+
* build if it finds anything that would. The custom properties are the ones an
|
|
7
|
+
* application already defines for its own interface, so these panels take on
|
|
8
|
+
* its colours rather than imposing their own; the fallbacks are there so the
|
|
9
|
+
* components are still legible in an application that defines none.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.ai-settings__key {
|
|
13
|
+
display: flex;
|
|
14
|
+
gap: 0.5rem;
|
|
15
|
+
align-items: center;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.ai-settings__key .input {
|
|
19
|
+
flex: 1 1 auto;
|
|
20
|
+
min-width: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.ai-settings__remember {
|
|
24
|
+
display: flex;
|
|
25
|
+
gap: 0.5rem;
|
|
26
|
+
align-items: center;
|
|
27
|
+
margin-top: 0.75rem;
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* Always visible once a provider is chosen. "Where do my notes go" is not a
|
|
33
|
+
* question a user should have to open a menu to answer.
|
|
34
|
+
*/
|
|
35
|
+
.ai-settings__notice {
|
|
36
|
+
margin: 0.75rem 0;
|
|
37
|
+
padding: 0.6rem 0.75rem;
|
|
38
|
+
border: 1px solid var(--border, #e3e2df);
|
|
39
|
+
border-radius: var(--radius, 10px);
|
|
40
|
+
color: var(--text-muted, #6b6862);
|
|
41
|
+
font-size: 0.9rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ai-chat__messages {
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
gap: 0.75rem;
|
|
48
|
+
max-height: 24rem;
|
|
49
|
+
overflow-y: auto;
|
|
50
|
+
padding: 0.25rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ai-chat__message {
|
|
54
|
+
padding: 0.6rem 0.75rem;
|
|
55
|
+
border-radius: var(--radius, 10px);
|
|
56
|
+
max-width: 90%;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ai-chat__message--user {
|
|
60
|
+
align-self: flex-end;
|
|
61
|
+
background: var(--accent, #1f5f4f);
|
|
62
|
+
color: var(--accent-contrast, #ffffff);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ai-chat__message--assistant {
|
|
66
|
+
align-self: flex-start;
|
|
67
|
+
background: var(--surface, #ffffff);
|
|
68
|
+
border: 1px solid var(--border, #e3e2df);
|
|
69
|
+
color: var(--text, #1b1a18);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* A `<pre>` rather than a markdown renderer. The text was written by a model
|
|
74
|
+
* that has just been shown documents from this machine; turning parts of it
|
|
75
|
+
* into markup would be a way for a document to reach the page.
|
|
76
|
+
*/
|
|
77
|
+
.ai-chat__text {
|
|
78
|
+
margin: 0;
|
|
79
|
+
font: inherit;
|
|
80
|
+
white-space: pre-wrap;
|
|
81
|
+
overflow-wrap: anywhere;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ai-chat__tool {
|
|
85
|
+
margin-bottom: 0.5rem;
|
|
86
|
+
font-size: 0.9rem;
|
|
87
|
+
color: var(--text-muted, #6b6862);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ai-chat__json {
|
|
91
|
+
margin: 0.4rem 0 0;
|
|
92
|
+
padding: 0.5rem;
|
|
93
|
+
border-radius: var(--radius, 10px);
|
|
94
|
+
background: var(--bg, #fbfbfa);
|
|
95
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
96
|
+
font-size: 0.8rem;
|
|
97
|
+
white-space: pre-wrap;
|
|
98
|
+
overflow-wrap: anywhere;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.ai-chat__confirm {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 0.5rem;
|
|
104
|
+
align-items: center;
|
|
105
|
+
margin-top: 0.4rem;
|
|
106
|
+
padding: 0.5rem;
|
|
107
|
+
border: 1px solid var(--pending, #8a6a12);
|
|
108
|
+
border-radius: var(--radius, 10px);
|
|
109
|
+
color: var(--text, #1b1a18);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Under a minute left. Amber and a little heavier, so a card that is about to
|
|
113
|
+
expire is not the same shape as one with nine minutes on it. */
|
|
114
|
+
.ai-chat__confirm--urgent {
|
|
115
|
+
border-color: var(--warning, #b4690e);
|
|
116
|
+
background: var(--warning-bg, #fdf3e3);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ai-chat__expires {
|
|
120
|
+
margin-left: auto;
|
|
121
|
+
color: var(--text-muted, #6b6862);
|
|
122
|
+
font-size: 0.8rem;
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ai-chat__typing {
|
|
127
|
+
display: inline-block;
|
|
128
|
+
margin-left: 0.25rem;
|
|
129
|
+
color: var(--text-muted, #6b6862);
|
|
130
|
+
animation: ai-chat-pulse 1.2s ease-in-out infinite;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@keyframes ai-chat-pulse {
|
|
134
|
+
0%,
|
|
135
|
+
100% {
|
|
136
|
+
opacity: 0.35;
|
|
137
|
+
}
|
|
138
|
+
50% {
|
|
139
|
+
opacity: 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
144
|
+
.ai-chat__typing {
|
|
145
|
+
animation: none;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ai-chat__usage {
|
|
150
|
+
margin: 0.5rem 0 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ai-chat__composer {
|
|
154
|
+
margin-top: 0.75rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.ai-chat__input {
|
|
158
|
+
width: 100%;
|
|
159
|
+
resize: vertical;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.ai-chat__actions {
|
|
163
|
+
display: flex;
|
|
164
|
+
gap: 0.5rem;
|
|
165
|
+
margin-top: 0.5rem;
|
|
166
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `broapp/ai/react` — the AI layer's interface.
|
|
3
|
+
*
|
|
4
|
+
* Shared code: nothing here imports the AI SDK, and a browser bundle that
|
|
5
|
+
* follows these imports gets the contract and the components and nothing else.
|
|
6
|
+
* Pair it with `ai.css`, or write your own using the class names these
|
|
7
|
+
* components emit.
|
|
8
|
+
*/
|
|
9
|
+
export { AiProvider, useAiContext } from './provider.tsx';
|
|
10
|
+
export type { AiClient } from './provider.tsx';
|
|
11
|
+
|
|
12
|
+
export { useAiSettings } from './use-ai-settings.ts';
|
|
13
|
+
export type { AiSettingsHook, ConnectionResult, UpdatePatch } from './use-ai-settings.ts';
|
|
14
|
+
|
|
15
|
+
export { useAiModels } from './use-ai-models.ts';
|
|
16
|
+
export type { AiModelsHook } from './use-ai-models.ts';
|
|
17
|
+
|
|
18
|
+
export { useAiChat } from './use-ai-chat.ts';
|
|
19
|
+
export type { AiChatHook, AiChatOptions, ChatMessage, ToolCallState } from './use-ai-chat.ts';
|
|
20
|
+
|
|
21
|
+
export { AiSettings } from './AiSettings.tsx';
|
|
22
|
+
export { AiChat } from './AiChat.tsx';
|
|
23
|
+
export type { AiChatProps } from './AiChat.tsx';
|
|
24
|
+
|
|
25
|
+
// Re-exported so an application needs one import to install the extension.
|
|
26
|
+
export { aiContract } from '../shared/index.ts';
|
|
27
|
+
export type {
|
|
28
|
+
AiContract,
|
|
29
|
+
AiSettings as AiSettingsValue,
|
|
30
|
+
BroappModel,
|
|
31
|
+
ChatEvent,
|
|
32
|
+
ChatFile,
|
|
33
|
+
ChatTurn,
|
|
34
|
+
ProviderInfo,
|
|
35
|
+
StoredMessage,
|
|
36
|
+
Thread,
|
|
37
|
+
ToolPermission,
|
|
38
|
+
} from '../shared/index.ts';
|