初始化项目

This commit is contained in:
2025-11-21 00:24:10 +08:00
commit 2878783349
34 changed files with 6774 additions and 0 deletions

546
views/ChatView.tsx Normal file
View File

@@ -0,0 +1,546 @@
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;