1 Commits

Author SHA1 Message Date
1494166861 更新至 v0.2.0_20251224 版本 2025-12-24 14:24:01 +08:00
8 changed files with 427 additions and 48 deletions

361
App.tsx
View File

@@ -25,13 +25,19 @@ import {
Quote,
LayoutGrid,
Lightbulb,
ArrowRight
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 } from './services/geminiService';
import { streamChatResponse, transcribeAudio, generateSpeech, getModelNameForMode, formatModelName } from './services/geminiService';
import Tools from './components/Tools';
// 将常量移至顶层,修复 ReferenceError
@@ -57,8 +63,12 @@ const App: React.FC = () => {
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);
@@ -74,6 +84,24 @@ const App: React.FC = () => {
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 = () => {
@@ -83,9 +111,9 @@ const App: React.FC = () => {
useEffect(() => {
const handleResize = () => {
// 在设置页面或首页默认不开启右侧历史记录
// 在设置页面、首页或工具页面默认不开启右侧历史记录
const isDesktop = window.innerWidth >= 1024;
const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings';
const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools';
setIsRightSidebarOpen(shouldBeOpen);
};
handleResize();
@@ -97,7 +125,20 @@ const App: React.FC = () => {
useEffect(() => { saveSessions(sessions); }, [sessions]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessions, streamingContent, currentSessionId]);
}, [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);
@@ -125,6 +166,52 @@ const App: React.FC = () => {
}
};
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]: []
@@ -159,7 +246,8 @@ const App: React.FC = () => {
id: (Date.now() - 100).toString(),
role: 'model',
content: scenarioConfig.greeting,
timestamp: Date.now() - 100
timestamp: Date.now() - 100,
model: getModelNameForMode(selectedMode) // Assuming greeting uses standard/selected mode logic mentally, or just informational
}],
mode: selectedMode,
scenario: selectedScenario,
@@ -188,10 +276,12 @@ const App: React.FC = () => {
setAttachments([]);
setIsProcessing(true);
setStreamingContent('');
setLoadingStep(0); // Reset animation step
try {
let fullResponse = '';
let groundingData: any = null;
const usedModel = getModelNameForMode(session!.mode);
await streamChatResponse(
updatedMessages,
@@ -212,7 +302,8 @@ const App: React.FC = () => {
role: 'model',
content: fullResponse,
timestamp: Date.now(),
groundingMetadata: groundingData
groundingMetadata: groundingData,
model: usedModel
};
setSessions(prev => prev.map(s => s.id === session!.id ? {
@@ -299,6 +390,106 @@ const App: React.FC = () => {
}
};
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);
@@ -315,7 +506,8 @@ const App: React.FC = () => {
const activeMode = currentSession ? currentSession.mode : selectedMode;
return (
<div className="flex h-screen bg-slate-50 overflow-hidden animate-fade-in">
// 使用 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">
@@ -350,9 +542,25 @@ const App: React.FC = () => {
</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-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 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'}
`}>
@@ -411,7 +619,8 @@ const App: React.FC = () => {
</div>
</div>
<div className="p-4 border-t border-slate-100">
{/* 底部菜单适配安全区域 */}
<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'}`}
@@ -424,15 +633,15 @@ 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">
<div className="flex items-center gap-2">
<button onClick={() => setIsLeftSidebarOpen(true)} className="md:hidden p-2 text-slate-600 active:scale-90 transition-transform">
<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">
<span className="text-blue-600 bg-blue-50 p-1 rounded-md">{getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)}</span>
<span className="hidden sm:inline">{t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title}</span>
<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' && (
@@ -443,9 +652,10 @@ const App: React.FC = () => {
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{activeView === 'chat' && (
<div className="hidden sm:flex items-center space-x-1 bg-slate-100 p-1 rounded-lg mr-2">
<>
<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' },
@@ -457,14 +667,44 @@ 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-3 py-1 text-xs font-medium rounded-md transition-all active:scale-95 ${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 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 !== '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>
@@ -475,7 +715,7 @@ const App: React.FC = () => {
<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">
<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}
@@ -545,7 +785,7 @@ const App: React.FC = () => {
{activeView === 'chat' && (
<div className="flex flex-col h-full relative">
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<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">
@@ -581,12 +821,43 @@ const App: React.FC = () => {
<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' && (
<button onClick={() => playTTS(msg.content)} className="hover:text-blue-500 active:scale-90 transition-transform"><Volume2 size={14}/></button>
<>
{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>
@@ -605,7 +876,8 @@ const App: React.FC = () => {
)}
</div>
<div className="p-4 bg-white border-t border-slate-100 shrink-0 z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
{/* 输入框区域增加底部安全距离适配 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" />
@@ -631,10 +903,25 @@ const App: React.FC = () => {
</div>
</div>
)}
{activeView === 'tools' && <div className="h-full overflow-y-auto animate-fade-in"><Tools language={settings.language} hasCustomKey={!!settings.apiKey} /></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">
<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">
@@ -707,16 +994,30 @@ const App: React.FC = () => {
{/* 设置视图下不显示右侧侧边栏 */}
<aside className={`
fixed inset-y-0 right-0 z-20 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 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') ? 'translate-x-0' : 'translate-x-full lg:hidden'}
${(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">
<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="flex-1 overflow-y-auto p-4 space-y-6">
<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>

View File

@@ -65,6 +65,9 @@ export const TRANSLATIONS = {
older: "更早",
transcribePrompt: "请准确转录此音频内容。",
getStarted: "开始探索",
installApp: "安装应用",
installAppDesc: "将社学搭子安装到您的设备,获得原生应用般的流畅体验。",
install: "安装",
onboarding: {
step1: "欢迎使用社学搭子!这是一个专为社会学研究者打造的数字空间。",
step2: "你可以通过左侧的场景切换,选择从‘经典导读’到‘研究讨论’的不同模式。",
@@ -148,6 +151,9 @@ export const TRANSLATIONS = {
older: "更早",
transcribePrompt: "請準確轉錄此音訊內容。",
getStarted: "開始探索",
installApp: "安裝應用",
installAppDesc: "將社學搭子安裝到您的設備,獲得原生應用般的流暢體驗。",
install: "安裝",
onboarding: {
step1: "歡迎使用社學搭子!這是一個專為社會學研究者打造的數字空間。",
step2: "你可以通過左側的場景切換,選擇從‘經典導讀’到‘研究討論’的不同模式。",
@@ -231,6 +237,9 @@ export const TRANSLATIONS = {
older: "それ以前",
transcribePrompt: "この音声を正確に書き起こしてください。",
getStarted: "はじめる",
installApp: "アプリをインストール",
installAppDesc: "デバイスにインストールして、より良い体験を。",
install: "インストール",
onboarding: {
step1: "ソシオパルへようこそ!社会学研究者のためのデジタル空間です。",
step2: "左側のメニューから、古典講読から研究アドバイザーまでシナリオを切り替えられます。",
@@ -314,6 +323,9 @@ export const TRANSLATIONS = {
older: "Older",
transcribePrompt: "Please transcribe this audio exactly as spoken.",
getStarted: "Get Started",
installApp: "Install App",
installAppDesc: "Install SocioPal on your device for a better experience.",
install: "Install",
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'.",

View File

@@ -1,19 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<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>
<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; }
body { font-family: 'Inter', sans-serif; overflow: hidden; touch-action: manipulation; }
/* 自定义滚动条 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* 隐藏滚动条但保留功能 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 自定义动画 */
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
@@ -27,9 +36,15 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 波浪跳动加载动画 */
@keyframes bounce-delay {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.animate-slide-up { animation: slideUp 0.3s ease-out forwards; }
.animate-fade-in { animation: fadeIn 0.4s ease-out forwards; }
.animate-breathe { animation: breathe 2s infinite ease-in-out; }
.animate-bounce-delay { animation: bounce-delay 1.4s infinite ease-in-out both; }
/* 按钮微动效 */
.btn-hover { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); }
@@ -51,7 +66,9 @@
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2",
"express": "https://esm.sh/express@^5.2.1",
"path": "https://esm.sh/path@^0.12.7",
"url": "https://esm.sh/url@^0.11.4"
"url": "https://esm.sh/url@^0.11.4",
"vite-plugin-pwa": "https://esm.sh/vite-plugin-pwa@^1.2.0",
"html2canvas": "https://esm.sh/html2canvas@^1.4.1"
}
}
</script>
@@ -60,4 +77,4 @@
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>

View File

@@ -19,11 +19,13 @@
"react-dom": "^18.3.1",
"react-markdown": "^9.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"html2canvas": "^1.4.1"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0"
"@types/react-dom": "^18.3.0",
"vite-plugin-pwa": "^0.19.0"
}
}

Binary file not shown.

View File

@@ -19,12 +19,33 @@ const getClient = () => {
};
// --- 模型定义 ---
const MODEL_CHAT_STANDARD = "gemini-3-flash-preview";
const MODEL_CHAT_DEEP = "gemini-3-pro-preview";
const MODEL_CHAT_FAST = "gemini-flash-lite-latest";
const MODEL_IMAGE_GEN = "gemini-3-pro-image-preview";
const MODEL_VIDEO_GEN = "veo-3.1-fast-generate-preview";
const MODEL_TTS = "gemini-2.5-flash-preview-tts";
export const MODEL_CHAT_STANDARD = "gemini-3-flash-preview";
export const MODEL_CHAT_DEEP = "gemini-3-pro-preview";
export const MODEL_CHAT_FAST = "gemini-flash-lite-latest";
export const MODEL_IMAGE_GEN = "gemini-3-pro-image-preview";
export const MODEL_VIDEO_GEN = "veo-3.1-fast-generate-preview";
export const MODEL_TTS = "gemini-2.5-flash-preview-tts";
// 获取当前模式对应的模型名称
export const getModelNameForMode = (mode: ChatMode): string => {
switch (mode) {
case ChatMode.DEEP: return MODEL_CHAT_DEEP;
case ChatMode.FAST: return MODEL_CHAT_FAST;
case ChatMode.STANDARD:
default:
return MODEL_CHAT_STANDARD;
}
};
// 格式化模型名称用于展示
export const formatModelName = (modelId: string): string => {
if (modelId === MODEL_CHAT_STANDARD) return "Gemini 3 Flash";
if (modelId === MODEL_CHAT_DEEP) return "Gemini 3 Pro";
if (modelId === MODEL_CHAT_FAST) return "Gemini Flash Lite";
if (modelId === MODEL_IMAGE_GEN) return "Gemini 3 Pro Image";
if (modelId === MODEL_VIDEO_GEN) return "Veo 3.1";
return modelId;
};
// --- 聊天功能 ---
@@ -38,7 +59,7 @@ export const streamChatResponse = async (
onChunk: (text: string, grounding?: any) => void
) => {
const ai = getClient();
let model = MODEL_CHAT_STANDARD;
let model = getModelNameForMode(mode);
// 根据场景构造系统指令
let baseInstruction = "";
@@ -84,14 +105,11 @@ export const streamChatResponse = async (
// 根据模式配置参数
if (mode === ChatMode.STANDARD) {
model = MODEL_CHAT_STANDARD;
config.tools = [{ googleSearch: {} }];
} else if (mode === ChatMode.DEEP) {
model = MODEL_CHAT_DEEP;
config.thinkingConfig = { thinkingBudget: 32768 }; // Pro 模型最大思考预算
} else if (mode === ChatMode.FAST) {
model = MODEL_CHAT_FAST;
}
// Fast 模式仅切换模型,无需额外配置
const chat = ai.chats.create({
model,

View File

@@ -27,6 +27,7 @@ export interface Message {
attachments?: Attachment[];
isThinking?: boolean;
groundingMetadata?: GroundingMetadata;
model?: string; // 模型名称
}
export interface GroundingMetadata {

View File

@@ -2,6 +2,7 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { cwd } from 'node:process'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
@@ -9,7 +10,34 @@ export default defineConfig(({ mode }) => {
const env = { ...process.env, ...loadEnv(mode, cwd(), '') };
return {
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'SocioPal - Social Learning Tool',
short_name: 'SocioPal',
description: 'A comprehensive AI-powered tool for sociology learning.',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
define: {
// 确保 API Key 在构建时注入
'process.env.API_KEY': JSON.stringify(env.API_KEY || '')