feat(speaking): 添加翻译功能并优化TTS错误处理
This commit is contained in:
parent
b993a94385
commit
f04821c6bf
|
|
@ -55,7 +55,7 @@ const features = ref([
|
|||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "AI智能解题",
|
||||
title: "AI引导解题",
|
||||
desc: "支持文本或图片上传题目,AI 以对话方式一步步引导思考,卡片式展示解题步骤,让解题过程清晰易懂,支持多学科通用解题。",
|
||||
class: "card-7",
|
||||
icon: "puzzle",
|
||||
|
|
|
|||
|
|
@ -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,19 +304,24 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
|||
},
|
||||
};
|
||||
|
||||
const response = await fetch(DOUBAO_TTS_API_PATH, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-App-Id": DOUBAO_APP_ID,
|
||||
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
|
||||
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
|
||||
"X-Api-Request-Id": generateUUID(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(DOUBAO_TTS_API_PATH, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-App-Id": DOUBAO_APP_ID,
|
||||
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
|
||||
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
|
||||
"X-Api-Request-Id": generateUUID(),
|
||||
},
|
||||
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 = "";
|
||||
if (ARK_API_KEY) {
|
||||
replyText = await callArkAPI(
|
||||
history.slice(0, -1).concat([{ role: "user", content: text }])
|
||||
);
|
||||
} else {
|
||||
// 模拟延迟
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
try {
|
||||
// 优先使用 ARK API,否则降级到 Problem API
|
||||
if (ARK_API_KEY) {
|
||||
replyText = await callArkAPI(
|
||||
history.slice(0, -1).concat([{ role: "user", content: text }])
|
||||
);
|
||||
} else {
|
||||
// 降级到 Problem API(Doubao-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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue