This commit is contained in:
2026-01-22 16:01:07 +08:00
parent d4d5a53747
commit fa6301538b
14 changed files with 99 additions and 620 deletions

View File

@@ -2,7 +2,7 @@
"name": "learn-languages", "name": "learn-languages",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --experimental-https", "dev": "next dev --experimental-https",

View File

@@ -3,11 +3,11 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { Folder } from "../../../../generated/prisma/browser";
import { Folder as Fd } from "lucide-react"; import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
interface FolderSelectorProps { interface FolderSelectorProps {
folders: (Folder & { total: number })[]; folders: TSharedFolderWithTotalPairs[];
} }
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => { const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
@@ -50,7 +50,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0" className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
> >
{/* 文件夹图标 */} {/* 文件夹图标 */}
<div className="flex-shrink-0"> <div className="shrink-0">
<Fd className="text-gray-600" size={24} /> <Fd className="text-gray-600" size={24} />
</div> </div>
{/* 文件夹信息 */} {/* 文件夹信息 */}

View File

@@ -6,14 +6,14 @@ import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random"; import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { Pair } from "../../../../generated/prisma/browser"; import { TSharedPair } from "@/shared/folder-type";
const myFont = localFont({ const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
}); });
interface MemorizeProps { interface MemorizeProps {
textPairs: Pair[]; textPairs: TSharedPair[];
} }
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => { const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
@@ -176,31 +176,28 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
</button> </button>
<button <button
onClick={toggleReverse} onClick={toggleReverse}
className={`px-4 py-2 rounded-full transition-colors text-sm ${ className={`px-4 py-2 rounded-full transition-colors text-sm ${reverse
reverse ? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
? "bg-[#35786f] text-white hover:bg-[#2d5f58]" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
: "bg-gray-200 text-gray-700 hover:bg-gray-300" }`}
}`}
> >
{t("reverse")} {t("reverse")}
</button> </button>
<button <button
onClick={toggleDictation} onClick={toggleDictation}
className={`px-4 py-2 rounded-full transition-colors text-sm ${ className={`px-4 py-2 rounded-full transition-colors text-sm ${dictation
dictation ? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
? "bg-[#35786f] text-white hover:bg-[#2d5f58]" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
: "bg-gray-200 text-gray-700 hover:bg-gray-300" }`}
}`}
> >
{t("dictation")} {t("dictation")}
</button> </button>
<button <button
onClick={toggleDisorder} onClick={toggleDisorder}
className={`px-4 py-2 rounded-full transition-colors text-sm ${ className={`px-4 py-2 rounded-full transition-colors text-sm ${disorder
disorder ? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
? "bg-[#35786f] text-white hover:bg-[#2d5f58]" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
: "bg-gray-200 text-gray-700 hover:bg-gray-300" }`}
}`}
> >
{t("disorder")} {t("disorder")}
</button> </button>

View File

@@ -1,14 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import {
getFoldersWithTotalPairsByUserId,
} from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/utils/random"; import { isNonNegativeInteger } from "@/utils/random";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,
@@ -27,13 +24,14 @@ export default async function MemorizePage({
if (!folder_id) { if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if(!session) redirect("/auth?redirect=/memorize") if (!session) redirect("/auth?redirect=/memorize");
return ( return (
<FolderSelector <FolderSelector
folders={await getFoldersWithTotalPairsByUserId(session.user.id)} folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
/> />
); );
} }
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />; return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
} }

View File

