调整页面和功能
This commit is contained in:
751
App.tsx
751
App.tsx
@@ -15,7 +15,11 @@ import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
GraduationCap,
|
||||
Coffee
|
||||
Coffee,
|
||||
History,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants';
|
||||
@@ -29,11 +33,21 @@ const App: React.FC = () => {
|
||||
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 [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
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(''); // For real-time effect
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [attachments, setAttachments] = useState<{mimeType: string, data: string, name?: string}[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
@@ -48,10 +62,24 @@ const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const loadedSessions = loadSessions();
|
||||
setSessions(loadedSessions);
|
||||
// Don't automatically select session on load, let user choose or start new
|
||||
if (loadedSessions.length > 0 && !currentSessionId) {
|
||||
setCurrentSessionId(loadedSessions[0].id);
|
||||
}
|
||||
// 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(() => {
|
||||
@@ -69,38 +97,20 @@ const App: React.FC = () => {
|
||||
// Helpers
|
||||
const getCurrentSession = () => sessions.find(s => s.id === currentSessionId);
|
||||
|
||||
const handleNewChatClick = () => {
|
||||
setCurrentSessionId(null);
|
||||
// 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');
|
||||
setIsSidebarOpen(false);
|
||||
setIsLeftSidebarOpen(false); // Close mobile menu if open
|
||||
};
|
||||
|
||||
const startScenarioSession = (scenario: ChatScenario) => {
|
||||
const scenarioConfig = t.scenarios[scenario];
|
||||
const initialGreeting = scenarioConfig.greeting;
|
||||
|
||||
const newSession: ChatSession = {
|
||||
id: Date.now().toString(),
|
||||
title: scenarioConfig.title,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'model',
|
||||
content: initialGreeting,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
],
|
||||
mode: ChatMode.STANDARD, // Default mode, can be changed
|
||||
scenario: scenario,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
setSessions([newSession, ...sessions]);
|
||||
setCurrentSessionId(newSession.id);
|
||||
};
|
||||
|
||||
const updateCurrentSession = (updater: (session: ChatSession) => ChatSession) => {
|
||||
setSessions(prev => prev.map(s => s.id === currentSessionId ? updater(s) : s));
|
||||
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) => {
|
||||
@@ -113,22 +123,100 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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 || !currentSessionId) return;
|
||||
if ((!input.trim() && attachments.length === 0) || isProcessing) return;
|
||||
|
||||
const session = getCurrentSession();
|
||||
if (!session) 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 })) // Simplify type for now
|
||||
attachments: attachments.map(a => ({ type: 'image', ...a }))
|
||||
};
|
||||
|
||||
updateCurrentSession(s => ({ ...s, messages: [...s.messages, userMsg] }));
|
||||
// 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);
|
||||
@@ -139,11 +227,11 @@ const App: React.FC = () => {
|
||||
let groundingData: any = null;
|
||||
|
||||
await streamChatResponse(
|
||||
[...session.messages, userMsg],
|
||||
updatedMessages,
|
||||
userMsg.content,
|
||||
session.mode,
|
||||
session!.mode,
|
||||
settings.language,
|
||||
session.scenario || ChatScenario.GENERAL,
|
||||
session!.scenario || ChatScenario.GENERAL,
|
||||
userMsg.attachments as any,
|
||||
(text, grounding) => {
|
||||
fullResponse += text;
|
||||
@@ -160,45 +248,41 @@ const App: React.FC = () => {
|
||||
groundingMetadata: groundingData
|
||||
};
|
||||
|
||||
updateCurrentSession(s => ({ ...s, messages: [...s.messages, modelMsg] }));
|
||||
|
||||
// Auto-update title if it's the first USER interaction (second message total due to greeting)
|
||||
if (session.messages.length === 1) { // 1 existing message (the greeting)
|
||||
const newTitle = userMsg.content.slice(0, 30) || t.newChat;
|
||||
updateCurrentSession(s => ({ ...s, title: newTitle }));
|
||||
}
|
||||
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: "Error: Could not generate response. Please check if API key is configured in environment.",
|
||||
content: t.apiError,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
updateCurrentSession(s => ({ ...s, messages: [...s.messages, errorMsg] }));
|
||||
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
|
||||
}]);
|
||||
setAttachments(prev => [...prev, { mimeType: file.type, data: base64Data, name: file.name }]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = ''; // Reset input
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRecordAudio = async () => {
|
||||
@@ -207,28 +291,24 @@ const App: React.FC = () => {
|
||||
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' }); // Chrome default
|
||||
// Convert blob to base64
|
||||
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); // Re-use processing state for spinner on button
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const text = await transcribeAudio(base64, 'audio/webm');
|
||||
setInput(prev => prev + " " + text);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Transcription failed");
|
||||
alert(t.transcriptionFail);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -236,12 +316,11 @@ const App: React.FC = () => {
|
||||
reader.readAsDataURL(blob);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch (e) {
|
||||
console.error("Mic error", e);
|
||||
alert("Microphone access denied or not available.");
|
||||
alert(t.micError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,228 +344,201 @@ const App: React.FC = () => {
|
||||
if (success) {
|
||||
setSettingsState(loadSettings());
|
||||
setSessions(loadSessions());
|
||||
alert("Import successful!");
|
||||
alert(t.importSuccess);
|
||||
} else {
|
||||
alert("Import failed.");
|
||||
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">
|
||||
{/* Sidebar - Mobile Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-20 md:hidden" onClick={() => setIsSidebarOpen(false)} />
|
||||
|
||||
{/* 1. LEFT SIDEBAR (Navigation) */}
|
||||
{isLeftSidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-20 md:hidden" onClick={() => setIsLeftSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<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
|
||||
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
md:relative md:translate-x-0 flex flex-col
|
||||
${isLeftSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<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={() => setIsSidebarOpen(false)} className="md:hidden text-slate-500">
|
||||
<button onClick={() => setIsLeftSidebarOpen(false)} className="md:hidden text-slate-500">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={handleNewChatClick}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 text-white py-2.5 rounded-xl hover:bg-blue-700 transition shadow-sm font-medium"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t.newChat}
|
||||
</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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-1">
|
||||
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 mt-2">History</div>
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
setCurrentSessionId(s.id);
|
||||
setActiveView('chat');
|
||||
setIsSidebarOpen(false);
|
||||
}}
|
||||
className={`w-full text-left p-3 rounded-lg text-sm truncate transition flex items-center gap-2 ${currentSessionId === s.id && activeView === 'chat' ? 'bg-blue-50 text-blue-700 font-medium' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<span className="opacity-70">{getScenarioIcon(s.scenario)}</span>
|
||||
<span className="truncate">{s.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-100 space-y-1">
|
||||
{/* 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('tools')}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg text-sm font-medium transition ${activeView === 'tools' ? 'bg-slate-100 text-slate-900' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<ImagePlus size={18} />
|
||||
{t.tools}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('settings')}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col h-full w-full relative">
|
||||
{/* 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={() => setIsSidebarOpen(true)} className="md:hidden p-2 text-slate-600">
|
||||
<button onClick={() => setIsLeftSidebarOpen(true)} className="md:hidden p-2 text-slate-600">
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
{activeView === 'chat' && currentSession && (
|
||||
{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.scenario)}</span>
|
||||
<span className="hidden sm:inline">{t.scenarios[currentSession.scenario || ChatScenario.GENERAL].title}</span>
|
||||
<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>
|
||||
|
||||
{/* Mode Switcher (Only visible in chat) */}
|
||||
{activeView === 'chat' && currentSession && (
|
||||
<div className="flex items-center space-x-2 bg-slate-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => updateCurrentSession(s => ({...s, mode: ChatMode.STANDARD}))}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition ${currentSession.mode === ChatMode.STANDARD ? 'bg-white shadow text-blue-600' : 'text-slate-500'}`}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCurrentSession(s => ({...s, mode: ChatMode.DEEP}))}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition ${currentSession.mode === ChatMode.DEEP ? 'bg-white shadow text-purple-600' : 'text-slate-500'}`}
|
||||
>
|
||||
Reasoning
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCurrentSession(s => ({...s, mode: ChatMode.FAST}))}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition ${currentSession.mode === ChatMode.FAST ? 'bg-white shadow text-green-600' : 'text-slate-500'}`}
|
||||
>
|
||||
Fast
|
||||
</button>
|
||||
</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">
|
||||
|
||||
{/* Chat View */}
|
||||
<div className="flex-1 overflow-hidden relative flex flex-col">
|
||||
{activeView === 'chat' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{!currentSession ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-slate-50 p-4 overflow-y-auto">
|
||||
<div className="max-w-4xl w-full text-center space-y-8">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-blue-100 w-16 h-16 rounded-2xl flex items-center justify-center mx-auto text-blue-600 mb-4">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">{t.welcome}</h2>
|
||||
<p className="text-slate-500">{t.tagline}</p>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-left">
|
||||
{/* Daily Q&A */}
|
||||
<button
|
||||
onClick={() => startScenarioSession(ChatScenario.GENERAL)}
|
||||
className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-orange-100 p-3 rounded-xl text-orange-600 group-hover:bg-orange-600 group-hover:text-white transition">
|
||||
<Coffee size={24} />
|
||||
</div>
|
||||
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-1 rounded-full">Basics</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-2">{t.scenarios.general.title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{t.scenarios.general.desc}</p>
|
||||
</button>
|
||||
|
||||
{/* Classic Reading */}
|
||||
<button
|
||||
onClick={() => startScenarioSession(ChatScenario.READING)}
|
||||
className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-purple-100 p-3 rounded-xl text-purple-600 group-hover:bg-purple-600 group-hover:text-white transition">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-1 rounded-full">Theory</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-2">{t.scenarios.reading.title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{t.scenarios.reading.desc}</p>
|
||||
</button>
|
||||
|
||||
{/* Concept */}
|
||||
<button
|
||||
onClick={() => startScenarioSession(ChatScenario.CONCEPT)}
|
||||
className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-blue-100 p-3 rounded-xl text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition">
|
||||
<Brain size={24} />
|
||||
</div>
|
||||
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-1 rounded-full">Deep Dive</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-2">{t.scenarios.concept.title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{t.scenarios.concept.desc}</p>
|
||||
</button>
|
||||
|
||||
{/* Research */}
|
||||
<button
|
||||
onClick={() => startScenarioSession(ChatScenario.RESEARCH)}
|
||||
className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-green-100 p-3 rounded-xl text-green-600 group-hover:bg-green-600 group-hover:text-white transition">
|
||||
<GraduationCap size={24} />
|
||||
</div>
|
||||
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-1 rounded-full">Advanced</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-2">{t.scenarios.research.title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{t.scenarios.research.desc}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<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>
|
||||
)}
|
||||
<div className={`max-w-[85%] md:max-w-[70%] space-y-1`}>
|
||||
{/* Attachments */}
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Bubble */}
|
||||
<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'
|
||||
@@ -497,7 +549,6 @@ const App: React.FC = () => {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Metadata / Actions */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 px-1">
|
||||
<span>{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
||||
{msg.role === 'model' && (
|
||||
@@ -513,7 +564,6 @@ const App: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sources List */}
|
||||
{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>
|
||||
@@ -532,7 +582,6 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming Pending State */}
|
||||
{isProcessing && streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="mr-3 flex-shrink-0 mt-1">
|
||||
@@ -552,91 +601,84 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-slate-100">
|
||||
<div className="max-w-4xl 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>
|
||||
))}
|
||||
{/* 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 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 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>
|
||||
)}
|
||||
|
||||
{/* Tools View */}
|
||||
{activeView === 'tools' && (
|
||||
<div className="h-full overflow-y-auto bg-slate-50/50">
|
||||
<Tools language={settings.language} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings View */}
|
||||
{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="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>
|
||||
|
||||
{/* Language */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Language</label>
|
||||
<select
|
||||
@@ -651,33 +693,96 @@ const App: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Management */}
|
||||
<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>
|
||||
<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" />
|
||||
{t.importData} <input type="file" onChange={handleImport} accept=".json" className="hidden" />
|
||||
</label>
|
||||
<button onClick={() => {
|
||||
if (window.confirm("Are you sure? This will delete all history.")) {
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
|
||||
setResultUrl(video);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Generation failed");
|
||||
setError(e.message || t.genError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
46
constants.ts
46
constants.ts
@@ -13,6 +13,9 @@ export const TRANSLATIONS = {
|
||||
modeDeep: "深度思考 (复杂推理)",
|
||||
modeFast: "极速模式 (快速响应)",
|
||||
tools: "创作工具",
|
||||
modules: "学习场景",
|
||||
studio: "多媒体实验室",
|
||||
history: "历史记录",
|
||||
imageGen: "图像生成",
|
||||
videoGen: "视频生成",
|
||||
uploadImage: "上传图片分析",
|
||||
@@ -40,6 +43,14 @@ export const TRANSLATIONS = {
|
||||
imagePromptPlaceholder: "描述您想生成的图片...",
|
||||
selectImageSize: "选择尺寸",
|
||||
videoDuration: "生成视频可能需要几分钟,请耐心等待。",
|
||||
confirmDelete: "确认删除此会话?",
|
||||
importSuccess: "导入成功!",
|
||||
importFail: "导入失败。",
|
||||
transcriptionFail: "转录失败",
|
||||
micError: "麦克风访问被拒绝或不可用。",
|
||||
genError: "生成失败",
|
||||
noHistory: "暂无历史记录。开始一段对话吧!",
|
||||
apiError: "错误:无法生成响应。请检查 API Key。",
|
||||
scenarios: {
|
||||
general: { title: "日常答疑", desc: "解答各类社会学基础问题", greeting: "你好!我是你的社会学学习搭子。有什么日常学习中的疑问需要我解答吗?" },
|
||||
reading: { title: "经典导读", desc: "马克思、韦伯、涂尔干等经典著作导读", greeting: "欢迎来到经典导读。今天你想通过哪位大家(如韦伯、涂尔干)的著作来深化理解?" },
|
||||
@@ -57,6 +68,9 @@ export const TRANSLATIONS = {
|
||||
modeDeep: "深度思考 (複雜推理)",
|
||||
modeFast: "極速模式 (快速響應)",
|
||||
tools: "創作工具",
|
||||
modules: "學習場景",
|
||||
studio: "多媒體實驗室",
|
||||
history: "歷史記錄",
|
||||
imageGen: "圖像生成",
|
||||
videoGen: "視頻生成",
|
||||
uploadImage: "上傳圖片分析",
|
||||
@@ -84,6 +98,14 @@ export const TRANSLATIONS = {
|
||||
imagePromptPlaceholder: "描述您想生成的圖片...",
|
||||
selectImageSize: "選擇尺寸",
|
||||
videoDuration: "生成視頻可能需要幾分鐘,請耐心等待。",
|
||||
confirmDelete: "確認刪除此會話?",
|
||||
importSuccess: "導入成功!",
|
||||
importFail: "導入失敗。",
|
||||
transcriptionFail: "轉錄失敗",
|
||||
micError: "麥克風訪問被拒絕或不可用。",
|
||||
genError: "生成失敗",
|
||||
noHistory: "暫無歷史記錄。開始一段對話吧!",
|
||||
apiError: "錯誤:無法生成響應。請檢查 API Key。",
|
||||
scenarios: {
|
||||
general: { title: "日常答疑", desc: "解答各類社會學基礎問題", greeting: "你好!我是你的社會學學習搭子。有什麼日常學習中的疑問需要我解答嗎?" },
|
||||
reading: { title: "經典導讀", desc: "馬克思、韋伯、塗爾干等經典著作導讀", greeting: "歡迎來到經典導讀。今天你想通過哪位大家(如韋伯、塗爾干)的著作來深化理解?" },
|
||||
@@ -101,6 +123,9 @@ export const TRANSLATIONS = {
|
||||
modeDeep: "Deep Think (Reasoning)",
|
||||
modeFast: "Fast (Lite)",
|
||||
tools: "Creative Tools",
|
||||
modules: "Learning Modules",
|
||||
studio: "Media Studio",
|
||||
history: "History",
|
||||
imageGen: "Image Gen",
|
||||
videoGen: "Video Gen",
|
||||
uploadImage: "Analyze Image",
|
||||
@@ -128,6 +153,14 @@ export const TRANSLATIONS = {
|
||||
imagePromptPlaceholder: "Describe the image to generate...",
|
||||
selectImageSize: "Select Size",
|
||||
videoDuration: "Video generation may take a few minutes.",
|
||||
confirmDelete: "Delete this chat?",
|
||||
importSuccess: "Import successful!",
|
||||
importFail: "Import failed.",
|
||||
transcriptionFail: "Transcription failed",
|
||||
micError: "Microphone access denied or not available.",
|
||||
genError: "Generation failed",
|
||||
noHistory: "No history yet. Start a conversation!",
|
||||
apiError: "Error: Could not generate response. Please check API Key.",
|
||||
scenarios: {
|
||||
general: { title: "Daily Q&A", desc: "General sociology questions", greeting: "Hi! I'm your sociology study companion. Do you have any questions for me today?" },
|
||||
reading: { title: "Classic Readings", desc: "Guide to Marx, Weber, Durkheim...", greeting: "Welcome to Classic Readings. Which foundational text or theorist shall we explore today?" },
|
||||
@@ -145,6 +178,9 @@ export const TRANSLATIONS = {
|
||||
modeDeep: "深い思考 (推論)",
|
||||
modeFast: "高速 (ライト)",
|
||||
tools: "クリエイティブツール",
|
||||
modules: "学習モジュール",
|
||||
studio: "メディアスタジオ",
|
||||
history: "履歴",
|
||||
imageGen: "画像生成",
|
||||
videoGen: "動画生成",
|
||||
uploadImage: "画像分析",
|
||||
@@ -172,6 +208,14 @@ export const TRANSLATIONS = {
|
||||
imagePromptPlaceholder: "生成したい画像を説明してください...",
|
||||
selectImageSize: "サイズを選択",
|
||||
videoDuration: "動画の生成には数分かかる場合があります。",
|
||||
confirmDelete: "このチャットを削除しますか?",
|
||||
importSuccess: "インポート成功!",
|
||||
importFail: "インポート失敗。",
|
||||
transcriptionFail: "転写に失敗しました",
|
||||
micError: "マイクへのアクセスが拒否されたか、利用できません。",
|
||||
genError: "生成に失敗しました",
|
||||
noHistory: "履歴はまだありません。会話を始めましょう!",
|
||||
apiError: "エラー:応答を生成できませんでした。APIキーを確認してください。",
|
||||
scenarios: {
|
||||
general: { title: "日常のQ&A", desc: "一般的な社会学の質問", greeting: "こんにちは!社会学の学習パートナーです。今日の質問は何ですか?" },
|
||||
reading: { title: "古典講読", desc: "マルクス、ウェーバー、デュルケーム...", greeting: "古典講読へようこそ。今日はどの社会学者の著作を深掘りしましょうか?" },
|
||||
@@ -179,4 +223,4 @@ export const TRANSLATIONS = {
|
||||
research: { title: "研究相談", desc: "方法論とデザイン", greeting: "こんにちは。研究デザインや方法論(質的・量的)についての相談に乗ります。" }
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user