@convai/embody 0.4.0 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @convai/embody
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 532c49e: Swapping the body under a fixed character id now reaches the screen.
8
+
9
+ `useCharacter` rebuilt the character only when the character id, credentials
10
+ or base url changed. The **resolver** was not among them, and the effect read
11
+ its options from a ref — so an authoring tool that holds one avatar id and
12
+ swaps the block underneath (preview release 3, then release 4) got nothing:
13
+ the old GLB stayed on the canvas and it read as a stubborn cache. Nothing was
14
+ cached. The loader was never asked for the new url.
15
+
16
+ The resolver decides WHICH body a character id resolves to, so it belongs in
17
+ the rebuild set, and now is.
18
+
19
+ This makes the resolver's identity part of the contract: **memoize it**, and
20
+ change it exactly when the body should change. A resolver rebuilt inline on
21
+ every render would rebuild the character on every render. The README's new
22
+ "Swapping the body under a fixed character id" section shows the shape.
23
+
24
+ Also documented, unchanged in behaviour: the `orbit` prop (shipped in 0.4.0
25
+ and previously absent from the README), which backend each npm tag talks to,
26
+ and the correct release-branch names — the table named three branches that do
27
+ not exist.
28
+
29
+ ## 0.4.1
30
+
31
+ ### Patch Changes
32
+
33
+ - 62deba9: `useWidgetClient` hides the server's `user-transcription` echo of a typed message, so `<ConvaiWidget/>` shows each sent message once instead of twice.
34
+
3
35
  ## 0.4.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Give a Convai character a body.**
4
4
 
