今天是 Phase 5 的第一天,我们不仅要实现前后端分离,还要把 Day 13 的“全自动语音交互”搬到网页上!由于服务器无法直接访问客户端麦克风,架构必须升级。我将在 Vue 3 前端引入 @ricky0123/vad-web(基于 ONNX 的端侧推理模型),实现“浏览器端静默检测”。后端 FastAPI 则升级支持音频文件上传,配合 Whisper 和 LangChain 完成全链路响应。本文将提供完整的架构图解与核心代码实现。

一、 项目进度:Day 14 启动

根据项目路线图,我们正式进入 Phase 5 部署阶段。
这是项目从“脚本”走向“产品”的关键一步。

二、 核心架构:前后端模块功能及交互

为了实现极致的语音交互体验,我们采用了 “端侧感知 + 云端推理” 的混合架构。

  • 前端 (Vue 3):不再仅仅是展示层,它承担了 VAD(语音活动检测) 的计算任务。这样做的好处是零延迟——用户刚闭嘴,浏览器立马知道,不需要把静音流上传到服务器浪费带宽。

  • 后端 (FastAPI):退居幕后,作为一个纯粹的 RESTful API 服务,负责处理繁重的 AI 逻辑。

1. 系统模块交互架构图

这张图清晰展示了 Vue 前端组件与 FastAPI 后端服务之间的数据流转:

2. 模块功能深度解析

💻 前端模块 (Vue 3 Client)
  • Web VAD (端侧静默检测):这是 Day 14 的核心创新。我们引入了 @ricky0123/vad-web 库,它在浏览器中运行一个轻量级的 ONNX 模型。它能实时监听麦克风,一旦检测到用户停止说话超过 0.5秒,立即触发录音停止信号。

  • Audio Recorder (录音机):接收 VAD 的信号,将刚才那段语音数据流(Float32Array)封装成标准的 .wav 文件(Blob 对象),准备上传。

  • Axios (通信):负责将 Blob 包装进 FormData,以 multipart/form-data 格式发送给后端。

⚙️ 后端模块 (FastAPI Server)
  • API Gateway:新增 /chat/audio 接口,专门处理文件上传。它接收前端传来的临时 .wav 文件。

  • Whisper STT:后端的第一道工序。它读取临时音频文件,将其转录为文本(例如:“阿强喜欢什么?”)。之后,这行文本就会像之前的键盘输入一样,流入 RAG 处理链。

  • Static Server:TTS 生成的 MP3 文件保存在服务器本地。FastAPI 开启静态文件挂载,将本地路径映射为 URL(如 http://api/static/voice123.mp3),供前端播放。

三、 实战 Part 1:后端升级 (server.py)

FastAPI 需要新增一个接口,专门接收前端传来的音频文件,并串联 Whisper、RAG 和 TTS。

安装依赖包:

pip install fastapi uvicorn python-multipart

新建/修改 server.py:

import uvicorn
import shutil
import uuid
import os
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

# 引入核心组件
from src.core.llm import LLMClient
from src.core.prompts import PROMPTS
from src.core.emotion import EmotionEngine
from src.core.knowledge import KnowledgeBase
from src.core.reranker import RerankEngine
from src.core.voice import VoiceEngine
from src.core.stt import STTEngine # Day 12 的 STT

# LangChain 组件
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from operator import itemgetter
from src.config.settings import settings

# --- 初始化 FastAPI ---
app = FastAPI(title="Project Echo API")

# 配置 CORS (允许前端跨域访问)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
    allow_credentials=True,
    expose_headers=["*"]
)

# 添加中间件为静态文件添加 CORS 头
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class CORPMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        if request.url.path.startswith("/static/"):
            response.headers["Cross-Origin-Resource-Policy"] = "cross-origin"
            response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
            response.headers["Access-Control-Allow-Origin"] = "*"
        return response

app.add_middleware(CORPMiddleware)

# 挂载静态文件目录 (用于托管生成的 MP3)
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")

