更新至 v0.3.0_20251226 版本

This commit is contained in:
2025-12-26 16:06:31 +08:00
parent efa471aa2e
commit 9bc88f7d6a
7 changed files with 675 additions and 58 deletions

445
App.tsx
View File

@@ -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<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 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<AppSettings>(() => {
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<ToastItem[]>([]);
// Thinking Toggle State
const [isThinkingMode, setIsThinkingMode] = useState(false);
@@ -120,6 +146,13 @@ export default function App() {
const [veoConfig, setVeoConfig] = useState<VeoConfig>({ aspectRatio: '16:9', resolution: '720p' });
const [imgConfig, setImgConfig] = useState<ImageConfig>({ size: '1K', aspectRatio: '1:1' });
// SQL Tool State
const [sqlInput, setSqlInput] = useState('');
const [sqlOutput, setSqlOutput] = useState('');
const [sqlTargetDB, setSqlTargetDB] = useState('MySQL');
const [isCustomSqlOpen, setIsCustomSqlOpen] = useState(false);
const [sqlCustomPrompt, setSqlCustomPrompt] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const geminiRef = useRef<GeminiService | null>(null);
@@ -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() {
</div>
);
const renderSQLTool = () => {
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b dark:border-slate-700 p-6 shrink-0 z-10 shadow-sm transition-all">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-blue-600 uppercase tracking-wider">{t('module.sql', settings.language)}</span>
</div>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-gray-100 dark:bg-slate-700 rounded-lg">
<Button onClick={handleSqlFormat} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.format', settings.language)}
</Button>
<div className="w-px bg-gray-300 dark:bg-slate-600 mx-1 self-center h-6"></div>
<Button onClick={handleSqlReplace} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.replace', settings.language)}
</Button>
<Button onClick={handleSqlMinify} disabled={isLoading} variant="ghost" className="text-sm h-9">
{t('sql.minify', settings.language)}
</Button>
<div className="w-px bg-gray-300 dark:bg-slate-600 mx-1 self-center h-6"></div>
<div className="flex items-center gap-2 pl-2 border-r border-gray-300 dark:border-slate-600 pr-2">
<select
className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-600 rounded text-sm h-8 px-2 outline-none"
value={sqlTargetDB} onChange={e => setSqlTargetDB(e.target.value)}
>
<option value="MySQL">MySQL</option>
<option value="PostgreSQL">PostgreSQL</option>
<option value="Oracle">Oracle</option>
<option value="SQL Server">SQL Server</option>
<option value="SQLite">SQLite</option>
</select>
<Button onClick={handleSqlConvert} disabled={isLoading} variant="primary" className="text-sm h-8 px-3">
{t('sql.convert', settings.language)}
</Button>
</div>
<Button
onClick={() => setIsCustomSqlOpen(!isCustomSqlOpen)}
disabled={isLoading}
variant={isCustomSqlOpen ? "primary" : "ghost"}
className="text-sm h-9 ml-auto"
>
<Sparkles size={14} /> {t('sql.custom', settings.language)} <ChevronDown size={14} className={`transform transition-transform ${isCustomSqlOpen ? 'rotate-180' : ''}`}/>
</Button>
</div>
{/* Custom Prompt Area */}
{isCustomSqlOpen && (
<div className="mb-4 animate-slide-up flex gap-2">
<input
type="text"
className="flex-1 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('sql.custom_prompt', settings.language)}
value={sqlCustomPrompt}
onChange={e => setSqlCustomPrompt(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSqlCustom()}
/>
<Button onClick={handleSqlCustom} disabled={!sqlCustomPrompt.trim() || isLoading} variant="primary" className="text-sm h-10">
{t('sql.run', settings.language)}
</Button>
</div>
)}
{/* Editor Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[calc(100vh-280px)]">
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-500 mb-2 uppercase">{t('sql.input', settings.language)}</label>
<textarea
className="flex-1 w-full bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 font-mono text-sm resize-none focus:ring-2 focus:ring-blue-500 outline-none"
placeholder={t('sql.placeholder', settings.language)}
value={sqlInput}
onChange={e => setSqlInput(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-500 mb-2 uppercase flex justify-between">
{t('sql.output', settings.language)}
{sqlOutput && (
<button onClick={() => handleCopy(sqlOutput)} className="text-blue-500 hover:text-blue-600 flex items-center gap-1">
<Copy size={12}/> Copy
</button>
)}
</label>
<div className="flex-1 w-full bg-gray-100 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700 rounded-xl p-4 font-mono text-sm overflow-auto whitespace-pre">
{sqlOutput || <span className="text-gray-400 italic">...</span>}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
const renderMessage = (msg: Message) => (
<div key={msg.id} className={`flex ${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'}`}>
<div key={msg.id} className={`flex flex-col mb-6 group animate-slide-up ${msg.role === MessageRole.USER ? 'items-end' : 'items-start'}`}>
{/* Message Metadata */}
<div className={`flex items-center gap-2 mb-1 text-xs text-gray-400 ${msg.role === MessageRole.USER ? 'flex-row-reverse' : 'flex-row'}`}>
<span className="font-semibold text-gray-500 dark:text-gray-400">
{msg.role === MessageRole.USER ? t('role.user', settings.language) : getModelDisplayName(currentModule)}
</span>
<span className="w-1 h-1 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<span>{new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
<div className={`max-w-[85%] md:max-w-[75%] rounded-2xl p-4 transition-all duration-300 hover:shadow-md ${msg.role === MessageRole.USER ? 'bg-blue-600 text-white rounded-tr-sm' : 'bg-white dark:bg-slate-800 border dark:border-slate-700 shadow-sm rounded-tl-sm'}`}>
{msg.images && msg.images.map((img, i) => (
<img key={i} src={`data:image/jpeg;base64,${img}`} alt="Generated" className="rounded-lg mb-2 max-h-64 object-contain bg-black/5" />
@@ -667,7 +979,7 @@ export default function App() {
<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}
<Globe size={10} /> {src.title || getHostname(src.uri)}
</a>
))}
</div>
@@ -921,9 +1233,22 @@ export default function App() {
};
return (
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden relative">
{renderSidebar()}
{/* Toast Container */}
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 w-full max-w-sm px-4 pointer-events-none">
{toasts.map(toast => (
<div key={toast.id} className={`pointer-events-auto bg-white dark:bg-slate-800 text-gray-800 dark:text-gray-100 px-4 py-3 rounded-lg shadow-xl border border-gray-100 dark:border-slate-700 flex items-center gap-3 animate-slide-up`}>
{toast.type === 'success' && <CheckCircle className="text-green-500 shrink-0" size={20} />}
{toast.type === 'error' && <AlertCircle className="text-red-500 shrink-0" size={20} />}
{toast.type === 'warning' && <AlertTriangle className="text-yellow-500 shrink-0" size={20} />}
{toast.type === 'info' && <Info className="text-blue-500 shrink-0" size={20} />}
<span className="text-sm font-medium">{toast.message}</span>
</div>
))}
</div>
{/* Overlay for mobile sidebar */}
{isSidebarOpen && <div onClick={() => setIsSidebarOpen(false)} className="fixed inset-0 bg-black/50 z-30 md:hidden backdrop-blur-sm" />}
@@ -940,19 +1265,23 @@ export default function App() {
{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))}
{getCurrentSession()?.title || (isCustomViewModule(currentModule) ? t('ui.workbench', settings.language) : t('action.new_chat', settings.language))}
</>
)}
</h2>
{isLoading && !isCreativeModule(currentModule) && (
{isLoading && !isCustomViewModule(currentModule) && (
<span className="text-xs text-blue-500 animate-pulse bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded-full whitespace-nowrap">
{loadingText}
</span>
)}
</div>
<div className="flex items-center gap-1">
{!isHome && currentSessionId && !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) && (
{!isHome && currentSessionId && !isCustomViewModule(currentModule) && (
<button onClick={() => setIsShareModalOpen(true)} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg">
<Share2 size={20}/>
</button>
)}
{!isHome && !isHistoryOpen && !isCustomViewModule(currentModule) && (
<button onClick={() => setIsHistoryOpen(true)} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg">
<PanelRight size={20} />
</button>
@@ -963,7 +1292,8 @@ export default function App() {
{/* 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() : (
currentModule === AppModule.SQL ? renderSQLTool() :
isCustomViewModule(currentModule) ? renderCreativeStudio() : (
<div className="h-full flex flex-col overflow-y-auto scroll-smooth">
{!currentSessionId ? (
<div className="flex-1 flex items-center justify-center p-8 text-center text-gray-400">
@@ -987,7 +1317,7 @@ export default function App() {
</div>
</div>
) : (
<div className="flex-1 p-4 md:p-8 max-w-4xl mx-auto w-full">
<div className="flex-1 p-4 md:p-8 max-w-4xl mx-auto w-full" id="chat-content">
{getCurrentSession()?.messages.map(renderMessage)}
<div ref={messagesEndRef} />
</div>
@@ -998,7 +1328,7 @@ export default function App() {
</main>
{/* Input Area (Only for Chat Modules) */}
{!isHome && !isCreativeModule(currentModule) && (
{!isHome && !isCustomViewModule(currentModule) && (
<div className="bg-white dark:bg-slate-900 border-t dark:border-slate-700 p-4 shrink-0 z-20">
<div className="max-w-4xl mx-auto">
@@ -1030,6 +1360,20 @@ export default function App() {
>
<Brain size={20} />
</button>
{/* AI Language Response Mode Selector */}
<div className="relative group flex items-center pb-3">
<Languages size={18} className="text-gray-400 absolute left-2 pointer-events-none" />
<select
value={settings.aiResponseLanguage}
onChange={(e) => setSettings({ ...settings, aiResponseLanguage: e.target.value as any })}
className="bg-transparent text-xs font-medium text-gray-500 hover:text-blue-600 outline-none cursor-pointer pl-8 appearance-none w-[110px]"
title={t('action.lang_mode', settings.language)}
>
<option value="system">{t('action.lang_system', settings.language)}</option>
<option value="input">{t('action.lang_input', settings.language)}</option>
</select>
</div>
<textarea
value={inputText}
@@ -1062,6 +1406,24 @@ export default function App() {
{renderHistorySidebar()}
</div>
{/* Share Modal */}
<Modal isOpen={isShareModalOpen} onClose={() => setIsShareModalOpen(false)} title={t('share.title', settings.language)}>
<div className="space-y-4">
<Button variant="secondary" onClick={handleCopySession} className="w-full justify-start h-12">
<Copy size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.copy', settings.language)}</span>
</Button>
<Button variant="secondary" onClick={handleDownloadText} className="w-full justify-start h-12">
<FileText size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.txt', settings.language)}</span>
</Button>
<Button variant="secondary" onClick={handleDownloadImage} className="w-full justify-start h-12">
<ImageIcon size={20} className="text-gray-500" />
<span className="flex-1 text-left">{t('share.img', settings.language)}</span>
</Button>
</div>
</Modal>
{/* Settings Modal */}
<Modal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} title={t('settings.title', settings.language)}>
<div className="space-y-6">
@@ -1071,6 +1433,9 @@ export default function App() {
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({...settings, apiKey: e.target.value})}
onBlur={() => {
if (settings.apiKey) showToast(t('success.apikey_updated', settings.language), 'success');
}}
className="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Expires monthly..."
/>

View File

@@ -49,10 +49,10 @@
}
</script>
<!-- Direct Favicon Link -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z'/%3E%3Cpath d='M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z'/%3E%3C/svg%3E" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z'/%3E%3Cpath d='M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z'/%3E%3C/svg%3E" />
<!-- PWA Manifest -->
<link rel="manifest" href="data:application/json;base64,ewogICJuYW1lIjogIkJpdFNhZ2UgLSBDUyBMZWFybmluZyBDb21wYW5pb24iLAogICJzaG9ydF9uYW1lIjogIkJpdFNhZ2UiLAogICJzdGFydF91cmwiOiAiLyIsCiAgImRpc3BsYXkiOiAiYnJvd3NlciIsCiAgImJhY2tncm91bmRfY29xvciI6ICIjZmZmZmZmIiwKICAidGhlbWVfY29xvciI6ICIjMjU2M2ViIiwKICAiaWNvbnMiOiBbCiAgICB7CiAgICAgICJzcmMiOiAiZGF0YTppbWFnZS9zdmcreG1sLCUzQ3N2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nbm9uZScgc3Ryb2tlPSclMjMyNTYzZWInIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWxpbmVjYXA9J3JvdW5kJyBzdHJva2UtbGluZWpvaW49J3JvdW5kJ%2BJTNDcGF0aCBkPSdNOS41IDJBMi41IDIuNSAwIDAgMSAxMiA0LjV2MTVhMi41IDIuNSAwIDAgMS00Ljk2LjQ0IDIuNSAyLjUgMCAwIDEtMi45Ni0zLjA4IDMgMyAwIDAgMS0uMzQtNS41OCAyLjUgMi41IDAgMCAxIDEuMzItNC4yNCAyLjUgMi41IDAgMCAxIDEuMzItNC4yNCAyLjUgMi41IDAgMCAxIDEuOTgtM0EyLjUgMi41IDAgMCAxIDkuNSAyWicvJTNFJTNDcGF0aCBkPSdNMTQuNSAyQTIuNSAyLjUgMCAwIDAgMTIgNC41djE1YTIuNSAyLjUgMCAwIDAgNC45Ni40NCAyLjUgMi41IDAgMCAwIDIuOTYtMy4wOCAzIDMgMCAwIDAgLjM0LTUuNTggMi41IDIuNSAwIDAgMC0xLjMyLTQuMjQgMi41IDIuNSAwIDAgMC0xLjk4LTNBMi41IDIuNSAwIDAgMCAxNC41IDJaJy8lM0UlM0Mvc3ZnJTNFLg0KIiwKICAgICAgInR5cGUiOiAiaW1hZ2Uvc3ZnK3htbCIsCiAgICAgICJzaXplcyI6ICIxOTJ4MTkyIDUxMng1MTIiCiAgICB9CiAgXQp9" />
<!-- PWA Manifest (display: standalone is critical for installability) -->
<link rel="manifest" href='data:application/json;charset=utf-8,{"name":"BitSage - CS Learning Companion","short_name":"BitSage","start_url":"/","display":"standalone","background_color":"#ffffff","theme_color":"#2563eb","icons":[{"src":"data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27%232563eb%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27%3E%3Cpath d=%27M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z%27/%3E%3Cpath d=%27M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z%27/%3E%3C/svg%3E","type":"image/svg+xml","sizes":"192x192 512x512"}]}' />
<style>
/* Custom scrollbar for webkit */
@@ -118,5 +118,14 @@
<body class="bg-gray-50 dark:bg-slate-900 text-gray-900 dark:text-gray-100 h-[100dvh] overflow-hidden">
<div id="root" class="h-full"></div>
<script type="module" src="/index.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(err => {
console.error('SW registration failed:', err);
});
});
}
</script>
</body>
</html>

Binary file not shown.

View File

@@ -34,8 +34,10 @@ export class GeminiService {
prompt: string,
module: AppModule,
history: {role: string, parts: any[]}[],
language: string,
media?: { data: string, mimeType: string }[],
enableThinking: boolean = false
enableThinking: boolean = false,
responseLangMode: 'system' | 'input' = 'system'
) {
const ai = this.getClient();
let model = MODEL_CHAT_PRO;
@@ -65,16 +67,57 @@ export class GeminiService {
break;
}
// Build contents
// Chat history + new prompt
// Note: @google/genai chat history format differs slightly from simple array.
// For simplicity in this single-file service, we'll use `generateContent` with a constructed history
// OR just use `chats.create`. `chats.create` is better for history.
// Construct System Instruction based on module and language
const langMap: Record<string, string> = {
'en': 'English',
'zh-CN': 'Simplified Chinese',
'zh-TW': 'Traditional Chinese',
'ja': 'Japanese'
};
let languageInstruction = '';
if (responseLangMode === 'input') {
languageInstruction = `- Detect the language of the user's input and reply exclusively in that language.`;
} else {
const targetLang = langMap[language] || 'English';
languageInstruction = `- Please provide your response in ${targetLang}.
- However, if the user explicitly asks a question in a different language, you should adapt and reply in the language of the question to ensure effective communication.`;
}
const contextMap: Record<string, string> = {
[AppModule.MATH]: 'Discrete Mathematics, Calculus, Linear Algebra, and Logic for Computer Science.',
[AppModule.THEORY]: 'Theory of Computation, Automata, Complexity Theory, and Computability.',
[AppModule.PRINCIPLES]: 'Computer Architecture, Organization, Digital Logic, and Assembly.',
[AppModule.SOFT_ENG]: 'Software Engineering, Design Patterns, Architecture, and DevOps.',
[AppModule.GRAPHICS]: 'Computer Graphics, Rendering, WebGL, and Linear Algebra for Graphics.',
[AppModule.NETWORK]: 'Computer Networks, Protocols (TCP/IP), Security, and Distributed Systems.',
[AppModule.AI_LAB]: 'Artificial Intelligence, Machine Learning, Deep Learning, and Neural Networks.',
[AppModule.RESEARCH]: 'Academic Research, Paper Search, and Citations.',
[AppModule.VISION]: 'Computer Vision, Image Analysis, and Generation.',
[AppModule.STUDIO]: 'Video Generation and Multimedia Processing.',
[AppModule.AUDIO]: 'Audio Processing, Speech Synthesis, and Transcription.',
[AppModule.SQL]: 'SQL Database Administration and Query Optimization.'
};
const domain = contextMap[module] || 'Computer Science';
const systemInstruction = `You are BitSage, an expert tutor and companion specializing in ${domain}.
Primary Goal: Help the user learn, understand, and explore concepts in this domain.
Language Preference:
${languageInstruction}
Style:
- Be precise, educational, and helpful.
- Use code blocks for code snippets.
- Use Markdown for formatting.
${enableThinking ? '- Thinking process is enabled. Use it to break down complex problems.' : ''}
`;
config.systemInstruction = systemInstruction;
// Convert generic history to SDK format
// The SDK `sendMessage` handles the current turn.
// We need to initialize history first.
const sdkHistory = history.map(h => ({
role: h.role,
parts: h.parts
@@ -140,12 +183,6 @@ export class GeminiService {
}
}
// Creating a NEW instance for Veo calls to ensure latest key if using the selection dialog flow?
// The prompt says "Create a new GoogleGenAI instance right before making an API call...".
// Since we are using our own stored key primarily, we stick to `this.ai`.
// If the user used the dialog, that key isn't automatically in our `this.apiKey`.
// We will assume `this.apiKey` (user entered) is the paid key required.
let operation = await ai.models.generateVideos({
model: MODEL_VIDEO,
prompt: prompt,
@@ -203,4 +240,29 @@ export class GeminiService {
if (!base64Audio) throw new Error("No audio generated");
return base64Audio;
}
async toolsSql(text: string, type: 'format' | 'convert' | 'custom', target?: string, instruction?: string) {
const ai = this.getClient();
let prompt = "";
if (type === 'custom') {
if (!text.trim()) {
// Generation mode
prompt = `You are a SQL expert. The user wants you to generate SQL code based on this request: "${instruction}".\n\nReturn ONLY the generated SQL code. Do not wrap in markdown backticks unless asked for explanation.`;
} else {
// Manipulation mode
prompt = `You are a SQL expert. The user wants you to perform the following action on the SQL code provided: "${instruction}".\n\nReturn ONLY the processed SQL code (or the answer if it's an analysis). Do not wrap in markdown backticks unless asked for explanation.\n\nSQL Code:\n${text}`;
}
} else if (type === 'format') {
prompt = `You are a SQL formatter. Format the following SQL code to be readable, with proper indentation and uppercased keywords. Return ONLY the formatted SQL code, no markdown backticks.\n\n${text}`;
} else if (type === 'convert') {
prompt = `You are a SQL converter. Convert the following SQL code to ${target} dialect. Return ONLY the converted SQL code, no markdown backticks.\n\n${text}`;
}
const response = await ai.models.generateContent({
model: MODEL_CHAT_PRO,
contents: [{ parts: [{ text: prompt }] }]
});
return response.text?.trim() || "";
}
}

View File

@@ -17,6 +17,7 @@ const translations: Record<Language, Record<string, string>> = {
'module.network': 'Comp. Network',
'module.ai_lab': 'AI Laboratory',
'module.research': 'Academic Search',
'module.sql': 'SQL Toolbox',
'module.vision': 'Vision Lab',
'module.studio': 'Video Studio',
'module.audio': 'Audio Lab',
@@ -29,6 +30,7 @@ const translations: Record<Language, Record<string, string>> = {
'desc.network': 'Protocols, security & distributed systems',
'desc.ai_lab': 'ML, DL & Neural Networks',
'desc.research': 'Web-grounded academic research',
'desc.sql': 'Format, convert & manipulate SQL',
'desc.vision': 'Image analysis and generation',
'desc.studio': 'AI video generation studio',
'desc.audio': 'Speech-to-text and Text-to-speech',
@@ -76,6 +78,9 @@ const translations: Record<Language, Record<string, string>> = {
'action.new_chat': 'New Chat',
'action.install': 'Install App',
'action.toggle_think': 'Deep Thinking Mode',
'action.lang_mode': 'AI Language',
'action.lang_system': 'System Lang',
'action.lang_input': 'Follow Input',
'history.title': 'History',
'history.empty': 'No history for this module.',
@@ -123,6 +128,38 @@ const translations: Record<Language, Record<string, string>> = {
'ui.workbench': 'Workbench',
'ui.gallery': 'Results',
'ui.config': 'Configuration',
// Share & Metadata
'share.title': 'Share Session',
'share.copy': 'Copy Full Chat',
'share.txt': 'Download Text',
'share.img': 'Download Image',
'success.copy': 'Copied to clipboard!',
'role.user': 'You',
'role.model': 'Gemini',
'role.veo': 'Veo',
// SQL Tool
'sql.format': 'Format SQL',
'sql.convert': 'Convert Dialect',
'sql.replace': 'Anonymize AS',
'sql.minify': 'Minify (Single Line)',
'sql.input': 'Input SQL',
'sql.output': 'Output SQL',
'sql.target_db': 'Target Database',
'sql.placeholder': 'Paste your SQL here...',
'sql.processing': 'Processing...',
'sql.custom': 'Other / AI Assist',
'sql.custom_prompt': 'Describe operation or generation needed...',
'sql.run': 'Run',
// New Toasts
'warning.no_sql': 'Please enter SQL code first.',
'success.apikey_updated': 'API Key configuration updated.',
'success.data_imported': 'Data imported successfully.',
'error.invalid_file': 'Invalid file format.',
'error.screenshot': 'Failed to generate screenshot.',
'error.tts': 'TTS Generation failed.',
},
'zh-CN': {
'app.name': '比特智者',
@@ -140,6 +177,7 @@ const translations: Record<Language, Record<string, string>> = {
'module.network': '计算机网络',
'module.ai_lab': '人工智能',
'module.research': '学术搜索',
'module.sql': 'SQL 工具箱',
'module.vision': '视觉实验室',
'module.studio': '视频工作室',
'module.audio': '音频实验室',
@@ -152,6 +190,7 @@ const translations: Record<Language, Record<string, string>> = {
'desc.network': '协议、安全与分布式系统',
'desc.ai_lab': '机器学习、深度学习与神经网络',
'desc.research': '基于网络的学术研究',
'desc.sql': 'SQL 格式化、转换与处理',
'desc.vision': '图像分析与生成',
'desc.studio': 'AI 视频生成工作室',
'desc.audio': '语音转文字与文字转语音',
@@ -197,6 +236,9 @@ const translations: Record<Language, Record<string, string>> = {
'action.new_chat': '新会话',
'action.install': '安装应用',
'action.toggle_think': '深度思考模式',
'action.lang_mode': 'AI 语言',
'action.lang_system': '跟随系统',
'action.lang_input': '跟随输入',
'history.title': '历史记录',
'history.empty': '暂无该模块的历史记录',
@@ -244,6 +286,38 @@ const translations: Record<Language, Record<string, string>> = {
'ui.workbench': '工作台',
'ui.gallery': '生成结果',
'ui.config': '参数配置',
// Share & Metadata
'share.title': '分享会话',
'share.copy': '复制完整对话',
'share.txt': '下载文本 (.txt)',
'share.img': '下载长图',
'success.copy': '已复制到剪贴板!',
'role.user': '你',
'role.model': 'Gemini',
'role.veo': 'Veo',
// SQL Tool
'sql.format': '格式化 SQL',
'sql.convert': '方言转换',
'sql.replace': 'AS 序号替换',
'sql.minify': '单行压缩',
'sql.input': '输入 SQL',
'sql.output': '输出 SQL',
'sql.target_db': '目标数据库',
'sql.placeholder': '在此粘贴您的 SQL...',
'sql.processing': '处理中...',
'sql.custom': '其他 / AI 助手',
'sql.custom_prompt': '描述您的需求(例如“提取表名”或“生成建表语句”)',
'sql.run': '执行',
// New Toasts
'warning.no_sql': '请先输入 SQL 代码。',
'success.apikey_updated': 'API Key 配置已更新。',
'success.data_imported': '数据导入成功。',
'error.invalid_file': '无效的文件格式。',
'error.screenshot': '截图生成失败。',
'error.tts': '语音生成失败。',
},
'ja': {
'app.name': 'BitSage',
@@ -260,6 +334,7 @@ const translations: Record<Language, Record<string, string>> = {
'module.network': 'コンピュータネットワーク',
'module.ai_lab': '人工知能ラボ',
'module.research': '学術検索',
'module.sql': 'SQLツール',
'module.vision': 'ビジョンラボ',
'module.studio': 'ビデオスタジオ',
'module.audio': 'オーディオラボ',
@@ -272,6 +347,7 @@ const translations: Record<Language, Record<string, string>> = {
'desc.network': 'プロトコル、セキュリティ、分散システム',
'desc.ai_lab': '機械学習、深層学習、ニューラルネットワーク',
'desc.research': 'Webに基づく学術研究',
'desc.sql': 'SQLのフォーマット、変換、操作',
'desc.vision': '画像分析と生成',
'desc.studio': 'AIビデオ生成スタジオ',
'desc.audio': '音声認識と音声合成',
@@ -317,6 +393,9 @@ const translations: Record<Language, Record<string, string>> = {
'action.new_chat': '新しいチャット',
'action.install': 'アプリをインストール',
'action.toggle_think': '深い思考モード',
'action.lang_mode': 'AI 言語',
'action.lang_system': 'システム言語',
'action.lang_input': '入力に合わせる',
'history.title': '履歴',
'history.empty': 'このモジュールの履歴はありません。',
@@ -364,6 +443,38 @@ const translations: Record<Language, Record<string, string>> = {
'ui.workbench': 'ワークベンチ',
'ui.gallery': '生成結果',
'ui.config': '設定',
// Share & Metadata
'share.title': 'セッションを共有',
'share.copy': '会話をコピー',
'share.txt': 'テキストをダウンロード',
'share.img': '長い画像を保存',
'success.copy': 'クリップボードにコピーしました!',
'role.user': 'あなた',
'role.model': 'Gemini',
'role.veo': 'Veo',
// SQL Tool
'sql.format': 'SQL整形',
'sql.convert': '方言変換',
'sql.replace': 'AS番号置換',
'sql.minify': '一行化',
'sql.input': '入力SQL',
'sql.output': '出力SQL',
'sql.target_db': 'ターゲットDB',
'sql.placeholder': 'ここにSQLを貼り付けてください...',
'sql.processing': '処理中...',
'sql.custom': 'その他 / AI アシスト',
'sql.custom_prompt': '操作を説明してください(例:「テーブル名を抽出」)',
'sql.run': '実行',
// New Toasts
'warning.no_sql': '先にSQLコードを入力してください。',
'success.apikey_updated': 'APIキーの設定が更新されました。',
'success.data_imported': 'データのインポートが完了しました。',
'error.invalid_file': '無効なファイル形式です。',
'error.screenshot': 'スクリーンショットの生成に失敗しました。',
'error.tts': '音声生成に失敗しました。',
},
'zh-TW': {
'app.name': '比特智者',
@@ -380,6 +491,7 @@ const translations: Record<Language, Record<string, string>> = {
'module.network': '計算機網路',
'module.ai_lab': '人工智慧',
'module.research': '學術搜尋',
'module.sql': 'SQL 工具箱',
'module.vision': '視覺實驗室',
'module.studio': '影片工作室',
'module.audio': '音訊實驗室',
@@ -392,6 +504,7 @@ const translations: Record<Language, Record<string, string>> = {
'desc.network': '通訊協定、資安與分散式系統',
'desc.ai_lab': '機器學習、深度學習與神經網路',
'desc.research': '基於網路的學術研究',
'desc.sql': 'SQL 格式化、轉換與處理',
'desc.vision': '圖像分析與生成',
'desc.studio': 'AI 影片生成工作室',
'desc.audio': '語音轉文字與文字轉語音',
@@ -437,6 +550,9 @@ const translations: Record<Language, Record<string, string>> = {
'action.new_chat': '新對話',
'action.install': '安裝應用',
'action.toggle_think': '深度思考模式',
'action.lang_mode': 'AI 語言',
'action.lang_system': '跟隨系統',
'action.lang_input': '跟隨輸入',
'history.title': '歷史記錄',
'history.empty': '暫無該模組的歷史記錄',
@@ -484,6 +600,38 @@ const translations: Record<Language, Record<string, string>> = {
'ui.workbench': '工作台',
'ui.gallery': '生成結果',
'ui.config': '參數配置',
// Share & Metadata
'share.title': '分享會話',
'share.copy': '複製完整對話',
'share.txt': '下載文字 (.txt)',
'share.img': '下載長圖',
'success.copy': '已複製到剪貼簿!',
'role.user': '你',
'role.model': 'Gemini',
'role.veo': 'Veo',
// SQL Tool
'sql.format': '格式化 SQL',
'sql.convert': '方言轉換',
'sql.replace': 'AS 序號替換',
'sql.minify': '單行壓縮',
'sql.input': '輸入 SQL',
'sql.output': '輸出 SQL',
'sql.target_db': '目標資料庫',
'sql.placeholder': '在此貼上您的 SQL...',
'sql.processing': '處理中...',
'sql.custom': '其他 / AI 助手',
'sql.custom_prompt': '描述您的需求(例如「提取表名」或「生成建表語句」)',
'sql.run': '執行',
// New Toasts
'warning.no_sql': '請先輸入 SQL 程式碼。',
'success.apikey_updated': 'API Key 設定已更新。',
'success.data_imported': '資料匯入成功。',
'error.invalid_file': '無效的檔案格式。',
'error.screenshot': '截圖生成失敗。',
'error.tts': '語音生成失敗。',
}
};

31
sw.js Normal file
View File

@@ -0,0 +1,31 @@
// BitSage Service Worker
const CACHE_NAME = 'bitsage-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/index.tsx'
];
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// Best effort caching
return cache.addAll(ASSETS_TO_CACHE).catch(() => {});
})
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
// Simple pass-through fetch handler to satisfy PWA requirements
// In a full production app, you might want more robust offline caching
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
});

View File

@@ -10,6 +10,7 @@ export enum AppModule {
// Tools
RESEARCH = 'research', // Search Grounding
SQL = 'sql', // SQL Tools
// Creative
VISION = 'vision', // Image Gen & Analysis
@@ -50,6 +51,7 @@ export interface AppSettings {
language: 'en' | 'ja' | 'zh-CN' | 'zh-TW';
theme: 'light' | 'dark' | 'system';
hasCompletedOnboarding: boolean;
aiResponseLanguage: 'system' | 'input'; // 'system' = use App Language, 'input' = Match User Input
}
export interface VeoConfig {