Files
ai-app-ckg/App.tsx
2025-12-26 16:06:34 +08:00

1501 lines
73 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, FileText, Database, ChevronDown,
CheckCircle, AlertCircle, Languages
} 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 disabled:active:scale-100";
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>
);
};
// Toast Type
interface ToastItem {
id: number;
message: string;
type: 'success' | 'error' | 'info' | 'warning';
}
// 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' },
{ id: AppModule.SQL, icon: Database, label: 'module.sql', desc: 'desc.sql' },
]
},
{
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' },
]
}
];
// Helper for safe URL parsing
const getHostname = (url: string) => {
try {
if (!url) return '';
return new URL(url).hostname;
} catch (e) {
return url;
}
};
// Main App
export default function App() {
// --- State ---
const [settings, setSettings] = useState<AppSettings>(() => {
const saved = localStorage.getItem('bitsage_settings');
const parsed = saved ? JSON.parse(saved) : {};
return {
apiKey: '',
language: 'zh-CN',
theme: 'system',
hasCompletedOnboarding: false,
aiResponseLanguage: 'system', // Default: Follow system language
...parsed
};
});
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 [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingText, setLoadingText] = useState('');
// Toast State
const [toasts, setToasts] = useState<ToastItem[]>([]);
// 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' });
// SQL Tool State
const [sqlInput, setSqlInput] = useState('');
const [sqlOutput, setSqlOutput] = useState('');
const [sqlTargetDB, setSqlTargetDB] = useState('MySQL');
const [isCustomSqlOpen, setIsCustomSqlOpen] = useState(false);
const [sqlCustomPrompt, setSqlCustomPrompt] = useState('');
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 Custom View modules
const sessionsToSave = sessions.filter(s =>
![AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO, AppModule.SQL].includes(s.module)
);
localStorage.setItem('bitsage_sessions', JSON.stringify(sessionsToSave));
}, [sessions]);
useEffect(() => {
if (!isHome && !isCustomViewModule(currentModule)) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [sessions, currentSessionId, isLoading, isHome]);
// --- Helpers ---
const showToast = (message: string, type: ToastItem['type'] = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
};
const isCustomViewModule = (mod: AppModule) => {
return [AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO, AppModule.SQL].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 custom view module
if (isCustomViewModule(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);
showToast(t('success.data_imported', settings.language), 'success');
} catch (err) {
showToast(t('alert.invalid_file', settings.language), 'error');
}
};
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);
}
};
// --- SQL Tool Actions ---
const handleSqlFormat = async () => {
if(!sqlInput.trim()) {
showToast(t('warning.no_sql', settings.language), 'warning');
return;
}
if(!settings.apiKey) {
setIsSettingsOpen(true);
return;
}
setIsLoading(true);
setLoadingText(t('sql.processing', settings.language));
try {
const res = await geminiRef.current!.toolsSql(sqlInput, 'format');
setSqlOutput(res);
} catch (e) {
setSqlOutput("Error: " + (e as Error).message);
} finally {
setIsLoading(false);
setLoadingText('');
}
};
const handleSqlConvert = async () => {
if(!sqlInput.trim()) {
showToast(t('warning.no_sql', settings.language), 'warning');
return;
}
if(!settings.apiKey) {
setIsSettingsOpen(true);
return;
}
setIsLoading(true);
setLoadingText(t('sql.processing', settings.language));
try {
const res = await geminiRef.current!.toolsSql(sqlInput, 'convert', sqlTargetDB);
setSqlOutput(res);
} catch (e) {
setSqlOutput("Error: " + (e as Error).message);
} finally {
setIsLoading(false);
setLoadingText('');
}
};
const handleSqlReplace = () => {
if(!sqlInput.trim()) {
showToast(t('warning.no_sql', settings.language), 'warning');
return;
}
let counter = 1;
// Regex to find AS followed by whitespace and an identifier
// Handles quoted identifiers roughly
const regex = /\bAS\s+((?:`[^`]+`)|(?:"[^"]+")|(?:'[^']+')|(?:\w+))/gi;
const result = sqlInput.replace(regex, () => {
return `AS ${counter++}`;
});
setSqlOutput(result);
};
const handleSqlMinify = () => {
if(!sqlInput.trim()) {
showToast(t('warning.no_sql', settings.language), 'warning');
return;
}
// Basic minification: remove comments, collapse whitespace
let res = sqlInput
.replace(/--.*$/gm, '') // remove single line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // remove multi line comments
.replace(/\s+/g, ' ') // collapse whitespace
.trim();
setSqlOutput(res);
};
const handleSqlCustom = async () => {
// Allow empty sqlInput if user is asking for generation
if(!sqlCustomPrompt.trim()) {
showToast("Please describe what you want the AI to do.", 'warning');
return;
}
if(!settings.apiKey) {
setIsSettingsOpen(true);
return;
}
setIsLoading(true);
setLoadingText(t('sql.processing', settings.language));
try {
const res = await geminiRef.current!.toolsSql(sqlInput, 'custom', undefined, sqlCustomPrompt);
setSqlOutput(res);
} catch (e) {
setSqlOutput("Error: " + (e as Error).message);
} finally {
setIsLoading(false);
setLoadingText('');
setIsCustomSqlOpen(false); // Auto close after run
}
};
// --- Share & Download Actions ---
const getModelDisplayName = (module: AppModule) => {
if (module === AppModule.STUDIO) return 'Veo 3.1';
if (module === AppModule.RESEARCH || module === AppModule.AUDIO) return 'Gemini 3 Flash';
return 'Gemini 3 Pro';
};
const handleCopySession = () => {
const session = getCurrentSession();
if (!session) return;
const text = session.messages.map(m => {
const role = m.role === MessageRole.USER ? t('role.user', settings.language) : getModelDisplayName(session.module);
const time = new Date(m.timestamp).toLocaleString();
return `[${role} - ${time}]\n${m.text || '[Media]'}\n`;
}).join('\n-------------------\n');
navigator.clipboard.writeText(text);
showToast(t('success.copy', settings.language), 'success');
setIsShareModalOpen(false);
};
const handleDownloadText = () => {
const session = getCurrentSession();
if (!session) return;
const text = session.messages.map(m => {
const role = m.role === MessageRole.USER ? t('role.user', settings.language) : getModelDisplayName(session.module);
const time = new Date(m.timestamp).toLocaleString();
return `[${role} - ${time}]\n${m.text || '[Media]'}\n`;
}).join('\n-------------------\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bitsage-chat-${session.id}.txt`;
a.click();
setIsShareModalOpen(false);
};
const handleDownloadImage = async () => {
const el = document.getElementById('chat-content');
if (!el) return;
try {
const isDark = document.documentElement.classList.contains('dark');
const canvas = await html2canvas(el, {
backgroundColor: isDark ? '#0f172a' : '#f9fafb', // slate-900 or gray-50
scale: 2, // High res
});
const link = document.createElement('a');
link.download = `bitsage-chat-${Date.now()}.png`;
link.href = canvas.toDataURL();
link.click();
setIsShareModalOpen(false);
} catch (e) {
console.error("Screenshot failed", e);
showToast(t('error.screenshot', settings.language), 'error');
}
};
// --- Core Actions ---
const handleSend = async () => {
if ((!inputText.trim() && attachments.length === 0) || isLoading) return;
if (!settings.apiKey) {
showToast(t('error.no_key', settings.language), 'error');
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: isCustomViewModule(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 && !isCustomViewModule(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,
settings.language,
attachments,
isThinkingMode,
settings.aiResponseLanguage
);
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) {
showToast(t('error.tts', settings.language) + ': ' + (e as Error).message, 'error');
} finally {
setIsLoading(false);
}
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
showToast(t('success.copy', settings.language), 'success');
};
// --- 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 Custom View Modules
if (isHome || isCustomViewModule(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 renderSQLTool = () => {
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b dark:border-slate-700 p-6 shrink-0 z-10 shadow-sm transition-all">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-blue-600 uppercase tracking-wider">{t('module.sql', settings.language)}</span>
</div>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-gray-100 dark:bg-slate-700 rounded-lg">
<Button onClick={handleSqlFormat} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.format', settings.language)}
</Button>
<div className="w-px bg-gray-300 dark:bg-slate-600 mx-1 self-center h-6"></div>
<Button onClick={handleSqlReplace} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.replace', settings.language)}
</Button>
<Button onClick={handleSqlMinify} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.minify', settings.language)}
</Button>
<div className="w-px bg-gray-300 dark:bg-slate-600 mx-1 self-center h-6"></div>
<div className="flex items-center gap-2 pl-2 border-r border-gray-300 dark:border-slate-600 pr-2">
<select
className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-600 rounded text-sm h-8 px-2 outline-none"
value={sqlTargetDB} onChange={e => setSqlTargetDB(e.target.value)}
>
<option value="MySQL">MySQL</option>
<option value="PostgreSQL">PostgreSQL</option>
<option value="Oracle">Oracle</option>
<option value="SQL Server">SQL Server</option>
<option value="SQLite">SQLite</option>
</select>
<Button onClick={handleSqlConvert} disabled={isLoading} variant="primary" className="text-sm h-8 px-3">
{t('sql.convert', settings.language)}
</Button>
</div>
<Button
onClick={() => setIsCustomSqlOpen(!isCustomSqlOpen)}
disabled={isLoading}
variant={isCustomSqlOpen ? "primary" : "ghost"}
className="text-sm h-9 ml-auto"
>
<Sparkles size={14} /> {t('sql.custom', settings.language)} <ChevronDown size={14} className={`transform transition-transform ${isCustomSqlOpen ? 'rotate-180' : ''}`}/>
</Button>
</div>
{/* Custom Prompt Area */}
{isCustomSqlOpen && (
<div className="mb-4 animate-slide-up flex gap-2">
<input
type="text"
className="flex-1 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('sql.custom_prompt', settings.language)}
value={sqlCustomPrompt}
onChange={e => setSqlCustomPrompt(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSqlCustom()}
/>
<Button onClick={handleSqlCustom} disabled={!sqlCustomPrompt.trim() || isLoading} variant="primary" className="text-sm h-10">
{t('sql.run', settings.language)}
</Button>
</div>
)}
{/* Editor Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[calc(100vh-280px)]">
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-500 mb-2 uppercase">{t('sql.input', settings.language)}</label>
<textarea
className="flex-1 w-full bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 font-mono text-sm resize-none focus:ring-2 focus:ring-blue-500 outline-none"
placeholder={t('sql.placeholder', settings.language)}
value={sqlInput}
onChange={e => setSqlInput(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-500 mb-2 uppercase flex justify-between">
{t('sql.output', settings.language)}
{sqlOutput && (
<button onClick={() => handleCopy(sqlOutput)} className="text-blue-500 hover:text-blue-600 flex items-center gap-1">
<Copy size={12}/> Copy
</button>
)}
</label>
<div className="flex-1 w-full bg-gray-100 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700 rounded-xl p-4 font-mono text-sm overflow-auto whitespace-pre">
{sqlOutput || <span className="text-gray-400 italic">...</span>}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
const renderMessage = (msg: Message) => (
<div key={msg.id} className={`flex flex-col mb-6 group animate-slide-up ${msg.role === MessageRole.USER ? 'items-end' : 'items-start'}`}>
{/* Message Metadata */}
<div className={`flex items-center gap-2 mb-1 text-xs text-gray-400 ${msg.role === MessageRole.USER ? 'flex-row-reverse' : 'flex-row'}`}>
<span className="font-semibold text-gray-500 dark:text-gray-400">
{msg.role === MessageRole.USER ? t('role.user', settings.language) : getModelDisplayName(currentModule)}
</span>
<span className="w-1 h-1 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<span>{new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
<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 rounded-tr-sm' : 'bg-white dark:bg-slate-800 border dark:border-slate-700 shadow-sm rounded-tl-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 || getHostname(src.uri)}
</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 relative">
{renderSidebar()}
{/* Toast Container */}
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 w-full max-w-sm px-4 pointer-events-none">
{toasts.map(toast => (
<div key={toast.id} className={`pointer-events-auto bg-white dark:bg-slate-800 text-gray-800 dark:text-gray-100 px-4 py-3 rounded-lg shadow-xl border border-gray-100 dark:border-slate-700 flex items-center gap-3 animate-slide-up`}>
{toast.type === 'success' && <CheckCircle className="text-green-500 shrink-0" size={20} />}
{toast.type === 'error' && <AlertCircle className="text-red-500 shrink-0" size={20} />}
{toast.type === 'warning' && <AlertTriangle className="text-yellow-500 shrink-0" size={20} />}
{toast.type === 'info' && <Info className="text-blue-500 shrink-0" size={20} />}
<span className="text-sm font-medium">{toast.message}</span>
</div>
))}
</div>
{/* 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 || (isCustomViewModule(currentModule) ? t('ui.workbench', settings.language) : t('action.new_chat', settings.language))}
</>
)}
</h2>
{isLoading && !isCustomViewModule(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 && !isCustomViewModule(currentModule) && (
<button onClick={() => setIsShareModalOpen(true)} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg">
<Share2 size={20}/>
</button>
)}
{!isHome && !isHistoryOpen && !isCustomViewModule(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() : (
currentModule === AppModule.SQL ? renderSQLTool() :
isCustomViewModule(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" id="chat-content">
{getCurrentSession()?.messages.map(renderMessage)}
<div ref={messagesEndRef} />
</div>
)}
</div>
)
)}
</main>
{/* Input Area (Only for Chat Modules) */}
{!isHome && !isCustomViewModule(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>
{/* AI Language Response Mode Selector */}
<div className="relative group flex items-center pb-3">
<Languages size={18} className="text-gray-400 absolute left-2 pointer-events-none" />
<select
value={settings.aiResponseLanguage}
onChange={(e) => setSettings({ ...settings, aiResponseLanguage: e.target.value as any })}
className="bg-transparent text-xs font-medium text-gray-500 hover:text-blue-600 outline-none cursor-pointer pl-8 appearance-none w-[110px]"
title={t('action.lang_mode', settings.language)}
>
<option value="system">{t('action.lang_system', settings.language)}</option>
<option value="input">{t('action.lang_input', settings.language)}</option>
</select>
</div>
<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>
{/* Share Modal */}
<Modal isOpen={isShareModalOpen} onClose={() => setIsShareModalOpen(false)} title={t('share.title', settings.language)}>
<div className="space-y-4">
<Button variant="secondary" onClick={handleCopySession} className="w-full justify-start h-12">
<Copy size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.copy', settings.language)}</span>
</Button>
<Button variant="secondary" onClick={handleDownloadText} className="w-full justify-start h-12">
<FileText size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.txt', settings.language)}</span>
</Button>
<Button variant="secondary" onClick={handleDownloadImage} className="w-full justify-start h-12">
<ImageIcon size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.img', settings.language)}</span>
</Button>
</div>
</Modal>
{/* 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})}
onBlur={() => {
if (settings.apiKey) showToast(t('success.apikey_updated', settings.language), 'success');
}}
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>
);
}