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,247 @@
1
+ /**
2
+ * The AI contract.
3
+ *
4
+ * It is a contract like any application's, with one difference: it owns the
5
+ * reserved `ai` route group, and it is mounted as a *second* host app on the
6
+ * same bridge rather than merged into the application's own contract. That
7
+ * keeps an application's route table free of Broapp's routes and lets the AI
8
+ * layer be absent entirely when it is not enabled.
9
+ *
10
+ * Every bound here is a limit on what a browser may send. They are deliberate:
11
+ * an unbounded `message` or `history` is a way to make the host allocate.
12
+ */
13
+ import { defineContract } from '../../shared/contract.ts';
14
+ import { s } from '../../shared/schema.ts';
15
+
16
+ /** A run identifier, chosen by the browser and echoed on every event. */
17
+ const runId = s.string({ pattern: /[A-Za-z0-9_-]{8,64}/ });
18
+
19
+ const capabilities = s.object({
20
+ tools: s.boolean(),
21
+ vision: s.boolean(),
22
+ structuredOutput: s.boolean(),
23
+ });
24
+
25
+ const model = s.object({
26
+ provider: s.string(),
27
+ modelId: s.string(),
28
+ label: s.string(),
29
+ capabilities,
30
+ });
31
+
32
+ const providerInfo = s.object({
33
+ id: s.string(),
34
+ label: s.string(),
35
+ local: s.boolean(),
36
+ needs: s.object({
37
+ apiKey: s.enum(['required', 'optional', 'none']),
38
+ baseUrl: s.enum(['required', 'optional', 'none']),
39
+ }),
40
+ defaultBaseUrl: s.nullable(s.string()),
41
+ });
42
+
43
+ const settings = s.object({
44
+ provider: s.nullable(s.string()),
45
+ modelId: s.nullable(s.string()),
46
+ baseUrl: s.nullable(s.string()),
47
+ hasKey: s.boolean(),
48
+ keyHint: s.nullable(s.string()),
49
+ remember: s.boolean(),
50
+ configured: s.boolean(),
51
+ });
52
+
53
+ const chatTurn = s.object({
54
+ role: s.enum(['user', 'assistant']),
55
+ content: s.string({ max: 20_000 }),
56
+ });
57
+
58
+ /**
59
+ * One image on a turn. `data` is base64 without the `data:` prefix.
60
+ *
61
+ * The bounds are the browser's contract as much as the host's: the panel
62
+ * downscales before it sends, and four images of two million characters still
63
+ * fit inside one Brobridge frame with room to spare.
64
+ */
65
+ const chatFile = s.object({
66
+ name: s.string({ max: 200 }),
67
+ mediaType: s.string({ pattern: /image\/(png|jpeg|gif|webp)/ }),
68
+ data: s.string({ min: 1, max: 2_000_000 }),
69
+ });
70
+
71
+ /** A conversation identifier. The host chooses it; the browser only echoes it. */
72
+ const threadId = s.string({ pattern: /[A-Za-z0-9_-]{8,64}/ });
73
+
74
+ /** A conversation, without its messages. */
75
+ const thread = s.object({
76
+ id: threadId,
77
+ title: s.string({ max: 120 }),
78
+ /** Null means "whatever Settings says". */
79
+ modelId: s.nullable(s.string({ max: 200 })),
80
+ createdAt: s.number(),
81
+ updatedAt: s.number(),
82
+ messageCount: s.number({ int: true, min: 0 }),
83
+ });
84
+
85
+ /**
86
+ * One stored UI message. Parts are the AI SDK's; the host stores, never
87
+ * interprets.
88
+ *
89
+ * `s.unknown()` is normally forbidden on an input, because the point of an
90
+ * input schema is that browser-supplied data is untrusted. It is right here
91
+ * for the one reason that exempts it: nothing on the host ever reads inside a
92
+ * part. They are written to SQLite as JSON and handed back to the same browser
93
+ * that sent them, so the shape the host would be validating is a shape only
94
+ * the AI SDK understands and only the AI SDK consumes. What is still bounded
95
+ * is the *amount*: 200 parts to a message, 200 messages to a save, and a byte
96
+ * ceiling on the whole save in `threads.ts`.
97
+ */
98
+ const storedMessage = s.object({
99
+ id: s.string({ max: 200 }),
100
+ role: s.enum(['user', 'assistant', 'system']),
101
+ parts: s.array(s.unknown(), { max: 200 }),
102
+ metadata: s.optional(s.unknown()),
103
+ });
104
+
105
+ /**
106
+ * One stream event, flat because the validator has no unions.
107
+ *
108
+ * `input` and `output` are `unknown`: they carry whatever an application's own
109
+ * operation takes and returns, which this layer cannot describe in advance.
110
+ * They are host-controlled on the way out, which is the only place `unknown`
111
+ * is safe.
112
+ */
113
+ const chatEvent = s.object({
114
+ type: s.enum(['text', 'tool-call', 'confirm', 'tool-result', 'usage', 'done', 'error']),
115
+ text: s.optional(s.string()),
116
+ callId: s.optional(s.string()),
117
+ tool: s.optional(s.string()),
118
+ input: s.optional(s.unknown()),
119
+ output: s.optional(s.unknown()),
120
+ denied: s.optional(s.boolean()),
121
+ permission: s.optional(s.enum(['read', 'confirm'])),
122
+ requestId: s.optional(s.string()),
123
+ releaseId: s.optional(s.string()),
124
+ argumentsHash: s.optional(s.string()),
125
+ expiresAt: s.optional(s.number()),
126
+ inputTokens: s.optional(s.number()),
127
+ outputTokens: s.optional(s.number()),
128
+ code: s.optional(s.string()),
129
+ message: s.optional(s.string()),
130
+ });
131
+
132
+ /** Broapp's AI routes. Applications may not declare the `ai` group themselves. */
133
+ export const aiContract = defineContract({
134
+ operations: {
135
+ 'ai.settingsGet': {
136
+ input: s.void(),
137
+ output: settings,
138
+ summary: 'The current AI settings. Never includes the API key itself.',
139
+ },
140
+ 'ai.settingsUpdate': {
141
+ input: s.object({
142
+ provider: s.optional(s.string({ max: 64 })),
143
+ modelId: s.optional(s.string({ max: 200 })),
144
+ baseUrl: s.optional(s.nullable(s.string({ max: 2000 }))),
145
+ // Null clears the stored key; a string replaces it. It goes to the
146
+ // secret store and is never read back out to the browser.
147
+ apiKey: s.optional(s.nullable(s.string({ max: 4000 }))),
148
+ remember: s.optional(s.boolean()),
149
+ }),
150
+ output: settings,
151
+ summary: 'Change one or more settings and return the result.',
152
+ },
153
+ 'ai.providersList': {
154
+ input: s.void(),
155
+ output: s.object({ providers: s.array(providerInfo, { max: 50 }) }),
156
+ summary: 'The providers compiled into this application.',
157
+ },
158
+ 'ai.modelsList': {
159
+ input: s.void(),
160
+ output: s.object({ models: s.array(model, { max: 1000 }) }),
161
+ summary: 'The models the configured provider offers.',
162
+ },
163
+ 'ai.connectionTest': {
164
+ input: s.void(),
165
+ output: s.object({ ok: s.boolean(), message: s.string(), latencyMs: s.number() }),
166
+ summary: 'Try the configured provider once and report what happened.',
167
+ },
168
+ 'ai.chatConfirm': {
169
+ input: s.object({ runId, callId: s.string({ max: 200 }), approve: s.boolean() }),
170
+ output: s.object({ accepted: s.boolean() }),
171
+ summary: 'Answer a confirm event. `accepted` is false when no run is waiting on that call.',
172
+ },
173
+ // Conversations are the user's own data, so every route below answers even
174
+ // when no provider is configured: somebody who has just removed their key
175
+ // is still entitled to read and delete what they wrote.
176
+ 'ai.threadsList': {
177
+ input: s.void(),
178
+ output: s.object({ threads: s.array(thread, { max: 500 }) }),
179
+ summary: 'Every stored conversation, most recently changed first.',
180
+ },
181
+ 'ai.threadsCreate': {
182
+ input: s.object({
183
+ title: s.optional(s.string({ max: 120 })),
184
+ modelId: s.optional(s.nullable(s.string({ max: 200 }))),
185
+ }),
186
+ output: thread,
187
+ summary: 'Start a conversation. Without a title it is named after its first message.',
188
+ },
189
+ 'ai.threadsGet': {
190
+ input: s.object({ id: threadId }),
191
+ output: s.object({ thread, messages: s.array(storedMessage, { max: 200 }) }),
192
+ summary: 'One conversation and its messages.',
193
+ },
194
+ 'ai.threadsSave': {
195
+ input: s.object({
196
+ id: threadId,
197
+ messages: s.array(storedMessage, { max: 200 }),
198
+ title: s.optional(s.string({ max: 120 })),
199
+ }),
200
+ output: thread,
201
+ summary: 'Replace the messages of a conversation, whole.',
202
+ },
203
+ 'ai.threadsUpdate': {
204
+ input: s.object({
205
+ id: threadId,
206
+ title: s.optional(s.string({ max: 120 })),
207
+ // Null puts the conversation back on whatever Settings says.
208
+ modelId: s.optional(s.nullable(s.string({ max: 200 }))),
209
+ }),
210
+ output: thread,
211
+ summary: 'Rename a conversation, or give it a model of its own.',
212
+ },
213
+ 'ai.threadsDelete': {
214
+ input: s.object({ id: threadId }),
215
+ output: s.object({ deleted: s.boolean() }),
216
+ summary: 'Delete one conversation and its messages.',
217
+ },
218
+ 'ai.threadsClear': {
219
+ input: s.void(),
220
+ output: s.object({ deleted: s.number({ int: true, min: 0 }) }),
221
+ summary: 'Delete every conversation.',
222
+ },
223
+ },
224
+ streams: {
225
+ 'ai.chat': {
226
+ params: s.object({
227
+ runId,
228
+ message: s.string({ min: 1, max: 20_000 }),
229
+ refs: s.array(s.string({ max: 200 }), { max: 50 }),
230
+ history: s.array(chatTurn, { max: 100 }),
231
+ // Images travel with the turn they arrive on. History keeps a
232
+ // placeholder instead, because a transcript of base64 would not fit.
233
+ files: s.optional(s.array(chatFile, { max: 4 })),
234
+ // The model for this turn only, and only *within* the configured
235
+ // provider. A provider is never overridden per turn: a different
236
+ // provider means a different key and a different answer to "does this
237
+ // leave my computer", and that stays a Settings decision.
238
+ modelId: s.optional(s.string({ max: 200 })),
239
+ }),
240
+ event: chatEvent,
241
+ summary: 'One chat turn. Emits text, tool calls, confirmations and usage.',
242
+ },
243
+ },
244
+ });
245
+
246
+ /** The AI contract's type, for `HostApp` and client generics. */
247
+ export type AiContract = typeof aiContract;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `broapp/ai` — the AI layer's shared surface.
3
+ *
4
+ * Shared code only: the contract, the types derived from it, and nothing that
5
+ * knows how a provider is reached. The host half is `broapp/ai/host`.
6
+ */
7
+ export { aiContract } from './contract.ts';
8
+ export type { AiContract } from './contract.ts';
9
+ export type {
10
+ AiSettings,
11
+ BroappModel,
12
+ ChatEvent,
13
+ ChatFile,
14
+ ChatTurn,
15
+ ProviderInfo,
16
+ StoredMessage,
17
+ Thread,
18
+ ToolPermission,
19
+ } from './types.ts';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Type-level proof that the contract and the hand-written types agree.
3
+ *
4
+ * Nothing here runs. It exists so that changing a schema in `contract.ts`
5
+ * without changing the matching interface in `types.ts` fails `tsc` instead of
6
+ * failing later, in the browser, as a shape that is almost right.
7
+ */
8
+ import type {
9
+ OperationInput,
10
+ OperationOutput,
11
+ StreamEvent,
12
+ StreamParams,
13
+ } from '../../shared/contract.ts';
14
+ import type { AiContract } from './contract.ts';
15
+ import type {
16
+ AiSettings,
17
+ BroappModel,
18
+ ChatEvent,
19
+ ChatFile,
20
+ ProviderInfo,
21
+ StoredMessage,
22
+ Thread,
23
+ } from './types.ts';
24
+
25
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
26
+ ? true
27
+ : false;
28
+
29
+ const settingsMatch: Equal<OperationOutput<AiContract, 'ai.settingsGet'>, AiSettings> = true;
30
+ void settingsMatch;
31
+
32
+ const settingsUpdateReturnsSettings: Equal<
33
+ OperationOutput<AiContract, 'ai.settingsUpdate'>,
34
+ AiSettings
35
+ > = true;
36
+ void settingsUpdateReturnsSettings;
37
+
38
+ const modelMatch: Equal<
39
+ OperationOutput<AiContract, 'ai.modelsList'>['models'][number],
40
+ BroappModel
41
+ > = true;
42
+ void modelMatch;
43
+
44
+ const providerMatch: Equal<
45
+ OperationOutput<AiContract, 'ai.providersList'>['providers'][number],
46
+ ProviderInfo
47
+ > = true;
48
+ void providerMatch;
49
+
50
+ const chatEventMatch: Equal<StreamEvent<AiContract, 'ai.chat'>, ChatEvent> = true;
51
+ void chatEventMatch;
52
+
53
+ const chatFileMatch: Equal<
54
+ NonNullable<StreamParams<AiContract, 'ai.chat'>['files']>[number],
55
+ ChatFile
56
+ > = true;
57
+ void chatFileMatch;
58
+
59
+ const threadMatch: Equal<OperationOutput<AiContract, 'ai.threadsCreate'>, Thread> = true;
60
+ void threadMatch;
61
+
62
+ const storedMessageMatch: Equal<
63
+ OperationOutput<AiContract, 'ai.threadsGet'>['messages'][number],
64
+ StoredMessage
65
+ > = true;
66
+ void storedMessageMatch;
67
+
68
+ // The update route is the only one that takes a partial: every field optional,
69
+ // so a browser can change one setting without restating the rest.
70
+ const updateAcceptsNothing: OperationInput<AiContract, 'ai.settingsUpdate'> = {};
71
+ void updateAcceptsNothing;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The types the browser sees.
3
+ *
4
+ * Everything here is shared code: it names what the AI layer exchanges over
5
+ * the bridge and nothing about how a provider is reached. No file in this
6
+ * directory may import the AI SDK packages — the browser bundle follows these
7
+ * imports, and the page's CSP forbids it from talking to a provider anyway.
8
+ *
9
+ * The fields are not marked `readonly`. These interfaces must be *identical*
10
+ * to what `Infer` derives from the contract in `contract.ts`, which
11
+ * `types.check.ts` asserts at compile time; a `readonly` here would make the
12
+ * two types merely compatible instead, and the drift check would stop
13
+ * catching drift.
14
+ */
15
+
16
+ /** A model a provider offers, as the browser sees it. */
17
+ export interface BroappModel {
18
+ provider: string;
19
+ modelId: string;
20
+ label: string;
21
+ capabilities: {
22
+ tools: boolean;
23
+ vision: boolean;
24
+ structuredOutput: boolean;
25
+ };
26
+ }
27
+
28
+ /** A provider compiled into this application, as the browser sees it. */
29
+ export interface ProviderInfo {
30
+ id: string;
31
+ label: string;
32
+ /** True when requests stay on this machine with the current settings. */
33
+ local: boolean;
34
+ needs: {
35
+ apiKey: 'required' | 'optional' | 'none';
36
+ baseUrl: 'required' | 'optional' | 'none';
37
+ };
38
+ defaultBaseUrl: string | null;
39
+ }
40
+
41
+ /** What the settings route returns. Never contains the key itself. */
42
+ export interface AiSettings {
43
+ provider: string | null;
44
+ modelId: string | null;
45
+ baseUrl: string | null;
46
+ hasKey: boolean;
47
+ /** Last four characters of the key, for the UI to show which key is set. */
48
+ keyHint: string | null;
49
+ /** False means the key is held in memory only and forgotten on exit. */
50
+ remember: boolean;
51
+ /** True when provider and model are both set and the provider's needs are met. */
52
+ configured: boolean;
53
+ }
54
+
55
+ /** How much ceremony a tool call needs before it runs. */
56
+ export type ToolPermission = 'read' | 'confirm';
57
+
58
+ /** One turn of prior conversation the browser sends back with each message. */
59
+ export interface ChatTurn {
60
+ role: 'user' | 'assistant';
61
+ content: string;
62
+ }
63
+
64
+ /**
65
+ * One image sent with a chat turn.
66
+ *
67
+ * `data` is base64 with no `data:` prefix, at most 2,000,000 characters, and
68
+ * `mediaType` is one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
69
+ * At most four travel with one message, and they travel only with the message
70
+ * they arrive on: a later turn's `history` keeps the line
71
+ * `[image: <name>]` in place of the image itself, because a transcript of
72
+ * base64 would not fit inside the contract's bound on a turn.
73
+ */
74
+ export interface ChatFile {
75
+ name: string;
76
+ mediaType: string;
77
+ data: string;
78
+ }
79
+
80
+ /**
81
+ * A stored conversation, without its messages.
82
+ *
83
+ * `modelId` is null for a conversation that follows Settings, and a model id
84
+ * for one that has been pinned to a model of its own. The provider is never
85
+ * part of a conversation: it is a Settings decision, because changing it
86
+ * changes which key is used and whether anything leaves the computer.
87
+ */
88
+ export interface Thread {
89
+ id: string;
90
+ title: string;
91
+ modelId: string | null;
92
+ createdAt: number;
93
+ updatedAt: number;
94
+ messageCount: number;
95
+ }
96
+
97
+ /**
98
+ * One message as it is stored.
99
+ *
100
+ * `parts` are the AI SDK's own message parts. The host writes them as JSON and
101
+ * hands them back unread — it has no opinion about what a part is, which is
102
+ * why the type is `unknown[]` rather than a copy of the SDK's union that would
103
+ * drift from it. One thing the host *does* change on the way in: a `file` part
104
+ * becomes the text `[image: name]`, because a data URL in SQLite would be a
105
+ * copy of the image nobody asked to keep.
106
+ */
107
+ export interface StoredMessage {
108
+ id: string;
109
+ role: 'user' | 'assistant' | 'system';
110
+ parts: unknown[];
111
+ metadata?: unknown;
112
+ }
113
+
114
+ /**
115
+ * One event on the `ai.chat` stream. Flat on purpose: the `s` validator has
116
+ * no unions, so the discriminant is `type` and the other fields are
117
+ * optional. Which fields are present for which type:
118
+ *
119
+ * text text
120
+ * tool-call callId, tool, input, permission
121
+ * confirm callId, tool, input, requestId, releaseId, argumentsHash,
122
+ * expiresAt (waits for ai.chatConfirm)
123
+ * tool-result callId, tool, output, denied?
124
+ * usage inputTokens, outputTokens
125
+ * done —
126
+ * error code, message
127
+ */
128
+ export interface ChatEvent {
129
+ type: 'text' | 'tool-call' | 'confirm' | 'tool-result' | 'usage' | 'done' | 'error';
130
+ text?: string;
131
+ callId?: string;
132
+ tool?: string;
133
+ input?: unknown;
134
+ output?: unknown;
135
+ denied?: boolean;
136
+ permission?: ToolPermission;
137
+ /** On `confirm`: what the gate is waiting on, so an answer can name it. */
138
+ requestId?: string;
139
+ releaseId?: string;
140
+ argumentsHash?: string;
141
+ /** On `confirm`: when the question stops waiting, so the card can count down. */
142
+ expiresAt?: number;
143
+ inputTokens?: number;
144
+ outputTokens?: number;
145
+ code?: string;
146
+ message?: string;
147
+ }