@ -1,7 +1,19 @@
< script setup >
< script setup >
import { ref , computed , nextTick , onUnmounted , onMounted } from "vue" ;
import { ref , computed , nextTick , onUnmounted , onMounted } from "vue" ;
import { useRouter } from "vue-router" ;
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" ;
import pako from "pako" ;
const router = useRouter ( ) ;
const router = useRouter ( ) ;
@ -12,7 +24,8 @@ const scenes = [
id : "school" ,
id : "school" ,
name : "校园生活" ,
name : "校园生活" ,
emoji : "🏫" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -20,7 +33,8 @@ const scenes = [
id : "hobby" ,
id : "hobby" ,
name : "兴趣爱好" ,
name : "兴趣爱好" ,
emoji : "🎮" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -28,7 +42,8 @@ const scenes = [
id : "exam" ,
id : "exam" ,
name : "英语考试" ,
name : "英语考试" ,
emoji : "📝" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -36,7 +51,8 @@ const scenes = [
id : "movie" ,
id : "movie" ,
name : "影视讨论" ,
name : "影视讨论" ,
emoji : "🎬" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -44,7 +60,8 @@ const scenes = [
id : "travel" ,
id : "travel" ,
name : "旅游英语" ,
name : "旅游英语" ,
emoji : "✈️" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -52,7 +69,8 @@ const scenes = [
id : "free" ,
id : "free" ,
name : "自由对话" ,
name : "自由对话" ,
emoji : "💬" ,
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 :
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." ,
"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." ,
} ,
} ,
@ -62,8 +80,16 @@ const scenes = [
const voiceOptions = [
const voiceOptions = [
{ id : "en_female_dacey_uranus_bigtts" , name : "Dacey (美音女)" , avatar : "👩🏫" } ,
{ id : "en_female_dacey_uranus_bigtts" , name : "Dacey (美音女)" , avatar : "👩🏫" } ,
{ id : "en_male_tim_uranus_bigtts" , name : "Tim (美音男)" , 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 (
const currentVoiceAvatar = computed (
@ -131,14 +157,16 @@ let asrInterimText = ref(""); // 实时识别中间结果
/ / 兼 容 的 U U I D 生 成 函 数
/ / 兼 容 的 U U I D 生 成 函 数
const generateUUID = ( ) => {
const generateUUID = ( ) => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' . replace ( /[xy]/g , ( c ) => {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" . replace ( /[xy]/g , ( c ) => {
const r = Math . random ( ) * 16 | 0 ;
const r = ( Math . random ( ) * 16 ) | 0 ;
const v = c === 'x' ? r : ( r & 0x3 | 0x8 ) ;
const v = c === "x" ? r : ( r & 0x3 ) | 0x8 ;
return v . toString ( 16 ) ;
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 ( ( ) => {
onMounted ( ( ) => {
@ -153,13 +181,23 @@ onMounted(() => {
const sendGreeting = async ( scene ) => {
const sendGreeting = async ( scene ) => {
isSending . value = true ;
isSending . value = true ;
const greetingId = ++ msgIdCounter ;
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 ) ;
messages . value . push ( greetingMsg ) ;
await scrollToBottom ( ) ;
await scrollToBottom ( ) ;
/ / 短 暂 延 迟 模 拟 " 思 考 "
/ / 短 暂 延 迟 模 拟 " 思 考 "
await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
const msg = messages . value . find ( ( m ) => m . id === greetingId ) ;
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 ( ) ;
await scrollToBottom ( ) ;
try {
try {
await synthesizeAndPlay ( scene . greeting , greetingId ) ;
await synthesizeAndPlay ( scene . greeting , greetingId ) ;
@ -204,7 +242,10 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
req _params : {
req _params : {
text ,
text ,
speaker : selectedVoice . value ,
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 ,
} ,
} ,
} ,
} ;
} ;
@ -263,7 +304,10 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
const totalLen = audioChunks . reduce ( ( a , v ) => a + v . length , 0 ) ;
const totalLen = audioChunks . reduce ( ( a , v ) => a + v . length , 0 ) ;
const allBytes = new Uint8Array ( totalLen ) ;
const allBytes = new Uint8Array ( totalLen ) ;
let offset = 0 ;
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 blob = new Blob ( [ allBytes ] , { type : "audio/mp3" } ) ;
const url = URL . createObjectURL ( blob ) ;
const url = URL . createObjectURL ( blob ) ;
@ -307,7 +351,10 @@ const playAudio = (url, msgId) => {
const replayMessage = ( msg ) => {
const replayMessage = ( msg ) => {
if ( ! msg . audioUrl ) return ;
if ( ! msg . audioUrl ) return ;
if ( msg . isPlaying ) {
if ( msg . isPlaying ) {
if ( currentAudioInstance ) { currentAudioInstance . pause ( ) ; currentAudioInstance = null ; }
if ( currentAudioInstance ) {
currentAudioInstance . pause ( ) ;
currentAudioInstance = null ;
}
msg . isPlaying = false ;
msg . isPlaying = false ;
return ;
return ;
}
}
@ -320,7 +367,7 @@ const callArkAPI = async (history) => {
method : "POST" ,
method : "POST" ,
headers : {
headers : {
"Content-Type" : "application/json" ,
"Content-Type" : "application/json" ,
"Authorization" : ` Bearer ${ ARK _API _KEY } ` ,
Authorization : ` Bearer ${ ARK _API _KEY } ` ,
} ,
} ,
body : JSON . stringify ( {
body : JSON . stringify ( {
model : ARK _MODEL ,
model : ARK _MODEL ,
@ -352,16 +399,32 @@ const sendMessage = async () => {
/ / 插 入 用 户 消 息
/ / 插 入 用 户 消 息
const userMsgId = ++ msgIdCounter ;
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 ) ;
messages . value . push ( userMsg ) ;
await scrollToBottom ( ) ;
await scrollToBottom ( ) ;
/ / 用 户 消 息 T T S ( 不 阻 塞 主 流 程 , 不 自 动 播 放 )
/ / 用 户 消 息 T T S ( 不 阻 塞 主 流 程 , 不 自 动 播 放 )
synthesizeAndPlay ( text , userMsgId , false ) . catch ( ( e ) => console . error ( "User TTS error:" , e ) ) ;
synthesizeAndPlay ( text , userMsgId , false ) . catch ( ( e ) =>
console . error ( "User TTS error:" , e )
) ;
/ / 插 入 A I l o a d i n g 占 位
/ / 插 入 A I l o a d i n g 占 位
const aiMsgId = ++ msgIdCounter ;
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 ) ;
messages . value . push ( aiMsg ) ;
await scrollToBottom ( ) ;
await scrollToBottom ( ) ;
@ -374,7 +437,9 @@ const sendMessage = async () => {
let replyText = "" ;
let replyText = "" ;
if ( ARK _API _KEY ) {
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 {
} else {
/ / 模 拟 延 迟
/ / 模 拟 延 迟
await new Promise ( ( r ) => setTimeout ( r , 800 ) ) ;
await new Promise ( ( r ) => setTimeout ( r , 800 ) ) ;
@ -383,7 +448,10 @@ const sendMessage = async () => {
/ / 更 新 A I 消 息 内 容
/ / 更 新 A I 消 息 内 容
const msg = messages . value . find ( ( m ) => m . id === aiMsgId ) ;
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 ( ) ;
await scrollToBottom ( ) ;
/ / T T S 合 成
/ / T T S 合 成
@ -391,7 +459,10 @@ const sendMessage = async () => {
} catch ( err ) {
} catch ( err ) {
console . error ( "Speaking error:" , err ) ;
console . error ( "Speaking error:" , err ) ;
const msg = messages . value . find ( ( m ) => m . id === aiMsgId ) ;
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 {
} finally {
isSending . value = false ;
isSending . value = false ;
await scrollToBottom ( ) ;
await scrollToBottom ( ) ;
@ -422,7 +493,13 @@ const float32ToInt16Bytes = (float32Array) => {
/ / f l a g s : 0 x 0 0 = 普 通 包 , 0 x 0 2 = 最 后 一 包
/ / f l a g s : 0 x 0 0 = 普 通 包 , 0 x 0 2 = 最 后 一 包
/ / s e r i a l i z a t i o n : 0 x 0 1 = J S O N , 0 x 0 0 = R a w
/ / s e r i a l i z a t i o n : 0 x 0 1 = J S O N , 0 x 0 0 = R a w
/ / c o m p r e s s i o n : 0 x 0 1 = G z i p , 0 x 0 0 = 无 压 缩
/ / c o m p r e s s i o n : 0 x 0 1 = G z i p , 0 x 0 0 = 无 压 缩
const buildFrame = ( messageType , flags , serialization , compression , payload ) => {
const buildFrame = (
messageType ,
flags ,
serialization ,
compression ,
payload
) => {
const header = new Uint8Array ( [
const header = new Uint8Array ( [
( 0x01 << 4 ) | 0x01 , / / b y t e 0 : v e r s i o n = 1 , h e a d e r S i z e = 1 ( × 4 = 4 字 节 )
( 0x01 << 4 ) | 0x01 , / / b y t e 0 : v e r s i o n = 1 , h e a d e r S i z e = 1 ( × 4 = 4 字 节 )
( messageType << 4 ) | flags , / / b y t e 1 : m e s s a g e T y p e | f l a g s
( messageType << 4 ) | flags , / / b y t e 1 : m e s s a g e T y p e | f l a g s
@ -453,7 +530,9 @@ const parseServerFrame = (buffer) => {
if ( msgType === 0x0f ) {
if ( msgType === 0x0f ) {
const errCode = view . getUint32 ( 4 , false ) ;
const errCode = view . getUint32 ( 4 , false ) ;
const errMsgSize = view . getUint32 ( 8 , 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 } ;
return { msgType , flags , error : true , code : errCode , message : errMsg } ;
}
}
@ -533,7 +612,7 @@ const startRecording = async () => {
try {
try {
/ / 1 . 通 过 V i t e 代 理 连 接 ( 代 理 层 自 动 注 入 鉴 权 H e a d e r , 绕 过 浏 览 器 W S 不 支 持 自 定 义 H e a d e r 的 限 制 )
/ / 1 . 通 过 V i t e 代 理 连 接 ( 代 理 层 自 动 注 入 鉴 权 H e a d e r , 绕 过 浏 览 器 W S 不 支 持 自 定 义 H e a d e r 的 限 制 )
const wsUrl = ` ws ://${ location . host } /asr-ws/api/v3/sauc/bigmodel ` ;
const wsUrl = ` ws s ://${ location . host } /asr-ws/api/v3/sauc/bigmodel ` ;
const ws = new WebSocket ( wsUrl ) ;
const ws = new WebSocket ( wsUrl ) ;
ws . binaryType = "arraybuffer" ;
ws . binaryType = "arraybuffer" ;
asrWs = ws ;
asrWs = ws ;
@ -606,7 +685,9 @@ const startRecording = async () => {
console . error ( "ASR WebSocket error" ) ;
console . error ( "ASR WebSocket error" ) ;
stopRecording ( ) ;
stopRecording ( ) ;
asrStatus . value = "error" ;
asrStatus . value = "error" ;
setTimeout ( ( ) => { asrStatus . value = "" ; } , 3000 ) ;
setTimeout ( ( ) => {
asrStatus . value = "" ;
} , 3000 ) ;
} ;
} ;
ws . onclose = ( ) => {
ws . onclose = ( ) => {
@ -616,11 +697,17 @@ const startRecording = async () => {
/ / 4 . 获 取 麦 克 风 并 采 集 P C M
/ / 4 . 获 取 麦 克 风 并 采 集 P C M
const stream = await navigator . mediaDevices . getUserMedia ( {
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 ;
asrMediaStream = stream ;
const AudioContextClass = window . AudioContext || window [ "webkitAudioContext" ] ;
const AudioContextClass =
window . AudioContext || window [ "webkitAudioContext" ] ;
const audioCtx = new AudioContextClass ( { sampleRate : 16000 } ) ;
const audioCtx = new AudioContextClass ( { sampleRate : 16000 } ) ;
asrAudioContext = audioCtx ;
asrAudioContext = audioCtx ;
@ -630,7 +717,8 @@ const startRecording = async () => {
asrScriptProcessor = processor ;
asrScriptProcessor = processor ;
processor . onaudioprocess = ( e ) => {
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 ) ) ;
const pcmBytes = float32ToInt16Bytes ( e . inputBuffer . getChannelData ( 0 ) ) ;
/ / A u d i o O n l y R e q u e s t ( 0 x 0 2 ) , f l a g s = 0 x 0 0 , R a w ( 0 x 0 0 ) , G z i p ( 0 x 0 1 )
/ / A u d i o O n l y R e q u e s t ( 0 x 0 2 ) , f l a g s = 0 x 0 0 , R a w ( 0 x 0 0 ) , G z i p ( 0 x 0 1 )
const compressed = pako . gzip ( pcmBytes ) ;
const compressed = pako . gzip ( pcmBytes ) ;
@ -646,7 +734,9 @@ const startRecording = async () => {
console . error ( "ASR start error:" , err ) ;
console . error ( "ASR start error:" , err ) ;
stopRecording ( ) ;
stopRecording ( ) ;
asrStatus . value = "error" ;
asrStatus . value = "error" ;
setTimeout ( ( ) => { asrStatus . value = "" ; } , 3000 ) ;
setTimeout ( ( ) => {
asrStatus . value = "" ;
} , 3000 ) ;
if ( err . name === "NotAllowedError" ) {
if ( err . name === "NotAllowedError" ) {
alert ( "麦克风权限被拒绝,请在浏览器设置中允许访问麦克风" ) ;
alert ( "麦克风权限被拒绝,请在浏览器设置中允许访问麦克风" ) ;
}
}
@ -674,15 +764,27 @@ onUnmounted(() => {
<!-- 顶部导航 -- >
<!-- 顶部导航 -- >
< nav class = "nav-bar" >
< nav class = "nav-bar" >
< button @click ="router.push('/')" class = "back-btn" >
< 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" >
< svg
< path stroke -linecap = " round " stroke -linejoin = " round " d = "M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" / >
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 >
< / svg >
返回首页
返回首页
< / button >
< / button >
< div class = "nav-title" > AI 口语对话 < / div >
< div class = "nav-title" > AI 口语对话 < / div >
< div class = "voice-select-wrap" >
< div class = "voice-select-wrap" >
< select v-model ="selectedVoice" class="voice-select" >
< 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 >
< / select >
< / div >
< / div >
< / nav >
< / nav >
@ -711,9 +813,16 @@ onUnmounted(() => {
< / div >
< / 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 头像 -- >
<!-- 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" >
< div class = "bubble-wrap" >
<!-- 气泡 -- >
<!-- 气泡 -- >
@ -724,16 +833,37 @@ onUnmounted(() => {
< / div >
< / div >
< span v-else > {{ msg.content }} < / span >
< span v-else > {{ msg.content }} < / span >
<!-- 消息播放按钮 ( AI 和用户消息均显示 ) -- >
<!-- 消息播放按钮 ( 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 >
< 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" >
< div v-if ="msg.isPlaying" class="wave-bars" >
< span > < / span > < span > < / span > < span > < / span > < span > < / span >
< span > < / span > < span > < / span > < span > < / span > < span > < / span >
< / div >
< / div >
<!-- 未播放 : 播放图标 -- >
<!-- 未播放 : 播放图标 -- >
< svg v -else xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke -width = " 2 " stroke = "currentColor" >
< svg
< 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" / >
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 >
< / svg >
< / button >
< / button >
< / div >
< / div >
@ -750,7 +880,11 @@ onUnmounted(() => {
< textarea
< textarea
v - model = "inputText"
v - model = "inputText"
class = "input-box"
class = "input-box"
: placeholder = "asrInterimText ? asrInterimText : '输入英语内容,按 Enter 发送, Shift+Enter 换行...'"
: placeholder = "
asrInterimText
? asrInterimText
: '输入英语内容,按 Enter 发送, Shift+Enter 换行...'
"
: disabled = "isSending"
: disabled = "isSending"
@ keydown = "handleKeydown"
@ keydown = "handleKeydown"
rows = "1"
rows = "1"
@ -758,7 +892,11 @@ onUnmounted(() => {
<!-- 麦克风按钮 -- >
<!-- 麦克风按钮 -- >
< button
< button
class = "mic-btn"
class = "mic-btn"
: class = "{ recording: isRecording, error: asrStatus === 'error', connecting: asrStatus === 'connecting' }"
: class = " {
recording : isRecording ,
error : asrStatus === 'error' ,
connecting : asrStatus === 'connecting' ,
} "
@ click = "startRecording"
@ click = "startRecording"
: title = "isRecording ? '停止录音' : '语音输入'"
: title = "isRecording ? '停止录音' : '语音输入'"
: disabled = "isSending"
: disabled = "isSending"
@ -768,18 +906,46 @@ onUnmounted(() => {
<!-- 录音中 : 麦克风 + 脉冲 -- >
<!-- 录音中 : 麦克风 + 脉冲 -- >
< template v -else -if = " isRecording " >
< template v -else -if = " isRecording " >
< div class = "mic-pulse" > < / div >
< div class = "mic-pulse" > < / div >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 24 24" >
< svg
< 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" / >
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 >
< / svg >
< / template >
< / template >
<!-- 默认 : 麦克风图标 -- >
<!-- 默认 : 麦克风图标 -- >
< svg v -else xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 24 24" >
< svg
< 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" / >
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 >
< / svg >
< / button >
< / button >
< button class = "send-btn" : disabled = "!inputText.trim() || isSending" @click ="sendMessage" >
< button
< svg v-if ="!isSending" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" >
class = "send-btn"
< 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" / >
: 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 >
< / svg >
< div v -else class = "send-spinner" > < / div >
< div v -else class = "send-spinner" > < / div >
< / button >
< / button >
@ -788,7 +954,9 @@ onUnmounted(() => {
< / template >
< / template >
< style scoped >
< style scoped >
* { box - sizing : border - box ; }
* {
box - sizing : border - box ;
}
. page - container {
. page - container {
max - width : 900 px ;
max - width : 900 px ;
@ -821,8 +989,14 @@ onUnmounted(() => {
transition : all 0.2 s ;
transition : all 0.2 s ;
white - space : nowrap ;
white - space : nowrap ;
}
}
. back - btn : hover { background : var ( -- card - bg ) ; color : var ( -- text - primary ) ; }
. back - btn : hover {
. back - btn svg { width : 20 px ; height : 20 px ; }
background : var ( -- card - bg ) ;
color : var ( -- text - primary ) ;
}
. back - btn svg {
width : 20 px ;
height : 20 px ;
}
. nav - title {
. nav - title {
flex : 1 ;
flex : 1 ;
@ -831,11 +1005,15 @@ onUnmounted(() => {
font - weight : 600 ;
font - weight : 600 ;
}
}
. voice - select - wrap { min - width : 180 px ; }
. voice - select - wrap {
min - width : 180 px ;
}
. voice - select {
. voice - select {
appearance : none ;
appearance : none ;
- webkit - 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.6 rem 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.6 rem center ;
border : 1 px solid var ( -- card - border ) ;
border : 1 px solid var ( -- card - border ) ;
color : var ( -- text - primary ) ;
color : var ( -- text - primary ) ;
padding : 0.4 rem 2 rem 0.4 rem 0.75 rem ;
padding : 0.4 rem 2 rem 0.4 rem 0.75 rem ;
@ -846,7 +1024,9 @@ onUnmounted(() => {
width : 100 % ;
width : 100 % ;
transition : border - color 0.2 s ;
transition : border - color 0.2 s ;
}
}
. voice - select : hover { border - color : var ( -- accent - 1 ) ; }
. voice - select : hover {
border - color : var ( -- accent - 1 ) ;
}
. voice - select option {
. voice - select option {
background : # 1 e1e2e ;
background : # 1 e1e2e ;
color : var ( -- text - primary ) ;
color : var ( -- text - primary ) ;
@ -860,8 +1040,13 @@ onUnmounted(() => {
overflow - x : auto ;
overflow - x : auto ;
padding - bottom : 0.25 rem ;
padding - bottom : 0.25 rem ;
}
}
. scene - tabs : : - webkit - scrollbar { height : 4 px ; }
. scene - tabs : : - webkit - scrollbar {
. scene - tabs : : - webkit - scrollbar - thumb { background : rgba ( 255 , 255 , 255 , 0.1 ) ; border - radius : 2 px ; }
height : 4 px ;
}
. scene - tabs : : - webkit - scrollbar - thumb {
background : rgba ( 255 , 255 , 255 , 0.1 ) ;
border - radius : 2 px ;
}
. scene - tab {
. scene - tab {
display : flex ;
display : flex ;
@ -877,13 +1062,18 @@ onUnmounted(() => {
font - size : 0.9 rem ;
font - size : 0.9 rem ;
transition : all 0.2 s ;
transition : all 0.2 s ;
}
}
. 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 {
. scene - tab . active {
background : rgba ( 99 , 102 , 241 , 0.15 ) ;
background : rgba ( 99 , 102 , 241 , 0.15 ) ;
border - color : rgba ( 99 , 102 , 241 , 0.4 ) ;
border - color : rgba ( 99 , 102 , 241 , 0.4 ) ;
color : var ( -- accent - 1 ) ;
color : var ( -- accent - 1 ) ;
}
}
. scene - emoji { font - size : 1 rem ; }
. scene - emoji {
font - size : 1 rem ;
}
/* Chat Area */
/* Chat Area */
. chat - area {
. chat - area {
@ -895,8 +1085,13 @@ onUnmounted(() => {
gap : 1.25 rem ;
gap : 1.25 rem ;
min - height : 0 ;
min - height : 0 ;
}
}
. chat - area : : - webkit - scrollbar { width : 6 px ; }
. chat - area : : - webkit - scrollbar {
. chat - area : : - webkit - scrollbar - thumb { background : rgba ( 255 , 255 , 255 , 0.1 ) ; border - radius : 3 px ; }
width : 6 px ;
}
. chat - area : : - webkit - scrollbar - thumb {
background : rgba ( 255 , 255 , 255 , 0.1 ) ;
border - radius : 3 px ;
}
/* Welcome */
/* Welcome */
. welcome - hint {
. welcome - hint {
@ -910,9 +1105,18 @@ onUnmounted(() => {
text - align : center ;
text - align : center ;
padding : 3 rem 0 ;
padding : 3 rem 0 ;
}
}
. welcome - icon { font - size : 3.5 rem ; }
. welcome - icon {
. welcome - hint h3 { margin : 0 ; font - size : 1.25 rem ; color : var ( -- text - primary ) ; }
font - size : 3.5 rem ;
. welcome - hint p { margin : 0 ; font - size : 0.95 rem ; }
}
. welcome - hint h3 {
margin : 0 ;
font - size : 1.25 rem ;
color : var ( -- text - primary ) ;
}
. welcome - hint p {
margin : 0 ;
font - size : 0.95 rem ;
}
/* Message Row */
/* Message Row */
. message - row {
. message - row {
@ -923,8 +1127,12 @@ onUnmounted(() => {
. message - row . user {
. message - row . user {
justify - content : flex - end ;
justify - content : flex - end ;
}
}
. message - row . user . avatar { order : 2 ; }
. message - row . user . avatar {
. message - row . user . bubble - wrap { order : 1 ; }
order : 2 ;
}
. message - row . user . bubble - wrap {
order : 1 ;
}
. avatar {
. avatar {
width : 38 px ;
width : 38 px ;
@ -939,7 +1147,10 @@ onUnmounted(() => {
flex - shrink : 0 ;
flex - shrink : 0 ;
order : 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 {
. bubble - wrap {
display : flex ;
display : flex ;
@ -947,7 +1158,9 @@ onUnmounted(() => {
max - width : 70 % ;
max - width : 70 % ;
order : 1 ;
order : 1 ;
}
}
. message - row . user . bubble - wrap { align - items : flex - end ; }
. message - row . user . bubble - wrap {
align - items : flex - end ;
}
. bubble {
. bubble {
padding : 0.875 rem 1.125 rem ;
padding : 0.875 rem 1.125 rem ;
@ -972,8 +1185,14 @@ onUnmounted(() => {
}
}
@ keyframes bubbleIn {
@ keyframes bubbleIn {
from { opacity : 0 ; transform : translateY ( 8 px ) ; }
from {
to { opacity : 1 ; transform : translateY ( 0 ) ; }
opacity : 0 ;
transform : translateY ( 8 px ) ;
}
to {
opacity : 1 ;
transform : translateY ( 0 ) ;
}
}
}
/* Loading dots */
/* Loading dots */
@ -984,16 +1203,29 @@ onUnmounted(() => {
padding : 0.25 rem 0 ;
padding : 0.25 rem 0 ;
}
}
. loading - dots span {
. loading - dots span {
width : 8 px ; height : 8 px ;
width : 8 px ;
height : 8 px ;
background : var ( -- text - secondary ) ;
background : var ( -- text - secondary ) ;
border - radius : 50 % ;
border - radius : 50 % ;
animation : dotBounce 1.2 s infinite ease - in - out ;
animation : dotBounce 1.2 s infinite ease - in - out ;
}
}
. loading - dots span : nth - child ( 2 ) { animation - delay : 0.2 s ; }
. loading - dots span : nth - child ( 2 ) {
. loading - dots span : nth - child ( 3 ) { animation - delay : 0.4 s ; }
animation - delay : 0.2 s ;
}
. loading - dots span : nth - child ( 3 ) {
animation - delay : 0.4 s ;
}
@ keyframes dotBounce {
@ keyframes dotBounce {
0 % , 80 % , 100 % { transform : scale ( 0.7 ) ; opacity : 0.5 ; }
0 % ,
40 % { transform : scale ( 1 ) ; opacity : 1 ; }
80 % ,
100 % {
transform : scale ( 0.7 ) ;
opacity : 0.5 ;
}
40 % {
transform : scale ( 1 ) ;
opacity : 1 ;
}
}
}
/* Audio Controls */
/* Audio Controls */
@ -1007,7 +1239,8 @@ onUnmounted(() => {
}
}
. play - btn {
. play - btn {
width : 30 px ; height : 30 px ;
width : 30 px ;
height : 30 px ;
border - radius : 50 % ;
border - radius : 50 % ;
background : rgba ( 99 , 102 , 241 , 0.15 ) ;
background : rgba ( 99 , 102 , 241 , 0.15 ) ;
border : 1 px solid rgba ( 99 , 102 , 241 , 0.3 ) ;
border : 1 px solid rgba ( 99 , 102 , 241 , 0.3 ) ;
@ -1018,22 +1251,43 @@ onUnmounted(() => {
cursor : pointer ;
cursor : pointer ;
transition : all 0.2 s ;
transition : all 0.2 s ;
}
}
. play - btn : hover { background : rgba ( 99 , 102 , 241 , 0.3 ) ; transform : scale ( 1.1 ) ; }
. play - btn : hover {
. play - btn . playing { background : rgba ( 99 , 102 , 241 , 0.25 ) ; border - color : var ( -- accent - 1 ) ; }
background : rgba ( 99 , 102 , 241 , 0.3 ) ;
. play - btn svg { width : 14 px ; height : 14 px ; }
transform : scale ( 1.1 ) ;
}
. play - btn . playing {
background : rgba ( 99 , 102 , 241 , 0.25 ) ;
border - color : var ( -- accent - 1 ) ;
}
. play - btn svg {
width : 14 px ;
height : 14 px ;
}
. tts - hint { font - size : 0.75 rem ; color : var ( -- text - secondary ) ; }
. tts - hint {
font - size : 0.75 rem ;
color : var ( -- text - secondary ) ;
}
/* 用户消息播放按钮右对齐 */
/* 用户消息播放按钮右对齐 */
. user - audio - controls { justify - content : flex - end ; }
. user - audio - controls {
justify - content : flex - end ;
}
. user - audio - controls . play - btn {
. user - audio - controls . play - btn {
background : rgba ( 139 , 92 , 246 , 0.2 ) ;
background : rgba ( 139 , 92 , 246 , 0.2 ) ;
border - color : rgba ( 139 , 92 , 246 , 0.4 ) ;
border - color : rgba ( 139 , 92 , 246 , 0.4 ) ;
color : # a78bfa ;
color : # a78bfa ;
}
}
. user - audio - controls . play - btn : hover { background : rgba ( 139 , 92 , 246 , 0.35 ) ; }
. user - audio - controls . play - btn : hover {
. user - audio - controls . play - btn . playing { background : rgba ( 139 , 92 , 246 , 0.3 ) ; border - color : # a78bfa ; }
background : rgba ( 139 , 92 , 246 , 0.35 ) ;
. user - audio - controls . wave - bars span { background : # a78bfa ; }
}
. 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 animation */
. wave - bars {
. wave - bars {
@ -1048,12 +1302,22 @@ onUnmounted(() => {
border - radius : 2 px ;
border - radius : 2 px ;
animation : waveBounce 0.6 s infinite alternate ;
animation : waveBounce 0.6 s infinite alternate ;
}
}
. wave - bars span : nth - child ( 2 ) { animation - delay : 0.15 s ; }
. wave - bars span : nth - child ( 2 ) {
. wave - bars span : nth - child ( 3 ) { animation - delay : 0.3 s ; }
animation - delay : 0.15 s ;
. wave - bars span : nth - child ( 4 ) { animation - delay : 0.45 s ; }
}
. wave - bars span : nth - child ( 3 ) {
animation - delay : 0.3 s ;
}
. wave - bars span : nth - child ( 4 ) {
animation - delay : 0.45 s ;
}
@ keyframes waveBounce {
@ keyframes waveBounce {
from { height : 3 px ; }
from {
to { height : 14 px ; }
height : 3 px ;
}
to {
height : 14 px ;
}
}
}
/* Input Area */
/* Input Area */
@ -1082,11 +1346,17 @@ onUnmounted(() => {
max - height : 120 px ;
max - height : 120 px ;
overflow - y : auto ;
overflow - y : auto ;
}
}
. input - box : focus { border - color : var ( -- accent - 1 ) ; }
. input - box : focus {
. input - box : disabled { opacity : 0.6 ; cursor : not - allowed ; }
border - color : var ( -- accent - 1 ) ;
}
. input - box : disabled {
opacity : 0.6 ;
cursor : not - allowed ;
}
. send - btn {
. send - btn {
width : 46 px ; height : 46 px ;
width : 46 px ;
height : 46 px ;
border - radius : 50 % ;
border - radius : 50 % ;
background : linear - gradient ( 135 deg , var ( -- accent - 1 ) , # 7 c3aed ) ;
background : linear - gradient ( 135 deg , var ( -- accent - 1 ) , # 7 c3aed ) ;
border : none ;
border : none ;
@ -1099,23 +1369,39 @@ onUnmounted(() => {
flex - shrink : 0 ;
flex - shrink : 0 ;
box - shadow : 0 4 px 12 px rgba ( 99 , 102 , 241 , 0.35 ) ;
box - shadow : 0 4 px 12 px rgba ( 99 , 102 , 241 , 0.35 ) ;
}
}
. send - btn : hover : not ( : disabled ) { transform : scale ( 1.08 ) ; box - shadow : 0 6 px 16 px rgba ( 99 , 102 , 241 , 0.5 ) ; }
. send - btn : hover : not ( : disabled ) {
. send - btn : disabled { opacity : 0.5 ; cursor : not - allowed ; transform : none ; }
transform : scale ( 1.08 ) ;
. send - btn svg { width : 20 px ; height : 20 px ; }
box - shadow : 0 6 px 16 px rgba ( 99 , 102 , 241 , 0.5 ) ;
}
. send - btn : disabled {
opacity : 0.5 ;
cursor : not - allowed ;
transform : none ;
}
. send - btn svg {
width : 20 px ;
height : 20 px ;
}
. send - spinner {
. send - spinner {
width : 18 px ; height : 18 px ;
width : 18 px ;
height : 18 px ;
border : 2 px solid rgba ( 255 , 255 , 255 , 0.3 ) ;
border : 2 px solid rgba ( 255 , 255 , 255 , 0.3 ) ;
border - top - color : white ;
border - top - color : white ;
border - radius : 50 % ;
border - radius : 50 % ;
animation : spin 0.8 s linear infinite ;
animation : spin 0.8 s linear infinite ;
}
}
@ keyframes spin { to { transform : rotate ( 360 deg ) ; } }
@ keyframes spin {
to {
transform : rotate ( 360 deg ) ;
}
}
/* Mic Button */
/* Mic Button */
. mic - btn {
. mic - btn {
position : relative ;
position : relative ;
width : 46 px ; height : 46 px ;
width : 46 px ;
height : 46 px ;
border - radius : 50 % ;
border - radius : 50 % ;
background : rgba ( 255 , 255 , 255 , 0.07 ) ;
background : rgba ( 255 , 255 , 255 , 0.07 ) ;
border : 1 px solid var ( -- card - border ) ;
border : 1 px solid var ( -- card - border ) ;
@ -1128,9 +1414,21 @@ onUnmounted(() => {
flex - shrink : 0 ;
flex - shrink : 0 ;
overflow : hidden ;
overflow : hidden ;
}
}
. mic - btn svg { width : 20 px ; height : 20 px ; position : relative ; z - index : 1 ; }
. mic - btn svg {
. mic - btn : hover : not ( : disabled ) { background : rgba ( 255 , 255 , 255 , 0.12 ) ; color : var ( -- text - primary ) ; border - color : rgba ( 255 , 255 , 255 , 0.2 ) ; }
width : 20 px ;
. mic - btn : disabled { opacity : 0.4 ; cursor : not - allowed ; }
height : 20 px ;
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 {
. mic - btn . recording {
@ -1138,7 +1436,9 @@ onUnmounted(() => {
border - color : rgba ( 239 , 68 , 68 , 0.5 ) ;
border - color : rgba ( 239 , 68 , 68 , 0.5 ) ;
color : # ef4444 ;
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 {
. mic - btn . connecting {
@ -1163,14 +1463,24 @@ onUnmounted(() => {
animation : micPulse 1.2 s ease - out infinite ;
animation : micPulse 1.2 s ease - out infinite ;
}
}
@ keyframes micPulse {
@ keyframes micPulse {
0 % { transform : scale ( 0.85 ) ; opacity : 0.8 ; }
0 % {
70 % { transform : scale ( 1.15 ) ; opacity : 0 ; }
transform : scale ( 0.85 ) ;
100 % { transform : scale ( 0.85 ) ; opacity : 0 ; }
opacity : 0.8 ;
}
70 % {
transform : scale ( 1.15 ) ;
opacity : 0 ;
}
100 % {
transform : scale ( 0.85 ) ;
opacity : 0 ;
}
}
}
/* 连接中旋转 */
/* 连接中旋转 */
. mic - spinner {
. mic - spinner {
width : 18 px ; height : 18 px ;
width : 18 px ;
height : 18 px ;
border : 2 px solid rgba ( 251 , 191 , 36 , 0.3 ) ;
border : 2 px solid rgba ( 251 , 191 , 36 , 0.3 ) ;
border - top - color : # fbbf24 ;
border - top - color : # fbbf24 ;
border - radius : 50 % ;
border - radius : 50 % ;
@ -1178,9 +1488,15 @@ onUnmounted(() => {
}
}
@ media ( max - width : 600 px ) {
@ media ( max - width : 600 px ) {
. page - container { padding : 1 rem 1 rem 0 ; }
. page - container {
. bubble - wrap { max - width : 85 % ; }
padding : 1 rem 1 rem 0 ;
. nav - title { font - size : 1.2 rem ; }
}
. bubble - wrap {
max - width : 85 % ;
}
. nav - title {
font - size : 1.2 rem ;
}
}
}
< / style >
< / style >