790 lines
35 KiB
TypeScript
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; |