# --- 全局组件初始化 ---
print("⚙️ [Server] 正在初始化 AI 内核...")
client = LLMClient()
llm = client.get_client()
emotion_engine = EmotionEngine()
kb = KnowledgeBase()
stt_engine = STTEngine(model_size="base") # 加载 Whisper
voice_engine = VoiceEngine(voice="zh-CN-XiaoyiNeural")

# 构建 RAG 链
base_retriever = kb.get_multiquery_retriever(llm)
reranker = RerankEngine(model_name="BAAI/bge-reranker-base", top_n=3)

# 封装成可调用的检索器
def retriever_with_rerank(query: str):
    docs = base_retriever.invoke(query)
    return reranker.rerank(query, docs)

final_retriever = RunnableLambda(retriever_with_rerank)

sys_prompt_base = PROMPTS["tsundere"]
prompt = ChatPromptTemplate.from_messages([
    ("system", sys_prompt_base),
    ("system", "{emotion_context}"),
    ("system", "【参考资料】:\n{context}"),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}")
])

def format_docs(docs):
    return "\n\n".join([d.page_content for d in docs])

rag_chain = (
    {
        "context": itemgetter("input") | final_retriever | format_docs,
        "input": itemgetter("input"),
        "emotion_context": itemgetter("emotion_context"),
        "history": itemgetter("history")
    }
    | prompt
    | llm
)

def get_session_history(session_id: str):
    return RedisChatMessageHistory(
        session_id=session_id,
        url=settings.REDIS_URL,
        ttl=3600*24*7
    )

final_chain = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="history",
)

# --- 定义响应模型 ---
class ChatResponse(BaseModel):
    text: str
    emotion: str
    audio_url: str

class TextRequest(BaseModel):
    text: str
    session_id: str = "user_web_voice"

# --- 核心接口:文字对话 ---
@app.post("/chat/text", response_model=ChatResponse)
async def chat_text_endpoint(request: TextRequest):
    try:
        user_text = request.text.strip()
        print(f"⌨️ 收到文字: {user_text}")
        
        if not user_text:
            raise HTTPException(status_code=400, detail="文字为空")

        # 1. Emotion + RAG: 生成回复
        current_emotion = emotion_engine.analyze(user_text)
        emotion_instruction = "用户情绪平稳。"
        if "[愤怒]" in current_emotion:
            emotion_instruction = "⚠️ 警告:用户生气了!请示弱道歉。"

        response = final_chain.invoke(
            {"input": user_text, "emotion_context": emotion_instruction},
            config={"configurable": {"session_id": request.session_id}}
        )
        ai_text = response.content
        
        # 2. TTS: 生成语音文件
        filename = f"speech_{uuid.uuid4().hex}.mp3"
        filepath = f"static/{filename}"
        
        # 调用 Edge-TTS 生成文件
        voice_engine.output_file = filepath
        await voice_engine.speak_async(ai_text, current_emotion)
        
        # 3. 返回结果
        return ChatResponse(
            text=ai_text,
            emotion=current_emotion,
            audio_url=f"http://localhost:8000/static/{filename}"
        )

    except Exception as e:
        print(f"Error: {e}")
        raise HTTPException(status_code=500, detail=str(e))

# --- 核心接口:语音对话 ---
@app.post("/chat/audio", response_model=ChatResponse)
async def chat_audio_endpoint(
    file: UploadFile = File(...), 
    session_id: str = "user_web_voice"
):
    temp_filename = f"temp_{uuid.uuid4().hex}.wav"
    try:
        # 1. 保存上传的音频
        with open(temp_filename, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)
            
        # 2. STT: 转录文本
        user_text = stt_engine.transcribe(temp_filename)
        print(f"👂 收到语音: {user_text}")
        
        if not user_text or len(user_text) < 1:
            raise HTTPException(status_code=400, detail="没听清")

        # 3. Emotion + RAG: 生成回复
        current_emotion = emotion_engine.analyze(user_text)
        emotion_instruction = "用户情绪平稳。"
        if "[愤怒]" in current_emotion:
            emotion_instruction = "⚠️ 警告:用户生气了!请示弱道歉。"

        response = final_chain.invoke(
            {"input": user_text, "emotion_context": emotion_instruction},
            config={"configurable": {"session_id": session_id}}
        )
        ai_text = response.content
        
        # 4. TTS: 生成语音文件
        filename = f"speech_{uuid.uuid4().hex}.mp3"
        filepath = f"static/{filename}"
        
        # 调用 Edge-TTS 生成文件 (使用异步接口)
        voice_engine.output_file = filepath
        await voice_engine.speak_async(ai_text, current_emotion) 
        
        # 5. 返回结果 (包含音频 URL)
        return ChatResponse(
            text=ai_text,
            emotion=current_emotion,
            audio_url=f"http://localhost:8000/static/{filename}"
        )

    except Exception as e:
        print(f"Error: {e}")
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        # 清理上传的临时文件
        if os.path.exists(temp_filename):
            os.remove(temp_filename)

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

