Skip to content

Commit 3633eb4

Browse files
LessUpCopilot
andcommitted
feat: ship multimodal avatar runtime upgrades
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d1b6089 commit 3633eb4

52 files changed

Lines changed: 3683 additions & 449 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### ✨ Runtime Capabilities
13+
14+
- Added structured dialogue request metadata so language preference, speech configuration, and recent vision context travel together with each chat turn
15+
- Persisted speech preferences and recent vision context in `digitalHumanStore`, and localized the voice interaction panel for multi-language TTS flows
16+
- Added custom avatar upload, replacement, fallback-to-built-in handling, and avatar management controls in the settings drawer
17+
- Added endpoint discovery with primary/backup failover for health checks, standard chat requests, and streaming chat requests
18+
- Added touch/WebXR readiness detection and surfaced an `AR Ready` platform indicator in the HUD
19+
- Added immersive WebXR AR session entry/exit flow, persisted immersive session state, and a viewer bridge that forwards the active XR session to the Three.js renderer
20+
- Added operator-facing endpoint routing diagnostics so the HUD now surfaces the active service endpoint and failover count
21+
- Added a shared avatar source adapter so page composition, settings UI, and controller fallback logic reuse the same source/status/object-URL decisions
22+
1223
### 🧹 Repository Simplification
1324

