增加翻译缓存

This commit is contained in:
2026-01-06 19:11:21 +08:00
parent 06e90687f1
commit 504ecd259d
18 changed files with 556 additions and 246 deletions

View File

@@ -1,57 +1,63 @@
"use server";
import { executeDictionaryLookup } from "./dictionary";
import { createLookUp, createPhrase, createWord, selectLastLookUp } from "../services/dictionaryService";
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
if (isDictErrorResponse(res)) return;
else if (isDictPhraseResponse(res)) {
return createPhrase({
// 先创建 Phrase
const phrase = await createPhrase({
standardForm: res.standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
lookups: {
create: {
user: req.userId ? {
connect: {
id: req.userId
}
} : undefined,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang
}
},
entries: {
createMany: {
data: res.entries
}
}
});
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryPhraseId: phrase.id,
});
// 创建 Entries
for (const entry of res.entries) {
await createPhraseEntry({
phraseId: phrase.id,
definition: entry.definition,
example: entry.example,
});
}
} else if (isDictWordResponse(res)) {
return createWord({
// 先创建 Word
const word = await createWord({
standardForm: (res as DictWordResponse).standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
lookups: {
create: {
user: req.userId ? {
connect: {
id: req.userId
}
} : undefined,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang
}
},
entries: {
createMany: {
data: (res as DictWordResponse).entries
}
}
});
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryWordId: word.id,
});
// 创建 Entries
for (const entry of (res as DictWordResponse).entries) {
await createWordEntry({
wordId: word.id,
ipa: entry.ipa,
definition: entry.definition,
partOfSpeech: entry.partOfSpeech,
example: entry.example,
});
}
}
};
@@ -102,19 +108,11 @@ export const lookUp = async ({
// 从数据库返回缓存的结果
if (lastLookUp.dictionaryWordId) {
createLookUp({
user: userId ? {
connect: {
id: userId
}
} : undefined,
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryWord: {
connect: {
id: lastLookUp.dictionaryWordId,
}
}
dictionaryWordId: lastLookUp.dictionaryWordId,
});
return {
standardForm: lastLookUp.dictionaryWord!.standardForm,
@@ -122,19 +120,11 @@ export const lookUp = async ({
};
} else if (lastLookUp.dictionaryPhraseId) {
createLookUp({
user: userId ? {
connect: {
id: userId
}
} : undefined,
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryPhrase: {
connect: {
id: lastLookUp.dictionaryPhraseId
}
}
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
});
return {
standardForm: lastLookUp.dictionaryPhrase!.standardForm,

View File

@@ -1,7 +1,13 @@
"use server";
import { getAnswer } from "./zhipu";
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService";
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types";
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genIPA = async (text: string) => {
return (
"[" +
@@ -24,6 +30,10 @@ export const genIPA = async (text: string) => {
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLocale = async (text: string) => {
return await getAnswer(
`
@@ -38,6 +48,10 @@ export const genLocale = async (text: string) => {
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLanguage = async (text: string) => {
const language = await getAnswer([
{
@@ -79,7 +93,12 @@ export const genLanguage = async (text: string) => {
return language.trim();
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getAnswer(
`
<text>${text}</text>
@@ -91,3 +110,144 @@ export const genTranslation = async (text: string, targetLanguage: string) => {
`.trim(),
);
};
/**
* 统一的翻译函数
* 一次调用生成所有信息,支持缓存查询
*/
export async function translateText(options: TranslateTextInput): Promise<TranslateTextOutput> {
const {
sourceText,
targetLanguage,
forceRetranslate = false,
needIpa = true,
userId,
} = options;
// 1. 检查缓存(如果未强制重新翻译)并获取翻译数据
let translatedData: TranslationLLMResponse | null = null;
let fromCache = false;
if (!forceRetranslate) {
const cached = await selectLatestTranslation({
sourceText,
targetLanguage,
});
if (cached && cached.translatedText && cached.sourceLanguage) {
// 如果不需要 IPA或缓存已有 IPA使用缓存
if (!needIpa || (cached.sourceIpa && cached.targetIpa)) {
console.log("✅ 翻译缓存命中");
translatedData = {
translatedText: cached.translatedText,
sourceLanguage: cached.sourceLanguage,
targetLanguage: cached.targetLanguage,
sourceIpa: cached.sourceIpa || undefined,
targetIpa: cached.targetIpa || undefined,
};
fromCache = true;
}
}
}
// 2. 如果缓存未命中,调用 LLM 生成翻译
if (!fromCache) {
translatedData = await callTranslationLLM({
sourceText,
targetLanguage,
needIpa,
});
}
// 3. 保存到数据库(不管缓存是否命中都保存)
if (translatedData) {
try {
await createTranslationHistory({
userId,
sourceText,
sourceLanguage: translatedData.sourceLanguage,
targetLanguage: translatedData.targetLanguage,
translatedText: translatedData.translatedText,
sourceIpa: needIpa ? translatedData.sourceIpa : undefined,
targetIpa: needIpa ? translatedData.targetIpa : undefined,
});
} catch (error) {
console.error("保存翻译历史失败:", error);
}
}
return {
sourceText,
translatedText: translatedData!.translatedText,
sourceLanguage: translatedData!.sourceLanguage,
targetLanguage: translatedData!.targetLanguage,
sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "",
targetIpa: needIpa ? (translatedData!.targetIpa || "") : "",
};
}
/**
* 调用 LLM 生成翻译和相关数据
*/
async function callTranslationLLM(params: {
sourceText: string;
targetLanguage: string;
needIpa: boolean;
}): Promise<TranslationLLMResponse> {
const { sourceText, targetLanguage, needIpa } = params;
console.log("🤖 调用 LLM 翻译");
let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese\",\n \"targetLanguage\": \"目标语言的标准英文名称\"";
if (needIpa) {
systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\"";
}
systemPrompt += "}\n\n规则\n1. 只返回 JSON不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n";
if (needIpa) {
systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n";
} else {
systemPrompt += "3. 本次请求不需要生成国际音标\n";
}
systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然";
const userPrompt = `请将以下文本翻译成 ${targetLanguage}\n\n<text>${sourceText}</text>\n\n返回 JSON 格式的翻译结果。`;
const response = await getAnswer([
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userPrompt,
},
]);
// 解析 LLM 返回的 JSON
try {
// 清理响应:移除 markdown 代码块标记和多余空白
let cleanedResponse = response
.replace(/```json\s*\n/g, "") // 移除 ```json 开头
.replace(/```\s*\n/g, "") // 移除 ``` 结尾
.replace(/```\s*$/g, "") // 移除末尾的 ```
.replace(/```json\s*$/g, "") // 移除末尾的 ```json
.trim();
const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse;
// 验证必需字段
if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) {
throw new Error("LLM 返回的数据缺少必需字段");
}
console.log("LLM 翻译成功");
return parsed;
} catch (error) {
console.error("LLM 翻译失败:", error);
console.error("原始响应:", response);
throw new Error("翻译失败:无法解析 LLM 响应");
}
}

View File

@@ -1,10 +1,17 @@
"use server";
import { DictionaryLookUpCreateInput, DictionaryLookUpWhereInput, DictionaryPhraseCreateInput, DictionaryPhraseEntryCreateInput, DictionaryWordCreateInput, DictionaryWordEntryCreateInput } from "../../../../generated/prisma/models";
import {
CreateDictionaryLookUpInput,
DictionaryLookUpQuery,
CreateDictionaryPhraseInput,
CreateDictionaryPhraseEntryInput,
CreateDictionaryWordInput,
CreateDictionaryWordEntryInput
} from "./types";
import prisma from "../../db";
export async function selectLastLookUp(content: DictionaryLookUpWhereInput) {
const lookUp = await prisma.dictionaryLookUp.findFirst({
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
return prisma.dictionaryLookUp.findFirst({
where: content,
include: {
dictionaryPhrase: {
@@ -22,35 +29,34 @@ export async function selectLastLookUp(content: DictionaryLookUpWhereInput) {
createdAt: 'desc'
}
});
return lookUp;
}
export async function createPhraseEntry(content: DictionaryPhraseEntryCreateInput) {
return await prisma.dictionaryPhraseEntry.create({
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
return prisma.dictionaryPhraseEntry.create({
data: content
});
}
export async function createWordEntry(content: DictionaryWordEntryCreateInput) {
return await prisma.dictionaryWordEntry.create({
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
return prisma.dictionaryWordEntry.create({
data: content
});
}
export async function createPhrase(content: DictionaryPhraseCreateInput) {
return await prisma.dictionaryPhrase.create({
export async function createPhrase(content: CreateDictionaryPhraseInput) {
return prisma.dictionaryPhrase.create({
data: content
});
}
export async function createWord(content: DictionaryWordCreateInput) {
return await prisma.dictionaryWord.create({
export async function createWord(content: CreateDictionaryWordInput) {
return prisma.dictionaryWord.create({
data: content
});
}
export async function createLookUp(content: DictionaryLookUpCreateInput) {
return await prisma.dictionaryLookUp.create({
export async function createLookUp(content: CreateDictionaryLookUpInput) {
return prisma.dictionaryLookUp.create({
data: content
});
}

View File

@@ -1,19 +1,18 @@
"use server";
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
import { CreateFolderInput, UpdateFolderInput } from "./types";
import prisma from "../../db";
export async function getFoldersByUserId(userId: string) {
const folders = await prisma.folder.findMany({
return prisma.folder.findMany({
where: {
userId: userId,
},
});
return folders;
}
export async function renameFolderById(id: number, newName: string) {
await prisma.folder.update({
return prisma.folder.update({
where: {
id: id,
},
@@ -32,29 +31,28 @@ export async function getFoldersWithTotalPairsByUserId(userId: string) {
},
},
});
return folders.map(folder => ({
...folder,
total: folder._count?.pairs ?? 0,
}));
}
export async function createFolder(folder: FolderCreateInput) {
await prisma.folder.create({
export async function createFolder(folder: CreateFolderInput) {
return prisma.folder.create({
data: folder,
});
}
export async function deleteFolderById(id: number) {
await prisma.folder.delete({
return prisma.folder.delete({
where: {
id: id,
},
});
}
export async function updateFolderById(id: number, data: FolderUpdateInput) {
await prisma.folder.update({
export async function updateFolderById(id: number, data: UpdateFolderInput) {
return prisma.folder.update({
where: {
id: id,
},

View File

@@ -1,16 +1,16 @@
"use server";
import { PairCreateInput, PairUpdateInput } from "../../../../generated/prisma/models";
import { CreatePairInput, UpdatePairInput } from "./types";
import prisma from "../../db";
export async function createPair(data: PairCreateInput) {
await prisma.pair.create({
export async function createPair(data: CreatePairInput) {
return prisma.pair.create({
data: data,
});
}
export async function deletePairById(id: number) {
await prisma.pair.delete({
return prisma.pair.delete({
where: {
id: id,
},
@@ -19,9 +19,9 @@ export async function deletePairById(id: number) {
export async function updatePairById(
id: number,
data: PairUpdateInput,
data: UpdatePairInput,
) {
await prisma.pair.update({
return prisma.pair.update({
where: {
id: id,
},
@@ -30,19 +30,17 @@ export async function updatePairById(
}
export async function getPairCountByFolderId(folderId: number) {
const count = await prisma.pair.count({
return prisma.pair.count({
where: {
folderId: folderId,
},
});
return count;
}
export async function getPairsByFolderId(folderId: number) {
const textPairs = await prisma.pair.findMany({
return prisma.pair.findMany({
where: {
folderId: folderId,
},
});
return textPairs;
}

View File

@@ -0,0 +1,31 @@
"use server";
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types";
import prisma from "../../db";
/**
* 创建翻译历史记录
*/
export async function createTranslationHistory(data: CreateTranslationHistoryInput) {
return prisma.translationHistory.create({
data: data,
});
}
/**
* 查询最新的翻译记录
* @param sourceText 源文本
* @param targetLanguage 目标语言
* @returns 最新的翻译记录,如果不存在则返回 null
*/
export async function selectLatestTranslation(query: TranslationHistoryQuery) {
return prisma.translationHistory.findFirst({
where: {
sourceText: query.sourceText,
targetLanguage: query.targetLanguage,
},
orderBy: {
createdAt: 'desc',
},
});
}

View File

@@ -0,0 +1,122 @@
/**
* Service 层的自定义业务类型
*
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
*/
// Folder 相关
export interface CreateFolderInput {
name: string;
userId: string;
}
export interface UpdateFolderInput {
name?: string;
}
// Pair 相关
export interface CreatePairInput {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1?: string;
ipa2?: string;
folderId: number;
}
export interface UpdatePairInput {
text1?: string;
text2?: string;
language1?: string;
language2?: string;
ipa1?: string;
ipa2?: string;
}
// Translation 相关
export interface CreateTranslationHistoryInput {
userId?: string;
sourceText: string;
sourceLanguage?: string;
targetLanguage: string;
translatedText: string;
sourceIpa?: string;
targetIpa?: string;
}
export interface TranslationHistoryQuery {
sourceText: string;
targetLanguage: string;
}
// Dictionary 相关
export interface CreateDictionaryLookUpInput {
userId?: string;
text: string;
queryLang: string;
definitionLang: string;
dictionaryWordId?: number;
dictionaryPhraseId?: number;
}
export interface DictionaryLookUpQuery {
userId?: string;
text?: string;
queryLang?: string;
definitionLang?: string;
dictionaryWordId?: number;
dictionaryPhraseId?: number;
}
export interface CreateDictionaryWordInput {
standardForm: string;
queryLang: string;
definitionLang: string;
}
export interface CreateDictionaryPhraseInput {
standardForm: string;
queryLang: string;
definitionLang: string;
}
export interface CreateDictionaryWordEntryInput {
wordId: number;
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
}
export interface CreateDictionaryPhraseEntryInput {
phraseId: number;
definition: string;
example: string;
}
// 翻译相关 - 统一翻译函数
export interface TranslateTextInput {
sourceText: string;
targetLanguage: string;
forceRetranslate?: boolean; // 默认 false
needIpa?: boolean; // 默认 true
userId?: string; // 可选用户 ID
}
export interface TranslateTextOutput {
sourceText: string;
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
sourceIpa: string; // 如果 needIpa=false返回空字符串
targetIpa: string; // 如果 needIpa=false返回空字符串
}
export interface TranslationLLMResponse {
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
sourceIpa?: string; // 可选,根据 needIpa 决定
targetIpa?: string; // 可选,根据 needIpa 决定
}

View File

@@ -1,5 +1,5 @@
import prisma from "@/lib/db";
import { UserCreateInput } from "../../../../generated/prisma/models";
import { randomUUID } from "crypto";
export async function createUserIfNotExists(email: string, name?: string | null) {
const user = await prisma.user.upsert({
@@ -8,9 +8,10 @@ export async function createUserIfNotExists(email: string, name?: string | null)
},
update: {},
create: {
id: randomUUID(),
email: email,
name: name || "New User",
} as UserCreateInput,
},
});
return user;
}