303 lines
9.5 KiB
TypeScript
303 lines
9.5 KiB
TypeScript
|
|
import { GoogleGenAI, Modality, Type } from "@google/genai";
|
|
import { ChatMode, Message, ChatScenario, AppLanguage } from "../types";
|
|
import { TRANSLATIONS, DEFAULT_LANGUAGE } from "../constants";
|
|
|
|
// 获取 Gemini 客户端实例
|
|
const getClient = () => {
|
|
// 优先从 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 });
|
|
};
|
|
|
|
// --- 模型定义 ---
|
|
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;
|
|
};
|
|
|
|
// --- 聊天功能 ---
|
|
|
|
export const streamChatResponse = async (
|
|
history: Message[],
|
|
currentMessage: string,
|
|
mode: ChatMode,
|
|
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 = getModelNameForMode(mode);
|
|
|
|
// 根据场景构造系统指令
|
|
let baseInstruction = "";
|
|
switch (scenario) {
|
|
case ChatScenario.READING:
|
|
baseInstruction = `You are a distinguished Sociology Professor specializing in Classical Sociological Theory.
|
|
Focus on the works of Marx, Weber, Durkheim, Simmel, and other foundational figures.
|
|
When answering:
|
|
1. Contextualize the text historically.
|
|
2. Explain key arguments precisely.
|
|
3. Discuss the critical reception and legacy.
|
|
4. Use academic yet accessible language.`;
|
|
break;
|
|
case ChatScenario.CONCEPT:
|
|
baseInstruction = `You are an expert Sociological Concept Analyst.
|
|
Your goal is to provide deep, multi-dimensional definitions of sociological terms.
|
|
When defining a concept:
|
|
1. Provide a clear, concise definition.
|
|
2. Explain its etymology or theoretical origin.
|
|
3. Contrast it with related or opposing concepts.
|
|
4. Provide concrete examples of the concept in action.`;
|
|
break;
|
|
case ChatScenario.RESEARCH:
|
|
baseInstruction = `You are a Senior Research Methodology Consultant.
|
|
You help students and researchers design their studies.
|
|
Focus on:
|
|
1. Refining research questions.
|
|
2. Suggesting appropriate methods (Qualitative, Quantitative, Mixed).
|
|
3. Discussing sampling, operationalization, and ethics.
|
|
4. Suggesting theoretical frameworks suitable for the topic.`;
|
|
break;
|
|
case ChatScenario.GENERAL:
|
|
default:
|
|
baseInstruction = `You are a helpful and knowledgeable Sociology Learning Assistant.
|
|
Answer questions clearly using sociological perspectives.
|
|
Encourage critical thinking and connect daily life examples to sociological theories.`;
|
|
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}\n\n${languageInstruction}`,
|
|
};
|
|
|
|
// 根据模式配置参数
|
|
if (mode === ChatMode.STANDARD) {
|
|
config.tools = [{ googleSearch: {} }];
|
|
} else if (mode === ChatMode.DEEP) {
|
|
config.thinkingConfig = { thinkingBudget: 32768 }; // Pro 模型最大思考预算
|
|
}
|
|
// Fast 模式仅切换模型,无需额外配置
|
|
|
|
const chat = ai.chats.create({
|
|
model,
|
|
config,
|
|
history: history.slice(0, -1).map(m => ({
|
|
role: m.role,
|
|
parts: [
|
|
{ text: m.content },
|
|
...(m.attachments || []).map(a => ({
|
|
inlineData: { mimeType: a.mimeType, data: a.data }
|
|
}))
|
|
]
|
|
}))
|
|
});
|
|
|
|
const parts: any[] = [{ text: currentMessage }];
|
|
attachments.forEach(att => {
|
|
parts.push({ inlineData: { mimeType: att.mimeType, data: att.data } });
|
|
});
|
|
|
|
try {
|
|
const result = await chat.sendMessageStream({
|
|
message: parts
|
|
});
|
|
|
|
for await (const chunk of result) {
|
|
const text = chunk.text;
|
|
const grounding = chunk.candidates?.[0]?.groundingMetadata;
|
|
if (text || grounding) {
|
|
onChunk(text || '', grounding);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Chat error", e);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
// --- 图像生成 ---
|
|
export const generateImage = async (
|
|
prompt: string,
|
|
size: "1K" | "2K" | "4K"
|
|
): Promise<string[]> => {
|
|
const ai = getClient();
|
|
|
|
const response = await ai.models.generateContent({
|
|
model: MODEL_IMAGE_GEN,
|
|
contents: { parts: [{ text: prompt }] },
|
|
config: {
|
|
imageConfig: {
|
|
imageSize: size,
|
|
}
|
|
}
|
|
});
|
|
|
|
const images: string[] = [];
|
|
if (response.candidates?.[0]?.content?.parts) {
|
|
for (const part of response.candidates[0].content.parts) {
|
|
if (part.inlineData && part.inlineData.data) {
|
|
images.push(`data:${part.inlineData.mimeType};base64,${part.inlineData.data}`);
|
|
}
|
|
}
|
|
}
|
|
return images;
|
|
};
|
|
|
|
// --- 视频生成 ---
|
|
export const generateVideo = async (
|
|
prompt: string,
|
|
aspectRatio: "16:9" | "9:16"
|
|
): Promise<string> => {
|
|
const ai = getClient();
|
|
|
|
let operation = await ai.models.generateVideos({
|
|
model: MODEL_VIDEO_GEN,
|
|
prompt: prompt,
|
|
config: {
|
|
numberOfVideos: 1,
|
|
aspectRatio: aspectRatio,
|
|
resolution: '720p',
|
|
}
|
|
});
|
|
|
|
while (!operation.done) {
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
operation = await ai.operations.getVideosOperation({ operation: operation });
|
|
}
|
|
|
|
const uri = operation.response?.generatedVideos?.[0]?.video?.uri;
|
|
if (!uri) throw new Error("No video URI returned");
|
|
|
|
// 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);
|
|
};
|
|
|
|
// --- 语音转录 ---
|
|
export const transcribeAudio = async (
|
|
audioBase64: 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,
|
|
contents: {
|
|
parts: [
|
|
{ inlineData: { mimeType, data: audioBase64 } },
|
|
{ text: t.transcribePrompt }
|
|
]
|
|
}
|
|
});
|
|
return response.text || "";
|
|
};
|
|
|
|
// --- 语音合成 ---
|
|
export const generateSpeech = async (
|
|
text: string
|
|
): Promise<AudioBuffer> => {
|
|
const ai = getClient();
|
|
const response = await ai.models.generateContent({
|
|
model: MODEL_TTS,
|
|
contents: { parts: [{ text }] },
|
|
config: {
|
|
responseModalities: [Modality.AUDIO],
|
|
speechConfig: {
|
|
voiceConfig: {
|
|
prebuiltVoiceConfig: { voiceName: 'Kore' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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)({sampleRate: 24000});
|
|
const audioBuffer = await decodeAudioData(
|
|
decode(base64Audio),
|
|
audioContext,
|
|
24000,
|
|
1
|
|
);
|
|
return audioBuffer;
|
|
};
|
|
|
|
// 辅助函数
|
|
function decode(base64: string) {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async function decodeAudioData(
|
|
data: Uint8Array,
|
|
ctx: AudioContext,
|
|
sampleRate: number,
|
|
numChannels: number,
|
|
): Promise<AudioBuffer> {
|
|
const dataInt16 = new Int16Array(data.buffer);
|
|
const frameCount = dataInt16.length / numChannels;
|
|
const buffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
|
|
|
|
for (let channel = 0; channel < numChannels; channel++) {
|
|
const channelData = buffer.getChannelData(channel);
|
|
for (let i = 0; i < frameCount; i++) {
|
|
channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
|
|
}
|
|
}
|
|
return buffer;
|
|
}
|