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 (
-
- );
-}
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 (<>
+
+
+
+ >);
+}
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();
+}
+