Files
ai-app-skg/App.tsx
2025-12-23 17:10:31 +08:00

790 lines
35 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
Settings as SettingsIcon,
MessageSquare,
Sparkles,
Menu,
X,
Mic,
ImagePlus,
Send,
Loader2,
Volume2,
Trash2,
Plus,
BookOpen,
Brain,
GraduationCap,
Coffee,
History,
ChevronRight,
ChevronLeft,
Calendar
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants';
import { AppLanguage, ChatMode, Message, UserSettings, ChatSession, ChatScenario } from './types';
import { loadSettings, saveSettings, loadSessions, saveSessions, exportData, importData, clearData } from './services/storage';
import { streamChatResponse, transcribeAudio, generateSpeech } from './services/geminiService';
import Tools from './components/Tools';
const App: React.FC = () => {
// State
const [settings, setSettingsState] = useState<UserSettings>(loadSettings());
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// New State for Lazy Creation
const [selectedScenario, setSelectedScenario] = useState<ChatScenario>(ChatScenario.GENERAL);
// Track selected mode for the upcoming session
const [selectedMode, setSelectedMode] = useState<ChatMode>(ChatMode.STANDARD);
const [input, setInput] = useState('');
const [activeView, setActiveView] = useState<'chat' | 'tools' | 'settings'>('chat');
// Sidebar States
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [attachments, setAttachments] = useState<{mimeType: string, data: string, name?: string}[]>([]);
const [isRecording, setIsRecording] = useState(false);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const t = TRANSLATIONS[settings.language] || TRANSLATIONS[DEFAULT_LANGUAGE];
// Effects
useEffect(() => {
const loadedSessions = loadSessions();
setSessions(loadedSessions);
// On load, do NOT select a session, let user start fresh or pick one
}, []);
// Responsive Sidebar Logic
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setIsRightSidebarOpen(false);
} else {
setIsRightSidebarOpen(true);
}
};
// Set initial state
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
saveSettings(settings);
}, [settings]);
useEffect(() => {
saveSessions(sessions);
}, [sessions]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessions, streamingContent, currentSessionId]);
// Helpers
const getCurrentSession = () => sessions.find(s => s.id === currentSessionId);
// Instead of creating session immediately, we prepare the UI
const handleScenarioSelect = (scenario: ChatScenario) => {
setSelectedScenario(scenario);
setCurrentSessionId(null); // Clear current session to show "New Chat" state
setActiveView('chat');
setIsLeftSidebarOpen(false); // Close mobile menu if open
};
const deleteSession = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (window.confirm(t.confirmDelete)) {
setSessions(prev => prev.filter(s => s.id !== id));
if (currentSessionId === id) setCurrentSessionId(null);
}
};
const getScenarioIcon = (scenario?: ChatScenario) => {
switch (scenario) {
case ChatScenario.READING: return <BookOpen size={18} />;
case ChatScenario.CONCEPT: return <Brain size={18} />;
case ChatScenario.RESEARCH: return <GraduationCap size={18} />;
case ChatScenario.GENERAL:
default: return <Coffee size={18} />;
}
};
const SCENARIOS = [
ChatScenario.GENERAL,
ChatScenario.READING,
ChatScenario.CONCEPT,
ChatScenario.RESEARCH
];
// Group Sessions by Date AND Filter by current Scenario
const getGroupedSessions = () => {
const groups: { [key: string]: ChatSession[] } = {
'Today': [],
'Yesterday': [],
'Previous 7 Days': [],
'Older': []
};
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86400000;
const lastWeek = today - 86400000 * 7;
// Filter sessions to only show those belonging to the selected scenario
const filteredSessions = sessions.filter(s => {
// Default to GENERAL if scenario is undefined (legacy data)
const sessionScenario = s.scenario || ChatScenario.GENERAL;
return sessionScenario === selectedScenario;
});
filteredSessions.forEach(s => {
if (s.createdAt >= today) groups['Today'].push(s);
else if (s.createdAt >= yesterday) groups['Yesterday'].push(s);
else if (s.createdAt >= lastWeek) groups['Previous 7 Days'].push(s);
else groups['Older'].push(s);
});
return groups;
};
// Handlers
const handleSendMessage = async () => {
if ((!input.trim() && attachments.length === 0) || isProcessing) return;
let session = getCurrentSession();
let isNewSession = false;
// LAZY CREATION LOGIC:
// If no session exists, create one now
if (!session) {
isNewSession = true;
const scenarioConfig = t.scenarios[selectedScenario];
const initialGreeting = scenarioConfig.greeting;
const newSession: ChatSession = {
id: Date.now().toString(),
title: input.slice(0, 30) || t.newChat, // Set title immediately from first prompt
messages: [
{
id: (Date.now() - 100).toString(),
role: 'model',
content: initialGreeting, // Inject the greeting that was shown in UI
timestamp: Date.now() - 100
}
],
mode: selectedMode, // Use the selected mode
scenario: selectedScenario,
createdAt: Date.now()
};
// Update local variable to use immediately
session = newSession;
}
// Prepare User Message
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: Date.now(),
attachments: attachments.map(a => ({ type: 'image', ...a }))
};
// Optimistic UI Update
const updatedMessages = [...session!.messages, userMsg];
if (isNewSession) {
setSessions(prev => [
{ ...session!, messages: updatedMessages },
...prev
]);
setCurrentSessionId(session!.id);
} else {
setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: updatedMessages } : s));
}
setInput('');
setAttachments([]);
setIsProcessing(true);
setStreamingContent('');
try {
let fullResponse = '';
let groundingData: any = null;
await streamChatResponse(
updatedMessages,
userMsg.content,
session!.mode,
settings.language,
session!.scenario || ChatScenario.GENERAL,
userMsg.attachments as any,
(text, grounding) => {
fullResponse += text;
setStreamingContent(fullResponse);
if (grounding) groundingData = grounding;
}
);
const modelMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'model',
content: fullResponse,
timestamp: Date.now(),
groundingMetadata: groundingData
};
setSessions(prev => prev.map(s => s.id === session!.id ? {
...s,
messages: [...updatedMessages, modelMsg]
} : s));
} catch (err) {
console.error(err);
const errorMsg: Message = {
id: Date.now().toString(),
role: 'model',
content: t.apiError,
timestamp: Date.now()
};
setSessions(prev => prev.map(s => s.id === session!.id ? {
...s,
messages: [...updatedMessages, errorMsg]
} : s));
} finally {
setIsProcessing(false);
setStreamingContent('');
}
};
// ... (Keep existing helpers: handleFileUpload, handleRecordAudio, playTTS, handleImport)
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
const base64Data = base64String.split(',')[1];
setAttachments(prev => [...prev, { mimeType: file.type, data: base64Data, name: file.name }]);
};
reader.readAsDataURL(file);
e.target.value = '';
};
const handleRecordAudio = async () => {
if (isRecording) {
mediaRecorderRef.current?.stop();
setIsRecording(false);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
const chunks: BlobPart[] = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1];
setIsProcessing(true);
try {
const text = await transcribeAudio(base64, 'audio/webm');
setInput(prev => prev + " " + text);
} catch (e) {
console.error(e);
alert(t.transcriptionFail);
} finally {
setIsProcessing(false);
}
};
reader.readAsDataURL(blob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
} catch (e) {
console.error("Mic error", e);
alert(t.micError);
}
};
const playTTS = async (text: string) => {
try {
const buffer = await generateSpeech(text);
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0);
} catch (e) {
console.error("TTS Error", e);
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const success = await importData(file);
if (success) {
setSettingsState(loadSettings());
setSessions(loadSessions());
alert(t.importSuccess);
} else {
alert(t.importFail);
}
}
};
const currentSession = getCurrentSession();
const groupedSessions = getGroupedSessions();
// Helper to get active mode for UI display
const activeMode = currentSession ? currentSession.mode : selectedMode;
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
{/* 1. LEFT SIDEBAR (Navigation) */}
{isLeftSidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-20 md:hidden" onClick={() => setIsLeftSidebarOpen(false)} />
)}
<aside className={`
fixed inset-y-0 left-0 z-30 w-64 bg-white border-r border-slate-200 transform transition-transform duration-300 ease-in-out
md:relative md:translate-x-0 flex flex-col
${isLeftSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="p-4 border-b border-slate-100 flex items-center justify-between h-16">
<h1 className="font-bold text-xl text-blue-600 flex items-center gap-2">
<span className="bg-blue-100 p-1.5 rounded-lg"><Sparkles size={18}/></span>
{t.appName}
</h1>
<button onClick={() => setIsLeftSidebarOpen(false)} className="md:hidden text-slate-500">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-8">
{/* Scenarios Navigation */}
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 px-2">{t.modules}</div>
<div className="space-y-1">
{SCENARIOS.map((scenario) => (
<button
key={scenario}
onClick={() => handleScenarioSelect(scenario)}
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition group text-left
${selectedScenario === scenario && activeView === 'chat' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}
`}
>
<span className={`p-2 rounded-lg transition flex-shrink-0 ${selectedScenario === scenario && activeView === 'chat' ? 'bg-white text-blue-600' : 'bg-slate-100 text-slate-500'}`}>
{getScenarioIcon(scenario)}
</span>
<span>{t.scenarios[scenario].title}</span>
</button>
))}
</div>
</div>
{/* Tools */}
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 px-2">{t.tools}</div>
<button
onClick={() => { setActiveView('tools'); setIsLeftSidebarOpen(false); }}
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition text-left ${activeView === 'tools' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
<span className="p-2 rounded-lg bg-slate-100 text-slate-500 flex-shrink-0"><ImagePlus size={18} /></span>
<span>{t.studio}</span>
</button>
</div>
</div>
{/* Settings Footer */}
<div className="p-4 border-t border-slate-100">
<button
onClick={() => { setActiveView('settings'); setIsLeftSidebarOpen(false); }}
className={`w-full flex items-center gap-3 p-3 rounded-lg text-sm font-medium transition ${activeView === 'settings' ? 'bg-slate-100 text-slate-900' : 'text-slate-600 hover:bg-slate-50'}`}
>
<SettingsIcon size={18} />
{t.settings}
</button>
</div>
</aside>
{/* 2. MAIN CONTENT (Center) */}
<main className="flex-1 flex flex-col h-full relative min-w-0">
{/* Header */}
<header className="h-16 bg-white border-b border-slate-100 flex items-center px-4 justify-between shrink-0">
<div className="flex items-center gap-2">
<button onClick={() => setIsLeftSidebarOpen(true)} className="md:hidden p-2 text-slate-600">
<Menu size={24} />
</button>
{activeView === 'chat' && (
<div className="flex items-center gap-2 text-slate-700 font-medium">
<span className="text-blue-600 bg-blue-50 p-1 rounded-md">
{getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)}
</span>
<span className="hidden sm:inline">
{t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Mode Switcher */}
{activeView === 'chat' && (
<div className="hidden sm:flex items-center space-x-1 bg-slate-100 p-1 rounded-lg mr-2">
<button
onClick={() => {
// Mode switching logic: if session exists update it, else update selection state
if(currentSession) {
setSessions(s => s.map(sess => sess.id === currentSessionId ? {...sess, mode: ChatMode.STANDARD} : sess));
} else {
setSelectedMode(ChatMode.STANDARD);
}
}}
className={`px-3 py-1 text-xs font-medium rounded-md transition ${activeMode === ChatMode.STANDARD ? 'bg-white shadow text-blue-600' : 'text-slate-500'}`}
>
Search
</button>
<button
onClick={() => {
if(currentSession) {
setSessions(s => s.map(sess => sess.id === currentSessionId ? {...sess, mode: ChatMode.DEEP} : sess));
} else {
setSelectedMode(ChatMode.DEEP);
}
}}
className={`px-3 py-1 text-xs font-medium rounded-md transition ${activeMode === ChatMode.DEEP ? 'bg-white shadow text-purple-600' : 'text-slate-500'}`}
>
Reasoning
</button>
<button
onClick={() => {
if(currentSession) {
setSessions(s => s.map(sess => sess.id === currentSessionId ? {...sess, mode: ChatMode.FAST} : sess));
} else {
setSelectedMode(ChatMode.FAST);
}
}}
className={`px-3 py-1 text-xs font-medium rounded-md transition ${activeMode === ChatMode.FAST ? 'bg-white shadow text-green-600' : 'text-slate-500'}`}
>
Fast
</button>
</div>
)}
{/* Toggle Right Sidebar - Only visible in Chat View */}
{activeView === 'chat' && (
<button
onClick={() => setIsRightSidebarOpen(!isRightSidebarOpen)}
className={`p-2 rounded-lg transition ${isRightSidebarOpen ? 'text-blue-600 bg-blue-50' : 'text-slate-500 hover:bg-slate-100'}`}
title={t.history}
>
{isRightSidebarOpen ? <History size={20} /> : <Calendar size={20} />}
</button>
)}
</div>
</header>
{/* View Content */}
<div className="flex-1 overflow-hidden relative flex flex-col">
{activeView === 'chat' && (
<div className="flex flex-col h-full relative">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{!currentSession ? (
/* Welcome State (No session yet) */
<div className="h-full flex flex-col items-center justify-center p-8 text-center text-slate-500">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 text-blue-600">
{getScenarioIcon(selectedScenario)}
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">{t.scenarios[selectedScenario].title}</h2>
<p className="max-w-md mb-8">{t.scenarios[selectedScenario].greeting}</p>
</div>
) : (
/* Active Session Messages */
<>
{currentSession.messages.map((msg, idx) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role === 'model' && (
<div className="mr-3 flex-shrink-0 mt-1">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
{getScenarioIcon(currentSession.scenario)}
</div>
</div>
)}
<div className={`max-w-[85%] md:max-w-[70%] space-y-1`}>
{msg.attachments?.map((att, i) => (
<div key={i} className="mb-2">
<img src={`data:${att.mimeType};base64,${att.data}`} alt="attachment" className="max-h-48 rounded-lg shadow-sm border border-slate-100" />
</div>
))}
<div className={`p-4 rounded-2xl shadow-sm text-sm md:text-base leading-relaxed ${
msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white border border-slate-100 text-slate-800 rounded-tl-none'
}`}>
<ReactMarkdown className="prose prose-sm max-w-none dark:prose-invert">
{msg.content}
</ReactMarkdown>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 px-1">
<span>{new Date(msg.timestamp).toLocaleTimeString()}</span>
{msg.role === 'model' && (
<>
<button onClick={() => playTTS(msg.content)} className="hover:text-blue-500"><Volume2 size={14}/></button>
{msg.groundingMetadata?.groundingChunks && msg.groundingMetadata.groundingChunks.length > 0 && (
<span className="flex items-center gap-1 text-green-600">
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
{t.searchSources}
</span>
)}
</>
)}
</div>
{msg.role === 'model' && msg.groundingMetadata?.groundingChunks && (
<div className="mt-2 text-xs bg-slate-50 p-2 rounded-lg border border-slate-100">
<p className="font-medium mb-1">{t.searchSources}:</p>
<ul className="list-disc pl-4 space-y-1">
{msg.groundingMetadata.groundingChunks.map((chunk, i) => chunk.web && (
<li key={i}>
<a href={chunk.web.uri} target="_blank" rel="noreferrer" className="text-blue-500 hover:underline truncate block max-w-xs">
{chunk.web.title}
</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
{isProcessing && streamingContent && (
<div className="flex justify-start">
<div className="mr-3 flex-shrink-0 mt-1">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
{getScenarioIcon(currentSession.scenario)}
</div>
</div>
<div className="max-w-[85%] md:max-w-[70%] bg-white border border-slate-100 p-4 rounded-2xl rounded-tl-none shadow-sm">
<ReactMarkdown className="prose prose-sm max-w-none text-slate-800">
{streamingContent}
</ReactMarkdown>
<div className="mt-2 flex items-center gap-2 text-xs text-blue-500 animate-pulse">
<Loader2 size={12} className="animate-spin" />
{currentSession?.mode === ChatMode.DEEP ? t.thinking : t.generating}
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
<div className="max-w-3xl mx-auto flex flex-col gap-2">
{attachments.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{attachments.map((a, i) => (
<div key={i} className="relative group">
<img src={`data:${a.mimeType};base64,${a.data}`} className="h-16 w-16 object-cover rounded-lg border border-slate-200" alt="preview" />
<button
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 shadow-sm opacity-0 group-hover:opacity-100 transition"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2 bg-slate-50 p-2 rounded-2xl border border-slate-200 focus-within:ring-2 focus-within:ring-blue-100 transition">
<input type="file" ref={fileInputRef} onChange={handleFileUpload} accept="image/*" className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-white rounded-xl transition"
title={t.uploadImage}
>
<ImagePlus size={20} />
</button>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t.inputPlaceholder}
className="flex-1 bg-transparent border-none focus:ring-0 resize-none max-h-32 py-2 text-slate-700 placeholder:text-slate-400"
rows={1}
/>
<button
onClick={handleRecordAudio}
className={`p-2 rounded-xl transition ${isRecording ? 'bg-red-100 text-red-500 animate-pulse' : 'text-slate-400 hover:text-blue-500 hover:bg-white'}`}
title={t.recordAudio}
>
<Mic size={20} />
</button>
<button
onClick={handleSendMessage}
disabled={(!input.trim() && attachments.length === 0) || isProcessing}
className="p-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 transition shadow-sm"
>
{isProcessing ? <Loader2 size={20} className="animate-spin"/> : <Send size={20} />}
</button>
</div>
</div>
</div>
</div>
)}
{activeView === 'tools' && <div className="h-full overflow-y-auto bg-slate-50/50"><Tools language={settings.language} /></div>}
{activeView === 'settings' && (
<div className="h-full overflow-y-auto p-4 md:p-8">
<div className="max-w-2xl mx-auto space-y-8">
{/* ... (Existing Settings UI reused) ... */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<SettingsIcon size={20} className="text-slate-400" />
{t.settings}
</h2>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-1">Language</label>
<select
value={settings.language}
onChange={(e) => setSettingsState(s => ({...s, language: e.target.value as AppLanguage}))}
className="w-full p-3 border border-slate-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value={AppLanguage.ZH_CN}></option>
<option value={AppLanguage.ZH_TW}></option>
<option value={AppLanguage.EN}>English</option>
<option value={AppLanguage.JA}></option>
</select>
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<h2 className="text-lg font-bold mb-4">{t.backupRestore}</h2>
<div className="flex flex-col sm:flex-row gap-4">
<button onClick={exportData} className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-sm font-medium transition">{t.exportData}</button>
<label className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-sm font-medium transition cursor-pointer text-center">
{t.importData} <input type="file" onChange={handleImport} accept=".json" className="hidden" />
</label>
<button onClick={() => { if (window.confirm("Are you sure?")) { clearData(); window.location.reload(); } }} className="px-4 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg text-sm font-medium transition ml-auto">{t.clearData}</button>
</div>
</div>
</div>
</div>
)}
</div>
</main>
{/* 3. RIGHT SIDEBAR (History) */}
{/* Conditionally render sidebar only when activeView is 'chat' */}
{activeView === 'chat' && (
<aside className={`
fixed inset-y-0 right-0 z-20 w-80 bg-white border-l border-slate-200 transform transition-transform duration-300 ease-in-out
lg:relative lg:translate-x-0
${isRightSidebarOpen ? 'translate-x-0' : 'translate-x-full lg:hidden'}
${!isRightSidebarOpen && 'hidden lg:hidden'}
`}>
<div className="flex flex-col h-full">
<div className="p-4 border-b border-slate-100 flex items-center justify-between h-16">
<h2 className="font-semibold text-slate-700 flex items-center gap-2">
<History size={18} className="text-slate-400" />
{t.history}
</h2>
<button onClick={() => setIsRightSidebarOpen(false)} className="lg:hidden text-slate-500">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{Object.entries(groupedSessions).map(([group, groupSessions]) => (
groupSessions.length > 0 && (
<div key={group}>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-1">{group}</div>
<div className="space-y-1">
{groupSessions.map(s => (
<div key={s.id} className="relative group">
<button
onClick={() => {
setCurrentSessionId(s.id);
setActiveView('chat');
// On mobile, maybe close sidebar?
if (window.innerWidth < 1024) setIsRightSidebarOpen(false);
}}
className={`w-full text-left p-3 rounded-xl transition flex items-start gap-3 border ${
currentSessionId === s.id && activeView === 'chat'
? 'bg-blue-50 border-blue-100 shadow-sm'
: 'bg-white border-transparent hover:bg-slate-50'
}`}
>
<span className={`mt-0.5 ${currentSessionId === s.id ? 'text-blue-600' : 'text-slate-400'}`}>
{getScenarioIcon(s.scenario)}
</span>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${currentSessionId === s.id ? 'text-blue-700' : 'text-slate-700'}`}>
{s.title}
</div>
<div className="text-xs text-slate-400 mt-0.5">
{new Date(s.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</div>
</button>
<button
onClick={(e) => deleteSession(e, s.id)}
className="absolute right-2 top-3 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
)
))}
{Object.values(groupedSessions).every(g => g.length === 0) && (
<div className="text-center text-slate-400 py-8 text-sm italic">
{t.noHistory}
</div>
)}
</div>
</div>
</aside>
)}
</div>
);
};
export default App;