This commit is contained in:
2026-01-05 14:31:18 +08:00
parent 4c64aa0a40
commit 3bc804c5e8
10 changed files with 626 additions and 68 deletions

View File

@@ -0,0 +1,321 @@
"use client";
import { useState } from "react";
import Container from "@/components/ui/Container";
import { LightButton } from "@/components/ui/buttons";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import { toast } from "sonner";
// 主流语言列表
const POPULAR_LANGUAGES = [
{ code: "english", name: "英语" },
{ code: "chinese", name: "中文" },
{ code: "japanese", name: "日语" },
{ code: "korean", name: "韩语" },
{ code: "french", name: "法语" },
{ code: "german", name: "德语" },
{ code: "italian", name: "意大利语" },
{ code: "spanish", name: "西班牙语" },
];
type DictionaryWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
type DictionaryPhraseEntry = {
definition: string;
example: string;
};
type DictionaryErrorResponse = {
error: string;
};
type DictionarySuccessResponse = {
standardForm: string;
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
};
type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse;
// 类型守卫:判断是否为单词条目
function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry {
return "ipa" in entry && "partOfSpeech" in entry;
}
// 类型守卫:判断是否为错误响应
function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse {
return "error" in response;
}
export default function Dictionary() {
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [showLangSettings, setShowLangSettings] = useState(false);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
setHasSearched(true);
setSearchResult(null);
try {
// 使用查询语言和释义语言
const result = await lookUp(searchQuery, queryLang, definitionLang);
// 检查是否为错误响应
if (isErrorResponse(result)) {
toast.error(result.error);
setSearchResult(null);
} else {
setSearchResult(result);
}
} catch (error) {
console.error("词典查询失败:", error);
toast.error("查询失败,请稍后重试");
setSearchResult(null);
} finally {
setIsSearching(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
{/* 搜索区域 */}
<div className="flex items-center justify-center px-4 py-12">
<Container className="max-w-3xl w-full p-4">
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
</h1>
<p className="text-gray-700 text-lg">
</p>
</div>
{/* 搜索表单 */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
placeholder="输入要查询的单词或短语..."
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
<LightButton
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-3"
>
{isSearching ? "查询中..." : "查询"}
</LightButton>
</form>
{/* 语言设置 */}
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-gray-800 font-semibold"></span>
<LightButton
onClick={() => setShowLangSettings(!showLangSettings)}
className="text-sm px-4 py-2"
>
{showLangSettings ? "收起" : "展开"}
</LightButton>
</div>
{showLangSettings && (
<div className="space-y-4">
{/* 查询语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
(/)
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={queryLang === lang.code}
onClick={() => setQueryLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.name}
</LightButton>
))}
</div>
<input
type="text"
value={queryLang}
onChange={(e) => setQueryLang(e.target.value)}
placeholder="或输入其他语言..."
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
()
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={definitionLang === lang.code}
onClick={() => setDefinitionLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.name}
</LightButton>
))}
</div>
<input
type="text"
value={definitionLang}
onChange={(e) => setDefinitionLang(e.target.value)}
placeholder="或输入其他语言..."
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
<span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
<span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
</div>
</div>
)}
</div>
{/* 搜索提示 */}
<div className="mt-4 text-center text-gray-700 text-sm">
<p>hello, look up, dictionary</p>
</div>
</Container>
</div>
{/* 搜索结果区域 */}
<div className="flex-1 px-4 pb-12">
<Container className="max-w-3xl w-full p-4">
{isSearching && (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p className="mt-4 text-white">...</p>
</div>
)}
{!isSearching && hasSearched && !searchResult && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl"></p>
<p className="text-gray-600 mt-2"></p>
</div>
)}
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
<div className="space-y-6">
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标准形式标题 */}
<div className="mb-6">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
{searchResult.standardForm !== searchQuery && (
<p className="text-gray-500 text-sm">
: {searchQuery}
</p>
)}
</div>
{/* 条目列表 */}
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
{isWordEntry(entry) ? (
// 单词条目
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{entry.ipa && (
<span className="text-gray-600 text-lg">
{entry.ipa}
</span>
)}
{entry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{entry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}
</p>
</div>
)}
</div>
) : (
// 短语条目
<div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}
</p>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{!hasSearched && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">使</p>
<p className="text-gray-600"></p>
</div>
)}
</Container>
</div>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/server/translatorActions";
import { genIPA, genLocale } from "@/lib/server/bigmodel/translatorActions";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";

View File

@@ -18,7 +18,7 @@ import {
genIPA,
genLocale,
genTranslation,
} from "@/lib/server/translatorActions";
} from "@/lib/server/bigmodel/translatorActions";
import { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";

View File

@@ -60,6 +60,12 @@ export default async function HomePage() {
description={t("srtPlayer.description")}
color="#3c988d"
></LinkArea>
<LinkArea
href="/dictionary"
name={t("dictionary.name")}
description={t("dictionary.description")}
color="#6a9c89"
></LinkArea>
<LinkArea
href="/alphabet"
name={t("alphabet.name")}

View File

@@ -1,62 +0,0 @@
"use server";
import { format } from "util";
async function callZhipuAPI(
messages: { role: string; content: string }[],
model = process.env.ZHIPU_MODEL_NAME,
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.2,
thinking: {
type: "disabled",
},
}),
});
if (!response.ok) {
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
}
return await response.json();
}
export async function getLLMAnswer(prompt: string) {
return (
await callZhipuAPI([
{
role: "user",
content: prompt,
},
])
).choices[0].message.content.trim() as string;
}
export async function simpleGetLLMAnswer(
prompt: string,
searchParams: URLSearchParams,
args: string[],
) {
if (args.some((arg) => typeof searchParams.get(arg) !== "string")) {
return Response.json({
status: "error",
message: "Missing required parameters",
});
}
return Response.json({
status: "success",
message: await getLLMAnswer(
format(prompt, ...args.map((v) => searchParams.get(v))),
),
});
}

