初始化项目
This commit is contained in:
546
views/ChatView.tsx
Normal file
546
views/ChatView.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user