Merge pull request 'fix: 修复 deploy 工作流缺少 Node.js 导致 checkout 失败的问题' #72
@@ -11,7 +11,7 @@ CAMTALK_AI_TTS_API_KEY=
|
|||||||
# CAMTALK_AI_LLM_MODEL=gpt-4o
|
# CAMTALK_AI_LLM_MODEL=gpt-4o
|
||||||
# CAMTALK_AI_LLM_ENDPOINT=https://api.openai.com/v1
|
# CAMTALK_AI_LLM_ENDPOINT=https://api.openai.com/v1
|
||||||
# CAMTALK_AI_LLM_TIMEOUT=10
|
# CAMTALK_AI_LLM_TIMEOUT=10
|
||||||
# CAMTALK_AI_STT_ENDPOINT=wss://api.deepgram.com/v1/listen
|
# CAMTALK_AI_STT_ENDPOINT=https://api.xiaomimimo.com/v1
|
||||||
# CAMTALK_AI_TTS_ENDPOINT=https://api.openai.com/v1
|
# CAMTALK_AI_TTS_ENDPOINT=https://api.openai.com/v1
|
||||||
# CAMTALK_AI_TTS_VOICE=alloy
|
# CAMTALK_AI_TTS_VOICE=alloy
|
||||||
# CAMTALK_AI_TTS_SPEED=1.0
|
# CAMTALK_AI_TTS_SPEED=1.0
|
||||||
|
|||||||
63
.gitea/workflows/deploy.yml
Normal file
63
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---- PR 时:验证 Docker 镜像可构建 ----
|
||||||
|
verify:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: aliyun
|
||||||
|
steps:
|
||||||
|
- name: Setup Node.js
|
||||||
|
run: |
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
apk add --no-cache nodejs
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: "http://8.161.227.145:3000/huanghaosheng/checkout@releases/v4"
|
||||||
|
|
||||||
|
- name: Verify Frontend Build
|
||||||
|
run: docker build -t camtalk-frontend-test ./frontend
|
||||||
|
|
||||||
|
- name: Verify Backend Build
|
||||||
|
run: docker build -t camtalk-backend-test ./backend
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
docker rmi camtalk-frontend-test camtalk-backend-test || true
|
||||||
|
|
||||||
|
# ---- push main 时:构建并部署 ----
|
||||||
|
deploy:
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
runs-on: aliyun
|
||||||
|
steps:
|
||||||
|
- name: Setup Node.js
|
||||||
|
run: |
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
apk add --no-cache nodejs
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: "http://8.161.227.145:3000/huanghaosheng/checkout@releases/v4"
|
||||||
|
|
||||||
|
- name: Build and Deploy
|
||||||
|
run: |
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh build
|
||||||
|
./deploy.sh restart
|
||||||
|
|
||||||
|
- name: Health Check
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if curl -sf http://localhost/api/health > /dev/null 2>&1; then
|
||||||
|
echo "服务启动成功"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "等待服务启动... ($i/10)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "服务启动超时"
|
||||||
|
exit 1
|
||||||
36
CLAUDE.md
36
CLAUDE.md
@@ -12,24 +12,23 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
|||||||
|
|
||||||
三层系统:
|
三层系统:
|
||||||
|
|
||||||
1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 ONNX Runtime Web)、UI 渲染。核心 Hook:`useVisionSession()`
|
1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 Canvas 像素比较)、UI 渲染。核心 Hook:`useVisionSession()`
|
||||||
2. **Go 网关**(gorilla/websocket, Redis, Viper, Zap)—— WebSocket 服务器、会话管理、模型路由、AI 编排、速率限制。每个 WebSocket 连接一个 goroutine。
|
2. **Go 网关**(Gin, gorilla/websocket, Viper, Zap)—— WebSocket 服务器、会话管理、AI 编排。每个 WebSocket 连接一个 goroutine。
|
||||||
3. **云端 AI 服务** —— GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。
|
3. **云端 AI 服务** —— 通过 OpenAI 兼容接口可灵活切换。默认:GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。
|
||||||
|
|
||||||
**关键模式**:LLM 文本流和 TTS 音频流并行推送给客户端,以最小化感知延迟。
|
**关键模式**:LLM 文本流和 TTS 音频流并行推送给客户端,以最小化感知延迟。
|
||||||
|
|
||||||
**存储**:冷热分离 —— Redis 存实时会话状态,PostgreSQL 存对话历史和用量统计(MVP 后引入)。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。
|
**存储**:MVP 阶段使用进程内存(`MemoryManager`),Redis 实现已就绪可通过配置切换,PostgreSQL 为规划中。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 前端 | React 18, TypeScript, Vite, ONNX Runtime Web, @ricky0123/vad-web |
|
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
|
||||||
| 后端 | Go, gorilla/websocket, Redis, Viper, Zap |
|
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
|
||||||
| LLM | GPT-4o(主), Claude Sonnet(备) |
|
| LLM | GPT-4o(默认,通过 OpenAI 兼容接口可切换) |
|
||||||
| STT | Deepgram(主), FunASR(自部署备选) |
|
| STT | Deepgram(默认) / MiMo ASR |
|
||||||
| TTS | OpenAI TTS(主), Edge TTS(免费替代) |
|
| TTS | OpenAI TTS(默认) / MiMo TTS |
|
||||||
| 模型路由 | GPT-4o-mini 用于轻量分类 |
|
|
||||||
|
|
||||||
## 构建与运行命令
|
## 构建与运行命令
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ go test -run TestName ./path # 运行单个测试
|
|||||||
go vet ./... # 静态分析
|
go vet ./... # 静态分析
|
||||||
```
|
```
|
||||||
|
|
||||||
基础设施:Redis 为会话状态必需。PostgreSQL 为 MVP 可选(内存回退)。
|
基础设施:MVP 使用进程内存管理会话状态。Redis 已实现可通过配置切换,PostgreSQL 为规划中。
|
||||||
|
|
||||||
## WebSocket 协议
|
## WebSocket 协议
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ go vet ./... # 静态分析
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `CameraManager` | 摄像头流采集 |
|
| `CameraManager` | 摄像头流采集 |
|
||||||
| `MicManager` | 麦克风音频采集 |
|
| `MicManager` | 麦克风音频采集 |
|
||||||
| `EdgeProcessor` | VAD + 关键帧检测(ONNX Runtime) |
|
| `EdgeProcessor` | VAD + 关键帧检测(Canvas 像素比较) |
|
||||||
| `WebSocketManager` | WebSocket 连接生命周期管理 |
|
| `WebSocketManager` | WebSocket 连接生命周期管理 |
|
||||||
| `ChatPanel` | 消息展示 |
|
| `ChatPanel` | 消息展示 |
|
||||||
| `VideoPreview` | 摄像头画面预览 |
|
| `VideoPreview` | 摄像头画面预览 |
|
||||||
@@ -89,11 +88,14 @@ go vet ./... # 静态分析
|
|||||||
|
|
||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| WebSocket Hub | 连接管理、广播/定向推送 |
|
| WebSocket Handler | 连接管理、单播消息推送 |
|
||||||
| Session Manager | 会话状态、对话历史(Redis + TTL) |
|
| Session Manager | 会话状态、对话历史(Memory/Redis,30 分钟 TTL) |
|
||||||
| Model Router | 按请求选择 AI 模型(规则引擎 + 成本阈值) |
|
| AI Orchestrator | STT→LLM→TTS 流式并行管道编排 |
|
||||||
| AI Orchestrator | 并行/串行 AI 调用编排,context 超时控制 |
|
| AI Service Layer | AI 服务抽象层(STT/LLM/TTS 多 provider) |
|
||||||
| Rate Limiter | 按用户的令牌桶速率限制 |
|
| REST API | 健康检查、会话管理(Gin 路由) |
|
||||||
|
| Models | 数据模型定义 |
|
||||||
|
| Model Router | 按请求选择 AI 模型(规划中) |
|
||||||
|
| Rate Limiter | 按用户的令牌桶速率限制(规划中) |
|
||||||
|
|
||||||
## 编码规范
|
## 编码规范
|
||||||
|
|
||||||
|
|||||||
135
README.md
135
README.md
@@ -1,2 +1,137 @@
|
|||||||
# CamTalk
|
# CamTalk
|
||||||
|
|
||||||
|
多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
三层系统,前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用:
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器客户端 Go 网关 :8080 云端 AI 服务
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ 媒体采集 │ │ WebSocket Handler│ │ STT(语音识别) │
|
||||||
|
│ VAD 语音检测 │ WebSocket│ Session Manager │ HTTP │ LLM(多模态推理) │
|
||||||
|
│ 关键帧检测 │ ◄──────► │ AI Orchestrator │ ◄──────► │ TTS(语音合成) │
|
||||||
|
│ UI 渲染 │ │ REST API │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键模式**:LLM 文本流和 TTS 音频流并行推送,用户先看到文字、紧接着听到语音,感知延迟 < 0.5 秒。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
|
||||||
|
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
|
||||||
|
| STT | Deepgram(默认) / MiMo ASR |
|
||||||
|
| LLM | GPT-4o(默认,通过 OpenAI 兼容接口可切换) |
|
||||||
|
| TTS | OpenAI TTS(默认) / MiMo TTS |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
CamTalk/
|
||||||
|
├── frontend/ # 浏览器客户端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ │ ├── CameraManager/ # 摄像头流采集
|
||||||
|
│ │ ├── MicManager/ # 麦克风音频采集
|
||||||
|
│ │ ├── EdgeProcessor/ # VAD + 关键帧检测
|
||||||
|
│ │ ├── WebSocketManager/ # WS 连接管理
|
||||||
|
│ │ ├── ChatPanel/ # 消息展示
|
||||||
|
│ │ ├── VideoPreview/ # 摄像头画面预览
|
||||||
|
│ │ ├── ConfigPanel/ # 配置面板
|
||||||
|
│ │ └── Toast/ # 通知提示
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ │ ├── useVisionSession.ts # 核心会话 Hook
|
||||||
|
│ │ └── useObservationMode.ts # 观察模式
|
||||||
|
│ ├── lib/ # 工具库
|
||||||
|
│ │ ├── websocket.ts # WebSocket 连接管理
|
||||||
|
│ │ ├── audio.ts # 音频编码
|
||||||
|
│ │ ├── ttsPlayer.ts # TTS 播放器
|
||||||
|
│ │ └── sampling.ts # 采样策略
|
||||||
|
│ └── types/ # TypeScript 类型定义
|
||||||
|
├── backend/ # Go 网关
|
||||||
|
│ ├── cmd/server/ # 入口
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── ai/ # AI 服务抽象层
|
||||||
|
│ │ ├── llm/ # LLM 服务(OpenAI 兼容)
|
||||||
|
│ │ ├── stt/ # STT 服务(Deepgram/MiMo)
|
||||||
|
│ │ └── tts/ # TTS 服务(OpenAI/MiMo)
|
||||||
|
│ ├── orchestrator/ # AI 编排器(STT→LLM→TTS 管道)
|
||||||
|
│ ├── session/ # 会话管理(Memory/Redis)
|
||||||
|
│ ├── ws/ # WebSocket Handler
|
||||||
|
│ ├── api/ # REST API
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── models/ # 数据模型
|
||||||
|
│ ├── errors/ # 错误码
|
||||||
|
│ └── logger/ # 日志
|
||||||
|
├── docs/ # 设计文档
|
||||||
|
└── CLAUDE.md # Claude Code 指引
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
- Node.js >= 18
|
||||||
|
- Go >= 1.24
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite 开发服务器 http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod download
|
||||||
|
go run ./cmd/server # 启动网关 :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
后端配置文件位于 `backend/config.yaml`,支持环境变量覆盖(前缀 `CAMTALK_`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 最小启动(需要至少一个 AI 服务的 API Key)
|
||||||
|
cd backend
|
||||||
|
CAMTALK_AI_LLM_API_KEY=sk-xxx \
|
||||||
|
CAMTALK_AI_STT_API_KEY=xxx \
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
配置优先级:环境变量 > `config.{env}.yaml` > `config.yaml` > `.env`
|
||||||
|
|
||||||
|
## WebSocket 协议
|
||||||
|
|
||||||
|
连接地址:`ws://localhost:8080/ws`
|
||||||
|
|
||||||
|
所有消息为 JSON 文本帧,统一信封格式 `{type, request_id?, timestamp?}`。
|
||||||
|
|
||||||
|
**客户端 → 服务端**:`query`、`config`、`interrupt`、`ping`
|
||||||
|
**服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong`
|
||||||
|
|
||||||
|
完整协议见 [docs/03-接口文档.md](docs/03-接口文档.md)。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [01-项目概述](docs/01-项目概述.md) | 项目目标与核心挑战 |
|
||||||
|
| [02-系统架构](docs/02-系统架构.md) | 三层架构、技术栈、部署方案 |
|
||||||
|
| [03-接口文档](docs/03-接口文档.md) | WebSocket 协议、REST API、配置管理 |
|
||||||
|
| [04-技术选型](docs/04-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 |
|
||||||
|
| [05-用户故事](docs/05-用户故事.md) | 用户场景与优先级 |
|
||||||
|
| [06-语音交互](docs/06-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
|
||||||
|
| [07-视觉理解](docs/07-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
|
||||||
|
| [08-成本控制](docs/08-成本控制.md) | 采样策略、端云协同、模型分级 |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © XEngineers
|
||||||
|
|||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -3,7 +3,6 @@
|
|||||||
bin/
|
bin/
|
||||||
|
|
||||||
# 环境配置
|
# 环境配置
|
||||||
.env
|
|
||||||
config.dev.yaml
|
config.dev.yaml
|
||||||
config.prod.yaml
|
config.prod.yaml
|
||||||
|
|
||||||
|
|||||||
27
backend/Dockerfile
Normal file
27
backend/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ---- 构建阶段 ----
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先复制依赖清单,利用 Docker 缓存层
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源码并构建
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /camtalk ./cmd/server
|
||||||
|
|
||||||
|
# ---- 运行阶段 ----
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制二进制和配置
|
||||||
|
COPY --from=builder /camtalk .
|
||||||
|
COPY config.yaml .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["./camtalk"]
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ws"
|
"github.com/hhs/camtalk/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Version 通过构建时 -ldflags 注入,如:
|
||||||
|
// go build -ldflags "-X main.Version=v1.0.0" ./cmd/server
|
||||||
|
var Version string
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -42,16 +47,47 @@ func main() {
|
|||||||
// 初始化 Session Manager(MVP 默认内存实现)
|
// 初始化 Session Manager(MVP 默认内存实现)
|
||||||
var sessionMgr session.Manager
|
var sessionMgr session.Manager
|
||||||
// TODO: 当 Redis 配置非空时切换为 RedisManager
|
// TODO: 当 Redis 配置非空时切换为 RedisManager
|
||||||
sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
|
sessionMgr = session.NewMemoryManager(
|
||||||
|
time.Duration(cfg.Session.TTL)*time.Minute,
|
||||||
|
cfg.Session.MaxHistory,
|
||||||
|
)
|
||||||
defer sessionMgr.(*session.MemoryManager).Stop()
|
defer sessionMgr.(*session.MemoryManager).Stop()
|
||||||
|
|
||||||
// 初始化 AI 服务
|
// 初始化 AI 服务
|
||||||
sttService := stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log)
|
logger.Log.Infow("initializing AI services",
|
||||||
llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, logger.Log)
|
"stt.provider", cfg.AI.STT.Provider,
|
||||||
ttsService := tts.NewOpenAIService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Speed, cfg.AI.TTS.Timeout, logger.Log)
|
"stt.model", cfg.AI.STT.Model,
|
||||||
|
"llm.provider", cfg.AI.LLM.Provider,
|
||||||
|
"llm.model", cfg.AI.LLM.Model,
|
||||||
|
"tts.provider", cfg.AI.TTS.Provider,
|
||||||
|
"tts.model", cfg.AI.TTS.Model,
|
||||||
|
"tts.voice", cfg.AI.TTS.Voice,
|
||||||
|
)
|
||||||
|
|
||||||
|
var sttService stt.Service
|
||||||
|
switch strings.ToLower(cfg.AI.STT.Provider) {
|
||||||
|
case "mimo", "xiaomi":
|
||||||
|
sttService = stt.NewMiMoService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, cfg.AI.STT.Timeout, logger.Log)
|
||||||
|
logger.Log.Infow("STT service initialized", "provider", "mimo", "model", cfg.AI.STT.Model, "endpoint", cfg.AI.STT.Endpoint)
|
||||||
|
default:
|
||||||
|
sttService = stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, cfg.AI.STT.Timeout, logger.Log)
|
||||||
|
logger.Log.Infow("STT service initialized", "provider", "deepgram", "model", cfg.AI.STT.Model)
|
||||||
|
}
|
||||||
|
llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, cfg.AI.LLM.HTTPClientTimeout, logger.Log)
|
||||||
|
logger.Log.Infow("LLM service initialized", "provider", cfg.AI.LLM.Provider, "model", cfg.AI.LLM.Model, "endpoint", cfg.AI.LLM.Endpoint, "timeout", cfg.AI.LLM.Timeout)
|
||||||
|
|
||||||
|
var ttsService tts.Service
|
||||||
|
switch strings.ToLower(cfg.AI.TTS.Provider) {
|
||||||
|
case "mimo", "xiaomi":
|
||||||
|
ttsService = tts.NewMiMoService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Timeout, cfg.AI.TTS.HTTPClientTimeout, logger.Log)
|
||||||
|
logger.Log.Infow("TTS service initialized", "provider", "mimo", "model", cfg.AI.TTS.Model, "voice", cfg.AI.TTS.Voice, "endpoint", cfg.AI.TTS.Endpoint)
|
||||||
|
default:
|
||||||
|
ttsService = tts.NewOpenAIService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Speed, cfg.AI.TTS.Timeout, cfg.AI.TTS.HTTPClientTimeout, logger.Log)
|
||||||
|
logger.Log.Infow("TTS service initialized", "provider", "openai", "model", cfg.AI.TTS.Model, "voice", cfg.AI.TTS.Voice, "speed", cfg.AI.TTS.Speed)
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 Orchestrator
|
// 初始化 Orchestrator
|
||||||
orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg.AI.LLM.Model)
|
orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg)
|
||||||
|
|
||||||
// Gin 模式
|
// Gin 模式
|
||||||
if cfg.App.Env == "prod" {
|
if cfg.App.Env == "prod" {
|
||||||
@@ -64,7 +100,7 @@ func main() {
|
|||||||
// REST API
|
// REST API
|
||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api")
|
||||||
{
|
{
|
||||||
apiGroup.GET("/health", healthHandler(sessionMgr))
|
apiGroup.GET("/health", healthHandler(sessionMgr, cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session REST 端点
|
// Session REST 端点
|
||||||
@@ -72,7 +108,7 @@ func main() {
|
|||||||
sessionHandler.RegisterRoutes(apiGroup)
|
sessionHandler.RegisterRoutes(apiGroup)
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch))
|
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg))
|
||||||
|
|
||||||
// HTTP Server
|
// HTTP Server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -96,7 +132,7 @@ func main() {
|
|||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
logger.Log.Info("shutting down...")
|
logger.Log.Info("shutting down...")
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
@@ -106,11 +142,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// healthHandler 健康检查。
|
// healthHandler 健康检查。
|
||||||
func healthHandler(sessionMgr session.Manager) gin.HandlerFunc {
|
func healthHandler(sessionMgr session.Manager, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
version := Version
|
||||||
|
if version == "" {
|
||||||
|
version = cfg.App.Version
|
||||||
|
}
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": "0.1.0",
|
"version": version,
|
||||||
"uptime_seconds": int(time.Since(startTime).Seconds()),
|
"uptime_seconds": int(time.Since(startTime).Seconds()),
|
||||||
"active_sessions": sessionMgr.ActiveCount(),
|
"active_sessions": sessionMgr.ActiveCount(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,20 +15,23 @@ redis:
|
|||||||
|
|
||||||
ai:
|
ai:
|
||||||
stt:
|
stt:
|
||||||
provider: Xiaomi MiMo
|
provider: mimo
|
||||||
model: mimo-v2.5
|
model: mimo-v2.5-asr
|
||||||
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
endpoint: "https://api.xiaomimimo.com/v1"
|
||||||
|
api_key: "sk-c3jhv58rr5djhxw398w2rrij5tfpnpdgxqq1bojagshzviah"
|
||||||
llm:
|
llm:
|
||||||
provider: dashscope
|
provider: dashscope
|
||||||
model: qwen3-vl-plus
|
model: qwen3-vl-plus
|
||||||
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
api_key: "sk-ws-H.REHELLY.C4s3.MEUCIQCRee37XWEKp2szaxVLFDtR1rxNNsf372zMvCR0Xl6UvQIgZgvhRTvaa1FmhbCQJgaHu4Jny29AQkn01-3hX9CWBOg"
|
||||||
timeout: 30
|
timeout: 30
|
||||||
tts:
|
tts:
|
||||||
provider: Xiaomi MiMo
|
provider: mimo
|
||||||
model: mimo-v2.5
|
model: mimo-v2.5-tts
|
||||||
voice: alloy
|
voice: mimo_default
|
||||||
speed: 1.0
|
speed: 1.0
|
||||||
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
||||||
|
api_key: "tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
|
|||||||
@@ -26,24 +26,23 @@ type OpenAIService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAIService 创建 OpenAI LLM 服务。
|
// NewOpenAIService 创建 OpenAI LLM 服务。
|
||||||
func NewOpenAIService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
|
// model、endpoint 由 config 层保证非空。
|
||||||
if model == "" {
|
func NewOpenAIService(apiKey, model, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
|
||||||
model = "gpt-4o"
|
|
||||||
}
|
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
timeout := time.Duration(timeoutSec) * time.Second
|
timeout := time.Duration(timeoutSec) * time.Second
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 10 * time.Second
|
timeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
|
||||||
|
if httpClientTimeout <= 0 {
|
||||||
|
httpClientTimeout = 60 * time.Second
|
||||||
|
}
|
||||||
return &OpenAIService{
|
return &OpenAIService{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: model,
|
model: model,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{Timeout: 60 * time.Second}, // HTTP client timeout > LLM timeout
|
client: &http.Client{Timeout: httpClientTimeout},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +61,7 @@ type chatMessage struct {
|
|||||||
|
|
||||||
type contentPart struct {
|
type contentPart struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text"`
|
||||||
ImageURL *imageURL `json:"image_url,omitempty"`
|
ImageURL *imageURL `json:"image_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +100,9 @@ func (o *OpenAIService) ChatStream(ctx context.Context, req Request) (<-chan Chu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建带超时的 context
|
// 创建带超时的 context
|
||||||
ctx, cancel := context.WithTimeout(ctx, o.timeout)
|
ctx, cancel := context.WithTimeout(ctx, o.timeout)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestOpenAIService_ChatStream_Success(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||||
|
|
||||||
ch, err := svc.ChatStream(context.Background(), Request{
|
ch, err := svc.ChatStream(context.Background(), Request{
|
||||||
Text: "这是什么?",
|
Text: "这是什么?",
|
||||||
@@ -99,7 +99,7 @@ func TestOpenAIService_ChatStream_WithImage(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||||
|
|
||||||
ch, err := svc.ChatStream(context.Background(), Request{
|
ch, err := svc.ChatStream(context.Background(), Request{
|
||||||
Image: []byte("fake-jpeg-data"),
|
Image: []byte("fake-jpeg-data"),
|
||||||
@@ -123,7 +123,7 @@ func TestOpenAIService_ChatStream_WithHistory(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||||
|
|
||||||
ch, err := svc.ChatStream(context.Background(), Request{
|
ch, err := svc.ChatStream(context.Background(), Request{
|
||||||
Text: "继续",
|
Text: "继续",
|
||||||
@@ -149,7 +149,7 @@ func TestOpenAIService_ChatStream_APIError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar())
|
svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||||
|
|
||||||
_, err := svc.ChatStream(context.Background(), Request{
|
_, err := svc.ChatStream(context.Background(), Request{
|
||||||
Text: "test",
|
Text: "test",
|
||||||
@@ -172,7 +172,7 @@ func TestOpenAIService_ChatStream_Timeout(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, zap.NewNop().Sugar()) // 1s timeout
|
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, 60, zap.NewNop().Sugar()) // 1s timeout
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -204,7 +204,7 @@ func TestOpenAIService_ChatStream_UsageInResponse(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||||
|
|
||||||
ch, err := svc.ChatStream(context.Background(), Request{Text: "test"})
|
ch, err := svc.ChatStream(context.Background(), Request{Text: "test"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,21 +18,22 @@ type DeepgramService struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
model string
|
model string
|
||||||
endpoint string
|
endpoint string
|
||||||
|
timeout time.Duration
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeepgramService 创建 Deepgram STT 服务。
|
// NewDeepgramService 创建 Deepgram STT 服务。
|
||||||
func NewDeepgramService(apiKey, model, endpoint string, logger *zap.SugaredLogger) *DeepgramService {
|
// model、endpoint 由 config 层保证非空,timeoutSec 为 0 时默认 5 秒。
|
||||||
if model == "" {
|
func NewDeepgramService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *DeepgramService {
|
||||||
model = "nova-2"
|
timeout := time.Duration(timeoutSec) * time.Second
|
||||||
}
|
if timeout <= 0 {
|
||||||
if endpoint == "" {
|
timeout = 5 * time.Second
|
||||||
endpoint = "wss://api.deepgram.com/v1/listen"
|
|
||||||
}
|
}
|
||||||
return &DeepgramService{
|
return &DeepgramService{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: model,
|
model: model,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
timeout: timeout,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,8 +58,8 @@ func (d *DeepgramService) Recognize(ctx context.Context, audio []byte, opts Opti
|
|||||||
// 构建 WebSocket URL,附带查询参数
|
// 构建 WebSocket URL,附带查询参数
|
||||||
wsURL := d.buildURL(opts)
|
wsURL := d.buildURL(opts)
|
||||||
|
|
||||||
// 5 秒总超时
|
// 总超时
|
||||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, d.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 建立 WebSocket 连接
|
// 建立 WebSocket 连接
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
package stt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMockDeepgram 创建模拟 Deepgram WebSocket 服务。
|
|
||||||
// 返回 httptest.Server 和对应的 ws:// URL。
|
|
||||||
func newMockDeepgram(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("upgrade error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
handler(conn)
|
|
||||||
}))
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
// wsToWss 将 http:// 转换为 ws://。
|
|
||||||
func wsToWss(httpURL string) string {
|
|
||||||
return "ws" + strings.TrimPrefix(httpURL, "http")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_Recognize_Success(t *testing.T) {
|
|
||||||
srv := newMockDeepgram(t, func(conn *websocket.Conn) {
|
|
||||||
// 读取音频数据
|
|
||||||
_, _, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("read audio: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送中间结果(非 final)
|
|
||||||
intermediate := deepgramResponse{
|
|
||||||
IsFinal: false,
|
|
||||||
}
|
|
||||||
intermediate.Channel.Alternatives = []struct {
|
|
||||||
Transcript string `json:"transcript"`
|
|
||||||
Confidence float64 `json:"confidence"`
|
|
||||||
}{{Transcript: "你好", Confidence: 0.9}}
|
|
||||||
data, _ := json.Marshal(intermediate)
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
|
|
||||||
// 发送最终结果
|
|
||||||
final := deepgramResponse{
|
|
||||||
IsFinal: true,
|
|
||||||
}
|
|
||||||
final.Channel.Alternatives = []struct {
|
|
||||||
Transcript string `json:"transcript"`
|
|
||||||
Confidence float64 `json:"confidence"`
|
|
||||||
}{{Transcript: "你好世界", Confidence: 0.95}}
|
|
||||||
data, _ = json.Marshal(final)
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
|
|
||||||
// 等待客户端关闭
|
|
||||||
_, _, _ = conn.ReadMessage()
|
|
||||||
})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar())
|
|
||||||
|
|
||||||
text, err := svc.Recognize(context.Background(), []byte("fake-pcm-audio"), Options{
|
|
||||||
Encoding: "pcm_s16le",
|
|
||||||
SampleRate: 16000,
|
|
||||||
Language: "zh-CN",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Recognize() error: %v", err)
|
|
||||||
}
|
|
||||||
if text != "你好世界" {
|
|
||||||
t.Errorf("Recognize() = %q, want %q", text, "你好世界")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_Recognize_EmptyAudio(t *testing.T) {
|
|
||||||
svc := NewDeepgramService("test-key", "", "ws://localhost", zap.NewNop().Sugar())
|
|
||||||
_, err := svc.Recognize(context.Background(), nil, Options{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Recognize() with empty audio should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_Recognize_ConnectError(t *testing.T) {
|
|
||||||
svc := NewDeepgramService("test-key", "", "ws://localhost:1", zap.NewNop().Sugar())
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := svc.Recognize(ctx, []byte("audio"), Options{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Recognize() with bad endpoint should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_Recognize_Timeout(t *testing.T) {
|
|
||||||
// 模拟一个永不响应的服务端
|
|
||||||
srv := newMockDeepgram(t, func(conn *websocket.Conn) {
|
|
||||||
// 读取音频但不发送任何结果,让客户端超时
|
|
||||||
_, _, _ = conn.ReadMessage()
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar())
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := svc.Recognize(ctx, []byte("audio"), Options{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Recognize() should timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_Recognize_MultipleFinals(t *testing.T) {
|
|
||||||
srv := newMockDeepgram(t, func(conn *websocket.Conn) {
|
|
||||||
_, _, _ = conn.ReadMessage()
|
|
||||||
|
|
||||||
// 发送多个 final 结果(多句话场景)
|
|
||||||
for _, text := range []string{"你好", "世界"} {
|
|
||||||
resp := deepgramResponse{IsFinal: true}
|
|
||||||
resp.Channel.Alternatives = []struct {
|
|
||||||
Transcript string `json:"transcript"`
|
|
||||||
Confidence float64 `json:"confidence"`
|
|
||||||
}{{Transcript: text, Confidence: 0.9}}
|
|
||||||
data, _ := json.Marshal(resp)
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _ = conn.ReadMessage()
|
|
||||||
})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar())
|
|
||||||
|
|
||||||
text, err := svc.Recognize(context.Background(), []byte("audio"), Options{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Recognize() error: %v", err)
|
|
||||||
}
|
|
||||||
if text != "你好世界" {
|
|
||||||
t.Errorf("Recognize() = %q, want %q", text, "你好世界")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeepgramService_buildURL(t *testing.T) {
|
|
||||||
svc := NewDeepgramService("key", "", "wss://api.deepgram.com/v1/listen", zap.NewNop().Sugar())
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
opts Options
|
|
||||||
want []string // URL 中应包含的参数
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "defaults",
|
|
||||||
opts: Options{},
|
|
||||||
want: []string{"encoding=pcm_s16le", "sample_rate=16000", "language=zh-CN"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom",
|
|
||||||
opts: Options{Encoding: "wav", SampleRate: 44100, Language: "en"},
|
|
||||||
want: []string{"encoding=wav", "sample_rate=44100", "language=en"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
u := svc.buildURL(tt.opts)
|
|
||||||
for _, param := range tt.want {
|
|
||||||
if !strings.Contains(u, param) {
|
|
||||||
t.Errorf("buildURL() = %q, should contain %q", u, param)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
233
backend/internal/ai/stt/mimo.go
Normal file
233
backend/internal/ai/stt/mimo.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package stt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MiMoService 基于 Xiaomi MiMo ASR HTTP API 的语音识别实现。
|
||||||
|
// 接口兼容 OpenAI chat/completions 格式,音频仅支持 mp3/wav。
|
||||||
|
type MiMoService struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
endpoint string
|
||||||
|
timeout time.Duration
|
||||||
|
logger *zap.SugaredLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMiMoService 创建 MiMo STT 服务。
|
||||||
|
// model、endpoint 由 config 层保证非空,timeoutSec 为 0 时默认 10 秒。
|
||||||
|
func NewMiMoService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *MiMoService {
|
||||||
|
timeout := time.Duration(timeoutSec) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &MiMoService{
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
endpoint: endpoint,
|
||||||
|
timeout: timeout,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoRequest MiMo ASR 请求体。
|
||||||
|
type mimoRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []mimoMessage `json:"messages"`
|
||||||
|
ASROptions *mimoASROptions `json:"asr_options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mimoMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []mimoContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mimoContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
InputAudio *mimoAudioIn `json:"input_audio,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mimoAudioIn struct {
|
||||||
|
Data string `json:"data"` // data URL: data:{mime};base64,{data}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mimoASROptions struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoResponse MiMo ASR 非流式响应。
|
||||||
|
type mimoResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognize 实现 stt.Service。将音频发送到 MiMo ASR API,返回识别文本。
|
||||||
|
func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) (string, error) {
|
||||||
|
if len(audio) == 0 {
|
||||||
|
return "", fmt.Errorf("stt: empty audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiMo 仅支持 mp3/wav,若输入为原始 PCM 则封装为 WAV
|
||||||
|
audioData := audio
|
||||||
|
mimeType := "audio/wav"
|
||||||
|
if !isWAV(audio) && !isMP3(audio) {
|
||||||
|
wav, err := pcmToWAV(audio, opts.SampleRate, 1)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stt: pcm to wav: %w", err)
|
||||||
|
}
|
||||||
|
audioData = wav
|
||||||
|
} else if isMP3(audio) {
|
||||||
|
mimeType = "audio/mpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(audioData)
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
|
||||||
|
|
||||||
|
// 映射语言代码
|
||||||
|
language := mapLanguage(opts.Language)
|
||||||
|
|
||||||
|
reqBody := mimoRequest{
|
||||||
|
Model: m.model,
|
||||||
|
Messages: []mimoMessage{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []mimoContent{
|
||||||
|
{
|
||||||
|
Type: "input_audio",
|
||||||
|
InputAudio: &mimoAudioIn{
|
||||||
|
Data: dataURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if language != "" {
|
||||||
|
reqBody.ASROptions = &mimoASROptions{Language: language}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stt: marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(m.endpoint, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stt: create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+m.apiKey)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stt: request mimo: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stt: read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("stt: mimo returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var mResp mimoResponse
|
||||||
|
if err := json.Unmarshal(respBody, &mResp); err != nil {
|
||||||
|
return "", fmt.Errorf("stt: unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("stt: mimo returned empty choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(mResp.Choices[0].Message.Content)
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapLanguage 将标准语言代码映射为 MiMo 支持的值(auto/zh/en)。
|
||||||
|
func mapLanguage(lang string) string {
|
||||||
|
switch {
|
||||||
|
case lang == "":
|
||||||
|
return "auto"
|
||||||
|
case strings.HasPrefix(lang, "zh"):
|
||||||
|
return "zh"
|
||||||
|
case strings.HasPrefix(lang, "en"):
|
||||||
|
return "en"
|
||||||
|
default:
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWAV 检查数据是否为 WAV 格式(RIFF 头)。
|
||||||
|
func isWAV(data []byte) bool {
|
||||||
|
return len(data) > 4 && string(data[:4]) == "RIFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMP3 检查数据是否为 MP3 格式(ID3 标签或帧同步字)。
|
||||||
|
func isMP3(data []byte) bool {
|
||||||
|
if len(data) > 3 && string(data[:3]) == "ID3" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 帧同步字:0xFF 0xFB/0xF3/0xF2
|
||||||
|
return len(data) > 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcmToWAV 将原始 PCM 数据封装为 WAV 文件。
|
||||||
|
func pcmToWAV(pcm []byte, sampleRate, channels int) ([]byte, error) {
|
||||||
|
if sampleRate == 0 {
|
||||||
|
sampleRate = 16000
|
||||||
|
}
|
||||||
|
if channels == 0 {
|
||||||
|
channels = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
bitsPerSample := 16
|
||||||
|
byteRate := sampleRate * channels * bitsPerSample / 8
|
||||||
|
blockAlign := channels * bitsPerSample / 8
|
||||||
|
dataSize := len(pcm)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
buf.WriteString("RIFF")
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize))
|
||||||
|
buf.WriteString("WAVE")
|
||||||
|
|
||||||
|
// fmt 子块
|
||||||
|
buf.WriteString("fmt ")
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(16)) // 子块大小
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM 格式
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(channels)) // 通道数
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)) // 采样率
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) // 字节率
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) // 块对齐
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) // 每样本位数
|
||||||
|
|
||||||
|
// data 子块
|
||||||
|
buf.WriteString("data")
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(dataSize))
|
||||||
|
buf.Write(pcm)
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
255
backend/internal/ai/stt/mimo_test.go
Normal file
255
backend/internal/ai/stt/mimo_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package stt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestMiMoService(handler http.HandlerFunc) (*MiMoService, *httptest.Server) {
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
s := NewMiMoService("test-key", "mimo-v2.5-asr", srv.URL, 0, zap.NewNop().Sugar())
|
||||||
|
return s, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_Recognize_Success(t *testing.T) {
|
||||||
|
s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 验证请求
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
t.Errorf("expected Authorization Bearer test-key, got %s", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
t.Errorf("expected path /chat/completions, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req mimoRequest
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
if req.Model != "mimo-v2.5-asr" {
|
||||||
|
t.Errorf("expected model mimo-v2.5-asr, got %s", req.Model)
|
||||||
|
}
|
||||||
|
if len(req.Messages) == 0 || req.Messages[0].Role != "user" {
|
||||||
|
t.Error("expected user message")
|
||||||
|
}
|
||||||
|
if req.ASROptions == nil || req.ASROptions.Language != "zh" {
|
||||||
|
t.Errorf("expected language zh, got %v", req.ASROptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := mimoResponse{
|
||||||
|
Choices: []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
}{
|
||||||
|
{Message: struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Content: "你好世界"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// 发送一个简单的有效 WAV(44 字节头 + 少量 PCM)
|
||||||
|
wav := makeValidWAV([]byte{0x00, 0x00, 0x00, 0x00})
|
||||||
|
text, err := s.Recognize(context.Background(), wav, Options{Language: "zh-CN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if text != "你好世界" {
|
||||||
|
t.Errorf("expected '你好世界', got '%s'", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_Recognize_EmptyAudio(t *testing.T) {
|
||||||
|
s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := s.Recognize(context.Background(), nil, Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_Recognize_ServerError(t *testing.T) {
|
||||||
|
s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("internal error"))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wav := makeValidWAV([]byte{0x00, 0x00})
|
||||||
|
_, err := s.Recognize(context.Background(), wav, Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_Recognize_EmptyChoices(t *testing.T) {
|
||||||
|
s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := mimoResponse{Choices: nil}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wav := makeValidWAV([]byte{0x00, 0x00})
|
||||||
|
_, err := s.Recognize(context.Background(), wav, Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty choices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_Recognize_PCMAutoWrap(t *testing.T) {
|
||||||
|
// 测试原始 PCM 数据自动封装为 WAV
|
||||||
|
s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req mimoRequest
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 data URL 格式
|
||||||
|
if len(req.Messages) == 0 || len(req.Messages[0].Content) == 0 {
|
||||||
|
t.Fatal("empty message content")
|
||||||
|
}
|
||||||
|
dataURL := req.Messages[0].Content[0].InputAudio.Data
|
||||||
|
if len(dataURL) < 22 || dataURL[:14] != "data:audio/wav" {
|
||||||
|
t.Errorf("expected wav data URL, got prefix: %s", dataURL[:min(len(dataURL), 30)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 base64 可解码
|
||||||
|
b64Part := dataURL[22:] // skip "data:audio/wav;base64,"
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(b64Part)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64 decode failed: %v", err)
|
||||||
|
}
|
||||||
|
// 应该是有效 WAV(RIFF 头)
|
||||||
|
if len(decoded) < 44 || string(decoded[:4]) != "RIFF" {
|
||||||
|
t.Error("decoded data is not a valid WAV")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := mimoResponse{
|
||||||
|
Choices: []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
}{
|
||||||
|
{Message: struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Content: "test"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// 发送原始 PCM(非 WAV/MP3)
|
||||||
|
pcm := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||||
|
text, err := s.Recognize(context.Background(), pcm, Options{SampleRate: 16000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if text != "test" {
|
||||||
|
t.Errorf("expected 'test', got '%s'", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", "auto"},
|
||||||
|
{"zh-CN", "zh"},
|
||||||
|
{"zh", "zh"},
|
||||||
|
{"en-US", "en"},
|
||||||
|
{"en", "en"},
|
||||||
|
{"ja", "auto"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := mapLanguage(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("mapLanguage(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWAV(t *testing.T) {
|
||||||
|
if !isWAV([]byte("RIFF....")) {
|
||||||
|
t.Error("expected true for RIFF header")
|
||||||
|
}
|
||||||
|
if isWAV([]byte("ID3...")) {
|
||||||
|
t.Error("expected false for ID3 header")
|
||||||
|
}
|
||||||
|
if isWAV([]byte{0x00}) {
|
||||||
|
t.Error("expected false for short data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMP3(t *testing.T) {
|
||||||
|
if !isMP3([]byte("ID3\x03")) {
|
||||||
|
t.Error("expected true for ID3 header")
|
||||||
|
}
|
||||||
|
if !isMP3([]byte{0xFF, 0xFB, 0x00}) {
|
||||||
|
t.Error("expected true for MP3 sync word")
|
||||||
|
}
|
||||||
|
if isMP3([]byte("RIFF")) {
|
||||||
|
t.Error("expected false for RIFF header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeValidWAV(pcm []byte) []byte {
|
||||||
|
// 构造一个最小有效 WAV
|
||||||
|
wav := make([]byte, 44+len(pcm))
|
||||||
|
copy(wav[:4], "RIFF")
|
||||||
|
// little-endian size = 36 + len(pcm)
|
||||||
|
size := uint32(36 + len(pcm))
|
||||||
|
wav[4] = byte(size)
|
||||||
|
wav[5] = byte(size >> 8)
|
||||||
|
wav[6] = byte(size >> 16)
|
||||||
|
wav[7] = byte(size >> 24)
|
||||||
|
copy(wav[8:12], "WAVE")
|
||||||
|
copy(wav[12:16], "fmt ")
|
||||||
|
// fmt chunk size = 16
|
||||||
|
wav[16] = 16
|
||||||
|
// PCM format = 1
|
||||||
|
wav[20] = 1
|
||||||
|
// channels = 1
|
||||||
|
wav[22] = 1
|
||||||
|
// sample rate = 16000
|
||||||
|
wav[24] = 0x80
|
||||||
|
wav[25] = 0x3E
|
||||||
|
// byte rate = 32000
|
||||||
|
wav[28] = 0x00
|
||||||
|
wav[29] = 0x7D
|
||||||
|
// block align = 2
|
||||||
|
wav[32] = 2
|
||||||
|
// bits per sample = 16
|
||||||
|
wav[34] = 16
|
||||||
|
copy(wav[36:40], "data")
|
||||||
|
dSize := uint32(len(pcm))
|
||||||
|
wav[40] = byte(dSize)
|
||||||
|
wav[41] = byte(dSize >> 8)
|
||||||
|
wav[42] = byte(dSize >> 16)
|
||||||
|
wav[43] = byte(dSize >> 24)
|
||||||
|
copy(wav[44:], pcm)
|
||||||
|
return wav
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
208
backend/internal/ai/tts/mimo.go
Normal file
208
backend/internal/ai/tts/mimo.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MiMoService 基于 Xiaomi MiMo TTS API 的语音合成实现。
|
||||||
|
// 接口兼容 OpenAI chat/completions 格式,通过 messages 传递待合成文本与风格指令。
|
||||||
|
type MiMoService struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
voice string
|
||||||
|
endpoint string
|
||||||
|
timeout time.Duration
|
||||||
|
logger *zap.SugaredLogger
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMiMoService 创建 MiMo TTS 服务。
|
||||||
|
// model、voice、endpoint 由 config 层保证非空。
|
||||||
|
func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *MiMoService {
|
||||||
|
timeout := time.Duration(timeoutSec) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
|
||||||
|
if httpClientTimeout <= 0 {
|
||||||
|
httpClientTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
return &MiMoService{
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
voice: voice,
|
||||||
|
endpoint: endpoint,
|
||||||
|
timeout: timeout,
|
||||||
|
logger: logger,
|
||||||
|
client: &http.Client{Timeout: httpClientTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoTTSRequest MiMo TTS API 请求体。
|
||||||
|
type mimoTTSRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []mimoTTSMessage `json:"messages"`
|
||||||
|
Audio mimoTTSAudio `json:"audio"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoTTSMessage MiMo TTS 消息。
|
||||||
|
type mimoTTSMessage struct {
|
||||||
|
Role string `json:"role"` // "user"(风格指令)| "assistant"(待合成文本)
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoTTSAudio MiMo TTS 音频配置。
|
||||||
|
type mimoTTSAudio struct {
|
||||||
|
Format string `json:"format"` // "mp3" | "wav" | "pcm16"
|
||||||
|
Voice string `json:"voice"` // 预置音色 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoTTSResponse MiMo TTS 非流式响应。
|
||||||
|
type mimoTTSResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Audio struct {
|
||||||
|
Data string `json:"data"` // base64 编码的音频数据
|
||||||
|
} `json:"audio"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimoTTSStreamResponse MiMo TTS 流式响应。
|
||||||
|
type mimoTTSStreamResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Audio struct {
|
||||||
|
Data string `json:"data"` // base64 编码的音频数据片段
|
||||||
|
} `json:"audio"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SynthesizeStream 实现 tts.Service。从 textStream 读取句子,逐句调用 MiMo TTS API。
|
||||||
|
func (m *MiMoService) SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error) {
|
||||||
|
voice := opts.Voice
|
||||||
|
if voice == "" {
|
||||||
|
voice = m.voice
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan Chunk, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
for text := range textStream {
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, err := m.synthesize(ctx, text, voice)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warnw("mimo tts: synthesize failed", "error", err, "text", text)
|
||||||
|
// 静默跳过,不中断整个流
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// textStream 关闭,发送 Final 标记
|
||||||
|
select {
|
||||||
|
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthesize 调用 MiMo TTS API 合成单个句子。
|
||||||
|
// 使用非流式调用,返回完整音频数据(base64 解码后)。
|
||||||
|
func (m *MiMoService) synthesize(ctx context.Context, text, voice string) ([]byte, error) {
|
||||||
|
// 单句超时
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 构建 MiMo TTS 请求:文本放在 assistant 消息中
|
||||||
|
reqBody := mimoTTSRequest{
|
||||||
|
Model: m.model,
|
||||||
|
Messages: []mimoTTSMessage{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Audio: mimoTTSAudio{
|
||||||
|
Format: "mp3",
|
||||||
|
Voice: voice,
|
||||||
|
},
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(m.endpoint, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("api-key", m.apiKey)
|
||||||
|
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
errBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("mimo tts: api error (status %d): %s", resp.StatusCode, string(errBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非流式响应:解析 JSON,提取 base64 音频数据
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ttsResp mimoTTSResponse
|
||||||
|
if err := json.Unmarshal(respBody, &ttsResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ttsResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("mimo tts: empty choices in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
audioData := ttsResp.Choices[0].Message.Audio.Data
|
||||||
|
if audioData == "" {
|
||||||
|
return nil, fmt.Errorf("mimo tts: empty audio data in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64 解码音频数据
|
||||||
|
audio, err := base64.StdEncoding.DecodeString(audioData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mimo tts: decode audio base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return audio, nil
|
||||||
|
}
|
||||||
414
backend/internal/ai/tts/mimo_test.go
Normal file
414
backend/internal/ai/tts/mimo_test.go
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockMiMoTTSServer 创建模拟 MiMo TTS API 的 HTTP 服务器。
|
||||||
|
func mockMiMoTTSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMiMoTTSResponse 构造 MiMo TTS 非流式响应 JSON。
|
||||||
|
func buildMiMoTTSResponse(audioData string) []byte {
|
||||||
|
resp := mimoTTSResponse{
|
||||||
|
Choices: []struct {
|
||||||
|
Message struct {
|
||||||
|
Audio struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"audio"`
|
||||||
|
} `json:"message"`
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Message: struct {
|
||||||
|
Audio struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"audio"`
|
||||||
|
}{
|
||||||
|
Audio: struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}{Data: audioData},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_Success(t *testing.T) {
|
||||||
|
var callCount int32
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.URL.Path, "/chat/completions") {
|
||||||
|
t.Errorf("path = %s, should contain /chat/completions", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 api-key 认证头
|
||||||
|
apiKey := r.Header.Get("api-key")
|
||||||
|
if apiKey != "test-key" {
|
||||||
|
t.Errorf("api-key = %q, want %q", apiKey, "test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求体
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req mimoTTSRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Errorf("unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
if req.Model != "mimo-v2.5-tts" {
|
||||||
|
t.Errorf("model = %q, want %q", req.Model, "mimo-v2.5-tts")
|
||||||
|
}
|
||||||
|
if len(req.Messages) != 1 || req.Messages[0].Role != "assistant" {
|
||||||
|
t.Errorf("expected 1 assistant message, got %d messages", len(req.Messages))
|
||||||
|
}
|
||||||
|
if req.Audio.Voice != "冰糖" {
|
||||||
|
t.Errorf("voice = %q, want %q", req.Audio.Voice, "冰糖")
|
||||||
|
}
|
||||||
|
if req.Audio.Format != "mp3" {
|
||||||
|
t.Errorf("format = %q, want %q", req.Audio.Format, "mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回假音频数据(base64 编码)
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("fake-mp3-data"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("你好", "世界", "!")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{
|
||||||
|
Voice: "冰糖", OutputFmt: "mp3", SampleRate: 24000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该有 3 个音频 chunk + 1 个 Final 标记
|
||||||
|
if len(chunks) != 4 {
|
||||||
|
t.Fatalf("got %d chunks, want 4", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证前 3 个有音频数据,IsLast 为 true(每句结束)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if string(chunks[i].Audio) != "fake-mp3-data" {
|
||||||
|
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
|
||||||
|
}
|
||||||
|
if !chunks[i].IsLast {
|
||||||
|
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
|
||||||
|
}
|
||||||
|
if chunks[i].Final {
|
||||||
|
t.Errorf("chunk[%d].Final should be false", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证最后一个是 Final(整轮结束)
|
||||||
|
if !chunks[3].Final {
|
||||||
|
t.Error("last chunk should be Final")
|
||||||
|
}
|
||||||
|
if chunks[3].Audio != nil {
|
||||||
|
t.Error("last chunk Audio should be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证调用了 3 次 API(3 个句子)
|
||||||
|
if atomic.LoadInt32(&callCount) != 3 {
|
||||||
|
t.Errorf("API called %d times, want 3", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_APIError(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "internal error")
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该只有一个 Final chunk(音频被跳过)
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) != 1 {
|
||||||
|
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
|
||||||
|
}
|
||||||
|
if !chunks[0].Final {
|
||||||
|
t.Error("chunk should be Final")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("late-mp3"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// 1 秒超时
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 1, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("很长的句子")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(ctx, textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时后音频被跳过,只有 Final
|
||||||
|
if len(chunks) != 1 {
|
||||||
|
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||||
|
}
|
||||||
|
if !chunks[0].Final {
|
||||||
|
t.Error("chunk should be Final")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) {
|
||||||
|
var callCount int32
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
// 空句子应该被跳过
|
||||||
|
textStream := sendSentences("", "你好", "")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 "你好" 应该被合成
|
||||||
|
if atomic.LoadInt32(&callCount) != 1 {
|
||||||
|
t.Errorf("API called %d times, want 1", callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 个音频(IsLast: true)+ 1 个 Final
|
||||||
|
if len(chunks) != 2 {
|
||||||
|
t.Fatalf("got %d chunks, want 2", len(chunks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_ContextCancelled(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := make(chan string, 3)
|
||||||
|
textStream <- "第一句"
|
||||||
|
textStream <- "第二句"
|
||||||
|
textStream <- "第三句"
|
||||||
|
close(textStream)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
// 立即取消
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(ctx, textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费 channel,应该很快结束
|
||||||
|
var count int
|
||||||
|
for range ch {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// 可能收到 0 个或 1 个 chunk,取决于时序
|
||||||
|
t.Logf("received %d chunks after context cancel", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) {
|
||||||
|
var callCount int32
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := atomic.AddInt32(&callCount, 1)
|
||||||
|
if n == 2 {
|
||||||
|
// 第二个句子失败
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("mp3-%d", n)))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("第一句", "第二句", "第三句")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 个成功音频(IsLast: true)+ 1 个 Final(第二句被跳过)
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Fatalf("got %d chunks, want 3", len(chunks))
|
||||||
|
}
|
||||||
|
if !chunks[0].IsLast {
|
||||||
|
t.Error("first audio chunk should be IsLast")
|
||||||
|
}
|
||||||
|
if !chunks[1].IsLast {
|
||||||
|
t.Error("second audio chunk should be IsLast")
|
||||||
|
}
|
||||||
|
if !chunks[len(chunks)-1].Final {
|
||||||
|
t.Error("last chunk should be Final")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_CustomVoice(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req mimoTTSRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Errorf("unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
if req.Audio.Voice != "茉莉" {
|
||||||
|
t.Errorf("voice = %q, want %q", req.Audio.Voice, "茉莉")
|
||||||
|
}
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{Voice: "茉莉"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_DefaultVoice(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req mimoTTSRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Errorf("unmarshal request: %v", err)
|
||||||
|
}
|
||||||
|
// 未指定 voice 时应使用默认 "冰糖"
|
||||||
|
if req.Audio.Voice != "冰糖" {
|
||||||
|
t.Errorf("voice = %q, want %q (default)", req.Audio.Voice, "冰糖")
|
||||||
|
}
|
||||||
|
audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(audioB64))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// 不指定 voice
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) {
|
||||||
|
srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 返回空音频数据
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(buildMiMoTTSResponse(""))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
|
ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
for c := range ch {
|
||||||
|
chunks = append(chunks, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空音频数据导致错误,句子被跳过,只有 Final
|
||||||
|
if len(chunks) != 1 {
|
||||||
|
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||||
|
}
|
||||||
|
if !chunks[0].Final {
|
||||||
|
t.Error("chunk should be Final")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,23 +25,19 @@ type OpenAIService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAIService 创建 OpenAI TTS 服务。
|
// NewOpenAIService 创建 OpenAI TTS 服务。
|
||||||
func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
|
// model、voice、endpoint 由 config 层保证非空。
|
||||||
if model == "" {
|
func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
|
||||||
model = "tts-1"
|
|
||||||
}
|
|
||||||
if voice == "" {
|
|
||||||
voice = "alloy"
|
|
||||||
}
|
|
||||||
if speed <= 0 {
|
if speed <= 0 {
|
||||||
speed = 1.0
|
speed = 1.0
|
||||||
}
|
}
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
timeout := time.Duration(timeoutSec) * time.Second
|
timeout := time.Duration(timeoutSec) * time.Second
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
|
||||||
|
if httpClientTimeout <= 0 {
|
||||||
|
httpClientTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
return &OpenAIService{
|
return &OpenAIService{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: model,
|
model: model,
|
||||||
@@ -50,7 +46,7 @@ func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, time
|
|||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: &http.Client{Timeout: httpClientTimeout},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +87,15 @@ func (o *OpenAIService) SynthesizeStream(ctx context.Context, textStream <-chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case ch <- Chunk{Audio: audio, IsLast: false}:
|
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// textStream 关闭,发送 IsLast 标记
|
// textStream 关闭,发送 Final 标记
|
||||||
select {
|
select {
|
||||||
case ch <- Chunk{Audio: nil, IsLast: true}:
|
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
textStream := sendSentences("你好", "世界", "!")
|
textStream := sendSentences("你好", "世界", "!")
|
||||||
|
|
||||||
@@ -74,24 +74,27 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
|
|||||||
chunks = append(chunks, c)
|
chunks = append(chunks, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应该有 3 个音频 chunk + 1 个 IsLast 标记
|
// 应该有 3 个音频 chunk + 1 个 Final 标记
|
||||||
if len(chunks) != 4 {
|
if len(chunks) != 4 {
|
||||||
t.Fatalf("got %d chunks, want 4", len(chunks))
|
t.Fatalf("got %d chunks, want 4", len(chunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证前 3 个有音频数据
|
// 验证前 3 个有音频数据,IsLast 为 true(每句结束)
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
if string(chunks[i].Audio) != "fake-mp3-data" {
|
if string(chunks[i].Audio) != "fake-mp3-data" {
|
||||||
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
|
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
|
||||||
}
|
}
|
||||||
if chunks[i].IsLast {
|
if !chunks[i].IsLast {
|
||||||
t.Errorf("chunk[%d].IsLast should be false", i)
|
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
|
||||||
|
}
|
||||||
|
if chunks[i].Final {
|
||||||
|
t.Errorf("chunk[%d].Final should be false", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证最后一个是 IsLast
|
// 验证最后一个是 Final(整轮结束)
|
||||||
if !chunks[3].IsLast {
|
if !chunks[3].Final {
|
||||||
t.Error("last chunk should be IsLast")
|
t.Error("last chunk should be Final")
|
||||||
}
|
}
|
||||||
if chunks[3].Audio != nil {
|
if chunks[3].Audio != nil {
|
||||||
t.Error("last chunk Audio should be nil")
|
t.Error("last chunk Audio should be nil")
|
||||||
@@ -110,7 +113,7 @@ func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
textStream := sendSentences("你好")
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
@@ -119,17 +122,17 @@ func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) {
|
|||||||
t.Fatalf("SynthesizeStream() error: %v", err)
|
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应该只有一个 IsLast chunk(音频被跳过)
|
// 应该只有一个 Final chunk(音频被跳过)
|
||||||
var chunks []Chunk
|
var chunks []Chunk
|
||||||
for c := range ch {
|
for c := range ch {
|
||||||
chunks = append(chunks, c)
|
chunks = append(chunks, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) != 1 {
|
if len(chunks) != 1 {
|
||||||
t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks))
|
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
|
||||||
}
|
}
|
||||||
if !chunks[0].IsLast {
|
if !chunks[0].Final {
|
||||||
t.Error("chunk should be IsLast")
|
t.Error("chunk should be Final")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
// 1 秒超时
|
// 1 秒超时
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 1, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 1, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
textStream := sendSentences("很长的句子")
|
textStream := sendSentences("很长的句子")
|
||||||
|
|
||||||
@@ -159,12 +162,12 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
|
|||||||
chunks = append(chunks, c)
|
chunks = append(chunks, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超时后音频被跳过,只有 IsLast
|
// 超时后音频被跳过,只有 Final
|
||||||
if len(chunks) != 1 {
|
if len(chunks) != 1 {
|
||||||
t.Fatalf("got %d chunks, want 1", len(chunks))
|
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||||
}
|
}
|
||||||
if !chunks[0].IsLast {
|
if !chunks[0].Final {
|
||||||
t.Error("chunk should be IsLast")
|
t.Error("chunk should be Final")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
// 空句子应该被跳过
|
// 空句子应该被跳过
|
||||||
textStream := sendSentences("", "你好", "")
|
textStream := sendSentences("", "你好", "")
|
||||||
@@ -197,7 +200,7 @@ func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) {
|
|||||||
t.Errorf("API called %d times, want 1", callCount)
|
t.Errorf("API called %d times, want 1", callCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1 个音频 + 1 个 IsLast
|
// 1 个音频(IsLast: true)+ 1 个 Final
|
||||||
if len(chunks) != 2 {
|
if len(chunks) != 2 {
|
||||||
t.Fatalf("got %d chunks, want 2", len(chunks))
|
t.Fatalf("got %d chunks, want 2", len(chunks))
|
||||||
}
|
}
|
||||||
@@ -210,7 +213,7 @@ func TestOpenAIService_SynthesizeStream_ContextCancelled(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
// 发送多个句子,但在第一个后取消
|
// 发送多个句子,但在第一个后取消
|
||||||
textStream := make(chan string, 3)
|
textStream := make(chan string, 3)
|
||||||
@@ -252,7 +255,7 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
textStream := sendSentences("第一句", "第二句", "第三句")
|
textStream := sendSentences("第一句", "第二句", "第三句")
|
||||||
|
|
||||||
@@ -266,12 +269,18 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
|
|||||||
chunks = append(chunks, c)
|
chunks = append(chunks, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2 个成功音频 + 1 个 IsLast(第二句被跳过)
|
// 2 个成功音频(IsLast: true)+ 1 个 Final(第二句被跳过)
|
||||||
if len(chunks) != 3 {
|
if len(chunks) != 3 {
|
||||||
t.Fatalf("got %d chunks, want 3", len(chunks))
|
t.Fatalf("got %d chunks, want 3", len(chunks))
|
||||||
}
|
}
|
||||||
if !chunks[len(chunks)-1].IsLast {
|
if !chunks[0].IsLast {
|
||||||
t.Error("last chunk should be IsLast")
|
t.Error("first audio chunk should be IsLast")
|
||||||
|
}
|
||||||
|
if !chunks[1].IsLast {
|
||||||
|
t.Error("second audio chunk should be IsLast")
|
||||||
|
}
|
||||||
|
if !chunks[len(chunks)-1].Final {
|
||||||
|
t.Error("last chunk should be Final")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +295,7 @@ func TestOpenAIService_SynthesizeStream_CustomVoice(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar())
|
svc := NewOpenAIService("test-key", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar())
|
||||||
|
|
||||||
textStream := sendSentences("你好")
|
textStream := sendSentences("你好")
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ type Options struct {
|
|||||||
// Chunk 一个音频片段。
|
// Chunk 一个音频片段。
|
||||||
type Chunk struct {
|
type Chunk struct {
|
||||||
Audio []byte // MP3 音频数据(未 Base64 编码)
|
Audio []byte // MP3 音频数据(未 Base64 编码)
|
||||||
IsLast bool // 是否为最后一片
|
IsLast bool // 当前句子是否为最后一片(每句结束时为 true)
|
||||||
|
Final bool // 整轮 TTS 是否结束(所有句子合成完毕后为 true,此时 Audio 为 nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import (
|
|||||||
|
|
||||||
// Config 应用配置。
|
// Config 应用配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
App AppConfig `mapstructure:"app"`
|
App AppConfig `mapstructure:"app"`
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Redis RedisConfig `mapstructure:"redis"`
|
Session SessionConfig `mapstructure:"session"`
|
||||||
AI AIConfig `mapstructure:"ai"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
Storage StorageConfig `mapstructure:"storage"`
|
AI AIConfig `mapstructure:"ai"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Storage StorageConfig `mapstructure:"storage"`
|
||||||
|
Log LogConfig `mapstructure:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionConfig 会话管理配置。
|
||||||
|
type SessionConfig struct {
|
||||||
|
TTL int `mapstructure:"ttl"` // 会话过期时间(分钟)
|
||||||
|
MaxHistory int `mapstructure:"max_history"` // 对话历史上限(条)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -25,10 +32,14 @@ type AppConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
ReadTimeout int `mapstructure:"read_timeout"`
|
ReadTimeout int `mapstructure:"read_timeout"`
|
||||||
WriteTimeout int `mapstructure:"write_timeout"`
|
WriteTimeout int `mapstructure:"write_timeout"`
|
||||||
|
HeartbeatInterval int `mapstructure:"heartbeat_interval"` // 心跳检查间隔(秒)
|
||||||
|
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时(秒)
|
||||||
|
ShutdownTimeout int `mapstructure:"shutdown_timeout"` // 优雅关闭超时(秒)
|
||||||
|
AllowedOrigins []string `mapstructure:"allowed_origins"` // CORS 允许的来源,空表示允许所有
|
||||||
}
|
}
|
||||||
|
|
||||||
// Addr 返回 host:port 地址。
|
// Addr 返回 host:port 地址。
|
||||||
@@ -49,28 +60,34 @@ type AIConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type STTConfig struct {
|
type STTConfig struct {
|
||||||
Provider string `mapstructure:"provider"`
|
Provider string `mapstructure:"provider"`
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
Model string `mapstructure:"model"`
|
Model string `mapstructure:"model"`
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
|
Timeout int `mapstructure:"timeout"` // STT 超时(秒)
|
||||||
|
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LLMConfig struct {
|
type LLMConfig struct {
|
||||||
Provider string `mapstructure:"provider"`
|
Provider string `mapstructure:"provider"`
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
Model string `mapstructure:"model"`
|
Model string `mapstructure:"model"`
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
Timeout int `mapstructure:"timeout"`
|
Timeout int `mapstructure:"timeout"`
|
||||||
|
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TTSConfig struct {
|
type TTSConfig struct {
|
||||||
Provider string `mapstructure:"provider"`
|
Provider string `mapstructure:"provider"`
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
Model string `mapstructure:"model"`
|
Model string `mapstructure:"model"`
|
||||||
Voice string `mapstructure:"voice"`
|
Voice string `mapstructure:"voice"`
|
||||||
Speed float64 `mapstructure:"speed"`
|
Speed float64 `mapstructure:"speed"`
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
Timeout int `mapstructure:"timeout"`
|
Timeout int `mapstructure:"timeout"`
|
||||||
|
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒)
|
||||||
|
OutputFormat string `mapstructure:"output_format"` // 输出格式:mp3/wav
|
||||||
|
SampleRate int `mapstructure:"sample_rate"` // 输出采样率
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
@@ -91,28 +108,42 @@ func Load() (*Config, error) {
|
|||||||
v.AddConfigPath(".")
|
v.AddConfigPath(".")
|
||||||
v.AddConfigPath("./config")
|
v.AddConfigPath("./config")
|
||||||
v.AddConfigPath("./backend")
|
v.AddConfigPath("./backend")
|
||||||
|
v.AddConfigPath("..") // 兼容从 backend/cmd/ 启动
|
||||||
|
v.AddConfigPath("../..") // 兼容从 backend/cmd/server/ 启动
|
||||||
|
|
||||||
// 默认值
|
// 默认值
|
||||||
v.SetDefault("app.env", "dev")
|
v.SetDefault("app.env", "dev")
|
||||||
|
v.SetDefault("app.version", "dev")
|
||||||
v.SetDefault("server.host", "0.0.0.0")
|
v.SetDefault("server.host", "0.0.0.0")
|
||||||
v.SetDefault("server.port", 8080)
|
v.SetDefault("server.port", 8080)
|
||||||
v.SetDefault("server.read_timeout", 30)
|
v.SetDefault("server.read_timeout", 30)
|
||||||
v.SetDefault("server.write_timeout", 30)
|
v.SetDefault("server.write_timeout", 30)
|
||||||
|
v.SetDefault("server.heartbeat_interval", 30)
|
||||||
|
v.SetDefault("server.heartbeat_timeout", 60)
|
||||||
|
v.SetDefault("server.shutdown_timeout", 10)
|
||||||
|
v.SetDefault("session.ttl", 30)
|
||||||
|
v.SetDefault("session.max_history", 20)
|
||||||
v.SetDefault("redis.addr", "localhost:6379")
|
v.SetDefault("redis.addr", "localhost:6379")
|
||||||
v.SetDefault("redis.db", 0)
|
v.SetDefault("redis.db", 0)
|
||||||
v.SetDefault("ai.stt.provider", "deepgram")
|
v.SetDefault("ai.stt.provider", "deepgram")
|
||||||
v.SetDefault("ai.stt.model", "nova-2")
|
v.SetDefault("ai.stt.model", "nova-2")
|
||||||
v.SetDefault("ai.stt.endpoint", "wss://api.deepgram.com/v1/listen")
|
v.SetDefault("ai.stt.endpoint", "wss://api.deepgram.com/v1/listen")
|
||||||
|
v.SetDefault("ai.stt.timeout", 5)
|
||||||
|
v.SetDefault("ai.stt.http_client_timeout", 30)
|
||||||
v.SetDefault("ai.llm.provider", "openai")
|
v.SetDefault("ai.llm.provider", "openai")
|
||||||
v.SetDefault("ai.llm.model", "gpt-4o")
|
v.SetDefault("ai.llm.model", "gpt-4o")
|
||||||
v.SetDefault("ai.llm.endpoint", "https://api.openai.com/v1")
|
v.SetDefault("ai.llm.endpoint", "https://api.openai.com/v1")
|
||||||
v.SetDefault("ai.llm.timeout", 10)
|
v.SetDefault("ai.llm.timeout", 10)
|
||||||
|
v.SetDefault("ai.llm.http_client_timeout", 60)
|
||||||
v.SetDefault("ai.tts.provider", "openai")
|
v.SetDefault("ai.tts.provider", "openai")
|
||||||
v.SetDefault("ai.tts.model", "tts-1")
|
v.SetDefault("ai.tts.model", "tts-1")
|
||||||
v.SetDefault("ai.tts.voice", "alloy")
|
v.SetDefault("ai.tts.voice", "mimo_default")
|
||||||
v.SetDefault("ai.tts.speed", 1.0)
|
v.SetDefault("ai.tts.speed", 1.0)
|
||||||
v.SetDefault("ai.tts.endpoint", "https://api.openai.com/v1")
|
v.SetDefault("ai.tts.endpoint", "https://api.openai.com/v1")
|
||||||
v.SetDefault("ai.tts.timeout", 5)
|
v.SetDefault("ai.tts.timeout", 5)
|
||||||
|
v.SetDefault("ai.tts.http_client_timeout", 30)
|
||||||
|
v.SetDefault("ai.tts.output_format", "mp3")
|
||||||
|
v.SetDefault("ai.tts.sample_rate", 24000)
|
||||||
v.SetDefault("storage.driver", "memory")
|
v.SetDefault("storage.driver", "memory")
|
||||||
v.SetDefault("log.level", "info")
|
v.SetDefault("log.level", "info")
|
||||||
v.SetDefault("log.format", "console")
|
v.SetDefault("log.format", "console")
|
||||||
@@ -134,6 +165,7 @@ func Load() (*Config, error) {
|
|||||||
// 按优先级尝试:当前目录、上级目录(兼容从 backend/ 或项目根目录启动)
|
// 按优先级尝试:当前目录、上级目录(兼容从 backend/ 或项目根目录启动)
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
_ = godotenv.Load("../.env")
|
_ = godotenv.Load("../.env")
|
||||||
|
_ = godotenv.Load("../../.env") // 兼容从 backend/cmd/server/ 启动
|
||||||
|
|
||||||
// 环境变量覆盖
|
// 环境变量覆盖
|
||||||
v.SetEnvPrefix("CAMTALK")
|
v.SetEnvPrefix("CAMTALK")
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type WsQuery struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Image string `json:"image"` // base64
|
Image string `json:"image"` // base64
|
||||||
Audio string `json:"audio"` // base64
|
Audio string `json:"audio"` // base64
|
||||||
|
Text string `json:"text"` // 用户手动输入的文本(有值时跳过 STT)
|
||||||
MimeType string `json:"mime_type"` // 默认 "audio/pcm"
|
MimeType string `json:"mime_type"` // 默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +112,8 @@ type WsTTSAudio struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Audio string `json:"audio"` // base64
|
Audio string `json:"audio"` // base64
|
||||||
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm"
|
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm"
|
||||||
IsLast bool `json:"is_last"`
|
IsLast bool `json:"is_last"` // 当前句子的音频是否完整(每句结束时为 true)
|
||||||
|
Final bool `json:"final"` // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WsError 服务端 error 消息。
|
// WsError 服务端 error 消息。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ai/llm"
|
"github.com/hhs/camtalk/internal/ai/llm"
|
||||||
"github.com/hhs/camtalk/internal/ai/stt"
|
"github.com/hhs/camtalk/internal/ai/stt"
|
||||||
"github.com/hhs/camtalk/internal/ai/tts"
|
"github.com/hhs/camtalk/internal/ai/tts"
|
||||||
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
@@ -18,11 +19,15 @@ import (
|
|||||||
|
|
||||||
// Pipeline 实现 Orchestrator 接口,管理 STT → LLM → TTS 流式管道。
|
// Pipeline 实现 Orchestrator 接口,管理 STT → LLM → TTS 流式管道。
|
||||||
type Pipeline struct {
|
type Pipeline struct {
|
||||||
sttService stt.Service
|
sttService stt.Service
|
||||||
llmService llm.Service
|
llmService llm.Service
|
||||||
ttsService tts.Service
|
ttsService tts.Service
|
||||||
sessionMgr session.Manager
|
sessionMgr session.Manager
|
||||||
model string // LLM 模型名,用于 llm_done 上报
|
model string // LLM 模型名,用于 llm_done 上报
|
||||||
|
ttsVoice string // TTS 音色
|
||||||
|
ttsSpeed float64 // TTS 语速
|
||||||
|
ttsOutputFmt string // TTS 输出格式
|
||||||
|
ttsSampleRate int // TTS 输出采样率
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建 Pipeline 实例。
|
// New 创建 Pipeline 实例。
|
||||||
@@ -31,14 +36,18 @@ func New(
|
|||||||
llmService llm.Service,
|
llmService llm.Service,
|
||||||
ttsService tts.Service,
|
ttsService tts.Service,
|
||||||
sessionMgr session.Manager,
|
sessionMgr session.Manager,
|
||||||
model string,
|
cfg *config.Config,
|
||||||
) *Pipeline {
|
) *Pipeline {
|
||||||
return &Pipeline{
|
return &Pipeline{
|
||||||
sttService: sttService,
|
sttService: sttService,
|
||||||
llmService: llmService,
|
llmService: llmService,
|
||||||
ttsService: ttsService,
|
ttsService: ttsService,
|
||||||
sessionMgr: sessionMgr,
|
sessionMgr: sessionMgr,
|
||||||
model: model,
|
model: cfg.AI.LLM.Model,
|
||||||
|
ttsVoice: cfg.AI.TTS.Voice,
|
||||||
|
ttsSpeed: cfg.AI.TTS.Speed,
|
||||||
|
ttsOutputFmt: cfg.AI.TTS.OutputFormat,
|
||||||
|
ttsSampleRate: cfg.AI.TTS.SampleRate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,22 +62,27 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
log := logger.Log
|
log := logger.Log
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// 解码音频数据
|
// 解码音频数据(文本输入模式可跳过)
|
||||||
audio, err := base64.StdEncoding.DecodeString(req.Audio)
|
var audio []byte
|
||||||
if err != nil {
|
if req.Text == "" && req.Audio != "" {
|
||||||
log.Errorw("音频解码失败", "error", err)
|
var err error
|
||||||
sender.SendError(models.WsError{
|
audio, err = base64.StdEncoding.DecodeString(req.Audio)
|
||||||
Type: "error",
|
if err != nil {
|
||||||
RequestID: req.RequestID,
|
log.Errorw("音频解码失败", "error", err)
|
||||||
Code: "INVALID_MESSAGE",
|
sender.SendError(models.WsError{
|
||||||
Message: "音频数据解码失败",
|
Type: "error",
|
||||||
})
|
RequestID: req.RequestID,
|
||||||
return err
|
Code: "INVALID_MESSAGE",
|
||||||
|
Message: "音频数据解码失败",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图片数据(可选)
|
// 解码图片数据(可选)
|
||||||
var image []byte
|
var image []byte
|
||||||
if req.Image != "" {
|
if req.Image != "" {
|
||||||
|
var err error
|
||||||
image, err = base64.StdEncoding.DecodeString(req.Image)
|
image, err = base64.StdEncoding.DecodeString(req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("图片解码失败", "error", err)
|
log.Errorw("图片解码失败", "error", err)
|
||||||
@@ -101,45 +115,64 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: STT 语音识别
|
// Step 1: 获取用户文本(语音识别或直接使用输入文本)
|
||||||
log.Infow("开始语音识别", "request_id", req.RequestID)
|
var userText string
|
||||||
sttResult, err := p.sttService.Recognize(ctx, audio, stt.Options{
|
if req.Text != "" {
|
||||||
Encoding: "pcm_s16le",
|
// 文本输入模式:跳过 STT,直接使用用户输入的文本
|
||||||
SampleRate: 16000,
|
log.Infow("使用文本输入", "request_id", req.RequestID, "text", req.Text)
|
||||||
Language: sess.Config.Language,
|
userText = req.Text
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorw("语音识别失败", "error", err)
|
|
||||||
sender.SendError(models.WsError{
|
|
||||||
Type: "error",
|
|
||||||
RequestID: req.RequestID,
|
|
||||||
Code: "STT_ERROR",
|
|
||||||
Message: "语音识别失败",
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送 STT 结果
|
// 发送 stt_result 以保持前端消息流一致性
|
||||||
if err := sender.SendSTTResult(models.WsSTTResult{
|
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||||
Type: "stt_result",
|
Type: "stt_result",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
Text: sttResult,
|
Text: userText,
|
||||||
IsFinal: true,
|
IsFinal: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorw("发送 STT 结果失败", "error", err)
|
log.Errorw("发送 STT 结果失败", "error", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 语音模式:执行 STT 语音识别
|
||||||
|
log.Infow("开始语音识别", "request_id", req.RequestID)
|
||||||
|
sttResult, err := p.sttService.Recognize(ctx, audio, stt.Options{
|
||||||
|
Encoding: "pcm_s16le",
|
||||||
|
SampleRate: 16000,
|
||||||
|
Language: sess.Config.Language,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorw("语音识别失败", "error", err)
|
||||||
|
sender.SendError(models.WsError{
|
||||||
|
Type: "error",
|
||||||
|
RequestID: req.RequestID,
|
||||||
|
Code: "STT_ERROR",
|
||||||
|
Message: "语音识别失败",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userText = sttResult
|
||||||
|
|
||||||
|
// 发送 STT 结果
|
||||||
|
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||||
|
Type: "stt_result",
|
||||||
|
RequestID: req.RequestID,
|
||||||
|
Text: userText,
|
||||||
|
IsFinal: true,
|
||||||
|
}); err != nil {
|
||||||
|
log.Errorw("发送 STT 结果失败", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 追加用户消息到历史
|
// 追加用户消息到历史
|
||||||
p.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
p.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: sttResult,
|
Content: userText,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2+3: LLM 流式推理 + TTS 并行合成
|
// Step 2+3: LLM 流式推理 + TTS 并行合成
|
||||||
log.Infow("开始 LLM 推理", "request_id", req.RequestID)
|
log.Infow("开始 LLM 推理", "request_id", req.RequestID)
|
||||||
llmReq := llm.Request{
|
llmReq := llm.Request{
|
||||||
Image: image,
|
Image: image,
|
||||||
Text: sttResult,
|
Text: userText,
|
||||||
History: history,
|
History: history,
|
||||||
Language: sess.Config.Language,
|
Language: sess.Config.Language,
|
||||||
}
|
}
|
||||||
@@ -166,11 +199,12 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
var ttsErr error
|
var ttsErr error
|
||||||
|
|
||||||
// goroutine 1: 消费 LLM token + 句子切分
|
// goroutine 1: 消费 LLM token + 句子切分
|
||||||
|
var tokenUsage *llm.TokenUsage
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer close(sentenceCh)
|
defer close(sentenceCh)
|
||||||
fullText = p.consumeLLMStream(ctx, llmStream, req.RequestID, sender, splitter)
|
fullText, tokenUsage = p.consumeLLMStream(ctx, llmStream, req.RequestID, sender, splitter)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// goroutine 2: TTS 合成(如果启用)
|
// goroutine 2: TTS 合成(如果启用)
|
||||||
@@ -205,13 +239,25 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
|
|
||||||
// 发送 llm_done
|
// 发送 llm_done
|
||||||
latency := time.Since(startTime).Milliseconds()
|
latency := time.Since(startTime).Milliseconds()
|
||||||
if err := sender.SendLLMDone(models.WsLLMDone{
|
done := models.WsLLMDone{
|
||||||
Type: "llm_done",
|
Type: "llm_done",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
FullText: fullText,
|
FullText: fullText,
|
||||||
Model: p.model,
|
Model: p.model,
|
||||||
LatencyMs: latency,
|
LatencyMs: latency,
|
||||||
}); err != nil {
|
}
|
||||||
|
if tokenUsage != nil {
|
||||||
|
done.TokensUsed = struct {
|
||||||
|
Prompt int `json:"prompt"`
|
||||||
|
Completion int `json:"completion"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}{
|
||||||
|
Prompt: tokenUsage.Prompt,
|
||||||
|
Completion: tokenUsage.Completion,
|
||||||
|
Total: tokenUsage.Total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sender.SendLLMDone(done); err != nil {
|
||||||
log.Errorw("发送 llm_done 失败", "error", err)
|
log.Errorw("发送 llm_done 失败", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,33 +271,36 @@ func (p *Pipeline) ProcessQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// consumeLLMStream 消费 LLM 流式输出,发送 llm_chunk 并进行句子切分。
|
// consumeLLMStream 消费 LLM 流式输出,发送 llm_chunk 并进行句子切分。
|
||||||
|
// 返回完整文本和 token 用量。
|
||||||
func (p *Pipeline) consumeLLMStream(
|
func (p *Pipeline) consumeLLMStream(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stream <-chan llm.Chunk,
|
stream <-chan llm.Chunk,
|
||||||
requestID string,
|
requestID string,
|
||||||
sender Sender,
|
sender Sender,
|
||||||
splitter *Splitter,
|
splitter *Splitter,
|
||||||
) string {
|
) (string, *llm.TokenUsage) {
|
||||||
log := logger.Log
|
log := logger.Log
|
||||||
var fullText strings.Builder
|
var fullText strings.Builder
|
||||||
|
var tokenUsage *llm.TokenUsage
|
||||||
|
|
||||||
for chunk := range stream {
|
for chunk := range stream {
|
||||||
// 检查上下文是否已取消
|
// 检查上下文是否已取消
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Infow("LLM 流被中断", "request_id", requestID)
|
log.Infow("LLM 流被中断", "request_id", requestID)
|
||||||
return fullText.String()
|
return fullText.String(), tokenUsage
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if chunk.Done {
|
if chunk.Done {
|
||||||
// 流结束
|
// 流结束,记录 token 用量
|
||||||
if chunk.TokensUsed != nil {
|
if chunk.TokensUsed != nil {
|
||||||
|
tokenUsage = chunk.TokensUsed
|
||||||
log.Infow("LLM 用量统计",
|
log.Infow("LLM 用量统计",
|
||||||
"request_id", requestID,
|
"request_id", requestID,
|
||||||
"prompt_tokens", chunk.TokensUsed.Prompt,
|
"prompt_tokens", tokenUsage.Prompt,
|
||||||
"completion_tokens", chunk.TokensUsed.Completion,
|
"completion_tokens", tokenUsage.Completion,
|
||||||
"total_tokens", chunk.TokensUsed.Total,
|
"total_tokens", tokenUsage.Total,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -277,7 +326,7 @@ func (p *Pipeline) consumeLLMStream(
|
|||||||
// 刷新切分器中的剩余文本
|
// 刷新切分器中的剩余文本
|
||||||
splitter.Flush()
|
splitter.Flush()
|
||||||
|
|
||||||
return fullText.String()
|
return fullText.String(), tokenUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
// synthesizeTTS 从句子 channel 读取文本,进行 TTS 合成并发送音频。
|
// synthesizeTTS 从句子 channel 读取文本,进行 TTS 合成并发送音频。
|
||||||
@@ -290,10 +339,10 @@ func (p *Pipeline) synthesizeTTS(
|
|||||||
log := logger.Log
|
log := logger.Log
|
||||||
|
|
||||||
ttsStream, err := p.ttsService.SynthesizeStream(ctx, sentenceCh, tts.Options{
|
ttsStream, err := p.ttsService.SynthesizeStream(ctx, sentenceCh, tts.Options{
|
||||||
Voice: "alloy",
|
Voice: p.ttsVoice,
|
||||||
Speed: 1.0,
|
Speed: p.ttsSpeed,
|
||||||
OutputFmt: "mp3",
|
OutputFmt: p.ttsOutputFmt,
|
||||||
SampleRate: 24000,
|
SampleRate: p.ttsSampleRate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("TTS 合成启动失败", "error", err)
|
log.Errorw("TTS 合成启动失败", "error", err)
|
||||||
@@ -319,6 +368,7 @@ func (p *Pipeline) synthesizeTTS(
|
|||||||
Audio: audioBase64,
|
Audio: audioBase64,
|
||||||
MimeType: "audio/mp3",
|
MimeType: "audio/mp3",
|
||||||
IsLast: chunk.IsLast,
|
IsLast: chunk.IsLast,
|
||||||
|
Final: chunk.Final,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorw("发送 tts_audio 失败", "error", err)
|
log.Errorw("发送 tts_audio 失败", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ai/llm"
|
"github.com/hhs/camtalk/internal/ai/llm"
|
||||||
"github.com/hhs/camtalk/internal/ai/stt"
|
"github.com/hhs/camtalk/internal/ai/stt"
|
||||||
"github.com/hhs/camtalk/internal/ai/tts"
|
"github.com/hhs/camtalk/internal/ai/tts"
|
||||||
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
)
|
)
|
||||||
@@ -254,7 +255,12 @@ func TestProcessQuery_Success(t *testing.T) {
|
|||||||
mockSender.On("SendTTSAudio", mock.Anything).Return(nil)
|
mockSender.On("SendTTSAudio", mock.Anything).Return(nil)
|
||||||
|
|
||||||
// 创建 Pipeline
|
// 创建 Pipeline
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 执行
|
// 执行
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -305,7 +311,12 @@ func TestProcessQuery_STTError(t *testing.T) {
|
|||||||
|
|
||||||
mockSender.On("SendError", mock.Anything).Return(nil)
|
mockSender.On("SendError", mock.Anything).Return(nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
@@ -357,7 +368,12 @@ func TestProcessQuery_LLMError(t *testing.T) {
|
|||||||
|
|
||||||
mockSender.On("SendError", mock.Anything).Return(nil)
|
mockSender.On("SendError", mock.Anything).Return(nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
@@ -415,7 +431,12 @@ func TestProcessQuery_TTSError(t *testing.T) {
|
|||||||
mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything).
|
mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything).
|
||||||
Return(nil, errors.New("TTS service unavailable"))
|
Return(nil, errors.New("TTS service unavailable"))
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
@@ -485,7 +506,12 @@ func TestProcessQuery_ContextCancelled(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything).Return((<-chan tts.Chunk)(ttsCh), nil)
|
mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything).Return((<-chan tts.Chunk)(ttsCh), nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 创建可取消的上下文
|
// 创建可取消的上下文
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -545,7 +571,12 @@ func TestProcessQuery_DisabledTTS(t *testing.T) {
|
|||||||
mockSender.On("SendLLMChunk", mock.Anything).Return(nil)
|
mockSender.On("SendLLMChunk", mock.Anything).Return(nil)
|
||||||
mockSender.On("SendLLMDone", mock.Anything).Return(nil)
|
mockSender.On("SendLLMDone", mock.Anything).Return(nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
@@ -617,7 +648,12 @@ func TestProcessQuery_InvalidAudio(t *testing.T) {
|
|||||||
|
|
||||||
mockSender.On("SendError", mock.Anything).Return(nil)
|
mockSender.On("SendError", mock.Anything).Return(nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
@@ -650,7 +686,12 @@ func TestProcessQuery_SessionNotFound(t *testing.T) {
|
|||||||
|
|
||||||
mockSender.On("SendError", mock.Anything).Return(nil)
|
mockSender.On("SendError", mock.Anything).Return(nil)
|
||||||
|
|
||||||
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o")
|
pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{
|
||||||
|
AI: config.AIConfig{
|
||||||
|
LLM: config.LLMConfig{Model: "gpt-4o"},
|
||||||
|
TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/errors"
|
"github.com/hhs/camtalk/internal/errors"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
@@ -17,8 +18,23 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
// newUpgrader 根据配置创建 WebSocket upgrader。
|
||||||
CheckOrigin: func(r *http.Request) bool { return true }, // 开发阶段允许所有来源
|
func newUpgrader(cfg *config.Config) websocket.Upgrader {
|
||||||
|
allowedOrigins := cfg.Server.AllowedOrigins
|
||||||
|
return websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
if len(allowedOrigins) == 0 {
|
||||||
|
return true // 未配置则允许所有来源(开发模式)
|
||||||
|
}
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
for _, o := range allowedOrigins {
|
||||||
|
if o == origin || o == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client 代表一个 WebSocket 客户端连接。
|
// Client 代表一个 WebSocket 客户端连接。
|
||||||
@@ -75,13 +91,21 @@ func (w *WSClient) SendError(err models.WsError) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeWS 处理 WebSocket 升级请求。
|
// ServeWS 处理 WebSocket 升级请求。
|
||||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator) gin.HandlerFunc {
|
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config) gin.HandlerFunc {
|
||||||
|
upgrader := newUpgrader(cfg)
|
||||||
|
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
||||||
|
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
||||||
|
version := cfg.App.Version
|
||||||
|
|
||||||
|
maxHistory := cfg.Session.MaxHistory
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
serveWS(c, sessionMgr, orch)
|
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator) {
|
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
||||||
|
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int) {
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
||||||
@@ -108,7 +132,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
_ = client.SendJSON(models.WsConnected{
|
_ = client.SendJSON(models.WsConnected{
|
||||||
Type: "connected",
|
Type: "connected",
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ServerVersion: "0.1.0",
|
ServerVersion: version,
|
||||||
})
|
})
|
||||||
logger.Log.Infow("client connected", "session", sessionID)
|
logger.Log.Infow("client connected", "session", sessionID)
|
||||||
|
|
||||||
@@ -122,12 +146,12 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
// 启动心跳检查 goroutine
|
// 启动心跳检查 goroutine
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
ticker := time.NewTicker(heartbeatInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if time.Since(lastPong) > 60*time.Second {
|
if time.Since(lastPong) > heartbeatTimeout {
|
||||||
logger.Log.Warnw("heartbeat timeout", "session", sessionID)
|
logger.Log.Warnw("heartbeat timeout", "session", sessionID)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
@@ -159,6 +183,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
|
|
||||||
switch envelope.Type {
|
switch envelope.Type {
|
||||||
case "ping":
|
case "ping":
|
||||||
|
lastPong = time.Now() // 刷新心跳计时器
|
||||||
_ = client.SendJSON(models.WsPong{Type: "pong"})
|
_ = client.SendJSON(models.WsPong{Type: "pong"})
|
||||||
|
|
||||||
case "query":
|
case "query":
|
||||||
@@ -180,7 +205,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取对话历史
|
// 获取对话历史
|
||||||
history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, 20)
|
history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory)
|
||||||
|
|
||||||
// 创建可取消的 context
|
// 创建可取消的 context
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
@@ -138,7 +139,12 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se
|
|||||||
t.Cleanup(func() { sessionMgr.Stop() })
|
t.Cleanup(func() { sessionMgr.Stop() })
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch))
|
cfg := &config.Config{
|
||||||
|
App: config.AppConfig{Version: "test"},
|
||||||
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
|
}
|
||||||
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|
||||||
@@ -181,7 +187,7 @@ func TestWS_Connected(t *testing.T) {
|
|||||||
msg := readJSON(t, conn)
|
msg := readJSON(t, conn)
|
||||||
assert.Equal(t, "connected", msg["type"])
|
assert.Equal(t, "connected", msg["type"])
|
||||||
assert.NotEmpty(t, msg["session_id"])
|
assert.NotEmpty(t, msg["session_id"])
|
||||||
assert.Equal(t, "0.1.0", msg["server_version"])
|
assert.Equal(t, "test", msg["server_version"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWS_PingPong 验证 ping/pong 心跳。
|
// TestWS_PingPong 验证 ping/pong 心跳。
|
||||||
|
|||||||
91
deploy.sh
Executable file
91
deploy.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# 确保 .env 存在
|
||||||
|
ensure_env() {
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
if [ -f .env.example ]; then
|
||||||
|
warn ".env 文件不存在,从 .env.example 复制模板"
|
||||||
|
cp .env.example .env
|
||||||
|
warn "请编辑 .env 填入实际的 API Key,然后重新运行"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
error ".env.example 也不存在,请手动创建 .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
info "构建 Docker 镜像..."
|
||||||
|
docker compose build
|
||||||
|
info "构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_up() {
|
||||||
|
ensure_env
|
||||||
|
info "启动服务..."
|
||||||
|
docker compose up -d
|
||||||
|
info "服务已启动"
|
||||||
|
info "前端: http://localhost"
|
||||||
|
info "健康检查: http://localhost/api/health"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_down() {
|
||||||
|
info "停止服务..."
|
||||||
|
docker compose down
|
||||||
|
info "服务已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_restart() {
|
||||||
|
info "重启服务..."
|
||||||
|
cmd_down
|
||||||
|
cmd_up
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
docker compose logs -f "${@}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
docker compose ps
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
CamTalk 部署脚本
|
||||||
|
|
||||||
|
用法: $0 <命令>
|
||||||
|
|
||||||
|
命令:
|
||||||
|
build 构建 Docker 镜像
|
||||||
|
up 启动服务(后台运行)
|
||||||
|
down 停止服务
|
||||||
|
restart 重启服务
|
||||||
|
logs 查看日志(可加服务名,如: $0 logs backend)
|
||||||
|
status 查看服务状态
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
build) cmd_build ;;
|
||||||
|
up) cmd_up ;;
|
||||||
|
down) cmd_down ;;
|
||||||
|
restart) cmd_restart ;;
|
||||||
|
logs) shift; cmd_logs "$@" ;;
|
||||||
|
status) cmd_status ;;
|
||||||
|
*) usage; exit 1 ;;
|
||||||
|
esac
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: camtalk-frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- camtalk-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: camtalk-backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
networks:
|
||||||
|
- camtalk-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
camtalk-net:
|
||||||
|
driver: bridge
|
||||||
113
docs/02-系统架构.md
113
docs/02-系统架构.md
@@ -9,7 +9,7 @@
|
|||||||
| 层级 | 职责 | 关键约束 |
|
| 层级 | 职责 | 关键约束 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
|
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
|
||||||
| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 |
|
| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 |
|
||||||
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
|
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
|
||||||
|
|
||||||
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。
|
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 |
|
| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 |
|
||||||
| 构建 | Vite | 开发热更新快,构建产物小 |
|
| 构建 | Vite | 开发热更新快,构建产物小 |
|
||||||
| 实时通信 | WebSocket(原生 API) | 浏览器原生支持,无需额外依赖 |
|
| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 |
|
||||||
| 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) |
|
| 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) |
|
||||||
| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 |
|
| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 |
|
||||||
| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 |
|
| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 |
|
||||||
@@ -32,22 +32,22 @@
|
|||||||
| 技术 | 选型 | 选择理由 |
|
| 技术 | 选型 | 选择理由 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 |
|
| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 |
|
||||||
|
| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 |
|
||||||
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
||||||
| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 |
|
| 会话存储 | Redis(规划中) / Memory(MVP 默认) | 高速 KV 存储,MVP 阶段使用进程内存,可通过配置切换到 Redis |
|
||||||
| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) |
|
| 持久化存储 | PostgreSQL(规划中) | 对话历史、用量统计、用户偏好(MVP 阶段未实现) |
|
||||||
| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
||||||
| 日志 | Zap | 高性能结构化日志 |
|
| 日志 | Zap | 高性能结构化日志 |
|
||||||
|
|
||||||
### AI 服务
|
### AI 服务
|
||||||
|
|
||||||
| 能力 | 主选方案 | 备选方案 | 选型考量 |
|
| 能力 | 主选方案 | 备选方案 | 选型考量 |
|
||||||
|------|---------|---------|---------|
|
|------|---------|---------|---------|
|
||||||
| 多模态 LLM | GPT-4o | Claude Sonnet | 视觉理解能力强,API 成熟 |
|
| 多模态 LLM | GPT-4o(默认) | 通义千问等 OpenAI 兼容模型 | 通过 OpenAI 兼容接口,可灵活切换 |
|
||||||
| 语音识别 STT | Deepgram | FunASR 自部署 | 流式识别延迟低(<500ms) |
|
| 语音识别 STT | Deepgram(默认) | MiMo ASR(小米) | 支持多 provider 切换 |
|
||||||
| 语音合成 TTS | OpenAI TTS | Edge TTS(免费) | 音质自然,支持流式 |
|
| 语音合成 TTS | OpenAI TTS(默认) | MiMo TTS(小米) | 支持多 provider 切换 |
|
||||||
| 轻量分类 | GPT-4o-mini | Haiku | 模型路由时的复杂度判断 |
|
|
||||||
|
|
||||||
> 不必绑定单一厂商。Go 网关的模型路由层统一封装不同 AI 服务的调用接口,按场景动态切换。
|
> 不必绑定单一厂商。Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。
|
||||||
|
|
||||||
## 核心交互流程
|
## 核心交互流程
|
||||||
|
|
||||||
@@ -78,52 +78,35 @@ Browser Go Gateway STT LLM TTS
|
|||||||
|
|
||||||
| 模块 | 职责 | 关键实现 |
|
| 模块 | 职责 | 关键实现 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection |
|
| WebSocket Handler | 管理客户端连接生命周期,单播消息推送 | goroutine per connection |
|
||||||
| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
| Session Manager | 维护用户会话状态、对话历史 | Memory(MVP 默认)/ Redis(可切换),30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
||||||
| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 |
|
| AI Orchestrator | 编排 STT→LLM→TTS 流式并行管道 | context 取消 + 超时控制 + 句子切分 |
|
||||||
| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 |
|
| AI Service Layer | AI 服务抽象层(STT/LLM/TTS) | 多 provider 支持(Deepgram/MiMo/OpenAI 等) |
|
||||||
| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 |
|
| REST API | 健康检查、会话管理端点 | Gin 路由 |
|
||||||
|
| Error Handler | 统一错误码定义与发送 | 错误码枚举 |
|
||||||
|
| Logger | 日志初始化封装 | Zap 结构化日志 |
|
||||||
|
| Models | 数据模型定义 | WebSocket 消息、会话、配置等 |
|
||||||
|
| Model Router | 根据请求类型选择 AI 模型(规划中) | 规则引擎 + 成本阈值 |
|
||||||
|
| Rate Limiter | 防止单用户过度消耗 API 额度(规划中) | 令牌桶算法 |
|
||||||
|
|
||||||
AI Orchestrator 核心代码(句子级流式并行):
|
AI Orchestrator 核心接口(`internal/orchestrator/orchestrator.go`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) {
|
// Orchestrator AI 编排器接口。
|
||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
type Orchestrator interface {
|
||||||
defer cancel()
|
ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
|
||||||
|
history []models.Message, sender Sender) error
|
||||||
// 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() {
|
|
||||||
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() }
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Step 3: TTS 并行消费句子流
|
|
||||||
ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...})
|
|
||||||
for chunk := range ttsStream {
|
|
||||||
client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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 消息
|
||||||
|
|
||||||
> **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。
|
> **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。
|
||||||
|
|
||||||
## 前端组件
|
## 前端组件
|
||||||
@@ -132,17 +115,19 @@ func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, r
|
|||||||
|------|------|
|
|------|------|
|
||||||
| CameraManager | 摄像头流采集 |
|
| CameraManager | 摄像头流采集 |
|
||||||
| MicManager | 麦克风音频采集 |
|
| MicManager | 麦克风音频采集 |
|
||||||
| EdgeProcessor | VAD + 关键帧检测(ONNX Runtime) |
|
| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) |
|
||||||
| WebSocketManager | WS 连接生命周期管理 |
|
| WebSocketManager | WS 连接生命周期管理 |
|
||||||
| ChatPanel | 消息展示 |
|
| ChatPanel | 消息展示 |
|
||||||
| VideoPreview | 摄像头画面预览 |
|
| VideoPreview | 摄像头画面预览 |
|
||||||
|
| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言) |
|
||||||
|
| Toast | 轻量通知提示(3 秒自动消失) |
|
||||||
|
|
||||||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。
|
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function useVisionSession() {
|
function useVisionSession() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const wsRef = useWebSocket("ws://localhost:8080/ws");
|
const wsRef = useWebSocket(`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const { captureFrame } = useCamera(videoRef);
|
const { captureFrame } = useCamera(videoRef);
|
||||||
|
|
||||||
@@ -173,7 +158,7 @@ function useVisionSession() {
|
|||||||
|
|
||||||
| 阶段 | 存储方案 | 持久化内容 | 理由 |
|
| 阶段 | 存储方案 | 持久化内容 | 理由 |
|
||||||
|------|---------|-----------|------|
|
|------|---------|-----------|------|
|
||||||
| MVP | Redis only | 无 | 快速验证核心功能,重启丢数据可接受 |
|
| MVP | Memory(进程内) | 无 | 快速验证核心功能,重启丢数据可接受。Redis 实现已就绪,可通过 `storage.driver` 配置切换 |
|
||||||
| 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 |
|
| 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 |
|
||||||
| 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 |
|
| 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 |
|
||||||
|
|
||||||
@@ -262,24 +247,8 @@ server {
|
|||||||
|
|
||||||
> WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。
|
> WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。
|
||||||
|
|
||||||
### 开发环境(Vite proxy)
|
### 开发环境
|
||||||
|
|
||||||
开发时前端(Vite :5173)和后端(Gin :8080)不同端口,用 Vite 内置代理解决跨域:
|
开发时前端(Vite :5173)和后端(Gin :8080)不同端口。前端 WebSocket 地址基于 `window.location.host` 动态构建,通过 Vite `server.proxy` 转发到后端,无需硬编码端口。
|
||||||
|
|
||||||
```typescript
|
`vite.config.ts` 中配置了 `/ws`(WebSocket)和 `/api`(REST)的代理,目标为 `http://localhost:8080`。
|
||||||
// frontend/vite.config.ts
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:8080",
|
|
||||||
"/ws": {
|
|
||||||
target: "ws://localhost:8080",
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
前端代码中 WebSocket 地址改为相对路径 `ws://localhost:5173/ws`,Vite 自动代理到后端。部署时 Nginx 同理,前端无需区分开发/生产地址。
|
|
||||||
|
|||||||
109
docs/03-接口文档.md
109
docs/03-接口文档.md
@@ -41,19 +41,22 @@ interface WsMessage {
|
|||||||
|
|
||||||
#### `query` — 发起一次视觉对话
|
#### `query` — 发起一次视觉对话
|
||||||
|
|
||||||
用户说完话后,客户端同时发送当前图像帧和语音片段:
|
用户说完话后,客户端同时发送当前图像帧和语音片段。也支持文本输入模式(手动输入文字时跳过语音识别):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface QueryMessage {
|
interface QueryMessage {
|
||||||
type: "query";
|
type: "query";
|
||||||
request_id: string; // 客户端生成的 UUID
|
request_id: string; // 客户端生成的 UUID
|
||||||
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
|
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
|
||||||
audio: string; // Base64 编码的音频片段(PCM 16kHz)
|
audio: string; // Base64 编码的音频片段(PCM 16kHz),文本输入时为空字符串
|
||||||
|
text?: string; // 用户手动输入的文本(有值时跳过 STT,直接使用此文本)
|
||||||
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
||||||
|
>
|
||||||
|
> **文本输入模式**:当用户关闭麦克风后,可通过对话框手动输入文字。此时 `text` 字段携带用户输入,`audio` 为空字符串,服务端跳过 STT 直接使用 `text` 进行 LLM 推理。
|
||||||
|
|
||||||
#### `config` — 更新会话配置
|
#### `config` — 更新会话配置
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@ interface ConfigMessage {
|
|||||||
```typescript
|
```typescript
|
||||||
interface InterruptMessage {
|
interface InterruptMessage {
|
||||||
type: "interrupt";
|
type: "interrupt";
|
||||||
request_id?: string; // 可选,指定打断哪次请求
|
request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,16 +146,22 @@ interface TTSAudioMessage {
|
|||||||
type: "tts_audio";
|
type: "tts_audio";
|
||||||
request_id: string;
|
request_id: string;
|
||||||
audio: string; // Base64 编码的音频片段
|
audio: string; // Base64 编码的音频片段
|
||||||
mime_type: string; // "audio/mpeg"
|
mime_type: string; // "audio/mp3"
|
||||||
is_last: boolean; // 是否为最后一片
|
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
||||||
|
final: boolean; // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**字段语义**:
|
||||||
|
|
||||||
|
- `is_last`: 每个句子合成完毕后为 `true`,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 `tts_audio` 消息。
|
||||||
|
- `final`: 所有句子合成完毕后为 `true`(此时 `audio` 为空字符串),用于前端判断本轮 TTS 已全部到齐。
|
||||||
|
|
||||||
**音频格式规范**(前端播放依赖此约定):
|
**音频格式规范**(前端播放依赖此约定):
|
||||||
|
|
||||||
| 属性 | 值 | 说明 |
|
| 属性 | 值 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 编码 | `audio/mpeg`(MP3) | 浏览器 `<audio>` 原生支持,OpenAI TTS 默认输出 |
|
| 编码 | `audio/mp3`(MP3) | 浏览器 `<audio>` 原生支持,OpenAI TTS 默认输出 |
|
||||||
| 采样率 | 24kHz | OpenAI TTS 默认 |
|
| 采样率 | 24kHz | OpenAI TTS 默认 |
|
||||||
| 声道 | 单声道 | 语音不需要立体声 |
|
| 声道 | 单声道 | 语音不需要立体声 |
|
||||||
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
|
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
|
||||||
@@ -162,31 +171,37 @@ interface TTSAudioMessage {
|
|||||||
|
|
||||||
**前端播放实现要点**:
|
**前端播放实现要点**:
|
||||||
|
|
||||||
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
|
1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 `onended` 回调中自动衔接。
|
||||||
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||||
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
||||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mpeg"`,前端解码时直接使用,无需运行时判断。
|
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 前端播放器伪代码
|
// 前端播放器伪代码
|
||||||
class AudioPlayer {
|
class AudioPlayer {
|
||||||
private queue: string[] = []; // Blob URL 队列
|
private sentenceChunks: string[] = []; // 当前句子的音频片段缓冲
|
||||||
|
private queue: string[] = []; // 已就绪的句子 Blob URL 队列
|
||||||
|
|
||||||
enqueue(base64: string) {
|
enqueue(base64: string, isLast: boolean) {
|
||||||
const url = decodeBase64Audio(base64, "audio/mpeg");
|
this.sentenceChunks.push(base64);
|
||||||
this.queue.push(url);
|
if (isLast) {
|
||||||
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
|
// 当前句子音频完整,拼接并加入播放队列
|
||||||
|
const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
|
||||||
|
this.sentenceChunks = [];
|
||||||
|
this.queue.push(url);
|
||||||
|
if (this.queue.length === 1) this.playNext(); // 第一句到了就开始播
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private playNext() {
|
private playNext() {
|
||||||
if (this.queue.length === 0) return;
|
if (this.queue.length === 0) return;
|
||||||
const audio = new Audio(this.queue[0]);
|
const audio = new Audio(this.queue.shift()!);
|
||||||
audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||||
audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||||
audio.play();
|
audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; }
|
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -211,13 +226,13 @@ interface PongMessage {
|
|||||||
|
|
||||||
### 消息流时序
|
### 消息流时序
|
||||||
|
|
||||||
一次完整交互:
|
**语音模式**(麦克风开启):
|
||||||
|
|
||||||
```
|
```
|
||||||
Client Server
|
Client Server
|
||||||
| |
|
| |
|
||||||
|-- query {image, audio} ------>|
|
|-- query {image, audio} ------>|
|
||||||
|<-- stt_result {text} ---------|
|
|<-- stt_result {text} ---------| (语音识别)
|
||||||
| |
|
| |
|
||||||
|<-- llm_chunk {delta: "这"} ---| (LLM 流式输出)
|
|<-- llm_chunk {delta: "这"} ---| (LLM 流式输出)
|
||||||
|<-- llm_chunk {delta: "是一"} -|
|
|<-- llm_chunk {delta: "是一"} -|
|
||||||
@@ -225,7 +240,25 @@ Client Server
|
|||||||
|<-- llm_done {full_text} ------|
|
|<-- llm_done {full_text} ------|
|
||||||
| |
|
| |
|
||||||
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||||
|<-- tts_audio {is_last: true} -|
|
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||||
|
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||||
|
```
|
||||||
|
|
||||||
|
**文本输入模式**(麦克风关闭,手动输入文字):
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
|-- query {image, text} ------->| (跳过 STT)
|
||||||
|
|<-- stt_result {text} ---------| (回显用户文本)
|
||||||
|
| |
|
||||||
|
|<-- llm_chunk {delta: "好的"} -| (LLM 流式输出)
|
||||||
|
|<-- llm_chunk {delta: ",我"} -|
|
||||||
|
|<-- llm_done {full_text} ------|
|
||||||
|
| |
|
||||||
|
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||||
|
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||||
|
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -293,7 +326,7 @@ DELETE /api/sessions/{session_id}
|
|||||||
|
|
||||||
## 三、AI 服务层接口
|
## 三、AI 服务层接口
|
||||||
|
|
||||||
Go 网关内部与外部 AI 服务(Deepgram STT、GPT-4o、OpenAI TTS)的调用契约。前后端联调时,后端需实现这些接口。
|
Go 网关内部与外部 AI 服务(STT、LLM、TTS)的调用契约。默认配置为 Deepgram STT、GPT-4o LLM、OpenAI TTS,但通过 OpenAI 兼容接口可灵活切换到其他服务商(如 MiMo ASR、通义千问等)。前后端联调时,后端需实现这些接口。
|
||||||
|
|
||||||
### STT 服务接口
|
### STT 服务接口
|
||||||
|
|
||||||
@@ -370,7 +403,7 @@ user: [图片 + 用户语音文本]
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 超时:10 秒,超时返回 `LLM_TIMEOUT` 错误
|
- 超时:10 秒,超时返回 `LLM_TIMEOUT` 错误
|
||||||
- 模型选择:默认 `gpt-4o`,由 Model Router 按需切换
|
- 模型选择:默认 `gpt-4o`,可通过配置切换到其他 OpenAI 兼容模型
|
||||||
|
|
||||||
### TTS 服务接口
|
### TTS 服务接口
|
||||||
|
|
||||||
@@ -644,7 +677,7 @@ backend/config.dev.yaml # 开发环境(go run 时使用)
|
|||||||
backend/config.prod.yaml # 生产环境
|
backend/config.prod.yaml # 生产环境
|
||||||
```
|
```
|
||||||
|
|
||||||
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。
|
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。此外,代码还通过 `godotenv` 加载 `.env` 文件(优先级最低,仅用于本地开发环境)。
|
||||||
|
|
||||||
### Go 配置结构体
|
### Go 配置结构体
|
||||||
|
|
||||||
@@ -686,6 +719,7 @@ type AIConfig struct {
|
|||||||
type STTConfig struct {
|
type STTConfig struct {
|
||||||
Provider string `mapstructure:"provider"` // "deepgram"
|
Provider string `mapstructure:"provider"` // "deepgram"
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
|
Model string `mapstructure:"model"` // 默认 "nova-2"
|
||||||
Endpoint string `mapstructure:"endpoint"` // 默认 "wss://api.deepgram.com/v1/listen"
|
Endpoint string `mapstructure:"endpoint"` // 默认 "wss://api.deepgram.com/v1/listen"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,6 +734,7 @@ type LLMConfig struct {
|
|||||||
type TTSConfig struct {
|
type TTSConfig struct {
|
||||||
Provider string `mapstructure:"provider"` // "openai"
|
Provider string `mapstructure:"provider"` // "openai"
|
||||||
APIKey string `mapstructure:"api_key"`
|
APIKey string `mapstructure:"api_key"`
|
||||||
|
Model string `mapstructure:"model"` // 默认 "tts-1"
|
||||||
Voice string `mapstructure:"voice"` // 默认 "alloy"
|
Voice string `mapstructure:"voice"` // 默认 "alloy"
|
||||||
Speed float64 `mapstructure:"speed"` // 默认 1.0
|
Speed float64 `mapstructure:"speed"` // 默认 1.0
|
||||||
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
|
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
|
||||||
@@ -738,6 +773,7 @@ redis:
|
|||||||
ai:
|
ai:
|
||||||
stt:
|
stt:
|
||||||
provider: deepgram
|
provider: deepgram
|
||||||
|
model: nova-2
|
||||||
endpoint: "wss://api.deepgram.com/v1/listen"
|
endpoint: "wss://api.deepgram.com/v1/listen"
|
||||||
llm:
|
llm:
|
||||||
provider: openai
|
provider: openai
|
||||||
@@ -746,6 +782,7 @@ ai:
|
|||||||
timeout: 10
|
timeout: 10
|
||||||
tts:
|
tts:
|
||||||
provider: openai
|
provider: openai
|
||||||
|
model: tts-1
|
||||||
voice: alloy
|
voice: alloy
|
||||||
speed: 1.0
|
speed: 1.0
|
||||||
endpoint: "https://api.openai.com/v1"
|
endpoint: "https://api.openai.com/v1"
|
||||||
@@ -791,8 +828,9 @@ func Load() (*Config, error) {
|
|||||||
// 1. 读默认配置文件
|
// 1. 读默认配置文件
|
||||||
v.SetConfigName("config")
|
v.SetConfigName("config")
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
v.AddConfigPath("./config") // go run 时
|
v.AddConfigPath(".")
|
||||||
v.AddConfigPath(".") // 二进制运行时
|
v.AddConfigPath("./config")
|
||||||
|
v.AddConfigPath("./backend")
|
||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
return nil, fmt.Errorf("read config: %w", err)
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
@@ -800,7 +838,7 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 按环境覆盖
|
// 2. 按环境覆盖
|
||||||
env := os.Getenv("CAMTALK_APP_ENV")
|
env := os.Getenv("APP_ENV")
|
||||||
if env == "" {
|
if env == "" {
|
||||||
env = "dev"
|
env = "dev"
|
||||||
}
|
}
|
||||||
@@ -886,6 +924,7 @@ type QueryRequest struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Image []byte `json:"-"` // Base64 解码后
|
Image []byte `json:"-"` // Base64 解码后
|
||||||
Audio []byte `json:"-"` // Base64 解码后
|
Audio []byte `json:"-"` // Base64 解码后
|
||||||
|
Text string `json:"text"` // 用户手动输入的文本(有值时跳过 STT)
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,7 +1069,7 @@ func NewApp(cfg *Config) *App {
|
|||||||
|
|
||||||
## 十、连接管理
|
## 十、连接管理
|
||||||
|
|
||||||
**心跳机制**:客户端每 30 秒发送 `ping`,服务端回复 `pong`。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
**心跳机制**:客户端每 30 秒发送应用层 `{type: "ping"}` 消息,服务端回复 `{type: "pong"}` 并刷新心跳计时器。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
||||||
|
|
||||||
**重连策略**(指数退避 + 抖动):
|
**重连策略**(指数退避 + 抖动):
|
||||||
|
|
||||||
@@ -1049,18 +1088,8 @@ function reconnect(attempt: number) {
|
|||||||
|
|
||||||
**生产环境**:Nginx 将 `/`(前端)、`/api/*`(REST)、`/ws`(WebSocket)统一反代到同一域名,详见 `02-系统架构.md` 部署架构章节。
|
**生产环境**:Nginx 将 `/`(前端)、`/api/*`(REST)、`/ws`(WebSocket)统一反代到同一域名,详见 `02-系统架构.md` 部署架构章节。
|
||||||
|
|
||||||
**开发环境**:Vite 内置代理,前端 :5173 的 `/api` 和 `/ws` 请求代理到后端 :8080:
|
**开发环境**:前端 WebSocket 地址基于 `window.location.host` 动态构建(相对路径),通过 Vite `server.proxy` 转发到后端 `http://localhost:8080`。REST API(`/api`)同理通过 Vite 代理转发。
|
||||||
|
|
||||||
```typescript
|
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境通过 Vite proxy 转发,前后端同源,无需额外配置 `CheckOrigin`。
|
||||||
// frontend/vite.config.ts
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:8080",
|
|
||||||
"/ws": { target: "ws://localhost:8080", ws: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境由 Vite proxy 转发,不存在跨域。因此后端无需配置 CORS 中间件,`CheckOrigin` 保持 gorilla/websocket 默认值即可。
|
|
||||||
|
|
||||||
> 如果未来需要支持第三方客户端直连(如移动端),再按需添加 CORS 中间件和 `CheckOrigin` 白名单。
|
> 如果未来需要支持第三方客户端直连(如移动端),再按需添加 CORS 中间件和 `CheckOrigin` 白名单。
|
||||||
|
|||||||
@@ -4,20 +4,58 @@
|
|||||||
|
|
||||||
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
|
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
|
||||||
|
|
||||||
**定位**:持久化部分是拓展选型,不阻塞 MVP(MVP 用 Redis 即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。
|
**定位**:持久化部分是拓展选型,不阻塞 MVP(MVP 用内存存储即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。AI 服务栈(STT/LLM/TTS)已确定默认选型,可通过配置灵活切换。
|
||||||
|
|
||||||
```
|
```
|
||||||
技术选型
|
技术选型
|
||||||
├── 持久化层 → 数据库选型: PostgreSQL
|
├── AI 服务栈
|
||||||
|
│ ├── STT: Deepgram(默认) / MiMo ASR
|
||||||
|
│ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型
|
||||||
|
│ └── TTS: OpenAI TTS(默认) / MiMo TTS
|
||||||
|
├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储)
|
||||||
└── 前端边缘处理层
|
└── 前端边缘处理层
|
||||||
├── 边缘推理: ONNX Runtime Web
|
├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较)
|
||||||
├── 语音检测: @ricky0123/vad-web
|
├── 语音检测: @ricky0123/vad-web
|
||||||
└── 媒体采集: MediaDevices API
|
└── 媒体采集: MediaDevices API
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、持久化层选型
|
## 一、AI 服务栈选型
|
||||||
|
|
||||||
|
### STT(语音识别)
|
||||||
|
|
||||||
|
| 方案 | 延迟 | 成本 | 特点 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别,延迟极低,WebSocket 接口 |
|
||||||
|
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI chat/completions 格式,HTTP 非流式 |
|
||||||
|
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
|
||||||
|
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
|
||||||
|
|
||||||
|
当前默认使用 Deepgram nova-2,可通过 `ai.stt.provider` 配置切换到 MiMo ASR。
|
||||||
|
|
||||||
|
### LLM(多模态大模型)
|
||||||
|
|
||||||
|
| 方案 | 成本 | 特点 |
|
||||||
|
|------|------|------|
|
||||||
|
| **GPT-4o**(默认) | $2.5/1M tokens | 视觉理解能力强,API 成熟,流式推理 |
|
||||||
|
| 通义千问 qwen3-vl-plus | 按量计费 | 阿里云,通过 OpenAI 兼容接口调用 |
|
||||||
|
| Claude Sonnet | $3/1M tokens | Anthropic,长上下文能力强 |
|
||||||
|
|
||||||
|
代码通过 OpenAI 兼容接口调用,可灵活切换到任何兼容服务商。配置 `ai.llm.provider`、`ai.llm.model`、`ai.llm.endpoint` 即可。
|
||||||
|
|
||||||
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
| 方案 | 成本 | 特点 |
|
||||||
|
|------|------|------|
|
||||||
|
| **OpenAI TTS**(默认) | $15/1M 字符 | 音质自然,支持流式,默认模型 tts-1,语音 alloy |
|
||||||
|
| MiMo TTS(小米) | 按量计费 | 国产替代,通过配置切换 |
|
||||||
|
|
||||||
|
当前默认使用 OpenAI TTS(tts-1, alloy),可通过 `ai.tts.provider` 配置切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、持久化层选型(规划中,MVP 阶段使用内存存储)
|
||||||
|
|
||||||
### 数据特征分析
|
### 数据特征分析
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ const vad = await MicVAD.new({
|
|||||||
// audio: Float32Array,送入 STT
|
// audio: Float32Array,送入 STT
|
||||||
sendToSTT(audio);
|
sendToSTT(audio);
|
||||||
},
|
},
|
||||||
positiveSpeechThreshold: 0.5, // 检测灵敏度
|
positiveSpeechThreshold: 0.5, // 检测灵敏度
|
||||||
minSpeechDuration: 250 // 最短语音时长 ms
|
negativeSpeechThreshold: 0.35, // 结束灵敏度
|
||||||
|
minSpeechMs: 250, // 最短语音时长 ms
|
||||||
|
redemptionMs: 300, // 语音结束确认时间 ms
|
||||||
|
preSpeechPadMs: 300, // 语音前填充 ms
|
||||||
});
|
});
|
||||||
|
|
||||||
vad.start();
|
vad.start();
|
||||||
@@ -39,34 +42,26 @@ vad.start();
|
|||||||
| 方案 | 延迟 | 成本 | 特点 |
|
| 方案 | 延迟 | 成本 | 特点 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
|
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
|
||||||
| **Deepgram** | <500ms | 按分钟计费 | 流式识别,延迟极低 |
|
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别,延迟极低 |
|
||||||
|
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI 格式,HTTP 非流式 |
|
||||||
| 浏览器原生 | ~1s | 免费 | 中文效果一般 |
|
| 浏览器原生 | ~1s | 免费 | 中文效果一般 |
|
||||||
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
|
|
||||||
|
|
||||||
流式 STT 是低延迟的关键——不必等用户说完,边说边识别:
|
当前实现为**一次性语音识别**(非流式):前端 VAD 检测到用户说完后,将完整音频片段发送到后端,后端调用 `stt.Recognize()` 一次性返回识别结果。流式 STT 为未来优化方向。
|
||||||
|
|
||||||
```typescript
|
音频编码格式:前端 `audio.ts` 将 Float32Array 转为 Int16 PCM(16kHz, pcm_s16le)再编码为 Base64。
|
||||||
// Deepgram 流式识别示例
|
|
||||||
const ws = new WebSocket("wss://api.deepgram.com/v1/listen", {
|
|
||||||
headers: { Authorization: `Token ${API_KEY}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const { transcript, is_final } = JSON.parse(event.data).channel.alternatives[0];
|
|
||||||
if (is_final) {
|
|
||||||
onFinalTranscript(transcript); // 一句完整语音,送入 LLM
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环节三:TTS(文字转语音)
|
## 环节三:TTS(文字转语音)
|
||||||
|
|
||||||
流式 TTS:检测 LLM 输出中的句子边界,每检测到一句就立即送入 TTS 合成并播放,不必等全部生成完。
|
流式 TTS:检测 LLM 输出中的句子边界,每检测到一句就立即送入 TTS 合成并播放,不必等全部生成完。
|
||||||
|
|
||||||
|
句子切分规则:按中文标点(`。!?`)、英文标点(`. ! ?`)和换行符切分。
|
||||||
|
|
||||||
|
当前实现参数:Voice `"alloy"`、Speed `1.0`、OutputFmt `"mp3"`、SampleRate `24000`。
|
||||||
|
|
||||||
方案选择:
|
方案选择:
|
||||||
- **OpenAI TTS**:音质好,延迟中等,按字符计费
|
- **OpenAI TTS**(默认):音质好,延迟中等,按字符计费,模型 tts-1
|
||||||
- **Edge TTS**:微软免费方案,音质不错,延迟略高
|
- **MiMo TTS**(小米):国产替代,通过配置切换
|
||||||
- **Fish Speech / CosyVoice**:开源方案,支持声音克隆,可自部署
|
- **Edge TTS**(规划中):微软免费方案,音质不错,延迟略高
|
||||||
|
|
||||||
## 延迟优化要点
|
## 延迟优化要点
|
||||||
|
|
||||||
|
|||||||
@@ -15,18 +15,30 @@
|
|||||||
| 事件驱动采样 | 用户主动触发(如拍照按钮) | 精确提问场景 |
|
| 事件驱动采样 | 用户主动触发(如拍照按钮) | 精确提问场景 |
|
||||||
| **混合策略** | 低频定时 + 高频事件触发 | **通用推荐方案** |
|
| **混合策略** | 低频定时 + 高频事件触发 | **通用推荐方案** |
|
||||||
|
|
||||||
关键帧检测核心逻辑:
|
关键帧检测核心逻辑(TypeScript 实现,`EdgeProcessor/index.tsx`):
|
||||||
|
|
||||||
```python
|
```typescript
|
||||||
import numpy as np
|
// 降低分辨率到 160x120 做检测,兼顾速度与精度
|
||||||
|
const DETECT_WIDTH = 160;
|
||||||
|
const DETECT_HEIGHT = 120;
|
||||||
|
|
||||||
def is_keyframe(prev_frame, curr_frame, threshold=30):
|
function calcSimilarity(prev: ImageData, curr: ImageData): number {
|
||||||
"""通过帧间像素差异判断是否为关键帧"""
|
const pixelCount = prev.width * prev.height;
|
||||||
diff = np.mean(np.abs(prev_frame.astype(int) - curr_frame.astype(int)))
|
let diffSum = 0;
|
||||||
return diff > threshold
|
// 只比较 RGB 三通道,跳过 Alpha
|
||||||
|
for (let i = 0; i < prev.data.length; i += 4) {
|
||||||
|
diffSum += Math.abs(prev.data[i] - curr.data[i])
|
||||||
|
+ Math.abs(prev.data[i+1] - curr.data[i+1])
|
||||||
|
+ Math.abs(prev.data[i+2] - curr.data[i+2]);
|
||||||
|
}
|
||||||
|
const avgDiff = diffSum / (pixelCount * 3);
|
||||||
|
return 1 - avgDiff / 255; // 相似度:1 = 完全相同,0 = 完全不同
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 实际开发中,先降低分辨率(如 320x240)做关键帧检测,再对命中帧保留原始分辨率送入 LLM,兼顾速度与精度。
|
阈值说明:
|
||||||
|
- 对话模式:`similarity > 0.9` 时跳过(视为重复帧)
|
||||||
|
- 观察模式:`similarity < 0.85` 时触发变化回调
|
||||||
|
|
||||||
## 图像编码与多模态输入
|
## 图像编码与多模态输入
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,12 @@
|
|||||||
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
|
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 混合策略:定时低频 + 事件高频
|
// 混合策略:定时低频 + 事件高频(sampling.ts)
|
||||||
const NORMAL_INTERVAL = 5000; // 正常 5 秒一帧
|
const IDLE_INTERVAL = 5000; // 空闲 5 秒一帧
|
||||||
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||||
|
|
||||||
let isUserSpeaking = false;
|
// SamplingController 根据 VAD 状态切换采样间隔
|
||||||
|
// detail_level 通过 session config 静态配置,不随说话状态动态变化
|
||||||
setInterval(() => {
|
|
||||||
captureAndSend(isUserSpeaking ? "low" : "high");
|
|
||||||
}, isUserSpeaking ? ACTIVE_INTERVAL : NORMAL_INTERVAL);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 策略二:端云协同——把计算推到边缘
|
## 策略二:端云协同——把计算推到边缘
|
||||||
@@ -43,11 +40,11 @@ setInterval(() => {
|
|||||||
不是所有计算都需要上云。可前置到客户端的计算:
|
不是所有计算都需要上云。可前置到客户端的计算:
|
||||||
|
|
||||||
- **VAD 语音检测**:浏览器端完成,减少无效音频上传(节省 ~70% 带宽)
|
- **VAD 语音检测**:浏览器端完成,减少无效音频上传(节省 ~70% 带宽)
|
||||||
- **人脸/物体检测**:用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB,推理 ~30ms),只在检测到新物体时触发 LLM
|
- **人脸/物体检测**(规划中):用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB,推理 ~30ms),只在检测到新物体时触发 LLM。当前 MVP 使用 Canvas 像素比较做关键帧检测
|
||||||
- **重复画面过滤**:计算帧间相似度,相似度 > 90% 直接跳过
|
- **重复画面过滤**:计算帧间相似度,对话模式 similarity > 0.9 跳过,观察模式 similarity < 0.85 触发
|
||||||
- **敏感内容过滤**:NSFW 检测前置,避免无效 API 调用
|
- **敏感内容过滤**(规划中):NSFW 检测前置,避免无效 API 调用
|
||||||
|
|
||||||
## 策略三:模型分级——用对模型做对事
|
## 策略三:模型分级——用对模型做对事(规划中)
|
||||||
|
|
||||||
不是每个问题都需要最贵的模型:
|
不是每个问题都需要最贵的模型:
|
||||||
|
|
||||||
@@ -58,20 +55,10 @@ setInterval(() => {
|
|||||||
└── 代码/推理 → o1 ($15/1M tokens)
|
└── 代码/推理 → o1 ($15/1M tokens)
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
> 当前 MVP 阶段使用单一模型(默认 GPT-4o),模型分级路由为未来优化方向。通过配置 `ai.llm.model` 可手动切换模型。
|
||||||
async function routeQuery(image: string, question: string) {
|
|
||||||
const complexity = await classifyComplexity(question);
|
|
||||||
const modelMap = {
|
|
||||||
simple: "gpt-4o-mini", // "这是什么?"
|
|
||||||
moderate: "gpt-4o", // "分析这张图"
|
|
||||||
complex: "o1" // "推理/规划"
|
|
||||||
};
|
|
||||||
return callLLM(modelMap[complexity], image, question);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 策略四:缓存与复用
|
## 策略四:缓存与复用(规划中)
|
||||||
|
|
||||||
- **语义缓存**:相似问题直接返回缓存结果(如反复问"这是什么")
|
- **语义缓存**(规划中):相似问题直接返回缓存结果(如反复问"这是什么")
|
||||||
- **上下文复用**:连续对话中,未变化的图像不必重复发送
|
- **上下文复用**:连续对话中,未变化的图像不必重复发送(已通过重复画面过滤实现)
|
||||||
- **Prompt 压缩**:精简 system prompt,减少每轮的固定 token 开销
|
- **对话历史裁剪**:前端按 `MAX_HISTORY_ROUNDS = 10` 裁剪,后端按 `defaultHistorySize = 20` 裁剪,限制每轮的固定 token 开销
|
||||||
|
|||||||
5
docs/10-功能创意.md
Normal file
5
docs/10-功能创意.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
1.视频录制
|
||||||
|
2.对话翻译
|
||||||
|
3.对话总结
|
||||||
|
4.手动对话功能
|
||||||
|
5.视频框大小可调整,可最小化然后拖动
|
||||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -12,6 +12,10 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# VAD 静态资源(从 node_modules 自动复制)
|
||||||
|
public/silero_vad_*.onnx
|
||||||
|
public/vad.worklet.bundle.min.js
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
11
frontend/.mcp.json
Normal file
11
frontend/.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ---- 构建阶段 ----
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先复制依赖清单,利用 Docker 缓存层
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 复制源码并构建
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- 运行阶段 ----
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# 复制 Nginx 配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="description" content="CamTalk — 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
|
||||||
|
<title>CamTalk — AI 视觉对话助手</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
46
frontend/nginx.conf
Normal file
46
frontend/nginx.conf
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 前端静态资源
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# REST API 反代
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket 反代
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
gzip_min_length 256;
|
||||||
|
}
|
||||||
188
frontend/skills/redesign-skill.md
Normal file
188
frontend/skills/redesign-skill.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Redesign Skill - Anti-Slop Frontend Framework
|
||||||
|
|
||||||
|
> Source: https://github.com/Leonxlnx/taste-skill/tree/main/skills/redesign-skill
|
||||||
|
> Purpose: Audit and upgrade existing frontend code to eliminate generic AI-generated UI patterns.
|
||||||
|
|
||||||
|
## How This Works
|
||||||
|
|
||||||
|
When applied to an existing project, follow this sequence:
|
||||||
|
|
||||||
|
1. **Scan** — Read the codebase. Identify the framework, styling method (Tailwind, vanilla CSS, styled-components, etc.), and current design patterns.
|
||||||
|
2. **Diagnose** — Run through the audit below. List every generic pattern, weak point, and missing state you find.
|
||||||
|
3. **Fix** — Apply targeted upgrades working with the existing stack. Do not rewrite from scratch. Improve what's there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Audit
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Check for these problems and fix them:
|
||||||
|
|
||||||
|
- **Browser default fonts or Inter everywhere.** Replace with a font that has character. Good options: `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`. For editorial/creative projects, pair a serif header with a sans-serif body.
|
||||||
|
- **Headlines lack presence.** Increase size for display text, tighten letter-spacing, reduce line-height. Headlines should feel heavy and intentional.
|
||||||
|
- **Body text too wide.** Limit paragraph width to roughly 65 characters. Increase line-height for readability.
|
||||||
|
- **Only Regular (400) and Bold (700) weights used.** Introduce Medium (500) and SemiBold (600) for more subtle hierarchy.
|
||||||
|
- **Numbers in proportional font.** Use a monospace font or enable tabular figures (`font-variant-numeric: tabular-nums`) for data-heavy interfaces.
|
||||||
|
- **Missing letter-spacing adjustments.** Use negative tracking for large headers, positive tracking for small caps or labels.
|
||||||
|
- **All-caps subheaders everywhere.** Try lowercase italics, sentence case, or small-caps instead.
|
||||||
|
- **Orphaned words.** Single words sitting alone on the last line. Fix with `text-wrap: balance` or `text-wrap: pretty`.
|
||||||
|
|
||||||
|
### Color and Surfaces
|
||||||
|
|
||||||
|
- **Pure `#000000` background.** Replace with off-black, dark charcoal, or tinted dark (`#0a0a0a`, `#121212`, or a dark navy).
|
||||||
|
- **Oversaturated accent colors.** Keep saturation below 80%. Desaturate accents so they blend with neutrals instead of screaming.
|
||||||
|
- **More than one accent color.** Pick one. Remove the rest. Consistency beats variety.
|
||||||
|
- **Mixing warm and cool grays.** Stick to one gray family. Tint all grays with a consistent hue (warm or cool, not both).
|
||||||
|
- **Purple/blue "AI gradient" aesthetic.** This is the most common AI design fingerprint. Replace with neutral bases and a single, considered accent.
|
||||||
|
- **Generic `box-shadow`.** Tint shadows to match the background hue. Use colored shadows (e.g., dark blue shadow on a blue background) instead of pure black at low opacity.
|
||||||
|
- **Flat design with zero texture.** Add subtle noise, grain, or micro-patterns to backgrounds. Pure flat vectors feel sterile.
|
||||||
|
- **Perfectly even gradients.** Break the uniformity with radial gradients, noise overlays, or mesh gradients instead of standard linear 45-degree fades.
|
||||||
|
- **Inconsistent lighting direction.** Audit all shadows to ensure they suggest a single, consistent light source.
|
||||||
|
- **Random dark sections in a light mode page (or vice versa).** A single dark-background section breaking an otherwise light page looks like a copy-paste accident. Either commit to a full dark mode or keep a consistent background tone throughout. If contrast is needed, use a slightly darker shade of the same palette — not a sudden jump to `#111` in the middle of a cream page.
|
||||||
|
- **Empty, flat sections with no visual depth.** Sections that are just text on a plain background feel unfinished. Add high-quality background imagery (blurred, overlaid, or masked), subtle patterns, or ambient gradients. Use reliable placeholder sources like `https://picsum.photos/seed/{name}/1920/1080` when real assets are not available. Experiment with background images behind hero sections, feature blocks, or CTAs — even a subtle full-width photo at low opacity adds presence.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- **Everything centered and symmetrical.** Break symmetry with offset margins, mixed aspect ratios, or left-aligned headers over centered content.
|
||||||
|
- **Three equal card columns as feature row.** This is the most generic AI layout. Replace with a 2-column zig-zag, asymmetric grid, horizontal scroll, or masonry layout.
|
||||||
|
- **Using `height: 100vh` for full-screen sections.** Replace with `min-height: 100dvh` to prevent layout jumping on mobile browsers (iOS Safari viewport bug).
|
||||||
|
- **Complex flexbox percentage math.** Replace with CSS Grid for reliable multi-column structures.
|
||||||
|
- **No max-width container.** Add a container constraint (around 1200-1440px) with auto margins so content doesn't stretch edge-to-edge on wide screens.
|
||||||
|
- **Cards of equal height forced by flexbox.** Allow variable heights or use masonry when content varies in length.
|
||||||
|
- **Uniform border-radius on everything.** Vary the radius: tighter on inner elements, softer on containers.
|
||||||
|
- **No overlap or depth.** Elements sit flat next to each other. Use negative margins to create layering and visual depth.
|
||||||
|
- **Symmetrical vertical padding.** Top and bottom padding are always identical. Adjust optically — bottom padding often needs to be slightly larger.
|
||||||
|
- **Dashboard always has a left sidebar.** Try top navigation, a floating command menu, or a collapsible panel instead.
|
||||||
|
- **Missing whitespace.** Double the spacing. Let the design breathe. Dense layouts work for data dashboards, not for marketing pages.
|
||||||
|
- **Buttons not bottom-aligned in card groups.** When cards have different content lengths, CTAs end up at random heights. Pin buttons to the bottom of each card so they form a clean horizontal line regardless of content above.
|
||||||
|
- **Feature lists starting at different vertical positions.** In pricing tables or comparison cards, the list of features should start at the same Y position across all columns. Use consistent spacing above the list or fixed-height title/price blocks.
|
||||||
|
- **Inconsistent vertical rhythm in side-by-side elements.** When placing cards, columns, or panels next to each other, align shared elements (titles, descriptions, prices, buttons) across all items. Misaligned baselines make the layout look broken.
|
||||||
|
- **Mathematical alignment that looks optically wrong.** Centering by the math doesn't always look centered to the eye. Icons next to text, play buttons in circles, or text in buttons often need 1-2px optical adjustments to feel right.
|
||||||
|
|
||||||
|
### Interactivity and States
|
||||||
|
|
||||||
|
- **No hover states on buttons.** Add background shift, slight scale, or translate on hover.
|
||||||
|
- **No active/pressed feedback.** Add a subtle `scale(0.98)` or `translateY(1px)` on press to simulate a physical click.
|
||||||
|
- **Instant transitions with zero duration.** Add smooth transitions (200-300ms) to all interactive elements.
|
||||||
|
- **Missing focus ring.** Ensure visible focus indicators for keyboard navigation. This is an accessibility requirement, not optional.
|
||||||
|
- **No loading states.** Replace generic circular spinners with skeleton loaders that match the layout shape.
|
||||||
|
- **No empty states.** An empty dashboard showing nothing is a missed opportunity. Design a composed "getting started" view.
|
||||||
|
- **No error states.** Add clear, inline error messages for forms. Do not use `window.alert()`.
|
||||||
|
- **Dead links.** Buttons that link to `#`. Either link to real destinations or visually disable them.
|
||||||
|
- **No indication of current page in navigation.** Style the active nav link differently so users know where they are.
|
||||||
|
- **Scroll jumping.** Anchor clicks jump instantly. Add `scroll-behavior: smooth`.
|
||||||
|
- **Animations using `top`, `left`, `width`, `height`.** Switch to `transform` and `opacity` for GPU-accelerated, smooth animation.
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
- **Generic names like "John Doe" or "Jane Smith".** Use diverse, realistic-sounding names.
|
||||||
|
- **Fake round numbers like `99.99%`, `50%`, `$100.00`.** Use organic, messy data: `47.2%`, `$99.00`, `+1 (312) 847-1928`.
|
||||||
|
- **Placeholder company names like "Acme Corp", "Nexus", "SmartFlow".** Invent contextual, believable brand names.
|
||||||
|
- **AI copywriting cliches.** Never use "Elevate", "Seamless", "Unleash", "Next-Gen", "Game-changer", "Delve", "Tapestry", or "In the world of...". Write plain, specific language.
|
||||||
|
- **Exclamation marks in success messages.** Remove them. Be confident, not loud.
|
||||||
|
- **"Oops!" error messages.** Be direct: "Connection failed. Please try again."
|
||||||
|
- **Passive voice.** Use active voice: "We couldn't save your changes" instead of "Mistakes were made."
|
||||||
|
- **All blog post dates identical.** Randomize dates to appear real.
|
||||||
|
- **Same avatar image for multiple users.** Use unique assets for every distinct person.
|
||||||
|
- **Lorem Ipsum.** Never use placeholder latin text. Write real draft copy.
|
||||||
|
- **Title Case On Every Header.** Use sentence case instead.
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
- **Generic card look (border + shadow + white background).** Remove the border, or use only background color, or use only spacing. Cards should exist only when elevation communicates hierarchy.
|
||||||
|
- **Always one filled button + one ghost button.** Add text links or tertiary styles to reduce visual noise.
|
||||||
|
- **Pill-shaped "New" and "Beta" badges.** Try square badges, flags, or plain text labels.
|
||||||
|
- **Accordion FAQ sections.** Use a side-by-side list, searchable help, or inline progressive disclosure.
|
||||||
|
- **3-card carousel testimonials with dots.** Replace with a masonry wall, embedded social posts, or a single rotating quote.
|
||||||
|
- **Pricing table with 3 towers.** Highlight the recommended tier with color and emphasis, not just extra height.
|
||||||
|
- **Modals for everything.** Use inline editing, slide-over panels, or expandable sections instead of popups for simple actions.
|
||||||
|
- **Avatar circles exclusively.** Try squircles or rounded squares for a less generic look.
|
||||||
|
- **Light/dark toggle always a sun/moon switch.** Use a dropdown, system preference detection, or integrate it into settings.
|
||||||
|
- **Footer link farm with 4 columns.** Simplify. Focus on main navigational paths and legally required links.
|
||||||
|
|
||||||
|
### Iconography
|
||||||
|
|
||||||
|
- **Lucide or Feather icons exclusively.** These are the "default" AI icon choice. Use Phosphor, Heroicons, or a custom set for differentiation.
|
||||||
|
- **Rocketship for "Launch", shield for "Security".** Replace cliche metaphors with less obvious icons (bolt, fingerprint, spark, vault).
|
||||||
|
- **Inconsistent stroke widths across icons.** Audit all icons and standardize to one stroke weight.
|
||||||
|
- **Missing favicon.** Always include a branded favicon.
|
||||||
|
- **Stock "diverse team" photos.** Use real team photos, candid shots, or a consistent illustration style instead of uncanny stock imagery.
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- **Div soup.** Use semantic HTML: `<header>`, `<main>`, `<article>`, `<section>`, `<footer>`.
|
||||||
|
- **Inline styles mixed with CSS classes.** Move all styling to the project's styling system.
|
||||||
|
- **Hardcoded pixel widths.** Use relative units (`%`, `rem`, `em`, `max-width`) for flexible layouts.
|
||||||
|
- **Missing alt text on images.** Describe image content for screen readers. Never leave `alt=""` or `alt="image"` on meaningful images.
|
||||||
|
- **Arbitrary z-index values like `9999`.** Establish a clean z-index scale in the theme/variables.
|
||||||
|
- **Commented-out dead code.** Remove all debug artifacts before shipping.
|
||||||
|
- **Import hallucinations.** Check that every import actually exists in `package.json` or the project dependencies.
|
||||||
|
- **Missing meta tags.** Add proper `<title>`, `<description>`, `<og:image>`, and social sharing meta tags.
|
||||||
|
|
||||||
|
### Strategic Omissions (What AI Typically Forgets)
|
||||||
|
|
||||||
|
- **No legal links.** Add privacy policy and terms of service links in the footer.
|
||||||
|
- **No "back" navigation.** Dead ends in user flows. Every page needs a way back.
|
||||||
|
- **No custom 404 page.** Design a helpful, branded "page not found" experience.
|
||||||
|
- **No form validation.** Add client-side validation for emails, required fields, and format checks.
|
||||||
|
- **No "skip to content" link.** Essential for keyboard users. Add a hidden skip-link.
|
||||||
|
- **No cookie consent.** If required by jurisdiction, add a compliant consent banner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Techniques
|
||||||
|
|
||||||
|
When upgrading a project, pull from these high-impact techniques to replace generic patterns:
|
||||||
|
|
||||||
|
### Typography Upgrades
|
||||||
|
|
||||||
|
- **Variable font animation.** Interpolate weight or width on scroll or hover for text that feels alive.
|
||||||
|
- **Outlined-to-fill transitions.** Text starts as a stroke outline and fills with color on scroll entry or interaction.
|
||||||
|
- **Text mask reveals.** Large typography acting as a window to video or animated imagery behind it.
|
||||||
|
|
||||||
|
### Layout Upgrades
|
||||||
|
|
||||||
|
- **Broken grid / asymmetry.** Elements that deliberately ignore column structure — overlapping, bleeding off-screen, or offset with calculated randomness.
|
||||||
|
- **Whitespace maximization.** Aggressive use of negative space to force focus on a single element.
|
||||||
|
- **Parallax card stacks.** Sections that stick and physically stack over each other during scroll.
|
||||||
|
- **Split-screen scroll.** Two halves of the screen sliding in opposite directions.
|
||||||
|
|
||||||
|
### Motion Upgrades
|
||||||
|
|
||||||
|
- **Smooth scroll with inertia.** Decouple scrolling from browser defaults for a heavier, cinematic feel.
|
||||||
|
- **Staggered entry.** Elements cascade in with slight delays, combining Y-axis translation with opacity fade. Never mount everything at once.
|
||||||
|
- **Spring physics.** Replace linear easing with spring-based motion for a natural, weighty feel on all interactive elements.
|
||||||
|
- **Scroll-driven reveals.** Content entering through expanding masks, wipes, or draw-on SVG paths tied to scroll progress.
|
||||||
|
|
||||||
|
### Surface Upgrades
|
||||||
|
|
||||||
|
- **True glassmorphism.** Go beyond `backdrop-filter: blur`. Add a 1px inner border and a subtle inner shadow to simulate edge refraction.
|
||||||
|
- **Spotlight borders.** Card borders that illuminate dynamically under the cursor.
|
||||||
|
- **Grain and noise overlays.** A fixed, pointer-events-none overlay with subtle noise to break digital flatness.
|
||||||
|
- **Colored, tinted shadows.** Shadows that carry the hue of the background rather than using generic black.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Priority
|
||||||
|
|
||||||
|
Apply changes in this order for maximum visual impact with minimum risk:
|
||||||
|
|
||||||
|
1. **Font swap** — biggest instant improvement, lowest risk
|
||||||
|
2. **Color palette cleanup** — remove clashing or oversaturated colors
|
||||||
|
3. **Hover and active states** — makes the interface feel alive
|
||||||
|
4. **Layout and spacing** — proper grid, max-width, consistent padding
|
||||||
|
5. **Replace generic components** — swap cliche patterns for modern alternatives
|
||||||
|
6. **Add loading, empty, and error states** — makes it feel finished
|
||||||
|
7. **Polish typography scale and spacing** — the premium final touch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Work with the existing tech stack. Do not migrate frameworks or styling libraries.
|
||||||
|
- Do not break existing functionality. Test after every change.
|
||||||
|
- Before importing any new library, check the project's dependency file first.
|
||||||
|
- If the project uses Tailwind, check the version (v3 vs v4) before modifying config.
|
||||||
|
- If the project has no framework, use vanilla CSS.
|
||||||
|
- Keep changes reviewable and focused. Small, targeted improvements over big rewrites.
|
||||||
98
frontend/skills/soft-skill.md
Normal file
98
frontend/skills/soft-skill.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: high-end-visual-design
|
||||||
|
description: Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Skill: Principal UI/UX Architect & Motion Choreographer (Awwwards-Tier)
|
||||||
|
|
||||||
|
## 1. Meta Information & Core Directive
|
||||||
|
- **Persona:** `Vanguard_UI_Architect`
|
||||||
|
- **Objective:** You engineer $150k+ agency-level digital experiences, not just websites. Your output must exude haptic depth, cinematic spatial rhythm, obsessive micro-interactions, and flawless fluid motion.
|
||||||
|
- **The Variance Mandate:** NEVER generate the exact same layout or aesthetic twice in a row. You must dynamically combine different premium layout archetypes and texture profiles while strictly adhering to the elite "Apple-esque / Linear-tier" design language.
|
||||||
|
|
||||||
|
## 2. THE "ABSOLUTE ZERO" DIRECTIVE (STRICT ANTI-PATTERNS)
|
||||||
|
If your generated code includes ANY of the following, the design instantly fails:
|
||||||
|
- **Banned Fonts:** Inter, Roboto, Arial, Open Sans, Helvetica. (Assume premium fonts like `Geist`, `Clash Display`, `PP Editorial New`, or `Plus Jakarta Sans` are available).
|
||||||
|
- **Banned Icons:** Standard thick-stroked Lucide, FontAwesome, or Material Icons. Use only ultra-light, precise lines (e.g., Phosphor Light, Remix Line).
|
||||||
|
- **Banned Borders & Shadows:** Generic 1px solid gray borders. Harsh, dark drop shadows (`shadow-md`, `rgba(0,0,0,0.3)`).
|
||||||
|
- **Banned Layouts:** Edge-to-edge sticky navbars glued to the top. Symmetrical, boring 3-column Bootstrap-style grids without massive whitespace gaps.
|
||||||
|
- **Banned Motion:** Standard `linear` or `ease-in-out` transitions. Instant state changes without interpolation.
|
||||||
|
|
||||||
|
## 3. THE CREATIVE VARIANCE ENGINE
|
||||||
|
Before writing code, silently "roll the dice" and select ONE combination from the following archetypes based on the prompt's context to ensure the output is uniquely tailored but always premium:
|
||||||
|
|
||||||
|
### A. Vibe & Texture Archetypes (Pick 1)
|
||||||
|
1. **Ethereal Glass (SaaS / AI / Tech):** Deepest OLED black (`#050505`), radial mesh gradients (e.g., subtle glowing purple/emerald orbs) in the background. Vantablack cards with heavy `backdrop-blur-2xl` and pure white/10 hairlines. Wide geometric Grotesk typography.
|
||||||
|
2. **Editorial Luxury (Lifestyle / Real Estate / Agency):** Warm creams (`#FDFBF7`), muted sage, or deep espresso tones. High-contrast Variable Serif fonts for massive headings. Subtle CSS noise/film-grain overlay (`opacity-[0.03]`) for a physical paper feel.
|
||||||
|
3. **Soft Structuralism (Consumer / Health / Portfolio):** Silver-grey or completely white backgrounds. Massive bold Grotesk typography. Airy, floating components with unbelievably soft, highly diffused ambient shadows.
|
||||||
|
|
||||||
|
### B. Layout Archetypes (Pick 1)
|
||||||
|
1. **The Asymmetrical Bento:** A masonry-like CSS Grid of varying card sizes (e.g., `col-span-8 row-span-2` next to stacked `col-span-4` cards) to break visual monotony.
|
||||||
|
- **Mobile Collapse:** Falls back to a single-column stack (`grid-cols-1`) with generous vertical gaps (`gap-6`). All `col-span` overrides reset to `col-span-1`.
|
||||||
|
2. **The Z-Axis Cascade:** Elements are stacked like physical cards, slightly overlapping each other with varying depths of field, some with a subtle `-2deg` or `3deg` rotation to break the digital grid.
|
||||||
|
- **Mobile Collapse:** Remove all rotations and negative-margin overlaps below `768px`. Stack vertically with standard spacing. Overlapping elements cause touch-target conflicts on mobile.
|
||||||
|
3. **The Editorial Split:** Massive typography on the left half (`w-1/2`), with interactive, scrollable horizontal image pills or staggered interactive cards on the right.
|
||||||
|
- **Mobile Collapse:** Converts to a full-width vertical stack (`w-full`). Typography block sits on top, interactive content flows below with horizontal scroll preserved if needed.
|
||||||
|
|
||||||
|
**Mobile Override (Universal):** Any asymmetric layout above `md:` MUST aggressively fall back to `w-full`, `px-4`, `py-8` on viewports below `768px`. Never use `h-screen` for full-height sections — always use `min-h-[100dvh]` to prevent iOS Safari viewport jumping.
|
||||||
|
|
||||||
|
## 4. HAPTIC MICRO-AESTHETICS (COMPONENT MASTERY)
|
||||||
|
|
||||||
|
### A. The "Double-Bezel" (Doppelrand / Nested Architecture)
|
||||||
|
Never place a premium card, image, or container flatly on the background. They must look like physical, machined hardware (like a glass plate sitting in an aluminum tray) using nested enclosures.
|
||||||
|
- **Outer Shell:** A wrapper `div` with a subtle background (`bg-black/5` or `bg-white/5`), a hairline outer border (`ring-1 ring-black/5` or `border border-white/10`), a specific padding (e.g., `p-1.5` or `p-2`), and a large outer radius (`rounded-[2rem]`).
|
||||||
|
- **Inner Core:** The actual content container inside the shell. It has its own distinct background color, its own inner highlight (`shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)]`), and a mathematically calculated smaller radius (e.g., `rounded-[calc(2rem-0.375rem)]`) for concentric curves.
|
||||||
|
|
||||||
|
### B. Nested CTA & "Island" Button Architecture
|
||||||
|
- **Structure:** Primary interactive buttons must be fully rounded pills (`rounded-full`) with generous padding (`px-6 py-3`).
|
||||||
|
- **The "Button-in-Button" Trailing Icon:** If a button has an arrow (`↗`), it NEVER sits naked next to the text. It must be nested inside its own distinct circular wrapper (e.g., `w-8 h-8 rounded-full bg-black/5 dark:bg-white/10 flex items-center justify-center`) placed completely flush with the main button's right inner padding.
|
||||||
|
|
||||||
|
### C. Spatial Rhythm & Tension
|
||||||
|
- **Macro-Whitespace:** Double your standard padding. Use `py-24` to `py-40` for sections. Allow the design to breathe heavily.
|
||||||
|
- **Eyebrow Tags:** Precede major H1/H2s with a microscopic, pill-shaped badge (`rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.2em] font-medium`).
|
||||||
|
|
||||||
|
## 5. MOTION CHOREOGRAPHY (FLUID DYNAMICS)
|
||||||
|
Never use default transitions. All motion must simulate real-world mass and spring physics. Use custom cubic-beziers (e.g., `transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]`).
|
||||||
|
|
||||||
|
### A. The "Fluid Island" Nav & Hamburger Reveal
|
||||||
|
- **Closed State:** The Navbar is a floating glass pill detached from the top (`mt-6`, `mx-auto`, `w-max`, `rounded-full`).
|
||||||
|
- **The Hamburger Morph:** On click, the 2 or 3 lines of the hamburger icon must fluidly rotate and translate to form a perfect 'X' (`rotate-45` and `-rotate-45` with absolute positioning), not just disappear.
|
||||||
|
- **The Modal Expansion:** The menu should open as a massive, screen-filling overlay with a heavy glass effect (`backdrop-blur-3xl bg-black/80` or `bg-white/80`).
|
||||||
|
- **Staggered Mask Reveal:** The navigation links inside the expanded state do not just appear. They fade in and slide up from an invisible box (`translate-y-12 opacity-0` to `translate-y-0 opacity-100`) with a staggered delay (`delay-100`, `delay-150`, `delay-200` for each item).
|
||||||
|
|
||||||
|
### B. Magnetic Button Hover Physics
|
||||||
|
- Use the `group` utility. On hover, do not just change the background color.
|
||||||
|
- Scale the entire button down slightly (`active:scale-[0.98]`) to simulate physical pressing.
|
||||||
|
- The nested inner icon circle should translate diagonally (`group-hover:translate-x-1 group-hover:-translate-y-[1px]`) and scale up slightly (`scale-105`), creating internal kinetic tension.
|
||||||
|
|
||||||
|
### C. Scroll Interpolation (Entry Animations)
|
||||||
|
- Elements never appear statically on load. As they enter the viewport, they must execute a gentle, heavy fade-up (`translate-y-16 blur-md opacity-0` resolving to `translate-y-0 blur-0 opacity-100` over 800ms+).
|
||||||
|
- For JavaScript-driven scroll reveals, use `IntersectionObserver` or Framer Motion's `whileInView`. Never use `window.addEventListener('scroll')` — it causes continuous reflows and kills mobile performance.
|
||||||
|
|
||||||
|
## 6. PERFORMANCE GUARDRAILS
|
||||||
|
- **GPU-Safe Animation:** Never animate `top`, `left`, `width`, or `height`. Animate exclusively via `transform` and `opacity`. Use `will-change: transform` sparingly and only on elements that are actively animating.
|
||||||
|
- **Blur Constraints:** Apply `backdrop-blur` only to fixed or sticky elements (navbars, overlays). Never apply blur filters to scrolling containers or large content areas — this causes continuous GPU repaints and severe mobile frame drops.
|
||||||
|
- **Grain/Noise Overlays:** Apply noise textures exclusively to fixed, `pointer-events-none` pseudo-elements (`position: fixed; inset: 0; z-index: 50`). Never attach them to scrolling containers.
|
||||||
|
- **Z-Index Discipline:** Do not use arbitrary `z-50` or `z-[9999]`. Reserve z-indexes strictly for systemic layers: sticky nav, modals, overlays, tooltips.
|
||||||
|
|
||||||
|
## 7. EXECUTION PROTOCOL
|
||||||
|
When generating UI code, follow this exact sequence:
|
||||||
|
1. **[SILENT THOUGHT]** Roll the Variance Engine (Section 3). Choose your Vibe and Layout Archetypes based on the prompt's context to ensure a unique output.
|
||||||
|
2. **[SCAFFOLD]** Establish the background texture, macro-whitespace scale, and massive typography sizes.
|
||||||
|
3. **[ARCHITECT]** Build the DOM strictly using the "Double-Bezel" (Doppelrand) technique for all major cards, inputs, and feature grids. Use exaggerated squircle radii (`rounded-[2rem]`).
|
||||||
|
4. **[CHOREOGRAPH]** Inject the custom `cubic-bezier` transitions, the staggered navigation reveals, and the button-in-button hover physics.
|
||||||
|
5. **[OUTPUT]** Deliver flawless, pixel-perfect React/Tailwind/HTML code. Do not include basic, generic fallbacks.
|
||||||
|
|
||||||
|
## 8. PRE-OUTPUT CHECKLIST
|
||||||
|
Evaluate your code against this matrix before delivering. This is the last filter.
|
||||||
|
- [ ] No banned fonts, icons, borders, shadows, layouts, or motion patterns from Section 2 are present
|
||||||
|
- [ ] A Vibe Archetype and Layout Archetype from Section 3 were consciously selected and applied
|
||||||
|
- [ ] All major cards and containers use the Double-Bezel nested architecture (outer shell + inner core)
|
||||||
|
- [ ] CTA buttons use the Button-in-Button trailing icon pattern where applicable
|
||||||
|
- [ ] Section padding is at minimum `py-24` — the layout breathes heavily
|
||||||
|
- [ ] All transitions use custom cubic-bezier curves — no `linear` or `ease-in-out`
|
||||||
|
- [ ] Scroll entry animations are present — no element appears statically
|
||||||
|
- [ ] Layout collapses gracefully below `768px` to single-column with `w-full` and `px-4`
|
||||||
|
- [ ] All animations use only `transform` and `opacity` — no layout-triggering properties
|
||||||
|
- [ ] `backdrop-blur` is only applied to fixed/sticky elements, never to scrolling content
|
||||||
|
- [ ] The overall impression reads as "$150k agency build", not "template with nice fonts"
|
||||||
1206
frontend/skills/taste-skill-main.md
Normal file
1206
frontend/skills/taste-skill-main.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,41 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
CamTalk — Web 端应用样式
|
CamTalk — Web 端应用样式
|
||||||
双栏布局:左侧视频面板 + 右侧聊天面板
|
双栏布局:左侧视频面板 + 右侧聊天面板
|
||||||
|
|
||||||
|
设计方向:深色科技极简,Ethereal Glass 美学
|
||||||
|
字体:Inter 优先,系统字体栈兜底
|
||||||
|
颜色:冷灰体系,单一蓝色强调色
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-primary: #2563eb;
|
--color-primary: #3b82f6;
|
||||||
--color-primary-hover: #1d4ed8;
|
--color-primary-hover: #2563eb;
|
||||||
--color-bg: #0b0f1a;
|
--color-bg: #0a0a0f;
|
||||||
--color-surface: #151b2b;
|
--color-surface: #111118;
|
||||||
--color-surface-2: #1c2438;
|
--color-surface-2: #1a1a24;
|
||||||
--color-text: #e2e8f0;
|
--color-surface-3: #22222e;
|
||||||
--color-text-muted: #64748b;
|
--color-text: #d4d4dc;
|
||||||
--color-border: #1e293b;
|
--color-text-muted: #5a5a6e;
|
||||||
--color-success: #22c55e;
|
--color-border: #1f1f2a;
|
||||||
--color-warning: #f59e0b;
|
--color-success: #34d399;
|
||||||
--color-error: #ef4444;
|
--color-warning: #fbbf24;
|
||||||
--radius: 10px;
|
--color-error: #f87171;
|
||||||
--radius-sm: 6px;
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--transition-fast: 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
--transition-smooth: 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--color-primary: #2563eb;
|
--color-primary: #2563eb;
|
||||||
--color-primary-hover: #1d4ed8;
|
--color-primary-hover: #1d4ed8;
|
||||||
--color-bg: #f1f5f9;
|
--color-bg: #f4f4f8;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-2: #f8fafc;
|
--color-surface-2: #f0f0f5;
|
||||||
--color-text: #0f172a;
|
--color-surface-3: #e8e8ee;
|
||||||
--color-text-muted: #64748b;
|
--color-text: #111118;
|
||||||
--color-border: #e2e8f0;
|
--color-text-muted: #6b6b80;
|
||||||
|
--color-border: #dddde5;
|
||||||
--color-success: #16a34a;
|
--color-success: #16a34a;
|
||||||
--color-warning: #d97706;
|
--color-warning: #d97706;
|
||||||
--color-error: #dc2626;
|
--color-error: #dc2626;
|
||||||
@@ -44,9 +52,12 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- App Shell ---- */
|
/* ---- App Shell ---- */
|
||||||
@@ -64,11 +75,14 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 24px;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 1px 8px rgba(10, 10, 15, 0.3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__left {
|
.header__left {
|
||||||
@@ -78,14 +92,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header__title {
|
.header__title {
|
||||||
font-size: 1.2rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__subtitle {
|
.header__subtitle {
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
|
font-weight: 400;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__right {
|
.header__right {
|
||||||
@@ -95,30 +112,34 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header__stats {
|
.header__stats {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
padding: 4px 10px;
|
padding: 4px 12px;
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
padding: 3px 10px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge--connected {
|
.badge--connected {
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
background: rgba(34, 197, 94, 0.1);
|
background: rgba(52, 211, 153, 0.08);
|
||||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge--connecting {
|
.badge--connecting {
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
background: rgba(245, 158, 11, 0.1);
|
background: rgba(251, 191, 36, 0.08);
|
||||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge--disconnected {
|
.badge--disconnected {
|
||||||
@@ -132,10 +153,13 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px;
|
padding: 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: background 0.15s;
|
transition: all var(--transition-fast);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
@@ -143,6 +167,10 @@ body {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.lang-group {
|
.lang-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -158,9 +186,9 @@ body {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn:hover {
|
.lang-btn:hover {
|
||||||
@@ -187,8 +215,8 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
@@ -198,6 +226,11 @@ body {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container:hover {
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container .video-preview {
|
.video-container .video-preview {
|
||||||
@@ -210,7 +243,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #1a1a2e;
|
background: #12121a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder {
|
.video-placeholder {
|
||||||
@@ -220,29 +253,36 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder__icon {
|
.video-placeholder__icon {
|
||||||
font-size: 2.4rem;
|
font-size: 2.2rem;
|
||||||
opacity: 0.2;
|
opacity: 0.15;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-placeholder__hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.video-indicator {
|
.video-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(10, 10, 15, 0.8);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-badge {
|
.live-badge {
|
||||||
@@ -252,22 +292,23 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(10, 10, 15, 0.8);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-badge__dot {
|
.live-badge__dot {
|
||||||
width: 8px;
|
width: 7px;
|
||||||
height: 8px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-success);
|
background: var(--color-success);
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-indicator--loading { color: var(--color-warning); }
|
.video-indicator--loading { color: var(--color-warning); }
|
||||||
@@ -278,45 +319,49 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
background: rgba(37, 99, 235, 0.85);
|
background: rgba(59, 130, 246, 0.85);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.7rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-badge {
|
.observation-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
background: rgba(34, 197, 94, 0.85);
|
background: rgba(52, 211, 153, 0.85);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.7rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
animation: pulse 2s ease-in-out infinite;
|
letter-spacing: 0.04em;
|
||||||
|
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay {
|
.video-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(10, 10, 15, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay__spinner {
|
.video-overlay__spinner {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
border: 2.5px solid rgba(255, 255, 255, 0.15);
|
||||||
border-top-color: white;
|
border-top-color: rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -327,53 +372,66 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-controls__row {
|
.video-controls__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--ctrl {
|
.btn--ctrl {
|
||||||
padding: 6px 12px;
|
padding: 7px 14px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all var(--transition-fast);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--ctrl:hover {
|
.btn--ctrl:hover {
|
||||||
background: var(--color-border);
|
background: var(--color-surface-3);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ctrl:active {
|
||||||
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--ctrl-on {
|
.btn--ctrl-on {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(52, 211, 153, 0.1);
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
border-color: rgba(34, 197, 94, 0.3);
|
border-color: rgba(52, 211, 153, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ctrl-on:hover {
|
||||||
|
background: rgba(52, 211, 153, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--ctrl-off {
|
.btn--ctrl-off {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(248, 113, 113, 0.08);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
border-color: rgba(239, 68, 68, 0.2);
|
border-color: rgba(248, 113, 113, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ctrl-off:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--speaking {
|
.btn--speaking {
|
||||||
animation: micPulse 0.8s ease-in-out infinite;
|
animation: micPulse 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
|
box-shadow: 0 0 12px rgba(52, 211, 153, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes micPulse {
|
@keyframes micPulse {
|
||||||
0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); transform: scale(1); }
|
0%, 100% { box-shadow: 0 0 8px rgba(52, 211, 153, 0.3); transform: scale(1); }
|
||||||
50% { box-shadow: 0 0 16px rgba(34, 197, 94, 0.8); transform: scale(1.05); }
|
50% { box-shadow: 0 0 18px rgba(52, 211, 153, 0.7); transform: scale(1.04); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 右侧聊天面板 ---- */
|
/* ---- 右侧聊天面板 ---- */
|
||||||
@@ -391,21 +449,23 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 14px 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel-header__mode {
|
.chat-panel-header__mode {
|
||||||
font-size: 0.7rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
background: rgba(34, 197, 94, 0.1);
|
background: rgba(52, 211, 153, 0.08);
|
||||||
padding: 2px 8px;
|
padding: 3px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel-body {
|
.chat-panel-body {
|
||||||
@@ -419,33 +479,91 @@ body {
|
|||||||
|
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel__messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel__welcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel__welcome-icon {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
opacity: 0.12;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel__welcome-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
max-width: 240px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel--empty {
|
.chat-panel--empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
gap: 12px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel--empty-icon {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
opacity: 0.12;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel--empty-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
max-width: 240px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message {
|
.chat-message {
|
||||||
padding: 10px 14px;
|
padding: 12px 16px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
max-width: 92%;
|
max-width: min(92%, 65ch);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message:hover {
|
||||||
|
background: var(--color-surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message--user {
|
.chat-message--user {
|
||||||
border-left: 2px solid var(--color-primary);
|
border-left: 2px solid var(--color-primary);
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background: rgba(37, 99, 235, 0.08);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message--user:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message--assistant {
|
.chat-message--assistant {
|
||||||
@@ -457,24 +575,77 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message__role {
|
.chat-message__role {
|
||||||
font-size: 0.65rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.08em;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message__content {
|
.chat-message__content {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.55;
|
line-height: 1.65;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
max-width: 65ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message__meta {
|
.chat-message__meta {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
font-size: 0.65rem;
|
font-size: 0.62rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Chat Input ---- */
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__field:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input__send:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Streaming Cursor ---- */
|
/* ---- Streaming Cursor ---- */
|
||||||
@@ -483,22 +654,24 @@ body {
|
|||||||
display: inline;
|
display: inline;
|
||||||
animation: blink 0.8s step-end infinite;
|
animation: blink 0.8s step-end infinite;
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- System Message ---- */
|
/* ---- System Message ---- */
|
||||||
|
|
||||||
.system-message {
|
.system-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 16px;
|
padding: 10px 16px;
|
||||||
margin: 0 20px;
|
margin: 0 24px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-message--warning {
|
.system-message--warning {
|
||||||
background: rgba(245, 158, 11, 0.1);
|
background: rgba(251, 191, 36, 0.08);
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
border: 1px solid rgba(251, 191, 36, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Drawer (Config Panel) ---- */
|
/* ---- Drawer (Config Panel) ---- */
|
||||||
@@ -507,9 +680,10 @@ body {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(10, 10, 15, 0.5);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(4px);
|
||||||
animation: fadeIn 0.2s ease;
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.25s var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
@@ -522,22 +696,23 @@ body {
|
|||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: slideInRight 0.25s ease;
|
animation: slideInRight 0.35s var(--transition-smooth);
|
||||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.3);
|
box-shadow: -8px 0 40px rgba(10, 10, 15, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__header {
|
.drawer__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 16px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__title {
|
.drawer__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__close {
|
.drawer__close {
|
||||||
@@ -546,9 +721,12 @@ body {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 4px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: all 0.15s;
|
transition: all var(--transition-fast);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__close:hover {
|
.drawer__close:hover {
|
||||||
@@ -556,26 +734,30 @@ body {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer__close:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.drawer__body {
|
.drawer__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Config Group ---- */
|
/* ---- Config Group ---- */
|
||||||
|
|
||||||
.config-group {
|
.config-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-group__title {
|
.config-group__title {
|
||||||
font-size: 0.7rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.1em;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,16 +765,23 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 0;
|
padding: 10px 8px;
|
||||||
|
margin: 0 -8px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-row__info {
|
.config-row__info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-row__label {
|
.config-row__label {
|
||||||
@@ -601,8 +790,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-row__desc {
|
.config-row__desc {
|
||||||
font-size: 0.72rem;
|
font-size: 0.7rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-row select,
|
.config-row select,
|
||||||
@@ -611,23 +801,36 @@ body {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 5px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row select:hover,
|
||||||
|
.config-row input[type="checkbox"]:hover {
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row select:focus,
|
||||||
|
.config-row input[type="checkbox"]:focus {
|
||||||
|
outline: 2px solid rgba(59, 130, 246, 0.4);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-row input[type="checkbox"] {
|
.config-row input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Toast ---- */
|
/* ---- Toast ---- */
|
||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 68px;
|
top: 72px;
|
||||||
right: 20px;
|
right: 24px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -637,16 +840,30 @@ body {
|
|||||||
.toast {
|
.toast {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.35s var(--transition-smooth);
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 8px 32px rgba(10, 10, 15, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--error { background: rgba(239, 68, 68, 0.9); color: white; }
|
.toast--error {
|
||||||
.toast--warning { background: rgba(245, 158, 11, 0.9); color: #000; }
|
background: rgba(248, 113, 113, 0.92);
|
||||||
.toast--info { background: rgba(37, 99, 235, 0.9); color: white; }
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--warning {
|
||||||
|
background: rgba(251, 191, 36, 0.92);
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--info {
|
||||||
|
background: rgba(59, 130, 246, 0.92);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Buttons ---- */
|
/* ---- Buttons ---- */
|
||||||
|
|
||||||
@@ -654,18 +871,23 @@ body {
|
|||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all var(--transition-fast);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--lg {
|
.btn--lg {
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.92rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,43 +907,46 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn--secondary:hover {
|
.btn--secondary:hover {
|
||||||
background: var(--color-border);
|
background: var(--color-surface-3);
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--warning {
|
.btn--warning {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
color: #000;
|
color: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--warning:hover { opacity: 0.9; }
|
.btn--warning:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.btn--danger {
|
.btn--danger {
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(248, 113, 113, 0.1);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--danger:hover {
|
.btn--danger:hover {
|
||||||
background: rgba(239, 68, 68, 0.25);
|
background: rgba(248, 113, 113, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--active {
|
.btn--active {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 0 12px rgba(37, 99, 235, 0.4);
|
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
|
||||||
animation: pulseBtn 2s ease-in-out infinite;
|
animation: pulseBtn 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Animations ---- */
|
/* ---- Animations ---- */
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.6; }
|
50% { opacity: 0.55; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulseBtn {
|
@keyframes pulseBtn {
|
||||||
0%, 100% { box-shadow: 0 0 12px rgba(37, 99, 235, 0.4); }
|
0%, 100% { box-shadow: 0 0 12px rgba(59, 130, 246, 0.35); }
|
||||||
50% { box-shadow: 0 0 20px rgba(37, 99, 235, 0.7); }
|
50% { box-shadow: 0 0 22px rgba(59, 130, 246, 0.6); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
@@ -734,7 +959,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { opacity: 0; transform: translateX(20px); }
|
from { opacity: 0; transform: translateX(16px); }
|
||||||
to { opacity: 1; transform: translateX(0); }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +976,7 @@ body {
|
|||||||
/* ---- Scrollbar ---- */
|
/* ---- Scrollbar ---- */
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -760,7 +985,7 @@ body {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function App() {
|
|||||||
isMicOn,
|
isMicOn,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
|
sendTextMessage,
|
||||||
} = useVisionSession();
|
} = useVisionSession();
|
||||||
|
|
||||||
const isConnected = connectionStatus === "connected";
|
const isConnected = connectionStatus === "connected";
|
||||||
@@ -108,8 +109,12 @@ function App() {
|
|||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => setShowConfig((v) => !v)}
|
onClick={() => setShowConfig((v) => !v)}
|
||||||
title="设置"
|
title="设置"
|
||||||
|
aria-label="设置"
|
||||||
>
|
>
|
||||||
⚙️
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -161,8 +166,21 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{!isConnected && !stream && (
|
{!isConnected && !stream && (
|
||||||
<div className="video-placeholder">
|
<div className="video-placeholder">
|
||||||
<span className="video-placeholder__icon">📷</span>
|
<svg className="video-placeholder__icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<span>点击右侧按钮开始对话</span>
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>点击下方按钮开始对话</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isConnected && !isCameraOn && (
|
||||||
|
<div className="video-placeholder">
|
||||||
|
<svg className="video-placeholder__icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>摄像头未开启</span>
|
||||||
|
<span className="video-placeholder__hint">可在右侧聊天框打字对话</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +244,7 @@ function App() {
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
currentReply={currentReply}
|
currentReply={currentReply}
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
|
onSendText={sendTextMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ChatPanel — 消息展示面板
|
// ChatPanel — 消息展示面板
|
||||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动
|
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChatMessage } from "../../types";
|
import type { ChatMessage } from "../../types";
|
||||||
import type { ConnectionStatus } from "../../lib/websocket";
|
import type { ConnectionStatus } from "../../lib/websocket";
|
||||||
|
|
||||||
@@ -11,12 +11,16 @@ interface ChatPanelProps {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
currentReply?: string;
|
currentReply?: string;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
|
onSendText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPanelProps) {
|
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isAutoScroll = useRef(true);
|
const isAutoScroll = useRef(true);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
|
const isConnected = connectionStatus === "connected";
|
||||||
|
|
||||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,52 +43,87 @@ export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPane
|
|||||||
}
|
}
|
||||||
}, [messages, currentReply]);
|
}, [messages, currentReply]);
|
||||||
|
|
||||||
// 空状态
|
// 提交文本消息
|
||||||
if (messages.length === 0 && !currentReply) {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
if (connectionStatus !== "connected") {
|
e.preventDefault();
|
||||||
return (
|
if (!inputText.trim() || !onSendText) return;
|
||||||
<div className="chat-panel chat-panel--empty">
|
onSendText(inputText);
|
||||||
<p>点击下方按钮开始对话</p>
|
setInputText("");
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
}
|
// 未连接时的空状态
|
||||||
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel chat-panel--empty">
|
<div className="chat-panel chat-panel--empty">
|
||||||
<p>对着摄像头说话即可</p>
|
<span className="chat-panel--empty-icon">💬</span>
|
||||||
|
<p>点击下方按钮开始对话</p>
|
||||||
|
<span className="chat-panel--empty-hint">连接后可打字或语音与 AI 交互</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel" ref={containerRef}>
|
<div className="chat-panel">
|
||||||
{messages.map((msg, index) => (
|
<div className="chat-panel__messages" ref={containerRef}>
|
||||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
{/* 空状态提示 */}
|
||||||
<div className="chat-message__role">
|
{messages.length === 0 && !currentReply && (
|
||||||
{msg.role === "user" ? "你" : "AI"}
|
<div className="chat-panel__welcome">
|
||||||
|
<span className="chat-panel__welcome-icon">💬</span>
|
||||||
|
<p>在下方输入文字开始对话</p>
|
||||||
|
<span className="chat-panel__welcome-hint">也可以开启麦克风用语音对话</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-message__content">{msg.content}</div>
|
)}
|
||||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
|
||||||
<div className="chat-message__meta">
|
{messages.map((msg, index) => (
|
||||||
{msg.tokensUsed} tokens
|
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||||
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
<div className="chat-message__role">
|
||||||
{msg.model && ` · ${msg.model}`}
|
{msg.role === "user" ? "你" : "AI"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="chat-message__content">{msg.content}</div>
|
||||||
</div>
|
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||||
))}
|
<div className="chat-message__meta">
|
||||||
|
{msg.tokensUsed} tokens
|
||||||
{/* 流式回复(尚未完成) */}
|
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
||||||
{currentReply && (
|
{msg.model && ` · ${msg.model}`}
|
||||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
</div>
|
||||||
<div className="chat-message__role">AI</div>
|
)}
|
||||||
<div className="chat-message__content">
|
|
||||||
{currentReply}
|
|
||||||
<span className="cursor">▌</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={bottomRef} />
|
{/* 流式回复(尚未完成) */}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 文本输入框 - 连接后始终显示 */}
|
||||||
|
{onSendText && (
|
||||||
|
<form className="chat-input" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="chat-input__field"
|
||||||
|
placeholder="输入文字对话..."
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="chat-input__send"
|
||||||
|
disabled={!inputText.trim()}
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
➤
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export function useVAD(options?: VADOptions) {
|
|||||||
getStream: () => Promise.resolve(stream),
|
getStream: () => Promise.resolve(stream),
|
||||||
startOnLoad: true,
|
startOnLoad: true,
|
||||||
model: "legacy",
|
model: "legacy",
|
||||||
|
// 指向 node_modules 中的 WASM 文件(由 Vite 中间件提供)
|
||||||
|
onnxWASMBasePath: "/node_modules/onnxruntime-web/dist/",
|
||||||
|
|
||||||
onSpeechStart: () => {
|
onSpeechStart: () => {
|
||||||
setIsSpeaking(true);
|
setIsSpeaking(true);
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export function useVisionSession() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止上一轮的 TTS 播放,防止新旧音频重叠
|
||||||
|
ttsPlayerRef.current?.stop();
|
||||||
|
setIsAudioPlaying(false);
|
||||||
|
|
||||||
const frame = captureFrame();
|
const frame = captureFrame();
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
console.warn("[Session] 无法捕获图像帧");
|
console.warn("[Session] 无法捕获图像帧");
|
||||||
@@ -262,9 +266,7 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
case "tts_audio":
|
case "tts_audio":
|
||||||
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
|
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
|
||||||
if (!msg.is_last) {
|
setIsAudioPlaying(true);
|
||||||
setIsAudioPlaying(true);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@@ -280,21 +282,26 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
/** 启动会话 */
|
/** 启动会话 */
|
||||||
const startSession = useCallback(async () => {
|
const startSession = useCallback(async () => {
|
||||||
// 1. 获取摄像头和麦克风
|
// 1. 连接 WebSocket(必须)
|
||||||
await startCamera();
|
|
||||||
setIsCameraOn(true);
|
|
||||||
const micStream = await startMic();
|
|
||||||
if (!micStream) {
|
|
||||||
showToast("无法获取麦克风权限", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsMicOn(true);
|
|
||||||
|
|
||||||
// 2. 连接 WebSocket
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
// 3. 启动 VAD(传入麦克风 stream)
|
// 2. 尝试获取摄像头(可选)
|
||||||
await startVAD(micStream);
|
try {
|
||||||
|
await startCamera();
|
||||||
|
setIsCameraOn(true);
|
||||||
|
} catch {
|
||||||
|
console.warn("[Session] 无法获取摄像头权限,将以纯文本模式运行");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试获取麦克风(可选)
|
||||||
|
const micStream = await startMic();
|
||||||
|
if (micStream) {
|
||||||
|
setIsMicOn(true);
|
||||||
|
// 4. 启动 VAD(仅在麦克风可用时)
|
||||||
|
await startVAD(micStream);
|
||||||
|
} else {
|
||||||
|
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
||||||
|
}
|
||||||
}, [startCamera, startMic, connect, startVAD]);
|
}, [startCamera, startMic, connect, startVAD]);
|
||||||
|
|
||||||
/** 结束会话 */
|
/** 结束会话 */
|
||||||
@@ -332,13 +339,17 @@ export function useVisionSession() {
|
|||||||
/** 麦克风开关 */
|
/** 麦克风开关 */
|
||||||
const toggleMic = useCallback(async () => {
|
const toggleMic = useCallback(async () => {
|
||||||
if (isMicOn) {
|
if (isMicOn) {
|
||||||
|
await stopVAD();
|
||||||
stopMic();
|
stopMic();
|
||||||
setIsMicOn(false);
|
setIsMicOn(false);
|
||||||
} else {
|
} else {
|
||||||
const micStream = await startMic();
|
const micStream = await startMic();
|
||||||
setIsMicOn(!!micStream);
|
if (micStream) {
|
||||||
|
await startVAD(micStream);
|
||||||
|
setIsMicOn(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isMicOn, startMic, stopMic]);
|
}, [isMicOn, startMic, stopMic, startVAD, stopVAD]);
|
||||||
|
|
||||||
/** 打断当前回复 */
|
/** 打断当前回复 */
|
||||||
const interrupt = useCallback(() => {
|
const interrupt = useCallback(() => {
|
||||||
@@ -359,6 +370,44 @@ export function useVisionSession() {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, [send, currentReply]);
|
}, [send, currentReply]);
|
||||||
|
|
||||||
|
/** 发送文本消息(手动输入) */
|
||||||
|
const sendTextMessage = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!text.trim() || isProcessingRef.current) return;
|
||||||
|
|
||||||
|
// 停止上一轮的 TTS 播放
|
||||||
|
ttsPlayerRef.current?.stop();
|
||||||
|
setIsAudioPlaying(false);
|
||||||
|
|
||||||
|
// 捕获当前摄像头画面
|
||||||
|
const frame = captureFrame();
|
||||||
|
|
||||||
|
const requestId = uuidv4();
|
||||||
|
send({
|
||||||
|
type: "query",
|
||||||
|
request_id: requestId,
|
||||||
|
image: frame ? dataUrlToBase64(frame) : "",
|
||||||
|
audio: "", // 文本输入无音频
|
||||||
|
text: text.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新请求统计
|
||||||
|
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: text.trim(), timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 记录到对话历史
|
||||||
|
historyRef.current.push({ role: "user", content: text.trim() });
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
},
|
||||||
|
[captureFrame, send],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
currentReply,
|
currentReply,
|
||||||
@@ -383,5 +432,6 @@ export function useVisionSession() {
|
|||||||
isMicOn,
|
isMicOn,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
|
sendTextMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// TTS Player — 语音播放器
|
// TTS Player — 语音播放器
|
||||||
// 职责:收集后端流式 tts_audio 片段,拼接后播放
|
// 职责:接收后端流式 tts_audio 片段,按句子排队播放
|
||||||
// 格式:MVP 仅支持 audio/mp3,pcm 为 TODO
|
// 设计:第一句到达即开始播放,后续句子在 onended 回调中自动衔接
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
type OnEndCallback = () => void;
|
type OnEndCallback = () => void;
|
||||||
|
|
||||||
export class TTSPlayer {
|
export class TTSPlayer {
|
||||||
private chunks: string[] = [];
|
/** 当前句子的音频片段缓冲(每句可能含多个 chunk) */
|
||||||
|
private sentenceChunks: string[] = [];
|
||||||
|
/** 已就绪的句子 Blob URL 播放队列 */
|
||||||
|
private queue: string[] = [];
|
||||||
private audio: HTMLAudioElement | null = null;
|
private audio: HTMLAudioElement | null = null;
|
||||||
private _isPlaying = false;
|
private _isPlaying = false;
|
||||||
private onEndCallback: OnEndCallback | null = null;
|
private onEndCallback: OnEndCallback | null = null;
|
||||||
|
|
||||||
/** 注册播放完成回调 */
|
/** 注册播放完成回调(所有句子播完后触发) */
|
||||||
onEnd(cb: OnEndCallback): void {
|
onEnd(cb: OnEndCallback): void {
|
||||||
this.onEndCallback = cb;
|
this.onEndCallback = cb;
|
||||||
}
|
}
|
||||||
@@ -25,25 +28,40 @@ export class TTSPlayer {
|
|||||||
/**
|
/**
|
||||||
* 入队一个 TTS 音频片段
|
* 入队一个 TTS 音频片段
|
||||||
* @param base64 Base64 编码的音频数据
|
* @param base64 Base64 编码的音频数据
|
||||||
* @param mimeType 音频格式("audio/mp3" 或 "audio/pcm")
|
* @param mimeType 音频格式("audio/mp3")
|
||||||
* @param isLast 是否为最后一个片段
|
* @param isLast 当前句子是否合成完毕(每句结束时为 true)
|
||||||
*/
|
*/
|
||||||
enqueue(base64: string, mimeType: string, isLast: boolean): void {
|
enqueue(base64: string, mimeType: string, isLast: boolean): void {
|
||||||
this.chunks.push(base64);
|
this.sentenceChunks.push(base64);
|
||||||
|
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
this.play(mimeType);
|
// 当前句子的音频已完整,拼接并加入播放队列
|
||||||
|
const combined = this.sentenceChunks.join("");
|
||||||
|
this.sentenceChunks = [];
|
||||||
|
|
||||||
|
const url = this.base64ToBlobUrl(combined, mimeType);
|
||||||
|
this.queue.push(url);
|
||||||
|
|
||||||
|
// 如果当前没有在播,立即开始播放
|
||||||
|
if (!this._isPlaying) {
|
||||||
|
this.playNext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止播放并清空缓冲区 */
|
/** 停止播放并清空所有队列 */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.audio) {
|
if (this.audio) {
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.audio.removeAttribute("src");
|
this.audio.removeAttribute("src");
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
}
|
}
|
||||||
this.chunks = [];
|
// 释放队列中的 Blob URL
|
||||||
|
for (const url of this.queue) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
this.queue = [];
|
||||||
|
this.sentenceChunks = [];
|
||||||
this._isPlaying = false;
|
this._isPlaying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,48 +75,48 @@ export class TTSPlayer {
|
|||||||
this.audio?.play();
|
this.audio?.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拼接所有片段并播放 */
|
/** 播放队列中的下一个句子 */
|
||||||
private play(mimeType: string): void {
|
private playNext(): void {
|
||||||
if (this.chunks.length === 0) return;
|
if (this.queue.length === 0) {
|
||||||
|
this._isPlaying = false;
|
||||||
// 拼接所有 Base64 片段
|
this.onEndCallback?.();
|
||||||
const combined = this.chunks.join("");
|
return;
|
||||||
this.chunks = [];
|
|
||||||
|
|
||||||
// Base64 → Uint8Array → Blob
|
|
||||||
const binary = atob(combined);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
const blob = new Blob([bytes], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// 播放
|
const url = this.queue.shift()!;
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
this.audio = audio;
|
this.audio = audio;
|
||||||
this._isPlaying = true;
|
this._isPlaying = true;
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
this._isPlaying = false;
|
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
this.onEndCallback?.();
|
this.playNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
console.error("[TTS] 播放失败");
|
console.error("[TTS] 播放失败,跳过");
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
this._isPlaying = false;
|
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
this.onEndCallback?.();
|
this.playNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.play().catch((err) => {
|
audio.play().catch((err) => {
|
||||||
console.error("[TTS] play() 被拒绝:", err);
|
console.error("[TTS] play() 被拒绝:", err);
|
||||||
this._isPlaying = false;
|
URL.revokeObjectURL(url);
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
this.onEndCallback?.();
|
this.playNext();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base64 字符串转 Blob URL */
|
||||||
|
private base64ToBlobUrl(base64: string, mimeType: string): string {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
|
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
|
||||||
|
|
||||||
const WS_URL = "ws://localhost:8080/ws";
|
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
|
||||||
|
const WS_URL =
|
||||||
|
import.meta.env.VITE_WS_URL ||
|
||||||
|
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
|
||||||
const PING_INTERVAL = 30_000; // 30 秒心跳
|
const PING_INTERVAL = 30_000; // 30 秒心跳
|
||||||
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
|
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export interface QueryMessage {
|
|||||||
type: "query";
|
type: "query";
|
||||||
request_id: string;
|
request_id: string;
|
||||||
image: string; // Base64 JPEG(不含 data: 前缀)
|
image: string; // Base64 JPEG(不含 data: 前缀)
|
||||||
audio: string; // Base64 PCM 16kHz
|
audio: string; // Base64 PCM 16kHz(文本输入时为空字符串)
|
||||||
|
text?: string; // 用户手动输入的文本(有值时跳过 STT)
|
||||||
mime_type?: string; // 默认 "audio/pcm"
|
mime_type?: string; // 默认 "audio/pcm"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,8 @@ export interface TTSAudioMessage {
|
|||||||
request_id: string;
|
request_id: string;
|
||||||
audio: string; // Base64 音频片段
|
audio: string; // Base64 音频片段
|
||||||
mime_type: string; // "audio/mp3" 或 "audio/pcm"
|
mime_type: string; // "audio/mp3" 或 "audio/pcm"
|
||||||
is_last: boolean;
|
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
||||||
|
final: boolean; // 整轮 TTS 是否结束
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorMessage {
|
export interface ErrorMessage {
|
||||||
|
|||||||
@@ -1,7 +1,63 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const ortDistDir = path.resolve(__dirname, 'node_modules/onnxruntime-web/dist')
|
||||||
|
const vadDistDir = path.resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist')
|
||||||
|
const publicDir = path.resolve(__dirname, 'public')
|
||||||
|
|
||||||
|
// 需要复制到 public 的静态资源
|
||||||
|
const staticFiles = [
|
||||||
|
{ src: vadDistDir, name: 'silero_vad_legacy.onnx' },
|
||||||
|
{ src: vadDistDir, name: 'silero_vad_v5.onnx' },
|
||||||
|
{ src: vadDistDir, name: 'vad.worklet.bundle.min.js' },
|
||||||
|
]
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
{
|
||||||
|
name: 'serve-vad-assets',
|
||||||
|
// 1. 启动时将 VAD 模型/工作线程复制到 public
|
||||||
|
configResolved() {
|
||||||
|
for (const { src, name } of staticFiles) {
|
||||||
|
const dest = path.join(publicDir, name)
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.copyFileSync(path.join(src, name), dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 2. 中间件拦截所有 WASM 相关请求,从原始路径提供文件
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const url = req.url?.split('?')[0] ?? ''
|
||||||
|
// 匹配任意路径下的 ort-wasm 文件(包括 .vite/deps/ 和根路径)
|
||||||
|
const wasmMatch = url.match(/\/(ort-wasm-simd-threaded\.(mjs|wasm))$/)
|
||||||
|
if (wasmMatch) {
|
||||||
|
const fileName = wasmMatch[1]
|
||||||
|
const filePath = path.join(ortDistDir, fileName)
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
res.setHeader('Content-Type', fileName.endsWith('.wasm') ? 'application/wasm' : 'application/javascript')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.end(fs.readFileSync(filePath))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user