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.
Files changed (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. 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&rsquo;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';