四、 实战 Part 2:Vue 前端 VAD 集成

1. 初始化项目

npm create vite@latest echo-client -- --template vue
cd echo-client
npm install
npm install axios @ricky0123/vad-web onnxruntime-web

2. 编写 Web VAD 逻辑 (src/App.vue)

<template>
  <div class="chat-container">
    <header>
      <h1>🎀 Project Echo: 网页版</h1>
      <div class="status-bar" :class="vadStatus">
        <div class="indicator"></div>
        <span>{{ statusText }}</span>
      </div>
    </header>

    <div class="messages" ref="msgContainer">
      <div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
        <div class="avatar">{{ msg.role === 'user' ? '🧑‍💻' : '🎀' }}</div>
        <div class="bubble">
          <div class="text">{{ msg.text }}</div>
          <!-- 隐藏音频播放条,默默播放 -->
          <audio v-if="msg.audio" :src="msg.audio" style="display: none;"></audio>
        </div>
      </div>
    </div>

    <div class="controls">
      <div class="input-group">
        <input 
          v-model="textInput" 
          @keyup.enter="sendText"
          type="text" 
          placeholder="输入文字消息... (按 Enter 发送)"
          :disabled="vadStatus === 'processing'"
          class="text-input"
        />
        <button @click="sendText" :disabled="!textInput.trim() || vadStatus === 'processing'" class="send-btn">
          📤 发送
        </button>
      </div>
      <p class="hint">🎤 语音输入:直接说话 | ⌨️ 文字输入:输入后按 Enter</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import axios from 'axios'
import * as vad from '@ricky0123/vad-web'
import * as ort from 'onnxruntime-web'

const messages = ref([{ role: 'ai', text: '你好呀!我是全自动语音助手。' }])
const statusText = ref('初始化中...')
const vadStatus = ref('idle')
const msgContainer = ref(null)
const textInput = ref('')
const audioPlayer = ref(null)
const isProcessing = ref(false)  // 添加处理状态锁
const userInteracted = ref(false)  // 跟踪用户是否交互过
let myVad = null

onMounted(async () => {
  try {
    // 配置 ONNX Runtime
    ort.env.wasm.numThreads = 1
    ort.env.wasm.simd = true
    
    myVad = await vad.MicVAD.new({
      onnxWASMBasePath: 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/',
      baseAssetPath: 'https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.30/dist/',
      onSpeechStart: () => {
        statusText.value = '🔴 正在聆听...'
        vadStatus.value = 'listening'
        // 用户说话算作交互
        userInteracted.value = true
      },
      onSpeechEnd: (audio) => {
        // 如果正在处理,忽略新的语音输入
        if (isProcessing.value) {
          console.log('正在处理中,忽略新语音')
          return
        }
        statusText.value = '⏳ 思考中...'
        vadStatus.value = 'processing'
        processAudio(audio)
      },
    })
    await myVad.start()
    statusText.value = '👂 待机中 (请说话)'
  } catch (e) {
    console.error('麦克风初始化失败:', e)
    statusText.value = `❌ 麦克风失败: ${e.message}`
  }
})