1425
- Removed repository-scoped AI workflow frameworks and generated automation from `.trellis/`, `.claude/`, and `.opencode/`

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,11 @@ See [CHANGELOG.md](CHANGELOG.md) for released features and [GitHub Projects](htt
354354
- [x] Voice interaction (TTS/ASR)
355355
- [x] Visual perception (MediaPipe)
356356
- [x] Streaming dialogue
357-
- [ ] Mobile AR support
358-
- [ ] Custom avatar upload
359-
- [ ] Multi-language TTS
357+
- [x] Service discovery and endpoint failover
358+
- [x] Mobile AR readiness detection
359+
- [x] Mobile AR support
360+
- [x] Custom avatar upload
361+
- [x] Multi-language TTS
360362

361363
---
362364

README.zh-CN.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,11 @@ npm run test:ui # Vitest UI 模式
354354
- [x] 语音交互(TTS/ASR)
355355
- [x] 视觉感知(MediaPipe)
356356
- [x] 流式对话
357-
- [ ] 移动端 AR 支持
358-
- [ ] 自定义形象上传
359-
- [ ] 多语言 TTS
357+
- [x] 服务发现与端点故障切换
358+
- [x] 移动端 AR readiness 检测
359+
- [x] 移动端 AR 支持
360+
- [x] 自定义形象上传
361+
- [x] 多语言 TTS
360362

361363
---
362364

src/__tests__/AdvancedDigitalHumanPage.test.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import AdvancedDigitalHumanPage from '@/pages/AdvancedDigitalHumanPage';
44
import { useDigitalHumanStore } from '@/store/digitalHumanStore';
55

66
const useChatStreamMock = vi.fn();
7+
const digitalHumanViewerMock = vi.fn();
8+
const topHUDMock = vi.fn();
79

810
vi.mock('@/components/viewer', () => ({
9-
DigitalHumanViewer: () => <div data-testid="viewer" />,
11+
DigitalHumanViewer: (props: unknown) => {
12+
digitalHumanViewerMock(props);
13+
return <div data-testid="viewer" />;
14+
},
1015
}));
1116

1217
vi.mock('@/components/TopHUD', () => ({
13-
default: () => <div data-testid="top-hud" />,
18+
default: (props: unknown) => {
19+
topHUDMock(props);
20+
return <div data-testid="top-hud" />;
21+
},
1422
}));
1523

1624
vi.mock('@/components/SettingsDrawer', () => ({
@@ -32,6 +40,7 @@ vi.mock('@/hooks/useAdvancedDigitalHumanController', () => ({
3240
activeTab: 'basic',
3341
autoRotate: false,
3442
closeSettings: vi.fn(),
43+
handleAvatarUpload: vi.fn(),
3544
handleBehaviorChange: vi.fn(),
3645
handleEmotionChange: vi.fn(),
3746
handleExpressionChange: vi.fn(),
@@ -40,7 +49,9 @@ vi.mock('@/hooks/useAdvancedDigitalHumanController', () => ({
4049
handleNewSession: vi.fn(),
4150
handlePlayPause: vi.fn(),
4251
handleReset: vi.fn(),
52+
handleToggleImmersiveAr: vi.fn(),
4353
handleToggleRecording: vi.fn(),
54+
handleUseBuiltInAvatar: vi.fn(),
4455
handleVoiceCommand: vi.fn(),
4556
setActiveTab: vi.fn(),
4657
showSettings: false,
@@ -61,6 +72,8 @@ vi.mock('@/hooks/useChatStream', () => ({
6172
describe('AdvancedDigitalHumanPage', () => {
6273
beforeEach(() => {
6374
useChatStreamMock.mockReset();
75+
digitalHumanViewerMock.mockReset();
76+
topHUDMock.mockReset();
6477
useChatStreamMock.mockReturnValue({
6578
chatInput: '',
6679
setChatInput: vi.fn(),
@@ -82,4 +95,33 @@ describe('AdvancedDigitalHumanPage', () => {
8295
}),
8396
);
8497
});
98+
99+
it('passes the active custom avatar model url into the viewer', () => {
100+
useDigitalHumanStore.setState({
101+
...(useDigitalHumanStore.getState() as unknown as Record<string, unknown>),
102+
avatarSource: {
103+
kind: 'custom',
104+
fileName: 'avatar.glb',
105+
modelUrl: 'blob:avatar-1',
106+
},
107+
} as unknown as Partial<ReturnType<typeof useDigitalHumanStore.getState>>);
108+
109+
render(<AdvancedDigitalHumanPage />);
110+
111+
expect(digitalHumanViewerMock).toHaveBeenCalledWith(
112+
expect.objectContaining({
113+
modelUrl: 'blob:avatar-1',
114+
}),
115+
);
116+
});
117+
118+
it('passes the immersive ar toggle handler into the HUD', () => {
119+
render(<AdvancedDigitalHumanPage />);
120+
121+
expect(topHUDMock).toHaveBeenCalledWith(
122+
expect.objectContaining({
123+
onToggleImmersiveAr: expect.any(Function),
124+
}),
125+
);
126+
});
85127
});

src/__tests__/App.test.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { render, screen, waitFor } from '@testing-library/react';
2+
import type { ReactNode } from 'react';
23
import { beforeEach, describe, expect, it, vi } from 'vitest';
34
import App from '@/App';
45

5-
const createServicesMock = vi.fn();
6+
const { servicesProviderRenderSpy } = vi.hoisted(() => ({
7+
servicesProviderRenderSpy: vi.fn(),
8+
}));
69

7-
vi.mock('@/core/createServices', () => ({
8-
createServices: () => createServicesMock(),
10+
vi.mock('@/core/services', () => ({
11+
ServicesProvider: ({ children }: { children: ReactNode }) => {
12+
servicesProviderRenderSpy();
13+
return <div data-testid="services-provider">{children}</div>;
14+
},
915
}));
1016

1117
vi.mock('@/pages/LandingPage', () => ({
@@ -16,35 +22,26 @@ vi.mock('@/pages/AdvancedDigitalHumanPage', () => ({
1622
default: () => <div data-testid="advanced-page" />,
1723
}));
1824

19-
function buildServices() {
20-
return {
21-
engine: { dispose: vi.fn() },
22-
tts: { dispose: vi.fn() },
23-
asr: { dispose: vi.fn() },
24-
dialogue: { reset: vi.fn() },
25-
};
26-
}
27-
2825
describe('App routing', () => {
2926
beforeEach(() => {
30-
createServicesMock.mockReset();
31-
createServicesMock.mockReturnValue(buildServices());
27+
servicesProviderRenderSpy.mockReset();
3228
window.location.hash = '#/';
3329
});
3430

35-
it('does not create app services for the landing route', async () => {
31+
it('does not render the app services provider for the landing route', async () => {
3632
render(<App />);
3733

3834
expect(await screen.findByTestId('landing-page')).toBeInTheDocument();
39-
await waitFor(() => expect(createServicesMock).not.toHaveBeenCalled());
35+
await waitFor(() => expect(servicesProviderRenderSpy).not.toHaveBeenCalled());
4036
});
4137

42-
it('creates app services for the app route', async () => {
38+
it('renders the app services provider for the app route', async () => {
4339
window.location.hash = '#/app';
4440

4541
render(<App />);
4642

4743
expect(await screen.findByTestId('advanced-page')).toBeInTheDocument();
48-
await waitFor(() => expect(createServicesMock).toHaveBeenCalledTimes(1));
44+
expect(await screen.findByTestId('services-provider')).toBeInTheDocument();
45+
await waitFor(() => expect(servicesProviderRenderSpy).toHaveBeenCalledTimes(1));
4946
});
5047
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import DigitalHumanViewer from '@/components/viewer/DigitalHumanViewer';
4+
import { useSystemStore } from '@/store/systemStore';
5+
6+
const immersiveSessionBridgeMock = vi.fn();
7+
8+
vi.mock('@react-three/fiber', () => ({
9+
Canvas: ({ children }: { children: React.ReactNode }) => (
10+
<div data-testid="canvas">{children}</div>
11+
),
12+
}));
13+
14+
vi.mock('@react-three/drei', () => ({
15+
Html: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
16+
}));
17+
18+
vi.mock('@/components/viewer/Scene', () => ({
19+
Scene: () => <div data-testid="scene" />,
20+
}));
21+
22+
vi.mock('@/components/viewer/PerformanceTracker', () => ({
23+
PerformanceTracker: () => <div data-testid="performance-tracker" />,
24+
}));
25+
26+
vi.mock('@/components/viewer/ImmersiveSessionBridge', () => ({
27+
ImmersiveSessionBridge: (props: unknown) => {
28+
immersiveSessionBridgeMock(props);
29+
return <div data-testid="immersive-session-bridge" />;
30+
},
31+
}));
32+
33+
describe('DigitalHumanViewer', () => {
34+
beforeEach(() => {
35+
immersiveSessionBridgeMock.mockReset();
36+
useSystemStore.setState({
37+
immersiveMode: 'standard',
38+
immersiveSession: null,
39+
immersiveError: null,
40+
});
41+
});
42+
43+
it('passes the active immersive session into the bridge', () => {
44+
const session = { kind: 'immersive-session' } as unknown as XRSession;
45+
useSystemStore.setState({
46+
immersiveMode: 'ar-active',
47+
immersiveSession: session,
48+
immersiveError: null,
49+
});
50+
51+
render(<DigitalHumanViewer showControls={false} />);
52+
53+
expect(immersiveSessionBridgeMock).toHaveBeenCalledWith(
54+
expect.objectContaining({
55+
session,
56+
}),
57+
);
58+
});
59+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { ImmersiveSessionBridge } from '@/components/viewer/ImmersiveSessionBridge';
4+
5+
const setSessionMock = vi.fn();
6+
const glMock = {
7+
xr: {
8+
enabled: false,
9+
setSession: setSessionMock,
10+
},
11+
};
12+
13+
vi.mock('@react-three/fiber', () => ({
14+
useThree: () => ({
15+
gl: glMock,
16+
}),
17+
}));
18+
19+
describe('ImmersiveSessionBridge', () => {
20+
beforeEach(() => {
21+
glMock.xr.enabled = false;
22+
setSessionMock.mockReset();
23+
setSessionMock.mockResolvedValue(undefined);
24+
});
25+
26+
it('enables xr on the renderer and forwards the active session', () => {
27+
const session = { kind: 'immersive-session' } as unknown as XRSession;
28+
29+
render(<ImmersiveSessionBridge session={session} />);
30+
31+
expect(glMock.xr.enabled).toBe(true);
32+
expect(setSessionMock).toHaveBeenCalledWith(session);
33+
});
34+
35+
it('disables xr when no immersive session is active', () => {
36+
glMock.xr.enabled = true;
37+
38+
render(<ImmersiveSessionBridge session={null} />);
39+
40+
expect(glMock.xr.enabled).toBe(false);
41+
expect(setSessionMock).not.toHaveBeenCalled();
42+
});
43+
});

0 commit comments

Comments
 (0)