import React, { useState, useRef, useEffect } from 'react'; import { ChatMessage, Role, MessageType, Language, ChatSession } from '../types'; import { geminiService } from '../services/geminiService'; import ChatBubble from '../components/ChatBubble'; import AudioRecorder from '../components/AudioRecorder'; import { Send, Image as ImageIcon, BrainCircuit, Loader2, Plus, History, MessageSquare, Trash2, X, Sparkles, PanelRightClose, PanelRightOpen, Share2, Download, FileText, Image as ImageIconLucide, Languages } from 'lucide-react'; import { translations } from '../utils/localization'; import html2canvas from 'html2canvas'; interface ChatViewProps { language: Language; sessions: ChatSession[]; activeSessionId: string; onNewSession: () => void; onSelectSession: (id: string) => void; onDeleteSession: (id: string) => void; onClearAllSessions: () => void; onUpdateSession: (id: string, messages: ChatMessage[]) => void; selectedModel?: string; addToast: (type: 'success' | 'error' | 'info', msg: string) => void; } const ChatView: React.FC = ({ language, sessions, activeSessionId, onNewSession, onSelectSession, onDeleteSession, onClearAllSessions, onUpdateSession, selectedModel, addToast }) => { const t = translations[language].chat; const tCommon = translations[language].common; const activeSession = sessions.find(s => s.id === activeSessionId) || sessions[0]; const messages = activeSession ? activeSession.messages : []; const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const [useThinking, setUseThinking] = useState(false); const [attachedImage, setAttachedImage] = useState(null); // Settings State const [aiSpeakingLanguage, setAiSpeakingLanguage] = useState<'ja' | 'native'>('ja'); const [isShareMenuOpen, setIsShareMenuOpen] = useState(false); // History Sidebar State - Default Closed as requested const [isHistoryOpen, setIsHistoryOpen] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const fileInputRef = useRef(null); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; useEffect(() => { scrollToBottom(); }, [messages, activeSessionId]); // Close share menu on click outside useEffect(() => { const handleClick = () => setIsShareMenuOpen(false); if (isShareMenuOpen) window.addEventListener('click', handleClick); return () => window.removeEventListener('click', handleClick); }, [isShareMenuOpen]); const handleUpdateMessage = (updatedMsg: ChatMessage) => { const updatedMessages = messages.map(m => m.id === updatedMsg.id ? updatedMsg : m); onUpdateSession(activeSessionId, updatedMessages); }; const handleSendMessage = async () => { if ((!inputValue.trim() && !attachedImage) || isLoading) return; const currentText = inputValue; const currentImage = attachedImage; setInputValue(''); setAttachedImage(null); setIsLoading(true); // 1. Construct User Message const userMsg: ChatMessage = { id: Date.now().toString(), role: Role.USER, type: MessageType.TEXT, content: currentText, timestamp: Date.now(), metadata: { imageUrl: currentImage || undefined } }; // IMPORTANT: Calculate new history locally to avoid stale closure issues after await const messagesWithUser = [...messages, userMsg]; // Update UI immediately with user message onUpdateSession(activeSessionId, messagesWithUser); try { // 2. Get Response const result = await geminiService.generateTextResponse( currentText || "Describe this image", currentImage || undefined, useThinking, language, selectedModel, aiSpeakingLanguage ); // 3. TTS (if short and not thinking) let ttsAudio: string | null = null; if (!useThinking && result.text.length < 300) { try { ttsAudio = await geminiService.generateSpeech(result.text); } catch (e) {} } const aiMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: Role.MODEL, type: MessageType.TEXT, content: result.text, model: result.model, timestamp: Date.now(), metadata: { isThinking: useThinking, audioUrl: ttsAudio || undefined } }; // 4. Add AI Message to the LOCALLY calculated history (messagesWithUser) // This ensures we don't lose the user message we just added onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]); } catch (error: any) { const errorMsg = error?.message || t.error; const errorMsgObj: ChatMessage = { id: Date.now().toString(), role: Role.MODEL, type: MessageType.TEXT, content: `${t.error}\n(${errorMsg})`, timestamp: Date.now() }; onUpdateSession(activeSessionId, [...messagesWithUser, errorMsgObj]); } finally { setIsLoading(false); setUseThinking(false); } }; const handleAudioInput = async (base64Audio: string) => { setIsLoading(true); try { // 1. Transcribe first (async) const transcription = await geminiService.transcribeAudio(base64Audio); const userMsg: ChatMessage = { id: Date.now().toString(), role: Role.USER, type: MessageType.AUDIO, content: `${t.transcribedPrefix}${transcription}`, timestamp: Date.now(), metadata: { audioUrl: base64Audio, transcription: transcription } }; // 2. Update UI with User Message const messagesWithUser = [...messages, userMsg]; onUpdateSession(activeSessionId, messagesWithUser); // 3. Generate AI Response const result = await geminiService.generateTextResponse(transcription, undefined, false, language, selectedModel, aiSpeakingLanguage); const ttsAudio = await geminiService.generateSpeech(result.text); const aiMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: Role.MODEL, type: MessageType.TEXT, content: result.text, model: result.model, timestamp: Date.now(), metadata: { audioUrl: ttsAudio || undefined } }; // 4. Update UI with AI Message using local history onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]); } catch (e) { console.error(e); addToast('error', t.error); } finally { setIsLoading(false); } }; const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onloadend = () => { setAttachedImage(reader.result as string); }; reader.readAsDataURL(file); } }; // Share Handlers const shareAsText = () => { const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${m.content}`).join('\n\n'); navigator.clipboard.writeText(text); addToast('success', tCommon.copied); }; const shareAsFile = () => { const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${m.content}`).join('\n\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `sakura_chat_${Date.now()}.txt`; a.click(); URL.revokeObjectURL(url); }; const shareAsImage = async () => { if (!messagesContainerRef.current) return; addToast('info', 'Generating image...'); // Clone the element to capture full content const original = messagesContainerRef.current; const clone = original.cloneNode(true) as HTMLElement; // We need to maintain the width to ensure text wrapping is identical const width = original.offsetWidth; clone.style.width = `${width}px`; clone.style.height = 'auto'; clone.style.maxHeight = 'none'; clone.style.overflow = 'visible'; clone.style.position = 'absolute'; clone.style.top = '-9999px'; clone.style.left = '0'; clone.style.background = '#f8fafc'; // Match bg-slate-50 clone.style.zIndex = '-1'; document.body.appendChild(clone); try { // Small delay to ensure DOM rendering await new Promise(resolve => setTimeout(resolve, 100)); const canvas = await html2canvas(clone, { useCORS: true, scale: 2, // Higher res backgroundColor: '#f8fafc', windowWidth: width, height: clone.scrollHeight, windowHeight: clone.scrollHeight }); const url = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = `sakura_chat_${Date.now()}.png`; a.click(); } catch (e) { console.error(e); addToast('error', 'Failed to generate image'); } finally { if (document.body.contains(clone)) { document.body.removeChild(clone); } } }; // --- Sub-components --- const HistoryContent = () => (

{t.history}

{sessions.length > 0 && ( )}
{sessions.length === 0 &&
{t.noHistory}
} {sessions.slice().sort((a,b) => b.updatedAt - a.updatedAt).map(session => (
{ onSelectSession(session.id); if(window.innerWidth < 768) setIsHistoryOpen(false); }} > {/* Icon */}
{/* Content */}

{session.title || t.untitled}

{new Date(session.updatedAt).toLocaleDateString()} {new Date(session.updatedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}

{session.messages.length > 1 ? session.messages[session.messages.length-1].content.substring(0, 50) : '...'}

{/* Delete Button */}
))}
); return (
{/* MAIN CHAT AREA */}
{/* Header / Toolbar */}
{selectedModel ? selectedModel.replace('gemini-', '').replace('-preview', '') : 'AI'}
{/* AI Language Toggle */}
{/* Share Button */}
{/* Share Dropdown */} {isShareMenuOpen && (
)}
{/* Toggle History Button */}
{/* Messages Scroll Area */}
{messages.length === 0 && (

{t.inputPlaceholder}

)} {messages.map((msg) => ( addToast('error', errorMsg)} /> ))} {isLoading && (
{t.sending}
)}
{/* Input Area */}
{attachedImage && (
Preview
{t.imageAttached}
)}