diff --git a/App.tsx b/App.tsx index bdb64b9..623964b 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,8 @@ import { 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 + 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'; @@ -21,7 +22,7 @@ import { GenerateContentResponse } from '@google/genai'; // Button Component const Button: React.FC & { 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 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", @@ -47,6 +48,13 @@ const Modal: React.FC<{ isOpen: boolean; onClose: () => void; title: string; chi ); }; +// Toast Type +interface ToastItem { + id: number; + message: string; + type: 'success' | 'error' | 'info' | 'warning'; +} + // Constants for UI const SIDEBAR_GROUPS = [ { @@ -65,6 +73,7 @@ const SIDEBAR_GROUPS = [ 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' }, ] }, { @@ -77,16 +86,29 @@ const SIDEBAR_GROUPS = [ } ]; +// 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(() => { const saved = localStorage.getItem('bitsage_settings'); - return saved ? JSON.parse(saved) : { + const parsed = saved ? JSON.parse(saved) : {}; + return { apiKey: '', language: 'zh-CN', theme: 'system', - hasCompletedOnboarding: false + hasCompletedOnboarding: false, + aiResponseLanguage: 'system', // Default: Follow system language + ...parsed }; }); @@ -104,9 +126,13 @@ export default function App() { 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([]); + // Thinking Toggle State const [isThinkingMode, setIsThinkingMode] = useState(false); @@ -120,6 +146,13 @@ export default function App() { const [veoConfig, setVeoConfig] = useState({ aspectRatio: '16:9', resolution: '720p' }); const [imgConfig, setImgConfig] = useState({ 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(null); const geminiRef = useRef(null); @@ -145,23 +178,31 @@ export default function App() { }, [settings]); useEffect(() => { - // Only save sessions that are NOT from Creative Studio modules + // Only save sessions that are NOT from Custom View modules const sessionsToSave = sessions.filter(s => - ![AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO].includes(s.module) + ![AppModule.VISION, AppModule.STUDIO, AppModule.AUDIO, AppModule.SQL].includes(s.module) ); localStorage.setItem('bitsage_sessions', JSON.stringify(sessionsToSave)); }, [sessions]); useEffect(() => { - if (!isHome && !isCreativeModule(currentModule)) { + if (!isHome && !isCustomViewModule(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 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(() => { @@ -174,8 +215,8 @@ export default function App() { setCurrentSessionId(null); // Draft mode if (window.innerWidth < 768) setIsSidebarOpen(false); - // Auto-open history on large screens ONLY if NOT creative module - if (isCreativeModule(module)) { + // Auto-open history on large screens ONLY if NOT custom view module + if (isCustomViewModule(module)) { setIsHistoryOpen(false); } else if (window.innerWidth >= 1024) { setIsHistoryOpen(true); @@ -253,9 +294,9 @@ export default function App() { 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)); + showToast(t('success.data_imported', settings.language), 'success'); } catch (err) { - alert(t('alert.invalid_file', settings.language)); + showToast(t('alert.invalid_file', settings.language), 'error'); } }; reader.readAsText(file); @@ -277,11 +318,175 @@ export default function App() { } }; + // --- 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; } @@ -293,7 +498,7 @@ export default function App() { if (!activeSessionId) { const newSession: Session = { id: Date.now().toString(), - title: isCreativeModule(currentModule) ? inputText.slice(0, 20) : t('action.new_chat', settings.language), + title: isCustomViewModule(currentModule) ? inputText.slice(0, 20) : t('action.new_chat', settings.language), module: currentModule, messages: [], createdAt: Date.now(), @@ -332,7 +537,7 @@ export default function App() { role: MessageRole.MODEL, timestamp: Date.now(), text: '', - isThinking: isThinkingMode && !isCreativeModule(currentModule) + isThinking: isThinkingMode && !isCustomViewModule(currentModule) }]); try { @@ -362,7 +567,15 @@ export default function App() { }); // Pass isThinkingMode to the service - const stream = await geminiRef.current!.generateText(inputText, currentModule, historyParts, attachments, isThinkingMode); + const stream = await geminiRef.current!.generateText( + inputText, + currentModule, + historyParts, + settings.language, + attachments, + isThinkingMode, + settings.aiResponseLanguage + ); let fullText = ''; let sources: any[] = []; @@ -424,7 +637,7 @@ export default function App() { const audio = new Audio(`data:audio/mp3;base64,${audioBase64}`); audio.play(); } catch (e) { - alert('TTS Error: ' + (e as Error).message); + showToast(t('error.tts', settings.language) + ': ' + (e as Error).message, 'error'); } finally { setIsLoading(false); } @@ -432,18 +645,9 @@ export default function App() { const handleCopy = (text: string) => { navigator.clipboard.writeText(text); + showToast(t('success.copy', settings.language), 'success'); }; - 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 --- @@ -510,8 +714,8 @@ export default function App() { ); const renderHistorySidebar = () => { - // Hide history on Home page or Creative Modules - if (isHome || isCreativeModule(currentModule)) return null; + // Hide history on Home page or Custom View Modules + if (isHome || isCustomViewModule(currentModule)) return null; const moduleSessions = sessions.filter(s => s.module === currentModule); @@ -629,9 +833,117 @@ export default function App() { ); + const renderSQLTool = () => { + return ( +
+
+
+
+
+ {t('module.sql', settings.language)} +
+
+ + {/* Controls */} +
+ +
+ + +
+
+ + +
+ + +
+ + {/* Custom Prompt Area */} + {isCustomSqlOpen && ( +
+ setSqlCustomPrompt(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSqlCustom()} + /> + +
+ )} + + {/* Editor Area */} +
+
+ +