更新至 v0.1.0_20251223 版本

This commit is contained in:
2025-12-24 00:52:25 +08:00
parent 5eeb9275e4
commit 34dcf09594
13 changed files with 841 additions and 573 deletions

737
App.tsx

File diff suppressed because it is too large Load Diff

101
README.md
View File

@@ -1,20 +1,95 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# SocioPal | 社学搭子 🎓
# Run and deploy your AI Studio app
[English](#english) | [简体中文](#simplified-chinese)
This contains everything you need to run your app locally.
---
View your app in AI Studio: https://ai.studio/apps/drive/10M3hDCXCBTcz9AYqzRIW22iDVBmND_DH
<a name="simplified-chinese"></a>
## 🌟 项目简介 (Simplified Chinese)
## Run Locally
**社学搭子 (SocioPal)** 是一款专为社会学学习者、研究者和爱好者设计的 AI 全能助手。它不仅仅是一个聊天机器人,更是一个集成了深度理论解析、多媒体创意生成及研究方法指导的“数字学术空间”。
**Prerequisites:** Node.js
### 🚀 核心功能
1. **场景化学习引导**
* **日常答疑**:快速解答基础社会学知识。
* **经典导读**:深度解析马克思、韦伯、涂尔干等名家著作。
* **概念解析**:提供多维度的社会学名词剖析。
* **研究讨论**:协助完善研究设计与方法论讨论。
2. **多模态交互**
* **智能搜索**:基于 Google Search 的实时学术资讯获取。
* **深度推理**:利用 Gemini 3 Pro 的思考模型处理复杂理论问题。
* **多媒体实验室**:生成社会学场景图像 (Imagen) 与 模拟视频 (Veo)。
3. **学术辅助工具**
* **语音转文字**:快速转录访谈录音或课堂笔记。
* **语音合成 (TTS)**:沉浸式听读理论文献。
* **本地备份**:支持全量数据导出与导入,确保学术资料安全。
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
### 🛠️ 技术栈
* **前端**React 18 + TypeScript + Tailwind CSS
* **AI 引擎**Google Gemini API (@google/genai)
* **模型选择**
* 文本/推理Gemini 3 Pro (Thinking), Gemini 3 Flash
* 图像Gemini 3 Pro Image (High Quality)
* 视频Veo 3.1 Fast
* 语音Gemini 2.5 Flash Native Audio
* **部署**:支持 Vite 构建,完美兼容 Cloud Run 容器化部署。
---
<a name="english"></a>
## 🌟 Project Introduction (English)
**SocioPal** is an all-in-one AI companion tailored for sociology students, researchers, and enthusiasts. More than just a chatbot, it serves as a "Digital Academic Space" integrating theoretical depth, multimedia generation, and methodological guidance.
### 🚀 Key Features
1. **Scenario-Based Learning**:
* **Daily Q&A**: Fast answers to foundational sociology questions.
* **Classic Readings**: Guided analysis of Marx, Weber, Durkheim, and other masters.
* **Concept Analysis**: Multi-dimensional breakdown of complex sociological terms.
* **Research Advisor**: Assistance with research design and methodology discussion.
2. **Multimodal Interaction**:
* **Google Search Grounding**: Real-time academic information retrieval.
* **Deep Reasoning**: Leverages Gemini 3 Pro's thinking capabilities for complex theory.
* **Multimedia Studio**: Generate sociology-themed images (Imagen) and simulated videos (Veo).
3. **Academic Utilities**:
* **Transcription**: Quickly convert interview recordings or lecture notes to text.
* **Text-to-Speech (TTS)**: Immersive listening experience for theoretical texts.
* **Local Backup**: Full data export and import to keep your academic progress safe.
### 🛠️ Tech Stack
* **Frontend**: React 18 + TypeScript + Tailwind CSS
* **AI Engine**: Google Gemini API (@google/genai)
* **Models**:
* Text/Reasoning: Gemini 3 Pro (with Thinking), Gemini 3 Flash
* Imaging: Gemini 3 Pro Image (High Quality)
* Video: Veo 3.1 Fast
* Audio: Gemini 2.5 Flash Native Audio
* **Deployment**: Vite-powered, fully compatible with Cloud Run containerization.
---
## 📦 快速开始 | Quick Start
### 1. 环境变量配置 | Environment Variables
在您的构建环境或 `.env` 文件中配置以下变量:
`API_KEY`: 您的 Google Gemini API Key.
### 2. 运行项目 | Running Locally
```bash
npm install
npm run dev
```
### 3. 构建与部署 | Build & Deploy
```bash
npm run build
npm run start
```
*Note: The project is pre-configured to listen on port 8080 for Cloud Run compatibility.*
## 📜 许可证 | License
MIT License.

View File

@@ -1,14 +1,16 @@
import React, { useState } from 'react';
import { TRANSLATIONS } from '../constants';
import { AppLanguage } from '../types';
import { generateImage, generateVideo } from '../services/geminiService';
import { Loader2, Image as ImageIcon, Video, Download } from 'lucide-react';
import { Loader2, Image as ImageIcon, Video, Download, Sparkles, Key } from 'lucide-react';
interface ToolsProps {
language: AppLanguage;
hasCustomKey?: boolean;
}
const Tools: React.FC<ToolsProps> = ({ language }) => {
const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
const t = TRANSLATIONS[language];
const [activeTab, setActiveTab] = useState<'image' | 'video'>('image');
const [prompt, setPrompt] = useState('');
@@ -21,10 +23,19 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
const handleGenerate = async () => {
if (!prompt.trim()) return;
// Users must select their own API key for image/video generation tasks
// Skip this check if user has manually configured a key in settings
if (!hasCustomKey && typeof (window as any).aistudio !== 'undefined') {
const hasKey = await (window as any).aistudio.hasSelectedApiKey();
if (!hasKey) {
await (window as any).aistudio.openSelectKey();
// Proceeding assuming selection was successful per guidelines
}
}
setLoading(true);
setError(null);
setResultUrl(null);
try {
if (activeTab === 'image') {
const images = await generateImage(prompt, imageSize);
@@ -34,6 +45,12 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
setResultUrl(video);
}
} catch (e: any) {
if (!hasCustomKey && e.message && e.message.includes("Requested entity was not found.")) {
// Handle race condition or invalid key by prompting re-selection, ONLY if no custom key
if (typeof (window as any).aistudio !== 'undefined') {
await (window as any).aistudio.openSelectKey();
}
}
setError(e.message || t.genError);
} finally {
setLoading(false);
@@ -41,55 +58,58 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
};
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">
<div className="flex space-x-2 bg-slate-200 p-1 rounded-lg w-fit">
<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">
<button
onClick={() => setActiveTab('image')}
className={`flex items-center space-x-2 px-4 py-2 rounded-md transition ${activeTab === 'image' ? 'bg-white shadow text-blue-600' : 'text-slate-600'}`}
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'}`}
>
<ImageIcon size={18} />
<span>{t.imageGen}</span>
<span className="font-medium">{t.imageGen}</span>
</button>
<button
onClick={() => setActiveTab('video')}
className={`flex items-center space-x-2 px-4 py-2 rounded-md transition ${activeTab === 'video' ? 'bg-white shadow text-purple-600' : 'text-slate-600'}`}
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'}`}
>
<Video size={18} />
<span>{t.videoGen}</span>
<span className="font-medium">{t.videoGen}</span>
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<textarea
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
rows={4}
placeholder={activeTab === 'image' ? t.imagePromptPlaceholder : t.videoPromptPlaceholder}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
<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="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"
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} />
</div>
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
<div className="flex gap-4">
<div className="flex flex-wrap gap-6 items-center justify-between">
<div className="flex gap-6">
{activeTab === 'image' ? (
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-500">{t.imageSize}:</span>
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-tight ml-1">{t.imageSize}</span>
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value as any)}
className="p-2 bg-slate-50 border border-slate-200 rounded-lg text-sm"
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"
>
<option value="1K">1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
<option value="1K">1K (HD)</option>
<option value="2K">2K (QHD)</option>
<option value="4K">4K (UHD)</option>
</select>
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-500">{t.aspectRatio}:</span>
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-tight ml-1">{t.aspectRatio}</span>
<select
value={videoRatio}
onChange={(e) => setVideoRatio(e.target.value as any)}
className="p-2 bg-slate-50 border border-slate-200 rounded-lg text-sm"
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"
>
<option value="16:9">{t.landscape}</option>
<option value="9:16">{t.portrait}</option>
@@ -101,40 +121,40 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
<button
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 flex items-center space-x-2"
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"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : null}
{loading ? <Loader2 className="animate-spin" size={20} /> : null}
<span>{loading ? t.generating : t.generate}</span>
</button>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg">
<div className="p-4 bg-red-50 text-red-600 text-sm rounded-xl border border-red-100 animate-slide-up">
{error}
</div>
)}
{loading && activeTab === 'video' && (
<div className="mt-4 text-center text-sm text-slate-500 animate-pulse">
<div className="text-center py-4 text-sm text-slate-500 animate-breathe">
{t.videoDuration}
</div>
)}
{resultUrl && (
<div className="mt-8 border-t pt-6">
<div className="relative rounded-xl overflow-hidden bg-black flex justify-center items-center">
<div className="mt-8 border-t border-slate-100 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-[500px] w-auto object-contain" />
<img src={resultUrl} alt="Generated" className="max-h-[600px] w-auto object-contain transition-transform duration-500 group-hover:scale-[1.02]" />
) : (
<video src={resultUrl} controls autoPlay loop className="max-h-[500px] w-auto" />
<video src={resultUrl} controls autoPlay loop className="max-h-[600px] w-auto" />
)}
<a
href={resultUrl}
download={`generated-${activeTab}-${Date.now()}.${activeTab === 'image' ? 'png' : 'mp4'}`}
className="absolute top-4 right-4 bg-white/90 p-2 rounded-full shadow hover:bg-white text-slate-800"
download={`sociopal-${activeTab}-${Date.now()}.${activeTab === 'image' ? 'png' : 'mp4'}`}
className="absolute top-4 right-4 bg-white/90 backdrop-blur p-3 rounded-full shadow-lg hover:bg-white text-slate-800 transition-all opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 active:scale-90"
title={t.download}
>
<Download size={20} />
<Download size={24} />
</a>
</div>
</div>
@@ -144,4 +164,4 @@ const Tools: React.FC<ToolsProps> = ({ language }) => {
);
};
export default Tools;
export default Tools;

