feat(flow): add interactive AI puppet to FlowScreen

- Added puppet component modules: PuppetView, FlowPuppetSlot, and type definitions

- The puppet supports actions such as idle/smile/jump/shake/think, with a default smile.

- FlowScreen integrates puppet slots; it automatically uses the "think" function when sending messages and allows for interactive actions like Smile/Jump/Shake.

- The code is independent of the existing chat logic and does not affect existing functionality.
This commit is contained in:
Ada
2026-02-04 16:57:28 -08:00
parent e33ea62e35
commit d44ccc3ace
7 changed files with 633 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
/**
* FlowPuppetSlot - Slot for FlowScreen to show interactive AI puppet.
* Composes PuppetView and optional action buttons; does not depend on FlowScreen logic.
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { PuppetView } from './PuppetView';
import type { FlowPuppetSlotProps, PuppetAction } from './types';
import { colors } from '../../theme/colors';
import { borderRadius, spacing } from '../../theme/colors';
const ACTIONS: PuppetAction[] = ['smile', 'jump', 'shake'];
export function FlowPuppetSlot({
currentAction,
isTalking,
onAction,
showActionButtons = true,
}: FlowPuppetSlotProps) {
const [localAction, setLocalAction] = useState<PuppetAction>(currentAction);
const effectiveAction = currentAction !== 'idle' ? currentAction : localAction;
const handleAction = useCallback(
(action: PuppetAction) => {
setLocalAction(action);
onAction?.(action);
if (['smile', 'wave', 'nod', 'shake', 'jump'].includes(action)) {
setTimeout(() => {
setLocalAction((prev) => (prev === action ? 'idle' : prev));
onAction?.('idle');
}, 2600);
}
},
[onAction]
);
return (
<View style={styles.wrapper}>
<PuppetView action={effectiveAction} isTalking={isTalking} />
{showActionButtons && (
<View style={styles.actions}>
{ACTIONS.map((act) => (
<TouchableOpacity
key={act}
style={styles.actionBtn}
onPress={() => handleAction(act)}
activeOpacity={0.8}
>
<Text style={styles.actionLabel}>{act}</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.lg,
},
actions: {
flexDirection: 'row',
marginTop: spacing.lg,
gap: spacing.sm,
},
actionBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
backgroundColor: colors.flow.cardBackground,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
},
actionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.flow.primary,
textTransform: 'capitalize',
},
});

View File

@@ -0,0 +1,340 @@
/**
* PuppetView - Interactive blue spirit avatar (React Native).
* Port of airi---interactive-ai-puppet Puppet with same actions:
* idle, wave, nod, shake, jump, think; mouth reflects isTalking.
* Code isolated so FlowScreen stays unchanged except composition.
*/
import React, { useEffect, useRef } from 'react';
import { View, StyleSheet, Animated, Easing } from 'react-native';
import { PuppetViewProps } from './types';
const PUPPET_SIZE = 160;
export function PuppetView({ action, isTalking }: PuppetViewProps) {
const floatAnim = useRef(new Animated.Value(0)).current;
const bounceAnim = useRef(new Animated.Value(0)).current;
const shakeAnim = useRef(new Animated.Value(0)).current;
const thinkScale = useRef(new Animated.Value(1)).current;
const thinkOpacity = useRef(new Animated.Value(1)).current;
const smileScale = useRef(new Animated.Value(1)).current;
// Idle: gentle float
useEffect(() => {
if (action !== 'idle') return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(floatAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 2000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
loop.start();
return () => loop.stop();
}, [action, floatAnim]);
// Smile: exaggerated smile scale pulse
useEffect(() => {
if (action !== 'smile') {
smileScale.setValue(1);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(smileScale, {
toValue: 1.12,
duration: 400,
useNativeDriver: true,
easing: Easing.out(Easing.ease),
}),
Animated.timing(smileScale, {
toValue: 1,
duration: 400,
useNativeDriver: true,
easing: Easing.in(Easing.ease),
}),
]),
{ iterations: 3 }
);
loop.start();
return () => loop.stop();
}, [action, smileScale]);
// Wave / Jump: bounce
useEffect(() => {
if (action !== 'wave' && action !== 'jump') return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(bounceAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
easing: Easing.out(Easing.ease),
}),
Animated.timing(bounceAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
easing: Easing.in(Easing.ease),
}),
])
);
loop.start();
return () => loop.stop();
}, [action, bounceAnim]);
// Shake: wiggle
useEffect(() => {
if (action !== 'shake') return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(shakeAnim, {
toValue: 1,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(shakeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
])
);
loop.start();
return () => loop.stop();
}, [action, shakeAnim]);
// Think: scale + opacity pulse
useEffect(() => {
if (action !== 'think') {
thinkScale.setValue(1);
thinkOpacity.setValue(1);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(thinkScale, {
toValue: 0.92,
duration: 600,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(thinkOpacity, {
toValue: 0.85,
duration: 600,
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(thinkScale, {
toValue: 1,
duration: 600,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(thinkOpacity, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]),
])
);
loop.start();
return () => loop.stop();
}, [action, thinkScale, thinkOpacity]);
const floatY = floatAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -8],
});
const bounceY = bounceAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -20],
});
const shakeRotate = shakeAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '8deg'],
});
const isBounce = action === 'wave' || action === 'jump';
const isShake = action === 'shake';
const isSmile = action === 'smile';
const mouthStyle = isTalking
? [styles.mouth, styles.mouthOpen]
: isSmile
? [styles.mouth, styles.mouthBigSmile]
: [styles.mouth, styles.mouthSmile];
return (
<Animated.View
style={[
styles.container,
action === 'idle' && {
transform: [{ translateY: floatY }],
},
isBounce && {
transform: [{ translateY: bounceY }],
},
isShake && {
transform: [{ rotate: shakeRotate }],
},
action === 'think' && {
transform: [{ scale: thinkScale }],
opacity: thinkOpacity,
},
isSmile && {
transform: [{ scale: smileScale }],
},
]}
>
{/* Aura glow */}
<View style={styles.aura} />
{/* Body (droplet-like rounded rect) */}
<View style={styles.body}>
{/* Gloss */}
<View style={styles.gloss} />
{/* Cheeks */}
<View style={[styles.cheek, styles.cheekLeft]} />
<View style={[styles.cheek, styles.cheekRight]} />
{/* Eyes */}
<View style={styles.eyes}>
<View style={[styles.eye, styles.eyeLeft]}>
<View style={styles.eyeSparkle} />
</View>
<View style={[styles.eye, styles.eyeRight]}>
<View style={styles.eyeSparkle} />
</View>
</View>
{/* Mouth - default smile; open when talking; big smile when smile action */}
<View style={mouthStyle} />
</View>
</Animated.View>
);
}
const BODY_SIZE = PUPPET_SIZE * 0.9;
const EYE_SIZE = 10;
const EYE_OFFSET_X = 18;
const EYE_OFFSET_Y = -8;
const styles = StyleSheet.create({
container: {
width: PUPPET_SIZE,
height: PUPPET_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
aura: {
position: 'absolute',
width: PUPPET_SIZE + 40,
height: PUPPET_SIZE + 40,
borderRadius: (PUPPET_SIZE + 40) / 2,
backgroundColor: 'rgba(14, 165, 233, 0.15)',
},
body: {
width: BODY_SIZE,
height: BODY_SIZE,
borderRadius: BODY_SIZE / 2,
backgroundColor: '#0ea5e9',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 0,
overflow: 'hidden',
shadowColor: '#0c4a6e',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 8,
},
gloss: {
position: 'absolute',
top: BODY_SIZE * 0.12,
left: BODY_SIZE * 0.2,
right: BODY_SIZE * 0.2,
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(255,255,255,0.35)',
},
cheek: {
position: 'absolute',
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: 'rgba(59, 130, 246, 0.35)',
},
cheekLeft: {
left: BODY_SIZE * 0.15,
top: BODY_SIZE * 0.42,
},
cheekRight: {
right: BODY_SIZE * 0.15,
top: BODY_SIZE * 0.42,
},
eyes: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: BODY_SIZE * 0.34,
},
eye: {
width: EYE_SIZE,
height: EYE_SIZE,
borderRadius: EYE_SIZE / 2,
backgroundColor: '#0c4a6e',
alignItems: 'center',
justifyContent: 'center',
},
eyeLeft: { marginRight: EYE_OFFSET_X },
eyeRight: { marginLeft: EYE_OFFSET_X },
eyeSparkle: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: '#fff',
position: 'absolute',
top: 1,
left: 2,
},
mouth: {
position: 'absolute',
top: BODY_SIZE * 0.52,
backgroundColor: '#0c4a6e',
},
mouthSmile: {
width: 22,
height: 6,
borderBottomLeftRadius: 11,
borderBottomRightRadius: 11,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
mouthOpen: {
width: 18,
height: 6,
top: BODY_SIZE * 0.51,
borderRadius: 3,
backgroundColor: 'rgba(12, 74, 110, 0.9)',
},
mouthBigSmile: {
width: 32,
height: 10,
top: BODY_SIZE * 0.51,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
});

View File

@@ -0,0 +1,3 @@
export { PuppetView } from './PuppetView';
export { FlowPuppetSlot } from './FlowPuppetSlot';
export type { PuppetAction, PuppetState, PuppetViewProps, FlowPuppetSlotProps } from './types';

View File

@@ -0,0 +1,28 @@
/**
* Puppet types - compatible with airi interactive AI puppet semantics.
* Used for FlowScreen multimodal avatar (action + talking state).
*/
export type PuppetAction = 'idle' | 'wave' | 'nod' | 'shake' | 'jump' | 'think' | 'talk' | 'smile';
export interface PuppetState {
currentAction: PuppetAction;
isTalking: boolean;
isThinking: boolean;
}
export interface PuppetViewProps {
action: PuppetAction;
isTalking: boolean;
}
export interface FlowPuppetSlotProps {
/** Current action (idle, wave, nod, shake, jump, think). */
currentAction: PuppetAction;
/** True when AI is "speaking" (e.g. streaming or responding). */
isTalking: boolean;
/** Optional: allow parent to set action (e.g. from AI tool call). */
onAction?: (action: PuppetAction) => void;
/** Show quick action buttons (wave, jump, shake) for interactivity. */
showActionButtons?: boolean;
}