Files
ai-app-ckg/App.tsx

1102 lines
54 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Settings, MessageSquare, 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
} 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.learning',
modules: [
{ id: AppModule.TUTOR, icon: MessageSquare, label: 'module.tutor', desc: 'desc.tutor' },
{ id: AppModule.THINKER, icon: Brain, label: 'module.thinker', desc: 'desc.thinker' },
{ 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.TUTOR);
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('');
// 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: currentModule === AppModule.THINKER
}]);
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 (currentModule === AppModule.THINKER) 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 };
});
const stream = await geminiRef.current!.generateText(inputText, currentModule, historyParts, attachments);
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;
}
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="flex flex-col items-center min-h-full p-4 animate-fade-in">
<div className="max-w-4xl w-full my-auto py-10">
<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.TUTOR === currentModule && <MessageSquare size={32} />}
{AppModule.THINKER === currentModule && <Brain size={32} />}
{AppModule.RESEARCH === currentModule && <Search size={32} />}
</div>
<p>{t('prompt.placeholder', 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>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={t('prompt.placeholder', 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>
);
}