Compare commits

..

No commits in common. "bb2452a8ec4cf75f6e36b59b489c13152716b831" and "fa52f2d021ee19bbd201ddbf34f7bc3d96350ee3" have entirely different histories.

2 changed files with 152 additions and 492 deletions

View File

@ -732,31 +732,6 @@ onUnmounted(() => {
flex-direction: column;
}
/* 自定义滚动条 */
.right-panel::-webkit-scrollbar {
width: 8px;
}
.right-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
margin: 4px 0;
}
.right-panel::-webkit-scrollbar-thumb {
background: rgba(20, 184, 166, 0.3);
border-radius: 4px;
transition: background 0.2s;
}
.right-panel::-webkit-scrollbar-thumb:hover {
background: rgba(20, 184, 166, 0.5);
}
.right-panel::-webkit-scrollbar-thumb:active {
background: rgba(20, 184, 166, 0.7);
}
/* Empty State */
.empty-state {
flex: 1;
@ -1145,6 +1120,7 @@ onUnmounted(() => {
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--card-border);
border-left: 3px solid #14b8a6;
background: rgba(20, 184, 166, 0.04);
}
.dim-icon {

View File

@ -1,19 +1,7 @@
<script setup>
import { ref, computed, nextTick, onUnmounted, onMounted } from "vue";
import { useRouter } from "vue-router";
import {
DOUBAO_APP_ID,
DOUBAO_ACCESS_TOKEN,
DOUBAO_RESOURCE_ID,
DOUBAO_TTS_API_PATH,
DOUBAO_AUDIO_FORMAT,
DOUBAO_SAMPLE_RATE,
ARK_API_KEY,
ARK_MODEL,
ARK_API_PATH,
ARK_MAX_TOKENS,
ARK_HISTORY_LIMIT,
} from "@/config/index.js";
import { DOUBAO_APP_ID, DOUBAO_ACCESS_TOKEN, DOUBAO_RESOURCE_ID, DOUBAO_TTS_API_PATH, DOUBAO_AUDIO_FORMAT, DOUBAO_SAMPLE_RATE, ARK_API_KEY, ARK_MODEL, ARK_API_PATH, ARK_MAX_TOKENS, ARK_HISTORY_LIMIT } from "@/config/index.js";
import pako from "pako";
const router = useRouter();
@ -24,8 +12,7 @@ const scenes = [
id: "school",
name: "校园生活",
emoji: "🏫",
greeting:
"Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
greeting: "Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
systemPrompt:
"You are a friendly English tutor for middle and high school students. Chat about school life topics like classes, homework, teachers, friends, and campus activities. Use simple, clear English suitable for teenagers. Keep responses to 2-3 sentences. Gently correct grammar mistakes and be encouraging.",
},
@ -33,8 +20,7 @@ const scenes = [
id: "hobby",
name: "兴趣爱好",
emoji: "🎮",
greeting:
"Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
greeting: "Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
systemPrompt:
"You are a friendly English tutor for middle and high school students. Discuss hobbies and interests like games, sports, music, art, and reading. Use casual, teen-friendly English. Keep responses to 2-3 sentences. Encourage the student to express themselves and gently fix any errors.",
},
@ -42,8 +28,7 @@ const scenes = [
id: "exam",
name: "英语考试",
emoji: "📝",
greeting:
"Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
greeting: "Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
systemPrompt:
"You are an English exam coach for middle and high school students. Practice common exam speaking tasks: describing pictures, expressing opinions, talking about daily life, and answering topic questions. Use language appropriate for school exams. Give brief feedback after each response. Keep your replies concise.",
},
@ -51,8 +36,7 @@ const scenes = [
id: "movie",
name: "影视讨论",
emoji: "🎬",
greeting:
"Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
greeting: "Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
systemPrompt:
"You are a fun English tutor for teenagers. Discuss movies, TV shows, animations, and pop culture. Use engaging, youth-friendly language. Ask about plots, characters, and opinions. Keep responses to 2-3 sentences and gently correct any grammar mistakes.",
},
@ -60,8 +44,7 @@ const scenes = [
id: "travel",
name: "旅游英语",
emoji: "✈️",
greeting:
"Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
greeting: "Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
systemPrompt:
"You are a helpful travel guide for teenage students. Role-play travel scenarios suitable for young learners: asking for directions, ordering at a restaurant, buying tickets, and checking into a hotel. Use simple and practical English. Keep responses short and easy to follow.",
},
@ -69,8 +52,7 @@ const scenes = [
id: "free",
name: "自由对话",
emoji: "💬",
greeting:
"Hey! I'm here to chat with you about anything you'd like! You can choose any topic — your dreams, daily life, thoughts, or just something fun. Feel free to express yourself. So, what would you like to talk about today?",
greeting: "Hey! I'm here to chat with you about anything you'd like! You can choose any topic — your dreams, daily life, thoughts, or just something fun. Feel free to express yourself. So, what would you like to talk about today?",
systemPrompt:
"You are a versatile English tutor for middle and high school students. Have open conversations on any topic the student wants to discuss. Be flexible, supportive, and encouraging. Use natural, teen-friendly English. Keep responses to 2-3 sentences. Gently correct grammar mistakes and help the student improve.",
},
@ -80,16 +62,8 @@ const scenes = [
const voiceOptions = [
{ id: "en_female_dacey_uranus_bigtts", name: "Dacey (美音女)", avatar: "👩‍🏫" },
{ id: "en_male_tim_uranus_bigtts", name: "Tim (美音男)", avatar: "👨‍🏫" },
{
id: "en_female_stokie_uranus_bigtts",
name: "Stokie (美音女)",
avatar: "👩‍💼",
},
{
id: "zh_female_yingyujiaoxue_uranus_bigtts",
name: "Tina (英音女)",
avatar: "👩‍🎓",
},
{ id: "en_female_stokie_uranus_bigtts", name: "Stokie (美音女)", avatar: "👩‍💼" },
{ id: "zh_female_yingyujiaoxue_uranus_bigtts", name: "Tina (英音女)", avatar: "👩‍🎓" },
];
const currentVoiceAvatar = computed(
@ -157,16 +131,14 @@ let asrInterimText = ref(""); // 实时识别中间结果
// UUID
const generateUUID = () => {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const currentScene = computed(() =>
scenes.find((s) => s.id === activeScene.value)
);
const currentScene = computed(() => scenes.find((s) => s.id === activeScene.value));
//
onMounted(() => {
@ -181,23 +153,13 @@ onMounted(() => {
const sendGreeting = async (scene) => {
isSending.value = true;
const greetingId = ++msgIdCounter;
const greetingMsg = {
id: greetingId,
role: "assistant",
content: "",
audioUrl: null,
isPlaying: false,
isLoading: true,
};
const greetingMsg = { id: greetingId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
messages.value.push(greetingMsg);
await scrollToBottom();
// ""
await new Promise((r) => setTimeout(r, 500));
const msg = messages.value.find((m) => m.id === greetingId);
if (msg) {
msg.content = scene.greeting;
msg.isLoading = false;
}
if (msg) { msg.content = scene.greeting; msg.isLoading = false; }
await scrollToBottom();
try {
await synthesizeAndPlay(scene.greeting, greetingId);
@ -242,10 +204,7 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
req_params: {
text,
speaker: selectedVoice.value,
audio_params: {
format: DOUBAO_AUDIO_FORMAT,
sample_rate: DOUBAO_SAMPLE_RATE,
},
audio_params: { format: DOUBAO_AUDIO_FORMAT, sample_rate: DOUBAO_SAMPLE_RATE },
},
};
@ -304,10 +263,7 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
const totalLen = audioChunks.reduce((a, v) => a + v.length, 0);
const allBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of audioChunks) {
allBytes.set(chunk, offset);
offset += chunk.length;
}
for (const chunk of audioChunks) { allBytes.set(chunk, offset); offset += chunk.length; }
const blob = new Blob([allBytes], { type: "audio/mp3" });
const url = URL.createObjectURL(blob);
@ -351,10 +307,7 @@ const playAudio = (url, msgId) => {
const replayMessage = (msg) => {
if (!msg.audioUrl) return;
if (msg.isPlaying) {
if (currentAudioInstance) {
currentAudioInstance.pause();
currentAudioInstance = null;
}
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
msg.isPlaying = false;
return;
}
@ -367,7 +320,7 @@ const callArkAPI = async (history) => {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ARK_API_KEY}`,
"Authorization": `Bearer ${ARK_API_KEY}`,
},
body: JSON.stringify({
model: ARK_MODEL,
@ -399,32 +352,16 @@ const sendMessage = async () => {
//
const userMsgId = ++msgIdCounter;
const userMsg = {
id: userMsgId,
role: "user",
content: text,
audioUrl: null,
isPlaying: false,
isLoading: false,
};
const userMsg = { id: userMsgId, role: "user", content: text, audioUrl: null, isPlaying: false, isLoading: false };
messages.value.push(userMsg);
await scrollToBottom();
// TTS
synthesizeAndPlay(text, userMsgId, false).catch((e) =>
console.error("User TTS error:", e)
);
synthesizeAndPlay(text, userMsgId, false).catch((e) => console.error("User TTS error:", e));
// AI loading
const aiMsgId = ++msgIdCounter;
const aiMsg = {
id: aiMsgId,
role: "assistant",
content: "",
audioUrl: null,
isPlaying: false,
isLoading: true,
};
const aiMsg = { id: aiMsgId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
messages.value.push(aiMsg);
await scrollToBottom();
@ -437,9 +374,7 @@ const sendMessage = async () => {
let replyText = "";
if (ARK_API_KEY) {
replyText = await callArkAPI(
history.slice(0, -1).concat([{ role: "user", content: text }])
);
replyText = await callArkAPI(history.slice(0, -1).concat([{ role: "user", content: text }]));
} else {
//
await new Promise((r) => setTimeout(r, 800));
@ -448,10 +383,7 @@ const sendMessage = async () => {
// AI
const msg = messages.value.find((m) => m.id === aiMsgId);
if (msg) {
msg.content = replyText;
msg.isLoading = false;
}
if (msg) { msg.content = replyText; msg.isLoading = false; }
await scrollToBottom();
// TTS
@ -459,10 +391,7 @@ const sendMessage = async () => {
} catch (err) {
console.error("Speaking error:", err);
const msg = messages.value.find((m) => m.id === aiMsgId);
if (msg) {
msg.content = "Sorry, something went wrong. Please try again.";
msg.isLoading = false;
}
if (msg) { msg.content = "Sorry, something went wrong. Please try again."; msg.isLoading = false; }
} finally {
isSending.value = false;
await scrollToBottom();
@ -493,13 +422,7 @@ const float32ToInt16Bytes = (float32Array) => {
// flags: 0x00=, 0x02=
// serialization: 0x01=JSON, 0x00=Raw
// compression: 0x01=Gzip, 0x00=
const buildFrame = (
messageType,
flags,
serialization,
compression,
payload
) => {
const buildFrame = (messageType, flags, serialization, compression, payload) => {
const header = new Uint8Array([
(0x01 << 4) | 0x01, // byte0: version=1, headerSize=1(×4=4)
(messageType << 4) | flags, // byte1: messageType | flags
@ -530,9 +453,7 @@ const parseServerFrame = (buffer) => {
if (msgType === 0x0f) {
const errCode = view.getUint32(4, false);
const errMsgSize = view.getUint32(8, false);
const errMsg = new TextDecoder().decode(
new Uint8Array(buffer, 12, errMsgSize)
);
const errMsg = new TextDecoder().decode(new Uint8Array(buffer, 12, errMsgSize));
return { msgType, flags, error: true, code: errCode, message: errMsg };
}
@ -612,7 +533,7 @@ const startRecording = async () => {
try {
// 1. Vite Header WS Header
const wsUrl = `wss://${location.host}/asr-ws/api/v3/sauc/bigmodel`;
const wsUrl = `ws://${location.host}/asr-ws/api/v3/sauc/bigmodel`;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
asrWs = ws;
@ -685,9 +606,7 @@ const startRecording = async () => {
console.error("ASR WebSocket error");
stopRecording();
asrStatus.value = "error";
setTimeout(() => {
asrStatus.value = "";
}, 3000);
setTimeout(() => { asrStatus.value = ""; }, 3000);
};
ws.onclose = () => {
@ -697,17 +616,11 @@ const startRecording = async () => {
// 4. PCM
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
asrMediaStream = stream;
const AudioContextClass =
window.AudioContext || window["webkitAudioContext"];
const AudioContextClass = window.AudioContext || window["webkitAudioContext"];
const audioCtx = new AudioContextClass({ sampleRate: 16000 });
asrAudioContext = audioCtx;
@ -717,8 +630,7 @@ const startRecording = async () => {
asrScriptProcessor = processor;
processor.onaudioprocess = (e) => {
if (!isRecording.value || !asrWs || asrWs.readyState !== WebSocket.OPEN)
return;
if (!isRecording.value || !asrWs || asrWs.readyState !== WebSocket.OPEN) return;
const pcmBytes = float32ToInt16Bytes(e.inputBuffer.getChannelData(0));
// Audio Only Request(0x02), flags=0x00, Raw(0x00), Gzip(0x01)
const compressed = pako.gzip(pcmBytes);
@ -734,9 +646,7 @@ const startRecording = async () => {
console.error("ASR start error:", err);
stopRecording();
asrStatus.value = "error";
setTimeout(() => {
asrStatus.value = "";
}, 3000);
setTimeout(() => { asrStatus.value = ""; }, 3000);
if (err.name === "NotAllowedError") {
alert("麦克风权限被拒绝,请在浏览器设置中允许访问麦克风");
}
@ -764,27 +674,15 @@ onUnmounted(() => {
<!-- 顶部导航 -->
<nav class="nav-bar">
<button @click="router.push('/')" class="back-btn">
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
返回首页
</button>
<div class="nav-title">AI 口语对话</div>
<div class="voice-select-wrap">
<select v-model="selectedVoice" class="voice-select">
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">
{{ v.name }}
</option>
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">{{ v.name }}</option>
</select>
</div>
</nav>
@ -813,16 +711,9 @@ onUnmounted(() => {
</div>
<!-- 消息列表 -->
<div
v-for="msg in messages"
:key="msg.id"
class="message-row"
:class="msg.role"
>
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="msg.role">
<!-- AI 头像 -->
<div v-if="msg.role === 'assistant'" class="avatar">
{{ currentVoiceAvatar }}
</div>
<div v-if="msg.role === 'assistant'" class="avatar">{{ currentVoiceAvatar }}</div>
<div class="bubble-wrap">
<!-- 气泡 -->
@ -833,37 +724,16 @@ onUnmounted(() => {
</div>
<span v-else>{{ msg.content }}</span>
<!-- 消息播放按钮AI 和用户消息均显示 -->
<div
v-if="!msg.isLoading && msg.content"
class="audio-controls"
:class="{ 'user-audio-controls': msg.role === 'user' }"
>
<div v-if="!msg.isLoading && msg.content" class="audio-controls" :class="{ 'user-audio-controls': msg.role === 'user' }">
<span v-if="!msg.audioUrl" class="tts-hint">合成中...</span>
<button
v-else
class="play-btn"
:class="{ playing: msg.isPlaying }"
@click="replayMessage(msg)"
:title="msg.isPlaying ? '暂停' : '播放'"
>
<button v-else class="play-btn" :class="{ playing: msg.isPlaying }" @click="replayMessage(msg)" :title="msg.isPlaying ? '暂停' : '播放'">
<!-- 播放中音波动画 -->
<div v-if="msg.isPlaying" class="wave-bars">
<span></span><span></span><span></span><span></span>
</div>
<!-- 未播放播放图标 -->
<svg
v-else
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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
/>
<svg v-else 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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
</button>
</div>
@ -880,11 +750,7 @@ onUnmounted(() => {
<textarea
v-model="inputText"
class="input-box"
:placeholder="
asrInterimText
? asrInterimText
: '输入英语内容,按 Enter 发送Shift+Enter 换行...'
"
:placeholder="asrInterimText ? asrInterimText : '输入英语内容,按 Enter 发送Shift+Enter 换行...'"
:disabled="isSending"
@keydown="handleKeydown"
rows="1"
@ -892,11 +758,7 @@ onUnmounted(() => {
<!-- 麦克风按钮 -->
<button
class="mic-btn"
:class="{
recording: isRecording,
error: asrStatus === 'error',
connecting: asrStatus === 'connecting',
}"
:class="{ recording: isRecording, error: asrStatus === 'error', connecting: asrStatus === 'connecting' }"
@click="startRecording"
:title="isRecording ? '停止录音' : '语音输入'"
:disabled="isSending"
@ -906,46 +768,18 @@ onUnmounted(() => {
<!-- 录音中麦克风 + 脉冲 -->
<template v-else-if="isRecording">
<div class="mic-pulse"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
/>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
</svg>
</template>
<!-- 默认麦克风图标 -->
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
/>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
</svg>
</button>
<button
class="send-btn"
:disabled="!inputText.trim() || isSending"
@click="sendMessage"
>
<svg
v-if="!isSending"
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="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
<button class="send-btn" :disabled="!inputText.trim() || isSending" @click="sendMessage">
<svg v-if="!isSending" 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="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
<div v-else class="send-spinner"></div>
</button>
@ -954,9 +788,7 @@ onUnmounted(() => {
</template>
<style scoped>
* {
box-sizing: border-box;
}
* { box-sizing: border-box; }
.page-container {
max-width: 900px;
@ -989,14 +821,8 @@ onUnmounted(() => {
transition: all 0.2s;
white-space: nowrap;
}
.back-btn:hover {
background: var(--card-bg);
color: var(--text-primary);
}
.back-btn svg {
width: 20px;
height: 20px;
}
.back-btn:hover { background: var(--card-bg); color: var(--text-primary); }
.back-btn svg { width: 20px; height: 20px; }
.nav-title {
flex: 1;
@ -1005,15 +831,11 @@ onUnmounted(() => {
font-weight: 600;
}
.voice-select-wrap {
min-width: 180px;
}
.voice-select-wrap { min-width: 180px; }
.voice-select {
appearance: none;
-webkit-appearance: none;
background: var(--card-bg)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")
no-repeat right 0.6rem center;
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 0.6rem center;
border: 1px solid var(--card-border);
color: var(--text-primary);
padding: 0.4rem 2rem 0.4rem 0.75rem;
@ -1024,9 +846,7 @@ onUnmounted(() => {
width: 100%;
transition: border-color 0.2s;
}
.voice-select:hover {
border-color: var(--accent-1);
}
.voice-select:hover { border-color: var(--accent-1); }
.voice-select option {
background: #1e1e2e;
color: var(--text-primary);
@ -1040,19 +860,14 @@ onUnmounted(() => {
overflow-x: auto;
padding-bottom: 0.25rem;
}
.scene-tabs::-webkit-scrollbar {
height: 4px;
}
.scene-tabs::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.scene-tabs::-webkit-scrollbar { height: 4px; }
.scene-tabs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.scene-tab {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(255, 255, 255, 0.05);
background: rgba(255,255,255,0.05);
border: 1px solid transparent;
color: var(--text-secondary);
padding: 0.5rem 1rem;
@ -1062,18 +877,13 @@ onUnmounted(() => {
font-size: 0.9rem;
transition: all 0.2s;
}
.scene-tab:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.scene-tab:hover { background: rgba(255,255,255,0.1); color: var(--text-primary); }
.scene-tab.active {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99,102,241,0.15);
border-color: rgba(99,102,241,0.4);
color: var(--accent-1);
}
.scene-emoji {
font-size: 1rem;
}
.scene-emoji { font-size: 1rem; }
/* Chat Area */
.chat-area {
@ -1085,13 +895,8 @@ onUnmounted(() => {
gap: 1.25rem;
min-height: 0;
}
.chat-area::-webkit-scrollbar {
width: 6px;
}
.chat-area::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.chat-area::-webkit-scrollbar { width: 6px; }
.chat-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
/* Welcome */
.welcome-hint {
@ -1105,18 +910,9 @@ onUnmounted(() => {
text-align: center;
padding: 3rem 0;
}
.welcome-icon {
font-size: 3.5rem;
}
.welcome-hint h3 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
}
.welcome-hint p {
margin: 0;
font-size: 0.95rem;
}
.welcome-icon { font-size: 3.5rem; }
.welcome-hint h3 { margin: 0; font-size: 1.25rem; color: var(--text-primary); }
.welcome-hint p { margin: 0; font-size: 0.95rem; }
/* Message Row */
.message-row {
@ -1127,19 +923,15 @@ onUnmounted(() => {
.message-row.user {
justify-content: flex-end;
}
.message-row.user .avatar {
order: 2;
}
.message-row.user .bubble-wrap {
order: 1;
}
.message-row.user .avatar { order: 2; }
.message-row.user .bubble-wrap { order: 1; }
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
display: flex;
align-items: center;
justify-content: center;
@ -1147,10 +939,7 @@ onUnmounted(() => {
flex-shrink: 0;
order: 0;
}
.user-avatar {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
}
.user-avatar { background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.3); }
.bubble-wrap {
display: flex;
@ -1158,9 +947,7 @@ onUnmounted(() => {
max-width: 70%;
order: 1;
}
.message-row.user .bubble-wrap {
align-items: flex-end;
}
.message-row.user .bubble-wrap { align-items: flex-end; }
.bubble {
padding: 0.875rem 1.125rem;
@ -1185,14 +972,8 @@ onUnmounted(() => {
}
@keyframes bubbleIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Loading dots */
@ -1203,29 +984,16 @@ onUnmounted(() => {
padding: 0.25rem 0;
}
.loading-dots span {
width: 8px;
height: 8px;
width: 8px; height: 8px;
background: var(--text-secondary);
border-radius: 50%;
animation: dotBounce 1.2s infinite ease-in-out;
}
.loading-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots span:nth-child(3) {
animation-delay: 0.4s;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotBounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* Audio Controls */
@ -1239,11 +1007,10 @@ onUnmounted(() => {
}
.play-btn {
width: 30px;
height: 30px;
width: 30px; height: 30px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
color: var(--accent-1);
display: flex;
align-items: center;
@ -1251,43 +1018,22 @@ onUnmounted(() => {
cursor: pointer;
transition: all 0.2s;
}
.play-btn:hover {
background: rgba(99, 102, 241, 0.3);
transform: scale(1.1);
}
.play-btn.playing {
background: rgba(99, 102, 241, 0.25);
border-color: var(--accent-1);
}
.play-btn svg {
width: 14px;
height: 14px;
}
.play-btn:hover { background: rgba(99,102,241,0.3); transform: scale(1.1); }
.play-btn.playing { background: rgba(99,102,241,0.25); border-color: var(--accent-1); }
.play-btn svg { width: 14px; height: 14px; }
.tts-hint {
font-size: 0.75rem;
color: var(--text-secondary);
}
.tts-hint { font-size: 0.75rem; color: var(--text-secondary); }
/* 用户消息播放按钮右对齐 */
.user-audio-controls {
justify-content: flex-end;
}
.user-audio-controls { justify-content: flex-end; }
.user-audio-controls .play-btn {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.4);
background: rgba(139,92,246,0.2);
border-color: rgba(139,92,246,0.4);
color: #a78bfa;
}
.user-audio-controls .play-btn:hover {
background: rgba(139, 92, 246, 0.35);
}
.user-audio-controls .play-btn.playing {
background: rgba(139, 92, 246, 0.3);
border-color: #a78bfa;
}
.user-audio-controls .wave-bars span {
background: #a78bfa;
}
.user-audio-controls .play-btn:hover { background: rgba(139,92,246,0.35); }
.user-audio-controls .play-btn.playing { background: rgba(139,92,246,0.3); border-color: #a78bfa; }
.user-audio-controls .wave-bars span { background: #a78bfa; }
/* Wave bars animation */
.wave-bars {
@ -1302,22 +1048,12 @@ onUnmounted(() => {
border-radius: 2px;
animation: waveBounce 0.6s infinite alternate;
}
.wave-bars span:nth-child(2) {
animation-delay: 0.15s;
}
.wave-bars span:nth-child(3) {
animation-delay: 0.3s;
}
.wave-bars span:nth-child(4) {
animation-delay: 0.45s;
}
.wave-bars span:nth-child(2) { animation-delay: 0.15s; }
.wave-bars span:nth-child(3) { animation-delay: 0.3s; }
.wave-bars span:nth-child(4) { animation-delay: 0.45s; }
@keyframes waveBounce {
from {
height: 3px;
}
to {
height: 14px;
}
from { height: 3px; }
to { height: 14px; }
}
/* Input Area */
@ -1346,17 +1082,11 @@ onUnmounted(() => {
max-height: 120px;
overflow-y: auto;
}
.input-box:focus {
border-color: var(--accent-1);
}
.input-box:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input-box:focus { border-color: var(--accent-1); }
.input-box:disabled { opacity: 0.6; cursor: not-allowed; }
.send-btn {
width: 46px;
height: 46px;
width: 46px; height: 46px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
border: none;
@ -1367,43 +1097,27 @@ onUnmounted(() => {
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
}
.send-btn:hover:not(:disabled) {
transform: scale(1.08);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.5);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.send-btn svg {
width: 20px;
height: 20px;
box-shadow: 0 4px 12px rgba(99,102,241,0.35);
}
.send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 16px rgba(99,102,241,0.5); }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.send-btn svg { width: 20px; height: 20px; }
.send-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
width: 18px; height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Mic Button */
.mic-btn {
position: relative;
width: 46px;
height: 46px;
width: 46px; height: 46px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
background: rgba(255,255,255,0.07);
border: 1px solid var(--card-border);
color: var(--text-secondary);
display: flex;
@ -1414,43 +1128,29 @@ onUnmounted(() => {
flex-shrink: 0;
overflow: hidden;
}
.mic-btn svg {
width: 20px;
height: 20px;
position: relative;
z-index: 1;
}
.mic-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.2);
}
.mic-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mic-btn svg { width: 20px; height: 20px; position: relative; z-index: 1; }
.mic-btn:hover:not(:disabled) { background: rgba(255,255,255,0.12); color: var(--text-primary); border-color: rgba(255,255,255,0.2); }
.mic-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 录音中状态 */
.mic-btn.recording {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.5);
background: rgba(239,68,68,0.15);
border-color: rgba(239,68,68,0.5);
color: #ef4444;
}
.mic-btn.recording:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
}
.mic-btn.recording:hover:not(:disabled) { background: rgba(239,68,68,0.25); }
/* 连接中状态 */
.mic-btn.connecting {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251,191,36,0.1);
border-color: rgba(251,191,36,0.4);
color: #fbbf24;
}
/* 错误状态 */
.mic-btn.error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239,68,68,0.1);
border-color: rgba(239,68,68,0.4);
color: #ef4444;
}
@ -1459,44 +1159,28 @@ onUnmounted(() => {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(239, 68, 68, 0.2);
background: rgba(239,68,68,0.2);
animation: micPulse 1.2s ease-out infinite;
}
@keyframes micPulse {
0% {
transform: scale(0.85);
opacity: 0.8;
}
70% {
transform: scale(1.15);
opacity: 0;
}
100% {
transform: scale(0.85);
opacity: 0;
}
0% { transform: scale(0.85); opacity: 0.8; }
70% { transform: scale(1.15); opacity: 0; }
100% { transform: scale(0.85); opacity: 0; }
}
/* 连接中旋转 */
.mic-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(251, 191, 36, 0.3);
width: 18px; height: 18px;
border: 2px solid rgba(251,191,36,0.3);
border-top-color: #fbbf24;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@media (max-width: 600px) {
.page-container {
padding: 1rem 1rem 0;
}
.bubble-wrap {
max-width: 85%;
}
.nav-title {
font-size: 1.2rem;
}
.page-container { padding: 1rem 1rem 0; }
.bubble-wrap { max-width: 85%; }
.nav-title { font-size: 1.2rem; }
}
</style>