2 Commits

Author SHA1 Message Date
6e09d8ec55 更新至 v0.3.0_20251226 版本 2025-12-26 16:03:17 +08:00
1494166861 更新至 v0.2.0_20251224 版本 2025-12-24 14:24:01 +08:00
11 changed files with 750 additions and 158 deletions

669
App.tsx

File diff suppressed because it is too large Load Diff

View File

@@ -59,33 +59,33 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
return (
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-6">
<div className="flex space-x-1 bg-slate-200 p-1 rounded-xl w-fit animate-fade-in shadow-inner">
<div className="flex space-x-1 bg-slate-200 dark:bg-slate-800 p-1 rounded-xl w-fit animate-fade-in shadow-inner">
<button
onClick={() => { setActiveTab('image'); setResultUrl(null); }}
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'image' ? 'bg-white shadow text-blue-600' : 'text-slate-600 hover:text-slate-800'}`}
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'image' ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
<ImageIcon size={18} />
<span className="font-medium">{t.imageGen}</span>
</button>
<button
onClick={() => { setActiveTab('video'); setResultUrl(null); }}
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'video' ? 'bg-white shadow text-purple-600' : 'text-slate-600 hover:text-slate-800'}`}
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'video' ? 'bg-white dark:bg-slate-700 shadow text-purple-600 dark:text-purple-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
<Video size={18} />
<span className="font-medium">{t.videoGen}</span>
</button>
</div>
<div className="bg-white rounded-3xl shadow-xl border border-slate-100 p-6 md:p-8 animate-slide-up space-y-6">
<div className="bg-white dark:bg-slate-900 rounded-3xl shadow-xl border border-slate-100 dark:border-slate-800 p-6 md:p-8 animate-slide-up space-y-6">
<div className="relative">
<textarea
className="w-full p-5 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:outline-none focus:bg-white transition-all resize-none min-h-[120px] text-slate-700"
className="w-full p-5 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:outline-none focus:bg-white dark:focus:bg-slate-900 transition-all resize-none min-h-[120px] text-slate-700 dark:text-slate-200"
rows={4}
placeholder={activeTab === 'image' ? t.imagePromptPlaceholder : t.videoPromptPlaceholder}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
<Sparkles className="absolute right-4 bottom-4 text-slate-300 pointer-events-none" size={20} />
<Sparkles className="absolute right-4 bottom-4 text-slate-300 dark:text-slate-600 pointer-events-none" size={20} />
</div>
<div className="flex flex-wrap gap-6 items-center justify-between">
@@ -96,7 +96,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value as any)}
className="px-4 py-2 bg-slate-100 border border-transparent rounded-xl text-sm font-medium focus:bg-white focus:border-blue-200 transition-all"
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
>
<option value="1K">1K (HD)</option>
<option value="2K">2K (QHD)</option>
@@ -109,7 +109,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
<select
value={videoRatio}
onChange={(e) => setVideoRatio(e.target.value as any)}
className="px-4 py-2 bg-slate-100 border border-transparent rounded-xl text-sm font-medium focus:bg-white focus:border-blue-200 transition-all"
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
>
<option value="16:9">{t.landscape}</option>
<option value="9:16">{t.portrait}</option>
@@ -121,7 +121,7 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
<button
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="px-8 py-3 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 active:scale-95 disabled:opacity-50 flex items-center space-x-2 transition-all shadow-lg shadow-blue-200"
className="px-8 py-3 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 active:scale-95 disabled:opacity-50 flex items-center space-x-2 transition-all shadow-lg shadow-blue-200 dark:shadow-none"
>
{loading ? <Loader2 className="animate-spin" size={20} /> : null}
<span>{loading ? t.generating : t.generate}</span>
@@ -129,19 +129,19 @@ const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
</div>
{error && (
<div className="p-4 bg-red-50 text-red-600 text-sm rounded-xl border border-red-100 animate-slide-up">
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-xl border border-red-100 dark:border-red-900/30 animate-slide-up">
{error}
</div>
)}
{loading && activeTab === 'video' && (
<div className="text-center py-4 text-sm text-slate-500 animate-breathe">
<div className="text-center py-4 text-sm text-slate-500 dark:text-slate-400 animate-breathe">
{t.videoDuration}
</div>
)}
{resultUrl && (
<div className="mt-8 border-t border-slate-100 pt-8 animate-slide-up">
<div className="mt-8 border-t border-slate-100 dark:border-slate-800 pt-8 animate-slide-up">
<div className="relative rounded-2xl overflow-hidden bg-slate-900 flex justify-center items-center shadow-2xl group">
{activeTab === 'image' ? (
<img src={resultUrl} alt="Generated" className="max-h-[600px] w-auto object-contain transition-transform duration-500 group-hover:scale-[1.02]" />

View File

@@ -56,6 +56,10 @@ export const TRANSLATIONS = {
noHistory: "暂无历史记录。开始一段对话吧!",
apiError: "错误:无法生成响应。请检查 API Key。",
languageLabel: "界面语言",
themeLabel: "主题模式",
themeAuto: "跟随系统",
themeLight: "浅色模式",
themeDark: "深色模式",
apiKeyIntro: "为了支持高质量图像生成和视频生成功能,请先选择您的 API Key。",
selectApiKeyBtn: "选择 API Key",
billingDocs: "了解计费文档",
@@ -65,6 +69,17 @@ export const TRANSLATIONS = {
older: "更早",
transcribePrompt: "请准确转录此音频内容。",
getStarted: "开始探索",
installApp: "安装应用",
installAppDesc: "将社学搭子安装到您的设备,获得原生应用般的流畅体验。",
install: "安装",
replyLanguageLabel: "AI 回复语言",
replyLangSystem: "跟随系统",
replyLangAuto: "跟随提问语言",
toast: {
copySuccess: "已复制到剪贴板",
genSuccess: "生成成功",
saveSuccess: "保存成功"
},
onboarding: {
step1: "欢迎使用社学搭子!这是一个专为社会学研究者打造的数字空间。",
step2: "你可以通过左侧的场景切换,选择从‘经典导读’到‘研究讨论’的不同模式。",
@@ -139,6 +154,10 @@ export const TRANSLATIONS = {
noHistory: "暫無歷史記錄。開始一段對話吧!",
apiError: "錯誤:無法生成響應。請檢查 API Key。",
languageLabel: "介面語言",
themeLabel: "主題模式",
themeAuto: "跟隨系統",
themeLight: "淺色模式",
themeDark: "深色模式",
apiKeyIntro: "為了支持高質量圖像生成和視頻生成功能,請先選擇您的 API Key。",
selectApiKeyBtn: "選擇 API Key",
billingDocs: "了解計費文檔",
@@ -148,6 +167,17 @@ export const TRANSLATIONS = {
older: "更早",
transcribePrompt: "請準確轉錄此音訊內容。",
getStarted: "開始探索",
installApp: "安裝應用",
installAppDesc: "將社學搭子安裝到您的設備,獲得原生應用般的流暢體驗。",
install: "安裝",
replyLanguageLabel: "AI 回復語言",
replyLangSystem: "跟隨系統",
replyLangAuto: "跟隨提問語言",
toast: {
copySuccess: "已複製到剪貼簿",
genSuccess: "生成成功",
saveSuccess: "保存成功"
},
onboarding: {
step1: "歡迎使用社學搭子!這是一個專為社會學研究者打造的數字空間。",
step2: "你可以通過左側的場景切換,選擇從‘經典導讀’到‘研究討論’的不同模式。",
@@ -158,10 +188,10 @@ export const TRANSLATIONS = {
homeFeatureTitle: "探索模組",
homeQuoteTitle: "社會學視角",
quotes: [
{ text: "人是懸掛在由他自己所編織的意義之網中的動物。", author: "克利福德·格茨" },
{ text: "人是懸掛在由他自己所編織的意義之網中的動物。", author: "克利福德·格茨" },
{ text: "想像力,這種能力可以使人看清個人生活與社會結構之間的聯繫。", author: "C·賴特·米爾斯" },
{ text: "社會學是關於社會行動的科學,其目的是通過對行動意義的解釋來理解行動。", author: "馬克斯·韋伯" },
{ text: "哲學家們只是用不同的方式解釋世界,而問題在於改變世界。", author: "卡爾·馬克思" }
{ text: "哲學家們只是用不同的方式解釋世界,而問題在於改變世界。", author: "卡尔·马克思" }
],
scenarios: {
general: { title: "日常答疑", desc: "解答各類社會學基礎問題", greeting: "你好!我是你的社會學學習搭子。有什麼日常學習中的疑問需要我解答嗎?" },
@@ -222,6 +252,10 @@ export const TRANSLATIONS = {
noHistory: "履歴がありません。",
apiError: "エラー応答を生成できませんでした。APIキーを確認してください。",
languageLabel: "言語",
themeLabel: "テーマ",
themeAuto: "システムに従う",
themeLight: "ライトモード",
themeDark: "ダークモード",
apiKeyIntro: "高品質な画像・動画生成を利用するには、まずAPIキーを選択してください。",
selectApiKeyBtn: "APIキーを選択",
billingDocs: "課金ドキュメントを確認",
@@ -231,6 +265,17 @@ export const TRANSLATIONS = {
older: "それ以前",
transcribePrompt: "この音声を正確に書き起こしてください。",
getStarted: "はじめる",
installApp: "アプリをインストール",
installAppDesc: "デバイスにインストールして、より良い体験を。",
install: "インストール",
replyLanguageLabel: "AI応答言語",
replyLangSystem: "システム言語に従う",
replyLangAuto: "入力言語に従う",
toast: {
copySuccess: "クリップボードにコピーしました",
genSuccess: "生成成功",
saveSuccess: "保存成功"
},
onboarding: {
step1: "ソシオパルへようこそ!社会学研究者のためのデジタル空間です。",
step2: "左側のメニューから、古典講読から研究アドバイザーまでシナリオを切り替えられます。",
@@ -305,6 +350,10 @@ export const TRANSLATIONS = {
noHistory: "No history yet.",
apiError: "Error: Could not generate response.",
languageLabel: "Language",
themeLabel: "Theme",
themeAuto: "System",
themeLight: "Light",
themeDark: "Dark",
apiKeyIntro: "To support high-quality image and video generation, please select your API Key first.",
selectApiKeyBtn: "Select API Key",
billingDocs: "Billing Documentation",
@@ -314,6 +363,17 @@ 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",
replyLanguageLabel: "AI Reply Language",
replyLangSystem: "System Default",
replyLangAuto: "Match User Input",
toast: {
copySuccess: "Copied to clipboard",
genSuccess: "Generation successful",
saveSuccess: "Saved successfully"
},
onboarding: {
step1: "Welcome to SocioPal! A digital space designed for sociology researchers.",
step2: "Switch scenarios on the left to explore modes from 'Classic Readings' to 'Research Advisor'.",

View File

@@ -1,18 +1,47 @@
<!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>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<!-- 添加 typography 插件支持 -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#1e293b', // 自定义深色背景
950: '#020617',
}
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; overflow: hidden; }
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; }
/* 深色模式滚动条 */
.dark ::-webkit-scrollbar-thumb { background: #475569; }
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
/* 隐藏滚动条但保留功能 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 自定义动画 */
@keyframes slideUp {
@@ -27,9 +56,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); }
@@ -47,17 +82,20 @@
"@google/genai": "https://esm.sh/@google/genai@^1.34.0",
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
"react-markdown": "https://esm.sh/react-markdown@^10.1.0",
"remark-gfm": "https://esm.sh/remark-gfm@^4.0.0",
"vite": "https://esm.sh/vite@^7.3.0",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2",
"express": "https://esm.sh/express@^5.2.1",
"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>
</head>
<body class="bg-slate-50 text-slate-900">
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>

View File

@@ -1,4 +1,3 @@
{
"name": "sociopal",
"private": true,
@@ -18,12 +17,15 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.0",
"remark-gfm": "^4.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
"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.

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;
};
// --- 聊天功能 ---
@@ -32,13 +53,13 @@ export const streamChatResponse = async (
history: Message[],
currentMessage: string,
mode: ChatMode,
language: AppLanguage,
targetLanguage: string, // 传入目标语言代码(如 'zh-CN', 'en')或 'auto'
scenario: ChatScenario = ChatScenario.GENERAL,
attachments: { mimeType: string; data: string }[] = [],
onChunk: (text: string, grounding?: any) => void
) => {
const ai = getClient();
let model = MODEL_CHAT_STANDARD;
let model = getModelNameForMode(mode);
// 根据场景构造系统指令
let baseInstruction = "";
@@ -78,20 +99,26 @@ export const streamChatResponse = async (
break;
}
// 语言指令:根据 targetLanguage 动态生成
let languageInstruction = "";
if (targetLanguage === 'auto') {
languageInstruction = "Reply in the language used by the user in their latest message. If the user explicitly asks to switch languages, follow their request.";
} else {
// 强制使用特定语言
languageInstruction = `You MUST reply in ${targetLanguage}. Do NOT use other languages unless explicitly asked for translation examples.`;
}
let config: any = {
systemInstruction: `${baseInstruction} Always reply in the user's preferred language: ${language}.`,
systemInstruction: `${baseInstruction}\n\n${languageInstruction}`,
};
// 根据模式配置参数
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

@@ -1,3 +1,4 @@
import { UserSettings, ChatSession, AppLanguage } from "../types";
import { DEFAULT_LANGUAGE } from "../constants";
@@ -12,7 +13,7 @@ export const loadSettings = (): UserSettings => {
if (stored) return JSON.parse(stored);
return {
language: DEFAULT_LANGUAGE,
theme: 'light'
theme: 'auto'
};
};
@@ -65,4 +66,4 @@ export const clearData = () => {
localStorage.removeItem(KEYS.SETTINGS);
localStorage.removeItem(KEYS.SESSIONS);
localStorage.removeItem(KEYS.CURRENT_SESSION);
};
};

View File

@@ -27,6 +27,7 @@ export interface Message {
attachments?: Attachment[];
isThinking?: boolean;
groundingMetadata?: GroundingMetadata;
model?: string; // 模型名称
}
export interface GroundingMetadata {
@@ -45,7 +46,7 @@ export interface Attachment {
export interface UserSettings {
language: AppLanguage;
theme: 'light' | 'dark';
theme: 'light' | 'dark' | 'auto';
isOnboarded?: boolean;
apiKey?: string;
}

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 || '')