@live-assistant/react-native 0.2.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 CHANGED
@@ -1,10 +1,24 @@
1
1
  # @live-assistant/react-native
2
2
 
3
- One install for a voice assistant in a React Native or Expo app: the controller,
4
- a Gemini Live connection, microphone and playback, React bindings and a
5
- ready-made widget, re-exported from one place.
3
+ One install for a voice assistant in a React Native, Expo or web app and one
4
+ component to use it:
6
5
 
7
- This page is the whole integration. You should not need another one.
6
+ ```tsx
7
+ import { LiveAssistant } from '@live-assistant/react-native';
8
+
9
+ <LiveAssistant tokenEndpoint="https://api.example.com/assistant/token" />
10
+ ```
11
+
12
+ That is the integration. The Gemini session, the microphone, the player, the tool
13
+ registry, the controller, the provider and the widget are built inside it, the
14
+ same way in every app — so they are not yours to write. The one value it cannot
15
+ invent is the route on **your** server that mints a short-lived token, because
16
+ your API key must never ship in an app bundle.
17
+
18
+ This page is the whole of it: the four steps below, then a table for every value
19
+ you can pass. If you would rather read code than prose,
20
+ [`examples/expo-app`](https://github.com/Recipely-Team/live-assistant/tree/main/examples/expo-app)
21
+ is a working app in one file, and CI builds it on every change.
8
22
 
9
23
  ---
10
24
 
@@ -89,39 +103,215 @@ the tools and the voice when the token is minted, and discards a setup sent by
89
103
  the client — so a tool the token did not declare simply does not exist, with no
90
104
  error. Declare every tool here.
91
105
 
92
- ## 4. Wire up the app
106
+ ## 4. Add the assistant
107
+
108
+ ```tsx
109
+ import { LiveAssistant } from '@live-assistant/react-native';
110
+
111
+ export function App() {
112
+ return (
113
+ <>
114
+ <Navigation />
115
+ <LiveAssistant
116
+ tokenEndpoint="https://api.example.com/assistant/token"
117
+ headers={{ authorization: `Bearer ${userToken}` }}
118
+ />
119
+ </>
120
+ );
121
+ }
122
+ ```
123
+
124
+ Render it once, near the root and beside your navigation, so a session survives
125
+ moving between screens. It builds the controller on its first render and stops
126
+ the session when it unmounts; `headers` and `language` are read again on every
127
+ connection, so a login refreshed mid-conversation is the one used when a long
128
+ session hands over to a new socket.
129
+
130
+ ### What it can already do, with nothing registered
131
+
132
+ On the web the assistant reads the page it is on and acts on it:
133
+
134
+ | It can | Meaning |
135
+ | --- | --- |
136
+ | `read` | what the page says, as text |
137
+ | `list` | what can be followed, pressed or filled, by name |
138
+ | `navigate` | follow a link, by its name or a path |
139
+ | `back` | go back |
140
+ | `press` | click a button or link |
141
+ | `type` | put text in a field |
142
+ | `scroll` | up, down, to the top or the bottom |
143
+
144
+ Nothing declares any of that. It reads the live DOM at the moment of the call, so
145
+ there is no route table to keep in step with your router and nothing to register
146
+ on a new screen. Targets are named the way someone reading the screen aloud would
147
+ name them — the accessible name — which is also why a React Native Web app works
148
+ unchanged: `accessibilityLabel` renders as `aria-label`.
149
+
150
+ Following a link **clicks** it rather than assigning the url, so your
151
+ single-page router stays in charge and the live session is not thrown away by a
152
+ reload. A target that is not on the page is answered with the names that are, so
153
+ the model picks one instead of guessing again.
154
+
155
+ On a phone there is no DOM, so the pack is inert unless you hand it a router:
93
156
 
94
157
  ```tsx
95
- import {
96
- AssistantController,
97
- AssistantProvider,
98
- AssistantWidget,
99
- GeminiLiveSession,
100
- Microphone,
101
- PcmPlayer,
102
- ToolRegistry,
103
- } from '@live-assistant/react-native';
104
-
105
- const tools = new ToolRegistry([
106
- {
107
- definition: {
108
- name: 'createNote',
109
- description: 'Creates a note with the given text',
110
- parameters: {
111
- type: 'object',
112
- properties: { text: { type: 'string' } },
113
- required: ['text'],
158
+ <LiveAssistant
159
+ tokenEndpoint={endpoint}
160
+ page={{ router: { go: (path) => router.push(path), back: () => router.back(), current: () => pathname } }}
161
+ />
162
+ ```
163
+
164
+ ### Your own tools
165
+
166
+ Everything the page cannot do for itself is a tool:
167
+
168
+ ```tsx
169
+ <LiveAssistant
170
+ tokenEndpoint={endpoint}
171
+ tools={[
172
+ {
173
+ definition: {
174
+ name: 'createNote',
175
+ description: 'Creates a note with the given text',
176
+ parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
114
177
  },
178
+ run: async ({ text }) => ({ ok: true, id: await notes.create(String(text)) }),
115
179
  },
116
- run: async ({ text }) => ({ ok: true, id: await notes.create(String(text)) }),
117
- },
118
- ]);
180
+ ]}
181
+ />
182
+ ```
183
+
184
+ Declare the same definitions when you mint the token (step 3): Gemini fixes the
185
+ tool list there, and a tool the token did not declare does not exist — with no
186
+ error saying so.
187
+
188
+ ## 5. Run it
189
+
190
+ ```sh
191
+ npx expo run:ios # or run:android — a development build, not Expo Go
192
+ npx expo start --web # the web half needs no rebuild
193
+ ```
194
+
195
+ Tap the orb. It asks for the microphone before spending a token, so the first run
196
+ shows the permission prompt.
119
197
 
198
+ ---
199
+
200
+ ## Configuration
201
+
202
+ Everything is optional except `tokenEndpoint` (or `getConnection` in its place).
203
+
204
+ ### `<LiveAssistant>`
205
+
206
+ | Prop | Default | What it does |
207
+ | --- | --- | --- |
208
+ | **`tokenEndpoint`** | — | **Required.** Your server's route that mints a token. Called as `POST` with `{ resumptionHandle, languageCode }`; answer `{ token, model, wsUrl? }` |
209
+ | `getConnection` | — | Instead of `tokenEndpoint`, when your app already has its own client. Throw to refuse — the thrown value comes back as `failure.cause` |
210
+ | `headers` | — | Added to the token request; where your `Authorization` goes. Read fresh on every connection |
211
+ | `language` | `'en-US'` | Sent to your endpoint as `languageCode` |
212
+ | `tools` | — | Your app's own tools, on top of the page pack. An array or a `ToolRegistry` |
213
+ | `page` | on where a document exists | Reading and driving the page. `false` removes it; an object narrows it — table below |
214
+ | `timing` | measured | `utteranceGapMs` 1200 · `answerTimeoutMs` 12000 · `silenceTimeoutMs` 90000, `null` never ends a session · `echoTailMs` 250 · `maxHandovers` 3 |
215
+ | `theme` | below | Colours, sizes and your logo |
216
+ | `strings` | English | Every word the widget says |
217
+ | `placement` | `'bottom-right'` | `'bottom-left'`, or `'inline'` to lay it out where you rendered it |
218
+ | `style` | — | Merged last onto the floating stack — safe-area insets go here (`{ bottom: insets.bottom + 16 }`) |
219
+ | `showTranscript` | `true` | Show the conversation in the panel |
220
+ | `showComposer` | `true` | Show the typing box |
221
+ | `renderMessage` | — | Replace a said line; the default bubble arrives as `fallback` |
222
+ | `renderTool` | — | Replace a tool run; return `null` to hide it. The default shows **nothing** for a run that succeeded, on the grounds that the assistant already said what it did |
223
+ | `onReady` | — | Called once with the controller, to start or stop a session from elsewhere (a push notification, a deep link) |
224
+ | `onFailure` | — | Called with each failure, for logging. The widget already tells the user |
225
+
226
+ ### `page`
227
+
228
+ | Field | Default | What it does |
229
+ | --- | --- | --- |
230
+ | `actions` | all seven | Which actions the model may use. A word left out never reaches the model, so it cannot ask for it and be refused |
231
+ | `name` | `'page'` | The tool's name, if `page` collides with one of yours |
232
+ | `root` | the whole document | A CSS selector the tools are confined to |
233
+ | `maxCharacters` | `4000` | Cap on what `read` returns, so a long page cannot crowd out the conversation |
234
+ | `maxTargets` | `40` | Cap on what `list` returns per kind |
235
+ | `router` | — | `{ go, back, current }` — navigation where there is no DOM |
236
+ | `document` / `window` | the globals | For tests, an iframe, or a server render |
237
+
238
+ ### `theme`
239
+
240
+ Pass any part of it; the rest keeps the default.
241
+
242
+ | Colour | Default | Where it shows |
243
+ | --- | --- | --- |
244
+ | `primary` | `#5B5BD6` | The orb at rest, and the controls' accent |
245
+ | `userGlow` | `#3E9BFF` | The ring that follows the user's voice |
246
+ | `assistantGlow` | `#B45BFF` | The glow that follows the assistant's voice |
247
+ | `surface` | `#FFFFFF` | The panel |
248
+ | `text` | `#1C1C28` | Panel text |
249
+ | `mutedText` | `#6B6B80` | The status line and secondary text |
250
+ | `userBubble` | `#5B5BD6` | The user's transcript bubble |
251
+ | `userText` | `#FFFFFF` | Text in it |
252
+ | `assistantBubble` | `#F0F0F7` | The assistant's bubble |
253
+ | `assistantText` | `#1C1C28` | Text in it |
254
+ | `toolChip` | `#E8F5EC` | A tool-run chip |
255
+ | `toolText` | `#1F6B3A` | Text on it |
256
+ | `danger` | `#D93F3F` | Errors, and the end-session control |
257
+ | `onPrimary` | `#FFFFFF` | Anything drawn on `primary` |
258
+
259
+ | Value | Default | What it does |
260
+ | --- | --- | --- |
261
+ | `logo` | — | **Your mark, drawn inside the orb.** Any `<Image source>`: a `require(...)`, a `{ uri }`, an imported asset |
262
+ | `logoSize` | `0.55` | The logo's share of the orb's diameter; `1` would touch the edges |
263
+ | `orbSize` | `64` | The orb's diameter |
264
+ | `radius` | `16` | Corner radius of the panel and its controls |
265
+ | `spacing` | `12` | Padding and the gaps between rows |
266
+ | `fontSize` | `15` | Transcript text size |
267
+ | `panelMaxHeight` | `420` | How tall the panel may grow before the transcript scrolls |
268
+
269
+ ```tsx
270
+ <LiveAssistant
271
+ tokenEndpoint={endpoint}
272
+ theme={{ logo: require('./assets/mark.png'), colors: { primary: '#E4572E', assistantGlow: '#FFB400' }, radius: 8 }}
273
+ />
274
+ ```
275
+
276
+ ### `strings`
277
+
278
+ Every word, in one object — the defaults are English and the library ships no
279
+ other language, because a library that guesses at your voice is one you have to
280
+ argue with.
281
+
282
+ | Key | Default |
283
+ | --- | --- |
284
+ | `start` / `stop` | `Start voice assistant` / `End` |
285
+ | `mute` / `unmute` | `Mute` / `Unmute` |
286
+ | `send` / `composerPlaceholder` | `Send` / `Type a message` |
287
+ | `status.idle` … `status.working` | `Tap to talk`, `Connecting…`, `Listening`, `Thinking…`, `Speaking`, `Working on it…` |
288
+ | `ended.silence` | `Ended after a quiet spell` |
289
+ | `errors.*` | one sentence per failure code — `microphone_denied`, `microphone_unavailable`, `connection_refused`, `connection_lost`, `no_answer` |
290
+ | `genericError` | `Something went wrong` |
291
+ | `toolRunning(name)` / `toolFailed(name)` | `Running {name}…` / `{name} did not work` |
292
+
293
+ ```tsx
294
+ <LiveAssistant
295
+ tokenEndpoint={endpoint}
296
+ strings={{ start: 'Asistanı başlat', stop: 'Bitir', status: { listening: 'Dinliyorum' } }}
297
+ />
298
+ ```
299
+
300
+ ---
301
+
302
+ ## Building it yourself
303
+
304
+ `<LiveAssistant>` is not a wall. It is these three things, and nothing you cannot
305
+ write out when you need to hold them apart — a controller that outlives the tree,
306
+ a widget somewhere other than where the provider is, a session you start from a
307
+ push notification:
308
+
309
+ ```tsx
120
310
  const assistant = new AssistantController({
121
311
  session: new GeminiLiveSession(),
122
312
  microphone: new Microphone(),
123
313
  player: new PcmPlayer(),
124
- tools,
314
+ tools: new ToolRegistry([...]),
125
315
  getConnection: async ({ resumptionHandle }) => {
126
316
  const response = await fetch('https://api.example.com/assistant/token', {
127
317
  method: 'POST',
@@ -143,43 +333,10 @@ export function App() {
143
333
  }
144
334
  ```
145
335
 
146
- Build the controller **once**, outside the component (or in `useState(() => …)`),
147
- and render the widget near the root so it survives navigation.
148
-
149
- ## 5. Run it
150
-
151
- ```sh
152
- npx expo run:ios # or run:android — a development build, not Expo Go
153
- npx expo start --web # the web half needs no rebuild
154
- ```
155
-
156
- Tap the orb. It asks for the microphone before spending a token, so the first run
157
- shows the permission prompt.
158
-
159
- ---
160
-
161
- ## Making it yours
162
-
163
- ```tsx
164
- <AssistantWidget
165
- placement="bottom-left"
166
- theme={{ colors: { primary: '#E4572E', assistantGlow: '#FFB400' }, radius: 8 }}
167
- strings={{ status: { listening: 'Dinliyorum' }, stop: 'Bitir' }}
168
- renderTool={(entry, fallback) =>
169
- entry.status === 'succeeded' ? <ActionChip name={entry.call.name} /> : fallback
170
- }
171
- />
172
- ```
173
-
174
- - **`theme`** — colours, orb size, radius, spacing, font size, panel height.
175
- - **`strings`** — every word, including statuses, errors and end reasons. The
176
- defaults are English.
177
- - **`renderMessage` / `renderTool`** — each transcript row, with the default
178
- rendering handed to you as `fallback`. Note that the default chip shows
179
- **nothing** for a tool run that succeeded, on the grounds that the assistant
180
- already said what it did; override `renderTool` to show every run.
336
+ Build the controller **once**, outside the component or in `useState(() => …)`:
337
+ it owns a socket and two devices.
181
338
 
182
- Drawing your own UI instead? Use the hooks — `useAssistant()`,
339
+ Drawing your own UI instead of the widget? Use the hooks — `useAssistant()`,
183
340
  `useTranscript()`, `useLevelFrames()` — and install `core`, `gemini`, `audio` and
184
341
  `react` directly rather than this package. **It matters for size**: this package
185
342
  re-exports with `export *` from a CommonJS build and Metro does not tree-shake,
package/dist/index.d.ts CHANGED
@@ -9,10 +9,13 @@
9
9
  * should be able to leave out. None of those reasons apply to the ordinary
10
10
  * case — an app that wants the assistant — and making that case install five
11
11
  * packages and keep five versions in step is friction with nothing behind it.
12
- * - **It adds nothing of its own.** Every name here is re-exported unchanged,
13
- * so reading the source of `@live-assistant/core` still explains what you
14
- * are holding, and an app that outgrows this package can depend on the
15
- * pieces directly without changing a single import.
12
+ * - **It adds exactly one thing of its own: `<LiveAssistant>`.** Everything
13
+ * else is re-exported unchanged, so reading the source of
14
+ * `@live-assistant/core` still explains what you are holding, and an app that
15
+ * outgrows this package can depend on the pieces directly without changing an
16
+ * import. The one addition exists because this is the only package that can
17
+ * see the session, the audio and the widget at once — and because the forty
18
+ * lines it replaces were identical in every app that would ever write them.
16
19
  * - **The token server is deliberately absent.** It mints credentials with an
17
20
  * API key, which belongs on a server and never in an app bundle. It is
18
21
  * installed on its own, where it runs.
@@ -22,4 +25,6 @@ export * from '@live-assistant/gemini';
22
25
  export * from '@live-assistant/audio';
23
26
  export * from '@live-assistant/react';
24
27
  export * from '@live-assistant/widget';
28
+ export { LiveAssistant } from './live-assistant';
29
+ export type { LiveAssistantProps } from './live-assistant';
25
30
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.LiveAssistant = void 0;
17
18
  /**
18
19
  * Everything an app on a phone needs, from one install.
19
20
  *
@@ -25,10 +26,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
25
26
  * should be able to leave out. None of those reasons apply to the ordinary
26
27
  * case — an app that wants the assistant — and making that case install five
27
28
  * packages and keep five versions in step is friction with nothing behind it.
28
- * - **It adds nothing of its own.** Every name here is re-exported unchanged,
29
- * so reading the source of `@live-assistant/core` still explains what you
30
- * are holding, and an app that outgrows this package can depend on the
31
- * pieces directly without changing a single import.
29
+ * - **It adds exactly one thing of its own: `<LiveAssistant>`.** Everything
30
+ * else is re-exported unchanged, so reading the source of
31
+ * `@live-assistant/core` still explains what you are holding, and an app that
32
+ * outgrows this package can depend on the pieces directly without changing an
33
+ * import. The one addition exists because this is the only package that can
34
+ * see the session, the audio and the widget at once — and because the forty
35
+ * lines it replaces were identical in every app that would ever write them.
32
36
  * - **The token server is deliberately absent.** It mints credentials with an
33
37
  * API key, which belongs on a server and never in an app bundle. It is
34
38
  * installed on its own, where it runs.
@@ -38,4 +42,6 @@ __exportStar(require("@live-assistant/gemini"), exports);
38
42
  __exportStar(require("@live-assistant/audio"), exports);
39
43
  __exportStar(require("@live-assistant/react"), exports);
40
44
  __exportStar(require("@live-assistant/widget"), exports);
45
+ var live_assistant_1 = require("./live-assistant");
46
+ Object.defineProperty(exports, "LiveAssistant", { enumerable: true, get: function () { return live_assistant_1.LiveAssistant; } });
41
47
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,uDAAqC;AACrC,yDAAuC;AACvC,wDAAsC;AACtC,wDAAsC;AACtC,yDAAuC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,uDAAqC;AACrC,yDAAuC;AACvC,wDAAsC;AACtC,wDAAsC;AACtC,yDAAuC;AACvC,mDAAiD;AAAxC,+GAAA,aAAa,OAAA"}
@@ -0,0 +1,67 @@
1
+ import { AssistantController, ToolRegistry } from '@live-assistant/core';
2
+ import type { AssistantControllerOptions, AssistantFailure, AssistantTool, PageToolsOptions } from '@live-assistant/core';
3
+ import type { GeminiLiveCredentials } from '@live-assistant/gemini';
4
+ import type { AssistantWidgetProps } from '@live-assistant/widget';
5
+ /** Shared by both ways of connecting; see `LiveAssistantProps`. */
6
+ interface LiveAssistantCommonProps extends AssistantWidgetProps {
7
+ /** Sent to your token endpoint as `languageCode`. Default `'en-US'`. */
8
+ readonly language?: string;
9
+ /** Added to the token request — this is where an app's own `Authorization` goes. Read fresh on every connection. */
10
+ readonly headers?: Readonly<Record<string, string>>;
11
+ /** Your app's own tools, on top of the page pack. */
12
+ readonly tools?: readonly AssistantTool[] | ToolRegistry;
13
+ /** Reading and driving the page. On by default in a browser; `false` removes it. */
14
+ readonly page?: boolean | PageToolsOptions;
15
+ readonly timing?: AssistantControllerOptions<GeminiLiveCredentials>['timing'];
16
+ /** Called with the controller once, for an app that wants to start or stop a session from elsewhere. */
17
+ readonly onReady?: (controller: AssistantController<GeminiLiveCredentials>) => void;
18
+ /** Called whenever a session fails. The widget already tells the user; this is for logging. */
19
+ readonly onFailure?: (failure: AssistantFailure) => void;
20
+ }
21
+ /** The endpoint form: the usual one. */
22
+ interface LiveAssistantEndpointProps extends LiveAssistantCommonProps {
23
+ /** Your server's route that mints a short-lived token. The one value this component cannot invent. */
24
+ readonly tokenEndpoint: string;
25
+ readonly getConnection?: never;
26
+ }
27
+ /** The escape hatch: an app that already has its own client for the same thing. */
28
+ interface LiveAssistantConnectionProps extends LiveAssistantCommonProps {
29
+ readonly getConnection: (request: {
30
+ readonly resumptionHandle?: string;
31
+ }) => Promise<GeminiLiveCredentials>;
32
+ readonly tokenEndpoint?: never;
33
+ }
34
+ export type LiveAssistantProps = LiveAssistantEndpointProps | LiveAssistantConnectionProps;
35
+ /**
36
+ * The whole integration: import it, put it in your tree, and there is a voice
37
+ * assistant on the screen.
38
+ *
39
+ * ```tsx
40
+ * <LiveAssistant tokenEndpoint="https://api.example.com/assistant/token" />
41
+ * ```
42
+ *
43
+ * @remarks
44
+ * - **One required value, because it is the only one we cannot know**: the
45
+ * route on your server that mints a short-lived token. Everything else — the
46
+ * Gemini session, the microphone, the player, the tool registry, the
47
+ * controller, the provider and the widget — is built here, the same way in
48
+ * every app, which is what makes it ours to write rather than yours.
49
+ * - **The controller is built once**, in a `useState` initialiser. It owns a
50
+ * socket and two devices; rebuilding it on a render would start a session per
51
+ * render. The session is stopped when this unmounts.
52
+ * - **`headers` and `language` are read at connection time, not at mount.** An
53
+ * app whose `Authorization` header is refreshed mid-session would otherwise
54
+ * reconnect with the token it had when the component first rendered — which
55
+ * is exactly when a long conversation hands over to a new socket.
56
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
57
+ * as `connection_refused` with the response in `failure.cause`, rather than
58
+ * as a session that fails later for no stated reason.
59
+ * - **It does not own your navigation or your screens.** The page pack reads
60
+ * the live DOM, so on the web it already works; a native app passes its own
61
+ * tools, or `page: { router }`.
62
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
63
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
64
+ */
65
+ export declare function LiveAssistant({ tokenEndpoint, getConnection, language, headers, tools, page, timing, onReady, onFailure, ...widget }: LiveAssistantProps): import("react").JSX.Element;
66
+ export {};
67
+ //# sourceMappingURL=live-assistant.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-assistant.d.ts","sourceRoot":"","sources":["../src/live-assistant.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzE,OAAO,KAAK,EAAE,0BAA0B,EAAE,gBAAgB,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAG1H,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAGpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE,mEAAmE;AACnE,UAAU,wBAAyB,SAAQ,oBAAoB;IAC7D,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,oHAAoH;IACpH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,YAAY,CAAC;IACzD,oFAAoF;IACpF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAC;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,0BAA0B,CAAC,qBAAqB,CAAC,CAAC,QAAQ,CAAC,CAAC;IAC9E,wGAAwG;IACxG,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,EAAE,mBAAmB,CAAC,qBAAqB,CAAC,KAAK,IAAI,CAAC;IACpF,+FAA+F;IAC/F,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAC1D;AAED,wCAAwC;AACxC,UAAU,0BAA2B,SAAQ,wBAAwB;IACnE,sGAAsG;IACtG,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,aAAa,CAAC,EAAE,KAAK,CAAC;CAChC;AAED,mFAAmF;AACnF,UAAU,4BAA6B,SAAQ,wBAAwB;IACrE,QAAQ,CAAC,aAAa,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC5G,QAAQ,CAAC,aAAa,CAAC,EAAE,KAAK,CAAC;CAChC;AAED,MAAM,MAAM,kBAAkB,GAAG,0BAA0B,GAAG,4BAA4B,CAAC;AAK3F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,aAAa,CAAC,EAC5B,aAAa,EACb,aAAa,EACb,QAA2B,EAC3B,OAAO,EACP,KAAK,EACL,IAAI,EACJ,MAAM,EACN,OAAO,EACP,SAAS,EACT,GAAG,MAAM,EACV,EAAE,kBAAkB,+BAuCpB"}
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LiveAssistant = LiveAssistant;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const core_1 = require("@live-assistant/core");
7
+ const audio_1 = require("@live-assistant/audio");
8
+ const gemini_1 = require("@live-assistant/gemini");
9
+ const react_2 = require("@live-assistant/react");
10
+ const widget_1 = require("@live-assistant/widget");
11
+ const DEFAULT_LANGUAGE = 'en-US';
12
+ const JSON_TYPE = 'application/json';
13
+ /**
14
+ * The whole integration: import it, put it in your tree, and there is a voice
15
+ * assistant on the screen.
16
+ *
17
+ * ```tsx
18
+ * <LiveAssistant tokenEndpoint="https://api.example.com/assistant/token" />
19
+ * ```
20
+ *
21
+ * @remarks
22
+ * - **One required value, because it is the only one we cannot know**: the
23
+ * route on your server that mints a short-lived token. Everything else — the
24
+ * Gemini session, the microphone, the player, the tool registry, the
25
+ * controller, the provider and the widget — is built here, the same way in
26
+ * every app, which is what makes it ours to write rather than yours.
27
+ * - **The controller is built once**, in a `useState` initialiser. It owns a
28
+ * socket and two devices; rebuilding it on a render would start a session per
29
+ * render. The session is stopped when this unmounts.
30
+ * - **`headers` and `language` are read at connection time, not at mount.** An
31
+ * app whose `Authorization` header is refreshed mid-session would otherwise
32
+ * reconnect with the token it had when the component first rendered — which
33
+ * is exactly when a long conversation hands over to a new socket.
34
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
35
+ * as `connection_refused` with the response in `failure.cause`, rather than
36
+ * as a session that fails later for no stated reason.
37
+ * - **It does not own your navigation or your screens.** The page pack reads
38
+ * the live DOM, so on the web it already works; a native app passes its own
39
+ * tools, or `page: { router }`.
40
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
41
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
42
+ */
43
+ function LiveAssistant({ tokenEndpoint, getConnection, language = DEFAULT_LANGUAGE, headers, tools, page, timing, onReady, onFailure, ...widget }) {
44
+ // Read at connection time rather than captured at mount: see the doc block.
45
+ const latest = (0, react_1.useRef)({ tokenEndpoint, getConnection, language, headers });
46
+ latest.current = { tokenEndpoint, getConnection, language, headers };
47
+ const [controller] = (0, react_1.useState)(() => new core_1.AssistantController({
48
+ session: new gemini_1.GeminiLiveSession(),
49
+ microphone: new audio_1.Microphone(),
50
+ player: new audio_1.PcmPlayer(),
51
+ tools: tools instanceof core_1.ToolRegistry ? tools : new core_1.ToolRegistry(tools ?? []),
52
+ ...(page === undefined ? {} : { page }),
53
+ ...(timing === undefined ? {} : { timing }),
54
+ getConnection: async ({ resumptionHandle }) => {
55
+ const current = latest.current;
56
+ if (current.getConnection !== undefined)
57
+ return current.getConnection({ resumptionHandle });
58
+ const response = await fetch(current.tokenEndpoint, {
59
+ method: 'POST',
60
+ headers: { 'content-type': JSON_TYPE, ...current.headers },
61
+ body: JSON.stringify({ resumptionHandle, languageCode: current.language }),
62
+ });
63
+ if (!response.ok)
64
+ throw new Error(`the token endpoint answered ${response.status}`);
65
+ return (await response.json());
66
+ },
67
+ }));
68
+ (0, react_1.useEffect)(() => {
69
+ onReady?.(controller);
70
+ return () => void controller.stop();
71
+ }, [controller, onReady]);
72
+ return ((0, jsx_runtime_1.jsxs)(react_2.AssistantProvider, { controller: controller, children: [(0, jsx_runtime_1.jsx)(FailureReporter, { onFailure: onFailure }), (0, jsx_runtime_1.jsx)(widget_1.AssistantWidget, { ...widget })] }));
73
+ }
74
+ const selectError = (state) => state.error;
75
+ /** Reports failures to the app without re-rendering the widget for them. */
76
+ function FailureReporter({ onFailure }) {
77
+ const error = (0, react_2.useAssistantState)(selectError);
78
+ (0, react_1.useEffect)(() => {
79
+ if (error !== null)
80
+ onFailure?.(error);
81
+ }, [error, onFailure]);
82
+ return null;
83
+ }
84
+ //# sourceMappingURL=live-assistant.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-assistant.js","sourceRoot":"","sources":["../src/live-assistant.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-assistant/react-native",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One install for a voice assistant in a React Native or Expo app: the session, a Gemini Live connection, microphone and playback, React bindings and a ready-made widget, all re-exported from one place.",
5
5
  "license": "MIT",
6
6
  "author": "Recep Tayyip Ekşi",
@@ -41,11 +41,11 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@live-assistant/audio": "0.2.0",
45
- "@live-assistant/core": "0.2.0",
46
- "@live-assistant/gemini": "0.2.0",
47
- "@live-assistant/react": "0.2.0",
48
- "@live-assistant/widget": "0.2.0"
44
+ "@live-assistant/audio": "0.3.0",
45
+ "@live-assistant/core": "0.3.0",
46
+ "@live-assistant/gemini": "0.3.0",
47
+ "@live-assistant/react": "0.3.0",
48
+ "@live-assistant/widget": "0.3.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": ">=18",
package/src/index.ts CHANGED
@@ -9,10 +9,13 @@
9
9
  * should be able to leave out. None of those reasons apply to the ordinary
10
10
  * case — an app that wants the assistant — and making that case install five
11
11
  * packages and keep five versions in step is friction with nothing behind it.
12
- * - **It adds nothing of its own.** Every name here is re-exported unchanged,
13
- * so reading the source of `@live-assistant/core` still explains what you
14
- * are holding, and an app that outgrows this package can depend on the
15
- * pieces directly without changing a single import.
12
+ * - **It adds exactly one thing of its own: `<LiveAssistant>`.** Everything
13
+ * else is re-exported unchanged, so reading the source of
14
+ * `@live-assistant/core` still explains what you are holding, and an app that
15
+ * outgrows this package can depend on the pieces directly without changing an
16
+ * import. The one addition exists because this is the only package that can
17
+ * see the session, the audio and the widget at once — and because the forty
18
+ * lines it replaces were identical in every app that would ever write them.
16
19
  * - **The token server is deliberately absent.** It mints credentials with an
17
20
  * API key, which belongs on a server and never in an app bundle. It is
18
21
  * installed on its own, where it runs.
@@ -22,3 +25,5 @@ export * from '@live-assistant/gemini';
22
25
  export * from '@live-assistant/audio';
23
26
  export * from '@live-assistant/react';
24
27
  export * from '@live-assistant/widget';
28
+ export { LiveAssistant } from './live-assistant';
29
+ export type { LiveAssistantProps } from './live-assistant';
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AssistantController, ToolRegistry } from '@live-assistant/core';
3
+ import type { AssistantControllerOptions, AssistantFailure, AssistantTool, PageToolsOptions } from '@live-assistant/core';
4
+ import { Microphone, PcmPlayer } from '@live-assistant/audio';
5
+ import { GeminiLiveSession } from '@live-assistant/gemini';
6
+ import type { GeminiLiveCredentials } from '@live-assistant/gemini';
7
+ import { AssistantProvider, useAssistantState } from '@live-assistant/react';
8
+ import { AssistantWidget } from '@live-assistant/widget';
9
+ import type { AssistantWidgetProps } from '@live-assistant/widget';
10
+
11
+ /** Shared by both ways of connecting; see `LiveAssistantProps`. */
12
+ interface LiveAssistantCommonProps extends AssistantWidgetProps {
13
+ /** Sent to your token endpoint as `languageCode`. Default `'en-US'`. */
14
+ readonly language?: string;
15
+ /** Added to the token request — this is where an app's own `Authorization` goes. Read fresh on every connection. */
16
+ readonly headers?: Readonly<Record<string, string>>;
17
+ /** Your app's own tools, on top of the page pack. */
18
+ readonly tools?: readonly AssistantTool[] | ToolRegistry;
19
+ /** Reading and driving the page. On by default in a browser; `false` removes it. */
20
+ readonly page?: boolean | PageToolsOptions;
21
+ readonly timing?: AssistantControllerOptions<GeminiLiveCredentials>['timing'];
22
+ /** Called with the controller once, for an app that wants to start or stop a session from elsewhere. */
23
+ readonly onReady?: (controller: AssistantController<GeminiLiveCredentials>) => void;
24
+ /** Called whenever a session fails. The widget already tells the user; this is for logging. */
25
+ readonly onFailure?: (failure: AssistantFailure) => void;
26
+ }
27
+
28
+ /** The endpoint form: the usual one. */
29
+ interface LiveAssistantEndpointProps extends LiveAssistantCommonProps {
30
+ /** Your server's route that mints a short-lived token. The one value this component cannot invent. */
31
+ readonly tokenEndpoint: string;
32
+ readonly getConnection?: never;
33
+ }
34
+
35
+ /** The escape hatch: an app that already has its own client for the same thing. */
36
+ interface LiveAssistantConnectionProps extends LiveAssistantCommonProps {
37
+ readonly getConnection: (request: { readonly resumptionHandle?: string }) => Promise<GeminiLiveCredentials>;
38
+ readonly tokenEndpoint?: never;
39
+ }
40
+
41
+ export type LiveAssistantProps = LiveAssistantEndpointProps | LiveAssistantConnectionProps;
42
+
43
+ const DEFAULT_LANGUAGE = 'en-US';
44
+ const JSON_TYPE = 'application/json';
45
+
46
+ /**
47
+ * The whole integration: import it, put it in your tree, and there is a voice
48
+ * assistant on the screen.
49
+ *
50
+ * ```tsx
51
+ * <LiveAssistant tokenEndpoint="https://api.example.com/assistant/token" />
52
+ * ```
53
+ *
54
+ * @remarks
55
+ * - **One required value, because it is the only one we cannot know**: the
56
+ * route on your server that mints a short-lived token. Everything else — the
57
+ * Gemini session, the microphone, the player, the tool registry, the
58
+ * controller, the provider and the widget — is built here, the same way in
59
+ * every app, which is what makes it ours to write rather than yours.
60
+ * - **The controller is built once**, in a `useState` initialiser. It owns a
61
+ * socket and two devices; rebuilding it on a render would start a session per
62
+ * render. The session is stopped when this unmounts.
63
+ * - **`headers` and `language` are read at connection time, not at mount.** An
64
+ * app whose `Authorization` header is refreshed mid-session would otherwise
65
+ * reconnect with the token it had when the component first rendered — which
66
+ * is exactly when a long conversation hands over to a new socket.
67
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
68
+ * as `connection_refused` with the response in `failure.cause`, rather than
69
+ * as a session that fails later for no stated reason.
70
+ * - **It does not own your navigation or your screens.** The page pack reads
71
+ * the live DOM, so on the web it already works; a native app passes its own
72
+ * tools, or `page: { router }`.
73
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
74
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
75
+ */
76
+ export function LiveAssistant({
77
+ tokenEndpoint,
78
+ getConnection,
79
+ language = DEFAULT_LANGUAGE,
80
+ headers,
81
+ tools,
82
+ page,
83
+ timing,
84
+ onReady,
85
+ onFailure,
86
+ ...widget
87
+ }: LiveAssistantProps) {
88
+ // Read at connection time rather than captured at mount: see the doc block.
89
+ const latest = useRef({ tokenEndpoint, getConnection, language, headers });
90
+ latest.current = { tokenEndpoint, getConnection, language, headers };
91
+
92
+ const [controller] = useState(
93
+ () =>
94
+ new AssistantController<GeminiLiveCredentials>({
95
+ session: new GeminiLiveSession(),
96
+ microphone: new Microphone(),
97
+ player: new PcmPlayer(),
98
+ tools: tools instanceof ToolRegistry ? tools : new ToolRegistry(tools ?? []),
99
+ ...(page === undefined ? {} : { page }),
100
+ ...(timing === undefined ? {} : { timing }),
101
+ getConnection: async ({ resumptionHandle }) => {
102
+ const current = latest.current;
103
+ if (current.getConnection !== undefined) return current.getConnection({ resumptionHandle });
104
+ const response = await fetch(current.tokenEndpoint as string, {
105
+ method: 'POST',
106
+ headers: { 'content-type': JSON_TYPE, ...current.headers },
107
+ body: JSON.stringify({ resumptionHandle, languageCode: current.language }),
108
+ });
109
+ if (!response.ok) throw new Error(`the token endpoint answered ${response.status}`);
110
+ return (await response.json()) as GeminiLiveCredentials;
111
+ },
112
+ }),
113
+ );
114
+
115
+ useEffect(() => {
116
+ onReady?.(controller);
117
+ return () => void controller.stop();
118
+ }, [controller, onReady]);
119
+
120
+ return (
121
+ <AssistantProvider controller={controller}>
122
+ <FailureReporter onFailure={onFailure} />
123
+ <AssistantWidget {...widget} />
124
+ </AssistantProvider>
125
+ );
126
+ }
127
+
128
+ const selectError = (state: { readonly error: AssistantFailure | null }): AssistantFailure | null => state.error;
129
+
130
+ /** Reports failures to the app without re-rendering the widget for them. */
131
+ function FailureReporter({ onFailure }: { readonly onFailure?: (failure: AssistantFailure) => void }) {
132
+ const error = useAssistantState(selectError);
133
+ useEffect(() => {
134
+ if (error !== null) onFailure?.(error);
135
+ }, [error, onFailure]);
136
+ return null;
137
+ }