Skip to content

Commit 4b37421

Browse files
author
shijiashuai
committed
feat(avatar): 引入统一人物表现协议并重构相关模块
新增 avatarPresentation.ts 作为统一表现协议层,集中定义表现状态与校验规则 重构 digitalHumanStore 复用协议类型,修正 setBehavior 不再意外覆盖动画 调整 DigitalHumanEngine 将表现入口收口至统一命令提交逻辑 更新 dialogueOrchestrator 与 audioService 优先通过引擎驱动表现状态 更新相关测试桩以覆盖新协议字段与完整行为集合
1 parent bbc80bb commit 4b37421

File tree

12 files changed

+663
-248
lines changed

12 files changed

+663
-248
lines changed

.claude/settings.local.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(gh repo:*)",
5+
"Bash(gh api:*)",
6+
"Bash(python -c \"import json,re; p='/home/shane/.claude/projects/-home-shane-lessup-meta-human/c37b280f-e7b8-4089-954f-88436d3bd09a/tool-results/b0cdlho3r.txt'; data=json.load\\(open\\(p\\)\\); pats=re.compile\\(r'\\(live2d|vrm|avatar|character|portrait|model\\)',re.I\\); matches=[x['path'] for x in data['tree'] if pats.search\\(x['path']\\)]; print\\('\\\\n'.join\\(matches[:300]\\)\\)\")",
7+
"Bash(python -c \"import json,re; p='/home/shane/.claude/projects/-home-shane-lessup-meta-human/c37b280f-e7b8-4089-954f-88436d3bd09a/tool-results/b0cdlho3r.txt'; data=json.load\\(open\\(p\\)\\); pats=re.compile\\(r'\\(frontend|web|app|components|widgets|store|state\\)',re.I\\); matches=[x['path'] for x in data['tree'] if pats.search\\(x['path']\\)]; print\\('\\\\n'.join\\(matches[:300]\\)\\)\")"
8+
]
9+
}
10+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Avatar Presentation Protocol 重构
2+
3+
日期:2026-03-13
4+
5+
## 变更内容
6+
7+
- 新增 `src/core/avatar/avatarPresentation.ts` 作为统一人物表现协议层,集中定义 `AvatarPresentation``AvatarCommand`、表现状态、校验集合、行为/姿态/动作映射与动画时长。
8+
- 调整 `src/store/digitalHumanStore.ts`,改为复用统一协议类型与选择器,并修正 `setBehavior()` 不再意外覆盖当前动画。
9+
- 重构 `src/core/avatar/DigitalHumanEngine.ts`,将 emotion / expression / behavior / animation 的兼容入口收口到统一命令提交逻辑,保留既有 API,同时新增 `applyCommand()` 作为协议入口。
10+
- 调整 `src/core/dialogue/dialogueOrchestrator.ts``src/core/audio/audioService.ts`,优先通过 `digitalHumanEngine` 驱动说话、倾听、思考与预设动作,减少对 store 底层字段的直接竞争写入。
11+
- 更新相关测试桩与属性测试,覆盖新的协议依赖字段与更完整的行为集合。
12+
13+
## 背景
14+
15+
原有人物系统中,`emotion``expression``behavior``animation` 的语义边界重叠,页面、对话、语音和引擎都可能直接写 store,导致状态所有权分散、Cyber/VRM 难以共享统一输入模型。本次重构先引入稳定的中间表现协议,为后续 Stage Driver 分层和渲染适配打基础。
16+
17+
## 验证
18+
19+
- 使用 `./node_modules/.bin/tsc --noEmit` 通过类型检查。
20+
- 使用 `./node_modules/.bin/vitest run src/__tests__/store.test.ts src/__tests__/engine.test.ts src/__tests__/properties/engine.property.test.ts src/__tests__/properties/orchestrator.property.test.ts src/__tests__/properties/tts.property.test.ts src/__tests__/properties/asr.property.test.ts --silent`,共 82 项测试全部通过。
21+
- 使用 `./node_modules/.bin/vite build` 构建通过;仍有既存的 chunk size warning,但不阻塞产物生成。
22+
23+
## 备注
24+
25+
- 当前 `npm run build` / `npm run test:run` 在本环境下会因 `spawn pwsh ENOENT` 失败,因此本次改用本地二进制直接执行 `vite``tsc``vitest` 完成验证。

