@live-assistant/react-native 0.2.0 → 0.3.1

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,26 @@
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: five steps, then a table for every value you can
19
+ pass. **Building for the web only?** Steps 1 and 2 are the native setup — there,
20
+ the install is the whole of it and nothing needs rebuilding; skip to
21
+ [step 3](#3-mint-tokens-on-your-server). If you would rather read code than prose,
22
+ [`examples/expo-app`](https://github.com/Recipely-Team/live-assistant/tree/main/examples/expo-app)
23
+ is a working app in one file, and CI builds it on every change.
8
24
 
9
25
  ---
10
26
 
@@ -25,6 +41,8 @@ On the web nothing native is needed, but `getUserMedia` only exists in a
25
41
 
26
42
  ## 2. Configure the microphone — one line
27
43
 
44
+ *Native only. A web build needs nothing from this step.*
45
+
28
46
  ```json
29
47
  {
30
48
  "expo": {
@@ -89,39 +107,215 @@ the tools and the voice when the token is minted, and discards a setup sent by
89
107
  the client — so a tool the token did not declare simply does not exist, with no
90
108
  error. Declare every tool here.
91
109
 
92
- ## 4. Wire up the app
110
+ ## 4. Add the assistant
111
+
112
+ ```tsx
113
+ import { LiveAssistant } from '@live-assistant/react-native';
114
+
115
+ export function App() {
116
+ return (
117
+ <>
118
+ <Navigation />
119
+ <LiveAssistant
120
+ tokenEndpoint="https://api.example.com/assistant/token"
121
+ headers={{ authorization: `Bearer ${userToken}` }}
122
+ />
123
+ </>
124
+ );
125
+ }
126
+ ```
127
+
128
+ Render it once, near the root and beside your navigation, so a session survives
129
+ moving between screens. It builds the controller on its first render and stops
130
+ the session when it unmounts; `headers` and `language` are read again on every
131
+ connection, so a login refreshed mid-conversation is the one used when a long
132
+ session hands over to a new socket.
133
+
134
+ ### What it can already do, with nothing registered
135
+
136
+ On the web the assistant reads the page it is on and acts on it:
137
+
138
+ | It can | Meaning |
139
+ | --- | --- |
140
+ | `read` | what the page says, as text |
141
+ | `list` | what can be followed, pressed or filled, by name |
142
+ | `navigate` | follow a link, by its name or a path |
143
+ | `back` | go back |
144
+ | `press` | click a button or link |
145
+ | `type` | put text in a field |
146
+ | `scroll` | up, down, to the top or the bottom |
147
+
148
+ Nothing declares any of that. It reads the live DOM at the moment of the call, so
149
+ there is no route table to keep in step with your router and nothing to register
150
+ on a new screen. Targets are named the way someone reading the screen aloud would
151
+ name them — the accessible name — which is also why a React Native Web app works
152
+ unchanged: `accessibilityLabel` renders as `aria-label`.
153
+
154
+ Following a link **clicks** it rather than assigning the url, so your
155
+ single-page router stays in charge and the live session is not thrown away by a
156
+ reload. A target that is not on the page is answered with the names that are, so
157
+ the model picks one instead of guessing again.
158
+
159
+ On a phone there is no DOM, so the pack is inert unless you hand it a router:
160
+
161
+ ```tsx
162
+ <LiveAssistant
163
+ tokenEndpoint={endpoint}
164
+ page={{ router: { go: (path) => router.push(path), back: () => router.back(), current: () => pathname } }}
165
+ />
166
+ ```
167
+
168
+ ### Your own tools
169
+
170
+ Everything the page cannot do for itself is a tool:
93
171
 
94
172
  ```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'],
173
+ <LiveAssistant
174
+ tokenEndpoint={endpoint}
175
+ tools={[
176
+ {
177
+ definition: {
178
+ name: 'createNote',
179
+ description: 'Creates a note with the given text',
180
+ parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
114
181
  },
182
+ run: async ({ text }) => ({ ok: true, id: await notes.create(String(text)) }),
115
183
  },
116
- run: async ({ text }) => ({ ok: true, id: await notes.create(String(text)) }),
117
- },
118
- ]);
184
+ ]}
185
+ />
186
+ ```
187
+
188
+ Declare the same definitions when you mint the token (step 3): Gemini fixes the
189
+ tool list there, and a tool the token did not declare does not exist — with no
190
+ error saying so.
191
+
192
+ ## 5. Run it
193
+
194
+ ```sh
195
+ npx expo run:ios # or run:android — a development build, not Expo Go
196
+ npx expo start --web # the web half needs no rebuild
197
+ ```
198
+
199
+ Tap the orb. It asks for the microphone before spending a token, so the first run
200
+ shows the permission prompt.
119
201
 
202
+ ---
203
+
204
+ ## Configuration
205
+
206
+ Everything is optional except `tokenEndpoint` (or `getConnection` in its place).
207
+
208
+ ### `<LiveAssistant>`
209
+
210
+ | Prop | Default | What it does |
211
+ | --- | --- | --- |
212
+ | **`tokenEndpoint`** | — | **Required.** Your server's route that mints a token. Called as `POST` with `{ resumptionHandle, languageCode }`; answer `{ token, model, wsUrl? }` |
213
+ | `getConnection` | — | Instead of `tokenEndpoint`, when your app already has its own client. Throw to refuse — the thrown value comes back as `failure.cause` |
214
+ | `headers` | — | Added to the token request; where your `Authorization` goes. Read fresh on every connection |
215
+ | `language` | `'en-US'` | Sent to your endpoint as `languageCode` |
216
+ | `tools` | — | Your app's own tools, on top of the page pack. An array or a `ToolRegistry` |
217
+ | `page` | on where a document exists | Reading and driving the page. `false` removes it; an object narrows it — table below |
218
+ | `timing` | measured | `utteranceGapMs` 1200 · `answerTimeoutMs` 12000 · `silenceTimeoutMs` 90000, `null` never ends a session · `echoTailMs` 250 · `maxHandovers` 3 |
219
+ | `theme` | below | Colours, sizes and your logo |
220
+ | `strings` | English | Every word the widget says |
221
+ | `placement` | `'bottom-right'` | `'bottom-left'`, or `'inline'` to lay it out where you rendered it |
222
+ | `style` | — | Merged last onto the floating stack — safe-area insets go here (`{ bottom: insets.bottom + 16 }`) |
223
+ | `showTranscript` | `true` | Show the conversation in the panel |
224
+ | `showComposer` | `true` | Show the typing box |
225
+ | `renderMessage` | — | Replace a said line; the default bubble arrives as `fallback` |
226
+ | `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 |
227
+ | `onReady` | — | Called once with the controller, to start or stop a session from elsewhere (a push notification, a deep link) |
228
+ | `onFailure` | — | Called with each failure, for logging. The widget already tells the user |
229
+
230
+ ### `page`
231
+
232
+ | Field | Default | What it does |
233
+ | --- | --- | --- |
234
+ | `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 |
235
+ | `name` | `'page'` | The tool's name, if `page` collides with one of yours |
236
+ | `root` | the whole document | A CSS selector the tools are confined to |
237
+ | `maxCharacters` | `4000` | Cap on what `read` returns, so a long page cannot crowd out the conversation |
238
+ | `maxTargets` | `40` | Cap on what `list` returns per kind |
239
+ | `router` | — | `{ go, back, current }` — navigation where there is no DOM |
240
+ | `document` / `window` | the globals | For tests, an iframe, or a server render |
241
+
242
+ ### `theme`
243
+
244
+ Pass any part of it; the rest keeps the default.
245
+
246
+ | Colour | Default | Where it shows |
247
+ | --- | --- | --- |
248
+ | `primary` | `#5B5BD6` | The orb at rest, and the controls' accent |
249
+ | `userGlow` | `#3E9BFF` | The ring that follows the user's voice |
250
+ | `assistantGlow` | `#B45BFF` | The glow that follows the assistant's voice |
251
+ | `surface` | `#FFFFFF` | The panel |
252
+ | `text` | `#1C1C28` | Panel text |
253
+ | `mutedText` | `#6B6B80` | The status line and secondary text |
254
+ | `userBubble` | `#5B5BD6` | The user's transcript bubble |
255
+ | `userText` | `#FFFFFF` | Text in it |
256
+ | `assistantBubble` | `#F0F0F7` | The assistant's bubble |
257
+ | `assistantText` | `#1C1C28` | Text in it |
258
+ | `toolChip` | `#E8F5EC` | A tool-run chip |
259
+ | `toolText` | `#1F6B3A` | Text on it |
260
+ | `danger` | `#D93F3F` | Errors, and the end-session control |
261
+ | `onPrimary` | `#FFFFFF` | Anything drawn on `primary` |
262
+
263
+ | Value | Default | What it does |
264
+ | --- | --- | --- |
265
+ | `logo` | — | **Your mark, drawn inside the orb.** Any `<Image source>`: a `require(...)`, a `{ uri }`, an imported asset |
266
+ | `logoSize` | `0.55` | The logo's share of the orb's diameter; `1` would touch the edges |
267
+ | `orbSize` | `64` | The orb's diameter |
268
+ | `radius` | `16` | Corner radius of the panel and its controls |
269
+ | `spacing` | `12` | Padding and the gaps between rows |
270
+ | `fontSize` | `15` | Transcript text size |
271
+ | `panelMaxHeight` | `420` | How tall the panel may grow before the transcript scrolls |
272
+
273
+ ```tsx
274
+ <LiveAssistant
275
+ tokenEndpoint={endpoint}
276
+ theme={{ logo: require('./assets/mark.png'), colors: { primary: '#E4572E', assistantGlow: '#FFB400' }, radius: 8 }}
277
+ />
278
+ ```
279
+
280
+ ### `strings`
281
+
282
+ Every word, in one object — the defaults are English and the library ships no
283
+ other language, because a library that guesses at your voice is one you have to
284
+ argue with.
285
+
286
+ | Key | Default |
287
+ | --- | --- |
288
+ | `start` / `stop` | `Start voice assistant` / `End` |
289
+ | `mute` / `unmute` | `Mute` / `Unmute` |
290
+ | `send` / `composerPlaceholder` | `Send` / `Type a message` |
291
+ | `status.idle` … `status.working` | `Tap to talk`, `Connecting…`, `Listening`, `Thinking…`, `Speaking`, `Working on it…` |
292
+ | `ended.silence` | `Ended after a quiet spell` |
293
+ | `errors.*` | one sentence per failure code — `microphone_denied`, `microphone_unavailable`, `connection_refused`, `connection_lost`, `no_answer` |
294
+ | `genericError` | `Something went wrong` |
295
+ | `toolRunning(name)` / `toolFailed(name)` | `Running {name}…` / `{name} did not work` |
296
+
297
+ ```tsx
298
+ <LiveAssistant
299
+ tokenEndpoint={endpoint}
300
+ strings={{ start: 'Asistanı başlat', stop: 'Bitir', status: { listening: 'Dinliyorum' } }}
301
+ />
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Building it yourself
307
+
308
+ `<LiveAssistant>` is not a wall. It is these three things, and nothing you cannot
309
+ write out when you need to hold them apart — a controller that outlives the tree,
310
+ a widget somewhere other than where the provider is, a session you start from a
311
+ push notification:
312
+
313
+ ```tsx
120
314
  const assistant = new AssistantController({
121
315
  session: new GeminiLiveSession(),
122
316
  microphone: new Microphone(),
123
317
  player: new PcmPlayer(),
124
- tools,
318
+ tools: new ToolRegistry([...]),
125
319
  getConnection: async ({ resumptionHandle }) => {
126
320
  const response = await fetch('https://api.example.com/assistant/token', {
127
321
  method: 'POST',
@@ -143,43 +337,10 @@ export function App() {
143
337
  }
144
338
  ```
145
339
 
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.
340
+ Build the controller **once**, outside the component or in `useState(() => …)`:
341
+ it owns a socket and two devices.
181
342
 
182
- Drawing your own UI instead? Use the hooks — `useAssistant()`,
343
+ Drawing your own UI instead of the widget? Use the hooks — `useAssistant()`,
183
344
  `useTranscript()`, `useLevelFrames()` — and install `core`, `gemini`, `audio` and
184
345
  `react` directly rather than this package. **It matters for size**: this package
185
346
  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,71 @@
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
+ * - **Handlers are held, not depended on.** `onReady` and `onFailure` are read
53
+ * through a ref, so passing them inline — which is how anyone passes them —
54
+ * cannot re-run the lifecycle effect. It once could, and its cleanup hung up
55
+ * on a live session whenever a parent re-rendered.
56
+ * - **`headers` and `language` are read at connection time, not at mount.** An
57
+ * app whose `Authorization` header is refreshed mid-session would otherwise
58
+ * reconnect with the token it had when the component first rendered — which
59
+ * is exactly when a long conversation hands over to a new socket.
60
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
61
+ * as `connection_refused` with the response in `failure.cause`, rather than
62
+ * as a session that fails later for no stated reason.
63
+ * - **It does not own your navigation or your screens.** The page pack reads
64
+ * the live DOM, so on the web it already works; a native app passes its own
65
+ * tools, or `page: { router }`.
66
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
67
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
68
+ */
69
+ export declare function LiveAssistant({ tokenEndpoint, getConnection, language, headers, tools, page, timing, onReady, onFailure, ...widget }: LiveAssistantProps): import("react").JSX.Element;
70
+ export {};
71
+ //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;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,+BA2CpB"}
@@ -0,0 +1,100 @@
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
+ * - **Handlers are held, not depended on.** `onReady` and `onFailure` are read
31
+ * through a ref, so passing them inline — which is how anyone passes them —
32
+ * cannot re-run the lifecycle effect. It once could, and its cleanup hung up
33
+ * on a live session whenever a parent re-rendered.
34
+ * - **`headers` and `language` are read at connection time, not at mount.** An
35
+ * app whose `Authorization` header is refreshed mid-session would otherwise
36
+ * reconnect with the token it had when the component first rendered — which
37
+ * is exactly when a long conversation hands over to a new socket.
38
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
39
+ * as `connection_refused` with the response in `failure.cause`, rather than
40
+ * as a session that fails later for no stated reason.
41
+ * - **It does not own your navigation or your screens.** The page pack reads
42
+ * the live DOM, so on the web it already works; a native app passes its own
43
+ * tools, or `page: { router }`.
44
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
45
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
46
+ */
47
+ function LiveAssistant({ tokenEndpoint, getConnection, language = DEFAULT_LANGUAGE, headers, tools, page, timing, onReady, onFailure, ...widget }) {
48
+ // Read at connection time rather than captured at mount: see the doc block.
49
+ const latest = (0, react_1.useRef)({ tokenEndpoint, getConnection, language, headers, onReady, onFailure });
50
+ latest.current = { tokenEndpoint, getConnection, language, headers, onReady, onFailure };
51
+ const [controller] = (0, react_1.useState)(() => new core_1.AssistantController({
52
+ session: new gemini_1.GeminiLiveSession(),
53
+ microphone: new audio_1.Microphone(),
54
+ player: new audio_1.PcmPlayer(),
55
+ tools: tools instanceof core_1.ToolRegistry ? tools : new core_1.ToolRegistry(tools ?? []),
56
+ ...(page === undefined ? {} : { page }),
57
+ ...(timing === undefined ? {} : { timing }),
58
+ getConnection: async ({ resumptionHandle }) => {
59
+ const current = latest.current;
60
+ if (current.getConnection !== undefined)
61
+ return current.getConnection({ resumptionHandle });
62
+ const response = await fetch(current.tokenEndpoint, {
63
+ method: 'POST',
64
+ headers: { 'content-type': JSON_TYPE, ...current.headers },
65
+ body: JSON.stringify({ resumptionHandle, languageCode: current.language }),
66
+ });
67
+ if (!response.ok)
68
+ throw new Error(`the token endpoint answered ${response.status}`);
69
+ return (await response.json());
70
+ },
71
+ }));
72
+ // Only the controller is a dependency. `onReady` used to be one too, and an
73
+ // inline arrow — the ordinary way to pass it — is a new function on every
74
+ // render, so the effect re-ran and its cleanup hung up on a live session
75
+ // whenever anything above this re-rendered.
76
+ (0, react_1.useEffect)(() => {
77
+ latest.current.onReady?.(controller);
78
+ return () => void controller.stop();
79
+ }, [controller]);
80
+ return ((0, jsx_runtime_1.jsxs)(react_2.AssistantProvider, { controller: controller, children: [(0, jsx_runtime_1.jsx)(FailureReporter, { onFailure: latest.current.onFailure }), (0, jsx_runtime_1.jsx)(widget_1.AssistantWidget, { ...widget })] }));
81
+ }
82
+ const selectError = (state) => state.error;
83
+ /**
84
+ * Reports failures to the app without re-rendering the widget for them.
85
+ *
86
+ * The handler is held in a ref for the same reason the lifecycle effect takes
87
+ * only the controller: an inline arrow changes identity every render, and a
88
+ * handler in the dependencies would report the same failure again each time.
89
+ */
90
+ function FailureReporter({ onFailure }) {
91
+ const error = (0, react_2.useAssistantState)(selectError);
92
+ const report = (0, react_1.useRef)(onFailure);
93
+ report.current = onFailure;
94
+ (0, react_1.useEffect)(() => {
95
+ if (error !== null)
96
+ report.current?.(error);
97
+ }, [error]);
98
+ return null;
99
+ }
100
+ //# 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.1",
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.1",
45
+ "@live-assistant/core": "0.3.1",
46
+ "@live-assistant/gemini": "0.3.1",
47
+ "@live-assistant/react": "0.3.1",
48
+ "@live-assistant/widget": "0.3.1"
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,153 @@
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
+ * - **Handlers are held, not depended on.** `onReady` and `onFailure` are read
64
+ * through a ref, so passing them inline — which is how anyone passes them —
65
+ * cannot re-run the lifecycle effect. It once could, and its cleanup hung up
66
+ * on a live session whenever a parent re-rendered.
67
+ * - **`headers` and `language` are read at connection time, not at mount.** An
68
+ * app whose `Authorization` header is refreshed mid-session would otherwise
69
+ * reconnect with the token it had when the component first rendered — which
70
+ * is exactly when a long conversation hands over to a new socket.
71
+ * - **A non-2xx from your endpoint is thrown on purpose.** It reaches the app
72
+ * as `connection_refused` with the response in `failure.cause`, rather than
73
+ * as a session that fails later for no stated reason.
74
+ * - **It does not own your navigation or your screens.** The page pack reads
75
+ * the live DOM, so on the web it already works; a native app passes its own
76
+ * tools, or `page: { router }`.
77
+ * - **Outgrowing it costs nothing**: build `AssistantController` yourself and
78
+ * render `AssistantProvider` + `AssistantWidget`, which is all this does.
79
+ */
80
+ export function LiveAssistant({
81
+ tokenEndpoint,
82
+ getConnection,
83
+ language = DEFAULT_LANGUAGE,
84
+ headers,
85
+ tools,
86
+ page,
87
+ timing,
88
+ onReady,
89
+ onFailure,
90
+ ...widget
91
+ }: LiveAssistantProps) {
92
+ // Read at connection time rather than captured at mount: see the doc block.
93
+ const latest = useRef({ tokenEndpoint, getConnection, language, headers, onReady, onFailure });
94
+ latest.current = { tokenEndpoint, getConnection, language, headers, onReady, onFailure };
95
+
96
+ const [controller] = useState(
97
+ () =>
98
+ new AssistantController<GeminiLiveCredentials>({
99
+ session: new GeminiLiveSession(),
100
+ microphone: new Microphone(),
101
+ player: new PcmPlayer(),
102
+ tools: tools instanceof ToolRegistry ? tools : new ToolRegistry(tools ?? []),
103
+ ...(page === undefined ? {} : { page }),
104
+ ...(timing === undefined ? {} : { timing }),
105
+ getConnection: async ({ resumptionHandle }) => {
106
+ const current = latest.current;
107
+ if (current.getConnection !== undefined) return current.getConnection({ resumptionHandle });
108
+ const response = await fetch(current.tokenEndpoint as string, {
109
+ method: 'POST',
110
+ headers: { 'content-type': JSON_TYPE, ...current.headers },
111
+ body: JSON.stringify({ resumptionHandle, languageCode: current.language }),
112
+ });
113
+ if (!response.ok) throw new Error(`the token endpoint answered ${response.status}`);
114
+ return (await response.json()) as GeminiLiveCredentials;
115
+ },
116
+ }),
117
+ );
118
+
119
+ // Only the controller is a dependency. `onReady` used to be one too, and an
120
+ // inline arrow — the ordinary way to pass it — is a new function on every
121
+ // render, so the effect re-ran and its cleanup hung up on a live session
122
+ // whenever anything above this re-rendered.
123
+ useEffect(() => {
124
+ latest.current.onReady?.(controller);
125
+ return () => void controller.stop();
126
+ }, [controller]);
127
+
128
+ return (
129
+ <AssistantProvider controller={controller}>
130
+ <FailureReporter onFailure={latest.current.onFailure} />
131
+ <AssistantWidget {...widget} />
132
+ </AssistantProvider>
133
+ );
134
+ }
135
+
136
+ const selectError = (state: { readonly error: AssistantFailure | null }): AssistantFailure | null => state.error;
137
+
138
+ /**
139
+ * Reports failures to the app without re-rendering the widget for them.
140
+ *
141
+ * The handler is held in a ref for the same reason the lifecycle effect takes
142
+ * only the controller: an inline arrow changes identity every render, and a
143
+ * handler in the dependencies would report the same failure again each time.
144
+ */
145
+ function FailureReporter({ onFailure }: { readonly onFailure?: (failure: AssistantFailure) => void }) {
146
+ const error = useAssistantState(selectError);
147
+ const report = useRef(onFailure);
148
+ report.current = onFailure;
149
+ useEffect(() => {
150
+ if (error !== null) report.current?.(error);
151
+ }, [error]);
152
+ return null;
153
+ }