Files
ai-app-skg/App.tsx
2025-12-24 14:24:01 +08:00

1053 lines
50 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo } from 'react';
import {
Settings as SettingsIcon,
Sparkles,
Menu,
X,
Mic,
ImagePlus,
Send,
Loader2,
Volume2,
Trash2,
Plus,
BookOpen,
Brain,
GraduationCap,
Coffee,
History,
ChevronRight,
Calendar,
Key,
ExternalLink,
Home as HomeIcon,
Quote,
LayoutGrid,
Lightbulb,
ArrowRight,
Share2,
Copy,
Image as ImageIcon,
FileText,
Download
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import html2canvas from 'html2canvas';
import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants';
import { AppLanguage, ChatMode, Message, UserSettings, ChatSession, ChatScenario } from './types';
import { loadSettings, saveSettings, loadSessions, saveSessions, exportData, importData, clearData } from './services/storage';
import { streamChatResponse, transcribeAudio, generateSpeech, getModelNameForMode, formatModelName } from './services/geminiService';
import Tools from './components/Tools';
// 将常量移至顶层,修复 ReferenceError
const SCENARIOS = [
ChatScenario.GENERAL,
ChatScenario.READING,
ChatScenario.CONCEPT,
ChatScenario.RESEARCH
];
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 [input, setInput] = useState('');
const [activeView, setActiveView] = useState<'home' | 'chat' | 'tools' | 'settings'>('home');
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [attachments, setAttachments] = useState<{mimeType: string, data: string, name?: string}[]>([]);
const [isRecording, setIsRecording] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [showShareMenu, setShowShareMenu] = useState(false);
const [installPrompt, setInstallPrompt] = useState<any>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const t = TRANSLATIONS[settings.language] || TRANSLATIONS[DEFAULT_LANGUAGE];
const randomQuote = useMemo(() => {
const quotes = t.quotes || [];
return quotes[Math.floor(Math.random() * quotes.length)] || { text: "", author: "" };
}, [t.quotes]);
useEffect(() => {
setSessions(loadSessions());
if (!settings.isOnboarded) {
setShowOnboarding(true);
}
// PWA Install Prompt Listener
const handleBeforeInstallPrompt = (e: any) => {
e.preventDefault();
setInstallPrompt(e);
};
const handleAppInstalled = () => {
setInstallPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const handleFinishOnboarding = () => {
setShowOnboarding(false);
setSettingsState(prev => ({ ...prev, isOnboarded: true }));
};
useEffect(() => {
const handleResize = () => {
// 在设置页面、首页或工具页面默认不开启右侧历史记录
const isDesktop = window.innerWidth >= 1024;
const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools';
setIsRightSidebarOpen(shouldBeOpen);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [activeView]);
useEffect(() => { saveSettings(settings); }, [settings]);
useEffect(() => { saveSessions(sessions); }, [sessions]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessions, streamingContent, currentSessionId, loadingStep, isProcessing]);
// Loading Text Animation Cycle
useEffect(() => {
let interval: any;
if (isProcessing && !streamingContent) {
interval = setInterval(() => {
setLoadingStep((prev) => prev + 1);
}, 2500);
} else {
setLoadingStep(0);
}
return () => clearInterval(interval);
}, [isProcessing, streamingContent]);
const getCurrentSession = () => sessions.find(s => s.id === currentSessionId);
const handleScenarioSelect = (scenario: ChatScenario) => {
setSelectedScenario(scenario);
setCurrentSessionId(null);
setActiveView('chat');
setIsLeftSidebarOpen(false);
};
const deleteSession = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (window.confirm(t.confirmDelete)) {
setSessions(prev => prev.filter(s => s.id !== id));
if (currentSessionId === id) setCurrentSessionId(null);
}
};
const getScenarioIcon = (scenario?: ChatScenario) => {
switch (scenario) {
case ChatScenario.READING: return <BookOpen size={18} />;
case ChatScenario.CONCEPT: return <Brain size={18} />;
case ChatScenario.RESEARCH: return <GraduationCap size={18} />;
default: return <Coffee size={18} />;
}
};
const getLoadingText = (scenario?: ChatScenario) => {
// 简单的场景化加载文案,根据需要可扩展到 constants.ts
const stepsMap: Record<string, string[]> = {
[ChatScenario.READING]: [
"正在翻阅经典著作...",
"分析历史背景...",
"解读理论脉络...",
"构建学术回答..."
],
[ChatScenario.CONCEPT]: [
"正在解析核心定义...",
"追溯词源...",
"对比相关理论...",
"生成应用案例..."
],
[ChatScenario.RESEARCH]: [
"正在评估研究问题...",
"回顾方法论框架...",
"检查伦理考量...",
"完善研究设计..."
],
[ChatScenario.GENERAL]: [
"正在思考...",
"连接社会学视角...",
"组织语言...",
"生成回答..."
]
};
// English fallbacks if needed, simplified for this snippet
const isEn = settings.language === AppLanguage.EN;
if (isEn) {
const stepsMapEn: Record<string, string[]> = {
[ChatScenario.READING]: ["Consulting classics...", "Analyzing context...", "Interpreting text..."],
[ChatScenario.CONCEPT]: ["Defining terms...", "Tracing etymology...", "Comparing frameworks..."],
[ChatScenario.RESEARCH]: ["Reviewing methodology...", "Checking ethics...", "Designing structure..."],
[ChatScenario.GENERAL]: ["Thinking...", "Connecting perspectives...", "Drafting response..."]
};
const texts = stepsMapEn[scenario || ChatScenario.GENERAL] || stepsMapEn[ChatScenario.GENERAL];
return texts[loadingStep % texts.length];
}
const texts = stepsMap[scenario || ChatScenario.GENERAL] || stepsMap[ChatScenario.GENERAL];
return texts[loadingStep % texts.length];
};
const getGroupedSessions = () => {
const groups: { [key: string]: ChatSession[] } = {
[t.today]: [], [t.yesterday]: [], [t.last7Days]: [], [t.older]: []
};
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86400000;
const lastWeek = today - 86400000 * 7;
sessions.filter(s => (s.scenario || ChatScenario.GENERAL) === selectedScenario).forEach(s => {
if (s.createdAt >= today) groups[t.today].push(s);
else if (s.createdAt >= yesterday) groups[t.yesterday].push(s);
else if (s.createdAt >= lastWeek) groups[t.last7Days].push(s);
else groups[t.older].push(s);
});
return groups;
};
const handleSendMessage = async () => {
if ((!input.trim() && attachments.length === 0) || isProcessing) return;
let session = getCurrentSession();
let isNewSession = false;
if (!session) {
isNewSession = true;
const scenarioConfig = t.scenarios[selectedScenario];
session = {
id: Date.now().toString(),
title: input.slice(0, 30) || t.newChat,
messages: [{
id: (Date.now() - 100).toString(),
role: 'model',
content: scenarioConfig.greeting,
timestamp: Date.now() - 100,
model: getModelNameForMode(selectedMode) // Assuming greeting uses standard/selected mode logic mentally, or just informational
}],
mode: selectedMode,
scenario: selectedScenario,
createdAt: Date.now()
};
}
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: Date.now(),
attachments: attachments.map(a => ({ type: 'image', ...a }))
};
const updatedMessages = [...session!.messages, userMsg];
if (isNewSession) {
setSessions(prev => [{ ...session!, messages: updatedMessages }, ...prev]);
setCurrentSessionId(session!.id);
} else {
setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: updatedMessages } : s));
}
setInput('');
setAttachments([]);
setIsProcessing(true);
setStreamingContent('');
setLoadingStep(0); // Reset animation step
try {
let fullResponse = '';
let groundingData: any = null;
const usedModel = getModelNameForMode(session!.mode);
await streamChatResponse(
updatedMessages,
userMsg.content,
session!.mode,
settings.language,
session!.scenario || ChatScenario.GENERAL,
userMsg.attachments as any,
(text, grounding) => {
fullResponse += text;
setStreamingContent(fullResponse);
if (grounding) groundingData = grounding;
}
);
const modelMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'model',
content: fullResponse,
timestamp: Date.now(),
groundingMetadata: groundingData,
model: usedModel
};
setSessions(prev => prev.map(s => s.id === session!.id ? {
...s,
messages: [...updatedMessages, modelMsg]
} : s));
} catch (err: any) {
console.error(err);
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 {
setIsProcessing(false);
setStreamingContent('');
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = (reader.result as string).split(',')[1];
setAttachments(prev => [...prev, { mimeType: file.type, data: base64Data, name: file.name }]);
};
reader.readAsDataURL(file);
e.target.value = '';
};
const handleRecordAudio = async () => {
if (isRecording) {
mediaRecorderRef.current?.stop();
setIsRecording(false);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
const chunks: BlobPart[] = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1];
setIsProcessing(true);
try {
const text = await transcribeAudio(base64, 'audio/webm', settings.language);
setInput(prev => prev + " " + text);
} catch (e) {
alert(t.transcriptionFail);
} finally {
setIsProcessing(false);
}
};
reader.readAsDataURL(blob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
} catch (e) {
alert(t.micError);
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const success = await importData(file);
if (success) {
alert(t.importSuccess);
setSessions(loadSessions());
setSettingsState(loadSettings());
} else {
alert(t.importFail);
}
e.target.value = '';
};
const handleOpenSelectKey = async () => {
if (typeof (window as any).aistudio !== 'undefined') {
await (window as any).aistudio.openSelectKey();
}
};
const handleInstallClick = async () => {
if (!installPrompt) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setInstallPrompt(null);
}
};
const handleShareText = () => {
const session = getCurrentSession();
if (!session) return;
const text = session.messages.map(m =>
`${m.role === 'user' ? 'User' : 'SocioPal'}: ${m.content}`
).join('\n\n');
navigator.clipboard.writeText(text).then(() => {
alert("对话已复制到剪贴板!");
setShowShareMenu(false);
});
};
const handleDownloadText = () => {
const session = getCurrentSession();
if (!session) return;
const text = session.messages.map(m =>
`[${new Date(m.timestamp).toLocaleString()}] ${m.role === 'user' ? 'User' : 'AI'}:\n${m.content}\n`
).join('\n-------------------\n\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `sociopal-chat-${Date.now()}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setShowShareMenu(false);
};
const handleShareImage = async () => {
if (!chatContainerRef.current) return;
setIsProcessing(true); // Temporarily show loading state
const originalElement = chatContainerRef.current;
try {
// 1. 克隆节点
const clone = originalElement.cloneNode(true) as HTMLElement;
const width = originalElement.clientWidth;
// 2. 设置克隆节点样式以展示全部内容
Object.assign(clone.style, {
position: 'absolute',
top: '-9999px',
left: '-9999px',
width: `${width}px`,
height: 'auto',
maxHeight: 'none',
overflow: 'visible',
zIndex: '-1000',
background: '#f8fafc', // slate-50
});
document.body.appendChild(clone);
// 3. 简单延时等待渲染
await new Promise(r => setTimeout(r, 100));
// 4. 使用 html2canvas 截图
const canvas = await html2canvas(clone, {
backgroundColor: '#f8fafc',
useCORS: true,
logging: false,
scale: 2, // 提升清晰度
width: width,
height: clone.scrollHeight,
windowWidth: width,
windowHeight: clone.scrollHeight
});
// 5. 清理克隆节点
document.body.removeChild(clone);
const image = canvas.toDataURL("image/png");
const link = document.createElement('a');
link.href = image;
link.download = `sociopal-chat-${Date.now()}.png`;
link.click();
setShowShareMenu(false);
} catch (error) {
console.error("Image generation failed", error);
alert("生成图片失败,请重试。");
} finally {
setIsProcessing(false);
}
};
const playTTS = async (text: string) => {
try {
const buffer = await generateSpeech(text);
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)({sampleRate: 24000});
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0);
} catch (e) { console.error(e); }
};
const currentSession = getCurrentSession();
const groupedSessions = getGroupedSessions();
const activeMode = currentSession ? currentSession.mode : selectedMode;
return (
// 使用 100dvh 适配移动端浏览器视口,防止被地址栏遮挡
<div className="flex h-[100dvh] w-full bg-slate-50 overflow-hidden animate-fade-in relative">
{/* 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">
<Sparkles size={32} />
</div>
<div className="space-y-4">
<h2 className="text-2xl font-bold text-slate-900">{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>
</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>
</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>
</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"
>
{t.onboarding.done}
</button>
</div>
</div>
)}
{/* Mobile Backdrop for Left Sidebar */}
{isLeftSidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/20 backdrop-blur-sm md:hidden transition-opacity"
onClick={() => setIsLeftSidebarOpen(false)}
/>
)}
{/* Mobile Backdrop for Right Sidebar */}
{isRightSidebarOpen && (
<div
className="fixed inset-0 z-10 bg-black/20 backdrop-blur-sm lg:hidden transition-opacity" // z-10 because right sidebar is z-20
onClick={() => setIsRightSidebarOpen(false)}
/>
)}
{/* 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
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>
{t.appName}
</h1>
<button onClick={() => setIsLeftSidebarOpen(false)} className="md:hidden text-slate-500 active:rotate-90 transition-transform">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-8">
<div>
<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'}
`}
>
<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'}`}>
<HomeIcon size={18} />
</span>
<span>{t.home}</span>
</button>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 px-2">{t.modules}</div>
<div className="space-y-1">
{SCENARIOS.map((scenario) => (
<button
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'}
`}
>
<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'}`}>
{getScenarioIcon(scenario)}
</span>
<span>{t.scenarios[scenario].title}</span>
</button>
))}
</div>
</div>
<div>
<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'}`}
>
<span className="p-2 rounded-lg bg-slate-100 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">
<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'}`}
>
<SettingsIcon size={18} />
{t.settings}
</button>
</div>
</aside>
{/* 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)]">
<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">
<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>
<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" />
<span>{t.home}</span>
</div>
)}
</div>
<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">
{[
{ 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' }
].map(m => (
<button
key={m.mode}
onClick={() => {
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'}`}
>
{m.label}
</button>
))}
</div>
{/* Share Button */}
<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'}`}
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">
<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">
<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">
<ImageIcon size={16} />
</button>
</div>
)}
{/* Backdrop to close menu */}
{showShareMenu && (
<div className="fixed inset-0 z-40 bg-transparent" onClick={() => setShowShareMenu(false)} />
)}
</div>
</>
)}
{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'}`}>
<History size={20} />
</button>
)}
</div>
</header>
<div className="flex-1 overflow-hidden relative flex flex-col">
{activeView === 'home' && (
<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">
{t.homeWelcome}
</h1>
<p className="text-xl text-slate-500 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"
>
{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"
>
{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">
<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">
<Lightbulb size={18} />
{t.homeQuoteTitle}
</div>
<blockquote className="text-2xl md:text-3xl font-serif text-slate-800 leading-relaxed italic">
{randomQuote.text}
</blockquote>
<div className="text-right text-slate-500 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">
<LayoutGrid size={24} className="text-blue-500" />
{t.homeFeatureTitle}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{SCENARIOS.map((scenario, idx) => (
<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"
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">
{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>
</button>
))}
</div>
</div>
</div>
</div>
)}
{activeView === 'chat' && (
<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">
{getScenarioIcon(selectedScenario)}
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">{t.scenarios[selectedScenario].title}</h2>
<p className="max-w-md">{t.scenarios[selectedScenario].greeting}</p>
</div>
) : (
<div className="space-y-6">
{currentSession.messages.map((msg) => (
<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">
{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">
<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'
}`}>
<div className="prose prose-sm max-w-none text-inherit prose-headings:text-inherit prose-strong:text-inherit">
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 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>
</>
)}
<button onClick={() => playTTS(msg.content)} className="hover:text-blue-500 active:scale-90 transition-transform"><Volume2 size={14}/></button>
</>
)}
</div>
</div>
</div>
))}
{/* 思考中(无内容)的动画状态 */}
{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">
{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="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}>
{getLoadingText(currentSession?.scenario)}
</div>
</div>
</div>
)}
{/* 正在生成(有内容)的状态 */}
{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>
<div className="mt-2 flex items-center gap-2 text-xs text-blue-500 animate-breathe">
{currentSession?.mode === ChatMode.DEEP ? t.thinking : t.generating}
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</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="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">
<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>
<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"
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={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"
>
{isProcessing ? <Loader2 size={20} className="animate-spin"/> : <Send size={20} />}
</button>
</div>
</div>
</div>
</div>
)}
{activeView === 'tools' && <div className="h-full overflow-y-auto animate-fade-in pb-20"><Tools language={settings.language} hasCustomKey={!!settings.apiKey} /></div>}
{activeView === 'settings' && (
<div className="h-full overflow-y-auto p-4 md:p-8 animate-fade-in pb-20">
<div className="max-w-2xl mx-auto space-y-8">
{installPrompt && (
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 rounded-2xl shadow-lg text-white mb-6 animate-slide-up flex items-center justify-between">
<div>
<h3 className="font-bold text-lg mb-1">{t.installApp}</h3>
<p className="text-blue-100 text-sm">{t.installAppDesc}</p>
</div>
<button
onClick={handleInstallClick}
className="px-4 py-2 bg-white text-blue-600 font-bold rounded-lg shadow hover:bg-blue-50 active:scale-95 transition-all flex items-center gap-2"
>
<Download size={18} />
{t.install}
</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="mb-6">
<label className="block text-sm font-medium text-slate-700 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"
>
<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 className="space-y-4">
<label className="block text-sm font-medium text-slate-700 mb-1">{t.apiKeyLabel}</label>
<div className="space-y-2">
<input
type="password"
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"
/>
<p className="text-xs text-slate-500">{t.apiKeyDesc}</p>
</div>
<div className="flex items-center gap-2 py-2">
<div className="h-px flex-1 bg-slate-100"></div>
<span className="text-xs text-slate-400">OR</span>
<div className="h-px flex-1 bg-slate-100"></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"
>
<Key size={18} />
{t.selectApiKeyBtn} (Google Cloud)
</button>
<a
href="https://ai.google.dev/gemini-api/docs/billing"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-500 hover:underline mt-2"
>
<ExternalLink size={12} />
{t.billingDocs}
</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="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">
{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>
</div>
</div>
</div>
</div>
)}
</div>
</main>
{/* 设置视图下不显示右侧侧边栏 */}
<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
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>
<div className="px-4 pt-4 pb-2">
<button
onClick={() => {
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"
>
<Plus size={18} />
{t.newChat}
</button>
</div>
<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="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'
}`}
>
<span className={`mt-0.5 ${currentSessionId === s.id ? 'text-blue-600' : 'text-slate-400'}`}>{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>
</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>
</div>
))}
</div>
</div>
))}
</div>
</div>
</aside>
</div>
);
};
export default App;