const processAudio = async (audioData) => {
  // 防止重复提交
  if (isProcessing.value) {
    console.log('已有请求处理中,忽略')
    return
  }
  
  isProcessing.value = true
  
  // 暂停VAD监听,防止检测到音频播放的声音
  if (myVad) {
    await myVad.pause()
  }
  
  const wavBlob = encodeWAV(audioData)
  const formData = new FormData()
  formData.append('file', wavBlob, 'speech.wav')

  try {
    const res = await axios.post('http://localhost:8000/chat/audio', formData)
    messages.value.push({ role: 'user', text: '(语音输入)' })
    messages.value.push({
      role: 'ai',
      text: res.data.text,
      audio: res.data.audio_url
    })
    scrollToBottom()
    await playLatestAudio()  // 等待音频播放完成
  } catch (err) {
    console.error(err)
    // 如果出错,立即恢复VAD
    if (myVad) {
      await myVad.start()
    }
    isProcessing.value = false
    statusText.value = '👂 待机中'
    vadStatus.value = 'idle'
  }
  // 注意:finally 不在这里,因为我们在 onAudioEnded 中恢复
}

const sendText = async () => {
  if (!textInput.value.trim() || isProcessing.value) return
  
  // 用户点击发送算作交互
  userInteracted.value = true
  
  isProcessing.value = true
  
  // 暂停VAD监听
  if (myVad) {
    await myVad.pause()
  }
  
  const userMessage = textInput.value.trim()
  textInput.value = ''
  vadStatus.value = 'processing'
  statusText.value = '⏳ 思考中...'

  try {
    messages.value.push({ role: 'user', text: userMessage })
    const res = await axios.post('http://localhost:8000/chat/text', {
      text: userMessage,
      session_id: 'user_web_voice'
    })
    
    messages.value.push({
      role: 'ai',
      text: res.data.text,
      audio: res.data.audio_url
    })
    scrollToBottom()
    await playLatestAudio()  // 等待音频播放完成
  } catch (err) {
    console.error(err)
    // 如果出错,立即恢复VAD
    if (myVad) {
      await myVad.start()
    }
    isProcessing.value = false
    statusText.value = '👂 待机中'
    vadStatus.value = 'idle'
  }
  // 注意:在 onAudioEnded 中恢复VAD和状态
}

const playLatestAudio = () => {
  return new Promise((resolve) => {
    nextTick(() => {
      const audioElements = document.querySelectorAll('audio')
      console.log(`找到 ${audioElements.length} 个音频元素`)
      if (audioElements.length > 0) {
        const latestAudio = audioElements[audioElements.length - 1]
        console.log('开始播放音频:', latestAudio.src)
        
        // 绑定事件
        latestAudio.onended = () => {
          console.log('音频播放结束')
          onAudioEnded()
          resolve()
        }
        latestAudio.onerror = (err) => {
          console.error('音频播放错误:', err)
          onAudioEnded()
          resolve()
        }
        
        // 只有用户交互后才自动播放
        if (userInteracted.value) {
          latestAudio.play().then(() => {
            console.log('音频开始播放')
          }).catch(err => {
            console.error('音频播放失败:', err)
            // 如果自动播放失败,显示提示
            statusText.value = '⚠️ 请点击页面任意位置以启用音频播放'
            onAudioEnded()
            resolve()
          })
        } else {
          console.log('等待用户交互后播放音频')
          statusText.value = '⚠️ 请说话或点击发送以启用音频播放'
          onAudioEnded()
          resolve()
        }
      } else {
        console.warn('没有找到音频元素')
        onAudioEnded()
        resolve()
      }
    })
  })
}

const onAudioEnded = async () => {
  console.log('音频播放完成,恢复VAD监听')
  statusText.value = '👂 待机中'
  vadStatus.value = 'idle'
  isProcessing.value = false
  
  // 恢复VAD监听
  if (myVad) {
    try {
      await myVad.start()
    } catch (err) {
      console.error('VAD恢复失败:', err)
    }
  }
}

const scrollToBottom = () => {
  nextTick(() => {
    if (msgContainer.value) {
      msgContainer.value.scrollTop = msgContainer.value.scrollHeight
    }
  })
}

