Files
ai-app-ckg/App.tsx
2025-12-25 16:39:44 +08:00

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