diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue index a45bdb7..1ccac13 100644 --- a/src/views/HomePage.vue +++ b/src/views/HomePage.vue @@ -55,7 +55,7 @@ const features = ref([ }, { id: 7, - title: "AI智能解题", + title: "AI引导解题", desc: "支持文本或图片上传题目,AI 以对话方式一步步引导思考,卡片式展示解题步骤,让解题过程清晰易懂,支持多学科通用解题。", class: "card-7", icon: "puzzle", diff --git a/src/views/Speaking.vue b/src/views/Speaking.vue index b70c65c..d2f7c5b 100644 --- a/src/views/Speaking.vue +++ b/src/views/Speaking.vue @@ -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(() => { + +
+ +
+ {{ msg.translation }} +
+
@@ -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;