From 37e221d8b8bdf3215cf167b7d1df0bc7dc46f005 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Tue, 6 Jan 2026 15:41:11 +0800 Subject: [PATCH] ... --- .../migration.sql | 8 + prisma/schema.prisma | 2 - .../(features)/dictionary/DictionaryEntry.tsx | 78 ++++ .../(features)/dictionary/DictionaryPage.tsx | 133 ++++++ src/app/(features)/dictionary/SearchForm.tsx | 124 ++++++ .../(features)/dictionary/SearchResult.tsx | 155 +++++++ src/app/(features)/dictionary/constants.ts | 10 + src/app/(features)/dictionary/index.ts | 11 + src/app/(features)/dictionary/page.tsx | 399 +----------------- src/app/(features)/dictionary/types.ts | 2 + src/lib/server/bigmodel/dictionaryActions.ts | 230 ++++++---- src/lib/server/services/dictionaryService.ts | 56 +++ src/lib/shared/dictionaryTypes.ts | 63 +++ src/lib/shared/index.ts | 1 + 14 files changed, 799 insertions(+), 473 deletions(-) create mode 100644 prisma/migrations/20260106061255_remove_unique_constraint/migration.sql create mode 100644 src/app/(features)/dictionary/DictionaryEntry.tsx create mode 100644 src/app/(features)/dictionary/DictionaryPage.tsx create mode 100644 src/app/(features)/dictionary/SearchForm.tsx create mode 100644 src/app/(features)/dictionary/SearchResult.tsx create mode 100644 src/app/(features)/dictionary/constants.ts create mode 100644 src/app/(features)/dictionary/index.ts create mode 100644 src/app/(features)/dictionary/types.ts create mode 100644 src/lib/server/services/dictionaryService.ts create mode 100644 src/lib/shared/dictionaryTypes.ts create mode 100644 src/lib/shared/index.ts diff --git a/prisma/migrations/20260106061255_remove_unique_constraint/migration.sql b/prisma/migrations/20260106061255_remove_unique_constraint/migration.sql new file mode 100644 index 0000000..ffbe1ce --- /dev/null +++ b/prisma/migrations/20260106061255_remove_unique_constraint/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key"; + +-- DropIndex +DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key"; + +-- RenameIndex +ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e4df3ce..77a719b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,7 +136,6 @@ model DictionaryWord { lookups DictionaryLookUp[] entries DictionaryWordEntry[] - @@unique([standardForm, queryLang, definitionLang]) @@index([standardForm]) @@index([queryLang, definitionLang]) @@map("dictionary_words") @@ -153,7 +152,6 @@ model DictionaryPhrase { lookups DictionaryLookUp[] entries DictionaryPhraseEntry[] - @@unique([standardForm, queryLang, definitionLang]) @@index([standardForm]) @@index([queryLang, definitionLang]) @@map("dictionary_phrases") diff --git a/src/app/(features)/dictionary/DictionaryEntry.tsx b/src/app/(features)/dictionary/DictionaryEntry.tsx new file mode 100644 index 0000000..b06495f --- /dev/null +++ b/src/app/(features)/dictionary/DictionaryEntry.tsx @@ -0,0 +1,78 @@ +import { DictWordEntry, DictPhraseEntry } from "./types"; + +interface DictionaryEntryProps { + entry: DictWordEntry | DictPhraseEntry; +} + +export function DictionaryEntry({ entry }: DictionaryEntryProps) { + // 检查是否有 ipa 字段来判断是否为单词条目 + const isWordEntry = "ipa" in entry && "partOfSpeech" in entry; + + if (isWordEntry) { + // 单词条目 + const wordEntry = entry as DictWordEntry; + return ( +
+ {/* 音标和词性 */} +
+ {wordEntry.ipa && ( + + {wordEntry.ipa} + + )} + {wordEntry.partOfSpeech && ( + + {wordEntry.partOfSpeech} + + )} +
+ + {/* 释义 */} +
+

+ 释义 +

+

{wordEntry.definition}

+
+ + {/* 例句 */} + {wordEntry.example && ( +
+

+ 例句 +

+

+ {wordEntry.example} +

+
+ )} +
+ ); + } + + // 短语条目 + const phraseEntry = entry as DictPhraseEntry; + return ( +
+ {/* 释义 */} +
+

+ 释义 +

+

{phraseEntry.definition}

+
+ + {/* 例句 */} + {phraseEntry.example && ( +
+

+ 例句 +

+

+ {phraseEntry.example} +

+
+ )} +
+ ); +} diff --git a/src/app/(features)/dictionary/DictionaryPage.tsx b/src/app/(features)/dictionary/DictionaryPage.tsx new file mode 100644 index 0000000..c5ff2e5 --- /dev/null +++ b/src/app/(features)/dictionary/DictionaryPage.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Container from "@/components/ui/Container"; +import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Folder } from "../../../../generated/prisma/browser"; +import { getFoldersByUserId } from "@/lib/server/services/folderService"; +import { DictLookUpResponse, isDictErrorResponse } from "./types"; +import { SearchForm } from "./SearchForm"; +import { SearchResult } from "./SearchResult"; + +export default function Dictionary() { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const [queryLang, setQueryLang] = useState("english"); + const [definitionLang, setDefinitionLang] = useState("chinese"); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [folders, setFolders] = useState([]); + const { data: session } = authClient.useSession(); + + // 加载用户的文件夹列表 + useEffect(() => { + if (session) { + getFoldersByUserId(session.user.id as string) + .then((loadedFolders) => { + setFolders(loadedFolders); + // 如果有文件夹且未选择,默认选择第一个 + if (loadedFolders.length > 0 && !selectedFolderId) { + setSelectedFolderId(loadedFolders[0].id); + } + }); + } + }, [session, selectedFolderId]); + + 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); + const result = await lookUp({ + text: searchQuery, + definitionLang: definitionLang, + queryLang: queryLang, + forceRelook: false + }) + + // 检查是否为错误响应 + if (isDictErrorResponse(result)) { + toast.error(result.error); + setSearchResult(null); + } else { + setSearchResult(result); + } + } catch (error) { + console.error("词典查询失败:", error); + toast.error("查询失败,请稍后重试"); + setSearchResult(null); + } finally { + setIsSearching(false); + } + }; + + return ( +
+ {/* 搜索区域 */} +
+ + + +
+ + {/* 搜索结果区域 */} +
+ + {isSearching && ( +
+
+

加载中...

+
+ )} + + {!isSearching && hasSearched && !searchResult && ( +
+

未找到结果

+

尝试其他单词或短语

+
+ )} + + {!isSearching && searchResult && !isDictErrorResponse(searchResult) && ( + + )} + + {!hasSearched && ( +
+
📚
+

欢迎使用词典

+

在上方搜索框中输入单词或短语开始查询

+
+ )} +
+
+
+ ); +} diff --git a/src/app/(features)/dictionary/SearchForm.tsx b/src/app/(features)/dictionary/SearchForm.tsx new file mode 100644 index 0000000..46cc10c --- /dev/null +++ b/src/app/(features)/dictionary/SearchForm.tsx @@ -0,0 +1,124 @@ +import { LightButton } from "@/components/ui/buttons"; +import { POPULAR_LANGUAGES } from "./constants"; + +interface SearchFormProps { + searchQuery: string; + onSearchQueryChange: (query: string) => void; + isSearching: boolean; + onSearch: (e: React.FormEvent) => void; + queryLang: string; + onQueryLangChange: (lang: string) => void; + definitionLang: string; + onDefinitionLangChange: (lang: string) => void; +} + +export function SearchForm({ + searchQuery, + onSearchQueryChange, + isSearching, + onSearch, + queryLang, + onQueryLangChange, + definitionLang, + onDefinitionLangChange, +}: SearchFormProps) { + return ( + <> + {/* 页面标题 */} +
+

+ 词典 +

+

+ 查询单词和短语,提供详细的释义和例句 +

+
+ + {/* 搜索表单 */} +
+ ) => onSearchQueryChange(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" + /> + + {isSearching ? "查询中..." : "查询"} + +
+ + {/* 语言设置 */} +
+
+ 语言设置 +
+ +
+ {/* 查询语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + onQueryLangChange(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ onQueryLangChange(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" + /> +
+ + {/* 释义语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + onDefinitionLangChange(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ onDefinitionLangChange(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" + /> +
+ + {/* 当前设置显示 */} +
+ 当前设置:查询 {POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang} + ,释义 {POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang} +
+
+
+ + + ); +} diff --git a/src/app/(features)/dictionary/SearchResult.tsx b/src/app/(features)/dictionary/SearchResult.tsx new file mode 100644 index 0000000..d4c50b4 --- /dev/null +++ b/src/app/(features)/dictionary/SearchResult.tsx @@ -0,0 +1,155 @@ +import { Plus, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Folder } from "../../../../generated/prisma/browser"; +import { createPair } from "@/lib/server/services/pairService"; +import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; +import { + DictWordResponse, + DictPhraseResponse, + isDictWordResponse, + DictWordEntry, + isDictErrorResponse, +} from "./types"; +import { DictionaryEntry } from "./DictionaryEntry"; +import { POPULAR_LANGUAGES } from "./constants"; + +interface SearchResultProps { + searchResult: DictWordResponse | DictPhraseResponse; + searchQuery: string; + queryLang: string; + definitionLang: string; + folders: Folder[]; + selectedFolderId: number | null; + onFolderSelect: (folderId: number | null) => void; + onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void; + onSearchingChange: (isSearching: boolean) => void; +} + +export function SearchResult({ + searchResult, + searchQuery, + queryLang, + definitionLang, + folders, + selectedFolderId, + onFolderSelect, + onResultUpdate, + onSearchingChange, +}: SearchResultProps) { + const { data: session } = authClient.useSession(); + + const handleRelookup = async () => { + onSearchingChange(true); + + try { + const result = await lookUp({ + text: searchQuery, + definitionLang: definitionLang, + queryLang: queryLang, + forceRelook: true + }); + + if (isDictErrorResponse(result)) { + toast.error(result.error); + } else { + onResultUpdate(result); + toast.success("已重新查询"); + } + } catch (error) { + console.error("词典重新查询失败:", error); + toast.error("查询失败,请稍后重试"); + } finally { + onSearchingChange(false); + } + }; + + const handleSave = () => { + if (!session) { + toast.error("请先登录"); + return; + } + if (!selectedFolderId) { + toast.error("请先创建文件夹"); + return; + } + + const entry = searchResult.entries[0]; + createPair({ + text1: searchResult.standardForm, + text2: entry.definition, + language1: queryLang, + language2: definitionLang, + ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined, + folder: { + connect: { + id: selectedFolderId, + }, + }, + }) + .then(() => { + const folderName = folders.find(f => f.id === selectedFolderId)?.name; + toast.success(`已保存到文件夹:${folderName}`); + }) + .catch(() => { + toast.error("保存失败,请稍后重试"); + }); + }; + + return ( +
+
+ {/* 标题和保存按钮 */} +
+
+

+ {searchResult.standardForm} +

+
+
+ {session && folders.length > 0 && ( + + )} + +
+
+ + {/* 条目列表 */} +
+ {searchResult.entries.map((entry, index) => ( +
+ +
+ ))} +
+ + {/* 重新查询按钮 */} +
+ +
+
+
+ ); +} diff --git a/src/app/(features)/dictionary/constants.ts b/src/app/(features)/dictionary/constants.ts new file mode 100644 index 0000000..e0c6791 --- /dev/null +++ b/src/app/(features)/dictionary/constants.ts @@ -0,0 +1,10 @@ +export 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: "西班牙语" }, +] as const; diff --git a/src/app/(features)/dictionary/index.ts b/src/app/(features)/dictionary/index.ts new file mode 100644 index 0000000..8cb51b8 --- /dev/null +++ b/src/app/(features)/dictionary/index.ts @@ -0,0 +1,11 @@ +// 类型定义 +export * from "./types"; + +// 常量 +export * from "./constants"; + +// 组件 +export { default as DictionaryPage } from "./DictionaryPage"; +export { SearchForm } from "./SearchForm"; +export { SearchResult } from "./SearchResult"; +export { DictionaryEntry } from "./DictionaryEntry"; diff --git a/src/app/(features)/dictionary/page.tsx b/src/app/(features)/dictionary/page.tsx index 4fbc9f7..dd8e42b 100644 --- a/src/app/(features)/dictionary/page.tsx +++ b/src/app/(features)/dictionary/page.tsx @@ -1,398 +1 @@ -"use client"; - -import { useState, useEffect } 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"; -import { Plus } from "lucide-react"; -import { authClient } from "@/lib/auth-client"; -import { Folder } from "../../../../generated/prisma/browser"; -import { getFoldersByUserId } from "@/lib/server/services/folderService"; -import { createPair } from "@/lib/server/services/pairService"; - -// 主流语言列表 -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(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 [selectedFolderId, setSelectedFolderId] = useState(null); - const [folders, setFolders] = useState([]); - const { data: session } = authClient.useSession(); - - // 加载用户的文件夹列表 - useEffect(() => { - if (session) { - getFoldersByUserId(session.user.id as string) - .then((loadedFolders) => { - setFolders(loadedFolders); - // 如果有文件夹且未选择,默认选择第一个 - if (loadedFolders.length > 0 && !selectedFolderId) { - setSelectedFolderId(loadedFolders[0].id); - } - }); - } - }, [session, selectedFolderId]); - - 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 ( -
- {/* 搜索区域 */} -
- - {/* 页面标题 */} -
-

