Files
ai-app-skr/views/ChatView.tsx
2025-11-21 00:24:18 +08:00

546 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ChatViewProps> = ({
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<string | null>(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<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 = () => (
<div className="flex flex-col h-full bg-white">
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<History size={18} className="text-indigo-500" /> {t.history}
</h3>
<div className="flex items-center gap-3">
{sessions.length > 0 && (
<button onClick={onClearAllSessions} className="text-xs text-red-400 hover:text-red-600 hover:underline">
{translations[language].reading.clear}
</button>
)}
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
<X size={20} />
</button>
</div>
</div>
<div className="p-4">
<button
onClick={() => { onNewSession(); setIsHistoryOpen(false); }}
className="w-full py-3 bg-indigo-600 text-white hover:bg-indigo-700 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-95"
>
<Plus size={18} /> {t.newChat}
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-20 space-y-3">
{sessions.length === 0 && <div className="text-center text-slate-400 text-xs mt-4">{t.noHistory}</div>}
{sessions.slice().sort((a,b) => b.updatedAt - a.updatedAt).map(session => (
<div
key={session.id}
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
session.id === activeSessionId
? 'bg-indigo-50 border-indigo-200 shadow-sm'
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
}`}
onClick={() => { onSelectSession(session.id); if(window.innerWidth < 768) setIsHistoryOpen(false); }}
>
{/* Icon */}
<div className={`w-10 h-10 rounded-xl flex-shrink-0 flex items-center justify-center ${session.id === activeSessionId ? 'bg-indigo-200 text-indigo-700' : 'bg-white text-slate-300 border border-slate-200'}`}>
<MessageSquare size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className={`font-bold text-sm truncate pr-6 ${session.id === activeSessionId ? 'text-indigo-900' : 'text-slate-700'}`}>
{session.title || t.untitled}
</h4>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] text-slate-400">
{new Date(session.updatedAt).toLocaleDateString()} {new Date(session.updatedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
<p className="text-[10px] text-slate-400 line-clamp-1 mt-1">
{session.messages.length > 1 ? session.messages[session.messages.length-1].content.substring(0, 50) : '...'}
</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => { e.stopPropagation(); onDeleteSession(session.id); }}
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
title={t.deleteChat}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
return (
<div className="flex h-full bg-slate-50 relative overflow-hidden">
{/* MAIN CHAT AREA */}
<div className="flex-1 flex flex-col h-full min-w-0 bg-slate-50/30 relative">
{/* Header / Toolbar */}
<div className="flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur border-b border-slate-200 z-20 sticky top-0">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-1.5 text-xs font-bold text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-lg border border-indigo-100 whitespace-nowrap">
<Sparkles size={12} />
{selectedModel ? selectedModel.replace('gemini-', '').replace('-preview', '') : 'AI'}
</div>
{/* AI Language Toggle */}
<button
onClick={() => setAiSpeakingLanguage(prev => prev === 'ja' ? 'native' : 'ja')}
className="flex items-center gap-1.5 text-xs font-bold text-slate-600 bg-white px-2.5 py-1 rounded-lg border border-slate-200 hover:bg-slate-50 whitespace-nowrap"
title={tCommon.aiLanguage}
>
<Languages size={12} />
{aiSpeakingLanguage === 'ja' ? tCommon.langJa : tCommon.langNative}
</button>
</div>
<div className="flex items-center gap-2">
{/* Share Button */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setIsShareMenuOpen(!isShareMenuOpen); }}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 transition-colors"
title={tCommon.share}
>
<Share2 size={18} />
</button>
{/* Share Dropdown */}
{isShareMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-40 bg-white rounded-xl shadow-xl border border-slate-100 overflow-hidden animate-scale-in z-50">
<button onClick={shareAsImage} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<ImageIconLucide size={16} /> {tCommon.shareImage}
</button>
<button onClick={shareAsText} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<FileText size={16} /> {tCommon.shareText}
</button>
<button onClick={shareAsFile} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
<Download size={16} /> {tCommon.shareFile}
</button>
</div>
)}
</div>
{/* Toggle History Button */}
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
isHistoryOpen
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
title={t.history}
>
<History size={18} />
<span className="hidden sm:inline">{t.history}</span>
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
</button>
</div>
</div>
{/* Messages Scroll Area */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6" ref={messagesContainerRef}>
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-slate-300 animate-fade-in pb-20">
<div className="w-20 h-20 bg-white rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-slate-100 rotate-3">
<MessageSquare size={36} className="text-indigo-200" />
</div>
<p className="text-sm font-bold text-slate-400">{t.inputPlaceholder}</p>
</div>
)}
{messages.map((msg) => (
<ChatBubble
key={msg.id}
message={msg}
language={language}
onUpdateMessage={handleUpdateMessage}
onError={(errorMsg) => addToast('error', errorMsg)}
/>
))}
{isLoading && (
<div className="flex items-center gap-2 text-slate-400 text-sm ml-4 animate-pulse">
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-sm border border-slate-100">
<Loader2 size={14} className="animate-spin text-indigo-500" />
</div>
{t.sending}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="bg-white border-t border-slate-200 p-3 sm:p-4 z-20 pb-[env(safe-area-inset-bottom)]">
{attachedImage && (
<div className="flex items-center gap-2 mb-3 px-1 animate-scale-in">
<div className="relative group">
<img src={attachedImage} alt="Preview" className="h-14 w-14 object-cover rounded-lg border border-slate-200 shadow-sm" />
<button
onClick={() => setAttachedImage(null)}
className="absolute -top-2 -right-2 bg-slate-800 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs shadow-md hover:bg-red-50 transition-colors"
>
×
</button>
</div>
<span className="text-xs text-indigo-600 font-bold bg-indigo-50 px-3 py-1 rounded-full">{t.imageAttached}</span>
</div>
)}
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row gap-2 items-end">
<div className="flex gap-1 items-center justify-between w-full sm:w-auto">
<div className="flex gap-1">
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-xl transition-all"
title="Attach Image"
>
<ImageIcon size={20} />
</button>
<input type="file" ref={fileInputRef} onChange={handleImageUpload} className="hidden" accept="image/*" />
<button
onClick={() => setUseThinking(!useThinking)}
className={`p-3 rounded-xl transition-all ${
useThinking ? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200' : 'text-slate-400 hover:text-amber-600 hover:bg-amber-50'
}`}
title={t.thinkingToggle}
>
<BrainCircuit size={20} />
</button>
<AudioRecorder onAudioCaptured={handleAudioInput} disabled={isLoading} />
</div>
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() && !attachedImage || isLoading}
className="sm:hidden p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 disabled:scale-95 transition-all shadow-md shadow-indigo-200"
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
</button>
</div>
<div className="flex-1 w-full">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); }}}
placeholder={useThinking ? t.thinkingPlaceholder : t.inputPlaceholder}
className="w-full border-2 border-transparent bg-slate-100 focus:bg-white rounded-2xl px-4 py-3 focus:border-indigo-500 focus:ring-0 resize-y min-h-[50px] max-h-[200px] text-sm md:text-base transition-all placeholder:text-slate-400 text-slate-800"
style={{ minHeight: '50px' }}
/>
</div>
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() && !attachedImage || isLoading}
className="hidden sm:flex p-3.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 transition-all shadow-lg shadow-indigo-200 hover:scale-105 active:scale-95"
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
</button>
</div>
</div>
</div>
{/* RIGHT SIDEBAR - DESKTOP */}
<div className={`
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
`}>
<HistoryContent />
</div>
{/* MOBILE DRAWER (Slide over) */}
{isHistoryOpen && (
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50">
<HistoryContent />
</div>
</div>
)}
</div>
);
};
export default ChatView;