View File

@@ -0,0 +1,100 @@
"use server";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { getAnswer } from "./zhipu";
type DictionaryWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
type DictionaryPhraseEntry = {
definition: string;
example: string;
};
type DictionaryErrorResponse = {
error: string;
};
type DictionarySuccessResponse = {
standardForm: string;
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
};
export const lookUp = async (
text: string,
queryLang: string,
definitionLang: string
): Promise<DictionarySuccessResponse | DictionaryErrorResponse> => {
const response = await getAnswer([
{
role: "system",
content: `
你是一个词典工具,返回单词/短语的JSON解释。
查询语言:${queryLang}
释义语言:${definitionLang}
用户输入在<text>标签内。判断是单词还是短语。
如果输入有效返回JSON对象格式为
{
"standardForm": "字符串,该语言下的正确形式",
"entries": [数组,包含一个或多个条目]
}
如果是单词,条目格式:
{
"ipa": "音标(如适用)",
"definition": "释义",
"partOfSpeech": "词性",
"example": "例句"
}
如果是短语,条目格式:
{
"definition": "短语释义",
"example": "例句"
}
所有释义内容使用${definitionLang}语言。
例句使用${queryLang}语言。
如果输入无效输入为空、包含非法字符、无法识别的语言等返回JSON对象
{
"error": "错误描述信息,使用${definitionLang}语言"
}
提供standardForm时尝试修正笔误或返回原形如英语动词原形、日语基本形等。若无法确定或输入正确则与输入相同。
示例:
英语输入"ran" -> standardForm: "run"
中文输入"跑眬" -> standardForm: "跑"
日语输入"走った" -> standardForm: "走る"
短语同理,尝试返回其标准/常见形式。
现在处理用户输入。
`.trim()
}, {
role: "user",
content: `<text>${text}</text>请处理text标签内的内容后返回给我json`
}
]);
const result = parseAIGeneratedJSON<
DictionaryErrorResponse |
{
standardForm: string,
entries: DictionaryPhraseEntry[];
} |
{
standardForm: string,
entries: DictionaryWordEntry[];
}>(response);
return result;
};

View File

@@ -1,12 +1,12 @@
"use server";
import { getLLMAnswer } from "./ai";
import { getAnswer } from "./zhipu";
export const genIPA = async (text: string) => {
return (
"[" +
(
await getLLMAnswer(
await getAnswer(
`
<text>${text}</text>
@@ -25,7 +25,7 @@ export const genIPA = async (text: string) => {
};
export const genLocale = async (text: string) => {
return await getLLMAnswer(
return await getAnswer(
`
<text>${text}</text>
@@ -39,7 +39,7 @@ export const genLocale = async (text: string) => {
};
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getLLMAnswer(
return await getAnswer(
`
<text>${text}</text>

View File

@@ -0,0 +1,45 @@
"use server";
type Messages = { role: string; content: string; }[];
async function callZhipuAPI(
messages: Messages,
model = process.env.ZHIPU_MODEL_NAME,
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.2,
thinking: {
type: "disabled",
},
}),
});
if (!response.ok) {
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function getAnswer(prompt: string): Promise<string>;
async function getAnswer(prompt: Messages): Promise<string>;
async function getAnswer(prompt: string | Messages): Promise<string> {
const messages = typeof prompt === "string"
? [{ role: "user", content: prompt }]
: prompt;
const response = await callZhipuAPI(messages);
return response.choices[0].message.content.trim() as string;
}
export { getAnswer };

View File

@@ -147,3 +147,29 @@ export class SeededRandom {
return shuffled;
}
}
export function parseAIGeneratedJSON<T>(aiResponse: string): T {
// 匹配 ```json ... ``` 包裹的内容
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
} else if (typeof error === 'string') {
throw new Error(`Failed to parse JSON: ${error}`);
} else {
throw new Error('Failed to parse JSON: Unknown error');
}
}
}
// 如果没有找到json代码块尝试直接解析整个字符串
try {
return JSON.parse(aiResponse.trim());
} catch (error) {
throw new Error('No valid JSON found in the response');
}
}