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.
- package/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +106 -0
- package/src/ai/host/create-ai.ts +200 -0
- package/src/ai/host/fake.ts +223 -0
- package/src/ai/host/from-contract.ts +85 -0
- package/src/ai/host/index.ts +32 -0
- package/src/ai/host/registry.ts +221 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +320 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/tool.ts +89 -0
- package/src/ai/react/AiChat.tsx +194 -0
- package/src/ai/react/AiSettings.tsx +222 -0
- package/src/ai/react/ai.css +152 -0
- package/src/ai/react/index.tsx +35 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +309 -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 +138 -0
- package/src/ai/shared/index.ts +16 -0
- package/src/ai/shared/types.check.ts +43 -0
- package/src/ai/shared/types.ts +87 -0
- package/src/host/app.ts +94 -30
- package/src/host/index.ts +5 -0
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +56 -2
- package/src/shared/errors.ts +10 -0
- package/src/shared/index.ts +8 -2
- package/src/shared/schema.ts +125 -28
|
@@ -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’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
|
+
}
|