@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 +33 -0
- package/dist/react/ConvaiCharacter.d.ts +10 -1
- package/dist/react/ConvaiCharacter.js +6 -3
- package/dist/react/OrbitRig.d.ts +30 -0
- package/dist/react/OrbitRig.js +92 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useWidgetClient.d.ts +26 -0
- package/dist/react/useWidgetClient.js +44 -0
- package/package.json +2 -1
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
|
-
|
|
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;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/react/index.js
CHANGED
|
@@ -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
|
+
"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",
|