...
This commit is contained in:
122
CLAUDE.md
Normal file
122
CLAUDE.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动开发服务器(启用 HTTPS)
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# 构建生产版本(standalone 输出模式,用于 Docker)
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# 启动生产服务器
|
||||||
|
pnpm run start
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
# 数据库操作
|
||||||
|
pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma
|
||||||
|
pnpm prisma db push # 推送 schema 变更到数据库
|
||||||
|
pnpm prisma studio # 打开 Prisma Studio 查看数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Next.js 16** 使用 App Router 和 standalone 输出模式
|
||||||
|
- **React 19** 启用 React Compiler 进行优化
|
||||||
|
- **TypeScript** 严格模式和 ES2023 目标
|
||||||
|
- **Tailwind CSS v4** 样式框架
|
||||||
|
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
||||||
|
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
||||||
|
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||||
|
- **edge-tts-universal** 文本转语音
|
||||||
|
- **pnpm** 包管理器
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 路由结构
|
||||||
|
|
||||||
|
应用使用 Next.js App Router 和基于功能的组织方式:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── (features)/ # 功能模块(translator, alphabet, memorize, dictionary, srt-player)
|
||||||
|
│ └── [locale]/ # 国际化路由
|
||||||
|
├── auth/ # 认证页面(sign-in, sign-up)
|
||||||
|
├── folders/ # 用户学习文件夹管理
|
||||||
|
├── api/ # API 路由
|
||||||
|
└── profile/ # 用户资料页面
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库 Schema
|
||||||
|
|
||||||
|
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
||||||
|
- **User**: 用户中心实体,包含认证信息
|
||||||
|
- **Folder**: 用户拥有的学习资料容器(级联删除 pairs)
|
||||||
|
- **Pair**: 语言对(翻译/词汇),支持 IPA,唯一约束为 (folderId, locale1, locale2, text1)
|
||||||
|
- **Session/Account**: better-auth 追踪
|
||||||
|
- **Verification**: 邮箱验证系统
|
||||||
|
|
||||||
|
### 核心模式
|
||||||
|
|
||||||
|
**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions,配合类型安全的 Prisma 操作。
|
||||||
|
|
||||||
|
**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。
|
||||||
|
|
||||||
|
**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。
|
||||||
|
|
||||||
|
**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。
|
||||||
|
|
||||||
|
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
需要在 `.env.local` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# LLM 集成
|
||||||
|
ZHIPU_API_KEY=your-api-key
|
||||||
|
ZHIPU_MODEL_NAME=your-model-name
|
||||||
|
|
||||||
|
# 认证
|
||||||
|
BETTER_AUTH_SECRET=your-secret
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
GITHUB_CLIENT_ID=your-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重要配置细节
|
||||||
|
|
||||||
|
- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`)
|
||||||
|
- **Standalone 输出**: 为 Docker 部署配置
|
||||||
|
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
||||||
|
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
||||||
|
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
|
||||||
|
|
||||||
|
## 代码组织
|
||||||
|
|
||||||
|
- `src/lib/actions/`: 数据库变更的 Server Actions
|
||||||
|
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
||||||
|
- `src/lib/browser/`: 客户端工具
|
||||||
|
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
||||||
|
- `src/i18n/`: 国际化配置
|
||||||
|
- `messages/`: 各支持语言的翻译文件
|
||||||
|
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
- 使用 pnpm,而不是 npm 或 yarn
|
||||||
|
- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push`
|
||||||
|
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
||||||
|
- 所有面向用户的文本都需要国际化
|
||||||
|
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||||
321
src/app/(features)/dictionary/page.tsx
Normal file
321
src/app/(features)/dictionary/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { VOICES } from "@/config/locales";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
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 { logger } from "@/lib/logger";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
genIPA,
|
genIPA,
|
||||||
genLocale,
|
genLocale,
|
||||||
genTranslation,
|
genTranslation,
|
||||||
} from "@/lib/server/translatorActions";
|
} from "@/lib/server/bigmodel/translatorActions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
import FolderSelector from "./FolderSelector";
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export default async function HomePage() {
|
|||||||
description={t("srtPlayer.description")}
|
description={t("srtPlayer.description")}
|
||||||
color="#3c988d"
|
color="#3c988d"
|
||||||
></LinkArea>
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/dictionary"
|
||||||
|
name={t("dictionary.name")}
|
||||||
|
description={t("dictionary.description")}
|
||||||
|
color="#6a9c89"
|
||||||
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/alphabet"
|
href="/alphabet"
|
||||||
name={t("alphabet.name")}
|
name={t("alphabet.name")}
|
||||||
|
|||||||
@@ -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))),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
100
src/lib/server/bigmodel/dictionaryActions.ts
Normal file
100
src/lib/server/bigmodel/dictionaryActions.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getLLMAnswer } from "./ai";
|
import { getAnswer } from "./zhipu";
|
||||||
|
|
||||||
export const genIPA = async (text: string) => {
|
export const genIPA = async (text: string) => {
|
||||||
return (
|
return (
|
||||||
"[" +
|
"[" +
|
||||||
(
|
(
|
||||||
await getLLMAnswer(
|
await getAnswer(
|
||||||
`
|
`
|
||||||
<text>${text}</text>
|
<text>${text}</text>
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export const genIPA = async (text: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const genLocale = async (text: string) => {
|
export const genLocale = async (text: string) => {
|
||||||
return await getLLMAnswer(
|
return await getAnswer(
|
||||||
`
|
`
|
||||||
<text>${text}</text>
|
<text>${text}</text>
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export const genLocale = async (text: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const genTranslation = async (text: string, targetLanguage: string) => {
|
export const genTranslation = async (text: string, targetLanguage: string) => {
|
||||||
return await getLLMAnswer(
|
return await getAnswer(
|
||||||
`
|
`
|
||||||
<text>${text}</text>
|
<text>${text}</text>
|
||||||
|
|
||||||
45
src/lib/server/bigmodel/zhipu.ts
Normal file
45
src/lib/server/bigmodel/zhipu.ts
Normal 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 };
|
||||||
@@ -147,3 +147,29 @@ export class SeededRandom {
|
|||||||
return shuffled;
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user