broapp 0.1.0 → 0.2.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.
@@ -0,0 +1,222 @@
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 ? null : (
105
+ <div className="form__row">
106
+ <label className="form__label" htmlFor="ai-key">
107
+ API key
108
+ </label>
109
+ <div className="ai-settings__key">
110
+ <input
111
+ className="input"
112
+ id="ai-key"
113
+ type="password"
114
+ autoComplete="off"
115
+ spellCheck={false}
116
+ disabled={pending}
117
+ placeholder={settings?.hasKey === true ? 'Replace the saved key' : 'Paste the key'}
118
+ value={key}
119
+ onChange={(event) => setKey(event.target.value)}
120
+ onBlur={() => void onSaveKey()}
121
+ />
122
+ <button
123
+ className="button button--primary"
124
+ type="button"
125
+ disabled={pending || key === ''}
126
+ onClick={() => void onSaveKey()}
127
+ >
128
+ Save key
129
+ </button>
130
+ </div>
131
+ {settings?.hasKey === true ? (
132
+ <p className="form__hint">
133
+ A key ending in {settings.keyHint ?? '…'} is saved.{' '}
134
+ <button
135
+ className="button"
136
+ type="button"
137
+ disabled={pending}
138
+ onClick={() => void update({ apiKey: null })}
139
+ >
140
+ Remove key
141
+ </button>
142
+ </p>
143
+ ) : null}
144
+ <label className="form__label ai-settings__remember" htmlFor="ai-remember">
145
+ <input
146
+ id="ai-remember"
147
+ type="checkbox"
148
+ disabled={pending}
149
+ checked={settings?.remember ?? true}
150
+ onChange={(event) => void update({ remember: event.target.checked })}
151
+ />
152
+ Remember key on this computer
153
+ </label>
154
+ <p className="form__hint">
155
+ Stored in this application&rsquo;s data folder, readable by your user account. Turn
156
+ off to keep it only until the app closes.
157
+ </p>
158
+ </div>
159
+ )}
160
+
161
+ <div className="form__row">
162
+ <label className="form__label" htmlFor="ai-model">
163
+ Model
164
+ </label>
165
+ <div className="ai-settings__key">
166
+ <select
167
+ className="input input--select"
168
+ id="ai-model"
169
+ disabled={pending || models.pending}
170
+ value={settings?.modelId ?? ''}
171
+ onChange={(event) => void update({ modelId: event.target.value })}
172
+ >
173
+ <option value="">{models.pending ? 'Loading…' : 'Choose a model'}</option>
174
+ {models.models.map((model) => (
175
+ <option key={model.modelId} value={model.modelId}>
176
+ {model.label}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ <button
181
+ className="button"
182
+ type="button"
183
+ disabled={models.pending}
184
+ onClick={() => void models.refresh()}
185
+ >
186
+ Refresh
187
+ </button>
188
+ </div>
189
+ {models.error === null ? null : (
190
+ <p className="message message--error" role="alert">
191
+ {models.error.message}
192
+ </p>
193
+ )}
194
+ </div>
195
+
196
+ <p className="ai-settings__notice" role="status">
197
+ {provider.local
198
+ ? 'Runs on this computer. Nothing is sent over the internet.'
199
+ : `Messages, the documents you are viewing, and search results are sent to ${provider.label} to generate answers.`}
200
+ </p>
201
+
202
+ <div className="form__row">
203
+ <button
204
+ className="button button--primary"
205
+ type="button"
206
+ disabled={pending}
207
+ onClick={() => void test().then(setResult)}
208
+ >
209
+ Test connection
210
+ </button>
211
+ {result === null ? null : (
212
+ <p className={`message ${result.ok ? 'message--ok' : 'message--error'}`} role="status">
213
+ {result.message}
214
+ {result.ok ? ` (${String(result.latencyMs)} ms)` : ''}
215
+ </p>
216
+ )}
217
+ </div>
218
+ </>
219
+ )}
220
+ </section>
221
+ );
222
+ }
@@ -0,0 +1,152 @@
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
+ .ai-chat__typing {
113
+ display: inline-block;
114
+ margin-left: 0.25rem;
115
+ color: var(--text-muted, #6b6862);
116
+ animation: ai-chat-pulse 1.2s ease-in-out infinite;
117
+ }
118
+
119
+ @keyframes ai-chat-pulse {
120
+ 0%,
121
+ 100% {
122
+ opacity: 0.35;
123
+ }
124
+ 50% {
125
+ opacity: 1;
126
+ }
127
+ }
128
+
129
+ @media (prefers-reduced-motion: reduce) {
130
+ .ai-chat__typing {
131
+ animation: none;
132
+ }
133
+ }
134
+
135
+ .ai-chat__usage {
136
+ margin: 0.5rem 0 0;
137
+ }
138
+
139
+ .ai-chat__composer {
140
+ margin-top: 0.75rem;
141
+ }
142
+
143
+ .ai-chat__input {
144
+ width: 100%;
145
+ resize: vertical;
146
+ }
147
+
148
+ .ai-chat__actions {
149
+ display: flex;
150
+ gap: 0.5rem;
151
+ margin-top: 0.5rem;
152
+ }
@@ -0,0 +1,35 @@
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
+ ChatTurn,
33
+ ProviderInfo,
34
+ ToolPermission,
35
+ } from '../shared/index.ts';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared AI state for everything below it.
3
+ *
4
+ * The settings panel and the chat panel both need to know whether AI is
5
+ * configured, and they must not disagree — a chat that says "not set up" next
6
+ * to a panel that just saved a key is worse than either alone. So the settings
7
+ * are fetched once, here, and both read them from the same place.
8
+ */
9
+ import * as React from 'react';
10
+
11
+ import type { BroappClient } from '../../client/client.ts';
12
+ import { BroappError } from '../../shared/errors.ts';
13
+ import { useBroapp, useBroappContract, useBroappReady, useConnection } from '../../react/hooks.tsx';
14
+ import type { AiContract } from '../shared/contract.ts';
15
+ import type { AiSettings } from '../shared/types.ts';
16
+
17
+ /** The AI client: the same connection, typed against the AI contract. */
18
+ export type AiClient = BroappClient<AiContract>;
19
+
20
+ interface AiContextValue {
21
+ readonly settings: AiSettings | null;
22
+ readonly error: BroappError | null;
23
+ readonly loading: boolean;
24
+ refresh(): Promise<void>;
25
+ /** Write the settings other hooks just fetched, so one round trip serves all. */
26
+ put(settings: AiSettings): void;
27
+ client(): Promise<AiClient>;
28
+ }
29
+
30
+ const AiContext = React.createContext<AiContextValue | null>(null);
31
+
32
+ /** The route every installation of the AI contract has. */
33
+ const PROBE_ROUTE = 'ai.settingsGet';
34
+
35
+ const MISSING_CONTRACT =
36
+ 'AiProvider needs the AI contract: <BroappProvider contract={contract} extensions={[aiContract]}>';
37
+
38
+ /** Owns the AI settings for the components below it. */
39
+ export function AiProvider({ children }: { children: React.ReactNode }): React.ReactElement {
40
+ const contract = useBroappContract();
41
+ if (!Object.prototype.hasOwnProperty.call(contract.operations, PROBE_ROUTE)) {
42
+ // Thrown at render rather than at the first call: the mistake is in the
43
+ // provider setup, and that is where a developer should be sent.
44
+ throw new Error(MISSING_CONTRACT);
45
+ }
46
+
47
+ const broapp = useBroapp<AiContract>();
48
+ const ready = useBroappReady<AiContract>();
49
+ const connection = useConnection();
50
+ const [settings, setSettings] = React.useState<AiSettings | null>(null);
51
+ const [error, setError] = React.useState<BroappError | null>(null);
52
+ const [loading, setLoading] = React.useState(false);
53
+
54
+ const client = React.useCallback(
55
+ async (): Promise<AiClient> => broapp ?? (await ready),
56
+ [broapp, ready],
57
+ );
58
+
59
+ const refresh = React.useCallback(async (): Promise<void> => {
60
+ setLoading(true);
61
+ try {
62
+ const connected = await client();
63
+ setSettings(await connected.call('ai.settingsGet', undefined));
64
+ setError(null);
65
+ } catch (cause) {
66
+ setError(
67
+ cause instanceof BroappError
68
+ ? cause
69
+ : new BroappError('internal', 'The AI settings could not be read.', cause),
70
+ );
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ }, [client]);
75
+
76
+ // Once, when there is something to talk to. A settings read before the
77
+ // connection settles would only fail and have to be retried.
78
+ const fetched = React.useRef(false);
79
+ React.useEffect(() => {
80
+ if (connection.phase !== 'ready' || fetched.current) return;
81
+ fetched.current = true;
82
+ void refresh();
83
+ }, [connection.phase, refresh]);
84
+
85
+ const value = React.useMemo<AiContextValue>(
86
+ () => ({ settings, error, loading, refresh, put: setSettings, client }),
87
+ [settings, error, loading, refresh, client],
88
+ );
89
+ return <AiContext.Provider value={value}>{children}</AiContext.Provider>;
90
+ }
91
+
92
+ /** The shared AI state. Throws outside {@link AiProvider}. */
93
+ export function useAiContext(): AiContextValue {
94
+ const value = React.useContext(AiContext);
95
+ if (value === null) throw new Error('This hook must be used inside <AiProvider>');
96
+ return value;
97
+ }