1136 lines
56 KiB
TypeScript
1136 lines
56 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
Settings, Brain, Search, Image as ImageIcon,
|
|
Video, Mic, Send, Upload, Download, Copy, Share2,
|
|
Menu, X, Sun, Moon, Volume2, Globe, Trash2, Plus, Info,
|
|
PanelRight, PanelRightClose, History, Home as HomeIcon,
|
|
Sparkles, Layers, Sliders, MonitorDown, AlertTriangle,
|
|
Sigma, Cpu, Code, Box, Network, Bot, Binary
|
|
} from 'lucide-react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import html2canvas from 'html2canvas';
|
|
|
|
import { AppModule, Message, MessageRole, Session, AppSettings, VeoConfig, ImageConfig } from './types';
|
|
import { GeminiService } from './services/gemini';
|
|
import { t } from './services/i18n';
|
|
import { GenerateContentResponse } from '@google/genai';
|
|
|
|
// --- Components ---
|
|
|
|
// Button Component
|
|
const Button: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' | 'ghost' | 'danger' }> =
|
|
({ className = '', variant = 'primary', children, ...props }) => {
|
|
const baseStyle = "px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95";
|
|
const variants = {
|
|
primary: "bg-blue-600 text-white hover:bg-blue-700 shadow-md hover:shadow-lg hover:-translate-y-0.5",
|
|
secondary: "bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700 hover:-translate-y-0.5 shadow-sm",
|
|
ghost: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800",
|
|
danger: "bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50 dark:hover:bg-red-900/30 hover:-translate-y-0.5"
|
|
};
|
|
return <button className={`${baseStyle} ${variants[variant]} ${className}`} {...props}>{children}</button>;
|
|
};
|
|
|
|
// Modal Component
|
|
const Modal: React.FC<{ isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }> = ({ isOpen, onClose, title, children }) => {
|
|
if (!isOpen) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-slide-up border border-gray-200 dark:border-slate-700 flex flex-col max-h-[90dvh]">
|
|
<div className="flex items-center justify-between p-4 border-b dark:border-slate-700 shrink-0">
|
|
<h3 className="text-lg font-bold">{title}</h3>
|
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full"><X size={20} /></button>
|
|
</div>
|
|
<div className="p-6 overflow-y-auto">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Constants for UI
|
|
const SIDEBAR_GROUPS = [
|
|
{
|
|
title: 'group.cs',
|
|
modules: [
|
|
{ id: AppModule.MATH, icon: Sigma, label: 'module.math', desc: 'desc.math' },
|
|
{ id: AppModule.THEORY, icon: Binary, label: 'module.theory', desc: 'desc.theory' },
|
|
{ id: AppModule.PRINCIPLES, icon: Cpu, label: 'module.principles', desc: 'desc.principles' },
|
|
{ id: AppModule.SOFT_ENG, icon: Code, label: 'module.soft_eng', desc: 'desc.soft_eng' },
|
|
{ id: AppModule.GRAPHICS, icon: Box, label: 'module.graphics', desc: 'desc.graphics' },
|
|
{ id: AppModule.NETWORK, icon: Network, label: 'module.network', desc: 'desc.network' },
|
|
{ id: AppModule.AI_LAB, icon: Bot, label: 'module.ai_lab', desc: 'desc.ai_lab' },
|
|
]
|
|
},
|
|
{
|
|
title: 'group.tools',
|
|
modules: [
|
|
{ id: AppModule.RESEARCH, icon: Search, label: 'module.research', desc: 'desc.research' },
|
|
]
|
|
},
|
|
{
|
|
title: 'group.creation',
|
|
modules: [
|
|
{ id: AppModule.VISION, icon: ImageIcon, label: 'module.vision', desc: 'desc.vision' },
|
|
{ id: AppModule.STUDIO, icon: Video, label: 'module.studio', desc: 'desc.studio' },
|
|
{ id: AppModule.AUDIO, icon: Mic, label: 'module.audio', desc: 'desc.audio' },
|
|
]
|
|
}
|
|
];
|
|
|
|
// Main App
|
|
export default function App() {
|
|
// --- State ---
|
|
const [settings, setSettings] = useState<AppSettings>(() => {
|
|
const saved = localStorage.getItem('bitsage_settings');
|
|
return saved ? JSON.parse(saved) : {
|
|
apiKey: '',
|
|
language: 'zh-CN',
|
|
theme: 'system',
|
|
hasCompletedOnboarding: false
|
|
};
|
|
});
|
|
|
|
const [sessions, setSessions] = useState<Session[]>(() => {
|
|
const saved = localStorage.getItem('bitsage_sessions');
|
|
return saved ? JSON.parse(saved) : [];
|
|
});
|
|
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
|
const [currentModule, setCurrentModule] = useState<AppModule>(AppModule.MATH);
|
|
const [isHome, setIsHome] = useState(true);
|
|
|
|
const [inputText, setInputText] = useState('');
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // Left Sidebar
|
|
const [isHistoryOpen, setIsHistoryOpen] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024); // Right Sidebar
|
|
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [loadingText, setLoadingText] = useState('');
|
|
|
|
// Thinking Toggle State
|
|
const [isThinkingMode, setIsThinkingMode] = useState(false);
|
|
|
|
// PWA Install Prompt
|
|
const [installPrompt, setInstallPrompt] = useState<any>(null);
|
|
|
|
// Attachments
|
|
const [attachments, setAttachments] = useState<{data: string, mimeType: string}[]>([]);
|
|
|
|
// Gen Configs
|
|
const [veoConfig, setVeoConfig] = useState<VeoConfig>({ aspectRatio: '16:9', resolution: '720p' });
|
|
const [imgConfig, setImgConfig] = useState<ImageConfig>({ size: '1K', aspectRatio: '1:1' });
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const geminiRef = useRef<GeminiService | null>(null);
|
|
|
|
// --- Effects ---
|
|
|
|
useEffect(() => {
|
|
const handleBeforeInstallPrompt = (e: any) => {
|
|
e.preventDefault();
|
|
setInstallPrompt(e);
|
|
};
|
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('bitsage_settings', JSON.stringify(settings));
|
|
if (settings.theme === 'dark' || (settings.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
geminiRef.current = new GeminiService(settings.apiKey);
|
|
}, [settings]);
|
|
|
|
useEffect(() => {
|
|
// Only save sessions that are NOT from Creative Studio modules
|
|
const sessionsToSave = sessions.filter(s =>
|
|
![AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO].includes(s.module)
|
|
);
|
|
localStorage.setItem('bitsage_sessions', JSON.stringify(sessionsToSave));
|
|
}, [sessions]);
|
|
|
|
useEffect(() => {
|
|
if (!isHome && !isCreativeModule(currentModule)) {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [sessions, currentSessionId, isLoading, isHome]);
|
|
|
|
// --- Helpers ---
|
|
|
|
const isCreativeModule = (mod: AppModule) => {
|
|
return [AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO].includes(mod);
|
|
};
|
|
|
|
const getCurrentSession = useCallback(() => {
|
|
return sessions.find(s => s.id === currentSessionId);
|
|
}, [sessions, currentSessionId]);
|
|
|
|
const handleModuleSelect = (module: AppModule) => {
|
|
setIsHome(false);
|
|
setCurrentModule(module);
|
|
setCurrentSessionId(null); // Draft mode
|
|
if (window.innerWidth < 768) setIsSidebarOpen(false);
|
|
|
|
// Auto-open history on large screens ONLY if NOT creative module
|
|
if (isCreativeModule(module)) {
|
|
setIsHistoryOpen(false);
|
|
} else if (window.innerWidth >= 1024) {
|
|
setIsHistoryOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleHomeSelect = () => {
|
|
setIsHome(true);
|
|
if (window.innerWidth < 768) setIsSidebarOpen(false);
|
|
};
|
|
|
|
const handleSessionSelect = (sessionId: string, module: AppModule) => {
|
|
setIsHome(false);
|
|
setCurrentModule(module);
|
|
setCurrentSessionId(sessionId);
|
|
if (window.innerWidth < 768) {
|
|
setIsSidebarOpen(false); // Close left nav
|
|
setIsHistoryOpen(false); // Close right nav on mobile after selection
|
|
}
|
|
};
|
|
|
|
const deleteSession = (e: React.MouseEvent, sessionId: string) => {
|
|
e.stopPropagation();
|
|
if (window.confirm(t('confirm.delete', settings.language))) {
|
|
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
|
if (currentSessionId === sessionId) {
|
|
setCurrentSessionId(null); // Go to draft mode
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateSessionMessages = (sessionId: string, newMessages: Message[]) => {
|
|
setSessions(prev => prev.map(s => {
|
|
if (s.id === sessionId) {
|
|
// Auto update title if it's the first user message
|
|
let title = s.title;
|
|
const userMsg = newMessages.find(m => m.role === MessageRole.USER);
|
|
if (s.messages.length === 0 && userMsg?.text) {
|
|
title = userMsg.text.slice(0, 30) + (userMsg.text.length > 30 ? '...' : '');
|
|
}
|
|
return { ...s, messages: newMessages, title, updatedAt: Date.now() };
|
|
}
|
|
return s;
|
|
}));
|
|
};
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
const base64 = (evt.target?.result as string).split(',')[1];
|
|
setAttachments(prev => [...prev, { data: base64, mimeType: file.type }]);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const exportData = () => {
|
|
const dataStr = JSON.stringify({ settings, sessions });
|
|
const blob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `bitsage_backup_${new Date().toISOString()}.json`;
|
|
a.click();
|
|
};
|
|
|
|
const importData = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
try {
|
|
const data = JSON.parse(evt.target?.result as string);
|
|
if (data.settings) setSettings(data.settings);
|
|
if (data.sessions) setSessions(data.sessions);
|
|
alert(t('alert.import_success', settings.language));
|
|
} catch (err) {
|
|
alert(t('alert.invalid_file', settings.language));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleClearData = () => {
|
|
if (window.confirm(t('confirm.clear_data', settings.language))) {
|
|
localStorage.clear();
|
|
window.location.reload();
|
|
}
|
|
};
|
|
|
|
const handleInstallClick = async () => {
|
|
if (!installPrompt) return;
|
|
installPrompt.prompt();
|
|
const { outcome } = await installPrompt.userChoice;
|
|
if (outcome === 'accepted') {
|
|
setInstallPrompt(null);
|
|
}
|
|
};
|
|
|
|
// --- Core Actions ---
|
|
|
|
const handleSend = async () => {
|
|
if ((!inputText.trim() && attachments.length === 0) || isLoading) return;
|
|
if (!settings.apiKey) {
|
|
setIsSettingsOpen(true);
|
|
return;
|
|
}
|
|
|
|
let activeSessionId = currentSessionId;
|
|
let currentSessionMessages: Message[] = [];
|
|
|
|
// Lazy Creation: Create session now if it doesn't exist
|
|
if (!activeSessionId) {
|
|
const newSession: Session = {
|
|
id: Date.now().toString(),
|
|
title: isCreativeModule(currentModule) ? inputText.slice(0, 20) : t('action.new_chat', settings.language),
|
|
module: currentModule,
|
|
messages: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
};
|
|
setSessions(prev => [newSession, ...prev]);
|
|
setCurrentSessionId(newSession.id);
|
|
activeSessionId = newSession.id;
|
|
currentSessionMessages = [];
|
|
} else {
|
|
const session = getCurrentSession();
|
|
if (session) currentSessionMessages = session.messages;
|
|
}
|
|
|
|
if (!activeSessionId) return;
|
|
|
|
const userMsg: Message = {
|
|
id: Date.now().toString(),
|
|
role: MessageRole.USER,
|
|
text: inputText,
|
|
timestamp: Date.now(),
|
|
images: attachments.filter(a => a.mimeType.startsWith('image/')).map(a => a.data)
|
|
};
|
|
|
|
const newMessages = [...currentSessionMessages, userMsg];
|
|
updateSessionMessages(activeSessionId, newMessages);
|
|
|
|
setInputText('');
|
|
setAttachments([]);
|
|
setIsLoading(true);
|
|
|
|
// Initial placeholder for AI response
|
|
const botMsgId = (Date.now() + 1).toString();
|
|
updateSessionMessages(activeSessionId, [...newMessages, {
|
|
id: botMsgId,
|
|
role: MessageRole.MODEL,
|
|
timestamp: Date.now(),
|
|
text: '',
|
|
isThinking: isThinkingMode && !isCreativeModule(currentModule)
|
|
}]);
|
|
|
|
try {
|
|
if (currentModule === AppModule.STUDIO && inputText) {
|
|
setLoadingText(t('status.generating', settings.language));
|
|
const videoUrl = await geminiRef.current!.generateVideo(inputText, veoConfig);
|
|
updateSessionMessages(activeSessionId, [...newMessages, {
|
|
id: botMsgId, role: MessageRole.MODEL, timestamp: Date.now(), video: videoUrl
|
|
}]);
|
|
} else if (currentModule === AppModule.VISION && inputText.toLowerCase().includes('generate') && attachments.length === 0) {
|
|
setLoadingText(t('status.generating', settings.language));
|
|
const imgUrl = await geminiRef.current!.generateImage(inputText, imgConfig);
|
|
updateSessionMessages(activeSessionId, [...newMessages, {
|
|
id: botMsgId, role: MessageRole.MODEL, timestamp: Date.now(), images: [imgUrl.split(',')[1]]
|
|
}]);
|
|
} else {
|
|
// Text/Chat Generation
|
|
if (isThinkingMode) setLoadingText(t('status.thinking', settings.language));
|
|
else setLoadingText(t('status.generating', settings.language));
|
|
|
|
// Prepare history for API
|
|
const historyParts = newMessages.map(m => {
|
|
const parts: any[] = [];
|
|
if (m.text) parts.push({ text: m.text });
|
|
if (m.images) m.images.forEach(img => parts.push({ inlineData: { mimeType: 'image/jpeg', data: img } }));
|
|
return { role: m.role, parts };
|
|
});
|
|
|
|
// Pass isThinkingMode to the service
|
|
const stream = await geminiRef.current!.generateText(inputText, currentModule, historyParts, attachments, isThinkingMode);
|
|
|
|
let fullText = '';
|
|
let sources: any[] = [];
|
|
|
|
for await (const chunk of stream) {
|
|
const c = chunk as GenerateContentResponse;
|
|
if (c.text) {
|
|
fullText += c.text;
|
|
// Update UI in real-time
|
|
setSessions(prev => prev.map(s => {
|
|
if (s.id === activeSessionId) {
|
|
const msgs = [...s.messages];
|
|
const lastMsg = msgs[msgs.length - 1];
|
|
if (lastMsg.id === botMsgId) {
|
|
lastMsg.text = fullText;
|
|
lastMsg.isThinking = false; // Turn off thinking indicator once text starts arriving (simplification)
|
|
}
|
|
return { ...s, messages: msgs };
|
|
}
|
|
return s;
|
|
}));
|
|
}
|
|
if (c.candidates?.[0]?.groundingMetadata?.groundingChunks) {
|
|
const chunks = c.candidates[0].groundingMetadata.groundingChunks;
|
|
chunks.forEach((ch: any) => {
|
|
if (ch.web?.uri) sources.push({ uri: ch.web.uri, title: ch.web.title });
|
|
});
|
|
}
|
|
}
|
|
|
|
// Final update with sources
|
|
setSessions(prev => prev.map(s => {
|
|
if (s.id === activeSessionId) {
|
|
const msgs = [...s.messages];
|
|
const lastMsg = msgs[msgs.length - 1];
|
|
if (lastMsg.id === botMsgId) {
|
|
lastMsg.sources = sources.length > 0 ? sources : undefined;
|
|
}
|
|
return { ...s, messages: msgs };
|
|
}
|
|
return s;
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
updateSessionMessages(activeSessionId, [...newMessages, {
|
|
id: botMsgId, role: MessageRole.MODEL, timestamp: Date.now(), text: `Error: ${(err as Error).message}`
|
|
}]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
setLoadingText('');
|
|
}
|
|
};
|
|
|
|
const handleTTS = async (text: string) => {
|
|
try {
|
|
setIsLoading(true);
|
|
const audioBase64 = await geminiRef.current!.generateSpeech(text);
|
|
const audio = new Audio(`data:audio/mp3;base64,${audioBase64}`);
|
|
audio.play();
|
|
} catch (e) {
|
|
alert('TTS Error: ' + (e as Error).message);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCopy = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
const handleShare = async () => {
|
|
const el = document.getElementById('chat-container');
|
|
if (el) {
|
|
const canvas = await html2canvas(el);
|
|
const link = document.createElement('a');
|
|
link.download = `bitsage-share-${Date.now()}.png`;
|
|
link.href = canvas.toDataURL();
|
|
link.click();
|
|
}
|
|
};
|
|
|
|
// --- Renderers ---
|
|
|
|
const renderSidebar = () => (
|
|
<div className={`fixed inset-y-0 left-0 z-40 w-64 bg-white dark:bg-slate-900 border-r dark:border-slate-700 transform transition-transform duration-300 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0`}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-4 border-b dark:border-slate-700 flex items-center justify-between">
|
|
<h1 onClick={handleHomeSelect} className="cursor-pointer text-xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent flex items-center gap-2">
|
|
<Brain className="text-blue-600" /> {t('app.name', settings.language)}
|
|
</h1>
|
|
<button onClick={() => setIsSidebarOpen(false)} className="md:hidden p-1"><X/></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{/* Home Link */}
|
|
<button
|
|
onClick={handleHomeSelect}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${isHome ? 'bg-blue-50 dark:bg-slate-800 text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-800'}`}
|
|
>
|
|
<HomeIcon size={18} />
|
|
{t('menu.home', settings.language)}
|
|
</button>
|
|
|
|
{/* Module Groups */}
|
|
{SIDEBAR_GROUPS.map((group, groupIdx) => (
|
|
<div key={groupIdx}>
|
|
<h3 className="text-xs font-bold text-gray-500 uppercase px-2 mb-2 tracking-wider">
|
|
{t(group.title, settings.language)}
|
|
</h3>
|
|
<div className="space-y-1">
|
|
{group.modules.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => handleModuleSelect(m.id)}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${!isHome && currentModule === m.id ? 'bg-blue-50 dark:bg-slate-800 text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-800'}`}
|
|
>
|
|
<m.icon size={18} />
|
|
{t(m.label, settings.language)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-4 border-t dark:border-slate-700 space-y-2">
|
|
{installPrompt && (
|
|
<button
|
|
onClick={handleInstallClick}
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40"
|
|
>
|
|
<MonitorDown size={18} /> {t('action.install', settings.language)}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsSettingsOpen(true)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800"
|
|
>
|
|
<Settings size={18} /> {t('settings.title', settings.language)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderHistorySidebar = () => {
|
|
// Hide history on Home page or Creative Modules
|
|
if (isHome || isCreativeModule(currentModule)) return null;
|
|
|
|
const moduleSessions = sessions.filter(s => s.module === currentModule);
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile Backdrop */}
|
|
{isHistoryOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-20 md:hidden backdrop-blur-sm"
|
|
onClick={() => setIsHistoryOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar Panel */}
|
|
<div className={`
|
|
fixed inset-y-0 right-0 z-30 w-72 bg-gray-50 dark:bg-slate-900 border-l dark:border-slate-700
|
|
transform transition-transform duration-300 ease-in-out
|
|
${isHistoryOpen ? 'translate-x-0' : 'translate-x-full'}
|
|
md:relative md:translate-x-0
|
|
${isHistoryOpen ? 'md:w-72' : 'md:w-0 md:overflow-hidden md:border-l-0'}
|
|
`}>
|
|
<div className="flex flex-col h-full w-72">
|
|
<div className="p-4 border-b dark:border-slate-700 flex items-center justify-between h-16 shrink-0">
|
|
<h3 className="font-semibold text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
|
<History size={18} /> {t('history.title', settings.language)}
|
|
</h3>
|
|
<button onClick={() => setIsHistoryOpen(false)} className="p-1 hover:bg-gray-200 dark:hover:bg-slate-800 rounded-full md:hidden">
|
|
<X size={18} />
|
|
</button>
|
|
<button onClick={() => setIsHistoryOpen(false)} className="p-1 hover:bg-gray-200 dark:hover:bg-slate-800 rounded-full hidden md:block">
|
|
<PanelRightClose size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
<Button variant="secondary" className="w-full mb-4 text-sm" onClick={() => setCurrentSessionId(null)}>
|
|
<Plus size={16} /> {t('action.new_chat', settings.language)}
|
|
</Button>
|
|
|
|
{moduleSessions.length === 0 ? (
|
|
<div className="text-center text-gray-400 text-sm mt-8">
|
|
{t('history.empty', settings.language)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{moduleSessions.map(s => (
|
|
<div
|
|
key={s.id}
|
|
onClick={() => handleSessionSelect(s.id, s.module)}
|
|
className={`group relative p-3 rounded-lg cursor-pointer transition-colors text-sm border ${currentSessionId === s.id ? 'bg-white dark:bg-slate-800 border-blue-200 dark:border-blue-900 shadow-sm' : 'border-transparent hover:bg-white dark:hover:bg-slate-800 hover:border-gray-200 dark:hover:border-slate-700'}`}
|
|
>
|
|
<p className={`font-medium truncate pr-6 ${currentSessionId === s.id ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
|
{s.title}
|
|
</p>
|
|
<span className="text-xs text-gray-400">
|
|
{new Date(s.updatedAt).toLocaleDateString()}
|
|
</span>
|
|
<button
|
|
onClick={(e) => deleteSession(e, s.id)}
|
|
className="absolute right-2 top-3 opacity-0 group-hover:opacity-100 p-1 hover:text-red-500 transition-opacity"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderHome = () => (
|
|
<div className="h-full w-full overflow-y-auto flex flex-col items-center p-4 animate-fade-in">
|
|
<div className="max-w-4xl w-full my-auto py-10 pb-24">
|
|
<div className="text-center mb-12 animate-slide-up">
|
|
<div className="bg-blue-100 dark:bg-blue-900/30 p-6 rounded-full inline-block mb-6 shadow-lg shadow-blue-500/20">
|
|
<Brain size={64} className="text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<h1 className="text-3xl md:text-4xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
|
{t('welcome.title', settings.language)}
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 text-lg max-w-xl mx-auto">
|
|
{t('welcome.subtitle', settings.language)}
|
|
</p>
|
|
{!settings.apiKey && (
|
|
<div className="mt-8">
|
|
<Button onClick={() => setIsSettingsOpen(true)} className="animate-pulse-slow">{t('btn.start', settings.language)}</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-8">
|
|
{SIDEBAR_GROUPS.flatMap(g => g.modules).map((m, i) => (
|
|
<button
|
|
key={m.id}
|
|
style={{ animationDelay: `${i * 100}ms` }}
|
|
onClick={() => handleModuleSelect(m.id)}
|
|
className="flex items-start gap-4 p-6 rounded-xl bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-700 hover:shadow-xl hover:-translate-y-1 transition-all duration-300 text-left group animate-slide-up opacity-0 fill-mode-forwards"
|
|
>
|
|
<div className="p-3 rounded-lg bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-400 group-hover:bg-blue-600 group-hover:text-white transition-colors duration-300">
|
|
<m.icon size={24} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{t(m.label, settings.language)}</h3>
|
|
{/* @ts-ignore */}
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{t(m.desc, settings.language)}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderMessage = (msg: Message) => (
|
|
<div key={msg.id} className={`flex ${msg.role === MessageRole.USER ? 'justify-end' : 'justify-start'} mb-6 group animate-slide-up`}>
|
|
<div className={`max-w-[85%] md:max-w-[75%] rounded-2xl p-4 transition-all duration-300 hover:shadow-md ${msg.role === MessageRole.USER ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 border dark:border-slate-700 shadow-sm'}`}>
|
|
|
|
{msg.images && msg.images.map((img, i) => (
|
|
<img key={i} src={`data:image/jpeg;base64,${img}`} alt="Generated" className="rounded-lg mb-2 max-h-64 object-contain bg-black/5" />
|
|
))}
|
|
|
|
{msg.video && (
|
|
<video src={msg.video} controls className="rounded-lg mb-2 max-h-64 w-full bg-black" />
|
|
)}
|
|
|
|
{msg.isThinking && msg.text && (
|
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2 italic">
|
|
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
|
{t('msg.thinking', settings.language)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={`markdown-body text-sm ${msg.role === MessageRole.USER ? 'text-white' : 'text-gray-800 dark:text-gray-200'}`}>
|
|
{!msg.text && msg.role === MessageRole.MODEL ? (
|
|
/* Typing Animation */
|
|
<div className="typing-indicator">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
) : (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.text || ''}</ReactMarkdown>
|
|
)}
|
|
</div>
|
|
|
|
{msg.sources && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-slate-700">
|
|
<p className="text-xs font-semibold text-gray-500 mb-1">{t('msg.sources', settings.language)}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{msg.sources.map((src, i) => (
|
|
<a key={i} href={src.uri} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs bg-gray-100 dark:bg-slate-700 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors truncate max-w-[200px]">
|
|
<Globe size={10} /> {src.title || new URL(src.uri).hostname}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{msg.text && (
|
|
<div className={`flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity ${msg.role === MessageRole.USER ? 'text-blue-100' : 'text-gray-400'}`}>
|
|
<button onClick={() => handleCopy(msg.text || '')} className="p-1 hover:bg-black/10 rounded transition-colors"><Copy size={14}/></button>
|
|
{msg.role === MessageRole.MODEL && (
|
|
<button onClick={() => handleTTS(msg.text || '')} className="p-1 hover:bg-black/10 rounded transition-colors"><Volume2 size={14}/></button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Creative Studio Specific Components
|
|
const renderCreativeStudio = () => {
|
|
const session = getCurrentSession();
|
|
const messages = session ? [...session.messages].reverse() : [];
|
|
|
|
// Group Model outputs with their preceding user inputs
|
|
const generations: { id: string, prompt: string, output: Message, timestamp: number }[] = [];
|
|
|
|
// Simple pairing logic: Find model message, look for immediate preceding user message
|
|
for (let i = 0; i < messages.length; i++) {
|
|
if (messages[i].role === MessageRole.MODEL) {
|
|
const userMsg = messages[i+1]; // since reversed
|
|
if (userMsg && userMsg.role === MessageRole.USER) {
|
|
generations.push({
|
|
id: messages[i].id,
|
|
prompt: userMsg.text || '',
|
|
output: messages[i],
|
|
timestamp: messages[i].timestamp
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-gray-50 dark:bg-slate-900">
|
|
|
|
{/* Top: Workbench / Input Area */}
|
|
<div className="bg-white dark:bg-slate-800 border-b dark:border-slate-700 p-6 shrink-0 z-10 shadow-sm">
|
|
<div className="max-w-5xl mx-auto">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<span className="text-xs font-bold text-blue-600 uppercase tracking-wider">{t('ui.workbench', settings.language)}</span>
|
|
<div className="h-px bg-gray-200 dark:bg-slate-700 flex-1"></div>
|
|
</div>
|
|
|
|
{/* Config Row */}
|
|
<div className="flex flex-wrap gap-4 mb-4">
|
|
{currentModule === AppModule.STUDIO && (
|
|
<>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
<Layers size={16} />
|
|
<select
|
|
className="bg-gray-100 dark:bg-slate-700 border-none rounded-lg px-3 py-1.5 outline-none cursor-pointer"
|
|
value={veoConfig.aspectRatio} onChange={e => setVeoConfig({...veoConfig, aspectRatio: e.target.value as any})}
|
|
>
|
|
<option value="16:9">{t('opt.landscape', settings.language)}</option>
|
|
<option value="9:16">{t('opt.portrait', settings.language)}</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
<Sliders size={16} />
|
|
<select
|
|
className="bg-gray-100 dark:bg-slate-700 border-none rounded-lg px-3 py-1.5 outline-none cursor-pointer"
|
|
value={veoConfig.resolution} onChange={e => setVeoConfig({...veoConfig, resolution: e.target.value as any})}
|
|
>
|
|
<option value="720p">720p</option>
|
|
<option value="1080p">1080p</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
{currentModule === AppModule.VISION && (
|
|
<>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
<Layers size={16} />
|
|
<select
|
|
className="bg-gray-100 dark:bg-slate-700 border-none rounded-lg px-3 py-1.5 outline-none cursor-pointer"
|
|
value={imgConfig.aspectRatio} onChange={e => setImgConfig({...imgConfig, aspectRatio: e.target.value as any})}
|
|
>
|
|
<option value="1:1">{t('opt.square', settings.language)}</option>
|
|
<option value="16:9">{t('opt.wide', settings.language)}</option>
|
|
<option value="9:16">{t('opt.portrait', settings.language)}</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
<Sliders size={16} />
|
|
<select
|
|
className="bg-gray-100 dark:bg-slate-700 border-none rounded-lg px-3 py-1.5 outline-none cursor-pointer"
|
|
value={imgConfig.size} onChange={e => setImgConfig({...imgConfig, size: e.target.value as any})}
|
|
>
|
|
<option value="1K">1K</option>
|
|
<option value="2K">2K</option>
|
|
<option value="4K">4K</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input & Action */}
|
|
<div className="relative">
|
|
<textarea
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
placeholder={
|
|
currentModule === AppModule.STUDIO ? t('veo.prompt', settings.language) :
|
|
currentModule === AppModule.AUDIO ? t('audio.prompt', settings.language) :
|
|
t('img.prompt', settings.language)
|
|
}
|
|
className="w-full bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-xl p-4 pr-32 focus:ring-2 focus:ring-blue-500 outline-none resize-none h-32 transition-all"
|
|
/>
|
|
<div className="absolute bottom-4 right-4 flex items-center gap-2">
|
|
<label className="p-2 text-gray-500 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg cursor-pointer transition-colors" title={t('action.upload', settings.language)}>
|
|
<input type="file" className="hidden" accept="image/*" onChange={handleFileUpload} />
|
|
<ImageIcon size={20} />
|
|
</label>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!inputText.trim() || isLoading}
|
|
className="shadow-lg"
|
|
>
|
|
{isLoading ? t('status.generating', settings.language) : (
|
|
<>
|
|
<Sparkles size={18} /> {t('action.generate', settings.language)}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{attachments.length > 0 && (
|
|
<div className="absolute top-4 right-4 flex gap-2">
|
|
{attachments.map((a, i) => (
|
|
<div key={i} className="relative group">
|
|
<img src={`data:${a.mimeType};base64,${a.data}`} className="h-12 w-12 object-cover rounded border border-gray-300 shadow-sm" alt="preview" />
|
|
<button onClick={() => setAttachments(attachments.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5"><X size={10}/></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom: Guide OR Gallery */}
|
|
<div className="flex-1 overflow-y-auto p-6 scroll-smooth bg-gray-50 dark:bg-slate-900">
|
|
<div className="max-w-5xl mx-auto h-full">
|
|
{generations.length === 0 && !isLoading ? (
|
|
// Empty State / Guide
|
|
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in p-8 border-2 border-dashed border-gray-200 dark:border-slate-700 rounded-2xl">
|
|
<div className="w-20 h-20 bg-blue-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-6 text-blue-600 dark:text-blue-400">
|
|
{currentModule === AppModule.VISION && <ImageIcon size={40} />}
|
|
{currentModule === AppModule.STUDIO && <Video size={40} />}
|
|
{currentModule === AppModule.AUDIO && <Mic size={40} />}
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">
|
|
{t(`guide.${currentModule}.title`, settings.language)}
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 max-w-lg mb-8">
|
|
{t(`guide.${currentModule}.desc`, settings.language)}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-2xl text-left">
|
|
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-gray-100 dark:border-slate-700">
|
|
<h4 className="font-semibold mb-2 flex items-center gap-2"><Info size={16} className="text-blue-500"/> Tip 1</h4>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{t(`guide.${currentModule}.tip1`, settings.language)}</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-gray-100 dark:border-slate-700">
|
|
<h4 className="font-semibold mb-2 flex items-center gap-2"><Info size={16} className="text-blue-500"/> Tip 2</h4>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{t(`guide.${currentModule}.tip2`, settings.language)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Gallery Grid
|
|
<>
|
|
<div className="flex items-center gap-2 mb-6">
|
|
<span className="text-xs font-bold text-gray-500 uppercase tracking-wider">{t('ui.gallery', settings.language)}</span>
|
|
<div className="h-px bg-gray-200 dark:bg-slate-700 flex-1"></div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 pb-20">
|
|
{/* Loading Card */}
|
|
{isLoading && (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm border border-blue-200 dark:border-blue-900 animate-pulse">
|
|
<div className="aspect-video bg-gray-100 dark:bg-slate-700 flex items-center justify-center">
|
|
<span className="text-sm text-gray-500">{loadingText}</span>
|
|
</div>
|
|
<div className="p-4 space-y-2">
|
|
<div className="h-4 bg-gray-100 dark:bg-slate-700 rounded w-3/4"></div>
|
|
<div className="h-4 bg-gray-100 dark:bg-slate-700 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{generations.map(gen => (
|
|
<div key={gen.id} className="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow border border-gray-100 dark:border-slate-700 group flex flex-col hover:-translate-y-1 duration-300">
|
|
{/* Media Viewer */}
|
|
<div className="relative bg-gray-900 aspect-video flex items-center justify-center overflow-hidden">
|
|
{gen.output.video ? (
|
|
<video src={gen.output.video} controls className="w-full h-full object-contain" />
|
|
) : gen.output.images && gen.output.images.length > 0 ? (
|
|
<img src={`data:image/jpeg;base64,${gen.output.images[0]}`} alt="gen" className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="p-6 text-sm text-gray-300 font-mono overflow-y-auto max-h-full w-full">
|
|
<ReactMarkdown>{gen.output.text || ''}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay Actions */}
|
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
|
<button onClick={() => {
|
|
if (gen.output.images) {
|
|
const link = document.createElement('a');
|
|
link.href = `data:image/jpeg;base64,${gen.output.images[0]}`;
|
|
link.download = `bitsage-${gen.timestamp}.jpg`;
|
|
link.click();
|
|
}
|
|
}} className="bg-black/50 hover:bg-black/70 text-white p-2 rounded-lg backdrop-blur-sm">
|
|
<Download size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Footer */}
|
|
<div className="p-4 flex-1 flex flex-col">
|
|
<p className="text-sm text-gray-800 dark:text-gray-200 line-clamp-2 mb-2 font-medium" title={gen.prompt}>
|
|
"{gen.prompt}"
|
|
</p>
|
|
<div className="mt-auto flex items-center justify-between text-xs text-gray-400">
|
|
<span>{new Date(gen.timestamp).toLocaleString()}</span>
|
|
<button onClick={() => setInputText(gen.prompt)} className="text-blue-500 hover:text-blue-600 flex items-center gap-1">
|
|
<Copy size={12} /> Use Prompt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
|
|
{renderSidebar()}
|
|
|
|
{/* Overlay for mobile sidebar */}
|
|
{isSidebarOpen && <div onClick={() => setIsSidebarOpen(false)} className="fixed inset-0 bg-black/50 z-30 md:hidden backdrop-blur-sm" />}
|
|
|
|
{/* Main Content Wrapper */}
|
|
<div className="flex-1 flex relative md:ml-64 h-full">
|
|
|
|
{/* Center Column - Use pure flex column, avoid sticky inside here for robustness on mobile */}
|
|
<div className="flex-1 flex flex-col h-full min-w-0 bg-white dark:bg-slate-900 relative">
|
|
{/* Header */}
|
|
<header className="h-16 bg-white dark:bg-slate-900 border-b dark:border-slate-700 flex items-center justify-between px-4 shrink-0 z-20">
|
|
<div className="flex items-center gap-3 overflow-hidden">
|
|
<button onClick={() => setIsSidebarOpen(true)} className="md:hidden p-2 text-gray-600"><Menu/></button>
|
|
<h2 className="font-semibold text-gray-800 dark:text-gray-200 truncate flex items-center gap-2">
|
|
{isHome ? t('app.name', settings.language) : (
|
|
<>
|
|
<span className="text-gray-400 hidden sm:inline">{t(`module.${currentModule}`, settings.language)} /</span>
|
|
{getCurrentSession()?.title || (isCreativeModule(currentModule) ? t('ui.workbench', settings.language) : t('action.new_chat', settings.language))}
|
|
</>
|
|
)}
|
|
</h2>
|
|
{isLoading && !isCreativeModule(currentModule) && (
|
|
<span className="text-xs text-blue-500 animate-pulse bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded-full whitespace-nowrap">
|
|
{loadingText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{!isHome && currentSessionId && !isCreativeModule(currentModule) && <button onClick={handleShare} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg"><Share2 size={20}/></button>}
|
|
{!isHome && !isHistoryOpen && !isCreativeModule(currentModule) && (
|
|
<button onClick={() => setIsHistoryOpen(true)} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg">
|
|
<PanelRight size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Viewport - Main Scrolling Area */}
|
|
<main className="flex-1 overflow-hidden relative bg-gray-50 dark:bg-slate-900" id="chat-container">
|
|
{isHome ? renderHome() : (
|
|
isCreativeModule(currentModule) ? renderCreativeStudio() : (
|
|
<div className="h-full flex flex-col overflow-y-auto scroll-smooth">
|
|
{!currentSessionId ? (
|
|
<div className="flex-1 flex items-center justify-center p-8 text-center text-gray-400">
|
|
<div className="animate-bounce-in">
|
|
<div className="w-16 h-16 bg-gray-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
{AppModule.MATH === currentModule && <Sigma size={32} />}
|
|
{AppModule.THEORY === currentModule && <Binary size={32} />}
|
|
{AppModule.PRINCIPLES === currentModule && <Cpu size={32} />}
|
|
{AppModule.SOFT_ENG === currentModule && <Code size={32} />}
|
|
{AppModule.GRAPHICS === currentModule && <Box size={32} />}
|
|
{AppModule.NETWORK === currentModule && <Network size={32} />}
|
|
{AppModule.AI_LAB === currentModule && <Bot size={32} />}
|
|
{AppModule.RESEARCH === currentModule && <Search size={32} />}
|
|
</div>
|
|
<h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-200">
|
|
{t(`hello.${currentModule}`, settings.language)}
|
|
</h3>
|
|
<p className="text-sm max-w-sm mx-auto">
|
|
{t(`desc.${currentModule}`, settings.language)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 p-4 md:p-8 max-w-4xl mx-auto w-full">
|
|
{getCurrentSession()?.messages.map(renderMessage)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</main>
|
|
|
|
{/* Input Area (Only for Chat Modules) */}
|
|
{!isHome && !isCreativeModule(currentModule) && (
|
|
<div className="bg-white dark:bg-slate-900 border-t dark:border-slate-700 p-4 shrink-0 z-20">
|
|
<div className="max-w-4xl mx-auto">
|
|
|
|
{/* Attachment Previews */}
|
|
{attachments.length > 0 && (
|
|
<div className="flex gap-2 mb-2 overflow-x-auto">
|
|
{attachments.map((a, i) => (
|
|
<div key={i} className="relative group animate-bounce-in">
|
|
<img src={`data:${a.mimeType};base64,${a.data}`} className="h-16 w-16 object-cover rounded-lg border border-gray-200" alt="preview" />
|
|
<button onClick={() => setAttachments(attachments.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5"><X size={12}/></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 items-end bg-gray-50 dark:bg-slate-800 p-2 rounded-xl border dark:border-slate-700 focus-within:ring-2 focus-within:ring-blue-500 transition-all">
|
|
<div className="flex gap-1 pb-1">
|
|
<label className="p-2 text-gray-500 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg cursor-pointer transition-colors" title={t('action.upload', settings.language)}>
|
|
<input type="file" className="hidden" multiple accept="image/*" onChange={handleFileUpload} />
|
|
<Plus size={20} />
|
|
</label>
|
|
</div>
|
|
|
|
{/* Deep Thinking Toggle */}
|
|
<button
|
|
onClick={() => setIsThinkingMode(!isThinkingMode)}
|
|
className={`p-2 rounded-lg transition-colors pb-3 ${isThinkingMode ? 'text-blue-600 bg-blue-100 dark:bg-blue-900/30' : 'text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-700'}`}
|
|
title={t('action.toggle_think', settings.language)}
|
|
>
|
|
<Brain size={20} />
|
|
</button>
|
|
|
|
<textarea
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}}
|
|
placeholder={t(`placeholder.${currentModule}`, settings.language)}
|
|
className="flex-1 bg-transparent border-none outline-none resize-none max-h-32 py-3 text-sm"
|
|
rows={1}
|
|
/>
|
|
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={(!inputText.trim() && attachments.length === 0) || isLoading}
|
|
className="mb-1"
|
|
>
|
|
<Send size={18} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Sidebar */}
|
|
{renderHistorySidebar()}
|
|
</div>
|
|
|
|
{/* Settings Modal */}
|
|
<Modal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} title={t('settings.title', settings.language)}>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('settings.apiKey', settings.language)}</label>
|
|
<input
|
|
type="password"
|
|
value={settings.apiKey}
|
|
onChange={(e) => setSettings({...settings, apiKey: e.target.value})}
|
|
className="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Expires monthly..."
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">{t('settings.key_notice', settings.language)}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('settings.language', settings.language)}</label>
|
|
<select
|
|
value={settings.language}
|
|
onChange={(e) => setSettings({...settings, language: e.target.value as any})}
|
|
className="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="en">English</option>
|
|
<option value="zh-CN">简体中文</option>
|
|
<option value="zh-TW">繁體中文</option>
|
|
<option value="ja">日本語</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('settings.theme', settings.language)}</label>
|
|
<div className="flex gap-2">
|
|
{(['light', 'dark', 'system'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setSettings({...settings, theme: mode})}
|
|
className={`flex-1 py-2 rounded-lg border ${settings.theme === mode ? 'bg-blue-50 dark:bg-slate-700 border-blue-500 text-blue-600' : 'border-gray-200 dark:border-slate-700'}`}
|
|
>
|
|
{mode === 'light' && <Sun size={16} className="mx-auto"/>}
|
|
{mode === 'dark' && <Moon size={16} className="mx-auto"/>}
|
|
{mode === 'system' && <span className="text-sm">Auto</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t dark:border-slate-700">
|
|
<label className="block text-sm font-medium mb-2">{t('settings.data', settings.language)}</label>
|
|
<div className="flex gap-2 mb-4">
|
|
<Button variant="secondary" onClick={exportData} className="flex-1 text-sm">
|
|
<Download size={16} /> {t('settings.export', settings.language)}
|
|
</Button>
|
|
<label className="flex-1">
|
|
<div className="w-full px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2 bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer text-sm">
|
|
<Upload size={16} /> {t('settings.import', settings.language)}
|
|
</div>
|
|
<input type="file" className="hidden" accept=".json" onChange={importData} />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t dark:border-slate-700">
|
|
<h4 className="text-sm font-medium text-red-600 mb-2 flex items-center gap-1"><AlertTriangle size={14}/> {t('settings.danger_zone', settings.language)}</h4>
|
|
<Button variant="danger" onClick={handleClearData} className="w-full text-sm">
|
|
<Trash2 size={16} /> {t('settings.clear_data', settings.language)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
} |