feat(speaking): 添加翻译功能并优化TTS错误处理
This commit is contained in:
parent
b993a94385
commit
f04821c6bf
|
|
@ -55,7 +55,7 @@ const features = ref([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
title: "AI智能解题",
|
title: "AI引导解题",
|
||||||
desc: "支持文本或图片上传题目,AI 以对话方式一步步引导思考,卡片式展示解题步骤,让解题过程清晰易懂,支持多学科通用解题。",
|
desc: "支持文本或图片上传题目,AI 以对话方式一步步引导思考,卡片式展示解题步骤,让解题过程清晰易懂,支持多学科通用解题。",
|
||||||
class: "card-7",
|
class: "card-7",
|
||||||
icon: "puzzle",
|
icon: "puzzle",
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,17 @@ import {
|
||||||
DOUBAO_TTS_API_PATH,
|
DOUBAO_TTS_API_PATH,
|
||||||
DOUBAO_AUDIO_FORMAT,
|
DOUBAO_AUDIO_FORMAT,
|
||||||
DOUBAO_SAMPLE_RATE,
|
DOUBAO_SAMPLE_RATE,
|
||||||
|
PROBLEM_API_KEY,
|
||||||
|
PROBLEM_API_URL,
|
||||||
|
PROBLEM_MODEL,
|
||||||
ARK_API_KEY,
|
ARK_API_KEY,
|
||||||
ARK_MODEL,
|
ARK_MODEL,
|
||||||
ARK_API_PATH,
|
ARK_API_PATH,
|
||||||
ARK_MAX_TOKENS,
|
ARK_MAX_TOKENS,
|
||||||
ARK_HISTORY_LIMIT,
|
ARK_HISTORY_LIMIT,
|
||||||
|
GRS_API_KEY,
|
||||||
|
EXAM_API_URL,
|
||||||
|
EXAM_MODEL,
|
||||||
} from "@/config/index.js";
|
} from "@/config/index.js";
|
||||||
import pako from "pako";
|
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) => {
|
const sendGreeting = async (scene) => {
|
||||||
isSending.value = true;
|
isSending.value = true;
|
||||||
|
|
@ -188,6 +234,8 @@ const sendGreeting = async (scene) => {
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
translation: null,
|
||||||
|
isTranslating: false,
|
||||||
};
|
};
|
||||||
messages.value.push(greetingMsg);
|
messages.value.push(greetingMsg);
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
|
|
@ -237,6 +285,13 @@ const scrollToBottom = async () => {
|
||||||
// TTS 合成并播放
|
// TTS 合成并播放
|
||||||
// autoPlay: 是否自动播放,默认为 true
|
// autoPlay: 是否自动播放,默认为 true
|
||||||
const synthesizeAndPlay = async (text, msgId, 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 = {
|
const payload = {
|
||||||
user: { uid: "speaking_" + Date.now() },
|
user: { uid: "speaking_" + Date.now() },
|
||||||
req_params: {
|
req_params: {
|
||||||
|
|
@ -249,6 +304,7 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch(DOUBAO_TTS_API_PATH, {
|
const response = await fetch(DOUBAO_TTS_API_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -261,7 +317,11 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
||||||
body: JSON.stringify(payload),
|
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 reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
@ -299,9 +359,13 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
||||||
} catch (e) {}
|
} 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);
|
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);
|
const allBytes = new Uint8Array(totalLen);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of audioChunks) {
|
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);
|
const msg = messages.value.find((m) => m.id === msgId);
|
||||||
if (msg) msg.audioUrl = url;
|
if (msg) msg.audioUrl = url;
|
||||||
|
|
||||||
|
console.log('[TTS] Blob URL created, autoPlay:', autoPlay);
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
playAudio(url, msgId);
|
playAudio(url, msgId);
|
||||||
}
|
}
|
||||||
|
} catch (ttsError) {
|
||||||
|
console.error('[TTS] Synthesis failed:', ttsError);
|
||||||
|
throw ttsError;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 播放音频
|
// 播放音频
|
||||||
const playAudio = (url, msgId) => {
|
const playAudio = (url, msgId) => {
|
||||||
|
console.log('[Audio] Playing audio for msgId:', msgId, 'url:', url?.substring(0, 50));
|
||||||
|
|
||||||
if (currentAudioInstance) {
|
if (currentAudioInstance) {
|
||||||
currentAudioInstance.pause();
|
currentAudioInstance.pause();
|
||||||
currentAudioInstance = null;
|
currentAudioInstance = null;
|
||||||
|
|
@ -338,13 +410,22 @@ const playAudio = (url, msgId) => {
|
||||||
if (msg) msg.isPlaying = true;
|
if (msg) msg.isPlaying = true;
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
|
console.log('[Audio] Playback ended for msgId:', msgId);
|
||||||
if (msg) msg.isPlaying = false;
|
if (msg) msg.isPlaying = false;
|
||||||
if (currentAudioInstance === audio) currentAudioInstance = null;
|
if (currentAudioInstance === audio) currentAudioInstance = null;
|
||||||
};
|
};
|
||||||
audio.onerror = () => {
|
|
||||||
|
audio.onerror = (e) => {
|
||||||
|
console.error('[Audio] Playback error:', e, 'msgId:', msgId);
|
||||||
if (msg) msg.isPlaying = false;
|
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();
|
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 getFallbackReply = () => {
|
||||||
const replies = fallbackReplies[activeScene.value] || fallbackReplies.free;
|
const replies = fallbackReplies[activeScene.value] || fallbackReplies.free;
|
||||||
|
|
@ -406,6 +509,8 @@ const sendMessage = async () => {
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
translation: null,
|
||||||
|
isTranslating: false,
|
||||||
};
|
};
|
||||||
messages.value.push(userMsg);
|
messages.value.push(userMsg);
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
|
|
@ -424,6 +529,8 @@ const sendMessage = async () => {
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
translation: null,
|
||||||
|
isTranslating: false,
|
||||||
};
|
};
|
||||||
messages.value.push(aiMsg);
|
messages.value.push(aiMsg);
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
|
|
@ -436,13 +543,21 @@ const sendMessage = async () => {
|
||||||
.map((m) => ({ role: m.role, content: m.content }));
|
.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
let replyText = "";
|
let replyText = "";
|
||||||
|
try {
|
||||||
|
// 优先使用 ARK API,否则降级到 Problem API
|
||||||
if (ARK_API_KEY) {
|
if (ARK_API_KEY) {
|
||||||
replyText = await callArkAPI(
|
replyText = await callArkAPI(
|
||||||
history.slice(0, -1).concat([{ role: "user", content: text }])
|
history.slice(0, -1).concat([{ role: "user", content: text }])
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 模拟延迟
|
// 降级到 Problem API(Doubao-Seed-2.0-lite)
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
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();
|
replyText = getFallbackReply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -869,6 +984,32 @@ onUnmounted(() => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 用户头像 -->
|
<!-- 用户头像 -->
|
||||||
|
|
@ -1290,6 +1431,61 @@ onUnmounted(() => {
|
||||||
background: #a78bfa;
|
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 animation */
|
||||||
.wave-bars {
|
.wave-bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue