更新至 v0.3.0_20251226 版本
This commit is contained in:
364
App.tsx
364
App.tsx
@@ -30,9 +30,16 @@ import {
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
FileText,
|
||||
Download
|
||||
Download,
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Globe,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants';
|
||||
import { AppLanguage, ChatMode, Message, UserSettings, ChatSession, ChatScenario } from './types';
|
||||
@@ -48,12 +55,19 @@ const SCENARIOS = [
|
||||
ChatScenario.RESEARCH
|
||||
];
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [settings, setSettingsState] = useState<UserSettings>(loadSettings());
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [selectedScenario, setSelectedScenario] = useState<ChatScenario>(ChatScenario.GENERAL);
|
||||
const [selectedMode, setSelectedMode] = useState<ChatMode>(ChatMode.STANDARD);
|
||||
const [replyLanguage, setReplyLanguage] = useState<string>('system'); // 'system', 'auto', or specific language code
|
||||
const [input, setInput] = useState('');
|
||||
const [activeView, setActiveView] = useState<'home' | 'chat' | 'tools' | 'settings'>('home');
|
||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||
@@ -66,6 +80,7 @@ const App: React.FC = () => {
|
||||
const [loadingStep, setLoadingStep] = useState(0);
|
||||
const [showShareMenu, setShowShareMenu] = useState(false);
|
||||
const [installPrompt, setInstallPrompt] = useState<any>(null);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -104,6 +119,41 @@ const App: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Theme Logic
|
||||
useEffect(() => {
|
||||
const applyTheme = () => {
|
||||
const root = document.documentElement;
|
||||
const isDark =
|
||||
settings.theme === 'dark' ||
|
||||
(settings.theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system changes if auto
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (settings.theme === 'auto') applyTheme();
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [settings.theme]);
|
||||
|
||||
const addToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
const id = Date.now().toString();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleFinishOnboarding = () => {
|
||||
setShowOnboarding(false);
|
||||
setSettingsState(prev => ({ ...prev, isOnboarded: true }));
|
||||
@@ -283,11 +333,18 @@ const App: React.FC = () => {
|
||||
let groundingData: any = null;
|
||||
const usedModel = getModelNameForMode(session!.mode);
|
||||
|
||||
// Determine target language for AI
|
||||
let targetLangCode = replyLanguage;
|
||||
if (replyLanguage === 'system') {
|
||||
targetLangCode = settings.language;
|
||||
}
|
||||
// if 'auto', we pass 'auto' to the service which handles it.
|
||||
|
||||
await streamChatResponse(
|
||||
updatedMessages,
|
||||
userMsg.content,
|
||||
session!.mode,
|
||||
settings.language,
|
||||
targetLangCode,
|
||||
session!.scenario || ChatScenario.GENERAL,
|
||||
userMsg.attachments as any,
|
||||
(text, grounding) => {
|
||||
@@ -313,6 +370,7 @@ const App: React.FC = () => {
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.message || t.apiError, 'error');
|
||||
const errorMsg: Message = { id: Date.now().toString(), role: 'model', content: err.message || t.apiError, timestamp: Date.now() };
|
||||
setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: [...updatedMessages, errorMsg] } : s));
|
||||
} finally {
|
||||
@@ -355,7 +413,7 @@ const App: React.FC = () => {
|
||||
const text = await transcribeAudio(base64, 'audio/webm', settings.language);
|
||||
setInput(prev => prev + " " + text);
|
||||
} catch (e) {
|
||||
alert(t.transcriptionFail);
|
||||
addToast(t.transcriptionFail, 'error');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -366,7 +424,7 @@ const App: React.FC = () => {
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch (e) {
|
||||
alert(t.micError);
|
||||
addToast(t.micError, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -375,11 +433,11 @@ const App: React.FC = () => {
|
||||
if (!file) return;
|
||||
const success = await importData(file);
|
||||
if (success) {
|
||||
alert(t.importSuccess);
|
||||
addToast(t.importSuccess, 'success');
|
||||
setSessions(loadSessions());
|
||||
setSettingsState(loadSettings());
|
||||
} else {
|
||||
alert(t.importFail);
|
||||
addToast(t.importFail, 'error');
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
@@ -408,7 +466,7 @@ const App: React.FC = () => {
|
||||
).join('\n\n');
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert("对话已复制到剪贴板!");
|
||||
addToast(t.toast.copySuccess, 'success');
|
||||
setShowShareMenu(false);
|
||||
});
|
||||
};
|
||||
@@ -453,7 +511,8 @@ const App: React.FC = () => {
|
||||
maxHeight: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: '-1000',
|
||||
background: '#f8fafc', // slate-50
|
||||
background: settings.theme === 'dark' ? '#0f172a' : '#f8fafc',
|
||||
color: settings.theme === 'dark' ? '#f1f5f9' : '#0f172a'
|
||||
});
|
||||
|
||||
document.body.appendChild(clone);
|
||||
@@ -463,7 +522,7 @@ const App: React.FC = () => {
|
||||
|
||||
// 4. 使用 html2canvas 截图
|
||||
const canvas = await html2canvas(clone, {
|
||||
backgroundColor: '#f8fafc',
|
||||
backgroundColor: settings.theme === 'dark' ? '#0f172a' : '#f8fafc',
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
scale: 2, // 提升清晰度
|
||||
@@ -484,7 +543,7 @@ const App: React.FC = () => {
|
||||
setShowShareMenu(false);
|
||||
} catch (error) {
|
||||
console.error("Image generation failed", error);
|
||||
alert("生成图片失败,请重试。");
|
||||
addToast(t.genError, 'error');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -507,34 +566,54 @@ const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
// 使用 100dvh 适配移动端浏览器视口,防止被地址栏遮挡
|
||||
<div className="flex h-[100dvh] w-full bg-slate-50 overflow-hidden animate-fade-in relative">
|
||||
<div className="flex h-[100dvh] w-full bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 overflow-hidden animate-fade-in relative transition-colors duration-300">
|
||||
|
||||
{/* Toast Notification */}
|
||||
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[110] flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg border animate-slide-up
|
||||
${toast.type === 'success' ? 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400' : ''}
|
||||
${toast.type === 'error' ? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400' : ''}
|
||||
${toast.type === 'info' ? 'bg-blue-50 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-400' : ''}
|
||||
`}
|
||||
>
|
||||
{toast.type === 'success' && <CheckCircle2 size={18} />}
|
||||
{toast.type === 'error' && <AlertCircle size={18} />}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
<div className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full p-8 animate-slide-up space-y-6">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-3xl shadow-2xl max-w-lg w-full p-8 animate-slide-up space-y-6 border border-slate-100 dark:border-slate-800">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900">{t.appName} - {t.tagline}</h2>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">{t.appName} - {t.tagline}</h2>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center font-bold">1</span>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{t.onboarding.step1}</p>
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">1</span>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">{t.onboarding.step1}</p>
|
||||
</li>
|
||||
<li className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center font-bold">2</span>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{t.onboarding.step2}</p>
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">2</span>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">{t.onboarding.step2}</p>
|
||||
</li>
|
||||
<li className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center font-bold">3</span>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{t.onboarding.step3}</p>
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">3</span>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">{t.onboarding.step3}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFinishOnboarding}
|
||||
className="w-full bg-blue-600 text-white font-bold py-4 rounded-2xl hover:bg-blue-700 active:scale-95 transition-all shadow-lg shadow-blue-200"
|
||||
className="w-full bg-blue-600 text-white font-bold py-4 rounded-2xl hover:bg-blue-700 active:scale-95 transition-all shadow-lg shadow-blue-200 dark:shadow-none"
|
||||
>
|
||||
{t.onboarding.done}
|
||||
</button>
|
||||
@@ -560,16 +639,16 @@ const App: React.FC = () => {
|
||||
|
||||
{/* LEFT SIDEBAR */}
|
||||
<aside className={`
|
||||
fixed inset-y-0 left-0 z-30 w-60 md:w-64 bg-white border-r border-slate-200 transform transition-transform duration-300 ease-in-out
|
||||
fixed inset-y-0 left-0 z-30 w-60 md:w-64 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out
|
||||
md:relative md:translate-x-0 flex flex-col shadow-xl md:shadow-none
|
||||
${isLeftSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between h-16">
|
||||
<h1 className="font-bold text-xl text-blue-600 flex items-center gap-2">
|
||||
<span className="bg-blue-100 p-1.5 rounded-lg"><Sparkles size={18}/></span>
|
||||
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between h-16">
|
||||
<h1 className="font-bold text-xl text-blue-600 dark:text-blue-400 flex items-center gap-2">
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 p-1.5 rounded-lg"><Sparkles size={18}/></span>
|
||||
{t.appName}
|
||||
</h1>
|
||||
<button onClick={() => setIsLeftSidebarOpen(false)} className="md:hidden text-slate-500 active:rotate-90 transition-transform">
|
||||
<button onClick={() => setIsLeftSidebarOpen(false)} className="md:hidden text-slate-500 dark:text-slate-400 active:rotate-90 transition-transform">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -579,10 +658,10 @@ const App: React.FC = () => {
|
||||
<button
|
||||
onClick={() => { setActiveView('home'); setIsLeftSidebarOpen(false); }}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition-all text-left active:scale-95 mb-4
|
||||
${activeView === 'home' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}
|
||||
${activeView === 'home' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}
|
||||
`}
|
||||
>
|
||||
<span className={`p-2 rounded-lg transition flex-shrink-0 ${activeView === 'home' ? 'bg-white text-blue-600 shadow-sm' : 'bg-slate-100 text-slate-500'}`}>
|
||||
<span className={`p-2 rounded-lg transition flex-shrink-0 ${activeView === 'home' ? 'bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-400 shadow-sm' : 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-500'}`}>
|
||||
<HomeIcon size={18} />
|
||||
</span>
|
||||
<span>{t.home}</span>
|
||||
@@ -595,10 +674,10 @@ const App: React.FC = () => {
|
||||
key={scenario}
|
||||
onClick={() => handleScenarioSelect(scenario)}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition-all group text-left active:scale-95
|
||||
${selectedScenario === scenario && activeView === 'chat' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}
|
||||
${selectedScenario === scenario && activeView === 'chat' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}
|
||||
`}
|
||||
>
|
||||
<span className={`p-2 rounded-lg transition flex-shrink-0 ${selectedScenario === scenario && activeView === 'chat' ? 'bg-white text-blue-600 shadow-sm' : 'bg-slate-100 text-slate-500'}`}>
|
||||
<span className={`p-2 rounded-lg transition flex-shrink-0 ${selectedScenario === scenario && activeView === 'chat' ? 'bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-400 shadow-sm' : 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-500'}`}>
|
||||
{getScenarioIcon(scenario)}
|
||||
</span>
|
||||
<span>{t.scenarios[scenario].title}</span>
|
||||
@@ -611,19 +690,28 @@ const App: React.FC = () => {
|
||||
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 px-2">{t.tools}</div>
|
||||
<button
|
||||
onClick={() => { setActiveView('tools'); setIsLeftSidebarOpen(false); }}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition-all active:scale-95 text-left ${activeView === 'tools' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg text-sm font-medium transition-all active:scale-95 text-left ${activeView === 'tools' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}`}
|
||||
>
|
||||
<span className="p-2 rounded-lg bg-slate-100 text-slate-500 flex-shrink-0"><ImagePlus size={18} /></span>
|
||||
<span className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-500 flex-shrink-0"><ImagePlus size={18} /></span>
|
||||
<span>{t.studio}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部菜单适配安全区域 */}
|
||||
<div className="p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] border-t border-slate-100">
|
||||
<div className="p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] border-t border-slate-100 dark:border-slate-800 flex flex-col gap-2">
|
||||
{installPrompt && (
|
||||
<button
|
||||
onClick={() => { handleInstallClick(); setIsLeftSidebarOpen(false); }}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg text-sm font-medium transition-all active:scale-95 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30"
|
||||
>
|
||||
<Download size={18} />
|
||||
{t.installApp}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setActiveView('settings'); setIsLeftSidebarOpen(false); }}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg text-sm font-medium transition-all active:scale-95 ${activeView === 'settings' ? 'bg-slate-100 text-slate-900' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg text-sm font-medium transition-all active:scale-95 ${activeView === 'settings' ? 'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/50'}`}
|
||||
>
|
||||
<SettingsIcon size={18} />
|
||||
{t.settings}
|
||||
@@ -633,20 +721,20 @@ const App: React.FC = () => {
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<main className="flex-1 flex flex-col h-full relative min-w-0">
|
||||
<header className="h-16 bg-white border-b border-slate-100 flex items-center px-4 justify-between shrink-0 z-10 shadow-sm pt-[env(safe-area-inset-top)]">
|
||||
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800 flex items-center px-4 justify-between shrink-0 z-10 shadow-sm pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<button onClick={() => setIsLeftSidebarOpen(true)} className="md:hidden p-2 text-slate-600 active:scale-90 transition-transform flex-shrink-0">
|
||||
<button onClick={() => setIsLeftSidebarOpen(true)} className="md:hidden p-2 text-slate-600 dark:text-slate-400 active:scale-90 transition-transform flex-shrink-0">
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
{activeView === 'chat' && (
|
||||
<div className="flex items-center gap-2 text-slate-700 font-medium animate-fade-in truncate">
|
||||
<span className="text-blue-600 bg-blue-50 p-1 rounded-md flex-shrink-0">{getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)}</span>
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-200 font-medium animate-fade-in truncate">
|
||||
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 p-1 rounded-md flex-shrink-0">{getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)}</span>
|
||||
<span className="hidden sm:inline truncate">{t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'home' && (
|
||||
<div className="flex items-center gap-2 text-slate-700 font-medium animate-fade-in">
|
||||
<HomeIcon size={18} className="text-blue-600" />
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-200 font-medium animate-fade-in">
|
||||
<HomeIcon size={18} className="text-blue-600 dark:text-blue-400" />
|
||||
<span>{t.home}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -655,11 +743,31 @@ const App: React.FC = () => {
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{activeView === 'chat' && (
|
||||
<>
|
||||
<div className="flex items-center space-x-1 bg-slate-100 p-1 rounded-lg mr-2 overflow-x-auto max-w-[130px] sm:max-w-none no-scrollbar">
|
||||
{/* Language Selector in Chat Header */}
|
||||
<div className="relative group flex items-center">
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg px-2 h-8">
|
||||
<Globe size={14} className="text-slate-500 dark:text-slate-400 mr-1.5" />
|
||||
<select
|
||||
value={replyLanguage}
|
||||
onChange={(e) => setReplyLanguage(e.target.value)}
|
||||
className="bg-transparent text-xs font-medium text-slate-600 dark:text-slate-300 focus:outline-none appearance-none pr-4 cursor-pointer"
|
||||
title={t.replyLanguageLabel}
|
||||
>
|
||||
<option value="system">{t.replyLangSystem}</option>
|
||||
<option value="auto">{t.replyLangAuto}</option>
|
||||
<option value={AppLanguage.ZH_CN}>简体中文</option>
|
||||
<option value={AppLanguage.ZH_TW}>繁體中文</option>
|
||||
<option value={AppLanguage.EN}>English</option>
|
||||
<option value={AppLanguage.JA}>日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-lg mr-2 overflow-x-auto max-w-[130px] sm:max-w-none no-scrollbar">
|
||||
{[
|
||||
{ mode: ChatMode.STANDARD, label: t.modeStandard, color: 'text-blue-600' },
|
||||
{ mode: ChatMode.DEEP, label: t.modeDeep, color: 'text-purple-600' },
|
||||
{ mode: ChatMode.FAST, label: t.modeFast, color: 'text-green-600' }
|
||||
{ mode: ChatMode.STANDARD, label: t.modeStandard, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ mode: ChatMode.DEEP, label: t.modeDeep, color: 'text-purple-600 dark:text-purple-400' },
|
||||
{ mode: ChatMode.FAST, label: t.modeFast, color: 'text-green-600 dark:text-green-400' }
|
||||
].map(m => (
|
||||
<button
|
||||
key={m.mode}
|
||||
@@ -667,7 +775,7 @@ const App: React.FC = () => {
|
||||
if(currentSession) setSessions(s => s.map(sess => sess.id === currentSessionId ? {...sess, mode: m.mode} : sess));
|
||||
else setSelectedMode(m.mode);
|
||||
}}
|
||||
className={`px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded-md transition-all active:scale-95 whitespace-nowrap flex-shrink-0 ${activeMode === m.mode ? `bg-white shadow ${m.color}` : 'text-slate-500 hover:bg-slate-200'}`}
|
||||
className={`px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded-md transition-all active:scale-95 whitespace-nowrap flex-shrink-0 ${activeMode === m.mode ? `bg-white dark:bg-slate-700 shadow ${m.color}` : 'text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
@@ -678,20 +786,20 @@ const App: React.FC = () => {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowShareMenu(!showShareMenu)}
|
||||
className={`p-2 rounded-lg transition-all active:scale-90 ${showShareMenu ? 'bg-blue-100 text-blue-600' : 'text-slate-500 hover:bg-slate-100'}`}
|
||||
className={`p-2 rounded-lg transition-all active:scale-90 ${showShareMenu ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}`}
|
||||
title="分享对话"
|
||||
>
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
{showShareMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 overflow-hidden z-50 animate-slide-up origin-top-right">
|
||||
<button onClick={handleShareText} className="w-full text-left px-4 py-3 hover:bg-slate-50 flex items-center gap-3 text-sm text-slate-700 transition-colors">
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-100 dark:border-slate-700 overflow-hidden z-50 animate-slide-up origin-top-right">
|
||||
<button onClick={handleShareText} className="w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center gap-3 text-sm text-slate-700 dark:text-slate-200 transition-colors">
|
||||
<Copy size={16} /> 复制文本
|
||||
</button>
|
||||
<button onClick={handleDownloadText} className="w-full text-left px-4 py-3 hover:bg-slate-50 flex items-center gap-3 text-sm text-slate-700 transition-colors border-t border-slate-50">
|
||||
<button onClick={handleDownloadText} className="w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center gap-3 text-sm text-slate-700 dark:text-slate-200 transition-colors border-t border-slate-50 dark:border-slate-700">
|
||||
<FileText size={16} /> 下载文件
|
||||
</button>
|
||||
<button onClick={handleShareImage} className="w-full text-left px-4 py-3 hover:bg-slate-50 flex items-center gap-3 text-sm text-slate-700 transition-colors border-t border-slate-50">
|
||||
<button onClick={handleShareImage} className="w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center gap-3 text-sm text-slate-700 dark:text-slate-200 transition-colors border-t border-slate-50 dark:border-slate-700">
|
||||
<ImageIcon size={16} /> 生成长图
|
||||
</button>
|
||||
</div>
|
||||
@@ -705,7 +813,7 @@ const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools' && (
|
||||
<button onClick={() => setIsRightSidebarOpen(!isRightSidebarOpen)} className={`p-2 rounded-lg transition-all active:scale-90 ${isRightSidebarOpen ? 'text-blue-600 bg-blue-50' : 'text-slate-500 hover:bg-slate-100'}`}>
|
||||
<button onClick={() => setIsRightSidebarOpen(!isRightSidebarOpen)} className={`p-2 rounded-lg transition-all active:scale-90 ${isRightSidebarOpen ? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30' : 'text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}`}>
|
||||
<History size={20} />
|
||||
</button>
|
||||
)}
|
||||
@@ -717,48 +825,48 @@ const App: React.FC = () => {
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-12 animate-fade-in">
|
||||
<div className="max-w-4xl mx-auto space-y-16 pb-20">
|
||||
<div className="space-y-6 text-center md:text-left">
|
||||
<h1 className="text-4xl md:text-6xl font-black text-slate-900 leading-tight animate-slide-up">
|
||||
<h1 className="text-4xl md:text-6xl font-black text-slate-900 dark:text-white leading-tight animate-slide-up">
|
||||
{t.homeWelcome}
|
||||
</h1>
|
||||
<p className="text-xl text-slate-500 max-w-2xl animate-slide-up [animation-delay:100ms]">
|
||||
<p className="text-xl text-slate-500 dark:text-slate-400 max-w-2xl animate-slide-up [animation-delay:100ms]">
|
||||
{t.tagline} {t.homeDesc}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 animate-slide-up [animation-delay:200ms]">
|
||||
<button
|
||||
onClick={() => handleScenarioSelect(ChatScenario.GENERAL)}
|
||||
className="px-8 py-4 bg-blue-600 text-white rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 hover:bg-blue-700 active:scale-95 transition-all"
|
||||
className="px-8 py-4 bg-blue-600 text-white rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 dark:shadow-none hover:bg-blue-700 active:scale-95 transition-all"
|
||||
>
|
||||
{t.getStarted} <ArrowRight size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('tools')}
|
||||
className="px-8 py-4 bg-white text-slate-700 border border-slate-200 rounded-2xl font-bold hover:bg-slate-50 active:scale-95 transition-all"
|
||||
className="px-8 py-4 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700 rounded-2xl font-bold hover:bg-slate-50 dark:hover:bg-slate-700 active:scale-95 transition-all"
|
||||
>
|
||||
{t.studio}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 md:p-12 rounded-[2.5rem] shadow-xl border border-slate-100 relative overflow-hidden animate-slide-up [animation-delay:300ms]">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5">
|
||||
<div className="bg-white dark:bg-slate-900 p-8 md:p-12 rounded-[2.5rem] shadow-xl border border-slate-100 dark:border-slate-800 relative overflow-hidden animate-slide-up [animation-delay:300ms]">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5 dark:opacity-10 dark:text-white">
|
||||
<Quote size={120} />
|
||||
</div>
|
||||
<div className="relative space-y-6">
|
||||
<div className="flex items-center gap-2 text-blue-600 font-bold uppercase tracking-widest text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold uppercase tracking-widest text-sm">
|
||||
<Lightbulb size={18} />
|
||||
{t.homeQuoteTitle}
|
||||
</div>
|
||||
<blockquote className="text-2xl md:text-3xl font-serif text-slate-800 leading-relaxed italic">
|
||||
<blockquote className="text-2xl md:text-3xl font-serif text-slate-800 dark:text-slate-200 leading-relaxed italic">
|
||||
“{randomQuote.text}”
|
||||
</blockquote>
|
||||
<div className="text-right text-slate-500 font-medium">
|
||||
<div className="text-right text-slate-500 dark:text-slate-400 font-medium">
|
||||
—— {randomQuote.author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<LayoutGrid size={24} className="text-blue-500" />
|
||||
{t.homeFeatureTitle}
|
||||
</h2>
|
||||
@@ -767,14 +875,14 @@ const App: React.FC = () => {
|
||||
<button
|
||||
key={scenario}
|
||||
onClick={() => handleScenarioSelect(scenario)}
|
||||
className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm hover:shadow-md hover:border-blue-100 text-left transition-all active:scale-95 group animate-slide-up"
|
||||
className="bg-white dark:bg-slate-900 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 shadow-sm hover:shadow-md hover:border-blue-100 dark:hover:border-blue-900 text-left transition-all active:scale-95 group animate-slide-up"
|
||||
style={{ animationDelay: `${400 + idx * 50}ms` }}
|
||||
>
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-400 group-hover:bg-blue-50 group-hover:text-blue-600 transition-colors mb-4">
|
||||
<div className="w-12 h-12 bg-slate-50 dark:bg-slate-800 rounded-2xl flex items-center justify-center text-slate-400 dark:text-slate-500 group-hover:bg-blue-50 dark:group-hover:bg-blue-900/30 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors mb-4">
|
||||
{getScenarioIcon(scenario)}
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-800 mb-2">{t.scenarios[scenario].title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{t.scenarios[scenario].desc}</p>
|
||||
<h3 className="font-bold text-slate-800 dark:text-slate-200 mb-2">{t.scenarios[scenario].title}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t.scenarios[scenario].desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -787,11 +895,11 @@ const App: React.FC = () => {
|
||||
<div className="flex flex-col h-full relative">
|
||||
<div ref={chatContainerRef} className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{!currentSession ? (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center text-slate-500 animate-slide-up">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 text-blue-600 shadow-inner">
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center text-slate-500 dark:text-slate-400 animate-slide-up">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6 text-blue-600 dark:text-blue-400 shadow-inner">
|
||||
{getScenarioIcon(selectedScenario)}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-2">{t.scenarios[selectedScenario].title}</h2>
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200 mb-2">{t.scenarios[selectedScenario].title}</h2>
|
||||
<p className="max-w-md">{t.scenarios[selectedScenario].greeting}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -800,32 +908,32 @@ const App: React.FC = () => {
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-slide-up`}>
|
||||
{msg.role === 'model' && (
|
||||
<div className="mr-3 flex-shrink-0 mt-1">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 shadow-sm">
|
||||
{getScenarioIcon(currentSession.scenario)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`max-w-[85%] md:max-w-[70%] space-y-1`}>
|
||||
{msg.attachments?.map((att, i) => (
|
||||
<div key={i} className="mb-2 overflow-hidden rounded-lg shadow-md border border-slate-100">
|
||||
<div key={i} className="mb-2 overflow-hidden rounded-lg shadow-md border border-slate-100 dark:border-slate-700">
|
||||
<img src={`data:${att.mimeType};base64,${att.data}`} alt="attachment" className="max-h-48 w-full object-cover transition-transform hover:scale-105" />
|
||||
</div>
|
||||
))}
|
||||
<div className={`p-4 rounded-2xl shadow-sm text-sm md:text-base leading-relaxed msg-transition ${
|
||||
msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-slate-100 text-slate-800 rounded-tl-none'
|
||||
msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 text-slate-800 dark:text-slate-200 rounded-tl-none'
|
||||
}`}>
|
||||
<div className="prose prose-sm max-w-none text-inherit prose-headings:text-inherit prose-strong:text-inherit">
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-inherit prose-headings:text-inherit prose-strong:text-inherit">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 px-1 mt-1">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500 px-1 mt-1">
|
||||
<span>{new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</span>
|
||||
{msg.role === 'model' && (
|
||||
<>
|
||||
{msg.model && (
|
||||
<>
|
||||
<span className="w-1 h-1 rounded-full bg-slate-300"></span>
|
||||
<span className="font-medium text-blue-600/80">{formatModelName(msg.model)}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600"></span>
|
||||
<span className="font-medium text-blue-600/80 dark:text-blue-400/80">{formatModelName(msg.model)}</span>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => playTTS(msg.content)} className="hover:text-blue-500 active:scale-90 transition-transform"><Volume2 size={14}/></button>
|
||||
@@ -840,17 +948,17 @@ const App: React.FC = () => {
|
||||
{isProcessing && !streamingContent && (
|
||||
<div className="flex justify-start animate-fade-in">
|
||||
<div className="mr-3 flex-shrink-0 mt-1">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 animate-pulse">
|
||||
{getScenarioIcon(currentSession?.scenario)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 p-4 rounded-2xl rounded-tl-none shadow-sm flex flex-col gap-2 min-w-[200px]">
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-4 rounded-2xl rounded-tl-none shadow-sm flex flex-col gap-2 min-w-[200px]">
|
||||
<div className="flex gap-1.5 items-center h-4">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce-delay [animation-delay:-0.32s]"></span>
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce-delay [animation-delay:-0.16s]"></span>
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce-delay"></span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-medium animate-fade-in" key={loadingStep}>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 font-medium animate-fade-in" key={loadingStep}>
|
||||
{getLoadingText(currentSession?.scenario)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -860,10 +968,10 @@ const App: React.FC = () => {
|
||||
{/* 正在生成(有内容)的状态 */}
|
||||
{isProcessing && streamingContent && (
|
||||
<div className="flex justify-start animate-fade-in">
|
||||
<div className="mr-3 flex-shrink-0 mt-1"><div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600"><Loader2 size={14} className="animate-spin" /></div></div>
|
||||
<div className="max-w-[85%] md:max-w-[70%] bg-white border border-slate-100 p-4 rounded-2xl rounded-tl-none shadow-sm">
|
||||
<div className="prose prose-sm max-w-none text-slate-800">
|
||||
<ReactMarkdown>{streamingContent}</ReactMarkdown>
|
||||
<div className="mr-3 flex-shrink-0 mt-1"><div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400"><Loader2 size={14} className="animate-spin" /></div></div>
|
||||
<div className="max-w-[85%] md:max-w-[70%] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-4 rounded-2xl rounded-tl-none shadow-sm">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-slate-800 dark:text-slate-200">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-blue-500 animate-breathe">
|
||||
{currentSession?.mode === ChatMode.DEEP ? t.thinking : t.generating}
|
||||
@@ -877,24 +985,24 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 输入框区域增加底部安全距离适配 pb-[max(1rem,env(safe-area-inset-bottom))] */}
|
||||
<div className="p-4 pb-[max(1rem,env(safe-area-inset-bottom))] bg-white border-t border-slate-100 shrink-0 z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||
<div className="p-4 pb-[max(1rem,env(safe-area-inset-bottom))] bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800 shrink-0 z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-2">
|
||||
<div className="flex items-end gap-2 bg-slate-50 p-2 rounded-2xl border border-slate-200 focus-within:ring-2 focus-within:ring-blue-100 transition-all">
|
||||
<div className="flex items-end gap-2 bg-slate-50 dark:bg-slate-800 p-2 rounded-2xl border border-slate-200 dark:border-slate-700 focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-900 transition-all">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileUpload} accept="image/*" className="hidden" />
|
||||
<button onClick={() => fileInputRef.current?.click()} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-white rounded-xl active:scale-90 transition-all"><ImagePlus size={20} /></button>
|
||||
<button onClick={() => fileInputRef.current?.click()} className="p-2 text-slate-400 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-white dark:hover:bg-slate-700 rounded-xl active:scale-90 transition-all"><ImagePlus size={20} /></button>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }}
|
||||
placeholder={t.inputPlaceholder}
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 resize-none max-h-32 py-2 text-slate-700"
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 resize-none max-h-32 py-2 text-slate-700 dark:text-slate-200 placeholder-slate-400"
|
||||
rows={1}
|
||||
/>
|
||||
<button onClick={handleRecordAudio} className={`p-2 rounded-xl transition-all active:scale-90 ${isRecording ? 'bg-red-100 text-red-500 animate-pulse' : 'text-slate-400 hover:text-blue-500'}`}><Mic size={20} /></button>
|
||||
<button onClick={handleRecordAudio} className={`p-2 rounded-xl transition-all active:scale-90 ${isRecording ? 'bg-red-100 dark:bg-red-900/30 text-red-500' : 'text-slate-400 dark:text-slate-400 hover:text-blue-500'}`}><Mic size={20} /></button>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={(!input.trim() && attachments.length === 0) || isProcessing}
|
||||
className="p-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 active:scale-90 disabled:opacity-50 transition-all shadow-md"
|
||||
className="p-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 active:scale-90 disabled:opacity-50 transition-all shadow-md dark:shadow-none"
|
||||
>
|
||||
{isProcessing ? <Loader2 size={20} className="animate-spin"/> : <Send size={20} />}
|
||||
</button>
|
||||
@@ -922,14 +1030,40 @@ const App: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 animate-slide-up">
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2"><SettingsIcon size={20} className="text-slate-400" />{t.settings}</h2>
|
||||
<div className="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-100 dark:border-slate-800 animate-slide-up">
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2 text-slate-900 dark:text-white"><SettingsIcon size={20} className="text-slate-400" />{t.settings}</h2>
|
||||
|
||||
{/* Theme Settings */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">{t.languageLabel}</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{t.themeLabel}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: 'auto', label: t.themeAuto, icon: Monitor },
|
||||
{ value: 'light', label: t.themeLight, icon: Sun },
|
||||
{ value: 'dark', label: t.themeDark, icon: Moon }
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setSettingsState(s => ({...s, theme: option.value as any}))}
|
||||
className={`flex flex-col items-center justify-center p-3 rounded-xl border transition-all ${
|
||||
settings.theme === option.value
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-slate-50 dark:bg-slate-800 border-transparent text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<option.icon size={20} className="mb-1" />
|
||||
<span className="text-xs font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t.languageLabel}</label>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSettingsState(s => ({...s, language: e.target.value as AppLanguage}))}
|
||||
className="w-full p-3 border border-slate-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 transition-shadow"
|
||||
className="w-full p-3 border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 transition-shadow"
|
||||
>
|
||||
<option value={AppLanguage.ZH_CN}>简体中文</option>
|
||||
<option value={AppLanguage.ZH_TW}>繁體中文</option>
|
||||
@@ -938,7 +1072,7 @@ const App: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">{t.apiKeyLabel}</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t.apiKeyLabel}</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
@@ -946,20 +1080,20 @@ const App: React.FC = () => {
|
||||
value={settings.apiKey || ''}
|
||||
onChange={(e) => setSettingsState(s => ({...s, apiKey: e.target.value}))}
|
||||
placeholder="sk-..."
|
||||
className="w-full p-3 border border-slate-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 transition-shadow outline-none"
|
||||
className="w-full p-3 border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 transition-shadow outline-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">{t.apiKeyDesc}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{t.apiKeyDesc}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="h-px flex-1 bg-slate-100"></div>
|
||||
<div className="h-px flex-1 bg-slate-100 dark:bg-slate-800"></div>
|
||||
<span className="text-xs text-slate-400">OR</span>
|
||||
<div className="h-px flex-1 bg-slate-100"></div>
|
||||
<div className="h-px flex-1 bg-slate-100 dark:bg-slate-800"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenSelectKey}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-50 text-blue-600 font-bold rounded-xl hover:bg-blue-100 active:scale-95 transition-all"
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-bold rounded-xl hover:bg-blue-100 dark:hover:bg-blue-900/40 active:scale-95 transition-all"
|
||||
>
|
||||
<Key size={18} />
|
||||
{t.selectApiKeyBtn} (Google Cloud)
|
||||
@@ -976,14 +1110,14 @@ const App: React.FC = () => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 animate-slide-up [animation-delay:100ms]">
|
||||
<h2 className="text-lg font-bold mb-4">{t.backupRestore}</h2>
|
||||
<div className="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-100 dark:border-slate-800 animate-slide-up [animation-delay:100ms]">
|
||||
<h2 className="text-lg font-bold mb-4 text-slate-900 dark:text-white">{t.backupRestore}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button onClick={exportData} className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-medium transition-all active:scale-95">{t.exportData}</button>
|
||||
<label className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-medium transition-all cursor-pointer text-center active:scale-95">
|
||||
<button onClick={exportData} className="px-4 py-3 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl text-sm font-medium transition-all active:scale-95">{t.exportData}</button>
|
||||
<label className="px-4 py-3 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl text-sm font-medium transition-all cursor-pointer text-center active:scale-95">
|
||||
{t.importData} <input type="file" onChange={handleImport} accept=".json" className="hidden" />
|
||||
</label>
|
||||
<button onClick={() => { if (window.confirm(t.confirmClearData)) { clearData(); window.location.reload(); } }} className="sm:col-span-2 px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl text-sm font-medium transition-all active:scale-[0.98]">{t.clearData}</button>
|
||||
<button onClick={() => { if (window.confirm(t.confirmClearData)) { clearData(); window.location.reload(); } }} className="sm:col-span-2 px-4 py-3 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 text-red-600 dark:text-red-400 rounded-xl text-sm font-medium transition-all active:scale-[0.98]">{t.clearData}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -994,14 +1128,14 @@ const App: React.FC = () => {
|
||||
|
||||
{/* 设置视图下不显示右侧侧边栏 */}
|
||||
<aside className={`
|
||||
fixed inset-y-0 right-0 z-20 w-64 lg:w-80 bg-white border-l border-slate-200 transform transition-transform duration-300 ease-in-out
|
||||
fixed inset-y-0 right-0 z-20 w-64 lg:w-80 bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out
|
||||
lg:relative lg:translate-x-0 shadow-xl lg:shadow-none
|
||||
${(isRightSidebarOpen && activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools') ? 'translate-x-0' : 'translate-x-full lg:hidden'}
|
||||
`}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between h-16 pt-[env(safe-area-inset-top)]">
|
||||
<h2 className="font-semibold text-slate-700 flex items-center gap-2"><History size={18} className="text-slate-400" />{t.history}</h2>
|
||||
<button onClick={() => setIsRightSidebarOpen(false)} className="lg:hidden text-slate-500 active:rotate-90 transition-transform"><X size={20} /></button>
|
||||
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between h-16 pt-[env(safe-area-inset-top)]">
|
||||
<h2 className="font-semibold text-slate-700 dark:text-slate-200 flex items-center gap-2"><History size={18} className="text-slate-400" />{t.history}</h2>
|
||||
<button onClick={() => setIsRightSidebarOpen(false)} className="lg:hidden text-slate-500 dark:text-slate-400 active:rotate-90 transition-transform"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
@@ -1010,7 +1144,7 @@ const App: React.FC = () => {
|
||||
handleScenarioSelect(selectedScenario);
|
||||
if (window.innerWidth < 1024) setIsRightSidebarOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 p-3 bg-blue-600 text-white rounded-xl font-bold shadow-md shadow-blue-100 hover:bg-blue-700 active:scale-95 transition-all"
|
||||
className="w-full flex items-center justify-center gap-2 p-3 bg-blue-600 text-white rounded-xl font-bold shadow-md shadow-blue-100 dark:shadow-none hover:bg-blue-700 active:scale-95 transition-all"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t.newChat}
|
||||
@@ -1020,23 +1154,23 @@ const App: React.FC = () => {
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-20">
|
||||
{Object.entries(groupedSessions).map(([group, groupSessions]) => groupSessions.length > 0 && (
|
||||
<div key={group} className="animate-fade-in">
|
||||
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-1">{group}</div>
|
||||
<div className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2 px-1">{group}</div>
|
||||
<div className="space-y-1">
|
||||
{groupSessions.map(s => (
|
||||
<div key={s.id} className="relative group animate-slide-up">
|
||||
<button
|
||||
onClick={() => { setCurrentSessionId(s.id); setActiveView('chat'); if (window.innerWidth < 1024) setIsRightSidebarOpen(false); }}
|
||||
className={`w-full text-left p-3 rounded-xl transition-all active:scale-95 flex items-start gap-3 border ${
|
||||
currentSessionId === s.id && activeView === 'chat' ? 'bg-blue-50 border-blue-100 shadow-sm' : 'bg-white border-transparent hover:bg-slate-50'
|
||||
currentSessionId === s.id && activeView === 'chat' ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-900/30 shadow-sm' : 'bg-white dark:bg-slate-900 border-transparent hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<span className={`mt-0.5 ${currentSessionId === s.id ? 'text-blue-600' : 'text-slate-400'}`}>{getScenarioIcon(s.scenario)}</span>
|
||||
<span className={`mt-0.5 ${currentSessionId === s.id ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400 dark:text-slate-500'}`}>{getScenarioIcon(s.scenario)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${currentSessionId === s.id ? 'text-blue-700' : 'text-slate-700'}`}>{s.title}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{new Date(s.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div>
|
||||
<div className={`text-sm font-medium truncate ${currentSessionId === s.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'}`}>{s.title}</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{new Date(s.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={(e) => deleteSession(e, s.id)} className="absolute right-2 top-3 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={14} /></button>
|
||||
<button onClick={(e) => deleteSession(e, s.id)} className="absolute right-2 top-3 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -59,33 +59,33 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<div className="flex space-x-1 bg-slate-200 p-1 rounded-xl w-fit animate-fade-in shadow-inner">
|
||||
<div className="flex space-x-1 bg-slate-200 dark:bg-slate-800 p-1 rounded-xl w-fit animate-fade-in shadow-inner">
|
||||
<button
|
||||
onClick={() => { setActiveTab('image'); setResultUrl(null); }}
|
||||
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'image' ? 'bg-white shadow text-blue-600' : 'text-slate-600 hover:text-slate-800'}`}
|
||||
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'image' ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
||||
>
|
||||
<ImageIcon size={18} />
|
||||
<span className="font-medium">{t.imageGen}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('video'); setResultUrl(null); }}
|
||||
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'video' ? 'bg-white shadow text-purple-600' : 'text-slate-600 hover:text-slate-800'}`}
|
||||
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'video' ? 'bg-white dark:bg-slate-700 shadow text-purple-600 dark:text-purple-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
||||
>
|
||||
<Video size={18} />
|
||||
<span className="font-medium">{t.videoGen}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl shadow-xl border border-slate-100 p-6 md:p-8 animate-slide-up space-y-6">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-3xl shadow-xl border border-slate-100 dark:border-slate-800 p-6 md:p-8 animate-slide-up space-y-6">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="w-full p-5 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:outline-none focus:bg-white transition-all resize-none min-h-[120px] text-slate-700"
|
||||
className="w-full p-5 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:outline-none focus:bg-white dark:focus:bg-slate-900 transition-all resize-none min-h-[120px] text-slate-700 dark:text-slate-200"
|
||||
rows={4}
|
||||
placeholder={activeTab === 'image' ? t.imagePromptPlaceholder : t.videoPromptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
<Sparkles className="absolute right-4 bottom-4 text-slate-300 pointer-events-none" size={20} />
|
||||
<Sparkles className="absolute right-4 bottom-4 text-slate-300 dark:text-slate-600 pointer-events-none" size={20} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-center justify-between">
|
||||
@@ -96,7 +96,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
||||
<select
|
||||
value={imageSize}
|
||||
onChange={(e) => setImageSize(e.target.value as any)}
|
||||
className="px-4 py-2 bg-slate-100 border border-transparent rounded-xl text-sm font-medium focus:bg-white focus:border-blue-200 transition-all"
|
||||
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
|
||||
>
|
||||
<option value="1K">1K (HD)</option>
|
||||
<option value="2K">2K (QHD)</option>
|
||||
@@ -109,7 +109,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
||||
<select
|
||||
value={videoRatio}
|
||||
onChange={(e) => setVideoRatio(e.target.value as any)}
|
||||
className="px-4 py-2 bg-slate-100 border border-transparent rounded-xl text-sm font-medium focus:bg-white focus:border-blue-200 transition-all"
|
||||
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
|
||||
>
|
||||
<option value="16:9">{t.landscape}</option>
|
||||
<option value="9:16">{t.portrait}</option>
|
||||
@@ -121,7 +121,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="px-8 py-3 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 active:scale-95 disabled:opacity-50 flex items-center space-x-2 transition-all shadow-lg shadow-blue-200"
|
||||
className="px-8 py-3 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 active:scale-95 disabled:opacity-50 flex items-center space-x-2 transition-all shadow-lg shadow-blue-200 dark:shadow-none"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={20} /> : null}
|
||||
<span>{loading ? t.generating : t.generate}</span>
|
||||
@@ -129,19 +129,19 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 text-sm rounded-xl border border-red-100 animate-slide-up">
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-xl border border-red-100 dark:border-red-900/30 animate-slide-up">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && activeTab === 'video' && (
|
||||
<div className="text-center py-4 text-sm text-slate-500 animate-breathe">
|
||||
<div className="text-center py-4 text-sm text-slate-500 dark:text-slate-400 animate-breathe">
|
||||
{t.videoDuration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultUrl && (
|
||||
<div className="mt-8 border-t border-slate-100 pt-8 animate-slide-up">
|
||||
<div className="mt-8 border-t border-slate-100 dark:border-slate-800 pt-8 animate-slide-up">
|
||||
<div className="relative rounded-2xl overflow-hidden bg-slate-900 flex justify-center items-center shadow-2xl group">
|
||||
{activeTab === 'image' ? (
|
||||
<img src={resultUrl} alt="Generated" className="max-h-[600px] w-auto object-contain transition-transform duration-500 group-hover:scale-[1.02]" />
|
||||
|
||||
52
constants.ts
52
constants.ts
@@ -56,6 +56,10 @@ export const TRANSLATIONS = {
|
||||
noHistory: "暂无历史记录。开始一段对话吧!",
|
||||
apiError: "错误:无法生成响应。请检查 API Key。",
|
||||
languageLabel: "界面语言",
|
||||
themeLabel: "主题模式",
|
||||
themeAuto: "跟随系统",
|
||||
themeLight: "浅色模式",
|
||||
themeDark: "深色模式",
|
||||
apiKeyIntro: "为了支持高质量图像生成和视频生成功能,请先选择您的 API Key。",
|
||||
selectApiKeyBtn: "选择 API Key",
|
||||
billingDocs: "了解计费文档",
|
||||
@@ -68,6 +72,14 @@ export const TRANSLATIONS = {
|
||||
installApp: "安装应用",
|
||||
installAppDesc: "将社学搭子安装到您的设备,获得原生应用般的流畅体验。",
|
||||
install: "安装",
|
||||
replyLanguageLabel: "AI 回复语言",
|
||||
replyLangSystem: "跟随系统",
|
||||
replyLangAuto: "跟随提问语言",
|
||||
toast: {
|
||||
copySuccess: "已复制到剪贴板",
|
||||
genSuccess: "生成成功",
|
||||
saveSuccess: "保存成功"
|
||||
},
|
||||
onboarding: {
|
||||
step1: "欢迎使用社学搭子!这是一个专为社会学研究者打造的数字空间。",
|
||||
step2: "你可以通过左侧的场景切换,选择从‘经典导读’到‘研究讨论’的不同模式。",
|
||||
@@ -142,6 +154,10 @@ export const TRANSLATIONS = {
|
||||
noHistory: "暫無歷史記錄。開始一段對話吧!",
|
||||
apiError: "錯誤:無法生成響應。請檢查 API Key。",
|
||||
languageLabel: "介面語言",
|
||||
themeLabel: "主題模式",
|
||||
themeAuto: "跟隨系統",
|
||||
themeLight: "淺色模式",
|
||||
themeDark: "深色模式",
|
||||
apiKeyIntro: "為了支持高質量圖像生成和視頻生成功能,請先選擇您的 API Key。",
|
||||
selectApiKeyBtn: "選擇 API Key",
|
||||
billingDocs: "了解計費文檔",
|
||||
@@ -154,6 +170,14 @@ export const TRANSLATIONS = {
|
||||
installApp: "安裝應用",
|
||||
installAppDesc: "將社學搭子安裝到您的設備,獲得原生應用般的流暢體驗。",
|
||||
install: "安裝",
|
||||
replyLanguageLabel: "AI 回復語言",
|
||||
replyLangSystem: "跟隨系統",
|
||||
replyLangAuto: "跟隨提問語言",
|
||||
toast: {
|
||||
copySuccess: "已複製到剪貼簿",
|
||||
genSuccess: "生成成功",
|
||||
saveSuccess: "保存成功"
|
||||
},
|
||||
onboarding: {
|
||||
step1: "歡迎使用社學搭子!這是一個專為社會學研究者打造的數字空間。",
|
||||
step2: "你可以通過左側的場景切換,選擇從‘經典導讀’到‘研究討論’的不同模式。",
|
||||
@@ -164,10 +188,10 @@ export const TRANSLATIONS = {
|
||||
homeFeatureTitle: "探索模組",
|
||||
homeQuoteTitle: "社會學視角",
|
||||
quotes: [
|
||||
{ text: "人是懸掛在由他自己所編織的意義之網中的動物。", author: "克利福德·格爾茨" },
|
||||
{ text: "人是懸掛在由他自己所編織的意義之網中的動物。", author: "克利福德·格尔茨" },
|
||||
{ text: "想像力,這種能力可以使人看清個人生活與社會結構之間的聯繫。", author: "C·賴特·米爾斯" },
|
||||
{ text: "社會學是關於社會行動的科學,其目的是通過對行動意義的解釋來理解行動。", author: "馬克斯·韋伯" },
|
||||
{ text: "哲學家們只是用不同的方式解釋世界,而問題在於改變世界。", author: "卡爾·馬克思" }
|
||||
{ text: "哲學家們只是用不同的方式解釋世界,而問題在於改變世界。", author: "卡尔·马克思" }
|
||||
],
|
||||
scenarios: {
|
||||
general: { title: "日常答疑", desc: "解答各類社會學基礎問題", greeting: "你好!我是你的社會學學習搭子。有什麼日常學習中的疑問需要我解答嗎?" },
|
||||
@@ -228,6 +252,10 @@ export const TRANSLATIONS = {
|
||||
noHistory: "履歴がありません。",
|
||||
apiError: "エラー:応答を生成できませんでした。APIキーを確認してください。",
|
||||
languageLabel: "言語",
|
||||
themeLabel: "テーマ",
|
||||
themeAuto: "システムに従う",
|
||||
themeLight: "ライトモード",
|
||||
themeDark: "ダークモード",
|
||||
apiKeyIntro: "高品質な画像・動画生成を利用するには、まずAPIキーを選択してください。",
|
||||
selectApiKeyBtn: "APIキーを選択",
|
||||
billingDocs: "課金ドキュメントを確認",
|
||||
@@ -240,6 +268,14 @@ export const TRANSLATIONS = {
|
||||
installApp: "アプリをインストール",
|
||||
installAppDesc: "デバイスにインストールして、より良い体験を。",
|
||||
install: "インストール",
|
||||
replyLanguageLabel: "AI応答言語",
|
||||
replyLangSystem: "システム言語に従う",
|
||||
replyLangAuto: "入力言語に従う",
|
||||
toast: {
|
||||
copySuccess: "クリップボードにコピーしました",
|
||||
genSuccess: "生成成功",
|
||||
saveSuccess: "保存成功"
|
||||
},
|
||||
onboarding: {
|
||||
step1: "ソシオパルへようこそ!社会学研究者のためのデジタル空間です。",
|
||||
step2: "左側のメニューから、古典講読から研究アドバイザーまでシナリオを切り替えられます。",
|
||||
@@ -314,6 +350,10 @@ export const TRANSLATIONS = {
|
||||
noHistory: "No history yet.",
|
||||
apiError: "Error: Could not generate response.",
|
||||
languageLabel: "Language",
|
||||
themeLabel: "Theme",
|
||||
themeAuto: "System",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
apiKeyIntro: "To support high-quality image and video generation, please select your API Key first.",
|
||||
selectApiKeyBtn: "Select API Key",
|
||||
billingDocs: "Billing Documentation",
|
||||
@@ -326,6 +366,14 @@ export const TRANSLATIONS = {
|
||||
installApp: "Install App",
|
||||
installAppDesc: "Install SocioPal on your device for a better experience.",
|
||||
install: "Install",
|
||||
replyLanguageLabel: "AI Reply Language",
|
||||
replyLangSystem: "System Default",
|
||||
replyLangAuto: "Match User Input",
|
||||
toast: {
|
||||
copySuccess: "Copied to clipboard",
|
||||
genSuccess: "Generation successful",
|
||||
saveSuccess: "Saved successfully"
|
||||
},
|
||||
onboarding: {
|
||||
step1: "Welcome to SocioPal! A digital space designed for sociology researchers.",
|
||||
step2: "Switch scenarios on the left to explore modes from 'Classic Readings' to 'Research Advisor'.",
|
||||
|
||||
25
index.html
25
index.html
@@ -9,7 +9,23 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>社学搭子 - 社会学学习工具</title>
|
||||
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- 添加 typography 插件支持 -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
850: '#1e293b', // 自定义深色背景
|
||||
950: '#020617',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; overflow: hidden; touch-action: manipulation; }
|
||||
@@ -18,6 +34,10 @@
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
/* 隐藏滚动条但保留功能 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
@@ -62,6 +82,7 @@
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.34.0",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
|
||||
"react-markdown": "https://esm.sh/react-markdown@^10.1.0",
|
||||
"remark-gfm": "https://esm.sh/remark-gfm@^4.0.0",
|
||||
"vite": "https://esm.sh/vite@^7.3.0",
|
||||
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2",
|
||||
"express": "https://esm.sh/express@^5.2.1",
|
||||
@@ -73,7 +94,7 @@
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900">
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
{
|
||||
"name": "sociopal",
|
||||
"private": true,
|
||||
@@ -18,6 +17,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"html2canvas": "^1.4.1"
|
||||
@@ -28,4 +28,4 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"vite-plugin-pwa": "^0.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
releases/HTY1024-APP-SKG-0.3.0_20251226.zip
Normal file
BIN
releases/HTY1024-APP-SKG-0.3.0_20251226.zip
Normal file
Binary file not shown.
@@ -53,7 +53,7 @@ export const streamChatResponse = async (
|
||||
history: Message[],
|
||||
currentMessage: string,
|
||||
mode: ChatMode,
|
||||
language: AppLanguage,
|
||||
targetLanguage: string, // 传入目标语言代码(如 'zh-CN', 'en')或 'auto'
|
||||
scenario: ChatScenario = ChatScenario.GENERAL,
|
||||
attachments: { mimeType: string; data: string }[] = [],
|
||||
onChunk: (text: string, grounding?: any) => void
|
||||
@@ -99,8 +99,17 @@ export const streamChatResponse = async (
|
||||
break;
|
||||
}
|
||||
|
||||
// 语言指令:根据 targetLanguage 动态生成
|
||||
let languageInstruction = "";
|
||||
if (targetLanguage === 'auto') {
|
||||
languageInstruction = "Reply in the language used by the user in their latest message. If the user explicitly asks to switch languages, follow their request.";
|
||||
} else {
|
||||
// 强制使用特定语言
|
||||
languageInstruction = `You MUST reply in ${targetLanguage}. Do NOT use other languages unless explicitly asked for translation examples.`;
|
||||
}
|
||||
|
||||
let config: any = {
|
||||
systemInstruction: `${baseInstruction} Always reply in the user's preferred language: ${language}.`,
|
||||
systemInstruction: `${baseInstruction}\n\n${languageInstruction}`,
|
||||
};
|
||||
|
||||
// 根据模式配置参数
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { UserSettings, ChatSession, AppLanguage } from "../types";
|
||||
import { DEFAULT_LANGUAGE } from "../constants";
|
||||
|
||||
@@ -12,7 +13,7 @@ export const loadSettings = (): UserSettings => {
|
||||
if (stored) return JSON.parse(stored);
|
||||
return {
|
||||
language: DEFAULT_LANGUAGE,
|
||||
theme: 'light'
|
||||
theme: 'auto'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,4 +66,4 @@ export const clearData = () => {
|
||||
localStorage.removeItem(KEYS.SETTINGS);
|
||||
localStorage.removeItem(KEYS.SESSIONS);
|
||||
localStorage.removeItem(KEYS.CURRENT_SESSION);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user