- 词典 -

-

- 查询单词和短语,提供详细的释义和例句 -

-
- - {/* 搜索表单 */} -
- ) => 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" - /> - - {isSearching ? "查询中..." : "查询"} - -
- - {/* 语言设置 */} -
-
- 语言设置 - setShowLangSettings(!showLangSettings)} - className="text-sm px-4 py-2" - > - {showLangSettings ? "收起" : "展开"} - -
- - {showLangSettings && ( -
- {/* 查询语言 */} -
- -
- {POPULAR_LANGUAGES.map((lang) => ( - setQueryLang(lang.code)} - className="text-sm px-3 py-1" - > - {lang.name} - - ))} -
- 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" - /> -
- - {/* 释义语言 */} -
- -
- {POPULAR_LANGUAGES.map((lang) => ( - setDefinitionLang(lang.code)} - className="text-sm px-3 py-1" - > - {lang.name} - - ))} -
- 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" - /> -
- - {/* 当前设置显示 */} -
- 当前设置:查询 {POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang} - ,释义 {POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang} -
-
- )} -
- - {/* 搜索提示 */} -
-

试试搜索:hello, look up, dictionary

-
-
-
- - {/* 搜索结果区域 */} -
- - {isSearching && ( -
-
-

加载中...

-
- )} - - {!isSearching && hasSearched && !searchResult && ( -
-

未找到结果

-

尝试其他单词或短语

-
- )} - - {!isSearching && searchResult && !isErrorResponse(searchResult) && ( -
-
- {/* 标题和保存按钮 */} -
-
-

- {searchResult.standardForm} -

- {searchResult.standardForm !== searchQuery && ( -

- 原始输入: {searchQuery} -

- )} -
-
- {session && folders.length > 0 && ( - - )} - -
-
- - {/* 条目列表 */} -
- {searchResult.entries.map((entry, index) => ( -
- {isWordEntry(entry) ? ( - // 单词条目 -
- {/* 音标和词性 */} -
- {entry.ipa && ( - - {entry.ipa} - - )} - {entry.partOfSpeech && ( - - {entry.partOfSpeech} - - )} -
- - {/* 释义 */} -
-

- 释义 -

-

{entry.definition}

-
- - {/* 例句 */} - {entry.example && ( -
-

- 例句 -

-

- {entry.example} -

-
- )} -
- ) : ( - // 短语条目 -
- {/* 释义 */} -
-

- 释义 -

-

{entry.definition}

-
- - {/* 例句 */} - {entry.example && ( -
-

- 例句 -

-

- {entry.example} -

-
- )} -
- )} -
- ))} -
-
-
- )} - - {!hasSearched && ( -
-
📚
-

欢迎使用词典

-

在上方搜索框中输入单词或短语开始查询

-
- )} -
-
-
- ); -} +export { default } from "./DictionaryPage"; diff --git a/src/app/(features)/dictionary/types.ts b/src/app/(features)/dictionary/types.ts new file mode 100644 index 0000000..5df1500 --- /dev/null +++ b/src/app/(features)/dictionary/types.ts @@ -0,0 +1,2 @@ +// 从 shared 文件夹导出所有词典类型和类型守卫 +export * from "@/lib/shared"; diff --git a/src/lib/server/bigmodel/dictionaryActions.ts b/src/lib/server/bigmodel/dictionaryActions.ts index a92433b..ea89dc8 100644 --- a/src/lib/server/bigmodel/dictionaryActions.ts +++ b/src/lib/server/bigmodel/dictionaryActions.ts @@ -2,99 +2,183 @@ import { parseAIGeneratedJSON } from "@/lib/utils"; import { getAnswer } from "./zhipu"; +import { createLookUp, createPhrase, createWord, selectLastLookUp } from "../services/dictionaryService"; +import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared"; -type DictionaryWordEntry = { - ipa: string; - definition: string; - partOfSpeech: string; - example: string; +const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => { + if (isDictErrorResponse(res)) return; + else if (isDictPhraseResponse(res)) { + return 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 + } + } + }); + } else if (isDictWordResponse(res)) { + return 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 + } + } + }); + } }; -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 => { - const response = await getAnswer([ - { - role: "system", - content: ` -你是一个词典工具,返回单词/短语的JSON解释。 +export const lookUp = async ({ + text, + queryLang, + definitionLang, + userId, + forceRelook = false +}: DictLookUpRequest): Promise => { + try { + const lastLookUp = await selectLastLookUp({ + text, + queryLang, + definitionLang + }); + if (forceRelook || !lastLookUp) { + const response = await getAnswer([ + { + role: "system", + content: ` +你是一个词典工具,返回单词或短语的 JSON 解释结果。 查询语言:${queryLang} 释义语言:${definitionLang} -用户输入在标签内。判断是单词还是短语。 +用户输入在 标签内,判断是单词还是短语。 -如果输入有效,返回JSON对象,格式为: +语言规则: + +若输入语言与查询语言一致,直接查询。 + +若不一致但语义清晰(如“吃”“跑”“睡觉”),先理解其语义,再映射到查询语言中最常见、最标准的对应词或短语(如 查询语言=意大利语,输入“吃” → mangiare)。 + +若语义不清晰或存在明显歧义,视为无效输入。 + +standardForm 规则: +返回查询语言下的标准形式(英语动词原形、日语基本形、罗曼语族不定式等)。如无法确定,则与输入相同。 + +有效输入时返回: { - "standardForm": "字符串,该语言下的正确形式", - "entries": [数组,包含一个或多个条目] +"standardForm": "标准形式", +"entries": [...] } -如果是单词,条目格式: +单词条目格式: { - "ipa": "音标(如适用)", - "definition": "释义", - "partOfSpeech": "词性", - "example": "例句" +"ipa": "音标(如适用)", +"definition": "释义(使用 ${definitionLang})", +"partOfSpeech": "词性", +"example": "例句(使用 ${queryLang})" } -如果是短语,条目格式: +短语条目格式: { - "definition": "短语释义", - "example": "例句" +"definition": "释义(使用 ${definitionLang})", +"example": "例句(使用 ${queryLang})" } -所有释义内容使用${definitionLang}语言。 -例句使用${queryLang}语言。 - -如果输入无效(如:输入为空、包含非法字符、无法识别的语言等),返回JSON对象: +无效输入返回: { - "error": "错误描述信息,使用${definitionLang}语言" +"error": "错误信息(使用 ${definitionLang})" } -提供standardForm时:尝试修正笔误或返回原形(如英语动词原形、日语基本形等)。若无法确定或输入正确,则与输入相同。 - -示例: -英语输入"ran" -> standardForm: "run" -中文输入"跑眬" -> standardForm: "跑" -日语输入"走った" -> standardForm: "走る" - -短语同理,尝试返回其标准/常见形式。 - -现在处理用户输入。 +只输出 JSON,不附加任何解释性文字。 `.trim() - }, { - role: "user", - content: `${text}请处理text标签内的内容后返回给我json` + }, { + role: "user", + content: `${text}请处理text标签内的内容后返回给我json` + } + ]).then(parseAIGeneratedJSON); + saveResult({ + text, + queryLang, + definitionLang, + userId, + forceRelook + }, response); + return response; + } else { + if (lastLookUp.dictionaryWordId) { + createLookUp({ + user: userId ? { + connect: { + id: userId + } + } : undefined, + text: text, + queryLang: queryLang, + definitionLang: definitionLang, + dictionaryWord: { + connect: { + id: lastLookUp.dictionaryWordId, + } + } + }); + return { + standardForm: lastLookUp.dictionaryWord!.standardForm, + entries: lastLookUp.dictionaryWord!.entries + }; + } else if (lastLookUp.dictionaryPhraseId) { + createLookUp({ + user: userId ? { + connect: { + id: userId + } + } : undefined, + text: text, + queryLang: queryLang, + definitionLang: definitionLang, + dictionaryPhrase: { + connect: { + id: lastLookUp.dictionaryPhraseId + } + } + }); + return { + standardForm: lastLookUp.dictionaryPhrase!.standardForm, + entries: lastLookUp.dictionaryPhrase!.entries + }; + } else { + return { error: "Database structure error!" }; + } } - ]); - - const result = parseAIGeneratedJSON< - DictionaryErrorResponse | - { - standardForm: string, - entries: DictionaryPhraseEntry[]; - } | - { - standardForm: string, - entries: DictionaryWordEntry[]; - }>(response); - - return result; + } catch (error) { + console.log(error); + return { error: "LOOK_UP_ERROR" }; + } }; diff --git a/src/lib/server/services/dictionaryService.ts b/src/lib/server/services/dictionaryService.ts new file mode 100644 index 0000000..4363dae --- /dev/null +++ b/src/lib/server/services/dictionaryService.ts @@ -0,0 +1,56 @@ +"use server"; + +import { DictionaryLookUpCreateInput, DictionaryLookUpWhereInput, DictionaryPhraseCreateInput, DictionaryPhraseEntryCreateInput, DictionaryWordCreateInput, DictionaryWordEntryCreateInput } from "../../../../generated/prisma/models"; +import prisma from "../../db"; + +export async function selectLastLookUp(content: DictionaryLookUpWhereInput) { + const lookUp = await prisma.dictionaryLookUp.findFirst({ + where: content, + include: { + dictionaryPhrase: { + include: { + entries: true + } + }, + dictionaryWord: { + include: { + entries: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + return lookUp; +} + +export async function createPhraseEntry(content: DictionaryPhraseEntryCreateInput) { + return await prisma.dictionaryPhraseEntry.create({ + data: content + }); +} + +export async function createWordEntry(content: DictionaryWordEntryCreateInput) { + return await prisma.dictionaryWordEntry.create({ + data: content + }); +} + +export async function createPhrase(content: DictionaryPhraseCreateInput) { + return await prisma.dictionaryPhrase.create({ + data: content + }); +} + +export async function createWord(content: DictionaryWordCreateInput) { + return await prisma.dictionaryWord.create({ + data: content + }); +} + +export async function createLookUp(content: DictionaryLookUpCreateInput) { + return await prisma.dictionaryLookUp.create({ + data: content + }); +} diff --git a/src/lib/shared/dictionaryTypes.ts b/src/lib/shared/dictionaryTypes.ts new file mode 100644 index 0000000..280681b --- /dev/null +++ b/src/lib/shared/dictionaryTypes.ts @@ -0,0 +1,63 @@ +export type DictLookUpRequest = { + text: string, + queryLang: string, + definitionLang: string, + userId?: string, + forceRelook: boolean; +}; + +export type DictWordEntry = { + ipa: string; + definition: string; + partOfSpeech: string; + example: string; +}; + +export type DictPhraseEntry = { + definition: string; + example: string; +}; + +export type DictErrorResponse = { + error: string; +}; + +export type DictWordResponse = { + standardForm: string; + entries: DictWordEntry[]; +}; + +export type DictPhraseResponse = { + standardForm: string; + entries: DictPhraseEntry[]; +}; + +export type DictLookUpResponse = + | DictErrorResponse + | DictWordResponse + | DictPhraseResponse; + +// 类型守卫:判断是否为错误响应 +export function isDictErrorResponse( + response: DictLookUpResponse +): response is DictErrorResponse { + return "error" in response; +} + +// 类型守卫:判断是否为单词响应 +export function isDictWordResponse( + response: DictLookUpResponse +): response is DictWordResponse { + if (isDictErrorResponse(response)) return false; + const entries = (response as DictWordResponse | DictPhraseResponse).entries; + return entries.length > 0 && "ipa" in entries[0] && "partOfSpeech" in entries[0]; +} + +// 类型守卫:判断是否为短语响应 +export function isDictPhraseResponse( + response: DictLookUpResponse +): response is DictPhraseResponse { + if (isDictErrorResponse(response)) return false; + const entries = (response as DictWordResponse | DictPhraseResponse).entries; + return entries.length > 0 && !("ipa" in entries[0] || "partOfSpeech" in entries[0]); +} diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts new file mode 100644 index 0000000..30779b4 --- /dev/null +++ b/src/lib/shared/index.ts @@ -0,0 +1 @@ +export * from "./dictionaryTypes";