@convai/embody 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @convai/embody
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 62deba9: `useWidgetClient` hides the server's `user-transcription` echo of a typed message, so `<ConvaiWidget/>` shows each sent message once instead of twice.
8
+
9
+ ## 0.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 8bfb1ab: Drag to turn the character, scroll to move in and out — on by default.
14
+
15
+ A 3D character you cannot walk around is a picture of one, and
16
+ `<ConvaiCharacter />` exists to be the batteries-included view. Until now
17
+ every consumer that wanted to look at the side of a head had to drop to
18
+ `CharacterModel` and build its own `Canvas`, which is most of what the
19
+ component was there to spare them.
20
+
21
+ `orbit` accepts `false` for a fixed camera, or an object to tune the limits:
22
+ `minDistance`, `maxDistance`, `minPolarAngle`, `maxPolarAngle`, `enablePan`,
23
+ `enableZoom`, `autoRotate`, `autoRotateSpeed`.
24
+
25
+ The defaults are tuned for one subject — a standing human — rather than for a
26
+ general scene viewer. Panning is off, because it only ever slides the
27
+ character off screen, and the vertical sweep stops a few degrees below eye
28
+ level, because orbiting under the floor shows the inside of a head. It uses
29
+ three's own `OrbitControls` rather than `@react-three/drei`, so it costs no
30
+ new dependency: `three` is already a peer and the `dependencies` block stays
31
+ empty.
32
+
33
+ Minor rather than patch because a character that did not move now moves with
34
+ no code change on the consumer's side.
35
+
3
36
  ## 0.3.0
4
37
 
5
38
  ### Minor Changes
@@ -1,4 +1,5 @@
1
1
  import { type CharacterModelProps } from './CharacterModel.js';
2
+ import { type OrbitOptions } from './OrbitRig.js';
2
3
  export interface ConvaiCharacterProps extends CharacterModelProps {
3
4
  /** Wrapper style. Defaults to filling the parent. */
4
5
  style?: React.CSSProperties;
@@ -14,8 +15,16 @@ export interface ConvaiCharacterProps extends CharacterModelProps {
14
15
  fov?: number;
15
16
  target?: [number, number, number];
16
17
  };
18
+ /**
19
+ * Drag to turn the character, scroll to move in and out. On by default: a
20
+ * 3D character you cannot walk around is a picture of one, and this
21
+ * component's whole job is the batteries-included view.
22
+ *
23
+ * Pass `false` for a fixed camera, or an object to tune the limits.
24
+ */
25
+ orbit?: boolean | OrbitOptions;
17
26
  /** Rendered while the body is still loading. */
18
27
  fallback?: React.ReactNode;
19
28
  }
20
- export declare function ConvaiCharacter({ style, className, background, camera, fallback, ...character }: ConvaiCharacterProps): import("react").JSX.Element;
29
+ export declare function ConvaiCharacter({ style, className, background, camera, orbit, fallback, ...character }: ConvaiCharacterProps): import("react").JSX.Element;
21
30
  export default ConvaiCharacter;
@@ -24,9 +24,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  import { Suspense } from 'react';
25
25
  import { Canvas } from '@react-three/fiber';
26
26
  import { CharacterModel } from './CharacterModel.js';
27
+ import { OrbitRig } from './OrbitRig.js';
27
28
  const DEFAULT_POSITION = [0, 1.45, 3.6];
28
29
  const DEFAULT_TARGET = [0, 0.92, 0];
