feat(speaking): 添加翻译功能并优化TTS错误处理

This commit is contained in:
cc 2026-03-25 20:46:42 +08:00
parent b993a94385
commit f04821c6bf
2 changed files with 219 additions and 23 deletions

View File

@ -55,7 +55,7 @@ const features = ref([
},
{
id: 7,
title: "AI智能解题",
title: "AI引导解题",
desc: "支持文本或图片上传题目AI 以对话方式一步步引导思考,卡片式展示解题步骤,让解题过程清晰易懂,支持多学科通用解题。",
class: "card-7",
icon: "puzzle",

View File

@ -8,11 +8,17 @@ import {
DOUBAO_TTS_API_PATH,
DOUBAO_AUDIO_FORMAT,
DOUBAO_SAMPLE_RATE,
PROBLEM_API_KEY,
PROBLEM_API_URL,
PROBLEM_MODEL,
ARK_API_KEY,
ARK_MODEL,
ARK_API_PATH,
ARK_MAX_TOKENS,
ARK_HISTORY_LIMIT,
GRS_API_KEY,
EXAM_API_URL,
EXAM_MODEL,
} from "@/config/index.js";
import pako from "pako";
@ -177,6 +183,46 @@ onMounted(() => {
}
});
//
const translateMessage = async (msg) => {
if (msg.isTranslating || msg.translation) return;
msg.isTranslating = true;
try {
const response = await fetch(PROBLEM_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PROBLEM_API_KEY}`,
},
body: JSON.stringify({
model: PROBLEM_MODEL,
messages: [
{
role: "system",
content: "你是一个英汉翻译器。直接输出翻译结果,不要输出任何思考过程、解释或额外内容。",
},
{ role: "user", content: `请将以下英语翻译成中文,只输出翻译结果:\n\n${msg.content}` },
],
max_tokens: 500,
}),
});
if (!response.ok) throw new Error(`翻译API错误: ${response.status}`);
const data = await response.json();
let result = data.choices[0].message.content.trim();
msg.translation = result || "翻译失败,请重试";
} catch (err) {
console.error("翻译失败:", err);
msg.translation = "翻译失败,请重试";
} finally {
msg.isTranslating = false;
}
};
//
const sendGreeting = async (scene) => {
isSending.value = true;
@ -188,6 +234,8 @@ const sendGreeting = async (scene) => {
audioUrl: null,
isPlaying: false,
isLoading: true,
translation: null,
isTranslating: false,
};
messages.value.push(greetingMsg);
await scrollToBottom();
@ -237,6 +285,13 @@ const scrollToBottom = async () => {
// TTS
// autoPlay: true
const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
console.log('[TTS] Starting synthesis for:', text?.substring(0, 50), 'msgId:', msgId);
if (!text || !text.trim()) {
console.warn('[TTS] Empty text, skipping');
return;
}
const payload = {
user: { uid: "speaking_" + Date.now() },
req_params: {
@ -249,6 +304,7 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
},
};
try {
const response = await fetch(DOUBAO_TTS_API_PATH, {
method: "POST",
headers: {
@ -261,7 +317,11 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`TTS HTTP ${response.status}`);
if (!response.ok) {
console.error('[TTS] HTTP error:', response.status, response.statusText);
throw new Error(`TTS HTTP ${response.status}`);
}
console.log('[TTS] API response OK');
const reader = response.body.getReader();
const decoder = new TextDecoder();
@ -299,9 +359,13 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
} catch (e) {}
}
if (audioChunks.length === 0) return;
if (audioChunks.length === 0) {
console.warn('[TTS] No audio chunks received');
return;
}
const totalLen = audioChunks.reduce((a, v) => a + v.length, 0);
console.log('[TTS] Audio chunks:', audioChunks.length, 'total bytes:', totalLen);
const allBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of audioChunks) {
@ -317,13 +381,21 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
const msg = messages.value.find((m) => m.id === msgId);
if (msg) msg.audioUrl = url;
console.log('[TTS] Blob URL created, autoPlay:', autoPlay);
if (autoPlay) {
playAudio(url, msgId);
}
} catch (ttsError) {
console.error('[TTS] Synthesis failed:', ttsError);
throw ttsError;
}
};
//
const playAudio = (url, msgId) => {
console.log('[Audio] Playing audio for msgId:', msgId, 'url:', url?.substring(0, 50));
if (currentAudioInstance) {
currentAudioInstance.pause();
currentAudioInstance = null;
@ -338,13 +410,22 @@ const playAudio = (url, msgId) => {
if (msg) msg.isPlaying = true;
audio.onended = () => {
console.log('[Audio] Playback ended for msgId:', msgId);
if (msg) msg.isPlaying = false;
if (currentAudioInstance === audio) currentAudioInstance = null;
};
audio.onerror = () => {
audio.onerror = (e) => {
console.error('[Audio] Playback error:', e, 'msgId:', msgId);
if (msg) msg.isPlaying = false;
};
audio.play();
audio.play().then(() => {
console.log('[Audio] Playback started successfully for msgId:', msgId);
}).catch((playError) => {
console.error('[Audio] Playback failed (likely blocked by browser):', playError);
if (msg) msg.isPlaying = false;
});
};
//
@ -383,6 +464,28 @@ const callArkAPI = async (history) => {
return data.choices[0].message.content.trim();
};
// Problem API
const callProblemAPI = async (history) => {
const response = await fetch(PROBLEM_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PROBLEM_API_KEY}`,
},
body: JSON.stringify({
model: PROBLEM_MODEL,
messages: [
{ role: "system", content: currentScene.value.systemPrompt },
...history,
],
max_tokens: ARK_MAX_TOKENS,
}),
});
if (!response.ok) throw new Error(`Problem API HTTP ${response.status}`);
const data = await response.json();
return data.choices[0].message.content.trim();
};
//
const getFallbackReply = () => {
const replies = fallbackReplies[activeScene.value] || fallbackReplies.free;
@ -406,6 +509,8 @@ const sendMessage = async () => {
audioUrl: null,
isPlaying: false,
isLoading: false,
translation: null,
isTranslating: false,
};
messages.value.push(userMsg);
await scrollToBottom();
@ -424,6 +529,8 @@ const sendMessage = async () => {
audioUrl: null,
isPlaying: false,
isLoading: true,
translation: null,
isTranslating: false,
};
messages.value.push(aiMsg);
await scrollToBottom();
@ -436,13 +543,21 @@ const sendMessage = async () => {
.map((m) => ({ role: m.role, content: m.content }));
let replyText = "";
try {
// 使 ARK API Problem API
if (ARK_API_KEY) {
replyText = await callArkAPI(
history.slice(0, -1).concat([{ role: "user", content: text }])
);
} else {
//
await new Promise((r) => setTimeout(r, 800));
// Problem APIDoubao-Seed-2.0-lite
replyText = await callProblemAPI(
history.slice(0, -1).concat([{ role: "user", content: text }])
);
}
} catch (apiErr) {
console.error("AI API error, fallback to preset replies:", apiErr);
// API 使
replyText = getFallbackReply();
}
@ -869,6 +984,32 @@ onUnmounted(() => {
</button>
</div>
</div>
<!-- 翻译按钮和翻译内容 -->
<div v-if="!msg.isLoading && msg.content" class="translate-section">
<button
class="translate-btn"
@click="translateMessage(msg)"
:disabled="msg.isTranslating"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
{{ msg.isTranslating ? '翻译中...' : (msg.translation ? '已翻译' : '翻译') }}
</button>
<div v-if="msg.translation" class="translation-text">
{{ msg.translation }}
</div>
</div>
</div>
<!-- 用户头像 -->
@ -1290,6 +1431,61 @@ onUnmounted(() => {
background: #a78bfa;
}
/* Translate Section */
.translate-section {
margin-top: 0.5rem;
}
.translate-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
padding: 0.35rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.translate-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.2);
}
.translate-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.translate-btn svg {
width: 14px;
height: 14px;
}
.translation-text {
margin-top: 0.5rem;
padding: 0.6rem 0.8rem;
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.15);
border-radius: 10px;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
animation: fadeSlideIn 0.3s ease-out;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Wave bars animation */
.wave-bars {
display: flex;