Merge pull request '添加计时器,麦克风,摄像头开关' (#45) from develop-frontend8 into develop 33分钟前 #46
163
docs/03-接口文档.md
163
docs/03-接口文档.md
@@ -256,7 +256,6 @@ POST /api/sessions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"user_id": "optional-user-id",
|
||||
"config": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
@@ -301,28 +300,18 @@ Go 网关内部与外部 AI 服务(Deepgram STT、GPT-4o、OpenAI TTS)的调
|
||||
语音识别:接收前端采集的音频,返回识别文本。
|
||||
|
||||
```go
|
||||
// STTService 语音识别服务契约。
|
||||
type STTService interface {
|
||||
// Service 语音识别服务契约。
|
||||
type Service 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)
|
||||
Recognize(ctx context.Context, audio []byte, opts Options) (string, error)
|
||||
}
|
||||
|
||||
// STTOptions 语音识别参数。
|
||||
type STTOptions struct {
|
||||
// Options 语音识别参数。
|
||||
type Options struct {
|
||||
Encoding string // "pcm_s16le" — 前端 VAD 输出格式
|
||||
SampleRate int // 16000 — 前端麦克风采样率
|
||||
Language string // "zh-CN"
|
||||
}
|
||||
|
||||
// STTPartial 流式识别的中间/最终结果。
|
||||
type STTPartial struct {
|
||||
Text string
|
||||
IsFinal bool
|
||||
}
|
||||
```
|
||||
|
||||
**Deepgram 接入约定**:
|
||||
@@ -336,23 +325,23 @@ type STTPartial struct {
|
||||
多模态推理:接收图像 + 文本 + 对话历史,流式返回回复。
|
||||
|
||||
```go
|
||||
// LLMService 多模态大模型服务契约。
|
||||
type LLMService interface {
|
||||
// Service 多模态大模型服务契约。
|
||||
type Service interface {
|
||||
// ChatStream 流式推理,返回增量文本的 channel。
|
||||
// 调用方必须消费 channel 直到 Done=true,否则需 cancel ctx 以释放连接。
|
||||
ChatStream(ctx context.Context, req LLMRequest) (<-chan LLMChunk, error)
|
||||
ChatStream(ctx context.Context, req Request) (<-chan Chunk, error)
|
||||
}
|
||||
|
||||
// LLMRequest 推理请求。
|
||||
type LLMRequest struct {
|
||||
// Request 推理请求。
|
||||
type Request struct {
|
||||
Image []byte // JPEG 图片(已从 Base64 解码)
|
||||
Text string // 用户语音识别后的文本
|
||||
History []Message // 最近 N 轮对话历史
|
||||
History []models.Message // 最近 N 轮对话历史
|
||||
Language string // "zh-CN"
|
||||
}
|
||||
|
||||
// LLMChunk 流式推理的一个增量片段。
|
||||
type LLMChunk struct {
|
||||
// Chunk 流式推理的一个增量片段。
|
||||
type Chunk struct {
|
||||
Delta string // 增量文本
|
||||
Done bool // 是否结束
|
||||
TokensUsed *TokenUsage // 仅 Done=true 时有值
|
||||
@@ -388,24 +377,24 @@ user: [图片 + 用户语音文本]
|
||||
语音合成:接收文本流,输出音频 chunk 流。
|
||||
|
||||
```go
|
||||
// TTSService 语音合成服务契约。
|
||||
type TTSService interface {
|
||||
// Service 语音合成服务契约。
|
||||
type Service interface {
|
||||
// SynthesizeStream 流式合成。
|
||||
// textStream 接收句子级文本(由 Orchestrator 的句子切分器产出),
|
||||
// 返回的 channel 持续输出 MP3 音频 chunk。
|
||||
SynthesizeStream(ctx context.Context, textStream <-chan string, opts TTSOptions) (<-chan TTSChunk, error)
|
||||
SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error)
|
||||
}
|
||||
|
||||
// TTSOptions 合成参数。
|
||||
type TTSOptions struct {
|
||||
// Options 合成参数。
|
||||
type Options struct {
|
||||
Voice string // "alloy" | "nova" | "shimmer" | ...
|
||||
Speed float64 // 1.0 为正常语速
|
||||
OutputFmt string // "mp3" — 固定使用 MP3,浏览器原生支持
|
||||
SampleRate int // 24000
|
||||
}
|
||||
|
||||
// TTSChunk 一个音频片段。
|
||||
type TTSChunk struct {
|
||||
// Chunk 一个音频片段。
|
||||
type Chunk struct {
|
||||
Audio []byte // MP3 音频数据(未 Base64 编码,由发送层编码)
|
||||
IsLast bool // 是否为最后一片
|
||||
}
|
||||
@@ -446,73 +435,32 @@ LLM 流式输出: "这" "是一" "朵红色" "的花。" "它看起" "来很美
|
||||
### Orchestrator 接口
|
||||
|
||||
```go
|
||||
// Orchestrator AI 编排器,协调 STT → LLM → TTS 全链路。
|
||||
type Orchestrator struct {
|
||||
stt STTService
|
||||
llm LLMService
|
||||
tts TTSService
|
||||
}
|
||||
|
||||
// Orchestrator AI 编排器接口。
|
||||
type Orchestrator interface {
|
||||
// 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)
|
||||
}
|
||||
// 通过 sender 向前端实时推送 stt_result、llm_chunk、llm_done、tts_audio 消息。
|
||||
ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
|
||||
history []models.Message, sender Sender) error
|
||||
}
|
||||
|
||||
// isSentenceEnd 判断 delta 中是否包含句子结束标志。
|
||||
func isSentenceEnd(delta string) bool {
|
||||
return strings.ContainsAny(delta, "。!?\n.!?\n")
|
||||
// Sender 抽象 WebSocket 消息推送能力,便于测试时 mock。
|
||||
type Sender interface {
|
||||
SendSTTResult(result models.WsSTTResult) error
|
||||
SendLLMChunk(chunk models.WsLLMChunk) error
|
||||
SendLLMDone(done models.WsLLMDone) error
|
||||
SendTTSAudio(audio models.WsTTSAudio) error
|
||||
SendError(err models.WsError) error
|
||||
}
|
||||
```
|
||||
|
||||
**Pipeline 实现**(`internal/orchestrator/pipeline.go`):
|
||||
1. Base64 解码音频/图片
|
||||
2. 调用 `stt.Recognize()` → 发送 `stt_result`
|
||||
3. 调用 `llm.ChatStream()` 获取流式输出,goroutine 消费 token → 发送 `llm_chunk` + 句子切分
|
||||
4. 另一 goroutine 从句子 channel 读取 → 调用 `tts.SynthesizeStream()` → 发送 `tts_audio`
|
||||
5. 流结束 → 发送 `llm_done`
|
||||
6. TTS 失败静默跳过,STT/LLM 失败发送对应 error 消息
|
||||
|
||||
### 并发控制
|
||||
|
||||
- 每个 `ProcessQuery` 调用在独立 goroutine 中运行
|
||||
@@ -584,9 +532,9 @@ session:{id}:history → List (对话历史)
|
||||
### 接口定义
|
||||
|
||||
```go
|
||||
// SessionManager 会话管理器。
|
||||
// WebSocket Handler 通过此接口操作会话,不直接接触 Redis。
|
||||
type SessionManager interface {
|
||||
// Manager 会话管理器接口。
|
||||
// WebSocket Handler 通过此接口操作会话,不直接接触存储层。
|
||||
type Manager interface {
|
||||
// Create 创建新会话,返回 session ID。
|
||||
Create(ctx context.Context, config models.SessionConfig) (string, error)
|
||||
|
||||
@@ -605,14 +553,20 @@ type SessionManager interface {
|
||||
// SetActiveRequest 标记当前正在处理的请求 ID(interrupt 用)。
|
||||
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
||||
|
||||
// GetActiveRequestID 获取当前活跃请求 ID。
|
||||
GetActiveRequestID(ctx context.Context, sessionID string) (string, error)
|
||||
|
||||
// ClearActiveRequest 清除活跃请求标记(请求完成或中断后)。
|
||||
ClearActiveRequest(ctx context.Context, sessionID string) error
|
||||
|
||||
// Touch 刷新 TTL(心跳时调用)。
|
||||
Touch(ctx context.Context, sessionID string) error
|
||||
|
||||
// Destroy 显式销毁会话(REST API DELETE 或连接断开清理)。
|
||||
// Destroy 显式销毁会话(REST API DELETE)。
|
||||
Destroy(ctx context.Context, sessionID string) error
|
||||
|
||||
// ActiveCount 返回当前活跃会话数(健康检查用)。
|
||||
ActiveCount() int
|
||||
}
|
||||
```
|
||||
|
||||
@@ -629,7 +583,8 @@ case "query":
|
||||
|
||||
history, _ := sessionMgr.GetHistory(ctx, sessionID, 20) // 获取对话上下文
|
||||
|
||||
go orchestrator.ProcessQuery(ctx, client, &msg, history) // 异步编排
|
||||
sender := &WSClient{client: client, requestID: msg.RequestID}
|
||||
go orch.ProcessQuery(ctx, sessionID, msg, history, sender) // 异步编排
|
||||
|
||||
// interrupt 分支
|
||||
case "interrupt":
|
||||
@@ -648,26 +603,30 @@ case "interrupt":
|
||||
联调阶段无 Redis 时,用同一接口的内存实现:
|
||||
|
||||
```go
|
||||
type InMemorySessionManager struct {
|
||||
type MemoryManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionEntry
|
||||
ttl time.Duration
|
||||
maxHistory int
|
||||
stopCleaner chan struct{}
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
session models.Session
|
||||
history []models.Message
|
||||
activeReqID string
|
||||
lastActive time.Time
|
||||
}
|
||||
```
|
||||
|
||||
注入时根据配置切换:
|
||||
|
||||
```go
|
||||
var sessionMgr SessionManager
|
||||
var sessionMgr session.Manager
|
||||
if cfg.Redis.Addr != "" {
|
||||
sessionMgr = NewRedisSessionManager(redisClient, 30*time.Minute, 20)
|
||||
sessionMgr = session.NewRedisManager(redisClient, 30*time.Minute, 20)
|
||||
} else {
|
||||
sessionMgr = NewInMemorySessionManager()
|
||||
sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -933,8 +892,6 @@ type QueryRequest struct {
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
TokensUsed int `json:"tokens_used,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user