Merge pull request 'docs' (#25) from docs into main
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/25
This commit was merged in pull request #25.
This commit is contained in:
@@ -35,7 +35,7 @@
|
||||
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
||||
| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 |
|
||||
| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) |
|
||||
| 配置管理 | Viper | 支持多格式配置,环境变量覆盖 |
|
||||
| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
||||
| 日志 | Zap | 高性能结构化日志 |
|
||||
|
||||
### AI 服务
|
||||
@@ -79,37 +79,53 @@ Browser Go Gateway STT LLM TTS
|
||||
| 模块 | 职责 | 关键实现 |
|
||||
|------|------|---------|
|
||||
| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection |
|
||||
| Session Manager | 维护用户会话状态、对话历史 | Redis + TTL 过期策略 |
|
||||
| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
||||
| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 |
|
||||
| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 |
|
||||
| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 |
|
||||
|
||||
AI Orchestrator 核心代码:
|
||||
AI Orchestrator 核心代码(句子级流式并行):
|
||||
|
||||
```go
|
||||
func (o *Orchestrator) ProcessQuery(ctx context.Context, req *QueryRequest) (*QueryResponse, error) {
|
||||
func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 并行:LLM 推理 + 准备 TTS
|
||||
llmCh := make(chan string, 1)
|
||||
// Step 1: STT — 识别用户语音(串行)
|
||||
text, err := o.stt.Recognize(ctx, req.Audio, STTOptions{...})
|
||||
if err != nil {
|
||||
client.SendError(req.RequestID, "STT_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
client.SendSTTResult(req.RequestID, text, true)
|
||||
|
||||
// Step 2: LLM 流式输出 + 句子切分(并行)
|
||||
llmStream, _ := o.llm.ChatStream(ctx, LLMRequest{Image: req.Image, Text: text, ...})
|
||||
sentenceCh := make(chan string, 4)
|
||||
go func() {
|
||||
resp, _ := o.llm.Chat(ctx, req.Image, req.Text, req.History)
|
||||
llmCh <- resp
|
||||
defer close(sentenceCh)
|
||||
var buf strings.Builder
|
||||
for chunk := range llmStream {
|
||||
client.SendLLMChunk(req.RequestID, chunk.Delta) // 逐 token 推送文字
|
||||
buf.WriteString(chunk.Delta)
|
||||
if isSentenceEnd(chunk.Delta) { // 按 。!?\n 切分
|
||||
sentenceCh <- buf.String()
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 { sentenceCh <- buf.String() }
|
||||
}()
|
||||
|
||||
llmText := <-llmCh
|
||||
// LLM 返回后,流式推送给客户端,同时启动 TTS
|
||||
ttsCh := make(chan []byte, 1)
|
||||
go func() {
|
||||
audio, _ := o.tts.Synthesize(ctx, llmText)
|
||||
ttsCh <- audio
|
||||
}()
|
||||
|
||||
return &QueryResponse{Text: llmText, Audio: <-ttsCh}, nil
|
||||
// Step 3: TTS 并行消费句子流
|
||||
ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...})
|
||||
for chunk := range ttsStream {
|
||||
client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。
|
||||
|
||||
## 前端组件
|
||||
|
||||
| 组件 | 职责 |
|
||||
|
||||
664
docs/03-接口文档.md
664
docs/03-接口文档.md
@@ -143,11 +143,53 @@ interface TTSAudioMessage {
|
||||
type: "tts_audio";
|
||||
request_id: string;
|
||||
audio: string; // Base64 编码的音频片段
|
||||
mime_type: string; // "audio/mp3" 或 "audio/pcm"
|
||||
mime_type: string; // "audio/mpeg"
|
||||
is_last: boolean; // 是否为最后一片
|
||||
}
|
||||
```
|
||||
|
||||
**音频格式规范**(前端播放依赖此约定):
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
|------|------|------|
|
||||
| 编码 | `audio/mpeg`(MP3) | 浏览器 `<audio>` 原生支持,OpenAI TTS 默认输出 |
|
||||
| 采样率 | 24kHz | OpenAI TTS 默认 |
|
||||
| 声道 | 单声道 | 语音不需要立体声 |
|
||||
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
|
||||
| 切片粒度 | 按句子切分 | LLM 输出中按 `。!?\n` 等标点切分,每个句子独立合成 |
|
||||
|
||||
**流式播放时序**:`tts_audio` 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。
|
||||
|
||||
**前端播放实现要点**:
|
||||
|
||||
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mpeg"`,前端解码时直接使用,无需运行时判断。
|
||||
|
||||
```typescript
|
||||
// 前端播放器伪代码
|
||||
class AudioPlayer {
|
||||
private queue: string[] = []; // Blob URL 队列
|
||||
|
||||
enqueue(base64: string) {
|
||||
const url = decodeBase64Audio(base64, "audio/mpeg");
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
|
||||
}
|
||||
|
||||
private playNext() {
|
||||
if (this.queue.length === 0) return;
|
||||
const audio = new Audio(this.queue[0]);
|
||||
audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
audio.play();
|
||||
}
|
||||
|
||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; }
|
||||
}
|
||||
```
|
||||
|
||||
#### `error` — 错误通知
|
||||
|
||||
```typescript
|
||||
@@ -250,7 +292,619 @@ DELETE /api/sessions/{session_id}
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
## 三、AI 服务层接口
|
||||
|
||||
Go 网关内部与外部 AI 服务(Deepgram STT、GPT-4o、OpenAI TTS)的调用契约。前后端联调时,后端需实现这些接口。
|
||||
|
||||
### STT 服务接口
|
||||
|
||||
语音识别:接收前端采集的音频,返回识别文本。
|
||||
|
||||
```go
|
||||
// STTService 语音识别服务契约。
|
||||
type STTService interface {
|
||||
// Recognize 识别一段完整音频,返回最终文本。
|
||||
Recognize(ctx context.Context, audio []byte, opts STTOptions) (string, error)
|
||||
|
||||
// RecognizeStream 流式识别(边说边识别,可选实现)。
|
||||
// audioStream 持续接收音频片段,返回的 channel 持续输出中间结果。
|
||||
RecognizeStream(ctx context.Context, audioStream <-chan []byte, opts STTOptions) (<-chan STTPartial, error)
|
||||
}
|
||||
|
||||
// STTOptions 语音识别参数。
|
||||
type STTOptions struct {
|
||||
Encoding string // "pcm_s16le" — 前端 VAD 输出格式
|
||||
SampleRate int // 16000 — 前端麦克风采样率
|
||||
Language string // "zh-CN"
|
||||
}
|
||||
|
||||
// STTPartial 流式识别的中间/最终结果。
|
||||
type STTPartial struct {
|
||||
Text string
|
||||
IsFinal bool
|
||||
}
|
||||
```
|
||||
|
||||
**Deepgram 接入约定**:
|
||||
- 连接方式:WebSocket `wss://api.deepgram.com/v1/listen`
|
||||
- 音频格式:PCM 16-bit signed little-endian,16kHz 单声道(与前端 `MicManager` 输出一致)
|
||||
- 返回格式:`channel.alternatives[0].transcript`,`is_final` 字段标识最终结果
|
||||
- 超时:单次识别 5 秒超时
|
||||
|
||||
### LLM 服务接口
|
||||
|
||||
多模态推理:接收图像 + 文本 + 对话历史,流式返回回复。
|
||||
|
||||
```go
|
||||
// LLMService 多模态大模型服务契约。
|
||||
type LLMService interface {
|
||||
// ChatStream 流式推理,返回增量文本的 channel。
|
||||
// 调用方必须消费 channel 直到 Done=true,否则需 cancel ctx 以释放连接。
|
||||
ChatStream(ctx context.Context, req LLMRequest) (<-chan LLMChunk, error)
|
||||
}
|
||||
|
||||
// LLMRequest 推理请求。
|
||||
type LLMRequest struct {
|
||||
Image []byte // JPEG 图片(已从 Base64 解码)
|
||||
Text string // 用户语音识别后的文本
|
||||
History []Message // 最近 N 轮对话历史
|
||||
Language string // "zh-CN"
|
||||
}
|
||||
|
||||
// LLMChunk 流式推理的一个增量片段。
|
||||
type LLMChunk struct {
|
||||
Delta string // 增量文本
|
||||
Done bool // 是否结束
|
||||
TokensUsed *TokenUsage // 仅 Done=true 时有值
|
||||
Model string // 实际使用的模型名
|
||||
}
|
||||
|
||||
// TokenUsage 用量统计。
|
||||
type TokenUsage struct {
|
||||
Prompt int
|
||||
Completion int
|
||||
Total int
|
||||
}
|
||||
```
|
||||
|
||||
**OpenAI API 接入约定**:
|
||||
- 端点:`POST https://api.openai.com/v1/chat/completions`
|
||||
- 图片传入:`image_url` 字段使用 `data:image/jpeg;base64,...` 格式
|
||||
- 流式响应:`stream: true`,通过 SSE 逐 chunk 返回
|
||||
- Prompt 结构:
|
||||
|
||||
```
|
||||
system: "你是一个视觉助手。用户通过摄像头看到一个场景,并用语音向你提问。
|
||||
请用简洁自然的中文回答。如果涉及视觉描述,先说'我看到...'。"
|
||||
user: [图片 + 用户语音文本]
|
||||
(重复 History 中的历史消息)
|
||||
```
|
||||
|
||||
- 超时:10 秒,超时返回 `LLM_TIMEOUT` 错误
|
||||
- 模型选择:默认 `gpt-4o`,由 Model Router 按需切换
|
||||
|
||||
### TTS 服务接口
|
||||
|
||||
语音合成:接收文本流,输出音频 chunk 流。
|
||||
|
||||
```go
|
||||
// TTSService 语音合成服务契约。
|
||||
type TTSService interface {
|
||||
// SynthesizeStream 流式合成。
|
||||
// textStream 接收句子级文本(由 Orchestrator 的句子切分器产出),
|
||||
// 返回的 channel 持续输出 MP3 音频 chunk。
|
||||
SynthesizeStream(ctx context.Context, textStream <-chan string, opts TTSOptions) (<-chan TTSChunk, error)
|
||||
}
|
||||
|
||||
// TTSOptions 合成参数。
|
||||
type TTSOptions struct {
|
||||
Voice string // "alloy" | "nova" | "shimmer" | ...
|
||||
Speed float64 // 1.0 为正常语速
|
||||
OutputFmt string // "mp3" — 固定使用 MP3,浏览器原生支持
|
||||
SampleRate int // 24000
|
||||
}
|
||||
|
||||
// TTSChunk 一个音频片段。
|
||||
type TTSChunk struct {
|
||||
Audio []byte // MP3 音频数据(未 Base64 编码,由发送层编码)
|
||||
IsLast bool // 是否为最后一片
|
||||
}
|
||||
```
|
||||
|
||||
**OpenAI TTS 接入约定**:
|
||||
- 端点:`POST https://api.openai.com/v1/audio/speech`
|
||||
- 模型:`tts-1`(低延迟优先)或 `tts-1-hd`(高音质)
|
||||
- 输出格式:`mp3`,24kHz
|
||||
- 流式:使用 `response_format: "mp3"` 并读取 response body 流
|
||||
- 超时:单个句子 5 秒超时
|
||||
|
||||
---
|
||||
|
||||
## 四、AI 编排器(Orchestrator)
|
||||
|
||||
### 编排策略:句子级流式并行
|
||||
|
||||
核心矛盾:LLM 流式输出逐 token,TTS 需要完整句子才能合成。解法:**句子切分器 + 管道并行**。
|
||||
|
||||
```
|
||||
LLM 流式输出: "这" "是一" "朵红色" "的花。" "它看起" "来很美" "丽。"
|
||||
↓
|
||||
┌── 句子检测器(按 。!?\n 切分)──┐
|
||||
↓ ↓
|
||||
句子1: "这是一朵红色的花。" 句子2: "它看起来很美丽。"
|
||||
↓ ↓
|
||||
TTS 合成 TTS 合成
|
||||
↓ ↓
|
||||
音频 chunk → 推送前端 音频 chunk → 推送前端
|
||||
```
|
||||
|
||||
**时序保证**:
|
||||
- `llm_chunk` 消息一定先于对应句子的 `tts_audio` 到达客户端
|
||||
- 用户先看到文字,紧接着听到语音(感知延迟 < 0.5 秒)
|
||||
- 不必等 LLM 全部输出完才开始 TTS
|
||||
|
||||
### Orchestrator 接口
|
||||
|
||||
```go
|
||||
// Orchestrator AI 编排器,协调 STT → LLM → TTS 全链路。
|
||||
type Orchestrator struct {
|
||||
stt STTService
|
||||
llm LLMService
|
||||
tts TTSService
|
||||
}
|
||||
|
||||
// ProcessQuery 处理一次完整的视觉对话请求。
|
||||
// 通过 client 向前端实时推送 stt_result、llm_chunk、llm_done、tts_audio 消息。
|
||||
func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Step 1: STT — 识别用户语音
|
||||
text, err := o.stt.Recognize(ctx, req.Audio, STTOptions{
|
||||
Encoding: "pcm_s16le", SampleRate: 16000, Language: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
client.SendError(req.RequestID, "STT_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
client.SendSTTResult(req.RequestID, text, true)
|
||||
|
||||
// Step 2: LLM 流式输出 + 句子切分
|
||||
llmStream, _ := o.llm.ChatStream(ctx, LLMRequest{
|
||||
Image: req.Image, Text: text, Language: "zh-CN",
|
||||
})
|
||||
|
||||
sentenceCh := make(chan string, 4)
|
||||
go func() {
|
||||
defer close(sentenceCh)
|
||||
var buf strings.Builder
|
||||
var fullText strings.Builder
|
||||
for chunk := range llmStream {
|
||||
// 即时推送文字给客户端(逐 token 显示)
|
||||
client.SendLLMChunk(req.RequestID, chunk.Delta)
|
||||
fullText.WriteString(chunk.Delta)
|
||||
buf.WriteString(chunk.Delta)
|
||||
// 遇到句子边界就吐出
|
||||
if isSentenceEnd(chunk.Delta) {
|
||||
sentenceCh <- buf.String()
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
// 最后一段不足一句的也吐出
|
||||
if buf.Len() > 0 {
|
||||
sentenceCh <- buf.String()
|
||||
}
|
||||
// 推送 llm_done
|
||||
client.SendLLMDone(req.RequestID, fullText.String(), chunk.TokensUsed, chunk.Model)
|
||||
}()
|
||||
|
||||
// Step 3: TTS 并行消费句子流
|
||||
ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{
|
||||
Voice: "alloy", OutputFmt: "mp3", SampleRate: 24000,
|
||||
})
|
||||
for chunk := range ttsStream {
|
||||
client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast)
|
||||
}
|
||||
}
|
||||
|
||||
// isSentenceEnd 判断 delta 中是否包含句子结束标志。
|
||||
func isSentenceEnd(delta string) bool {
|
||||
return strings.ContainsAny(delta, "。!?\n.!?\n")
|
||||
}
|
||||
```
|
||||
|
||||
### 并发控制
|
||||
|
||||
- 每个 `ProcessQuery` 调用在独立 goroutine 中运行
|
||||
- `context.WithTimeout` 确保 10 秒总超时
|
||||
- `interrupt` 消息触发 `cancel()`,LLM/TTS 流式全部中断
|
||||
- 同一 session 内同时只允许一个活跃请求,新请求自动取消上一个
|
||||
|
||||
### 错误处理与降级
|
||||
|
||||
| 故障点 | 处理策略 | 客户端表现 |
|
||||
|--------|---------|-----------|
|
||||
| STT 失败 | 发送 `STT_ERROR`,终止本次请求 | 回退到纯文本模式 |
|
||||
| LLM 超时(>10s) | 发送 `LLM_TIMEOUT`,取消 TTS | 提示用户重试 |
|
||||
| LLM 部分输出后失败 | 已推送的 `llm_chunk` 保留,发送 `error` 通知中断 | 显示已收到的部分文字 |
|
||||
| TTS 失败 | 静默跳过,`llm_done` 正常发送 | 只有文字回复,无语音 |
|
||||
| TTS 部分失败 | 已推送的音频保留,后续句子跳过 | 部分句子有语音 |
|
||||
| interrupt 打断 | cancel context,清空所有流 | 前端清空播放队列 |
|
||||
|
||||
---
|
||||
|
||||
## 五、Session Manager
|
||||
|
||||
WebSocket Handler 和 AI Orchestrator 之间的会话管理层。负责维护会话生命周期、对话上下文和配置状态。
|
||||
|
||||
### Redis 数据结构
|
||||
|
||||
每个会话在 Redis 中占 2 个 key:
|
||||
|
||||
```
|
||||
session:{id}:meta → Hash (会话元数据)
|
||||
session:{id}:history → List (对话历史)
|
||||
```
|
||||
|
||||
**Hash — `session:{id}:meta`**
|
||||
|
||||
| field | 类型 | 示例 | 说明 |
|
||||
|-------|------|------|------|
|
||||
| `session_id` | string | `"550e8400-..."` | 主键冗余 |
|
||||
| `config.tts_enabled` | string | `"true"` | Redis Hash 值均为 string |
|
||||
| `config.detail_level` | string | `"low"` | |
|
||||
| `config.language` | string | `"zh-CN"` | |
|
||||
| `created_at` | string | `"2026-06-13T10:00:00Z"` | RFC3339 |
|
||||
| `last_active` | string | `"2026-06-13T10:05:30Z"` | 每次消息刷新 |
|
||||
| `active_request_id` | string | `"uuid"` 或 `""` | 当前处理中的请求 ID,用于 interrupt |
|
||||
|
||||
**List — `session:{id}:history`**
|
||||
|
||||
每个元素是一条 JSON 序列化的 Message:
|
||||
|
||||
```json
|
||||
{"role":"user","content":"这是什么花?"}
|
||||
{"role":"assistant","content":"这是一朵红色的玫瑰。"}
|
||||
```
|
||||
|
||||
- `LPUSH` 新消息到左头(最新在前)
|
||||
- `LRANGE 0 {limit-1}` 取最近 N 轮
|
||||
- `LTRIM 0 {max-1}` 限制总条数(默认保留最近 20 条 = 10 轮对话)
|
||||
|
||||
### TTL 策略
|
||||
|
||||
| 场景 | TTL | 说明 |
|
||||
|------|-----|------|
|
||||
| 创建时 | 30 分钟 | `EXPIRE` 设置 |
|
||||
| 每次收到消息 | 重置 30 分钟 | `EXPIRE` 刷新 |
|
||||
| WebSocket 断开 | 不主动删 | 等自然过期,支持重连恢复 |
|
||||
| 超过 30 分钟无活动 | 自动过期 | Redis 自动清理 meta + history |
|
||||
| 显式销毁(REST API) | 立即 `DEL` | 两个 key 一起删 |
|
||||
|
||||
### 接口定义
|
||||
|
||||
```go
|
||||
// SessionManager 会话管理器。
|
||||
// WebSocket Handler 通过此接口操作会话,不直接接触 Redis。
|
||||
type SessionManager interface {
|
||||
// Create 创建新会话,返回 session ID。
|
||||
Create(ctx context.Context, config models.SessionConfig) (string, error)
|
||||
|
||||
// Get 获取会话(含 config)。不存在返回 ErrSessionNotFound。
|
||||
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
||||
|
||||
// UpdateConfig 更新会话配置(config 消息触发)。
|
||||
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
||||
|
||||
// GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
|
||||
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
||||
|
||||
// AppendMessage 追加一条对话消息,同时刷新 TTL。
|
||||
AppendMessage(ctx context.Context, sessionID string, msg models.Message) error
|
||||
|
||||
// SetActiveRequest 标记当前正在处理的请求 ID(interrupt 用)。
|
||||
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
||||
|
||||
// ClearActiveRequest 清除活跃请求标记(请求完成或中断后)。
|
||||
ClearActiveRequest(ctx context.Context, sessionID string) error
|
||||
|
||||
// Touch 刷新 TTL(心跳时调用)。
|
||||
Touch(ctx context.Context, sessionID string) error
|
||||
|
||||
// Destroy 显式销毁会话(REST API DELETE 或连接断开清理)。
|
||||
Destroy(ctx context.Context, sessionID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Handler 集成
|
||||
|
||||
```go
|
||||
// query 分支
|
||||
case "query":
|
||||
var msg models.WsQuery
|
||||
json.Unmarshal(message, &msg)
|
||||
|
||||
sessionMgr.Touch(ctx, sessionID) // 刷新 TTL
|
||||
sessionMgr.SetActiveRequest(ctx, sessionID, msg.RequestID) // 标记活跃请求
|
||||
|
||||
history, _ := sessionMgr.GetHistory(ctx, sessionID, 20) // 获取对话上下文
|
||||
|
||||
go orchestrator.ProcessQuery(ctx, client, &msg, history) // 异步编排
|
||||
|
||||
// interrupt 分支
|
||||
case "interrupt":
|
||||
reqID, _ := sessionMgr.GetActiveRequestID(ctx, sessionID)
|
||||
if reqID != "" {
|
||||
cancelFunc(reqID) // 取消对应 context
|
||||
sessionMgr.ClearActiveRequest(ctx, sessionID)
|
||||
}
|
||||
|
||||
// 连接断开
|
||||
// 不调用 Destroy,让 session 自然过期(支持重连恢复)
|
||||
```
|
||||
|
||||
### MVP 内存实现
|
||||
|
||||
联调阶段无 Redis 时,用同一接口的内存实现:
|
||||
|
||||
```go
|
||||
type InMemorySessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionEntry
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
session models.Session
|
||||
history []models.Message
|
||||
activeReqID string
|
||||
}
|
||||
```
|
||||
|
||||
注入时根据配置切换:
|
||||
|
||||
```go
|
||||
var sessionMgr SessionManager
|
||||
if cfg.Redis.Addr != "" {
|
||||
sessionMgr = NewRedisSessionManager(redisClient, 30*time.Minute, 20)
|
||||
} else {
|
||||
sessionMgr = NewInMemorySessionManager()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、配置管理
|
||||
|
||||
使用 Viper 加载配置,支持 YAML 文件 + 环境变量覆盖。**环境变量优先级高于配置文件**。
|
||||
|
||||
### 配置文件位置
|
||||
|
||||
```
|
||||
backend/config.yaml # 默认加载
|
||||
backend/config.dev.yaml # 开发环境(go run 时使用)
|
||||
backend/config.prod.yaml # 生产环境
|
||||
```
|
||||
|
||||
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。
|
||||
|
||||
### Go 配置结构体
|
||||
|
||||
```go
|
||||
// Config 应用配置。
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Env string `mapstructure:"env"` // "dev" | "prod",默认 "dev"
|
||||
Version string `mapstructure:"version"` // 由编译时注入
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"` // 默认 "0.0.0.0"
|
||||
Port int `mapstructure:"port"` // 默认 8080
|
||||
ReadTimeout int `mapstructure:"read_timeout"` // 秒,默认 30
|
||||
WriteTimeout int `mapstructure:"write_timeout"` // 秒,默认 30
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string `mapstructure:"addr"` // "localhost:6379"
|
||||
Password string `mapstructure:"password"` // 无密码留空
|
||||
DB int `mapstructure:"db"` // 默认 0
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
STT STTConfig `mapstructure:"stt"`
|
||||
LLM LLMConfig `mapstructure:"llm"`
|
||||
TTS TTSConfig `mapstructure:"tts"`
|
||||
}
|
||||
|
||||
type STTConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "deepgram"
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Endpoint string `mapstructure:"endpoint"` // 默认 "wss://api.deepgram.com/v1/listen"
|
||||
}
|
||||
|
||||
type LLMConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "openai"
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"` // 默认 "gpt-4o"
|
||||
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
|
||||
Timeout int `mapstructure:"timeout"` // 秒,默认 10
|
||||
}
|
||||
|
||||
type TTSConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "openai"
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Voice string `mapstructure:"voice"` // 默认 "alloy"
|
||||
Speed float64 `mapstructure:"speed"` // 默认 1.0
|
||||
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
|
||||
Timeout int `mapstructure:"timeout"` // 秒,默认 5
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Driver string `mapstructure:"driver"` // "memory" | "postgres"
|
||||
DSN string `mapstructure:"dsn"` // PostgreSQL 连接串,driver=postgres 时必填
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
|
||||
Format string `mapstructure:"format"` // "json" | "console",生产用 json
|
||||
}
|
||||
```
|
||||
|
||||
### 配置文件示例
|
||||
|
||||
```yaml
|
||||
# config.yaml — 所有环境共享的默认值
|
||||
app:
|
||||
env: dev
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
read_timeout: 30
|
||||
write_timeout: 30
|
||||
|
||||
redis:
|
||||
addr: "localhost:6379"
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
ai:
|
||||
stt:
|
||||
provider: deepgram
|
||||
endpoint: "wss://api.deepgram.com/v1/listen"
|
||||
llm:
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
endpoint: "https://api.openai.com/v1"
|
||||
timeout: 10
|
||||
tts:
|
||||
provider: openai
|
||||
voice: alloy
|
||||
speed: 1.0
|
||||
endpoint: "https://api.openai.com/v1"
|
||||
timeout: 5
|
||||
|
||||
storage:
|
||||
driver: memory
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: console
|
||||
```
|
||||
|
||||
### 环境变量覆盖规则
|
||||
|
||||
Viper 自动将配置项映射为环境变量,规则:**前缀 `CAMTALK_` + 路径大写用 `_` 连接**。
|
||||
|
||||
| 配置项 | 环境变量 | 示例 |
|
||||
|--------|---------|------|
|
||||
| `server.port` | `CAMTALK_SERVER_PORT` | `8080` |
|
||||
| `redis.addr` | `CAMTALK_REDIS_ADDR` | `redis:6379` |
|
||||
| `redis.password` | `CAMTALK_REDIS_PASSWORD` | — |
|
||||
| `ai.stt.api_key` | `CAMTALK_AI_STT_API_KEY` | — |
|
||||
| `ai.llm.api_key` | `CAMTALK_AI_LLM_API_KEY` | — |
|
||||
| `ai.tts.api_key` | `CAMTALK_AI_TTS_API_KEY` | — |
|
||||
| `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` |
|
||||
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` |
|
||||
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | — |
|
||||
| `app.env` | `CAMTALK_APP_ENV` | `prod` |
|
||||
| `log.level` | `CAMTALK_LOG_LEVEL` | `warn` |
|
||||
| `log.format` | `CAMTALK_LOG_FORMAT` | `json` |
|
||||
|
||||
> API Key 和密码**只通过环境变量注入**,不写入配置文件,避免泄露到版本控制。
|
||||
|
||||
### 配置加载代码
|
||||
|
||||
```go
|
||||
// internal/config/config.go
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// 1. 读默认配置文件
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./config") // go run 时
|
||||
v.AddConfigPath(".") // 二进制运行时
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按环境覆盖
|
||||
env := os.Getenv("CAMTALK_APP_ENV")
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
v.SetConfigName("config." + env)
|
||||
v.MergeInConfig() // 忽略文件不存在
|
||||
|
||||
// 3. 环境变量覆盖
|
||||
v.SetEnvPrefix("CAMTALK")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// 4. 解析
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
### main.go 集成
|
||||
|
||||
```go
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
// 使用 cfg.Server.Port 替代硬编码 :8080
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("CamTalk gateway starting on %s (env=%s)", addr, cfg.App.Env)
|
||||
r.Run(addr)
|
||||
}
|
||||
```
|
||||
|
||||
### 启动方式
|
||||
|
||||
```bash
|
||||
# 开发环境(默认 config.yaml,API Key 通过环境变量注入)
|
||||
CAMTALK_AI_LLM_API_KEY=sk-xxx \
|
||||
CAMTALK_AI_STT_API_KEY=xxx \
|
||||
go run ./cmd/server
|
||||
|
||||
# 生产环境
|
||||
CAMTALK_APP_ENV=prod \
|
||||
CAMTALK_REDIS_ADDR=redis:6379 \
|
||||
CAMTALK_AI_LLM_API_KEY=sk-xxx \
|
||||
CAMTALK_AI_STT_API_KEY=xxx \
|
||||
CAMTALK_AI_TTS_API_KEY=xxx \
|
||||
CAMTALK_STORAGE_DRIVER=postgres \
|
||||
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
|
||||
CAMTALK_LOG_LEVEL=warn \
|
||||
CAMTALK_LOG_FORMAT=json \
|
||||
./bin/camtalk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、数据模型
|
||||
|
||||
> 注:Go 和 TypeScript 的数据模型定义见下方。AI 服务层的 Go 模型见上方"AI 服务层接口"章节。
|
||||
|
||||
### Go 后端模型
|
||||
|
||||
@@ -326,7 +980,7 @@ type ClientMessage =
|
||||
|
||||
---
|
||||
|
||||
## 四、扩展接口设计
|
||||
## 八、扩展接口设计
|
||||
|
||||
通过 Repository 接口隔离存储层,MVP 用内存实现,后续替换为数据库——业务逻辑零改动。
|
||||
|
||||
@@ -402,7 +1056,7 @@ func NewApp(cfg *Config) *App {
|
||||
|
||||
---
|
||||
|
||||
## 五、错误码
|
||||
## 九、错误码
|
||||
|
||||
| 错误码 | 含义 | 客户端处理建议 |
|
||||
|--------|------|--------------|
|
||||
@@ -417,7 +1071,7 @@ func NewApp(cfg *Config) *App {
|
||||
| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 |
|
||||
| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 |
|
||||
|
||||
## 六、连接管理
|
||||
## 十、连接管理
|
||||
|
||||
**心跳机制**:客户端每 30 秒发送 `ping`,服务端回复 `pong`。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
||||
|------|------|
|
||||
| [01-项目概述](01-项目概述.md) | 项目目标、核心挑战、交付物 |
|
||||
| [02-系统架构](02-系统架构.md) | 三层架构、技术栈、核心交互流程、前后端模块、存储策略、部署架构 |
|
||||
| [03-接口文档](03-接口文档.md) | WebSocket 协议、REST API、数据模型、错误码、连接管理(**实现时首先阅读**) |
|
||||
| [03-接口文档](03-接口文档.md) | WebSocket 协议、REST API、**AI 服务层接口**、**编排器设计**、**Session Manager**、**配置管理(Viper)**、数据模型、错误码、连接管理(**实现时首先阅读**) |
|
||||
| [04-技术选型](04-技术选型.md) | 持久化层(PostgreSQL)和前端边缘处理层的选型对比与决策理由 |
|
||||
| [05-用户故事](05-用户故事.md) | P0/P1/P2 用户故事、验收标准、优先级决策依据 |
|
||||
| [06-语音交互](06-语音交互.md) | VAD → STT → LLM → TTS 全链路、延迟优化 |
|
||||
|
||||
@@ -103,6 +103,15 @@ body {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vad-indicator--loading {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.vad-indicator--error {
|
||||
color: var(--color-error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
@@ -110,10 +119,10 @@ body {
|
||||
|
||||
.chat-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Video Preview ---- */
|
||||
@@ -148,6 +157,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-panel--empty {
|
||||
@@ -239,3 +250,111 @@ body {
|
||||
.btn--warning:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ---- Streaming Cursor ---- */
|
||||
|
||||
.cursor {
|
||||
display: inline;
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- Message Meta ---- */
|
||||
|
||||
.chat-message__meta {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ---- System Message ---- */
|
||||
|
||||
.system-message {
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.system-message--warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast--warning {
|
||||
background: rgba(245, 158, 11, 0.9);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background: rgba(37, 99, 235, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Video Overlay ---- */
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.video-overlay__spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px 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); }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useVisionSession } from "./hooks/useVisionSession";
|
||||
import { VideoPreview } from "./components/VideoPreview";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@@ -13,6 +14,8 @@ function App() {
|
||||
currentReply,
|
||||
isProcessing,
|
||||
isSpeaking,
|
||||
isVADReady,
|
||||
vadError,
|
||||
connectionStatus,
|
||||
videoRef,
|
||||
stream,
|
||||
@@ -21,12 +24,18 @@ function App() {
|
||||
interrupt,
|
||||
} = useVisionSession();
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>CamTalk</h1>
|
||||
<span className={`status status--${connectionStatus}`}>
|
||||
{connectionStatus === "connected" ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"}
|
||||
{isConnected
|
||||
? "已连接"
|
||||
: connectionStatus === "connecting"
|
||||
? "连接中..."
|
||||
: "未连接"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -34,23 +43,41 @@ function App() {
|
||||
<div className="video-section">
|
||||
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
||||
{isSpeaking && <div className="vad-indicator">🎤 正在聆听...</div>}
|
||||
{isConnected && !isVADReady && !vadError && (
|
||||
<div className="vad-indicator vad-indicator--loading">
|
||||
正在初始化语音检测...
|
||||
</div>
|
||||
)}
|
||||
{vadError && (
|
||||
<div className="vad-indicator vad-indicator--error">
|
||||
⚠️ {vadError}
|
||||
</div>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="video-overlay">
|
||||
<div className="video-overlay__spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-section">
|
||||
<ChatPanel messages={messages} />
|
||||
{currentReply && (
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
<div className="chat-message__role">AI</div>
|
||||
<div className="chat-message__content">{currentReply}</div>
|
||||
{connectionStatus === "disconnected" && messages.length > 0 && (
|
||||
<div className="system-message system-message--warning">
|
||||
连接已断开,正在重连...
|
||||
</div>
|
||||
)}
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
currentReply={currentReply}
|
||||
connectionStatus={connectionStatus}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
{connectionStatus !== "connected" ? (
|
||||
{!isConnected ? (
|
||||
<button className="btn btn--primary" onClick={startSession}>
|
||||
开始对话
|
||||
{connectionStatus === "connecting" ? "连接中..." : "开始对话"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -65,6 +92,8 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,90 @@
|
||||
// ============================================================
|
||||
// ChatPanel — 消息展示面板
|
||||
// 职责:渲染对话消息列表(用户提问 + AI 回复)
|
||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ChatMessage } from "../../types";
|
||||
import type { ConnectionStatus } from "../../lib/websocket";
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages }: ChatPanelProps) {
|
||||
if (messages.length === 0) {
|
||||
export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
|
||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
isAutoScroll.current = scrollHeight - scrollTop - clientHeight < 60;
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// 新消息或流式更新时自动滚动
|
||||
useEffect(() => {
|
||||
if (isAutoScroll.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages, currentReply]);
|
||||
|
||||
// 空状态
|
||||
if (messages.length === 0 && !currentReply) {
|
||||
if (connectionStatus !== "connected") {
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<p>开始对话:对着摄像头说话即可</p>
|
||||
<p>点击下方按钮开始对话</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<p>对着摄像头说话即可</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel" ref={containerRef}>
|
||||
{messages.map((msg, index) => (
|
||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div className="chat-message__role">
|
||||
{msg.role === "user" ? "你" : "AI"}
|
||||
</div>
|
||||
<div className="chat-message__content">{msg.content}</div>
|
||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||
<div className="chat-message__meta">
|
||||
{msg.tokensUsed} tokens
|
||||
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
||||
{msg.model && ` · ${msg.model}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 流式回复(尚未完成) */}
|
||||
{currentReply && (
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
<div className="chat-message__role">AI</div>
|
||||
<div className="chat-message__content">
|
||||
{currentReply}
|
||||
<span className="cursor">▌</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,35 +4,103 @@
|
||||
// 技术:@ricky0123/vad-web(VAD)、ONNX Runtime Web(关键帧检测)
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
|
||||
export interface VADOptions {
|
||||
/** 语音结束回调,携带录音 Float32Array */
|
||||
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
||||
onSpeechEnd?: (audio: Float32Array) => void;
|
||||
/** 语音开始回调 */
|
||||
onSpeechStart?: () => void;
|
||||
/** 语音过短被忽略回调 */
|
||||
onVADMisfire?: () => void;
|
||||
}
|
||||
|
||||
export function useVAD(_options?: VADOptions) {
|
||||
const [isSpeaking] = useState(false);
|
||||
// TODO: 初始化 @ricky0123/vad-web,加载后设为 true
|
||||
const isReady = false;
|
||||
/**
|
||||
* 语音活动检测 Hook
|
||||
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
||||
*/
|
||||
export function useVAD(options?: VADOptions) {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const vadRef = useRef<MicVAD | null>(null);
|
||||
const optionsRef = useRef(options);
|
||||
|
||||
// TODO: 实现 VAD 初始化
|
||||
// 1. 加载 @ricky0123/vad-web
|
||||
// 2. 配置 VAD 参数(阈值、最小语音时长等)
|
||||
// 3. 连接麦克风 stream
|
||||
// 4. 在 onSpeechEnd 时收集音频并回调 _options.onSpeechEnd
|
||||
// 保持 options 引用最新,避免回调闭包问题
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
// TODO: 启动 VAD 监听
|
||||
/**
|
||||
* 初始化 VAD 并开始监听
|
||||
* @param stream 麦克风 MediaStream(由外部管理)
|
||||
*/
|
||||
const start = useCallback(async (stream: MediaStream) => {
|
||||
// 如果已有实例,先销毁
|
||||
if (vadRef.current) {
|
||||
await vadRef.current.destroy();
|
||||
vadRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const vad = await MicVAD.new({
|
||||
getStream: () => Promise.resolve(stream),
|
||||
startOnLoad: true,
|
||||
model: "legacy",
|
||||
|
||||
onSpeechStart: () => {
|
||||
setIsSpeaking(true);
|
||||
optionsRef.current?.onSpeechStart?.();
|
||||
},
|
||||
|
||||
onSpeechEnd: (audio: Float32Array) => {
|
||||
setIsSpeaking(false);
|
||||
optionsRef.current?.onSpeechEnd?.(audio);
|
||||
},
|
||||
|
||||
onVADMisfire: () => {
|
||||
setIsSpeaking(false);
|
||||
optionsRef.current?.onVADMisfire?.();
|
||||
},
|
||||
|
||||
// VAD 参数(对齐 docs/06-语音交互.md 推荐值)
|
||||
positiveSpeechThreshold: 0.5,
|
||||
negativeSpeechThreshold: 0.35,
|
||||
redemptionMs: 300,
|
||||
preSpeechPadMs: 300,
|
||||
minSpeechMs: 250,
|
||||
submitUserSpeechOnPause: false,
|
||||
});
|
||||
|
||||
vadRef.current = vad;
|
||||
setIsReady(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "VAD 初始化失败";
|
||||
setError(message);
|
||||
console.error("[VAD] 初始化失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// TODO: 停止 VAD 监听
|
||||
/** 停止 VAD 并销毁实例 */
|
||||
const stop = useCallback(async () => {
|
||||
if (vadRef.current) {
|
||||
await vadRef.current.destroy();
|
||||
vadRef.current = null;
|
||||
}
|
||||
setIsReady(false);
|
||||
setIsSpeaking(false);
|
||||
}, []);
|
||||
|
||||
return { isSpeaking, isReady, start, stop };
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
vadRef.current?.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isSpeaking, isReady, error, start, stop };
|
||||
}
|
||||
|
||||
// ---- 关键帧检测(ONNX Runtime Web)----
|
||||
@@ -41,11 +109,6 @@ export function useKeyframeDetection() {
|
||||
// TODO: 加载 ONNX 模型后设为 true
|
||||
const isReady = false;
|
||||
|
||||
// TODO: 实现关键帧检测
|
||||
// 1. 加载 ONNX 模型
|
||||
// 2. 对比当前帧与上一帧的像素差异
|
||||
// 3. 超过阈值则判定为关键帧
|
||||
|
||||
const isKeyframe = useCallback(
|
||||
(_currentFrame: ImageData, _previousFrame: ImageData): boolean => {
|
||||
// TODO: 实现像素差异对比
|
||||
|
||||
51
frontend/src/components/Toast/index.tsx
Normal file
51
frontend/src/components/Toast/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================
|
||||
// Toast — 轻量通知组件
|
||||
// 职责:3 秒自动消失的通知提示
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { registerToastSetter, type ToastItem } from "../../lib/toast";
|
||||
|
||||
export type { ToastItem };
|
||||
|
||||
/** Toast 容器组件,放在 App 根部 */
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
// 注册全局 setter
|
||||
useEffect(() => {
|
||||
registerToastSetter(setToasts);
|
||||
return () => registerToastSetter(null);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((t) => (
|
||||
<ToastItemView key={t.id} item={t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItemView({
|
||||
item,
|
||||
onDismiss,
|
||||
}: {
|
||||
item: ToastItem;
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onDismiss(item.id), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [item.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className={`toast toast--${item.type}`} onClick={() => onDismiss(item.id)}>
|
||||
{item.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,14 @@
|
||||
// 来源:docs/02-系统架构.md 核心 Hook 设计
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { wsClient } from "../lib/websocket";
|
||||
import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio";
|
||||
import { getErrorMessage } from "../lib/errors";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { useCamera } from "../components/CameraManager";
|
||||
import { useMicrophone } from "../components/MicManager";
|
||||
import { useVAD } from "../components/EdgeProcessor";
|
||||
import { useWebSocketManager } from "../components/WebSocketManager";
|
||||
import type { ChatMessage, ServerMessage, LLMDoneMessage } from "../types";
|
||||
@@ -19,12 +22,31 @@ export function useVisionSession() {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { videoRef, captureFrame, startCamera, stopCamera, stream } = useCamera();
|
||||
const { startMic, stopMic } = useMicrophone();
|
||||
const { status, connect, disconnect, send } = useWebSocketManager();
|
||||
|
||||
// 用 ref 跟踪 isProcessing,避免 VAD 回调闭包问题
|
||||
const isProcessingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
isProcessingRef.current = isProcessing;
|
||||
}, [isProcessing]);
|
||||
|
||||
// VAD:语音结束时自动发送 query
|
||||
const { isSpeaking, start: startVAD, stop: stopVAD } = useVAD({
|
||||
const {
|
||||
isSpeaking,
|
||||
isReady: isVADReady,
|
||||
error: vadError,
|
||||
start: startVAD,
|
||||
stop: stopVAD,
|
||||
} = useVAD({
|
||||
onSpeechEnd: useCallback(
|
||||
(audio: Float32Array) => {
|
||||
// 处理中忽略,防止重复发送
|
||||
if (isProcessingRef.current) {
|
||||
console.warn("[Session] 正在处理中,忽略语音输入");
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = captureFrame();
|
||||
if (!frame) {
|
||||
console.warn("[Session] 无法捕获图像帧");
|
||||
@@ -39,7 +61,7 @@ export function useVisionSession() {
|
||||
audio: encodeAudioToBase64(audio),
|
||||
});
|
||||
|
||||
// 添加用户消息(STT 结果到达后会更新文本)
|
||||
// 添加用户消息(STT 流式结果会逐步更新文本)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: "(语音识别中...)", timestamp: Date.now() },
|
||||
@@ -54,43 +76,51 @@ export function useVisionSession() {
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.onMessage((msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case "stt_result":
|
||||
if (msg.is_final) {
|
||||
case "stt_result": {
|
||||
// 流式更新用户消息文本(包括中间结果和最终结果)
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastUserIdx = updated.findLastIndex((m) => m.role === "user");
|
||||
if (lastUserIdx >= 0) {
|
||||
updated[lastUserIdx] = { ...updated[lastUserIdx], content: msg.text };
|
||||
updated[lastUserIdx] = {
|
||||
...updated[lastUserIdx],
|
||||
content: msg.text || "(未识别到语音)",
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "llm_chunk":
|
||||
setCurrentReply((prev) => prev + msg.delta);
|
||||
break;
|
||||
|
||||
case "llm_done":
|
||||
case "llm_done": {
|
||||
const done = msg as LLMDoneMessage;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: (msg as LLMDoneMessage).full_text,
|
||||
content: done.full_text,
|
||||
timestamp: Date.now(),
|
||||
tokensUsed: (msg as LLMDoneMessage).tokens_used?.total,
|
||||
tokensUsed: done.tokens_used?.total,
|
||||
latencyMs: done.latency_ms,
|
||||
model: done.model,
|
||||
},
|
||||
]);
|
||||
setCurrentReply("");
|
||||
setIsProcessing(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tts_audio":
|
||||
// TODO: 音频流播放
|
||||
// TODO: 阶段 5 音频流播放
|
||||
break;
|
||||
|
||||
case "error":
|
||||
console.error("[Session] 服务端错误:", msg.code, msg.message);
|
||||
showToast(getErrorMessage(msg.code), "error");
|
||||
setIsProcessing(false);
|
||||
break;
|
||||
}
|
||||
@@ -99,31 +129,64 @@ export function useVisionSession() {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// 连接断开时显示提示
|
||||
useEffect(() => {
|
||||
if (status === "disconnected") {
|
||||
// 只在非主动断开时提示(通过检查是否有活跃会话判断)
|
||||
// 这里简单处理,由 App 层根据状态显示
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
/** 启动会话 */
|
||||
const startSession = useCallback(async () => {
|
||||
// 1. 获取摄像头和麦克风
|
||||
await startCamera();
|
||||
const micStream = await startMic();
|
||||
if (!micStream) {
|
||||
showToast("无法获取麦克风权限", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 连接 WebSocket
|
||||
connect();
|
||||
startVAD();
|
||||
}, [startCamera, connect, startVAD]);
|
||||
|
||||
// 3. 启动 VAD(传入麦克风 stream)
|
||||
await startVAD(micStream);
|
||||
}, [startCamera, startMic, connect, startVAD]);
|
||||
|
||||
/** 结束会话 */
|
||||
const stopSession = useCallback(() => {
|
||||
stopVAD();
|
||||
const stopSession = useCallback(async () => {
|
||||
await stopVAD();
|
||||
stopMic();
|
||||
stopCamera();
|
||||
disconnect();
|
||||
}, [stopVAD, stopCamera, disconnect]);
|
||||
// 清理所有对话状态
|
||||
setMessages([]);
|
||||
setCurrentReply("");
|
||||
setIsProcessing(false);
|
||||
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
||||
|
||||
/** 打断当前回复 */
|
||||
const interrupt = useCallback(() => {
|
||||
send({ type: "interrupt" });
|
||||
// 将未完成的流式内容保存为最终消息
|
||||
if (currentReply) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: currentReply + "(已打断)", timestamp: Date.now() },
|
||||
]);
|
||||
}
|
||||
setCurrentReply("");
|
||||
setIsProcessing(false);
|
||||
}, [send]);
|
||||
}, [send, currentReply]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
currentReply,
|
||||
isProcessing,
|
||||
isSpeaking,
|
||||
isVADReady,
|
||||
vadError,
|
||||
connectionStatus: status,
|
||||
videoRef,
|
||||
stream,
|
||||
|
||||
24
frontend/src/lib/errors.ts
Normal file
24
frontend/src/lib/errors.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// ============================================================
|
||||
// 错误码 → 用户友好文案映射
|
||||
// 来源:docs/03-接口文档.md §五 错误码
|
||||
// ============================================================
|
||||
|
||||
import type { ErrorCode } from "../types";
|
||||
|
||||
const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
INVALID_MESSAGE: "消息格式异常,请重试",
|
||||
SESSION_NOT_FOUND: "会话已过期,请重新连接",
|
||||
RATE_LIMITED: "请求太频繁,请稍后再试",
|
||||
IMAGE_TOO_LARGE: "图像过大,请降低分辨率",
|
||||
AUDIO_TOO_SHORT: "语音太短,请再说一句",
|
||||
LLM_TIMEOUT: "AI 响应超时,请重试",
|
||||
LLM_ERROR: "AI 服务异常,请稍后重试",
|
||||
STT_ERROR: "语音识别失败,请重试",
|
||||
TTS_ERROR: "语音合成失败",
|
||||
INTERNAL_ERROR: "服务内部错误,请重试",
|
||||
};
|
||||
|
||||
/** 将错误码转为用户友好文案 */
|
||||
export function getErrorMessage(code: string): string {
|
||||
return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`;
|
||||
}
|
||||
29
frontend/src/lib/toast.ts
Normal file
29
frontend/src/lib/toast.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// Toast 全局状态管理
|
||||
// 与 Toast 组件配合使用
|
||||
// ============================================================
|
||||
|
||||
export type ToastType = "error" | "warning" | "info";
|
||||
|
||||
let nextId = 0;
|
||||
let _setToasts: React.Dispatch<React.SetStateAction<ToastItem[]>> | null = null;
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 注册 Toast state setter(由 ToastContainer 组件调用) */
|
||||
export function registerToastSetter(
|
||||
setter: React.Dispatch<React.SetStateAction<ToastItem[]>> | null,
|
||||
) {
|
||||
_setToasts = setter;
|
||||
}
|
||||
|
||||
/** 显示一条 Toast */
|
||||
export function showToast(message: string, type: ToastType = "info") {
|
||||
if (!_setToasts) return;
|
||||
const id = nextId++;
|
||||
_setToasts((prev) => [...prev, { id, type, message }]);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export interface ChatMessage {
|
||||
imageUrl?: string;
|
||||
timestamp: number;
|
||||
tokensUsed?: number;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// ---- WebSocket 通用信封 ----
|
||||
|
||||
Reference in New Issue
Block a user