From 609ac697784dce1f09e9e94e6561fa5ac6dcc774 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Mon, 6 Oct 2025 18:50:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=A8=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg | 1 + ...24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg | 1 + src/app/api/{ipa => textinfo}/route.ts | 62 ++---- src/app/api/translate/route.ts | 73 +++++++ src/app/ipa-reader/page.tsx | 33 ---- src/app/page.tsx | 6 +- .../{ipa-reader => translator}/IPAForm.tsx | 2 +- src/app/translator/page.tsx | 187 ++++++++++++++++++ src/components/Button.tsx | 15 +- src/components/IconClick.tsx | 21 ++ .../ipa-reader => hooks}/useAudioPlayer.ts | 5 +- src/utils.ts | 29 +++ 12 files changed, 340 insertions(+), 95 deletions(-) create mode 100644 public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg create mode 100644 public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg rename src/app/api/{ipa => textinfo}/route.ts (52%) create mode 100644 src/app/api/translate/route.ts delete mode 100644 src/app/ipa-reader/page.tsx rename src/app/{ipa-reader => translator}/IPAForm.tsx (98%) create mode 100644 src/app/translator/page.tsx create mode 100644 src/components/IconClick.tsx rename src/{app/ipa-reader => hooks}/useAudioPlayer.ts (95%) diff --git a/public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..50319b5 --- /dev/null +++ b/public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..47a9e72 --- /dev/null +++ b/public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/ipa/route.ts b/src/app/api/textinfo/route.ts similarity index 52% rename from src/app/api/ipa/route.ts rename to src/app/api/textinfo/route.ts index ebb20fc..60e57b3 100644 --- a/src/app/api/ipa/route.ts +++ b/src/app/api/textinfo/route.ts @@ -1,55 +1,27 @@ +import { callZhipuAPI } from "@/utils"; import { NextRequest, NextResponse } from "next/server"; -import { env } from "process"; -const API_KEY = env.ZHIPU_API_KEY; - -async function callZhipuAPI(messages: { role: string, content: string }[], model = 'glm-4.5-flash') { - const url = 'https://open.bigmodel.cn/api/paas/v4/chat/completions'; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': 'Bearer ' + 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}`); - } - - return await response.json(); -} - -async function getIPAFromLLM(text: string) { - console.log(text); +async function getTextinfo(text: string) { + console.log(`get textinfo of ${text}`); const messages = [ { role: 'user', content: ` -请推断下面文本的语言,并返回其宽式国际音标(IPA),以JSON格式 -[[TEXT]] +请推断以下文本的语言、locale,生成宽式国际音标(IPA),以JSON格式返回 +[${text}] 结果如: { -"lang": "german", -"ipa": "[ˈɡuːtn̩ ˈtaːk]", -"locale": "de-DE" + "text": "你好。", + "lang": "mandarin", + "ipa": "[ni˨˩˦ xɑʊ˨˩˦]", + "locale": "zh-CN" } 注意: 直接返回json文本, ipa一定要加[], lang的值是小写字母的英文的语言名称, locale如果可能有多个,选取最可能的一个,其中使用符号"-" -`.replace('[TEXT]', text) - } - ]; +` + }]; try { const response = await callZhipuAPI(messages); let to_parse = response.choices[0].message.content.trim() as string; @@ -69,25 +41,21 @@ export async function GET(request: NextRequest) { if (!text) { return NextResponse.json( - { error: "查询参数错误", message: "text 参数是必需的" }, + { error: "查询参数错误", message: "text参数是必需的" }, { status: 400 } ); } - const ipaData = await getIPAFromLLM(text); - - if (!ipaData) { + const textInfo = await getTextinfo(text); + if (!textInfo) { return NextResponse.json( { error: "服务暂时不可用", message: "LLM API 请求失败" }, { status: 503 } ); } - - return NextResponse.json(ipaData, { status: 200 }); - + return NextResponse.json(textInfo, { status: 200 }); } catch (error) { console.error('API 错误:', error); - return NextResponse.json( { error: "服务器内部错误", message: "请稍后重试" }, { status: 500 } diff --git a/src/app/api/translate/route.ts b/src/app/api/translate/route.ts new file mode 100644 index 0000000..bc9e2d0 --- /dev/null +++ b/src/app/api/translate/route.ts @@ -0,0 +1,73 @@ +import { callZhipuAPI } from "@/utils"; +import { NextRequest, NextResponse } from "next/server"; + +async function translate(text: string, target_lang: string) { + console.log(`translate "${text}" into ${target_lang}`); + const messages = [ + { + role: 'user', content: ` +请推断以下文本的语言、locale,生成宽式国际音标(IPA),并翻译到${target_lang},同样需要语言、locale、IPA信息,以JSON格式返回 +[${text}] +结果如: +{ + "source": { + "text": "你好。", + "lang": "mandarin", + "ipa": "[ni˨˩˦ xɑʊ˨˩˦]", + "locale": "zh-CN" + }, + "target": { + "text": "Hallo.", + "lang": "german", + "ipa": " [haˈloː]", + "locale": "de-DE" + } +} +注意: +直接返回json文本, +ipa一定要加[], +lang的值是小写字母的英文的语言名称, +locale如果可能有多个,选取最可能的一个,其中使用符号"-" +` + }]; + try { + const response = await callZhipuAPI(messages); + let to_parse = response.choices[0].message.content.trim() as string; + if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3); + if (to_parse.length === 0) throw Error('ai啥也每说'); + return JSON.parse(to_parse); + } catch (error) { + console.error(error); + return null; + } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const text = searchParams.get('text'); + const target_lang = searchParams.get('target'); + + if (!text || !target_lang) { + return NextResponse.json( + { error: "查询参数错误", message: "text参数, target参数是必需的" }, + { status: 400 } + ); + } + + const textInfo = await translate(text, target_lang); + if (!textInfo) { + return NextResponse.json( + { error: "服务暂时不可用", message: "LLM API 请求失败" }, + { status: 503 } + ); + } + return NextResponse.json(textInfo, { status: 200 }); + } catch (error) { + console.error('API 错误:', error); + return NextResponse.json( + { error: "服务器内部错误", message: "请稍后重试" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/ipa-reader/page.tsx b/src/app/ipa-reader/page.tsx deleted file mode 100644 index abba037..0000000 --- a/src/app/ipa-reader/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import IPAForm from "./IPAForm"; - - - -export default function Home() { - - - const [voicesData, setVoicesData] = useState<{ - locale: string, - short_name: string - }[] | null>(null); - const [loading, setLoading] = useState(true); - useEffect(() => { - fetch('/list_of_voices.json') - .then(res => res.json()) - .then(setVoicesData) - .catch(() => setVoicesData(null)) - .finally(() => setLoading(false)); - }, []); - if (loading) return
加载中...
; - if (!voicesData) return
加载失败
; - return ( -
-
-

IPA Reader

- -
-
- ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index b85039f..dab4a2b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -43,9 +43,9 @@ function LinkGrid() {
(null); + const [loading, setLoading] = useState(true); + const [targetLang, setTargetLang] = useState('Italian'); + const nullTextInfo = { + source: { + text: null, + language: null, + ipa: null, + locale: null + }, + target: { + text: null, + language: null, + ipa: null, + locale: null + } + }; + const [textInfo, setTextInfo] = useState<{ + source: { + text: string | null, + language: string | null, + ipa: string | null, + locale: string | null + }, + target: { + text: string | null, + language: string | null, + ipa: string | null, + locale: string | null + } + }>(nullTextInfo); + const [translating, setTranslating] = useState(false); + const { playAudio } = useAudioPlayer(); + + useEffect(() => { + fetch('/list_of_voices.json') + .then(res => res.json()) + .then(setVoicesData) + .catch(() => setVoicesData(null)) + .finally(() => setLoading(false)); + }, []); + if (loading) return
加载中...
; + if (!voicesData) return
加载失败
; + + const tl = ['English', 'Italian', 'Japanese']; + + const inputLanguage = () => { + const lang = prompt('Input a language.')?.trim(); + if (lang) { + setTargetLang(lang); + } + } + + const translate = () => { + if (translating) return; + if (!textInfo.source.text || textInfo.source.text.length === 0) return; + + setTranslating(true); + + const params = new URLSearchParams({ + text: textInfo.source.text, + target: targetLang + }) + fetch(`/api/translate?${params}`) + .then(res => res.json()) + .then(setTextInfo) + .finally(() => setTranslating(false)); + } + + const handleInputChange = (e: ChangeEvent) => { + setTextInfo({ + source: { + text: e.target.value, + language: null, + ipa: null, + locale: null + }, + target: { + text: null, + language: null, + ipa: null, + locale: null + } + }); + } + + const readSource = async () => { + if (!textInfo.source.text || textInfo.source.text.length === 0) return; + + if (!textInfo.source.locale) { + const params = new URLSearchParams({ text: textInfo.source.text }); + const res = await fetch(`/api/textinfo?${params}`); + const info = await res.json(); + setTextInfo( + { + source: info, + target: { ...textInfo.target } + } + ); + } + const voice = voicesData.find(v => v.locale.startsWith(textInfo.source.locale!)); + if (!voice) { + return; + } + const tts = new EdgeTTS(textInfo.source.text, voice.short_name); + const result = await tts.synthesize(); + playAudio(URL.createObjectURL(result.audio)); + } + const readTarget = async () => { + if (!textInfo.target.text || textInfo.target.text.length === 0) return; + + const voice = voicesData.find(v => v.locale.startsWith(textInfo.target.locale!)); + if (!voice) { + return; + } + const tts = new EdgeTTS(textInfo.target.text, voice.short_name); + const result = await tts.synthesize(); + playAudio(URL.createObjectURL(result.audio)); + } + + return ( + <> +
+
+
+ +
+ {textInfo.source.ipa || ''} +
+
+ { + if (textInfo.source.text && textInfo.source.text.length !== 0) + await navigator.clipboard.writeText(textInfo.source.text); + }} src={'/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg'} alt="copy"> + +
+
+
+ detect language +
+
+
+
+
{ + textInfo.target.text || '' + }
+
+ {textInfo.target.ipa || ''} +
+
+ { + if (textInfo.target.text && textInfo.target.text.length !== 0) + await navigator.clipboard.writeText(textInfo.target.text); + }} src={'/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg'} alt="copy"> + +
+
+
+ translate into + + + + +
+
+
+ +
+ +
+ + ); +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c2ec0ba..b8985b2 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,22 +1,19 @@ export default function Button({ label, onClick, - className, - disabled + className = '', + selected = false }: { label: - string, onClick?: () => void, + string, + onClick?: () => void, className?: string, - disabled?: boolean + selected?: boolean }) { return ( diff --git a/src/components/IconClick.tsx b/src/components/IconClick.tsx new file mode 100644 index 0000000..1a5b2d1 --- /dev/null +++ b/src/components/IconClick.tsx @@ -0,0 +1,21 @@ +import Image from "next/image"; + + +interface IconClickProps { + src: string; + alt: string; + onClick?: () => void; +} +export default function IconClick( + { src, alt, onClick = () => { } }: IconClickProps) { + return (<> +
+ {alt} +
+ ); +} diff --git a/src/app/ipa-reader/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts similarity index 95% rename from src/app/ipa-reader/useAudioPlayer.ts rename to src/hooks/useAudioPlayer.ts index 346ce94..29b0536 100644 --- a/src/app/ipa-reader/useAudioPlayer.ts +++ b/src/hooks/useAudioPlayer.ts @@ -1,6 +1,7 @@ import { useRef, useEffect } from "react"; -export default function useAudioPlayer() { + +export function useAudioPlayer() { const audioRef = useRef(null); useEffect(() => { audioRef.current = new Audio(); @@ -36,4 +37,4 @@ export default function useAudioPlayer() { pauseAudio, stopAudio }; -}; \ No newline at end of file +} diff --git a/src/utils.ts b/src/utils.ts index b0e4fcc..4c82ffe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { env } from "process"; + export function inspect(word: string) { const goto = (url: string) => { window.open(url, '_blank'); @@ -11,3 +13,30 @@ export function inspect(word: string) { export function urlGoto(url: string) { window.open(url, '_blank'); } +const API_KEY = env.ZHIPU_API_KEY; +export async function callZhipuAPI(messages: { role: string; content: string; }[], model = 'glm-4.5-flash') { + const url = 'https://open.bigmodel.cn/api/paas/v4/chat/completions'; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + 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}`); + } + + return await response.json(); +} +