INTEGRATION GUIDE
Documentation for Vitto
Vitto Rive Avatar — Integration Guide
Quick Start
1. Install Rive Runtime
yarn add @rive-app/canvas
2. Place the .riv File
Once built by an animator following RIVE-PRODUCTION-SPEC.md, place the file at:
apps/gograb-ai/src/assets/rive/vitto-avatar.riv
3. Use the NX Library (Already Set Up)
The library is available at @libs-anim/vitto-rive-avatar:
libs/libs-anim/vitto-rive-avatar/
├── src/
│ ├── lib/
│ │ ├── components/
│ │ │ └── vitto-rive-avatar/
│ │ ├── services/
│ │ │ └── vitto-mood.service.ts
│ │ └── types/
│ │ └── vitto-mood.types.ts
│ └── index.ts
├── project.json
├── package.json
├── tsconfig.lib.json
└── README.md
Import path: @libs-anim/vitto-rive-avatar (already in tsconfig.base.json paths).
4. Use in Any Component
import { Component, inject } from '@angular/core';
import { VittoRiveAvatarComponent, VittoMoodService, VittoMood } from '@libs-anim/vitto-rive-avatar';
@Component({
selector: 'ai-chat-panel',
standalone: true,
imports: [VittoRiveAvatarComponent],
template: `
<div class="chat-header">
<ai-vitto-rive-avatar
[size]="120"
[enableGazeTracking]="true"
(moodChanged)="onMoodChange($event)"
/>
<span class="mood-label">{{ moodService.currentMoodMeta().description }}</span>
</div>
`,
})
export class ChatPanelComponent {
readonly moodService = inject(VittoMoodService);
onMoodChange(mood: VittoMood): void {
console.log('Vitto mood changed to:', mood);
}
}
Architecture
Signal Flow
ConversationMoodState (NGXS)
│
▼
VittoMoodService.setMood('cheerful') ←── debounced, validated
│
├── _currentMood signal updates
├── currentMoodIndex computed updates
│
▼
VittoRiveAvatarComponent (effect)
│
├── Sets Rive input: moodIndex = 2
├── Sets Rive input: gazeX/gazeY (from mouse)
└── Sets Rive input: isSpeaking (from AI response state)
│
▼
Rive State Machine ("MoodController")
│
├── Transitions to "cheerful" state
├── Plays processing beat (eyes → ears → details)
└── Starts cheerful idle loop
Mood Sources
Mood can be set from multiple sources. The service handles deduplication and debouncing:
// From conversation AI analysis
this.moodService.setMood(analyzedMood);
// From manual UI control (mood picker)
this.moodService.setMoodImmediate('celebratory');
// From AI response state
this.moodService.setSpeaking(true); // while streaming response
this.moodService.setSpeaking(false); // when done
Gaze Tracking
Mouse gaze tracking is automatically handled by the component when enableGazeTracking is true. The flow:
mousemoveevent ondocument(throttled to 60fps)- Mouse position normalized to -1..1 relative to avatar center
VittoMoodService.updateGaze()updates the signal- Component effect syncs to Rive
gazeX/gazeYinputs - Rive state machine offsets pupils accordingly
The gaze influence is mood-dependent (e.g., reduced during focused where scanning overrides, nearly zero during sleepy).
Conversation Integration
Connecting to Backend AI Mood Analysis
The backend AI API (backend-ai-api on port 3001) can analyze conversation context and return mood suggestions. Wire it up:
import { AI_API_URL } from '@libs-foundational/api-config';
@Injectable({ providedIn: 'root' })
export class ConversationMoodAnalyzer {
private readonly aiApiUrl = inject(AI_API_URL);
private readonly http = inject(HttpClient);
private readonly moodService = inject(VittoMoodService);
analyzeMood(messageContent: string): void {
this.http.post<{ mood: VittoMood }>(
`${this.aiApiUrl}/ai/analyze-mood`,
{ content: messageContent }
).subscribe(response => {
this.moodService.setMood(response.mood);
});
}
}
Simple Rule-Based Mood Detection (No Backend)
For immediate mood feedback without waiting for API analysis:
function detectMoodFromKeywords(text: string): VittoMood {
const lower = text.toLowerCase();
if (/error|bug|crash|fail|broken/.test(lower)) return 'urgent';
if (/sorry|apologize|mistake|my bad/.test(lower)) return 'apologetic';
if (/amazing|awesome|perfect|great|love/.test(lower)) return 'celebratory';
if (/think|analyze|consider|evaluate/.test(lower)) return 'analytical';
if (/why|how|what if|wonder/.test(lower)) return 'curious';
if (/help|understand|feel|frustrat/.test(lower)) return 'empathetic';
if (/proud|nailed|accomplished|shipped/.test(lower)) return 'proud';
if (/focus|concentrate|deep dive|debug/.test(lower)) return 'focused';
if (/idea|eureka|realized|insight/.test(lower)) return 'inspired';
if (/confused|unclear|don't understand/.test(lower)) return 'confused';
if (/fun|play|joke|haha/.test(lower)) return 'playful';
if (/happy|glad|yay|excited/.test(lower)) return 'cheerful';
if (/tired|sleepy|break|rest/.test(lower)) return 'sleepy';
return 'neutral';
}
Configuration
Custom .riv File Path
<ai-vitto-rive-avatar
[rivFilePath]="'assets/rive/vitto-v2.riv'"
[size]="160"
/>
Reduced Motion
The component automatically detects prefers-reduced-motion: reduce and sets animation intensity to 0. You can also control it programmatically:
this.moodService.setIntensity(0); // disable all animations
this.moodService.setIntensity(0.5); // half intensity
this.moodService.setIntensity(1); // full animations
Disable Gaze Tracking
<ai-vitto-rive-avatar [enableGazeTracking]="false" />
Rive File Requirements
The .riv file MUST contain:
| Requirement | Value |
|---|---|
| Artboard name | VittoAvatar |
| State machine name | MoodController |
Input: moodIndex |
Number (0-13) |
Input: gazeX |
Number (-1 to 1) |
Input: gazeY |
Number (-1 to 1) |
Input: intensity |
Number (0 to 1) |
Input: isSpeaking |
Boolean |
See RIVE-PRODUCTION-SPEC.md for complete animation specifications.
File Inventory
| File | Purpose |
|---|---|
RIVE-PRODUCTION-SPEC.md |
Complete spec for building the .riv file |
VITTO-ANIMATION-BIBLE.md |
Design philosophy and animation principles |
vitto-rive-prototype.html |
Live SVG prototype for reference |
angular/components/vitto-rive-avatar/ |
Angular standalone component |
angular/services/vitto-mood.service.ts |
Mood management service |
angular/types/vitto-mood.types.ts |
TypeScript type definitions |
angular/index.ts |
Barrel export |
INTEGRATION-GUIDE.md |
This document |
Testing
Unit Tests (Vitest)
describe('VittoMoodService', () => {
it('should default to neutral mood', () => {
const service = TestBed.inject(VittoMoodService);
expect(service.currentMood()).toBe('neutral');
});
it('should update mood index when mood changes', () => {
const service = TestBed.inject(VittoMoodService);
service.setMoodImmediate('cheerful');
expect(service.currentMoodIndex()).toBe(2);
});
it('should clamp gaze position to -1..1', () => {
const service = TestBed.inject(VittoMoodService);
service.updateGaze({ x: 5, y: -3 });
expect(service.gazePosition()).toEqual({ x: 1, y: -1 });
});
it('should track mood history', () => {
const service = TestBed.inject(VittoMoodService);
service.setMoodImmediate('focused');
service.setMoodImmediate('cheerful');
expect(service.moodHistory().length).toBe(2);
});
});
E2E (Playwright)
test('Vitto avatar renders and responds to mood changes', async ({ page }) => {
await page.goto('/chat');
const avatar = page.locator('ai-vitto-rive-avatar');
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute('data-mood', 'neutral');
// Trigger mood change
await page.evaluate(() => {
const service = (window as any).__vittoMoodService;
service.setMoodImmediate('cheerful');
});
await expect(avatar).toHaveAttribute('data-mood', 'cheerful');
});