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:
85
src/components/puppet/FlowPuppetSlot.tsx
Normal file
85
src/components/puppet/FlowPuppetSlot.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
340
src/components/puppet/PuppetView.tsx
Normal file
340
src/components/puppet/PuppetView.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
3
src/components/puppet/index.ts
Normal file
3
src/components/puppet/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PuppetView } from './PuppetView';
|
||||
export { FlowPuppetSlot } from './FlowPuppetSlot';
|
||||
export type { PuppetAction, PuppetState, PuppetViewProps, FlowPuppetSlotProps } from './types';
|
||||
28
src/components/puppet/types.ts
Normal file
28
src/components/puppet/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user