调整页面和功能

This commit is contained in:
2025-12-23 17:10:20 +08:00
parent ec2f3ff10c
commit 5eeb9275e4
3 changed files with 474 additions and 325 deletions

751
App.tsx
View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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: "こんにちは。研究デザインや方法論(質的・量的)についての相談に乗ります。" }
}
}
};
};