1187 lines
60 KiB
TypeScript
1187 lines
60 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,
|
|
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';
|
|
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
|
|
];
|
|
|
|
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.FAST);
|
|
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);
|
|
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 [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
// 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 }));
|
|
};
|
|
|
|
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);
|
|
|
|
// 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,
|
|
targetLangCode,
|
|
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);
|
|
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 {
|
|
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) {
|
|
addToast(t.transcriptionFail, 'error');
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
stream.getTracks().forEach(track => track.stop());
|
|
};
|
|
mediaRecorder.start();
|
|
setIsRecording(true);
|
|
} catch (e) {
|
|
addToast(t.micError, 'error');
|
|
}
|
|
};
|
|
|
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const success = await importData(file);
|
|
if (success) {
|
|
addToast(t.importSuccess, 'success');
|
|
setSessions(loadSessions());
|
|
setSettingsState(loadSettings());
|
|
} else {
|
|
addToast(t.importFail, 'error');
|
|
}
|
|
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(() => {
|
|
addToast(t.toast.copySuccess, 'success');
|
|
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: settings.theme === 'dark' ? '#0f172a' : '#f8fafc',
|
|
color: settings.theme === 'dark' ? '#f1f5f9' : '#0f172a'
|
|
});
|
|
|
|
document.body.appendChild(clone);
|
|
|
|
// 3. 简单延时等待渲染
|
|
await new Promise(r => setTimeout(r, 100));
|
|
|
|
// 4. 使用 html2canvas 截图
|
|
const canvas = await html2canvas(clone, {
|
|
backgroundColor: settings.theme === 'dark' ? '#0f172a' : '#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);
|
|
addToast(t.genError, 'error');
|
|
} 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 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 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 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 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 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 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 dark:shadow-none"
|
|
>
|
|
{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 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 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 dark:text-slate-400 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 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 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>
|
|
</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 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 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>
|
|
</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 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 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 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 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}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* MAIN CONTENT */}
|
|
<main className="flex-1 flex flex-col h-full relative min-w-0">
|
|
<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 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 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 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>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{activeView === 'chat' && (
|
|
<>
|
|
{/* 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.FAST, label: t.modeFast, color: 'text-green-600 dark:text-green-400' },
|
|
{ mode: ChatMode.DEEP, label: t.modeDeep, color: 'text-purple-600 dark:text-purple-400' },
|
|
{ mode: ChatMode.STANDARD, label: t.modeStandard, color: 'text-blue-600 dark:text-blue-400' }
|
|
].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 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>
|
|
))}
|
|
</div>
|
|
|
|
{/* Share Button */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowShareMenu(!showShareMenu)}
|
|
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 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 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 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>
|
|
)}
|
|
{/* 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 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>
|
|
)}
|
|
</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 dark:text-white leading-tight animate-slide-up">
|
|
{t.homeWelcome}
|
|
</h1>
|
|
<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 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 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 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 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 dark:text-slate-200 leading-relaxed italic">
|
|
“{randomQuote.text}”
|
|
</blockquote>
|
|
<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 dark:text-white 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 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 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 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>
|
|
</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 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 dark:text-slate-200 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 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 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 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 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 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 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>
|
|
</>
|
|
)}
|
|
</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 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 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 dark:text-slate-500 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 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}
|
|
</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 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 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 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 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 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 dark:shadow-none"
|
|
>
|
|
{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 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 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 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>
|
|
<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 dark:text-slate-300 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 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 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 dark:bg-slate-800"></div>
|
|
<span className="text-xs text-slate-400">OR</span>
|
|
<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 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)
|
|
</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 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 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 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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* 设置视图下不显示右侧侧边栏 */}
|
|
<aside className={`
|
|
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 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">
|
|
<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 dark:shadow-none 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 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 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 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 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 dark:hover:bg-red-900/20 rounded-lg opacity-0 group-hover:opacity-100 transition-all"><Trash2 size={14} /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|