154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
|
|
import { createFitnessChat } from '../services/geminiService';
|
|
import { WorkoutSession, Language } from '../types';
|
|
import { Chat, GenerateContentResponse } from '@google/genai';
|
|
import { t } from '../services/i18n';
|
|
|
|
interface AICoachProps {
|
|
history: WorkoutSession[];
|
|
lang: Language;
|
|
}
|
|
|
|
interface Message {
|
|
id: string;
|
|
role: 'user' | 'model';
|
|
text: string;
|
|
}
|
|
|
|
const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
|
|
const [messages, setMessages] = useState<Message[]>([
|
|
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
|
|
]);
|
|
const [input, setInput] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const chatSessionRef = useRef<Chat | null>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const chat = createFitnessChat(history);
|
|
if (chat) {
|
|
chatSessionRef.current = chat;
|
|
} else {
|
|
setError(t('ai_error', lang));
|
|
}
|
|
} catch (err) {
|
|
setError("Failed to initialize AI");
|
|
}
|
|
}, [history, lang]);
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages]);
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim() || !chatSessionRef.current || loading) return;
|
|
|
|
const userMsg: Message = { id: crypto.randomUUID(), role: 'user', text: input };
|
|
setMessages(prev => [...prev, userMsg]);
|
|
setInput('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text });
|
|
const text = result.text;
|
|
|
|
const aiMsg: Message = {
|
|
id: crypto.randomUUID(),
|
|
role: 'model',
|
|
text: text || "Error generating response."
|
|
};
|
|
setMessages(prev => [...prev, aiMsg]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
let errorText = 'Connection error.';
|
|
if (err instanceof Error) {
|
|
try {
|
|
const json = JSON.parse(err.message);
|
|
if (json.error) errorText = json.error;
|
|
else errorText = err.message;
|
|
} catch {
|
|
errorText = err.message;
|
|
}
|
|
}
|
|
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: errorText }]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
|
|
<AlertTriangle size={48} className="text-error mb-4" />
|
|
<p>{error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-surface">
|
|
{/* Header */}
|
|
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
|
|
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
|
|
<Bot size={20} className="text-on-secondary-container" />
|
|
</div>
|
|
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
|
|
{messages.map((msg) => (
|
|
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
|
|
? 'bg-primary text-on-primary rounded-br-none'
|
|
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
|
|
}`}>
|
|
{msg.text}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{loading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-surface-container-high px-4 py-3 rounded-[20px] rounded-bl-none flex gap-2 items-center text-on-surface-variant text-sm">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
{t('ai_typing', lang)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-4 bg-surface-container mt-auto">
|
|
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
|
|
<input
|
|
type="text"
|
|
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
|
|
placeholder={t('ai_placeholder', lang)}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={loading || !input.trim()}
|
|
className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-on-primary hover:opacity-90 disabled:opacity-50 transition-opacity shrink-0"
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AICoach;
|