29
- export function ConvaiCharacter({ style, className, background = '#17181c', camera, fallback = null, ...character }) {
30
+ export function ConvaiCharacter({ style, className, background = '#17181c', camera, orbit = true, fallback = null, ...character }) {
30
31
  const position = camera?.position ?? DEFAULT_POSITION;
31
32
  const target = camera?.target ?? DEFAULT_TARGET;
32
33
  return (_jsxs("div", { className: className, style: { width: '100%', height: '100%', minHeight: '100vh', ...style }, children: [_jsxs(Canvas, { camera: { position, fov: camera?.fov ?? 35, near: 0.05, far: 100 },
@@ -34,7 +35,9 @@ export function ConvaiCharacter({ style, className, background = '#17181c', came
34
35
  // existing page content rather than in a box of its own.
35
36
  gl: { antialias: true, alpha: background === null },
36
37
  // Aim at the character's head rather than the origin at its feet;
37
- // r3f points the camera at [0,0,0] otherwise.
38
- onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
38
+ // r3f points the camera at [0,0,0] otherwise. Harmless when orbit is
39
+ // on — OrbitRig takes the same target and drives the camera from
40
+ // there — and load-bearing when it is off.
41
+ onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
39
42
  }
40
43
  export default ConvaiCharacter;
@@ -0,0 +1,30 @@
1
+ export interface OrbitOptions {
2
+ /** Closest the camera may come, in metres. */
3
+ minDistance?: number;
4
+ /** Furthest the camera may retreat, in metres. */
5
+ maxDistance?: number;
6
+ /** Highest the camera may rise, in radians from straight up. */
7
+ minPolarAngle?: number;
8
+ /** Lowest the camera may sink. Past `PI/2` it starts looking up from below. */
9
+ maxPolarAngle?: number;
10
+ /** Drag the subject around the frame. Off by default — it loses the character. */
11
+ enablePan?: boolean;
12
+ /** Scroll to dolly in and out. */
13
+ enableZoom?: boolean;
14
+ /** Turn the character slowly on its own, for an idle showcase. */
15
+ autoRotate?: boolean;
16
+ /** Degrees per second when `autoRotate` is on. */
17
+ autoRotateSpeed?: number;
18
+ }
19
+ export interface OrbitRigProps {
20
+ /** The point orbited around — the character's head, not its feet. */
21
+ target: [number, number, number];
22
+ options?: OrbitOptions;
23
+ }
24
+ /**
25
+ * Renders nothing. Lives inside `<Canvas>` so it can reach the camera and the
26
+ * canvas element, which is also why it cannot simply be set up in `onCreated`
27
+ * — damping needs a per-frame `update()`.
28
+ */
29
+ export declare function OrbitRig({ target, options }: OrbitRigProps): null;
30
+ export default OrbitRig;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Drag-to-orbit for the character view.
3
+ *
4
+ * Uses three's own `OrbitControls` rather than `@react-three/drei` so this
5
+ * costs no new dependency: `three` is already a peer, and embody ships with
6
+ * an empty `dependencies` block that is worth keeping empty.
7
+ *
8
+ * The defaults are tuned for one subject — a standing human — rather than for
9
+ * a general scene viewer. Panning is off and the vertical sweep stops just
10
+ * below eye level, because in a character viewer both of those only ever lose
11
+ * the character: pan slides it off screen, and orbiting under the floor shows
12
+ * the inside of a head.
13
+ */
14
+ import { useFrame, useThree } from '@react-three/fiber';
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
17
+ /**
18
+ * Roughly 20 degrees above the head down to a few degrees below eye level.
19
+ * `PI/2` is dead level with the orbit target, so the small overshoot buys a
20
+ * slight up-angle without ever reaching the floor.
21
+ */
22
+ const DEFAULTS = {
23
+ minDistance: 0.6,
24
+ maxDistance: 8,
25
+ minPolarAngle: Math.PI * 0.12,
26
+ maxPolarAngle: Math.PI * 0.52,
27
+ enablePan: false,
28
+ enableZoom: true,
29
+ autoRotate: false,
30
+ autoRotateSpeed: 1.2,
31
+ };
32
+ /**
33
+ * Renders nothing. Lives inside `<Canvas>` so it can reach the camera and the
34
+ * canvas element, which is also why it cannot simply be set up in `onCreated`
35
+ * — damping needs a per-frame `update()`.
36
+ */
37
+ export function OrbitRig({ target, options }) {
38
+ const camera = useThree((s) => s.camera);
39
+ const domElement = useThree((s) => s.gl.domElement);
40
+ // Created and disposed in the SAME effect, never in useMemo. Disposal
41
+ // removes the listeners OrbitControls put on the canvas — without it a
42
+ // remounted character leaves a live controller behind and two fight over
43
+ // every drag. But a memoised instance disposed in a cleanup is a trap:
44
+ // React StrictMode runs mount → cleanup → mount in dev, and the second
45
+ // mount reused the same, already-disposed instance. The rig then rendered
46
+ // fine and did nothing until something recreated the controls — a resize,
47
+ // an HMR reload — which is exactly "orbit comes and goes". Pairing
48
+ // construction with disposal means the remount gets a live instance.
49
+ const [controls, setControls] = useState(null);
50
+ const controlsRef = useRef(null);
51
+ useEffect(() => {
52
+ const next = new OrbitControls(camera, domElement);
53
+ controlsRef.current = next;
54
+ setControls(next);
55
+ return () => {
56
+ next.dispose();
57
+ if (controlsRef.current === next)
58
+ controlsRef.current = null;
59
+ };
60
+ }, [camera, domElement]);
61
+ const [tx, ty, tz] = target;
62
+ const { minDistance, maxDistance, minPolarAngle, maxPolarAngle, enablePan, enableZoom, autoRotate, autoRotateSpeed, } = { ...DEFAULTS, ...options };
63
+ useEffect(() => {
64
+ if (!controls)
65
+ return;
66
+ controls.target.set(tx, ty, tz);
67
+ controls.minDistance = minDistance;
68
+ controls.maxDistance = maxDistance;
69
+ controls.minPolarAngle = minPolarAngle;
70
+ controls.maxPolarAngle = maxPolarAngle;
71
+ controls.enablePan = enablePan;
72
+ controls.enableZoom = enableZoom;
73
+ controls.autoRotate = autoRotate;
74
+ controls.autoRotateSpeed = autoRotateSpeed;
75
+ // Damping is what makes a drag feel like weight rather than a jump cut.
76
+ controls.enableDamping = true;
77
+ controls.dampingFactor = 0.08;
78
+ controls.update();
79
+ }, [
80
+ controls, tx, ty, tz,
81
+ minDistance, maxDistance, minPolarAngle, maxPolarAngle,
82
+ enablePan, enableZoom, autoRotate, autoRotateSpeed,
83
+ ]);
84
+ useFrame(() => {
85
+ // The ref, not the state: a frame can run between dispose and the
86
+ // re-render that clears the state, and updating a disposed controller
87
+ // is harmless but pointless.
88
+ controlsRef.current?.update();
89
+ });
90
+ return null;
91
+ }
92
+ export default OrbitRig;
@@ -31,6 +31,8 @@ export { ConvaiCharacter, default } from './ConvaiCharacter.js';
31
31
  export type { ConvaiCharacterProps } from './ConvaiCharacter.js';
32
32
  export { CharacterModel } from './CharacterModel.js';
33
33
  export type { CharacterModelProps } from './CharacterModel.js';
34
+ export { OrbitRig } from './OrbitRig.js';
35
+ export type { OrbitOptions, OrbitRigProps } from './OrbitRig.js';
34
36
  export { useWidgetClient } from './useWidgetClient.js';
35
37
  export type { WidgetClient } from './useWidgetClient.js';
36
38
  export { ConvaiWidget, useConvaiClient, AudioRenderer, AudioContext } from '@convai/web-sdk/react';
@@ -28,6 +28,9 @@
28
28
  export { useCharacter } from './useCharacter.js';
29
29
  export { ConvaiCharacter, default } from './ConvaiCharacter.js';
30
30
  export { CharacterModel } from './CharacterModel.js';
31
+ // Exported for apps that build their own <Canvas> around CharacterModel and
32
+ // still want the orbit behaviour ConvaiCharacter gives for free.
33
+ export { OrbitRig } from './OrbitRig.js';
31
34
  export { useWidgetClient } from './useWidgetClient.js';
32
35
  // ── Chat / conversation surface, re-exported from @convai/web-sdk/react ────
33
36
  export { ConvaiWidget, useConvaiClient, AudioRenderer, AudioContext } from '@convai/web-sdk/react';
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convai/embody",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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>",
@@ -79,6 +79,7 @@
79
79
  "@types/react-dom": "^19.2.7",
80
80
  "@types/react-test-renderer": "18.3.1",
81
81
  "@types/three": "^0.182.0",
82
+ "jsdom": "^25.0.1",
82
83
  "react": "^18.3.1",
83
84
  "react-dom": "^18.3.1",
84
85
  "react-test-renderer": "18.3.1",