feat: user personalization with Honcho-backed preferences
- WelcomeScreen: first-time name entry with cute onboarding - identify WS message: sets user_id, loads saved prefs from Honcho - set_preference WS message: saves scene/outfit/accessory to Honcho metadata - Preferences auto-load on return visits via localStorage + Honcho peer meta - Kira uses the user's name in greeting and prompts - Backend: get/set preference methods in KiraMemory service - Frontend: optimistic preference updates, synced to backend on change
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export interface UserPreferences {
|
||||
name: string;
|
||||
scene: string;
|
||||
outfit: string;
|
||||
accessory: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'kira';
|
||||
@@ -8,25 +15,54 @@ interface Message {
|
||||
}
|
||||
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws`;
|
||||
const USER_ID_KEY = 'kira-user-id';
|
||||
|
||||
function loadUserId(): string {
|
||||
return localStorage.getItem(USER_ID_KEY) || '';
|
||||
}
|
||||
|
||||
function saveUserId(id: string) {
|
||||
localStorage.setItem(USER_ID_KEY, id);
|
||||
}
|
||||
|
||||
export function useConversation() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isKiraSpeaking, setIsKiraSpeaking] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [identified, setIdentified] = useState(false);
|
||||
const [preferences, setPreferences] = useState<UserPreferences>({
|
||||
name: '',
|
||||
scene: 'cozy-room',
|
||||
outfit: 'cozy-hoodie',
|
||||
accessory: '',
|
||||
});
|
||||
const [loadingPrefs, setLoadingPrefs] = useState(true);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
// Connect WebSocket
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
setLoadingPrefs(true);
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setIsConnected(true);
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
// Auto-identify if returning user
|
||||
const savedId = loadUserId();
|
||||
if (savedId) {
|
||||
ws.send(JSON.stringify({ type: 'identify', user_id: savedId }));
|
||||
} else {
|
||||
setLoadingPrefs(false);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
setTimeout(connect, 3000);
|
||||
@@ -51,6 +87,25 @@ export function useConversation() {
|
||||
// Handle incoming WS messages
|
||||
const handleMessage = useCallback((msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'identified': {
|
||||
setIdentified(true);
|
||||
setLoadingPrefs(false);
|
||||
if (msg.user_id) saveUserId(msg.user_id);
|
||||
if (msg.preferences) {
|
||||
setPreferences({
|
||||
name: msg.preferences.name || '',
|
||||
scene: msg.preferences.scene || 'cozy-room',
|
||||
outfit: msg.preferences.outfit || 'cozy-hoodie',
|
||||
accessory: msg.preferences.accessory || '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'preference_saved':
|
||||
// Already optimistically updated locally
|
||||
break;
|
||||
|
||||
case 'transcript':
|
||||
addMessage('user', msg.text);
|
||||
break;
|
||||
@@ -91,15 +146,52 @@ export function useConversation() {
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Send text directly (no microphone)
|
||||
const sendText = useCallback((text: string) => {
|
||||
if (!text.trim()) return;
|
||||
// ── Identity ──
|
||||
|
||||
const identify = useCallback((name: string) => {
|
||||
const userId = `kira-${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||||
saveUserId(userId);
|
||||
setPreferences((p) => ({ ...p, name }));
|
||||
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'conversation_text', text: text.trim() }));
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'identify',
|
||||
user_id: userId,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Push-to-talk: start recording
|
||||
// ── Preferences ──
|
||||
|
||||
const setPreference = useCallback((key: string, value: string) => {
|
||||
// Optimistic update
|
||||
setPreferences((p) => ({ ...p, [key]: value }));
|
||||
|
||||
// Sync to backend
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN && identified) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'set_preference',
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
}, [identified]);
|
||||
|
||||
// ── Text ──
|
||||
|
||||
const sendText = useCallback((text: string) => {
|
||||
if (!text.trim()) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'conversation_text',
|
||||
text: text.trim(),
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Audio ──
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
@@ -117,16 +209,12 @@ export function useConversation() {
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
// Send all audio chunks as one blob
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'audio_chunk',
|
||||
data: base64,
|
||||
}));
|
||||
wsRef.current.send(JSON.stringify({ type: 'audio_chunk', data: base64 }));
|
||||
wsRef.current.send(JSON.stringify({ type: 'transcribe' }));
|
||||
}
|
||||
};
|
||||
@@ -144,7 +232,6 @@ export function useConversation() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Push-to-talk: stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
recorderRef.current?.stop();
|
||||
}, []);
|
||||
@@ -163,6 +250,11 @@ export function useConversation() {
|
||||
isConnected,
|
||||
isKiraSpeaking,
|
||||
isRecording,
|
||||
identified,
|
||||
preferences,
|
||||
loadingPrefs,
|
||||
identify,
|
||||
setPreference,
|
||||
sendText,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
|
||||
Reference in New Issue
Block a user