@@ -1,84 +0,0 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { Dispatch, useEffect, useState } from "react";
import z from "zod";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps {
item: z.infer<typeof TranslationHistorySchema>;
setShow: Dispatch<React.SetStateAction<boolean>>;
}
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!session) return;
const userId = session.user.id;
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [session]);
if (!session) {
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<div>{t("notAuthenticated")}</div>
</Container>
</div>
);
}
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<h1>{t("chooseFolder")}</h1>
<div className="border border-gray-200 rounded-2xl">
{(loading && <span>...</span>) ||
(folders.length > 0 &&
folders.map((folder) => (
<button
key={folder.id}
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
onClick={() => {
createPair({
text1: item.text1,
text2: item.text2,
language1: item.language1,
language2: item.language2,
folderId: folder.id,
})
.then(() => {
toast.success(t("success"));
setShow(false);
})
.catch(() => {
toast.error(t("error"));
});
}}
>
<Fd />
{t("folderInfo", { id: folder.id, name: folder.name })}
</button>
))) || <div>{t("noFolders")}</div>}
</div>
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -1,57 +0,0 @@
import Container from "@/components/ui/Container";
import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { LightButton } from "@/components/ui/buttons";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void;
userId: string;
cancel: () => void;
}
const FolderSelector: React.FC<FolderSelectorProps> = ({
setSelectedFolderId,
userId,
cancel,
}) => {
const [loading, setLoading] = useState(false);
const [folders, setFolders] = useState<Folder[]>([]);
useEffect(() => {
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [userId]);
return (
<div
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
>
<Container className="p-6">
{(loading && <p>Loading...</p>) ||
(folders.length > 0 && (
<>
<h1>Select a Folder</h1>
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
{folders.map((folder) => (
<button
className="p-2 w-full flex hover:bg-gray-50 gap-2"
key={folder.id}
onClick={() => setSelectedFolderId(folder.id)}
>
<Fd />
{folder.id}. {folder.name}
</button>
))}
</div>
</>
)) || <p>No folders found</p>}
<LightButton onClick={cancel}>Cancel</LightButton>
</Container>
</div>
);
};
export default FolderSelector;

View File

@@ -4,22 +4,12 @@ import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons"; import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { logger } from "@/lib/logger";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import z from "zod"; import { translateText } from "@/modules/translator";
import AddToFolder from "./AddToFolder";
import { translateText } from "@/modules/translator/translator-action";
import type { TranslateTextOutput } from "@/lib/server/services/types";
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/utils/random";
import { authClient } from "@/lib/auth-client";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TranslateTextOutput } from "@/modules/translator";
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
@@ -34,18 +24,10 @@ export default function TranslatorPage() {
targetLanguage: string; targetLanguage: string;
} | null>(null); } | null>(null);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema
> | null>(null);
const lastTTS = useRef({ const lastTTS = useRef({
text: "", text: "",
url: "", url: "",
}); });
const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
const tts = async (text: string, locale: string) => { const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) { if (lastTTS.current.text !== text) {
@@ -65,14 +47,13 @@ export default function TranslatorPage() {
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES); const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url); await load(url);
await play();
lastTTS.current.text = text; lastTTS.current.text = text;
lastTTS.current.url = url; lastTTS.current.url = url;
} catch (error) { } catch (error) {
toast.error("Failed to generate audio"); toast.error("Failed to generate audio");
logger.error("生成音频失败", error);
} }
} }
await play();
}; };
const translate = async () => { const translate = async () => {
@@ -94,7 +75,6 @@ export default function TranslatorPage() {
targetLanguage, targetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
userId: session?.user?.id,
}); });
setTranslationResult(result); setTranslationResult(result);
@@ -102,34 +82,6 @@ export default function TranslatorPage() {
sourceText, sourceText,
targetLanguage, targetLanguage,
}); });
// 更新本地历史记录
const historyItem = {
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
};
setHistory(tlsoPush(historyItem));
// 自动保存到文件夹
if (autoSave && autoSaveFolderId) {
createPair({
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
ipa1: result.sourceIpa || undefined,
ipa2: result.targetIpa || undefined,
folderId: autoSaveFolderId,
})
.then(() => {
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
})
.catch((error) => {
toast.error(`保存失败: ${error.message}`);
});
}
} catch (error) { } catch (error) {
toast.error("翻译失败,请重试"); toast.error("翻译失败,请重试");
console.error("翻译错误:", error); console.error("翻译错误:", error);
@@ -261,84 +213,6 @@ export default function TranslatorPage() {
{t("translate")} {t("translate")}
</button> </button>
</div> </div>
{/* AutoSave Component */}
<div className="w-screen flex justify-center items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={autoSave}
onChange={(e) => {
const checked = e.target.checked;
if (checked === true && !session) {
toast.warning("Please login to enable auto-save");
return;
}
if (checked === false) setAutoSaveFolderId(null);
setAutoSave(checked);
}}
className="mr-2"
/>
{t("autoSave")}
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
</label>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div
key={index}
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
>
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
if (!session?.user) {
toast.info("请先登录后再保存到文件夹");
return;
}
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
{autoSave && !autoSaveFolderId && (
<FolderSelector
userId={session!.user.id as string}
cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/>
)}
</div>
)}
</> </>
); );
} }

View File

@@ -0,0 +1,2 @@
export * from './translator-action';
export * from './translator-action-dto';

View File

@@ -0,0 +1,40 @@
export interface CreateTranslationHistoryInput {
userId?: string;
sourceText: string;
sourceLanguage: string;
targetLanguage: string;
translatedText: string;
sourceIpa?: string;
targetIpa?: string;
}
export interface TranslationHistoryQuery {
sourceText: string;
targetLanguage: 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,253 +1 @@
"use server"; "use server";
import { getAnswer } from "@/lib/bigmodel/zhipu";
import { selectLatestTranslation, createTranslationHistory } from "./translator-service";
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "./translator-dto";
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genIPA = async (text: string) => {
return (
"[" +
(
await getAnswer(
`
<text>${text}</text>
请生成以上文本的严式国际音标
然后直接发给我
不要附带任何说明
不要擅自增减符号
不许用"/"或者"[]"包裹
`.trim(),
)
)
.replaceAll("[", "")
.replaceAll("]", "") +
"]"
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLocale = async (text: string) => {
return await getAnswer(
`
<text>${text}</text>
推断以上文本的地区locale
然后直接发给我
形如如zh-CN
不要附带任何说明
不要擅自增减符号
`.trim(),
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLanguage = async (text: string) => {
const language = await getAnswer([
{
role: "system",
content: `
你是一个语言检测工具。请识别文本的语言并返回语言名称。
返回语言的标准英文名称,例如:
- 中文: Chinese
- 英语: English
- 日语: Japanese
- 韩语: Korean
- 法语: French
- 德语: German
- 意大利语: Italian
- 葡萄牙语: Portuguese
- 西班牙语: Spanish
- 俄语: Russian
- 阿拉伯语: Arabic
- 印地语: Hindi
- 泰语: Thai
- 越南语: Vietnamese
- 等等...
如果无法识别语言,返回 "Unknown"
规则:
1. 只返回语言的标准英文名称
2. 首字母大写,其余小写
3. 不要附带任何说明
4. 不要擅自增减符号
`.trim()
},
{
role: "user",
content: `<text>${text}</text>`
}
]);
return language.trim();
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getAnswer(
`
<text>${text}</text>
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
然后直接发给我
不要附带任何说明
不要擅自增减符号
`.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,39 +0,0 @@
export interface CreateTranslationHistoryInput {
userId?: string;
sourceText: string;
sourceLanguage: string;
targetLanguage: string;
translatedText: string;
sourceIpa?: string;
targetIpa?: string;
}
export interface TranslationHistoryQuery {
sourceText: string;
targetLanguage: 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

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

View File

@@ -1,31 +0,0 @@
"use server";
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-dto";
import prisma from "@/lib/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',
},
});
}