// 辅助函数:将 Float32Array 音频转换为 WAV 格式
const encodeWAV = (samples) => {
  const buffer = new ArrayBuffer(44 + samples.length * 2)
  const view = new DataView(buffer)

  // WAV 文件头
  const writeString = (offset, string) => {
    for (let i = 0; i < string.length; i++) {
      view.setUint8(offset + i, string.charCodeAt(i))
    }
  }

  writeString(0, 'RIFF')
  view.setUint32(4, 36 + samples.length * 2, true)
  writeString(8, 'WAVE')
  writeString(12, 'fmt ')
  view.setUint32(16, 16, true) // fmt chunk size
  view.setUint16(20, 1, true)  // audio format (PCM)
  view.setUint16(22, 1, true)  // number of channels
  view.setUint32(24, 16000, true) // sample rate
  view.setUint32(28, 16000 * 2, true) // byte rate
  view.setUint16(32, 2, true)  // block align
  view.setUint16(34, 16, true) // bits per sample
  writeString(36, 'data')
  view.setUint32(40, samples.length * 2, true)

  // 写入音频数据
  let offset = 44
  for (let i = 0; i < samples.length; i++) {
    const s = Math.max(-1, Math.min(1, samples[i]))
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
    offset += 2
  }

  return new Blob([buffer], { type: 'audio/wav' })
}

</script>

<style scoped>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

header {
  padding: 20px;
  background: rgba(255, 255, 255, 0.95);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  text-align: center;
}

h1 {
  color: #764ba2;
  font-size: 24px;
  margin-bottom: 10px;
}

.status-bar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 8px 16px;
  background: #f0f0f0;
  border-radius: 20px;
  display: inline-flex;
  font-size: 14px;
  color: #666;
}

.indicator {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #ccc;
  transition: all 0.3s;
}

.status-bar.listening .indicator {
  background: #f44336;
  animation: pulse 1s infinite;
}

.status-bar.processing .indicator {
  background: #ff9800;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.2); opacity: 0.7; }
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.message {
  display: flex;
  gap: 12px;
  align-items: flex-start;
}

.message.user {
  flex-direction: row-reverse;
}

.avatar {
  font-size: 32px;
  flex-shrink: 0;
}

.bubble {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 18px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.message.user .bubble {
  background: #667eea;
  color: white;
  border-bottom-right-radius: 4px;
}

.message.ai .bubble {
  background: white;
  color: #333;
  border-bottom-left-radius: 4px;
}

.text {
  line-height: 1.5;
  word-wrap: break-word;
}

.controls {
  padding: 20px;
  background: rgba(255, 255, 255, 0.95);
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}

.input-group {
  display: flex;
  gap: 10px;
  margin-bottom: 10px;
}

.text-input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #ddd;
  border-radius: 24px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.3s;
}

.text-input:focus {
  border-color: #667eea;
}

.text-input:disabled {
  background: #f5f5f5;
  cursor: not-allowed;
}

.send-btn {
  padding: 12px 24px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 24px;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.3s;
  white-space: nowrap;
}

.send-btn:hover:not(:disabled) {
  background: #5568d3;
  transform: translateY(-1px);
  box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}

.send-btn:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.hint {
  text-align: center;
  color: #666;
  font-size: 12px;
  margin-top: 8px;
}
</style>

五、 效果验证:见证“全双工”语音交互

代码写完了,现在是检验真理的时刻。我们需要验证 前端 VAD 是否灵敏 以及 前后端通信是否顺畅

1. 服务启动与界面展示

同时启动 FastAPI 后端 (python server.py) 和 Vue 前端 (npm run dev)。打开浏览器访问 http://localhost:5173。
可以看到,我们终于告别了 Streamlit 的固定布局,拥有了一个完全自定义的现代化聊天界面。

五、 总结与预告

今天我们完成了 前后端分离 与 Web VAD 的高难度集成。
Project Echo 现在的交互体验已经非常接近原生的 App 了,用户无需安装任何插件,打开网页就能直接对话。

明日预告 (Day 15)
代码写完了,但怎么让它在别人的电脑上也能跑?
明天是最后一天,我们将进行 Docker 容器化。我们将编写 Dockerfile,把 Python 环境、Redis、Vue 前端全部打包成一个镜像,实现“一键部署”。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