View File

@@ -1,3 +1,4 @@
import { AppLanguage, ChatMode } from './types';
export const DEFAULT_LANGUAGE = AppLanguage.ZH_CN;
@@ -5,13 +6,15 @@ export const DEFAULT_LANGUAGE = AppLanguage.ZH_CN;
export const TRANSLATIONS = {
[AppLanguage.ZH_CN]: {
appName: "社学搭子",
home: "首页",
tagline: "您的AI社会学助教",
homeDesc: "一个面向社会学研究者的数字化深度学习与学术空间。",
newChat: "新建会话",
settings: "设置",
inputPlaceholder: "输入您的问题...",
modeStandard: "标准搜索 (实时联网)",
modeDeep: "深度思考 (复杂推理)",
modeFast: "极速模式 (快速响应)",
modeStandard: "搜索",
modeDeep: "推理",
modeFast: "极速",
tools: "创作工具",
modules: "学习场景",
studio: "多媒体实验室",
@@ -22,7 +25,7 @@ export const TRANSLATIONS = {
recordAudio: "录音提问",
generate: "生成",
download: "下载",
apiKeyLabel: "Google Gemini API Key",
apiKeyLabel: "API Key 设置",
apiKeyDesc: "您的密钥将仅存储在本地浏览器中。",
backupRestore: "数据备份与恢复",
exportData: "导出数据",
@@ -44,6 +47,7 @@ export const TRANSLATIONS = {
selectImageSize: "选择尺寸",
videoDuration: "生成视频可能需要几分钟,请耐心等待。",
confirmDelete: "确认删除此会话?",
confirmClearData: "确定要清除所有本地数据吗?此操作不可撤销。",
importSuccess: "导入成功!",
importFail: "导入失败。",
transcriptionFail: "转录失败",
@@ -51,6 +55,31 @@ export const TRANSLATIONS = {
genError: "生成失败",
noHistory: "暂无历史记录。开始一段对话吧!",
apiError: "错误:无法生成响应。请检查 API Key。",
languageLabel: "界面语言",
apiKeyIntro: "为了支持高质量图像生成和视频生成功能,请先选择您的 API Key。",
selectApiKeyBtn: "选择 API Key",
billingDocs: "了解计费文档",
today: "今天",
yesterday: "昨天",
last7Days: "过去7天",
older: "更早",
transcribePrompt: "请准确转录此音频内容。",
getStarted: "开始探索",
onboarding: {
step1: "欢迎使用社学搭子!这是一个专为社会学研究者打造的数字空间。",
step2: "你可以通过左侧的场景切换,选择从‘经典导读’到‘研究讨论’的不同模式。",
step3: "顶部的模式切换(搜索、推理、极速)能满足你从实时查资料到深度写论文的所有需求。",
done: "我知道了"
},
homeWelcome: "与经典的对话,与社会的重逢。",
homeFeatureTitle: "探索模块",
homeQuoteTitle: "社会学视点",
quotes: [
{ text: "人是悬挂在由他自己所编织的意义之网中的动物。", author: "克利福德·格尔茨" },
{ text: "想象力,这种能力可以使人看清个人生活与社会结构之间的联系。", author: "C·赖特·米尔斯" },
{ text: "社会学是关于社会行动的科学,其目的是通过对行动意义的解释来理解行动。", author: "马克斯·韦伯" },
{ text: "哲学家们只是用不同的方式解释世界,而问题在于改变世界。", author: "卡尔·马克思" }
],
scenarios: {
general: { title: "日常答疑", desc: "解答各类社会学基础问题", greeting: "你好!我是你的社会学学习搭子。有什么日常学习中的疑问需要我解答吗?" },
reading: { title: "经典导读", desc: "马克思、韦伯、涂尔干等经典著作导读", greeting: "欢迎来到经典导读。今天你想通过哪位大家(如韦伯、涂尔干)的著作来深化理解?" },
@@ -60,13 +89,15 @@ export const TRANSLATIONS = {
},
[AppLanguage.ZH_TW]: {
appName: "社學搭子",
home: "首頁",
tagline: "您的AI社會學助教",
newChat: "新建會話",
homeDesc: "一個面向社會學研究者的數位化深度學習與學術空間。",
newChat: "新建對話",
settings: "設置",
inputPlaceholder: "輸入您的問題...",
modeStandard: "標準搜索 (實時聯網)",
modeDeep: "深度思考 (複雜推理)",
modeFast: "極速模式 (快速響應)",
modeStandard: "搜索",
modeDeep: "推理",
modeFast: "極速",
tools: "創作工具",
modules: "學習場景",
studio: "多媒體實驗室",
@@ -77,8 +108,8 @@ export const TRANSLATIONS = {
recordAudio: "錄音提問",
generate: "生成",
download: "下載",
apiKeyLabel: "Google Gemini API Key",
apiKeyDesc: "您的鑰將僅存儲在本地瀏覽器中。",
apiKeyLabel: "API Key 設置",
apiKeyDesc: "您的鑰將僅存儲在本地瀏覽器中。",
backupRestore: "數據備份與恢復",
exportData: "導出數據",
importData: "導入數據",
@@ -89,7 +120,7 @@ export const TRANSLATIONS = {
portrait: "豎屏 9:16",
generating: "生成中...",
thinking: "正在深度思考...",
transcribing: "正在轉錄音...",
transcribing: "正在轉錄音...",
speaking: "朗讀",
searchSources: "參考來源",
errorApiKey: "請先在設置中配置 API Key",
@@ -98,7 +129,8 @@ export const TRANSLATIONS = {
imagePromptPlaceholder: "描述您想生成的圖片...",
selectImageSize: "選擇尺寸",
videoDuration: "生成視頻可能需要幾分鐘,請耐心等待。",
confirmDelete: "確認刪除此話?",
confirmDelete: "確認刪除此話?",
confirmClearData: "確定要清除所有本地數據嗎?此操作不可撤銷。",
importSuccess: "導入成功!",
importFail: "導入失敗。",
transcriptionFail: "轉錄失敗",
@@ -106,22 +138,132 @@ export const TRANSLATIONS = {
genError: "生成失敗",
noHistory: "暫無歷史記錄。開始一段對話吧!",
apiError: "錯誤:無法生成響應。請檢查 API Key。",
languageLabel: "介面語言",
apiKeyIntro: "為了支持高質量圖像生成和視頻生成功能,請先選擇您的 API Key。",
selectApiKeyBtn: "選擇 API Key",
billingDocs: "了解計費文檔",
today: "今天",
yesterday: "昨天",
last7Days: "過去7天",
older: "更早",
transcribePrompt: "請準確轉錄此音訊內容。",
getStarted: "開始探索",
onboarding: {
step1: "歡迎使用社學搭子!這是一個專為社會學研究者打造的數字空間。",
step2: "你可以通過左側的場景切換,選擇從‘經典導讀’到‘研究討論’的不同模式。",
step3: "頂部的模式切換(搜索、推理、極速)能滿足你從實時查資料到深度寫論文的所有需求。",
done: "我知道了"
},
homeWelcome: "與經典的對話,與社會的重逢。",
homeFeatureTitle: "探索模組",
homeQuoteTitle: "社會學視角",
quotes: [
{ text: "人是懸掛在由他自己所編織的意義之網中的動物。", author: "克利福德·格爾茨" },
{ text: "想像力,這種能力可以使人看清個人生活與社會結構之間的聯繫。", author: "C·賴特·米爾斯" },
{ text: "社會學是關於社會行動的科學,其目的是通過對行動意義的解釋來理解行動。", author: "馬克斯·韋伯" },
{ text: "哲學家們只是用不同的方式解釋世界,而問題在於改變世界。", author: "卡爾·馬克思" }
],
scenarios: {
general: { title: "日常答疑", desc: "解答各類社會學基礎問題", greeting: "你好!我是你的社會學學習搭子。有什麼日常學習中的疑問需要我解答嗎?" },
reading: { title: "經典導讀", desc: "馬克思、韋伯、塗爾干等經典著作導讀", greeting: "歡迎來到經典導。今天你想通過哪位大家(如韋伯、塗爾干)的著作來深化理解?" },
reading: { title: "經典導讀", desc: "馬克思、韋伯、涂爾幹等經典著作導讀", greeting: "歡迎來到經典導。今天你想通過哪位大家(如韋伯、涂爾幹)的著作來深化理解?" },
concept: { title: "概念解析", desc: "深入剖析社會學核心概念", greeting: "概念是社會學的基石。請告訴我你需要深度解析哪個概念?(例如:異化、不僅、科層制)" },
research: { title: "研究討論", desc: "研究設計、方法論與田野調查建議", greeting: "你好,研究員。無論是定性還是定量,我都可以協助你完善研究設計或討論方法論問題。" }
}
},
[AppLanguage.JA]: {
appName: "ソシオパル",
home: "ホーム",
tagline: "あなたのAI社会学チューター",
homeDesc: "社会学研究者のためのデジタル・ディープラーニングと学術空間。",
newChat: "新しいチャット",
settings: "設定",
inputPlaceholder: "質問を入力...",
modeStandard: "検索",
modeDeep: "推論",
modeFast: "高速",
tools: "作成ツール",
modules: "学習モジュール",
studio: "メディアスタジオ",
history: "履歴",
imageGen: "画像生成",
videoGen: "動画生成",
uploadImage: "画像を分析",
recordAudio: "音声を録音",
generate: "生成",
download: "ダウンロード",
apiKeyLabel: "APIキー設定",
apiKeyDesc: "キーはブラウザにローカル保存されます。",
backupRestore: "バックアップと復元",
exportData: "データをエクスポート",
importData: "データをインポート",
clearData: "全データを消去",
imageSize: "画像サイズ",
aspectRatio: "アスペクト比",
landscape: "横向き 16:9",
portrait: "縦向き 9:16",
generating: "生成中...",
thinking: "思考中...",
transcribing: "文字起こし中...",
speaking: "読み上げ",
searchSources: "出典",
errorApiKey: "設定でAPIキーを構成してください",
welcome: "学習シナリオを選択して開始してください:",
videoPromptPlaceholder: "生成したい社会学のシーンを説明してください...",
imagePromptPlaceholder: "生成したい画像を説明してください...",
selectImageSize: "サイズを選択",
videoDuration: "動画生成には数分かかる場合があります。",
confirmDelete: "このチャットを削除しますか?",
confirmClearData: "すべてのデータを消去してもよろしいですか?この操作は元に戻せません。",
importSuccess: "インポートに成功しました!",
importFail: "インポートに失敗しました。",
transcriptionFail: "文字起こしに失敗しました",
micError: "マイクへのアクセスが拒否されたか、利用できません。",
genError: "生成に失敗しました",
noHistory: "履歴がありません。",
apiError: "エラー応答を生成できませんでした。APIキーを確認してください。",
languageLabel: "言語",
apiKeyIntro: "高品質な画像・動画生成を利用するには、まずAPIキーを選択してください。",
selectApiKeyBtn: "APIキーを選択",
billingDocs: "課金ドキュメントを確認",
today: "今日",
yesterday: "昨日",
last7Days: "過去7日間",
older: "それ以前",
transcribePrompt: "この音声を正確に書き起こしてください。",
getStarted: "はじめる",
onboarding: {
step1: "ソシオパルへようこそ!社会学研究者のためのデジタル空間です。",
step2: "左側のメニューから、古典講読から研究アドバイザーまでシナリオを切り替えられます。",
step3: "上部のモード(検索、推論、高速)を使い分けることで、あらゆるニーズに対応します。",
done: "了解しました"
},
homeWelcome: "古典との対話、社会との再会。",
homeFeatureTitle: "機能エクスプローラー",
homeQuoteTitle: "社会学的視点",
quotes: [
{ text: "人間は、自分自身が紡いだ意味の網にぶら下がっている動物である。", author: "クリフォード・ギアツ" },
{ text: "想像力とは、個人の生活と社会構造のつながりを見極める能力である。", author: "C.ライト・ミルズ" },
{ text: "社会学とは、社会的行為の主観的な意味を解明し、その経過と結果を説明しようとする科学である。", author: "マックス・ウェーバー" },
{ text: "哲学者たちは世界を様々に解釈してきただけだ。大切なのは世界を変えることである。", author: "カール・マルクス" }
],
scenarios: {
general: { title: "Q&A", desc: "社会学に関する一般的な質問", greeting: "こんにちは!社会学の学習パートナーです。何か質問はありますか?" },
reading: { title: "古典講読", desc: "マルクス、ウェーバー、デュルケーム等のガイド", greeting: "古典講読へようこそ。今日はどの理論家について学びたいですか?" },
concept: { title: "概念分析", desc: "核心的概念の深掘り", greeting: "概念は社会学の基礎です。どの用語を分析したいですか?(例:異化、官僚制)" },
research: { title: "研究相談", desc: "調査設計、方法論のアドバイス", greeting: "こんにちは。調査設計や方法論についてサポートします。" }
}
},
[AppLanguage.EN]: {
appName: "SocioPal",
home: "Home",
tagline: "Your AI Sociology Tutor",
homeDesc: "A digital deep learning and academic space for sociology researchers.",
newChat: "New Chat",
settings: "Settings",
inputPlaceholder: "Ask a question...",
modeStandard: "Standard (Search)",
modeDeep: "Deep Think (Reasoning)",
modeFast: "Fast (Lite)",
modeStandard: "Search",
modeDeep: "Reason",
modeFast: "Fast",
tools: "Creative Tools",
modules: "Learning Modules",
studio: "Media Studio",
@@ -132,7 +274,7 @@ export const TRANSLATIONS = {
recordAudio: "Record Audio",
generate: "Generate",
download: "Download",
apiKeyLabel: "Google Gemini API Key",
apiKeyLabel: "API Key Settings",
apiKeyDesc: "Your key is stored locally in your browser.",
backupRestore: "Backup & Restore",
exportData: "Export Data",
@@ -143,8 +285,8 @@ export const TRANSLATIONS = {
landscape: "Landscape 16:9",
portrait: "Portrait 9:16",
generating: "Generating...",
thinking: "Thinking deeply...",
transcribing: "Transcribing audio...",
thinking: "Thinking...",
transcribing: "Transcribing...",
speaking: "Read Aloud",
searchSources: "Sources",
errorApiKey: "Please configure your API Key in Settings first.",
@@ -154,73 +296,44 @@ export const TRANSLATIONS = {
selectImageSize: "Select Size",
videoDuration: "Video generation may take a few minutes.",
confirmDelete: "Delete this chat?",
confirmClearData: "Are you sure you want to clear all data? This cannot be undone.",
importSuccess: "Import successful!",
importFail: "Import failed.",
transcriptionFail: "Transcription failed",
micError: "Microphone access denied or not available.",
genError: "Generation failed",
noHistory: "No history yet. Start a conversation!",
apiError: "Error: Could not generate response. Please check API Key.",
noHistory: "No history yet.",
apiError: "Error: Could not generate response.",
languageLabel: "Language",
apiKeyIntro: "To support high-quality image and video generation, please select your API Key first.",
selectApiKeyBtn: "Select API Key",
billingDocs: "Billing Documentation",
today: "Today",
yesterday: "Yesterday",
last7Days: "Last 7 Days",
older: "Older",
transcribePrompt: "Please transcribe this audio exactly as spoken.",
getStarted: "Get Started",
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'.",
step3: "The top mode switch (Search, Reason, Fast) caters to all needs from real-time info to deep analysis.",
done: "Got it"
},
homeWelcome: "Dialogue with Classics, Reconnection with Society.",
homeFeatureTitle: "Explore Modules",
homeQuoteTitle: "Sociological Perspective",
quotes: [
{ text: "Man is an animal suspended in webs of significance he himself has spun.", author: "Clifford Geertz" },
{ text: "The sociological imagination enables us to grasp history and biography and the relations between the two within society.", author: "C. Wright Mills" },
{ text: "Sociology is the science whose object is to interpret the meaning of social action and thereby give a causal explanation of its way and effects.", author: "Max Weber" },
{ text: "The philosophers have only interpreted the world, in various ways; the point is to change it.", author: "Karl Marx" }
],
scenarios: {
general: { title: "Daily Q&A", desc: "General sociology questions", greeting: "Hi! I'm your sociology study companion. Do you have any questions for me today?" },
reading: { title: "Classic Readings", desc: "Guide to Marx, Weber, Durkheim...", greeting: "Welcome to Classic Readings. Which foundational text or theorist shall we explore today?" },
concept: { title: "Concept Analysis", desc: "Deep dive into terms", greeting: "Concepts are the building blocks of sociology. Which term would you like to analyze deeply?" },
research: { title: "Research Advisor", desc: "Methodology and design", greeting: "Hello, researcher. I can assist with your research design, methodology, or field work questions." }
}
},
[AppLanguage.JA]: {
appName: "SocioPal",
tagline: "AI社会学チューター",
newChat: "新しいチャット",
settings: "設定",
inputPlaceholder: "質問を入力...",
modeStandard: "標準 (検索)",
modeDeep: "深い思考 (推論)",
modeFast: "高速 (ライト)",
tools: "クリエイティブツール",
modules: "学習モジュール",
studio: "メディアスタジオ",
history: "履歴",
imageGen: "画像生成",
videoGen: "動画生成",
uploadImage: "画像分析",
recordAudio: "音声入力",
generate: "生成",
download: "ダウンロード",
apiKeyLabel: "Google Gemini API Key",
apiKeyDesc: "キーはブラウザにローカルに保存されます。",
backupRestore: "バックアップと復元",
exportData: "データをエクスポート",
importData: "データをインポート",
clearData: "すべてのデータを消去",
imageSize: "画像サイズ",
aspectRatio: "アスペクト比",
landscape: "横向き 16:9",
portrait: "縦向き 9:16",
generating: "生成中...",
thinking: "深く考えています...",
transcribing: "音声を文字起こし中...",
speaking: "読み上げ",
searchSources: "情報源",
errorApiKey: "設定でAPIキーを設定してください。",
welcome: "学習シナリオを選択してください:",
videoPromptPlaceholder: "生成したい社会学のシナリオ動画を説明してください...",
imagePromptPlaceholder: "生成したい画像を説明してください...",
selectImageSize: "サイズを選択",
videoDuration: "動画の生成には数分かかる場合があります。",
confirmDelete: "このチャットを削除しますか?",
importSuccess: "インポート成功!",
importFail: "インポート失敗。",
transcriptionFail: "転写に失敗しました",
micError: "マイクへのアクセスが拒否されたか、利用できません。",
genError: "生成に失敗しました",
noHistory: "履歴はまだありません。会話を始めましょう!",
apiError: "エラー応答を生成できませんでした。APIキーを確認してください。",
scenarios: {
general: { title: "日常のQ&A", desc: "一般的な社会学の質問", greeting: "こんにちは!社会学の学習パートナーです。今日の質問は何ですか?" },
reading: { title: "古典講読", desc: "マルクス、ウェーバー、デュルケーム...", greeting: "古典講読へようこそ。今日はどの社会学者の著作を深掘りしましょうか?" },
concept: { title: "概念分析", desc: "用語の深い分析", greeting: "概念は社会学の基礎です。どの用語を詳しく分析したいですか?" },
research: { title: "研究相談", desc: "方法論とデザイン", greeting: "こんにちは。研究デザインや方法論(質的・量的)についての相談に乗ります。" }
}
}
};
};

View File

@@ -3,28 +3,55 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SocioPal - Social Learning Tool</title>
<title>社学搭子 - 社会学学习工具</title>
<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; }
/* Custom scrollbar for webkit */
body { font-family: 'Inter', sans-serif; overflow: hidden; }
/* 自定义滚动条 */
::-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; }
/* 自定义动画 */
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.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; }
/* 按钮微动效 */
.btn-hover { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); }
.btn-hover:active { transform: scale(0.95); }
/* 消息气泡过渡 */
.msg-transition { transition: all 0.3s ease; }
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"@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",
"vite": "https://esm.sh/vite@^7.3.0",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2"
"@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"
}
}
</script>

View File

@@ -1,3 +1,4 @@
{
"name": "sociopal",
"private": true,
@@ -5,21 +6,24 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"build": "vite build",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"@google/genai": "^0.1.0",
"lucide-react": "^0.300.0",
"@google/genai": "^1.34.0",
"@vitejs/plugin-react": "^4.2.0",
"express": "^4.18.2",
"lucide-react": "^0.473.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
"react-markdown": "^9.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0"
}
}
}

Binary file not shown.

26
server.js Normal file
View File

@@ -0,0 +1,26 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
// 默认端口 8080但优先遵循 Cloud Run 的 PORT 环境变量
const PORT = process.env.PORT || 8080;
// 托管构建后的静态文件目录
const distPath = path.join(__dirname, 'dist');
app.use(express.static(distPath));
// 处理 SPA 路由,将所有请求重定向到 index.html
app.get('*', (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`SocioPal is running on port ${PORT}`);
console.log(`Serving static files from: ${distPath}`);
});

View File

@@ -1,14 +1,24 @@
import { GoogleGenAI, Modality, Type } from "@google/genai";
import { ChatMode, Message, ChatScenario } from "../types";
// Helper to get client
import { GoogleGenAI, Modality, Type } from "@google/genai";
import { ChatMode, Message, ChatScenario, AppLanguage } from "../types";
import { TRANSLATIONS, DEFAULT_LANGUAGE } from "../constants";
// 获取 Gemini 客户端实例
const getClient = () => {
const apiKey = process.env.API_KEY;
if (!apiKey) throw new Error("API Key is missing. Please ensure process.env.API_KEY is set.");
// 优先从 LocalStorage 获取用户配置的 API Key
const storedSettings = localStorage.getItem("sociopal_settings");
const settings = storedSettings ? JSON.parse(storedSettings) : {};
const apiKey = settings.apiKey || process.env.API_KEY;
if (!apiKey) {
throw new Error("API Key 未配置。请在设置中输入您的 API Key。");
}
// Create a new GoogleGenAI instance right before making an API call
return new GoogleGenAI({ apiKey });
};
// --- Models ---
// --- 模型定义 ---
const MODEL_CHAT_STANDARD = "gemini-3-flash-preview";
const MODEL_CHAT_DEEP = "gemini-3-pro-preview";
const MODEL_CHAT_FAST = "gemini-flash-lite-latest";
@@ -16,13 +26,13 @@ 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";
// --- Chat ---
// --- 聊天功能 ---
export const streamChatResponse = async (
history: Message[],
currentMessage: string,
mode: ChatMode,
language: string,
language: AppLanguage,
scenario: ChatScenario = ChatScenario.GENERAL,
attachments: { mimeType: string; data: string }[] = [],
onChunk: (text: string, grounding?: any) => void
@@ -30,7 +40,7 @@ export const streamChatResponse = async (
const ai = getClient();
let model = MODEL_CHAT_STANDARD;
// Construct System Instruction based on Scenario
// 根据场景构造系统指令
let baseInstruction = "";
switch (scenario) {
case ChatScenario.READING:
@@ -72,13 +82,13 @@ export const streamChatResponse = async (
systemInstruction: `${baseInstruction} Always reply in the user's preferred language: ${language}.`,
};
// Configure based on mode
// 根据模式配置参数
if (mode === ChatMode.STANDARD) {
model = MODEL_CHAT_STANDARD;
config.tools = [{ googleSearch: {} }];
} else if (mode === ChatMode.DEEP) {
model = MODEL_CHAT_DEEP;
config.thinkingConfig = { thinkingBudget: 32768 }; // Max for pro
config.thinkingConfig = { thinkingBudget: 32768 }; // Pro 模型最大思考预算
} else if (mode === ChatMode.FAST) {
model = MODEL_CHAT_FAST;
}
@@ -104,7 +114,7 @@ export const streamChatResponse = async (
try {
const result = await chat.sendMessageStream({
message: { parts }
message: parts
});
for await (const chunk of result) {
@@ -120,21 +130,19 @@ export const streamChatResponse = async (
}
};
// --- Image Generation ---
// --- 图像生成 ---
export const generateImage = async (
prompt: string,
size: "1K" | "2K" | "4K"
): Promise<string[]> => {
const ai = getClient();
// Using gemini-3-pro-image-preview
const response = await ai.models.generateContent({
model: MODEL_IMAGE_GEN,
contents: { parts: [{ text: prompt }] },
config: {
imageConfig: {
imageSize: size,
count: 1, // Only 1 allowed usually for this model in preview
}
}
});
@@ -150,7 +158,7 @@ export const generateImage = async (
return images;
};
// --- Video Generation ---
// --- 视频生成 ---
export const generateVideo = async (
prompt: string,
aspectRatio: "16:9" | "9:16"
@@ -163,11 +171,10 @@ export const generateVideo = async (
config: {
numberOfVideos: 1,
aspectRatio: aspectRatio,
resolution: '720p', // fast-generate-preview often defaults to this
resolution: '720p',
}
});
// Poll for completion
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 5000));
operation = await ai.operations.getVideosOperation({ operation: operation });
@@ -176,31 +183,37 @@ export const generateVideo = async (
const uri = operation.response?.generatedVideos?.[0]?.video?.uri;
if (!uri) throw new Error("No video URI returned");
// Fetch the actual bytes using the key
const fetchResponse = await fetch(`${uri}&key=${process.env.API_KEY}`);
// Fetch with explicit key, prioritizing local setting
const storedSettings = localStorage.getItem("sociopal_settings");
const settings = storedSettings ? JSON.parse(storedSettings) : {};
const apiKey = settings.apiKey || process.env.API_KEY;
const fetchResponse = await fetch(`${uri}&key=${apiKey}`);
const blob = await fetchResponse.blob();
return URL.createObjectURL(blob);
};
// --- Transcription ---
// --- 语音转录 ---
export const transcribeAudio = async (
audioBase64: string,
mimeType: string
mimeType: string,
language: AppLanguage
): Promise<string> => {
const ai = getClient();
const t = TRANSLATIONS[language] || TRANSLATIONS[DEFAULT_LANGUAGE];
const response = await ai.models.generateContent({
model: MODEL_CHAT_STANDARD, // 3-flash is good for audio
model: MODEL_CHAT_STANDARD,
contents: {
parts: [
{ inlineData: { mimeType, data: audioBase64 } },
{ text: "Please transcribe this audio exactly as spoken." }
{ text: t.transcribePrompt }
]
}
});
return response.text || "";
};
// --- TTS ---
// --- 语音合成 ---
export const generateSpeech = async (
text: string
): Promise<AudioBuffer> => {
@@ -221,7 +234,7 @@ export const generateSpeech = async (
const base64Audio = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!base64Audio) throw new Error("No audio generated");
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({sampleRate: 24000});
const audioBuffer = await decodeAudioData(
decode(base64Audio),
audioContext,
@@ -231,7 +244,7 @@ export const generateSpeech = async (
return audioBuffer;
};
// Helper utils for audio
// 辅助函数
function decode(base64: string) {
const binaryString = atob(base64);
const len = binaryString.length;
@@ -259,4 +272,4 @@ async function decodeAudioData(
}
}
return buffer;
}
}

View File

@@ -16,6 +16,6 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["."],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["vite.config.ts", "node_modules"]
}

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"composite": true,
"composite": false,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",

View File

@@ -1,3 +1,4 @@
export enum AppLanguage {
EN = 'en',
ZH_CN = 'zh-CN',
@@ -24,7 +25,7 @@ export interface Message {
content: string;
timestamp: number;
attachments?: Attachment[];
isThinking?: boolean; // If true, show thinking UI
isThinking?: boolean;
groundingMetadata?: GroundingMetadata;
}
@@ -45,6 +46,8 @@ export interface Attachment {
export interface UserSettings {
language: AppLanguage;
theme: 'light' | 'dark';
isOnboarded?: boolean;
apiKey?: string;
}
export interface ChatSession {
@@ -54,4 +57,4 @@ export interface ChatSession {
mode: ChatMode;
scenario?: ChatScenario;
createdAt: number;
}
}

View File

@@ -1,14 +1,39 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { cwd } from 'node:process'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, (process as any).cwd(), '');
// Use cwd() from node:process to avoid type resolution issues with the global process object
const env = { ...process.env, ...loadEnv(mode, cwd(), '') };
return {
plugins: [react()],
define: {
// Define process.env.API_KEY during build time to support the existing code structure
'process.env.API_KEY': JSON.stringify(env.API_KEY)
// 确保 API Key 在构建时注入
'process.env.API_KEY': JSON.stringify(env.API_KEY || '')
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: false,
// 优化构建产物
rollupOptions: {
output: {
manualChunks: {
'vendor': ['react', 'react-dom', '@google/genai']
}
}
}
},
server: {
port: 8080,
host: '0.0.0.0'
},
preview: {
port: 8080,
host: '0.0.0.0'
}
}
})
})