5
- [![npm](https://img.shields.io/npm/v/@convai/embody/stg?label=npm%20stg&color=blue)](https://www.npmjs.com/package/@convai/embody)
5
+ [![npm](https://img.shields.io/npm/v/@convai/embody/latest?label=npm&color=blue)](https://www.npmjs.com/package/@convai/embody)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-3178C6)](https://www.typescriptlang.org/)
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE)
8
8
 
@@ -12,13 +12,21 @@ lipsync, gaze, breathing and blink. Layered on
12
12
 
13
13
  ---
14
14
 
15
- > **Branch: `stg`** — this branch defaults to the staging backend, which has
16
- > its own character database. Use `main` or `prod` for production.
15
+ > **Branch: `main`** — this branch defaults to the production backend and
16
+ > publishes the `latest` tag. `stg` targets staging, which has its own
17
+ > character database.
17
18
  >
18
19
  > **Status: early release.** The front door — a credential plus a
19
20
  > `characterId` — is settled; what is still moving is listed in
20
21
  > [Roadmap](#roadmap).
21
22
 
23
+ > **Which backend a build talks to is fixed at publish time.** The `latest`
24
+ > and `beta` tags point at `https://api.convai.com`; the `stg` tag points at
25
+ > `https://api-stg.convai.com`. Character ids are per-environment and not
26
+ > interchangeable — a staging id against production returns *"Character not
27
+ > found or doesn't belong to user"*, which reads like a permissions problem
28
+ > and is a wrong-host one. Override with `baseUrl` only if you know why.
29
+
22
30
  ## Install
23
31
 
24
32
  ```bash
@@ -58,6 +66,32 @@ For anything user-facing, mint a short-lived auth token server-side and pass
58
66
  <ConvaiCharacter token={token} characterId={characterId} />
59
67
  ```
60
68
 
69
+ ### Moving the camera
70
+
71
+ Drag to turn the character, scroll to move in and out. On by default — a 3D
72
+ character you cannot walk around is a picture of one.
73
+
74
+ ```tsx
75
+ <ConvaiCharacter apiKey={apiKey} characterId={characterId} orbit={false} />
76
+ ```
77
+
78
+ Pass an object to tune the limits instead of turning it off:
79
+
80
+ ```tsx
81
+ <ConvaiCharacter
82
+ apiKey={apiKey}
83
+ characterId={characterId}
84
+ orbit={{ maxDistance: 6, autoRotate: true, autoRotateSpeed: 0.8 }}
85
+ />
86
+ ```
87
+
88
+ `minDistance`, `maxDistance`, `minPolarAngle`, `maxPolarAngle`, `enablePan`,
89
+ `enableZoom`, `autoRotate`, `autoRotateSpeed`. The defaults are tuned for one
90
+ subject — a standing human — not for a general scene viewer: panning is off
91
+ because it only ever slides the character out of frame, and the vertical sweep
92
+ stops just below eye level because orbiting under the floor shows the inside
93
+ of a head.
94
+
61
95
  ### In a scene you already have
62
96
 
63
97
  `ConvaiCharacter` brings its own canvas. When the character should join a
@@ -174,6 +208,24 @@ The defaults cover the normal case; each of these exists for a specific one.
174
208
  | `baseUrl` | A different backend environment. Defaults per release channel. |
175
209
  | `queue` on `update()` | One client driving several bodies, when you are deciding which owns the current turn. |
176
210
 
211
+ ### Swapping the body under a fixed character id
212
+
213
+ `resolver` is how an authoring tool previews a body that is not published — or
214
+ not even saved. Its **identity is part of the rebuild contract**: memoize it,
215
+ and change it exactly when the body should change.
216
+
217
+ ```tsx
218
+ const resolver = useMemo(
219
+ () => ({ async resolve() { return bundle } }),
220
+ [bundle],
221
+ )
222
+
223
+ <ConvaiCharacter characterId={avatarId} resolver={resolver} connect={false} />
224
+ ```
225
+
226
+ Changing the resolver rebuilds the character; keeping it stable does not. A
227
+ resolver rebuilt inline on every render would reload the GLB on every render.
228
+
177
229
  ## Docs
178
230
 
179
231
  Design, plan and decision history live in [`docs/`](./docs):
@@ -194,9 +246,9 @@ The backend host is a source constant, `DEFAULT_BASE_URL` in
194
246
 
195
247
  | Branch | Host | npm tag |
196
248
  |---|---|---|
197
- | `stable-release` | `https://api.convai.com` | `latest` |
198
- | `beta-release` | `https://api.convai.com` | `beta` |
199
- | `stg-release` | `https://api-stg.convai.com` | `stg` |
249
+ | `main` / `prod` | `https://api.convai.com` | `latest` |
250
+ | `beta` | `https://api.convai.com` | `beta` |
251
+ | `stg` | `https://api-stg.convai.com` | `stg` |
200
252
 
201
253
  Beta points at **production** — it is the first line of testing against real
202
254
  infrastructure, not a staging channel. Only `stg-release` targets staging,
@@ -217,7 +269,7 @@ pnpm check:hosts # auto-detects the current branch
217
269
 
218
270
  ```bash
219
271
  pnpm install
220
- pnpm test # 183 tests
272
+ pnpm test # 220 tests
221
273
  pnpm typecheck
222
274
  pnpm check:tests # every src/ module has a named test — see docs/testing-spec.md
223
275
  pnpm build # emits dist/ with .d.ts
@@ -7,6 +7,12 @@ export interface UseCharacterOptions extends CreateCharacterOptions {
7
7
  */
8
8
  queue?: BlendshapeQueue;
9
9
  }
10
+ /**
11
+ * NOTE ON `resolver` (inherited from CreateCharacterOptions): its identity is
12
+ * part of this hook's dependency set. Memoize it, and change it precisely
13
+ * when the body should change — an inline `{ resolve() {...} }` is a new
14
+ * object every render and would rebuild the character every render.
15
+ */
10
16
  export interface UseCharacterResult {
11
17
  character: CharacterHandle | null;
12
18
  /**
@@ -35,11 +35,23 @@ export function useCharacter(opts) {
35
35
  setError(null);
36
36
  }, (c) => setCharacter(c), (e) => setError(e));
37
37
  return () => lifecycle.cancel();
38
- // Re-create when the identity of the character OR the credentials change
39
- // — a new key/token means a different session, and reusing the old one
40
- // would leave the body talking over a connection the app has revoked.
38
+ // Re-create when the identity of the character, the credentials, OR the
39
+ // resolver change.
40
+ //
41
+ // The resolver belongs here because it decides WHICH BODY this character
42
+ // id resolves to. An authoring tool holds the character id fixed and
43
+ // swaps the block underneath — preview release 3, then release 4 — and
44
+ // without this dependency none of that reached the screen: the effect
45
+ // never re-ran, so the old GLB stayed on the canvas and the change looked
46
+ // like a stubborn cache. Nothing was cached; the loader was simply never
47
+ // asked for the new url.
48
+ //
49
+ // This makes the resolver's IDENTITY part of the contract: pass a stable
50
+ // one (`useMemo`) and change it exactly when the body should change. A
51
+ // resolver rebuilt inline on every render would rebuild the character on
52
+ // every render.
41
53
  // eslint-disable-next-line react-hooks/exhaustive-deps
42
- }, [opts.characterId, opts.apiKey, opts.authToken, opts.token, opts.baseUrl]);
54
+ }, [opts.characterId, opts.apiKey, opts.authToken, opts.token, opts.baseUrl, opts.resolver]);
43
55
  useEffect(() => {
44
56
  if (character)
45
57
  character.camera = camera;
@@ -6,6 +6,31 @@ export type WidgetClient = ConvaiClient & {
6
6
  isVideoEnabled: boolean;
7
7
  isScreenShareActive: boolean;
8
8
  };
9
+ type ChatMessage = ConvaiClient['chatMessages'][number];
10
+ /**
11
+ * How long after a typed message its server echo can still arrive. Measured
12
+ * at ~2 s on staging; the window is generous because a slow turn is still an
13
+ * echo, while the same words said aloud half a minute later are a new message.
14
+ */
15
+ export declare const TYPED_ECHO_WINDOW_MS = 30000;
16
+ /**
17
+ * The transcript without the server's echo of typed messages.
18
+ *
19
+ * `sendUserTextMessage` appends the text locally as `user-llm-text`, and the
20
+ * SDK drops the server's `user-llm-text` echo so the message appears once.
21
+ * But the server ALSO answers typed text with a final `user-transcription`
22
+ * carrying the same words — with no `logicalTurnId` to correlate on — which
23
+ * the SDK stores as a new message and `<ConvaiWidget/>` renders as a second
24
+ * "You" bubble. Every typed message showed twice; every reply once. Verified
25
+ * on web-sdk 1.8.0 and 1.9.0-beta.3, which filter identically.
26
+ *
27
+ * A transcription is an echo when an unmatched typed message with the same
28
+ * trimmed text precedes it within the window; each typed message cancels at
29
+ * most one. Spoken turns never produce a `user-llm-text`, so a real
30
+ * transcription is never dropped. An echo that streams in chunks can show
31
+ * its partial text until the final chunk matches — a flicker, not a duplicate.
32
+ */
33
+ export declare function withoutTypedEchoes(messages: readonly ChatMessage[]): ChatMessage[];
9
34
  /**
10
35
  * ```tsx
11
36
  * const { client } = useCharacter({ apiKey, characterId })
@@ -16,3 +41,4 @@ export type WidgetClient = ConvaiClient & {
16
41
  * Returns `null` until the character's connection exists.
17
42
  */
18
43
  export declare function useWidgetClient(client: ConvaiClient | null): WidgetClient | null;
44
+ export {};
@@ -12,6 +12,47 @@
12
12
  * for activity, and the three control emitters for the toggles.
13
13
  */
14
14
  import { useEffect, useMemo, useState } from 'react';
15
+ /**
16
+ * How long after a typed message its server echo can still arrive. Measured
17
+ * at ~2 s on staging; the window is generous because a slow turn is still an
18
+ * echo, while the same words said aloud half a minute later are a new message.
19
+ */
20
+ export const TYPED_ECHO_WINDOW_MS = 30_000;
21
+ /**
22
+ * The transcript without the server's echo of typed messages.
23
+ *
24
+ * `sendUserTextMessage` appends the text locally as `user-llm-text`, and the
25
+ * SDK drops the server's `user-llm-text` echo so the message appears once.
26
+ * But the server ALSO answers typed text with a final `user-transcription`
27
+ * carrying the same words — with no `logicalTurnId` to correlate on — which
28
+ * the SDK stores as a new message and `<ConvaiWidget/>` renders as a second
29
+ * "You" bubble. Every typed message showed twice; every reply once. Verified
30
+ * on web-sdk 1.8.0 and 1.9.0-beta.3, which filter identically.
31
+ *
32
+ * A transcription is an echo when an unmatched typed message with the same
33
+ * trimmed text precedes it within the window; each typed message cancels at
34
+ * most one. Spoken turns never produce a `user-llm-text`, so a real
35
+ * transcription is never dropped. An echo that streams in chunks can show
36
+ * its partial text until the final chunk matches — a flicker, not a duplicate.
37
+ */
38
+ export function withoutTypedEchoes(messages) {
39
+ const pending = [];
40
+ return messages.filter((m) => {
41
+ const content = m.content.trim();
42
+ const at = Date.parse(m.timestamp);
43
+ if (m.type === 'user-llm-text') {
44
+ pending.push({ content, at });
45
+ return true;
46
+ }
47
+ if (m.type !== 'user-transcription')
48
+ return true;
49
+ const i = pending.findIndex((t) => t.content === content && at - t.at >= 0 && at - t.at <= TYPED_ECHO_WINDOW_MS);
50
+ if (i === -1)
51
+ return true;
52
+ pending.splice(i, 1);
53
+ return false;
54
+ });
55
+ }
15
56
  /** Priority order matches the SDK's: speaking > listening > thinking > connected. */
16
57
  function activityOf(state) {
17
58
  if (state.isSpeaking)
@@ -89,6 +130,9 @@ export function useWidgetClient(client) {
89
130
  };
90
131
  return new Proxy(client, {
91
132
  get(target, prop, receiver) {
133
+ // The widget renders straight from this; see withoutTypedEchoes.
134
+ if (prop === 'chatMessages')
135
+ return withoutTypedEchoes(Reflect.get(target, prop, target));
92
136
  if (typeof prop === 'string' && prop in overrides)
93
137
  return overrides[prop];
94
138
  // Receiver is the TARGET, not the proxy, so getters and methods run
@@ -53,7 +53,7 @@ export interface CharacterResolver {
53
53
  * not pass `baseUrl` to the wrong backend, which is why it is checked rather
54
54
  * than trusted.
55
55
  */
56
- export declare const DEFAULT_BASE_URL = "https://api-stg.convai.com";
56
+ export declare const DEFAULT_BASE_URL = "https://api.convai.com";
57
57
  export interface HttpResolverOptions {
58
58
  /** Base url of the embodiment backend. Defaults to the release branch's host. */
59
59
  baseUrl?: string;
@@ -20,7 +20,7 @@
20
20
  * not pass `baseUrl` to the wrong backend, which is why it is checked rather
21
21
  * than trusted.
22
22
  */
23
- export const DEFAULT_BASE_URL = 'https://api-stg.convai.com';
23
+ export const DEFAULT_BASE_URL = 'https://api.convai.com';
24
24
  /** Shared so `createCharacter` and the resolver cannot drift apart. */
25
25
  export const MISSING_CREDENTIAL = '@convai/embody: a credential is required — pass `token` (recommended for ' +
26
26
  'browsers) or `apiKey`. There is no anonymous access to Convai.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convai/embody",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Give a Convai character a body: resolve 3D characters by characterId and drive them with lipsync and animation. Layered on @convai/web-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Convai <support@convai.com>",