Compare commits
1 Commits
v0.1.0_202
...
v0.2.0_202
| Author | SHA1 | Date | |
|---|---|---|---|
| 1494166861 |
361
App.tsx
361
App.tsx
@@ -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>
|
||||
|
||||
12
constants.ts
12
constants.ts
@@ -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'.",
|
||||
|
||||
25
index.html
25
index.html
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
releases/HTY1024-APP-SKG-0.2.0_20251224.zip
Normal file
BIN
releases/HTY1024-APP-SKG-0.2.0_20251224.zip
Normal file
Binary file not shown.
@@ -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,
|
||||
|
||||
1
types.ts
1
types.ts
@@ -27,6 +27,7 @@ export interface Message {
|
||||
attachments?: Attachment[];
|
||||
isThinking?: boolean;
|
||||
groundingMetadata?: GroundingMetadata;
|
||||
model?: string; // 模型名称
|
||||
}
|
||||
|
||||
export interface GroundingMetadata {
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
Reference in New Issue
Block a user