src/__tests__/properties/asr.property.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,24 @@ vi.mock('../../store/digitalHumanStore', () => ({
1414
getState: vi.fn(() => ({
1515
setRecording: vi.fn(),
1616
setBehavior: vi.fn(),
17+
setAnimation: vi.fn(),
18+
setEmotion: vi.fn(),
19+
setExpression: vi.fn(),
20+
setPlaying: vi.fn(),
21+
setExpressionIntensity: vi.fn(),
1722
addError: vi.fn(),
1823
setLoading: vi.fn(),
1924
addChatMessage: vi.fn(),
2025
sessionId: 'test-session',
2126
isMuted: false,
27+
avatarType: 'cyber',
28+
currentAnimation: 'idle',
29+
currentBehavior: 'idle',
30+
currentEmotion: 'neutral',
31+
currentExpression: 'neutral',
32+
expressionIntensity: 0.8,
33+
isRecording: false,
34+
isSpeaking: false,
2235
})),
2336
},
2437
}));
@@ -113,11 +126,24 @@ describe('ASR Service Properties', () => {
113126
(useDigitalHumanStore.getState as any).mockReturnValue({
114127
setRecording: vi.fn((val: boolean) => setRecordingCalls.push(val)),
115128
setBehavior: vi.fn(),
129+
setAnimation: vi.fn(),
130+
setEmotion: vi.fn(),
131+
setExpression: vi.fn(),
132+
setPlaying: vi.fn(),
133+
setExpressionIntensity: vi.fn(),
116134
addError: vi.fn(),
117135
setLoading: vi.fn(),
118136
addChatMessage: vi.fn(),
119137
sessionId: 'test-session',
120138
isMuted: false,
139+
avatarType: 'cyber',
140+
currentAnimation: 'idle',
141+
currentBehavior: 'idle',
142+
currentEmotion: 'neutral',
143+
currentExpression: 'neutral',
144+
expressionIntensity: 0.8,
145+
isRecording: false,
146+
isSpeaking: false,
121147
});
122148

123149
await fc.assert(

src/__tests__/properties/engine.property.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ describe('Digital Human Engine Properties', () => {
240240

241241
const validBehaviors = [
242242
'idle', 'greeting', 'listening', 'thinking', 'speaking', 'excited',
243-
'wave', 'greet', 'think', 'nod', 'shakeHead', 'dance', 'speak', 'waveHand', 'raiseHand'
243+
'wave', 'greet', 'think', 'nod', 'shakeHead', 'dance', 'speak', 'waveHand', 'raiseHand',
244+
'bow', 'clap', 'thumbsUp', 'headTilt', 'shrug', 'lookAround', 'cheer', 'sleep', 'crossArms', 'point'
244245
];
245246

246247
await fc.assert(

src/__tests__/properties/orchestrator.property.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@ vi.mock('../../store/digitalHumanStore', () => ({
2222
setEmotion: vi.fn((val: string) => {
2323
stateChanges.push({ type: 'emotion', value: val, timestamp: Date.now() });
2424
}),
25+
setAnimation: vi.fn(),
2526
currentEmotion: 'neutral',
2627
currentBehavior: 'idle',
28+
currentAnimation: 'idle',
29+
currentExpression: 'neutral',
30+
expressionIntensity: 0.8,
31+
avatarType: 'cyber',
32+
isRecording: false,
2733
isSpeaking: false,
2834
isLoading: false,
2935
})),
@@ -40,6 +46,9 @@ vi.mock('../../core/avatar/DigitalHumanEngine', () => ({
4046
playAnimation: vi.fn((val: string) => {
4147
stateChanges.push({ type: 'engine-animation', value: val, timestamp: Date.now() });
4248
}),
49+
setBehavior: vi.fn((val: string) => {
50+
stateChanges.push({ type: 'engine-behavior', value: val, timestamp: Date.now() });
51+
}),
4352
},
4453
}));
4554

src/__tests__/properties/tts.property.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ vi.mock('../../store/digitalHumanStore', () => ({
1414
getState: vi.fn(() => ({
1515
setSpeaking: vi.fn(),
1616
setBehavior: vi.fn(),
17+
setAnimation: vi.fn(),
18+
setEmotion: vi.fn(),
19+
setExpression: vi.fn(),
20+
setPlaying: vi.fn(),
21+
setExpressionIntensity: vi.fn(),
1722
addError: vi.fn(),
23+
avatarType: 'cyber',
24+
currentAnimation: 'idle',
25+
currentBehavior: 'idle',
26+
currentEmotion: 'neutral',
27+
currentExpression: 'neutral',
28+
expressionIntensity: 0.8,
29+
isRecording: false,
30+
isSpeaking: false,
1831
})),
1932
},
2033
}));
@@ -133,7 +146,20 @@ describe('TTS Service Properties', () => {
133146
(useDigitalHumanStore.getState as any).mockReturnValue({
134147
setSpeaking: mockSetSpeaking,
135148
setBehavior: vi.fn(),
149+
setAnimation: vi.fn(),
150+
setEmotion: vi.fn(),
151+
setExpression: vi.fn(),
152+
setPlaying: vi.fn(),
153+
setExpressionIntensity: vi.fn(),
136154
addError: vi.fn(),
155+
avatarType: 'cyber',
156+
currentAnimation: 'idle',
157+
currentBehavior: 'idle',
158+
currentEmotion: 'neutral',
159+
currentExpression: 'neutral',
160+
expressionIntensity: 0.8,
161+
isRecording: false,
162+
isSpeaking: false,
137163
});
138164

139165
await fc.assert(

src/__tests__/store.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ describe('digitalHumanStore — 行为', () => {
8787
expect(useDigitalHumanStore.getState().currentBehavior).toBe('greeting');
8888
});
8989

90+
it('setBehavior 不强制覆盖当前动画', () => {
91+
const store = useDigitalHumanStore.getState();
92+
store.setAnimation('wave');
93+
store.setBehavior('greeting');
94+
expect(useDigitalHumanStore.getState().currentAnimation).toBe('wave');
95+
});
96+
9097
it('setAnimation 更新动画', () => {
9198
useDigitalHumanStore.getState().setAnimation('wave');
9299
expect(useDigitalHumanStore.getState().currentAnimation).toBe('wave');

src/core/audio/audioService.ts

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useDigitalHumanStore } from '../../store/digitalHumanStore';
2+
import { digitalHumanEngine } from '../avatar/DigitalHumanEngine';
23
import { sendUserInput } from '../dialogue/dialogueService';
34
import { handleDialogueResponse } from '../dialogue/dialogueOrchestrator';
45

@@ -108,7 +109,7 @@ export class TTSService {
108109
this.synth.cancel();
109110
this.isProcessingQueue = false;
110111
useDigitalHumanStore.getState().setSpeaking(false);
111-
useDigitalHumanStore.getState().setBehavior('idle');
112+
digitalHumanEngine.setBehavior('idle');
112113
}
113114

114115
// 语音合成 - 支持队列
@@ -177,22 +178,22 @@ export class TTSService {
177178

178179
utterance.onstart = () => {
179180
useDigitalHumanStore.getState().setSpeaking(true);
180-
useDigitalHumanStore.getState().setBehavior('speaking');
181+
digitalHumanEngine.setBehavior('speaking');
181182
};
182183

183184
utterance.onend = () => {
184185
// 只有队列为空时才重置状态
185186
if (this.speechQueue.length === 0) {
186187
useDigitalHumanStore.getState().setSpeaking(false);
187-
useDigitalHumanStore.getState().setBehavior('idle');
188+
digitalHumanEngine.setBehavior('idle');
188189
}
189190
resolve();
190191
};
191192

192193
utterance.onerror = (event) => {
193194
console.error('语音合成错误:', event);
194195
useDigitalHumanStore.getState().setSpeaking(false);
195-
useDigitalHumanStore.getState().setBehavior('idle');
196+
digitalHumanEngine.setBehavior('idle');
196197
useDigitalHumanStore.getState().addError(`语音合成失败: ${event.error}`);
197198
reject(new Error(event.error));
198199
};
@@ -355,7 +356,7 @@ export class ASRService {
355356

356357
this.recognition.onstart = () => {
357358
this.isRunning = true;
358-
useDigitalHumanStore.getState().setBehavior('listening');
359+
digitalHumanEngine.setBehavior('listening');
359360
this.callbacks.onStart?.();
360361
};
361362

@@ -450,7 +451,7 @@ export class ASRService {
450451
this.clearTimeout();
451452
this.isRunning = false;
452453
useDigitalHumanStore.getState().setRecording(false);
453-
useDigitalHumanStore.getState().setBehavior('idle');
454+
digitalHumanEngine.setBehavior('idle');
454455
}
455456

456457
async start(options?: ASRStartOptions): Promise<boolean> {
@@ -523,7 +524,7 @@ export class ASRService {
523524
this.mode = 'command';
524525
this.isRunning = false;
525526
useDigitalHumanStore.getState().setRecording(false);
526-
useDigitalHumanStore.getState().setBehavior('idle');
527+
digitalHumanEngine.setBehavior('idle');
527528
}
528529

529530
abort(): void {
@@ -604,7 +605,7 @@ export class ASRService {
604605
const store = useDigitalHumanStore.getState();
605606

606607
store.setLoading(true);
607-
store.setBehavior('thinking');
608+
digitalHumanEngine.setBehavior('thinking');
608609
store.addChatMessage('user', text);
609610

610611
try {
@@ -621,7 +622,7 @@ export class ASRService {
621622
} catch (error: unknown) {
622623
console.error('对话服务错误:', error);
623624
store.addError('对话服务暂时不可用,请稍后重试');
624-
store.setBehavior('idle');
625+
digitalHumanEngine.setBehavior('idle');
625626

626627
// 本地降级回复
627628
const fallbackReply = '抱歉,我暂时无法处理您的请求,请稍后再试。';
@@ -636,75 +637,63 @@ export class ASRService {
636637

637638
// 预设动作:打招呼
638639
performGreeting(): void {
639-
const store = useDigitalHumanStore.getState();
640-
store.setEmotion('happy');
641-
store.setExpression('smile');
642-
store.setBehavior('greeting');
643-
store.setAnimation('wave');
640+
digitalHumanEngine.setEmotion('happy');
641+
digitalHumanEngine.setExpression('smile');
642+
digitalHumanEngine.playAnimation('wave');
644643

645644
this.tts.speak('您好!很高兴见到您!有什么可以帮助您的吗?');
646645

647646
setTimeout(() => {
648-
store.setEmotion('neutral');
649-
store.setExpression('neutral');
650-
store.setBehavior('idle');
651-
store.setAnimation('idle');
647+
digitalHumanEngine.setEmotion('neutral');
648+
digitalHumanEngine.setExpression('neutral');
649+
digitalHumanEngine.setBehavior('idle');
652650
}, 4000);
653651
}
654652

655653
// 预设动作:跳舞
656654
performDance(): void {
657-
const store = useDigitalHumanStore.getState();
658-
store.setAnimation('dance');
659-
store.setBehavior('excited');
660-
store.setEmotion('happy');
655+
digitalHumanEngine.setEmotion('happy');
656+
digitalHumanEngine.playAnimation('dance');
661657

662658
this.tts.speak('让我为您跳一支舞!');
663659

664660
setTimeout(() => {
665-
store.setAnimation('idle');
666-
store.setBehavior('idle');
667-
store.setEmotion('neutral');
661+
digitalHumanEngine.setEmotion('neutral');
662+
digitalHumanEngine.setBehavior('idle');
668663
}, 6000);
669664
}
670665

671666
// 预设动作:点头
672667
performNod(): void {
673-
const store = useDigitalHumanStore.getState();
674-
store.setAnimation('nod');
675-
store.setBehavior('listening');
668+
digitalHumanEngine.playAnimation('nod');
676669

677670
this.tts.speak('好的,我明白了。');
678671

679672
setTimeout(() => {
680-
store.setAnimation('idle');
681-
store.setBehavior('idle');
673+
digitalHumanEngine.setBehavior('idle');
682674
}, 2000);
683675
}
684676

685677
// 预设动作:摇头
686678
performShakeHead(): void {
687-
const store = useDigitalHumanStore.getState();
688-
store.setAnimation('shakeHead');
679+
digitalHumanEngine.playAnimation('shakeHead');
689680

690681
this.tts.speak('不太确定呢。');
691682

692683
setTimeout(() => {
693-
store.setAnimation('idle');
684+
digitalHumanEngine.setBehavior('idle');
694685
}, 2000);
695686
}
696687

697688
// 预设动作:思考
698689
performThinking(): void {
699-
const store = useDigitalHumanStore.getState();
700-
store.setBehavior('thinking');
701-
store.setAnimation('think');
690+
digitalHumanEngine.setBehavior('thinking');
691+
digitalHumanEngine.playAnimation('think');
702692

703693
this.tts.speak('让我想想...');
704694

705695
setTimeout(() => {
706-
store.setBehavior('idle');
707-
store.setAnimation('idle');
696+
digitalHumanEngine.setBehavior('idle');
708697
}, 3000);
709698
}
710699
}

0 commit comments

Comments
 (0)