Compare commits
190 Commits
3dc2015a91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9a01269d2 | |||
| 74f1a8eb34 | |||
| 4dd4138197 | |||
| 5ef059eb40 | |||
| 9523fe6650 | |||
| a219934f4b | |||
| 72a8d4803e | |||
| dcf53a3783 | |||
| b5ec6551bf | |||
| 79d321c78a | |||
| 57bd8c72b8 | |||
| d640b5b41b | |||
| f1b9e4fcc1 | |||
| 18aa5f1949 | |||
| a16628119a | |||
| 3888941f85 | |||
| 0a816049fa | |||
| 140a178993 | |||
| 31c1720c00 | |||
| c6093c01c1 | |||
| 3267f35edb | |||
| a309c269e0 | |||
| 22930f0080 | |||
| c00b8a83d9 | |||
| 36007babcb | |||
| f01c84d89e | |||
| c3808ff38c | |||
| 2740bea755 | |||
| 69b9694cfe | |||
| 70ee212ea2 | |||
| f08f547e76 | |||
| 03a19befe1 | |||
| c4b68b77d1 | |||
| f2a883bda8 | |||
| 4cc0713459 | |||
| 2180751a9b | |||
| 964c5c967e | |||
| dcd08d031c | |||
| 6bca4e0d47 | |||
| 881c3f9853 | |||
| 2881941be6 | |||
| 1b99d24fc1 | |||
| f9e68b96b9 | |||
| c58c6b59a5 | |||
| e24fb2cc19 | |||
| 7a705744d0 | |||
| 583c33727a | |||
| 6cbabb63bb | |||
| b4fbf8625b | |||
| 239f8f9877 | |||
| d430e6e5b2 | |||
| edc66625ba | |||
| 9dce107a84 | |||
| 1c7dd708a0 | |||
| d0e4bdaeec | |||
| 949a707e0f | |||
| 55f7f183a7 | |||
| 6b4b033df3 | |||
| 76d331c885 | |||
| ad700743ef | |||
| 6ab4776e08 | |||
| c17798ec67 | |||
| 8a43f4406a | |||
| 065673fae2 | |||
| 03c27e7790 | |||
| 0a59173476 | |||
| 939e43acd0 | |||
| 87c3e7a8dd | |||
| d6e9555a97 | |||
| 9c763ec12a | |||
| 1bab02ae84 | |||
| 89d7b7c17c | |||
| 34d498510e | |||
| 104b28efd3 | |||
| a02a8bc374 | |||
| 0d99d06f06 | |||
| 8b18953010 | |||
| 0108ef2064 | |||
| e0dc8272a5 | |||
| 11c3955bd6 | |||
| a66ab764d9 | |||
| 51117b43f6 | |||
| 492fb06c08 | |||
| 6967dd7b2e | |||
| eea5c07eaa | |||
| 1079e22699 | |||
| ad5d90e344 | |||
| c094fe0867 | |||
| 032de796c8 | |||
| 8b4acb3ce7 | |||
| 9e5f691056 | |||
| 03127aa01a | |||
| 99fcd6bc29 | |||
| d0f5f5c94d | |||
| 361c5d07d3 | |||
| 910e71b6f0 | |||
| 19645be04e | |||
| d53265755a | |||
| 252cdcc8e7 | |||
| 515d7ae034 | |||
| 311330cea1 | |||
| 7adf81c6e5 | |||
| ea00939c13 | |||
| b74fb3564d | |||
| 3b6226394b | |||
| a6df8c9131 | |||
| 023c834074 | |||
| 3e00e39e8a | |||
| 9ad486d117 | |||
| 6af26ffc91 | |||
| 7ee6918015 | |||
| 2fb23852ef | |||
| ed95ce56a8 | |||
| e151c5b665 | |||
| dffd8bd4a5 | |||
| 3140a660e4 | |||
| 23e0e22a12 | |||
| ba7c5ed5ea | |||
| cc7a333b6d | |||
| f8af2f0ccc | |||
| d6059ae397 | |||
| d4398e55fe | |||
| f5440c9e2d | |||
| 190f40908f | |||
| ddb90c58ef | |||
| 340c26b7a6 | |||
| ca07188eda | |||
| 15a50f855b | |||
| e37dc7074d | |||
| f86c2560cd | |||
| 90c5ad7724 | |||
| 45e2c4f37b | |||
| b7d0edb6da | |||
| 159bf278e6 | |||
| 542126df90 | |||
| e56600e408 | |||
| 04215e7a53 | |||
| 9ba4eb4825 | |||
| bc7eb7409c | |||
| 0a8a20f3f8 | |||
| e20f58b4ae | |||
| ab07e01adf | |||
| 0447fdacac | |||
| 12cf59f1e8 | |||
| 4f735e6d29 | |||
| 32aea44f3b | |||
| 4d51bcefb0 | |||
| fe5ac20a1f | |||
| 2a13ee9c89 | |||
| df35ff73b5 | |||
| 92e6636d05 | |||
| 12e2d24f37 | |||
| ae100c4a75 | |||
| 4a5905307c | |||
| 2a7d4c74d4 | |||
| 6bb4773e07 | |||
| 97e125234b | |||
| 39073b7673 | |||
| 85d47c1fc4 | |||
| 4ff8cec312 | |||
| 3572b867c0 | |||
| dfed964f76 | |||
| 4651ae185b | |||
| 1c65433d40 | |||
| 590c4592ea | |||
| 15cd157f45 | |||
| 970f10a274 | |||
| d78cdb509d | |||
| ea70d2efc6 | |||
| a2a28a9f56 | |||
| 898e30b526 | |||
| 87c54b80c0 | |||
| 7288f443f1 | |||
| 488b1e62ba | |||
| 50c84fce88 | |||
| ae09fba400 | |||
| f8a79a3b0b | |||
| ae9a27c300 | |||
| 1f7cd407a8 | |||
| 6f81212997 | |||
| 402ad8c949 | |||
| 52015fa6c6 | |||
| 97706ea197 | |||
| d53de4f33f | |||
| de78d60959 | |||
| 795ace75d6 | |||
| 582f68f68b | |||
| 556666c046 | |||
| 04dac0b673 | |||
| f38fbf0527 |
@@ -8,14 +8,28 @@ jobs:
|
||||
deploy:
|
||||
runs-on: aliyun
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: "http://8.161.227.145:3000/huanghaosheng/checkout@releases/v4"
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: apk add --no-cache docker-cli docker-cli-compose
|
||||
|
||||
- name: Build and Deploy
|
||||
- name: Deploy
|
||||
run: |
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh build
|
||||
./deploy.sh restart
|
||||
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
apk add --no-cache rsync docker-cli docker-cli-compose
|
||||
GIT_URL="http://8.161.227.145:3000/XEngineers/CamTalk.git"
|
||||
if [ -d /root/camtalk/.git ]; then
|
||||
cd /root/camtalk
|
||||
git fetch "$GIT_URL" ${GITHUB_REF_NAME} --depth=1
|
||||
git reset --hard FETCH_HEAD
|
||||
else
|
||||
rm -rf /tmp/camtalk-deploy
|
||||
git clone --depth=1 --branch ${GITHUB_REF_NAME} \
|
||||
http://8.161.227.145:3000/XEngineers/CamTalk.git /tmp/camtalk-deploy
|
||||
mkdir -p /root/camtalk
|
||||
rsync -a --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='pgdata' \
|
||||
--exclude='redisdata' \
|
||||
/tmp/camtalk-deploy/ /root/camtalk/
|
||||
rm -rf /tmp/camtalk-deploy
|
||||
fi
|
||||
|
||||
chmod +x /root/camtalk/deploy.sh
|
||||
/root/camtalk/deploy.sh build
|
||||
/root/camtalk/deploy.sh restart
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ Thumbs.db
|
||||
|
||||
# ---- Obsidian ----
|
||||
.obsidian/
|
||||
.claudian/
|
||||
修改过程笔记/
|
||||
学习复盘/
|
||||
docs/follow-up/
|
||||
|
||||
140
CLAUDE.md
140
CLAUDE.md
@@ -1,123 +1,73 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。
|
||||
CamTalk — 多模态实时 AI 视觉对话助手(摄像头 + 麦克风 + 视觉 + 语音 AI)
|
||||
|
||||
## 项目概述
|
||||
|
||||
CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
|
||||
|
||||
> **文档优先原则:** 执行任何开发任务前,先读取 `docs/` 下的相关设计文档(架构、接口、技术选型等),以文档为最高依据。代码实现应与文档一致;若有偏差,优先更新文档(尤其是接口文档)。
|
||||
> **文档优先原则:** 开发前先读 `docs/` 设计文档,以文档为准;若代码与文档不一致,优先更新文档(尤其接口文档)。详细设计见 `docs/01-13` 系列文档。**注意**:`docs/Eino/` 框架文档内容庞大(~75 个文件),仅在需要了解 Eino Graph/节点/Callback 等框架细节时才读取。
|
||||
|
||||
## 架构
|
||||
|
||||
三层系统:
|
||||
三层系统:前端(React + Vite)→ Go 网关(Gin + WebSocket + Eino Graph AI 编排)→ AI 服务(DashScope LLM, MiMo STT/TTS)
|
||||
|
||||
1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 Canvas 像素比较)、UI 渲染。核心 Hook:`useVisionSession()`
|
||||
2. **Go 网关**(Gin, gorilla/websocket, Viper, Zap)—— WebSocket 服务器、会话管理、AI 编排(基于 CloudWeGo Eino Graph)。每个 WebSocket 连接一个 goroutine。
|
||||
3. **云端 AI 服务** —— 通过 OpenAI 兼容接口可灵活切换。默认:DashScope qwen3-vl-plus(LLM)、MiMo ASR(STT)、MiMo TTS(TTS)。仅通过 Go 网关访问,浏览器不直连。
|
||||
**AI 编排流水线**(Eino Graph 7 节点 DAG):`STT → History → ChatModel → Msg2Str → Splitter → TTS → Done`。LLM token 通过 Callback 实时推送,TTS 逐句并行合成。
|
||||
|
||||
**关键模式**:AI 编排基于 Eino Graph 声明式 DAG(`START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END`),LLM token 通过 Callback 实时推送,TTS 逐句合成并行推送,最小化感知延迟。
|
||||
**会话存储**(TieredManager):L1 Memory → L2 Redis → L3 PostgreSQL 三级存储,30 分钟 TTL,Redis 故障自动降级。
|
||||
|
||||
**存储**:三级存储架构(TieredManager)—— L1 Memory → L2 Redis → L3 PostgreSQL,自动降级。Repository 接口模式(UserRepository、MessageRepository、SessionRepository),PostgreSQL + 内存双实现。
|
||||
**鉴权**:JWT 双 token 轮转(Access 120min + Refresh 7d),重放攻击检测(DB hash 校验),Redis 缓存装饰器。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
|
||||
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
|
||||
| AI 编排 | CloudWeGo Eino Graph(声明式 DAG 编排) |
|
||||
| LLM | DashScope qwen3-vl-plus(默认,通过 eino-ext OpenAI ChatModel 接入) |
|
||||
| STT | MiMo ASR(默认) / Deepgram |
|
||||
| TTS | MiMo TTS(默认) / OpenAI TTS |
|
||||
前端:React 18 + TypeScript + Vite,VAD(@ricky0123/vad-web),ONNX Runtime
|
||||
后端:Go 1.25+, Gin, WebSocket, Viper, Zap, CloudWeGo Eino Graph
|
||||
AI:DashScope qwen3-vl-plus, MiMo ASR/TTS(可切换 Deepgram/OpenAI TTS)
|
||||
存储:PostgreSQL 15 + Redis 7
|
||||
|
||||
## 构建与运行命令
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
cd frontend && npm install
|
||||
npm run dev # Vite 开发服务器
|
||||
npm run build # 生产构建
|
||||
npm run lint # ESLint 检查
|
||||
npm run test # Vitest 测试
|
||||
|
||||
# 后端
|
||||
cd backend && go mod download
|
||||
go run ./cmd/server # 启动网关,监听 :8080
|
||||
go build -o bin/camtalk ./cmd/server
|
||||
go test ./... # 运行所有测试
|
||||
go test -run TestName ./path # 运行单个测试
|
||||
go vet ./... # 静态分析
|
||||
# 前端:npm run dev(Vite,代理 /ws 和 /api 到 :8080)
|
||||
# 后端:go run ./cmd/server(监听 :8080)
|
||||
# 生产:./deploy.sh up(4 容器:frontend/backend/postgres/redis)
|
||||
```
|
||||
|
||||
基础设施:三级存储架构(L1 Memory → L2 Redis → L3 PostgreSQL),通过配置控制启用层级。
|
||||
核心环境变量(`.env.example`):`CAMTALK_AI_LLM_API_KEY`, `CAMTALK_AI_STT_API_KEY`, `CAMTALK_AUTH_JWT_SECRET`, `CAMTALK_STORAGE_DSN`
|
||||
|
||||
## WebSocket 协议
|
||||
配置优先级:环境变量 > `config.{APP_ENV}.yaml` > `config.yaml`
|
||||
环境切换:`APP_ENV=dev|prod`(dev 默认,prod 启用限流 + 严格 CORS)
|
||||
|
||||
端点:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
|
||||
## 协议与 API
|
||||
|
||||
所有消息为 JSON 文本帧,统一信封格式 `{type, request_id?, timestamp?}`。完整契约见 `docs/02-接口文档.md`。
|
||||
**WebSocket**:`ws://localhost:8080/ws?token=<jwt>&conversation_id=<uuid>`
|
||||
- 客户端:`query`(图像/音频 Base64), `config`, `interrupt`, `ping`
|
||||
- 服务端:`connected`, `stt_result`, `llm_chunk`, `llm_done`, `tts_audio`, `error`, `pong`
|
||||
- 心跳:客户端 30s ping,服务端 60s 超时断连;重连:指数退避 1s→30s
|
||||
- 实现:`CamTalkWebSocket` 单例(`frontend/src/lib/websocket.ts`),订阅模式,自动重连
|
||||
|
||||
**客户端 → 服务端**:`query`(图像 Base64 + 音频 Base64)、`config`、`interrupt`、`ping`
|
||||
**服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong`
|
||||
**REST API**:`/api/auth/*`(注册/登录/刷新/登出),`/api/conversations/*`(CRUD + 消息分页),`/api/health`
|
||||
|
||||
**心跳**:客户端每 30 秒 ping,服务端 60 秒无 ping 断开连接。
|
||||
**重连**:指数退避 + 抖动 —— 1s, 2s, 4s, 8s… 最大 30s。
|
||||
**错误码**:`INVALID_MESSAGE`, `SESSION_NOT_FOUND`, `RATE_LIMITED`, `IMAGE_TOO_LARGE`, `LLM_TIMEOUT`, `STT/TTS/LLM_ERROR`, `INVALID_TOKEN`, 等
|
||||
|
||||
## REST API(辅助)
|
||||
## 关键文件路径
|
||||
|
||||
- `GET /api/health` — 健康检查(版本、运行时间、活跃会话数)
|
||||
- `POST /api/auth/register` — 注册
|
||||
- `POST /api/auth/login` — 登录
|
||||
- `POST /api/auth/refresh` — 刷新 Token
|
||||
- `POST /api/auth/logout` — 登出
|
||||
- `GET /api/conversations` — 对话列表
|
||||
- `POST /api/conversations` — 创建对话
|
||||
- `GET/PUT/PATCH/DELETE /api/conversations/:id` — 对话 CRUD
|
||||
- `GET /api/conversations/:id/messages` — 获取对话消息
|
||||
**后端核心**:
|
||||
- `backend/internal/eino/` — Graph 定义、节点、Callback、Adapter、State
|
||||
- `backend/internal/session/tiered.go` — 三级会话存储
|
||||
- `backend/internal/store/` — Repository 实现(PG + 内存 + Redis 缓存)
|
||||
- `backend/internal/ws/handler.go` — WebSocket 连接管理
|
||||
- `backend/migrations/` — SQL 迁移文件
|
||||
|
||||
## 错误码
|
||||
|
||||
`INVALID_MESSAGE`、`SESSION_NOT_FOUND`、`RATE_LIMITED`、`IMAGE_TOO_LARGE`、`AUDIO_TOO_SHORT`、`LLM_TIMEOUT`、`LLM_ERROR`、`STT_ERROR`、`TTS_ERROR`、`INTERNAL_ERROR`、`USERNAME_TAKEN`、`INVALID_CREDENTIALS`、`INVALID_TOKEN`、`INVALID_INPUT`
|
||||
|
||||
## 前端组件结构
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `AuthPage` | 登录/注册表单 |
|
||||
| `CameraManager` | 摄像头流采集 |
|
||||
| `MicManager` | 麦克风音频采集 |
|
||||
| `EdgeProcessor` | VAD + 关键帧检测(Canvas 像素比较) |
|
||||
| `WebSocketManager` | WebSocket 连接生命周期管理 |
|
||||
| `ChatPanel` | 消息展示、流式回复、文本输入、场景选择 |
|
||||
| `VideoPreview` | 摄像头画面预览 |
|
||||
| `SessionSidebar` | 左侧抽屉式对话列表 |
|
||||
| `ConfigPanel` | 右侧抽屉式配置面板 |
|
||||
| `Toast` | 轻量通知提示 |
|
||||
|
||||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话。
|
||||
|
||||
## 后端模块结构
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| WebSocket Handler | 连接管理、JWT 认证、单播消息推送 |
|
||||
| Session Manager | 会话状态、对话历史(三级存储:Memory/Redis/PostgreSQL,30 分钟 TTL) |
|
||||
| Eino 编排层 | 基于 Eino Graph 的声明式 AI 编排(7 节点 DAG,Stream 模式,Callback AOP) |
|
||||
| AI Orchestrator | `EinoOrchestrator` 适配器,包装 Graph 实现 `Orchestrator` 接口 |
|
||||
| AI Service Layer | AI 服务抽象层(STT/TTS 多 provider,LLM 通过 eino-ext ChatModel) |
|
||||
| Auth | JWT 双 token 轮转认证,bcrypt 密码哈希 |
|
||||
| Store | 持久化存储层(UserRepository/MessageRepository/SessionRepository,内存 + PostgreSQL) |
|
||||
| REST API | 健康检查、认证、对话管理(Gin 路由) |
|
||||
| Models | 数据模型定义 |
|
||||
| Migrations | 数据库版本化迁移(嵌入式 SQL) |
|
||||
| Model Router | 按请求选择 AI 模型(规划中) |
|
||||
| Rate Limiter | 按用户的令牌桶速率限制(规划中) |
|
||||
**前端核心**:
|
||||
- `frontend/src/hooks/useVisionSession.ts` — 核心会话 Hook(~500 行)
|
||||
- `frontend/src/lib/websocket.ts` — WebSocket 客户端单例
|
||||
- `frontend/src/lib/auth.tsx` — JWT 自动刷新 + AuthProvider
|
||||
- `frontend/src/lib/api.ts` — REST 客户端(401 拦截 + token 刷新)
|
||||
- `frontend/src/lib/ttsPlayer.ts` — 流式 TTS 音频播放队列
|
||||
- `frontend/vite.config.ts` — VAD 模型文件自动复制 + 代理配置
|
||||
|
||||
## 编码规范
|
||||
|
||||
- **Go**:遵循标准 Go 规范。所有 AI 调用使用 `context.Context` 做取消/超时。并发 map 访问使用 `sync.RWMutex`。结构体标签用 `json:"snake_case"`。
|
||||
- **TypeScript**:严格模式。所有数据模型用接口定义。WebSocket 消息类型用可辨识联合类型(`type` 字段)。
|
||||
- **提交信息**:Conventional Commits 格式,描述用中文。示例:`feat: 添加 WebSocket 连接管理`、`fix: 修复心跳超时判断`、`docs: 更新接口文档`
|
||||
- **禁止自动 push**:除非用户明确要求。
|
||||
- **文档优先**:实现功能前先读取 `docs/` 下的相关设计文档。实现与文档不一致时,优先更新 `docs/` 下的接口文档。
|
||||
- **Go**:标准规范,`context.Context` 超时控制,`sync.RWMutex` 并发保护,`json:"snake_case"` 标签,编译期接口检查 `var _ Interface = (*Impl)(nil)`
|
||||
- **TypeScript**:严格模式,接口定义数据模型,WebSocket 消息用可辨识联合类型(`type` 字段区分)
|
||||
- **CORS**:禁止后端代码/配置文件配置 CORS,统一由代理层处理(开发环境 Vite proxy,生产环境 Nginx)
|
||||
- **提交信息**:Conventional Commits,中文描述(如 `feat: 添加 WebSocket 心跳`)
|
||||
- **禁止自动 push**:除非用户明确要求
|
||||
- **文档优先**:开发前先读 `docs/` 设计文档,代码与文档不一致时优先更新文档
|
||||
|
||||
116
CamTalk-演讲稿.md
116
CamTalk-演讲稿.md
@@ -1,116 +0,0 @@
|
||||
## CamTalk — 多模态实时 AI 视觉对话助手 · 演讲稿
|
||||
|
||||
> 面向面试官,预计 10-15 分钟。建议配合架构图或项目文档做演示。
|
||||
|
||||
---
|
||||
|
||||
### 开场(约 1 分钟)
|
||||
|
||||
各位好,今天我想和大家分享一个我主导设计和开发的项目——**CamTalk**,一个多模态实时 AI 视觉对话助手。
|
||||
|
||||
简单来说,用户打开浏览器,对着摄像头,用语音提问,AI 就能同时"看到"画面、"听到"语音,然后用文字和语音自然地回应。整个过程不需要打字,就像一个面对面的助手。
|
||||
|
||||
做这个项目的初衷其实很直接——现在的大模型已经具备多模态能力,但大多数产品还是"上传一张图、输入一段文字"的交互方式。我认为真正的多模态交互应该是**无感的**——用户只需要说话,AI 自己去理解视觉场景,就像两个人面对面聊天一样。
|
||||
|
||||
---
|
||||
|
||||
### 系统架构(约 3 分钟)
|
||||
|
||||
CamTalk 采用三层架构:**前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用**。
|
||||
|
||||
**前端**是 React 18 加 TypeScript,跑在浏览器里。它负责三件事:摄像头和麦克风的采集,边缘侧的预处理——比如语音活动检测和关键帧过滤,以及 UI 渲染。通过 WebSocket 与后端通信。
|
||||
|
||||
**后端**是 Go 写的网关服务,用 Gin 框架做 HTTP 路由,gorilla/websocket 处理长连接。它是整个系统的"大脑",负责会话管理、AI 编排,以及和各家 AI 服务的对接。每个 WebSocket 连接对应一个 goroutine,天然适合这种长连接场景。
|
||||
|
||||
**AI 服务层**是可插拔的。LLM 默认用 GPT-4o,通过 OpenAI 兼容接口可以随时切换成通义千问等国产模型。语音识别默认 Deepgram,语音合成默认 OpenAI TTS,同时也支持小米的 MiMo 系列作为备选。
|
||||
|
||||
有人可能会问:为什么不直接让前端调用 AI API?这里有三点考虑。第一是**安全性**,API Key 不应该暴露在客户端。第二是**统一管控**,速率限制、成本监控、模型路由这些逻辑集中在网关层更好维护。第三是**可扩展性**,未来加缓存、做负载均衡、多实例部署,都在网关层解决。
|
||||
|
||||
部署方面,我们设计了 Nginx 做同源反向代理,前端静态资源和后端 API 在同一个域名下,天然解决跨域问题。Go 网关可以水平扩展,通过 Redis 共享会话状态。目前也已经配置好了 Docker Compose 一键部署方案,包含前端、后端和 PostgreSQL 三个容器。
|
||||
|
||||
---
|
||||
|
||||
### 核心交互流程(约 3 分钟)
|
||||
|
||||
我想重点讲一下一次完整的交互流程,因为它串起了整个系统最核心的技术挑战。
|
||||
|
||||
用户对着摄像头说了一句话,比如"这是什么花"。首先是**前端**的 VAD——语音活动检测模块——在浏览器端实时检测用户何时开始说话、何时说完。这一步完全在端侧完成,用的是 @ricky0123/vad-web,基于 WebRTC VAD 算法。好处是:用户不说话时不需要上传任何音频,节省约 70% 的无效带宽。
|
||||
|
||||
VAD 检测到语音结束后,前端会同时做两件事:把音频编码成 PCM 格式,以及从摄像头捕获当前画面,一起通过 WebSocket 发给后端。
|
||||
|
||||
后端收到后,启动一个 **AI 编排管道**(我们叫它 Orchestrator)。第一步,把音频发给 STT 服务做语音识别,拿到文字结果。第二步,把识别出的文字、摄像头画面以及对话历史,一起打包发给多模态 LLM 做推理。LLM 以流式方式逐 token 输出。第三步,也是最关键的优化——我们不等待 LLM 输出完再调用 TTS,而是做**句子级切分**:LLM 每输出一个完整句子,就立即送入 TTS 合成并推送给客户端。
|
||||
|
||||
所以客户端的体验是这样的:文字一个 token 一个 token 地出现,几乎同时语音就开始播放了。用户**先看到文字、紧接着听到语音**,感知延迟可以控制在 0.5 秒以内。整个端到端的目标延迟是 1.5 到 2 秒。
|
||||
|
||||
这个"LLM 文本流和 TTS 音频流并行推送"的设计,是我们降低感知延迟最关键的手段。
|
||||
|
||||
---
|
||||
|
||||
### 成本控制(约 2 分钟)
|
||||
|
||||
做实时多模态应用,成本是最容易失控的地方。我在设计之初就把成本控制作为架构级别的考量。
|
||||
|
||||
最直观的例子是视觉链路:如果按 1fps 全量发送画面给 LLM,一个用户每天用 10 分钟,一天就是 60 万帧的 token 消耗,1000 个用户时成本完全不可控。
|
||||
|
||||
我们的核心策略叫**端云协同**——把适合的计算前置到客户端。
|
||||
|
||||
在视觉侧,我们做了三个优化:一是降低采样频率,空闲时 5 秒一帧,用户说话时 1 秒一帧;二是关键帧过滤,通过 Canvas 像素比较计算帧间相似度,画面没有显著变化就不发送;三是只在用户提问时捕获画面,而不是持续上传视频流。
|
||||
|
||||
在语音侧,VAD 在浏览器端检测,只上传有效语音片段,环境噪音和静默时段完全不消耗带宽。
|
||||
|
||||
在推理侧,我们规划了模型分级策略——简单识别类问题走 GPT-4o-mini,深度分析走 GPT-4o,复杂推理走 o1。同时对话历史做了裁剪,前端保留最近 10 轮,后端保留 20 轮,限制每轮的固定 token 开销。
|
||||
|
||||
这些策略综合下来,预估月成本可以从无优化的约 5000 美元降到 300 到 500 美元,降幅大约 90%。
|
||||
|
||||
---
|
||||
|
||||
### 工程设计与取舍(约 2 分钟)
|
||||
|
||||
除了技术实现,我想分享几个设计上的取舍。
|
||||
|
||||
**存储方案的分阶段设计**。MVP 阶段我们用进程内存存会话状态,快速验证核心功能。但代码层面我们已经通过 Repository 接口模式做了抽象——HistoryRepository、UsageRepository 这些接口定义好了,底层实现可以是 Memory、Redis 或 PostgreSQL,通过配置切换。目前 Redis 实现已经就绪,PostgreSQL 的 schema 也设计好了,包括 sessions、messages、usage_daily 三张表。这种渐进式设计让我们既能快速交付,又为后续扩展留好了空间。
|
||||
|
||||
**文档驱动开发**。项目里有一套完整的设计文档,涵盖架构、接口协议、技术选型、成本控制等。我们遵循"文档优先"原则——实现功能前先写设计文档,实现和文档不一致时优先更新文档。这在团队协作中特别重要,接口契约清晰,前后端可以并行开发。
|
||||
|
||||
**WebSocket 协议的可靠性设计**。客户端每 30 秒发心跳,服务端 60 秒没收到心跳就断开。断线后用指数退避加抖动重连——1 秒、2 秒、4 秒、8 秒,最大 30 秒。消息用统一信封格式,所有消息都带 type 字段做类型分发。
|
||||
|
||||
---
|
||||
|
||||
### 用户故事与产品规划(约 2 分钟)
|
||||
|
||||
最后讲一下产品层面的思考。用户故事我按 P0 到 P2 分了三个优先级。
|
||||
|
||||
P0 是 MVP 必做的四个场景:AI 识别画面中的物体、语音对话无需打字、AI 能看到摄像头画面、AI 用语音回答。这四个跑通了,核心价值就成立了。
|
||||
|
||||
P1 是体验增强:AI 主动观察画面变化并提示重要事件、识别画面中的文字做 OCR、以及多轮对话的上下文记忆。
|
||||
|
||||
P2 是进阶探索:比如视障用户的无障碍辅助——AI 实时描述周围环境并提示障碍物,画面中外语内容的实时翻译,以及"观察模式"和"对话模式"的切换。
|
||||
|
||||
优先级判断用两个维度交叉评估:用户价值和实现成本。P0 是高价值且成本合理的,P1 是高价值但成本较高的,P2 是探索性的,验证后再投入。
|
||||
|
||||
目前还有几个功能创意在规划中,包括视频录制、对话翻译、对话总结、手动对话输入,以及对话情景选择——比如面试官模式、英语老师模式、辩论赛模式等。
|
||||
|
||||
---
|
||||
|
||||
### 总结(约 1 分钟)
|
||||
|
||||
总结一下,CamTalk 这个项目有几个我比较满意的设计点。
|
||||
|
||||
第一是**架构清晰**:三层分离,每层职责明确,前端做轻量预处理,后端做智能编排,AI 服务可插拔。
|
||||
|
||||
第二是**体验导向**:从用户感知延迟倒推技术方案,流式并行推送、句子级切分、端侧 VAD 这些手段都是围绕"让对话像真人一样自然"这个目标设计的。
|
||||
|
||||
第三是**成本意识**:从架构层面就融入了成本控制,端云协同、智能采样、模型分级,不是等功能做完再去优化成本。
|
||||
|
||||
第四是**工程成熟度**:接口抽象、文档驱动、渐进式存储升级,为项目的长期演进留好了空间。
|
||||
|
||||
以上就是 CamTalk 项目的整体介绍。谢谢大家,有什么问题我们可以一起讨论。
|
||||
|
||||
---
|
||||
|
||||
> **附:讲解提示**
|
||||
>
|
||||
> - 如果面试官追问技术深度,可以展开讲 Orchestrator 的管道实现细节(goroutine 并发、context 取消、句子切分算法)或 VAD 参数调优。
|
||||
> - 如果追问产品思维,可以展开讲用户故事的优先级判断逻辑,以及观察模式和对话模式的差异设计。
|
||||
> - 如果追问可扩展性,可以讲 Redis 共享会话、多 Gateway 水平扩展、模型路由器的规划。
|
||||
> - 如果追问成本数据,可以给出具体的 token 消耗计算过程和各种优化手段的量化效果。
|
||||
598
README.md
598
README.md
@@ -1,167 +1,561 @@
|
||||
# CamTalk
|
||||
|
||||
多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
|
||||
<div align="center">
|
||||
|
||||
- **路演视频**:[哔哩哔哩弹幕网——七牛云第四批议题1](https://www.bilibili.com/video/BV1dDJK6cE5S/)
|
||||
- **线上体验**:http://8.161.227.145:9000
|
||||
**多模态实时 AI 视觉对话助手**
|
||||
|
||||
> ⚠️ **注意**:由于线上地址使用 HTTP 协议,浏览器默认禁止在非 HTTPS 环境下调用摄像头和麦克风。需要按以下步骤配置 Chrome 浏览器:
|
||||
用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
|
||||
[路演视频](https://www.bilibili.com/video/BV1dDJK6cE5S/) • [在线体验](http://8.161.227.145:9000) • [文档](docs/README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
|
||||
> ⚠️ **在线体验提示**:由于演示环境使用 HTTP 协议,需配置 Chrome 允许非 HTTPS 下访问摄像头/麦克风:
|
||||
>
|
||||
> 1. 在浏览器地址栏中输入 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`,回车
|
||||
> 2. 将 **Insecure origins treated as secure** 选项设置为 **Enabled**(已启用)
|
||||
> 3. 在输入框中输入 `http://8.161.227.145:9000` 地址
|
||||
> 4. 点击右下角弹出的 **Relaunch** 按钮,自动重启浏览器
|
||||
>
|
||||
> 重启后即可在该 HTTP 地址下正常调用摄像头和麦克风。
|
||||
> 1. 访问 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
|
||||
> 2. 启用该选项,并在输入框填入 `http://8.161.227.145:9000`
|
||||
> 3. 点击 **Relaunch** 重启浏览器
|
||||
|
||||

|
||||

|
||||
|
||||
## 架构
|
||||
## ✨ 核心特性
|
||||
|
||||
三层系统,前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用:
|
||||
- 🎥 **多模态理解**:摄像头视觉 + 麦克风语音双输入,AI 理解完整场景
|
||||
- 🗣️ **自然对话**:基于 VAD 的端到端语音交互,低延迟流式响应
|
||||
- 🚀 **实时推送**:LLM 文本流 + TTS 音频流并行推送,感知延迟 < 0.5 秒
|
||||
- 🎭 **情景模式**:自由对话、面试官、英语老师等多场景支持
|
||||
- 💾 **对话历史**:自动保存会话,支持搜索、重命名、删除、时间分组
|
||||
- 🔐 **安全认证**:JWT 双 token 轮转 + Refresh Token Rotation 防重放
|
||||
- 📊 **三级存储**:Memory → Redis → PostgreSQL 自动降级,保障可靠性
|
||||
- 🌐 **国际化**:支持中文、英文、日文界面
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
CamTalk 采用**三层架构**:前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph client[浏览器客户端]
|
||||
A1[媒体采集]
|
||||
A2[VAD 语音检测]
|
||||
A3[关键帧检测]
|
||||
A4[UI 渲染]
|
||||
subgraph Browser["🌐 浏览器客户端"]
|
||||
UI["React UI 渲染"]
|
||||
VAD["VAD 语音检测"]
|
||||
Media["媒体采集"]
|
||||
end
|
||||
|
||||
subgraph gateway[Go 网关 :8080]
|
||||
B1[WebSocket Handler]
|
||||
B2[Session Manager]
|
||||
B3[AI Orchestrator]
|
||||
B4[REST API]
|
||||
subgraph Gateway["⚙️ Go 网关 (Eino Graph)"]
|
||||
WS["WebSocket Handler"]
|
||||
Auth["JWT 认证"]
|
||||
Session["会话管理 (三级存储)"]
|
||||
Orch["AI 编排器 (7节点DAG)"]
|
||||
end
|
||||
|
||||
subgraph cloud[云端 AI 服务]
|
||||
C1[STT 语音识别]
|
||||
C2[LLM 多模态推理]
|
||||
C3[TTS 语音合成]
|
||||
subgraph AI["☁️ 云端 AI 服务"]
|
||||
STT["STT (MiMo/Deepgram)"]
|
||||
LLM["LLM (qwen3-vl-plus)"]
|
||||
TTS["TTS (MiMo/OpenAI)"]
|
||||
end
|
||||
|
||||
client <-->|WebSocket| gateway
|
||||
gateway <-->|HTTP| cloud
|
||||
Browser <-->|"WebSocket<br/>(JWT + query/config)"| Gateway
|
||||
Orch --> STT
|
||||
Orch --> LLM
|
||||
Orch --> TTS
|
||||
```
|
||||
|
||||
**关键模式**:LLM 文本流和 TTS 音频流并行推送,用户先看到文字、紧接着听到语音,感知延迟 < 0.5 秒。
|
||||
### AI 编排流水线(Eino Graph)
|
||||
|
||||
## 技术栈
|
||||
基于 [CloudWeGo Eino](https://github.com/cloudwego/eino) 框架的声明式 7 节点 DAG:
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端 | 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 |
|
||||
```
|
||||
START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
**核心优势**:
|
||||
- **流式处理**:ChatModel 逐 token 推送,Callback AOP 机制实时转发客户端
|
||||
- **句子级 TTS**:Splitter 实时切分句子,TTS 逐句并行合成,无需等待完整回复
|
||||
- **类型安全**:Go 泛型 + 编译期检查,Graph 拓扑错误在编译时发现
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><b>层级</b></td>
|
||||
<td><b>技术选型</b></td>
|
||||
<td><b>说明</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>前端</b></td>
|
||||
<td>React 18 + TypeScript + Vite</td>
|
||||
<td>组件化开发,类型安全,快速热更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>VAD</b></td>
|
||||
<td>@ricky0123/vad-web (ONNX Runtime)</td>
|
||||
<td>浏览器端语音活动检测,零延迟</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>后端</b></td>
|
||||
<td>Go 1.25+ + Gin + gorilla/websocket</td>
|
||||
<td>高并发 goroutine,长连接管理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>AI 编排</b></td>
|
||||
<td>CloudWeGo Eino Graph</td>
|
||||
<td>声明式 DAG,Stream 模式,Callback AOP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>STT</b></td>
|
||||
<td>MiMo ASR(默认)/ Deepgram</td>
|
||||
<td>实时语音识别,多语言支持</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>LLM</b></td>
|
||||
<td>DashScope qwen3-vl-plus</td>
|
||||
<td>多模态推理(通过 eino-ext OpenAI 接入)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>TTS</b></td>
|
||||
<td>MiMo TTS(默认)/ OpenAI TTS</td>
|
||||
<td>自然语音合成</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>存储</b></td>
|
||||
<td>PostgreSQL 15 + Redis 7</td>
|
||||
<td>三级存储架构:Memory → Redis → PG</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>认证</b></td>
|
||||
<td>JWT (HS256) + bcrypt</td>
|
||||
<td>双 token 轮转 + Refresh Token Rotation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>配置</b></td>
|
||||
<td>Viper + godotenv</td>
|
||||
<td>YAML + .env + 环境变量覆盖</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>日志</b></td>
|
||||
<td>Zap</td>
|
||||
<td>高性能结构化日志 + Trace ID 追踪</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
CamTalk/
|
||||
├── frontend/ # 浏览器客户端
|
||||
├── frontend/ # 🌐 浏览器客户端
|
||||
│ └── src/
|
||||
│ ├── components/ # UI 组件
|
||||
│ │ ├── LandingPage/ # 登录着陆页 + LoginModal
|
||||
│ │ ├── CameraManager/ # 摄像头流采集
|
||||
│ │ ├── MicManager/ # 麦克风音频采集
|
||||
│ │ ├── EdgeProcessor/ # VAD + 关键帧检测
|
||||
│ │ ├── WebSocketManager/ # WS 连接管理
|
||||
│ │ ├── ChatPanel/ # 消息展示
|
||||
│ │ ├── VideoPreview/ # 摄像头画面预览
|
||||
│ │ ├── ConfigPanel/ # 配置面板
|
||||
│ │ └── Toast/ # 通知提示
|
||||
│ │ ├── MicManager/ # 麦克风音频采集 + VAD
|
||||
│ │ ├── WebSocketManager/ # WS 连接生命周期
|
||||
│ │ ├── ChatPanel/ # 消息展示 + 流式回复
|
||||
│ │ ├── SessionSidebar/ # 对话历史侧边栏
|
||||
│ │ └── ConfigPanel/ # 配置面板(主题/TTS/语言/场景)
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useVisionSession.ts # 核心会话 Hook
|
||||
│ │ ├── useVisionSession.ts # 核心会话 Hook (~500 行)
|
||||
│ │ ├── useSessionList.ts # 对话列表管理
|
||||
│ │ └── useObservationMode.ts # 观察模式
|
||||
│ ├── lib/ # 工具库
|
||||
│ │ ├── websocket.ts # WebSocket 连接管理
|
||||
│ │ ├── audio.ts # 音频编码
|
||||
│ │ ├── ttsPlayer.ts # TTS 播放器
|
||||
│ │ └── sampling.ts # 采样策略
|
||||
│ │ ├── websocket.ts # WebSocket 单例(心跳/重连/订阅)
|
||||
│ │ ├── api.ts # REST 客户端(401拦截+刷新)
|
||||
│ │ ├── auth.tsx # AuthProvider(JWT 自动刷新)
|
||||
│ │ ├── ttsPlayer.ts # TTS 流式播放队列
|
||||
│ │ └── i18n/ # 国际化(zh-CN/en-US/ja-JP)
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── backend/ # Go 网关
|
||||
│ ├── cmd/server/ # 入口
|
||||
├── backend/ # ⚙️ Go 网关
|
||||
│ ├── cmd/server/ # 服务入口(main.go)
|
||||
│ └── internal/
|
||||
│ ├── eino/ # 🔥 Eino Graph 编排层(7节点DAG)
|
||||
│ │ ├── graph.go # Graph 构建与编译
|
||||
│ │ ├── adapter.go # EinoOrchestrator 适配器
|
||||
│ │ ├── callback.go # LLM token 推送回调
|
||||
│ │ ├── state.go # 跨节点状态管理
|
||||
│ │ └── nodes_*.go # STT/History/Splitter/TTS/Done 节点
|
||||
│ ├── session/ # 会话管理(TieredManager 三级存储)
|
||||
│ ├── store/ # 持久化层(Repository 接口 + PG/内存实现)
|
||||
│ │ ├── user_pg.go # PostgreSQL 实现
|
||||
│ │ └── cached_user.go # Redis 缓存装饰器
|
||||
│ ├── auth/ # 认证(JWT/bcrypt/中间件)
|
||||
│ ├── ai/ # AI 服务抽象层
|
||||
│ │ ├── llm/ # LLM 服务(OpenAI 兼容)
|
||||
│ │ ├── stt/ # STT 服务(Deepgram/MiMo)
|
||||
│ │ └── tts/ # TTS 服务(OpenAI/MiMo)
|
||||
│ ├── orchestrator/ # AI 编排器(STT→LLM→TTS 管道)
|
||||
│ ├── session/ # 会话管理(Memory/Redis)
|
||||
│ │ ├── llm/ # LLM 提示词与场景
|
||||
│ │ ├── stt/ # STT 服务(MiMo/Deepgram)
|
||||
│ │ └── tts/ # TTS 服务(MiMo/OpenAI)
|
||||
│ ├── ws/ # WebSocket Handler
|
||||
│ ├── api/ # REST API
|
||||
│ ├── config/ # 配置管理
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── errors/ # 错误码
|
||||
│ └── logger/ # 日志
|
||||
├── docs/ # 设计文档
|
||||
└── CLAUDE.md # Claude Code 指引
|
||||
│ ├── api/ # REST API(Auth/Conversation)
|
||||
│ ├── config/ # 配置管理(Viper)
|
||||
│ └── logger/ # 日志(Zap + Trace ID)
|
||||
├── migrations/ # 📊 数据库迁移(嵌入式 SQL)
|
||||
├── docs/ # 📚 设计文档
|
||||
│ ├── 01-架构设计.md
|
||||
│ ├── 02-接口文档.md
|
||||
│ ├── 08-Eino框架与编排设计.md
|
||||
│ ├── 10-鉴权体系.md
|
||||
│ └── 13-日志追踪.md
|
||||
├── deploy.sh # 🐳 部署脚本(Docker Compose)
|
||||
├── docker-compose.yml # 容器编排配置
|
||||
└── CLAUDE.md # 🤖 Claude Code 开发指引
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Node.js >= 18
|
||||
- Go >= 1.24
|
||||
- **Node.js** >= 18
|
||||
- **Go** >= 1.25
|
||||
- **PostgreSQL** >= 15(可选 Docker)
|
||||
- **Redis** >= 7(可选,用于缓存加速)
|
||||
|
||||
### 前端
|
||||
### 本地开发
|
||||
|
||||
#### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite 开发服务器 http://localhost:5173
|
||||
git clone https://github.com/yourusername/CamTalk.git
|
||||
cd CamTalk
|
||||
```
|
||||
|
||||
### 后端
|
||||
#### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# 编辑 .env 文件,填入以下必需配置:
|
||||
# - CAMTALK_AUTH_JWT_SECRET(使用 openssl rand -hex 32 生成)
|
||||
# - CAMTALK_STORAGE_DSN(PostgreSQL 连接字符串)
|
||||
# - CAMTALK_AI_LLM_API_KEY(DashScope API Key)
|
||||
# - CAMTALK_AI_STT_API_KEY(MiMo/Deepgram API Key)
|
||||
# - CAMTALK_AI_TTS_API_KEY(MiMo/OpenAI API Key)
|
||||
```
|
||||
|
||||
#### 3. 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 安装依赖
|
||||
go mod download
|
||||
go run ./cmd/server # 启动网关 :8080
|
||||
```
|
||||
|
||||
### 配置
|
||||
# 运行数据库迁移(自动创建表)
|
||||
go run ./cmd/server migrate
|
||||
|
||||
后端配置文件位于 `backend/config.yaml`,支持环境变量覆盖(前缀 `CAMTALK_`)。
|
||||
|
||||
```bash
|
||||
# 最小启动(需要至少一个 AI 服务的 API Key)
|
||||
cd backend
|
||||
CAMTALK_AI_LLM_API_KEY=sk-xxx \
|
||||
CAMTALK_AI_STT_API_KEY=xxx \
|
||||
# 启动服务(监听 :8080)
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
配置优先级:环境变量 > `config.{env}.yaml` > `config.yaml` > `.env`
|
||||
#### 4. 启动前端
|
||||
|
||||
## WebSocket 协议
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
连接地址:`ws://localhost:8080/ws`
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
所有消息为 JSON 文本帧,统一信封格式 `{type, request_id?, timestamp?}`。
|
||||
# 启动开发服务器(http://localhost:5173)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**客户端 → 服务端**:`query`、`config`、`interrupt`、`ping`
|
||||
**服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong`
|
||||
#### 5. 访问应用
|
||||
|
||||
完整协议见 [docs/03-接口文档.md](docs/03-接口文档.md)。
|
||||
打开浏览器访问 [http://localhost:5173](http://localhost:5173),注册账号后即可开始使用。
|
||||
|
||||
## 文档
|
||||
#### 6. 代码检查与测试
|
||||
|
||||
```bash
|
||||
# 安装 Go 代码检查工具
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
# 运行后端代码检查
|
||||
cd backend
|
||||
golangci-lint run
|
||||
|
||||
# 后端单元测试
|
||||
go test ./...
|
||||
|
||||
# 后端集成测试(需要 PostgreSQL)
|
||||
go test -tags=integration ./...
|
||||
|
||||
# 前端代码检查
|
||||
cd frontend
|
||||
npm run lint
|
||||
|
||||
# 前端测试
|
||||
npm test
|
||||
```
|
||||
|
||||
### 远程部署
|
||||
|
||||
#### 方式一:Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码到服务器
|
||||
git clone https://github.com/yourusername/CamTalk.git
|
||||
cd CamTalk
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp backend/.env.example backend/.env
|
||||
# 编辑 .env 文件,填入生产环境配置
|
||||
|
||||
# 3. 一键部署(frontend + backend + postgres + redis)
|
||||
./deploy.sh up
|
||||
|
||||
# 4. 查看日志
|
||||
./deploy.sh logs
|
||||
|
||||
# 5. 停止服务
|
||||
./deploy.sh down
|
||||
```
|
||||
|
||||
部署完成后访问 [http://localhost:9000](http://localhost:9000)
|
||||
|
||||
#### 方式二:手动部署
|
||||
|
||||
```bash
|
||||
# 1. 构建前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build # 输出到 dist/
|
||||
|
||||
# 2. 构建后端
|
||||
cd backend
|
||||
go build -o camtalk ./cmd/server
|
||||
|
||||
# 3. 配置 Nginx
|
||||
# 参考 nginx.conf.example 配置反向代理
|
||||
|
||||
# 4. 启动服务
|
||||
APP_ENV=prod ./camtalk
|
||||
|
||||
# 5. 使用 systemd 管理(可选)
|
||||
sudo systemctl enable camtalk
|
||||
sudo systemctl start camtalk
|
||||
```
|
||||
|
||||
#### 环境变量检查清单
|
||||
|
||||
部署前确保已配置以下环境变量:
|
||||
|
||||
- ✅ `CAMTALK_AUTH_JWT_SECRET`(使用 `openssl rand -hex 32` 生成)
|
||||
- ✅ `CAMTALK_STORAGE_DSN`(PostgreSQL 连接字符串)
|
||||
- ✅ `CAMTALK_AI_LLM_API_KEY`(DashScope API Key)
|
||||
- ✅ `CAMTALK_AI_STT_API_KEY`(STT 服务 API Key)
|
||||
- ✅ `CAMTALK_AI_TTS_API_KEY`(TTS 服务 API Key)
|
||||
- ✅ `APP_ENV=prod`(启用生产环境配置)
|
||||
|
||||
### 配置优先级
|
||||
|
||||
```
|
||||
环境变量 > config.{APP_ENV}.yaml > config.yaml > .env
|
||||
```
|
||||
|
||||
通过 `APP_ENV=prod` 切换生产环境配置(启用限流 + 严格 CORS)
|
||||
|
||||
## 📡 WebSocket 协议
|
||||
|
||||
连接地址:`ws://localhost:8080/ws?token=<jwt>&conversation_id=<uuid>`
|
||||
|
||||
所有消息为 JSON 文本帧,统一信封格式:
|
||||
|
||||
```typescript
|
||||
interface BaseMessage {
|
||||
type: string;
|
||||
request_id?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端 → 服务端
|
||||
|
||||
| 消息类型 | 说明 | 示例 |
|
||||
|---------|------|------|
|
||||
| `query` | 发送视觉+语音查询 | `{type: "query", image: "base64...", audio: "base64..."}` |
|
||||
| `config` | 更新会话配置 | `{type: "config", scenario: "interviewer", language: "en"}` |
|
||||
| `interrupt` | 中断当前响应 | `{type: "interrupt", request_id: "xxx"}` |
|
||||
| `ping` | 心跳保活 | `{type: "ping"}` |
|
||||
|
||||
### 服务端 → 客户端
|
||||
|
||||
| 消息类型 | 说明 | 触发时机 |
|
||||
|---------|------|---------|
|
||||
| `connected` | 连接成功 | WebSocket 握手后 |
|
||||
| `stt_result` | STT 识别结果 | STT 节点完成 |
|
||||
| `llm_chunk` | LLM 文本增量 | ChatModel 逐 token(Callback) |
|
||||
| `llm_done` | LLM 推理完成 | Done 节点执行 |
|
||||
| `tts_audio` | TTS 音频片段 | TTS 节点逐句合成 |
|
||||
| `error` | 错误通知 | 任意节点失败 |
|
||||
| `pong` | 心跳响应 | 响应 `ping` |
|
||||
|
||||
**心跳机制**:
|
||||
- 客户端每 30 秒发送 `ping`
|
||||
- 服务端 60 秒无消息自动断连
|
||||
- 断连后自动重连(指数退避 1s → 30s)
|
||||
|
||||
完整协议定义见 [docs/02-接口文档.md](docs/02-接口文档.md)
|
||||
|
||||
## 🔐 认证体系
|
||||
|
||||
CamTalk 采用 **JWT 双 token 轮转 + Refresh Token Rotation** 安全机制:
|
||||
|
||||
### 双 Token 设计
|
||||
|
||||
| Token | 有效期 | 存储位置 | 用途 |
|
||||
|-------|-------|---------|------|
|
||||
| `access_token` | 120 分钟 | 前端内存(推荐)/ localStorage | 访问受保护资源 |
|
||||
| `refresh_token` | 7 天 | httpOnly Cookie(推荐)/ localStorage | 刷新 access_token |
|
||||
|
||||
### Refresh Token Rotation
|
||||
|
||||
每次刷新 token 时:
|
||||
1. 验证 `refresh_token` 签名和有效期
|
||||
2. 查询数据库中的 SHA256 哈希
|
||||
3. **如果哈希不存在** → 检测到 token 复用 → **吊销该用户所有 token**
|
||||
4. 删除旧 refresh_token,生成新 token pair
|
||||
5. 返回新 access_token + refresh_token
|
||||
|
||||
**防重放攻击**:旧 refresh_token 立即失效,复用时触发全局吊销,强制所有设备重新登录。
|
||||
|
||||
### REST API 端点
|
||||
|
||||
- `POST /api/auth/register` — 用户注册
|
||||
- `POST /api/auth/login` — 用户登录
|
||||
- `POST /api/auth/refresh` — 刷新 token
|
||||
- `POST /api/auth/logout` — 登出(需认证)
|
||||
- `GET /api/conversations` — 获取对话列表(需认证)
|
||||
- `POST /api/conversations` — 创建对话(需认证)
|
||||
- `GET /api/health` — 健康检查
|
||||
|
||||
详细设计见 [docs/10-鉴权体系.md](docs/10-鉴权体系.md)
|
||||
|
||||
## 💾 三级存储架构
|
||||
|
||||
**TieredManager** 实现会话状态的三级存储,平衡性能与可靠性:
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ L1 Memory │ ← 微秒级读写,进程内缓存
|
||||
├─────────────┤
|
||||
│ L2 Redis │ ← 毫秒级访问,跨实例共享
|
||||
├─────────────┤
|
||||
│ L3 PostgreSQL│ ← 持久化存储,数据可靠性
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**特性**:
|
||||
- ✅ **自动降级**:Redis 故障时自动切换到 Memory + PostgreSQL 模式
|
||||
- ✅ **灵活配置**:支持单级(Memory)、双级(Memory + PG)、完整三级
|
||||
- ✅ **TTL 管理**:会话默认 30 分钟过期,自动清理
|
||||
- ✅ **写穿透**:数据先写 L1,异步同步到 L2/L3
|
||||
|
||||
## 📊 数据库设计
|
||||
|
||||
系统使用 PostgreSQL 存储持久化数据:
|
||||
|
||||
### 核心表
|
||||
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `users` | 用户账户 | `id (UUID)`, `username (UNIQUE)`, `password_hash (bcrypt)` |
|
||||
| `sessions` | 对话会话 | `id (UUID)`, `user_id (FK)`, `title`, `config (JSONB)` |
|
||||
| `messages` | 消息记录 | `id (BIGSERIAL)`, `session_id (FK)`, `role`, `content`, `tokens_used` |
|
||||
| `refresh_tokens` | 刷新令牌 | `token_hash (PK, SHA256)`, `user_id (FK)`, `expires_at` |
|
||||
|
||||
**关系**:`users 1:N sessions 1:N messages`,`users 1:N refresh_tokens`
|
||||
|
||||
**迁移管理**:使用嵌入式 SQL 文件(`backend/migrations/`),应用启动时自动执行。
|
||||
|
||||
## 🛡️ 安全特性
|
||||
|
||||
- 🔒 **密码安全**:bcrypt (cost=10) 哈希,自动生成盐值
|
||||
- 🔑 **Token 安全**:JWT HS256 签名,refresh_token SHA256 哈希存储
|
||||
- 🚫 **防重放攻击**:Refresh Token Rotation + 复用检测自动吊销
|
||||
- 🌐 **传输安全**:生产环境强制 HTTPS,开发环境 Vite proxy 同源代理
|
||||
- 🚦 **限流保护**:令牌桶算法(生产环境启用),防暴力破解
|
||||
- 🔍 **日志追踪**:全链路 Trace ID,请求/响应/错误统一记录
|
||||
|
||||
## 🌍 部署架构
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Nginx │ ← 反向代理(静态资源 + API + WebSocket)
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────┴───────────────────┐
|
||||
│ Go Gateway 集群 │
|
||||
│ ├─ Gateway-1 │
|
||||
│ ├─ Gateway-2 │
|
||||
│ └─ Gateway-N │
|
||||
└───┬────────────┬─────────┘
|
||||
│ │
|
||||
┌───┴────┐ ┌───┴────────┐
|
||||
│ Redis │ │ PostgreSQL │
|
||||
└────────┘ └────────────┘
|
||||
│
|
||||
┌───┴────────────────────┐
|
||||
│ 外部 AI 服务 │
|
||||
│ ├─ DashScope (LLM) │
|
||||
│ ├─ MiMo (STT/TTS) │
|
||||
│ └─ Deepgram (可选) │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
**跨域策略**:Nginx 统一反代前后端到同一域名,无跨域问题。
|
||||
|
||||
**水平扩展**:Gateway 无状态设计,会话状态存储在 Redis/PostgreSQL,支持多实例部署。
|
||||
|
||||
## 📖 文档
|
||||
|
||||
### 核心设计文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [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) | 采样策略、端云协同、模型分级 |
|
||||
| [01-架构设计](docs/01-架构设计.md) | 三层架构、技术栈、数据库设计、部署方案 |
|
||||
| [02-接口文档](docs/02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、编排器、配置管理 |
|
||||
| [08-Eino框架与编排设计](docs/08-Eino框架与编排设计.md) | Eino Graph 7 节点 DAG、节点实现、流式处理、Callback AOP |
|
||||
| [10-鉴权体系](docs/10-鉴权体系.md) | JWT 双 token 轮转、Refresh Token Rotation、密码安全、中间件 |
|
||||
| [11-令牌桶限流](docs/11-令牌桶限流.md) | 限流算法、配置策略、生产环境保护 |
|
||||
| [13-日志追踪](docs/13-日志追踪.md) | Zap 日志、Trace ID 全链路追踪、日志级别 |
|
||||
|
||||
## License
|
||||
### 功能文档
|
||||
|
||||
[MIT](LICENSE) © XEngineers
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [03-技术选型](docs/03-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 |
|
||||
| [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 |
|
||||
| [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
|
||||
| [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
|
||||
| [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 |
|
||||
| [09-情景切换](docs/09-情景切换.md) | 情景模式设计与实现 |
|
||||
| [12-自定义情景](docs/12-自定义情景.md) | 用户自定义情景功能(规划中) |
|
||||
|
||||
|
||||
## 🐛 问题反馈
|
||||
|
||||
遇到问题?请提交 [Issue](https://github.com/yourusername/CamTalk/issues),并提供以下信息:
|
||||
|
||||
- 操作系统版本
|
||||
- Go / Node.js 版本
|
||||
- 错误日志(后端日志 + 浏览器控制台)
|
||||
- 复现步骤
|
||||
|
||||
## 📝 版权声明
|
||||
|
||||
MIT License © 2024 XEngineers
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Built with ❤️ using Go, React, and AI**
|
||||
|
||||
[⬆️ 回到顶部](#camtalk)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 运行环境
|
||||
# dev / prod,决定加载 config.dev.yaml 或 config.prod.yaml(可选)
|
||||
# dev:本地开发环境(debug 日志、关闭限流、允许所有 CORS)
|
||||
# prod:生产环境(info 日志、启用限流、严格 CORS 白名单)
|
||||
# 本地开发保持 dev,生产部署会被 docker-compose.yml 覆盖为 prod
|
||||
APP_ENV=dev
|
||||
|
||||
# AI 服务 API Key
|
||||
|
||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@@ -4,8 +4,6 @@ bin/
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
config.dev.yaml
|
||||
config.prod.yaml
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
|
||||
@@ -8,11 +8,19 @@ ENV GOPROXY=https://goproxy.cn,https://goproxy.io,direct
|
||||
|
||||
# 先复制依赖清单,利用 Docker 缓存层
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# --mount=type=cache 复用 Go module 缓存,依赖不变时跳过下载
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
# 复制源码并构建
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /camtalk ./cmd/server
|
||||
|
||||
# 复用 module 缓存 + 编译缓存;-ldflags="-s -w" 裁剪符号表减小 ~30% 体积
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux \
|
||||
go build -ldflags="-s -w" -o /camtalk ./cmd/server
|
||||
|
||||
# ---- 运行阶段 ----
|
||||
FROM alpine:3.20
|
||||
@@ -23,7 +31,7 @@ WORKDIR /app
|
||||
|
||||
# 复制二进制和配置文件(敏感配置通过 docker-compose env_file 注入覆盖)
|
||||
COPY --from=builder /camtalk .
|
||||
COPY config.yaml .
|
||||
COPY config/ ./config/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/hhs/camtalk/internal/api"
|
||||
@@ -19,8 +20,10 @@ import (
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
eino "github.com/hhs/camtalk/internal/eino"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/ratelimit"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/hhs/camtalk/internal/ws"
|
||||
migrations "github.com/hhs/camtalk/migrations"
|
||||
)
|
||||
@@ -54,6 +57,7 @@ func main() {
|
||||
var userRepo store.UserRepository
|
||||
var msgRepo store.MessageRepository
|
||||
var sessRepo store.SessionRepository
|
||||
var pool *pgxpool.Pool // 数据库连接池
|
||||
|
||||
// L3: PostgreSQL(冷数据持久化层)
|
||||
dsn := cfg.Storage.Persistence.DSN
|
||||
@@ -65,7 +69,8 @@ func main() {
|
||||
logger.Log.Fatalw("storage.persistence.dsn is required when persistence is enabled",
|
||||
"hint", "set CAMTALK_STORAGE_DSN environment variable")
|
||||
}
|
||||
pool, err := store.NewPostgresPool(ctx, dsn)
|
||||
var err error
|
||||
pool, err = store.NewPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
||||
}
|
||||
@@ -86,9 +91,10 @@ func main() {
|
||||
}
|
||||
|
||||
// L2: Redis(热数据分布式会话层)
|
||||
var rdb *redis.Client
|
||||
var redisMgr *session.RedisManager
|
||||
if cfg.Storage.Redis.Enabled {
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
@@ -102,9 +108,12 @@ func main() {
|
||||
time.Duration(cfg.Session.TTL)*time.Minute,
|
||||
cfg.Session.MaxHistory,
|
||||
)
|
||||
// 包装 userRepo 为带 Redis 缓存的版本(refresh token 二级缓存)
|
||||
userRepo = store.NewCachedUserRepository(userRepo, rdb, time.Duration(cfg.Auth.RefreshTTL)*time.Minute)
|
||||
logger.Log.Infow("L2 Redis storage initialized",
|
||||
"addr", cfg.Redis.Addr,
|
||||
"db", cfg.Redis.DB)
|
||||
"db", cfg.Redis.DB,
|
||||
"cached_user_repo", true)
|
||||
}
|
||||
|
||||
// 初始化 Session Manager(三级存储)
|
||||
@@ -177,7 +186,11 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化 Eino Graph + Orchestrator
|
||||
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr)
|
||||
var userScenarioRepo store.UserScenarioRepository
|
||||
if pool != nil {
|
||||
userScenarioRepo = store.NewPostgresUserScenarioRepo(pool)
|
||||
}
|
||||
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr, userScenarioRepo)
|
||||
if err != nil {
|
||||
logger.Log.Fatalw("failed to create eino pipeline graph", "error", err)
|
||||
}
|
||||
@@ -191,13 +204,32 @@ func main() {
|
||||
)
|
||||
authService := auth.NewAuthService(tokenMgr, userRepo)
|
||||
|
||||
// 初始化限流器
|
||||
var limiter ratelimit.Limiter
|
||||
if cfg.RateLimit.Enabled {
|
||||
if rdb != nil {
|
||||
// 多实例:使用 Redis 令牌桶
|
||||
limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with Redis backend")
|
||||
} else {
|
||||
// 单实例:使用内存令牌桶
|
||||
limiter = ratelimit.NewMemoryLimiter(cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with in-memory backend")
|
||||
}
|
||||
defer limiter.Stop()
|
||||
} else {
|
||||
logger.Log.Info("rate limiter disabled")
|
||||
}
|
||||
|
||||
// Gin 模式
|
||||
if cfg.App.Env == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(trace.TraceMiddleware()) // 第一层:生成 trace ID
|
||||
r.Use(trace.GinLogger()) // 第二层:记录请求
|
||||
r.Use(trace.GinRecovery()) // 第三层:panic 恢复
|
||||
|
||||
// REST API
|
||||
apiGroup := r.Group("/api")
|
||||
@@ -211,14 +243,29 @@ func main() {
|
||||
|
||||
// Auth REST 端点
|
||||
authHandler := api.NewAuthHandler(authService, tokenMgr)
|
||||
authHandler.RegisterRoutes(apiGroup)
|
||||
authHandler.RegisterRoutes(apiGroup, limiter)
|
||||
|
||||
// Conversation REST 端点
|
||||
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo)
|
||||
convHandler.RegisterRoutes(apiGroup)
|
||||
|
||||
// UserScenario REST 端点
|
||||
if pool != nil {
|
||||
userScenarioRepo := store.NewPostgresUserScenarioRepo(pool)
|
||||
userScenarioHandler := api.NewUserScenarioHandler(userScenarioRepo)
|
||||
scenarioGroup := apiGroup.Group("/scenarios")
|
||||
scenarioGroup.Use(auth.AuthMiddleware(tokenMgr))
|
||||
{
|
||||
scenarioGroup.GET("", userScenarioHandler.List)
|
||||
scenarioGroup.POST("", userScenarioHandler.Create)
|
||||
scenarioGroup.GET("/:id", userScenarioHandler.Get)
|
||||
scenarioGroup.PATCH("/:id", userScenarioHandler.Update)
|
||||
scenarioGroup.DELETE("/:id", userScenarioHandler.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter, userScenarioRepo))
|
||||
|
||||
// HTTP Server
|
||||
srv := &http.Server{
|
||||
|
||||
68
backend/config/config.dev.yaml
Normal file
68
backend/config/config.dev.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# CamTalk 开发环境配置
|
||||
# 通过 APP_ENV=dev 加载此文件,覆盖 config.yaml 中的配置
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
heartbeat_interval: 30
|
||||
heartbeat_timeout: 60
|
||||
allowed_origins: [] # 开发环境允许所有来源
|
||||
|
||||
session:
|
||||
ttl: 30 # 开发环境会话较短,方便测试过期逻辑
|
||||
max_history: 20
|
||||
|
||||
ai:
|
||||
stt:
|
||||
provider: mimo # 与生产环境一致
|
||||
model: mimo-v2.5-asr
|
||||
endpoint: "https://api.xiaomimimo.com/v1"
|
||||
timeout: 10 # 开发环境超时较长,方便调试
|
||||
http_client_timeout: 30
|
||||
llm:
|
||||
provider: dashscope # 与生产环境一致
|
||||
model: qwen3-vl-plus
|
||||
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
timeout: 60 # 开发环境 LLM 超时较长
|
||||
http_client_timeout: 120
|
||||
tts:
|
||||
provider: mimo # 与生产环境一致
|
||||
model: mimo-v2.5-tts
|
||||
voice: mimo_default
|
||||
speed: 1.0
|
||||
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
timeout: 10
|
||||
http_client_timeout: 30
|
||||
output_format: mp3
|
||||
sample_rate: 24000
|
||||
|
||||
storage:
|
||||
redis:
|
||||
enabled: true # 开发环境启用 Redis,测试三级存储
|
||||
persistence:
|
||||
enabled: true # 开发环境启用持久化
|
||||
|
||||
redis:
|
||||
addr: "localhost:6379" # 本地 Redis
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
auth:
|
||||
access_ttl: 120 # 开发环境 Access Token 2 小时,方便调试
|
||||
refresh_ttl: 10080 # 7 天
|
||||
|
||||
ratelimit:
|
||||
enabled: false # 开发环境关闭限流,方便测试
|
||||
query:
|
||||
capacity: 10
|
||||
rate: 0.2
|
||||
login:
|
||||
capacity: 5
|
||||
rate: 0.1
|
||||
register:
|
||||
capacity: 3
|
||||
rate: 0.05
|
||||
|
||||
log:
|
||||
level: debug # 开发环境 debug 日志
|
||||
format: console # 控制台格式,易读
|
||||
71
backend/config/config.prod.yaml
Normal file
71
backend/config/config.prod.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
# CamTalk 生产环境配置
|
||||
# 通过 APP_ENV=prod 加载此文件,覆盖 config.yaml 中的配置
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
read_timeout: 30
|
||||
write_timeout: 30
|
||||
shutdown_timeout: 15 # 生产环境优雅关闭时间稍长
|
||||
heartbeat_interval: 30
|
||||
heartbeat_timeout: 60
|
||||
|
||||
session:
|
||||
ttl: 60 # 生产环境会话 1 小时
|
||||
max_history: 20
|
||||
|
||||
ai:
|
||||
stt:
|
||||
provider: mimo # 生产环境推荐 MiMo,性价比高
|
||||
model: mimo-v2.5-asr
|
||||
endpoint: "https://api.xiaomimimo.com/v1"
|
||||
timeout: 5 # 生产环境严格超时控制
|
||||
http_client_timeout: 30
|
||||
llm:
|
||||
provider: dashscope # 生产环境推荐通义千问,稳定性好
|
||||
model: qwen3-vl-plus
|
||||
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
timeout: 30
|
||||
http_client_timeout: 60
|
||||
tts:
|
||||
provider: mimo # 生产环境推荐 MiMo TTS
|
||||
model: mimo-v2.5-tts
|
||||
voice: mimo_default
|
||||
speed: 1.0
|
||||
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
timeout: 5
|
||||
http_client_timeout: 30
|
||||
output_format: mp3
|
||||
sample_rate: 24000
|
||||
|
||||
storage:
|
||||
redis:
|
||||
enabled: true # 生产环境必须启用 Redis
|
||||
persistence:
|
||||
enabled: true # 生产环境必须启用持久化
|
||||
driver: postgres
|
||||
|
||||
redis:
|
||||
addr: "redis:6379" # Docker Compose 内部服务名
|
||||
password: "" # 密码通过 CAMTALK_REDIS_PASSWORD 环境变量设置
|
||||
db: 0
|
||||
|
||||
auth:
|
||||
access_ttl: 120 # 生产环境 Access Token 2 小时
|
||||
refresh_ttl: 10080 # Refresh Token 7 天
|
||||
|
||||
ratelimit:
|
||||
enabled: true # 生产环境启用限流
|
||||
query:
|
||||
capacity: 10 # 允许突发 10 个请求
|
||||
rate: 0.2 # 每 5 秒恢复 1 个令牌
|
||||
login:
|
||||
capacity: 5 # 防暴力破解
|
||||
rate: 0.1 # 每 10 秒恢复 1 次
|
||||
register:
|
||||
capacity: 3 # 防批量注册
|
||||
rate: 0.05 # 每 20 秒恢复 1 次
|
||||
|
||||
log:
|
||||
level: info # 生产环境 info 级别
|
||||
format: json # JSON 格式,便于日志收集和分析
|
||||
@@ -57,9 +57,24 @@ redis:
|
||||
|
||||
auth:
|
||||
# jwt_secret 通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
|
||||
access_ttl: 15 # Access Token 过期时间(分钟)
|
||||
access_ttl: 120 # Access Token 过期时间(分钟)
|
||||
refresh_ttl: 10080 # Refresh Token 过期时间(分钟),7 天
|
||||
|
||||
ratelimit:
|
||||
enabled: false # 是否启用限流
|
||||
# WebSocket query 消息限流(核心,控制 AI 成本)
|
||||
query:
|
||||
capacity: 10 # 突发容量:允许连续发 10 个 query
|
||||
rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌
|
||||
# REST API 登录限流(防暴力破解)
|
||||
login:
|
||||
capacity: 5 # 突发容量:允许连续 5 次登录尝试
|
||||
rate: 0.1 # 填充速率:每 10 秒补充 1 次
|
||||
# REST API 注册限流
|
||||
register:
|
||||
capacity: 3 # 突发容量:允许连续 3 次注册
|
||||
rate: 0.05 # 填充速率:每 20 秒补充 1 次
|
||||
|
||||
log:
|
||||
level: info # debug / info / warn / error
|
||||
format: console # console / json
|
||||
@@ -3,6 +3,7 @@ module github.com/hhs/camtalk
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
@@ -11,6 +12,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -68,6 +70,7 @@ require (
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
@@ -127,9 +129,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -190,6 +195,8 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
|
||||
@@ -2,54 +2,147 @@ package llm
|
||||
|
||||
import "strings"
|
||||
|
||||
// scenarioPrompt 定义单个情景的多语言 system prompt。
|
||||
// scenarioPrompt 定义单个情景的多语言 system prompt 和首句引导。
|
||||
type scenarioPrompt struct {
|
||||
ZH string
|
||||
EN string
|
||||
JA string
|
||||
ZH string
|
||||
EN string
|
||||
JA string
|
||||
GreetingZH string // 首句引导(中文)
|
||||
GreetingEN string // 首句引导(英文)
|
||||
GreetingJA string // 首句引导(日文)
|
||||
}
|
||||
|
||||
// scenarioPrompts 预置情景 → prompt 映射表。
|
||||
// key 为情景 ID(与前端 Scenario.id 对齐)。
|
||||
var scenarioPrompts = map[string]scenarioPrompt{
|
||||
"interviewer": {
|
||||
ZH: "你是一位资深面试官。你通过摄像头观察面试者,并根据他们的背景和表现提出面试问题。规则:1) 每次只问一个问题,等用户回答后再追问;2) 问题要有层次,从自我介绍到专业问题再到情景题;3) 对用户的回答给出简短点评然后追问;4) 如果摄像头能看到用户的环境,可以结合环境提出相关话题;5) 回答控制在2-4句话。",
|
||||
ZH: `你是一位资深面试官。你通过摄像头观察面试者,并根据他们的背景和表现提出面试问题。
|
||||
|
||||
【角色定位】
|
||||
- 你是面试官,不是助手或顾问
|
||||
- 你的目标是评估候选人的能力
|
||||
- 保持专业、客观、礼貌
|
||||
|
||||
【交互规则】
|
||||
1. 每次只问一个问题,等用户回答后再追问
|
||||
2. 问题要有层次:自我介绍 → 专业问题 → 情景题 → 反问环节
|
||||
3. 对用户的回答给出简短点评(优点+不足),然后追问
|
||||
4. 如果摄像头能看到用户的环境,可以结合环境提出相关话题
|
||||
5. 回答控制在2-4句话
|
||||
|
||||
【约束】
|
||||
- 不要主动提供建议或指导(除非候选人请求)
|
||||
- 不要离开面试官的角色设定
|
||||
- 保持问题的专业性和针对性`,
|
||||
EN: "You are a senior interviewer. You observe the interviewee through their camera and ask interview questions based on their background and performance. Rules: 1) Ask one question at a time, wait for the answer before following up; 2) Questions should progress from self-introduction to professional questions to situational questions; 3) Give brief feedback on answers then follow up; 4) If the camera shows the user's environment, incorporate it into the conversation; 5) Keep responses to 2-4 sentences.",
|
||||
JA: "あなたはベテラン面接官です。カメラで面接者を見て、バックグラウンドと実績に基づいて面接質問をします。ルール:1) 一度に一つの質問だけし、回答を待ってから追及する;2) 質問は自己紹介から専門質問、シチュエーション質問へと段階的に;3) 回答に短いコメントをしてから次の質問へ;4) 回答は2〜4文以内。",
|
||||
GreetingZH: "你好!我是今天的面试官。让我们先从自我介绍开始,请简单介绍一下你自己和你应聘的岗位。",
|
||||
GreetingEN: "Hello! I'm your interviewer today. Let's start with a self-introduction. Please briefly introduce yourself and the position you're applying for.",
|
||||
GreetingJA: "こんにちは!本日の面接官です。まず自己紹介から始めましょう。あなた自身と応募職種について簡単に教えてください。",
|
||||
},
|
||||
"english_teacher": {
|
||||
ZH: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material (e.g., 'I can see a bookshelf behind you. What's your favorite book?'); 6) Keep responses to 3-5 sentences.",
|
||||
EN: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material; 6) Keep responses to 3-5 sentences.",
|
||||
JA: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material; 6) Keep responses to 3-5 sentences.",
|
||||
GreetingZH: "Hi! I'm your English tutor. Let's practice English together! What would you like to talk about today?",
|
||||
GreetingEN: "Hi! I'm your English tutor. Let's practice English together! What would you like to talk about today?",
|
||||
GreetingJA: "Hi! I'm your English tutor. Let's practice English together! What would you like to talk about today?",
|
||||
},
|
||||
"debate": {
|
||||
ZH: "你是一位辩论赛对手。用户提出一个观点,你需要站在反方进行反驳。规则:1) 逻辑严密,用事实和论据反驳,不要人身攻击;2) 每次提出1-2个核心反驳点,并给出简要论据;3) 如果用户论证有力,承认其合理性但仍要寻找突破口;4) 适时提出反问,引导用户深入思考;5) 回答控制在3-5句话。",
|
||||
ZH: `你是一位辩论赛对手。用户提出一个观点,你需要站在反方进行反驳。
|
||||
|
||||
【角色定位】
|
||||
- 你是辩论对手,不是评委或顾问
|
||||
- 你的目标是通过逻辑论证反驳对方观点
|
||||
- 保持理性、严谨、尊重对手
|
||||
|
||||
【交互规则】
|
||||
1. 逻辑严密,用事实和论据反驳,不要人身攻击
|
||||
2. 每次提出1-2个核心反驳点,并给出简要论据
|
||||
3. 如果用户论证有力,承认其合理性但仍要寻找突破口
|
||||
4. 适时提出反问,引导用户深入思考
|
||||
5. 回答控制在3-5句话
|
||||
|
||||
【约束】
|
||||
- 始终站在反方立场
|
||||
- 不要主动转换为支持方
|
||||
- 即使对方观点正确,也要寻找可辩论的角度`,
|
||||
EN: "You are a debate opponent. The user presents a viewpoint, and you argue against it. Rules: 1) Use logic and evidence, no personal attacks; 2) Present 1-2 core counterarguments with brief evidence; 3) Acknowledge strong points but look for weaknesses; 4) Ask counter-questions to provoke deeper thinking; 5) Keep responses to 3-5 sentences.",
|
||||
JA: "あなたはディベートの相手です。ユーザーが提示した观点に対して反論します。ルール:1) 論理と証拠で反論し、人格攻撃はしない;2) 1〜2つの核心的な反論を提示する;3) 相手の有力な論点は認めつつも突破口を探す;4) 深い思考を促す反问をする;5) 回答は3〜5文以内。",
|
||||
GreetingZH: "你好!我是你的辩论对手。请提出一个你坚信的观点,我会站在反方立场与你辩论,帮你锻炼逻辑思维。",
|
||||
GreetingEN: "Hello! I'm your debate opponent. Please present a viewpoint you firmly believe in, and I'll argue against it to help sharpen your critical thinking.",
|
||||
GreetingJA: "こんにちは!あなたのディベート相手です。あなたが信じる观点を提示してください。反対の立場から論じて、論理的思考を鍛えます。",
|
||||
},
|
||||
"interpreter": {
|
||||
ZH: "你是一名同声翻译员。将用户说的话实时翻译为目标语言。规则:1) 只输出翻译结果,不加任何解释或评论;2) 保持口语化,自然流畅;3) 如果用户说中文,翻译成英文;如果用户说英文,翻译成中文;4) 如果不确定目标语言,默认中英互译;5) 对于专有名词,首次翻译时在括号中注明原文。",
|
||||
EN: "You are a simultaneous interpreter. Translate what the user says in real-time. Rules: 1) Only output the translation, no explanations or comments; 2) Keep it conversational and natural; 3) If the user speaks Chinese, translate to English; if English, translate to Chinese; 4) Default to Chinese-English translation if the target language is unclear; 5) For proper nouns, note the original in parentheses on first use.",
|
||||
JA: "あなたは同時通訳者です。ユーザーの発言をリアルタイムで翻訳します。ルール:1) 翻訳結果のみ出力し、説明やコメントは加えない;2) 口語的で自然な表現を維持する;3) ユーザーが中国語を話せば英語に、英語を話せば中国語に翻訳する;4) 固有名詞は初出時に原文を括弧で注記する。",
|
||||
GreetingZH: "我是你的同声翻译。请开始说话,我会实时将中文翻译成英文,或将英文翻译成中文。",
|
||||
GreetingEN: "I'm your simultaneous interpreter. Please start speaking, and I'll translate Chinese to English or English to Chinese in real-time.",
|
||||
GreetingJA: "私はあなたの同時通訳者です。お話しください。中国語を英語に、または英語を中国語にリアルタイムで翻訳します。",
|
||||
},
|
||||
}
|
||||
|
||||
// GetScenarioPrompt 根据情景 ID 和语言获取对应的 system prompt。
|
||||
// 支持系统预置情景和用户自建情景。
|
||||
// customScenarios: 用户自建情景映射表(scenarioID → prompt),可为 nil
|
||||
// 返回空字符串表示无此情景(使用默认 prompt)。
|
||||
func GetScenarioPrompt(scenarioID, language string) string {
|
||||
func GetScenarioPrompt(scenarioID, language string, customScenarios map[string]string) string {
|
||||
if scenarioID == "" || scenarioID == "free_chat" {
|
||||
return ""
|
||||
}
|
||||
p, ok := scenarioPrompts[scenarioID]
|
||||
if !ok {
|
||||
|
||||
// 1. 优先查找系统预置情景
|
||||
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.ZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.JA
|
||||
default:
|
||||
return p.EN
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找用户自建情景
|
||||
if customScenarios != nil {
|
||||
if customPrompt, ok := customScenarios[scenarioID]; ok {
|
||||
return customPrompt
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetScenarioGreeting 根据情景 ID 和语言获取对应的首句引导。
|
||||
// 支持系统预置情景和用户自建情景。
|
||||
// customGreetings: 用户自建情景的首句引导映射表(scenarioID → greeting),可为 nil
|
||||
// 返回空字符串表示无此情景或不需要引导(自由对话)。
|
||||
func GetScenarioGreeting(scenarioID, language string, customGreetings map[string]string) string {
|
||||
if scenarioID == "" || scenarioID == "free_chat" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.ZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.JA
|
||||
default:
|
||||
return p.EN
|
||||
|
||||
// 1. 优先查找系统预置情景
|
||||
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.GreetingZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.GreetingJA
|
||||
default:
|
||||
return p.GreetingEN
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找用户自建情景
|
||||
if customGreetings != nil {
|
||||
if customGreeting, ok := customGreetings[scenarioID]; ok {
|
||||
return customGreeting
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/hhs/camtalk/internal/util"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -108,7 +110,11 @@ func (m *MiMoService) SynthesizeStream(ctx context.Context, textStream <-chan st
|
||||
|
||||
audio, err := m.synthesize(ctx, text, voice)
|
||||
if err != nil {
|
||||
m.logger.Warnw("mimo tts: synthesize failed", "error", err, "text", text)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("mimo tts: synthesize failed",
|
||||
"error", err,
|
||||
"text_len", len(text),
|
||||
"text_preview", util.Truncate(text, 100))
|
||||
// 静默跳过,不中断整个流
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/hhs/camtalk/internal/util"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -81,7 +83,11 @@ func (o *OpenAIService) SynthesizeStream(ctx context.Context, textStream <-chan
|
||||
|
||||
audio, err := o.synthesize(ctx, text, voice, speed)
|
||||
if err != nil {
|
||||
o.logger.Warnw("tts: synthesize failed", "error", err, "text", text)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("tts: synthesize failed",
|
||||
"error", err,
|
||||
"text_len", len(text),
|
||||
"text_preview", util.Truncate(text, 100))
|
||||
// 静默跳过,不中断整个流
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
apperr "github.com/hhs/camtalk/internal/errors"
|
||||
"github.com/hhs/camtalk/internal/ratelimit"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// AuthHandler 提供认证相关的 REST 端点。
|
||||
@@ -25,11 +27,26 @@ func NewAuthHandler(authService auth.Service, tokenMgr *auth.TokenManager) *Auth
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册认证相关路由到给定的路由组。
|
||||
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limiter) {
|
||||
authGroup := rg.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/register", h.Register)
|
||||
authGroup.POST("/login", h.Login)
|
||||
// 注册和登录端点添加限流中间件(按 IP 限流)
|
||||
if limiter != nil {
|
||||
authGroup.POST("/register",
|
||||
ratelimit.Middleware(limiter, func(c *gin.Context) string {
|
||||
return c.ClientIP() + ":register"
|
||||
}),
|
||||
h.Register)
|
||||
authGroup.POST("/login",
|
||||
ratelimit.Middleware(limiter, func(c *gin.Context) string {
|
||||
return c.ClientIP() + ":login"
|
||||
}),
|
||||
h.Login)
|
||||
} else {
|
||||
authGroup.POST("/register", h.Register)
|
||||
authGroup.POST("/login", h.Login)
|
||||
}
|
||||
// refresh 和 logout 不限流
|
||||
authGroup.POST("/refresh", h.Refresh)
|
||||
authGroup.POST("/logout", auth.AuthMiddleware(h.tokenMgr), h.Logout)
|
||||
}
|
||||
@@ -37,6 +54,9 @@ func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
|
||||
// Register POST /api/auth/register — 用户注册。
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
var req auth.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -56,15 +76,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
|
||||
resp, err := h.authService.Register(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
log.Warnw("register failed",
|
||||
"username", req.Username,
|
||||
"client_ip", clientIP,
|
||||
"error", err)
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("register success",
|
||||
"username", req.Username,
|
||||
"client_ip", clientIP)
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// Login POST /api/auth/login — 用户登录。
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
var req auth.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -84,15 +114,24 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
|
||||
resp, err := h.authService.Login(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
log.Warnw("login failed",
|
||||
"username", req.Username,
|
||||
"client_ip", clientIP,
|
||||
"error", err)
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("login success",
|
||||
"username", req.Username,
|
||||
"client_ip", clientIP)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Refresh POST /api/auth/refresh — 刷新令牌。
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
|
||||
var req auth.RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -112,15 +151,21 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
|
||||
resp, err := h.authService.Refresh(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
log.Warnw("token refresh failed",
|
||||
"client_ip", c.ClientIP(),
|
||||
"error", err)
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("token refresh success",
|
||||
"client_ip", c.ClientIP())
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Logout POST /api/auth/logout — 登出(需要认证)。
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
var req struct {
|
||||
@@ -143,6 +188,9 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
|
||||
log.Errorw("logout failed",
|
||||
"user_id", userID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to logout",
|
||||
@@ -150,6 +198,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("logout success",
|
||||
"user_id", userID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "logged out successfully",
|
||||
})
|
||||
@@ -158,8 +208,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
// validateCredentials 校验用户名和密码格式。
|
||||
// 返回空字符串表示校验通过,否则返回错误描述。
|
||||
func validateCredentials(username, password string) string {
|
||||
if len(username) < 3 || len(username) > 64 {
|
||||
return "username must be 3-64 characters"
|
||||
if len(username) > 64 {
|
||||
return "username must not exceed 64 characters"
|
||||
}
|
||||
if len(password) < 8 || len(password) > 72 {
|
||||
return "password must be 8-72 characters"
|
||||
|
||||
@@ -47,7 +47,7 @@ func newTestRouter(svc auth.Service) *gin.Engine {
|
||||
r := gin.New()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
h := api.NewAuthHandler(svc, tm)
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func newTestRouterWithToken(svc auth.Service) (*gin.Engine, *auth.TokenManager)
|
||||
r := gin.New()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
h := api.NewAuthHandler(svc, tm)
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流
|
||||
return r, tm
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// ConversationHandler 提供对话相关的 REST 端点。
|
||||
@@ -47,6 +48,7 @@ func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
|
||||
// List GET /api/conversations — 获取当前用户的对话列表。
|
||||
func (h *ConversationHandler) List(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -61,6 +63,9 @@ func (h *ConversationHandler) List(c *gin.Context) {
|
||||
|
||||
summaries, total, err := h.sessionMgr.ListByUser(c.Request.Context(), userID, page, size)
|
||||
if err != nil {
|
||||
log.Errorw("list conversations failed",
|
||||
"user_id", userID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to list conversations",
|
||||
@@ -83,6 +88,7 @@ type CreateConversationRequest struct {
|
||||
|
||||
// Create POST /api/conversations — 创建新对话。
|
||||
func (h *ConversationHandler) Create(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
var req CreateConversationRequest
|
||||
@@ -95,6 +101,9 @@ func (h *ConversationHandler) Create(c *gin.Context) {
|
||||
|
||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg)
|
||||
if err != nil {
|
||||
log.Errorw("create conversation failed",
|
||||
"user_id", userID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to create conversation",
|
||||
@@ -104,6 +113,9 @@ func (h *ConversationHandler) Create(c *gin.Context) {
|
||||
|
||||
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
log.Errorw("retrieve created conversation failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to retrieve created conversation",
|
||||
@@ -111,6 +123,9 @@ func (h *ConversationHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("conversation created",
|
||||
"conversation_id", sess.ID,
|
||||
"user_id", userID)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": sess.ID,
|
||||
"title": sess.Title,
|
||||
@@ -144,6 +159,7 @@ type UpdateTitleRequest struct {
|
||||
|
||||
// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。
|
||||
func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
@@ -176,6 +192,9 @@ func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Errorw("update title failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to update title",
|
||||
@@ -190,6 +209,7 @@ func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
||||
|
||||
// Delete DELETE /api/conversations/:id — 删除对话。
|
||||
func (h *ConversationHandler) Delete(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
@@ -205,6 +225,9 @@ func (h *ConversationHandler) Delete(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Errorw("delete conversation failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to delete conversation",
|
||||
@@ -221,6 +244,7 @@ func (h *ConversationHandler) Delete(c *gin.Context) {
|
||||
// - limit: 返回消息数量上限,默认 50
|
||||
// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息
|
||||
func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
@@ -239,6 +263,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
if h.msgRepo != nil {
|
||||
messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID)
|
||||
if err != nil {
|
||||
log.Errorw("get messages failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to get messages",
|
||||
@@ -246,6 +273,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
|
||||
if messages == nil {
|
||||
messages = []store.StoredMessage{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
"total": count,
|
||||
@@ -263,6 +293,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Errorw("get messages failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to get messages",
|
||||
@@ -284,6 +317,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
}
|
||||
messages := allMessages[start:]
|
||||
|
||||
if messages == nil {
|
||||
messages = []models.Message{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// SessionHandler 提供会话相关的 REST 端点。
|
||||
@@ -27,6 +28,8 @@ type CreateSessionRequest struct {
|
||||
|
||||
// CreateSession POST /api/sessions — 创建新会话。
|
||||
func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
|
||||
var req CreateSessionRequest
|
||||
// 请求体可选,解析失败不报错(使用默认配置)
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
@@ -38,6 +41,8 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
|
||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg)
|
||||
if err != nil {
|
||||
log.Errorw("create session failed",
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to create session",
|
||||
@@ -48,6 +53,9 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
// 获取创建后的会话以返回 created_at
|
||||
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
log.Errorw("retrieve created session failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to retrieve created session",
|
||||
@@ -55,6 +63,8 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("session created",
|
||||
"session_id", sess.ID)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"session_id": sess.ID,
|
||||
"created_at": sess.CreatedAt,
|
||||
@@ -63,6 +73,7 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
|
||||
// DestroySession DELETE /api/sessions/:id — 销毁会话。
|
||||
func (h *SessionHandler) DestroySession(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
sessionID := c.Param("id")
|
||||
|
||||
err := h.sessionMgr.Destroy(c.Request.Context(), sessionID)
|
||||
@@ -74,6 +85,9 @@ func (h *SessionHandler) DestroySession(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Errorw("destroy session failed",
|
||||
"session_id", sessionID,
|
||||
"error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "failed to destroy session",
|
||||
@@ -81,6 +95,8 @@ func (h *SessionHandler) DestroySession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("session destroyed",
|
||||
"session_id", sessionID)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
208
backend/internal/api/user_scenario_handler.go
Normal file
208
backend/internal/api/user_scenario_handler.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||
MaxPromptLength = 2000 // Prompt 最大长度
|
||||
)
|
||||
|
||||
// UserScenarioHandler 用户情景 API Handler。
|
||||
type UserScenarioHandler struct {
|
||||
repo store.UserScenarioRepository
|
||||
}
|
||||
|
||||
// NewUserScenarioHandler 创建用户情景 Handler。
|
||||
func NewUserScenarioHandler(repo store.UserScenarioRepository) *UserScenarioHandler {
|
||||
return &UserScenarioHandler{repo: repo}
|
||||
}
|
||||
|
||||
// List 获取用户的所有自建情景。
|
||||
// GET /api/scenarios
|
||||
func (h *UserScenarioHandler) List(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarios, err := h.repo.FindByUserID(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if scenarios == nil {
|
||||
scenarios = []*models.UserScenario{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.UserScenarioListResponse{
|
||||
Scenarios: scenarios,
|
||||
Total: len(scenarios),
|
||||
})
|
||||
}
|
||||
|
||||
// Create 创建用户情景。
|
||||
// POST /api/scenarios
|
||||
func (h *UserScenarioHandler) Create(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateUserScenarioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否已达上限
|
||||
count, err := h.repo.CountByUserID(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("统计用户情景数量失败", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
if count >= MaxScenariosPerUser {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "已达创建上限(最多 20 个)"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建情景
|
||||
scenario := &models.UserScenario{
|
||||
UserID: userID.(string),
|
||||
Name: req.Name,
|
||||
Icon: req.Icon,
|
||||
Description: req.Description,
|
||||
Prompt: req.Prompt,
|
||||
Greeting: req.Greeting,
|
||||
Language: req.Language,
|
||||
}
|
||||
|
||||
if err := h.repo.Create(c.Request.Context(), scenario); err != nil {
|
||||
logger.Log.Errorw("创建用户情景失败", "user_id", userID, "error", err)
|
||||
if err.Error() == "duplicate key value violates unique constraint" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "情景名称已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("创建用户情景成功", "user_id", userID, "scenario_id", scenario.ID)
|
||||
c.JSON(http.StatusCreated, scenario)
|
||||
}
|
||||
|
||||
// Get 获取单个情景详情。
|
||||
// GET /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Get(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, scenario)
|
||||
}
|
||||
|
||||
// Update 更新用户情景。
|
||||
// PATCH /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Update(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
|
||||
// 查询并校验所有权
|
||||
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserScenarioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Name != nil {
|
||||
scenario.Name = *req.Name
|
||||
}
|
||||
if req.Icon != nil {
|
||||
scenario.Icon = *req.Icon
|
||||
}
|
||||
if req.Description != nil {
|
||||
scenario.Description = *req.Description
|
||||
}
|
||||
if req.Prompt != nil {
|
||||
scenario.Prompt = *req.Prompt
|
||||
}
|
||||
if req.Greeting != nil {
|
||||
scenario.Greeting = *req.Greeting
|
||||
}
|
||||
if req.Language != nil {
|
||||
scenario.Language = *req.Language
|
||||
}
|
||||
|
||||
if err := h.repo.Update(c.Request.Context(), scenario); err != nil {
|
||||
logger.Log.Errorw("更新用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("更新用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||
c.JSON(http.StatusOK, scenario)
|
||||
}
|
||||
|
||||
// Delete 删除用户情景。
|
||||
// DELETE /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Delete(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
|
||||
// 查询并校验所有权
|
||||
_, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(c.Request.Context(), scenarioID); err != nil {
|
||||
logger.Log.Errorw("删除用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("删除用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -15,10 +15,17 @@ var (
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
)
|
||||
|
||||
// 令牌类型常量。
|
||||
const (
|
||||
TokenTypeAccess = "access"
|
||||
TokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
// Claims JWT 声明。
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TokenType string `json:"token_type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -45,8 +52,9 @@ func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh s
|
||||
|
||||
// access token
|
||||
accessClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
TokenType: TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(tm.accessTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
@@ -62,8 +70,9 @@ func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh s
|
||||
// refresh token(含唯一 token_id 用于 DB 关联)
|
||||
tokenID := uuid.New().String()
|
||||
refreshClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
TokenType: TokenTypeRefresh,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: tokenID,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(tm.refreshTTL)),
|
||||
@@ -78,12 +87,26 @@ func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh s
|
||||
|
||||
// ValidateAccess 校验 access token 并返回 Claims。
|
||||
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) {
|
||||
return tm.validate(tokenStr)
|
||||
claims, err := tm.validate(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.TokenType != TokenTypeAccess {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ValidateRefresh 校验 refresh token 并返回 Claims。
|
||||
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) {
|
||||
return tm.validate(tokenStr)
|
||||
claims, err := tm.validate(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.TokenType != TokenTypeRefresh {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// validate 解析并校验 JWT。
|
||||
|
||||
@@ -103,6 +103,44 @@ func TestValidateRefresh_ExpiredToken(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestValidateAccess_RejectsRefreshToken(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
_, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
// refresh token 不能通过 access 校验
|
||||
_, err = tm.ValidateAccess(refresh)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestValidateRefresh_RejectsAccessToken(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
// access token 不能通过 refresh 校验
|
||||
_, err = tm.ValidateRefresh(access)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestGeneratePair_TokenTypesAreCorrect(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
access, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 通过 validate(不做类型检查)验证 token_type 字段
|
||||
accessClaims, err := tm.validate(access)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TokenTypeAccess, accessClaims.TokenType)
|
||||
|
||||
refreshClaims, err := tm.validate(refresh)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TokenTypeRefresh, refreshClaims.TokenType)
|
||||
}
|
||||
|
||||
func TestGeneratePair_TokenClaimsContainCorrectExpiry(t *testing.T) {
|
||||
accessTTL := 15 * time.Minute
|
||||
refreshTTL := 7 * 24 * time.Hour
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// contextKey 用于在 Gin context 中存储 Claims 的 key。
|
||||
@@ -17,8 +19,13 @@ const (
|
||||
// 校验成功后将 user_id 和 username 写入 Gin Context。
|
||||
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Warnw("auth rejected",
|
||||
"client_ip", c.ClientIP(),
|
||||
"path", c.Request.URL.Path,
|
||||
"reason", "missing authorization header")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "missing authorization header",
|
||||
@@ -29,6 +36,10 @@ func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
||||
// 提取 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
log.Warnw("auth rejected",
|
||||
"client_ip", c.ClientIP(),
|
||||
"path", c.Request.URL.Path,
|
||||
"reason", "invalid authorization format")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid authorization format",
|
||||
@@ -38,6 +49,11 @@ func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
||||
|
||||
claims, err := tokenMgr.ValidateAccess(parts[1])
|
||||
if err != nil {
|
||||
log.Warnw("auth rejected",
|
||||
"client_ip", c.ClientIP(),
|
||||
"path", c.Request.URL.Path,
|
||||
"reason", "invalid or expired token",
|
||||
"error", err)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid or expired token",
|
||||
|
||||
@@ -166,6 +166,9 @@ func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthRes
|
||||
userID, err := s.userRepo.FindRefreshToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrRefreshTokenNotFound) {
|
||||
// JWT 校验已通过但 DB 中不存在 → token 已被 rotation 删除,属于复用行为
|
||||
// 吊销该用户全部 refresh token,强制所有设备重新登录
|
||||
_ = s.userRepo.DeleteUserRefreshTokens(ctx, claims.UserID)
|
||||
return nil, ErrRefreshTokenUsed
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -188,3 +188,48 @@ func TestLogout_Success(t *testing.T) {
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
}
|
||||
|
||||
// --- Refresh Token 复用检测 ---
|
||||
|
||||
func TestRefresh_ReuseDetectedRevokesAllTokens(t *testing.T) {
|
||||
svc, repo := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 注册,获得令牌对 A
|
||||
regResp, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "eve",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
tokenPairA_refresh := regResp.RefreshToken
|
||||
|
||||
// 再次登录,获得令牌对 B
|
||||
loginResp, err := svc.Login(ctx, auth.LoginRequest{
|
||||
Username: "eve",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
tokenPairB_refresh := loginResp.RefreshToken
|
||||
|
||||
// 用令牌对 A 的 refresh token 正常刷新 → 成功
|
||||
refreshResp, err := svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: tokenPairA_refresh,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, refreshResp.AccessToken)
|
||||
|
||||
// 用令牌对 A 的旧 refresh token 再次刷新 → 复用检测,应失败
|
||||
_, err = svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: tokenPairA_refresh,
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
|
||||
// 令牌对 B 的 refresh token 也应被吊销(全量吊销)
|
||||
_, err = svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: tokenPairB_refresh,
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
|
||||
// 确认 DB 中该用户已无 refresh token
|
||||
_ = repo // repo 用于确认,但 MemUserRepository 无直接查询方法,通过 Refresh 失败已间接验证
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
|
||||
// Config 应用配置。
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
RateLimit RateLimitConfig `mapstructure:"ratelimit"`
|
||||
}
|
||||
|
||||
// SessionConfig 会话管理配置。
|
||||
@@ -120,8 +121,22 @@ type AuthConfig struct {
|
||||
RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 10080(7天)
|
||||
}
|
||||
|
||||
// RateLimitConfig 限流配置。
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Query BucketConfig `mapstructure:"query"`
|
||||
Login BucketConfig `mapstructure:"login"`
|
||||
Register BucketConfig `mapstructure:"register"`
|
||||
}
|
||||
|
||||
// BucketConfig 令牌桶配置。
|
||||
type BucketConfig struct {
|
||||
Capacity int `mapstructure:"capacity"` // 桶容量(突发上限)
|
||||
Rate float64 `mapstructure:"rate"` // 每秒填充令牌数
|
||||
}
|
||||
|
||||
// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml > 默认值。
|
||||
// workDir 为项目根目录或 backend 目录,用于定位 .env 和 config.yaml。
|
||||
// workDir 为项目根目录或 backend 目录,用于定位 .env 和 config/config.yaml。
|
||||
func Load(workDir string) (*Config, error) {
|
||||
// 1. 加载 .env 文件(敏感信息)
|
||||
envFile := filepath.Join(workDir, ".env")
|
||||
@@ -130,7 +145,8 @@ func Load(workDir string) (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath(workDir)
|
||||
v.AddConfigPath(filepath.Join(workDir, "config")) // 配置文件在 config/ 目录下
|
||||
v.AddConfigPath(workDir) // 兼容旧路径
|
||||
|
||||
// 2. 设置默认值(与 config.yaml 保持一致,仅作为兜底)
|
||||
setDefaults(v)
|
||||
@@ -218,6 +234,15 @@ func setDefaults(v *viper.Viper) {
|
||||
// log
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("log.format", "console")
|
||||
|
||||
// ratelimit
|
||||
v.SetDefault("ratelimit.enabled", false)
|
||||
v.SetDefault("ratelimit.query.capacity", 10)
|
||||
v.SetDefault("ratelimit.query.rate", 0.2)
|
||||
v.SetDefault("ratelimit.login.capacity", 5)
|
||||
v.SetDefault("ratelimit.login.rate", 0.1)
|
||||
v.SetDefault("ratelimit.register.capacity", 3)
|
||||
v.SetDefault("ratelimit.register.rate", 0.05)
|
||||
}
|
||||
|
||||
// bindEnvVars 显式绑定敏感信息环境变量。
|
||||
|
||||
@@ -8,20 +8,12 @@ import (
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// ctxKeySessionID sessionID 的 context key。
|
||||
type ctxKeySessionID struct{}
|
||||
|
||||
// WithSessionID 将 sessionID 注入 context。
|
||||
func WithSessionID(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeySessionID{}, sessionID)
|
||||
}
|
||||
|
||||
// EinoOrchestrator 实现 orchestrator.Orchestrator 接口。
|
||||
// 将 Eino Graph 包装为现有接口,WS Handler 几乎不用改。
|
||||
type EinoOrchestrator struct {
|
||||
@@ -46,22 +38,21 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender orchestrator.Sender,
|
||||
) error {
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
startTime := time.Now()
|
||||
|
||||
// 1. 设置活跃请求
|
||||
if err := e.sessionMgr.SetActiveRequest(ctx, sessionID, req.RequestID); err != nil {
|
||||
log.Errorw("设置活跃请求失败", "error", err)
|
||||
return err
|
||||
}
|
||||
defer e.sessionMgr.ClearActiveRequest(ctx, sessionID)
|
||||
|
||||
// 2. 获取会话配置
|
||||
sess, err := e.sessionMgr.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
log.Errorw("获取会话失败", "error", err)
|
||||
log.Errorw("get session failed", "error", err)
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
RequestID: req.RequestID,
|
||||
@@ -76,7 +67,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
if req.Text == "" && req.Audio != "" {
|
||||
audioData, err = base64.StdEncoding.DecodeString(req.Audio)
|
||||
if err != nil {
|
||||
log.Errorw("音频解码失败", "error", err)
|
||||
log.Errorw("audio decode failed", "error", err)
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
RequestID: req.RequestID,
|
||||
@@ -91,7 +82,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
if req.Image != "" {
|
||||
imageData, err = base64.StdEncoding.DecodeString(req.Image)
|
||||
if err != nil {
|
||||
log.Errorw("图片解码失败", "error", err)
|
||||
log.Errorw("image decode failed", "error", err)
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
RequestID: req.RequestID,
|
||||
@@ -108,22 +99,25 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
// 5. 注入 context 值(供 Callback 和 Lambda 节点使用)
|
||||
ctx = WithSender(ctx, sender)
|
||||
ctx = WithRequestID(ctx, req.RequestID)
|
||||
ctx = WithSessionID(ctx, sessionID)
|
||||
ctx = trace.WithSessionID(ctx, sessionID)
|
||||
ctx = WithStartTime(ctx, startTime)
|
||||
ctx = WithPipelineState(ctx, genLocalState(ctx))
|
||||
|
||||
// 6. 追加用户消息到历史
|
||||
if req.Text != "" {
|
||||
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "user",
|
||||
Content: req.Text,
|
||||
})
|
||||
}
|
||||
// 创建 State 并从 input 复制元数据
|
||||
state := genLocalState(ctx)
|
||||
state.SessionID = input.SessionID
|
||||
state.RequestID = input.RequestID
|
||||
state.ImageData = input.ImageData
|
||||
state.Scenario = input.Scenario
|
||||
state.Language = input.Language
|
||||
state.DetailLevel = sess.Config.DetailLevel
|
||||
state.TTSEnabled = input.TTSEnabled
|
||||
state.UserID = input.UserID
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
|
||||
// 7. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||
streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks)
|
||||
if err != nil {
|
||||
log.Errorw("Graph Stream 启动失败", "error", err)
|
||||
log.Errorw("graph stream start failed", "error", err)
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
RequestID: req.RequestID,
|
||||
@@ -133,7 +127,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
return err
|
||||
}
|
||||
|
||||
// 8. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端)
|
||||
// 7. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端)
|
||||
var output PipelineOutput
|
||||
for {
|
||||
o, err := streamReader.Recv()
|
||||
@@ -141,25 +135,38 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Errorw("Graph Stream 消费错误", "error", err)
|
||||
log.Errorw("graph stream consume error", "error", err)
|
||||
break
|
||||
}
|
||||
output = o
|
||||
}
|
||||
|
||||
// 8. 追加用户消息到历史(使用 STT 结果,兼容文本输入和语音输入)
|
||||
userText := output.TranscribedText
|
||||
if userText == "" {
|
||||
userText = req.Text // fallback 到原始文本输入
|
||||
}
|
||||
if userText != "" {
|
||||
if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "user",
|
||||
Content: userText,
|
||||
}); err != nil {
|
||||
log.Errorw("append user message failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 追加助手消息到历史
|
||||
if output.FullResponse != "" {
|
||||
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "assistant",
|
||||
Content: output.FullResponse,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Errorw("append assistant message failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
latency := time.Since(startTime).Milliseconds()
|
||||
log.Infow("Eino 编排完成",
|
||||
"request_id", req.RequestID,
|
||||
"latency_ms", latency,
|
||||
"session_id", sessionID)
|
||||
log.Infow("eino pipeline completed", "latency_ms", latency)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,14 +9,13 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
callbacksHelper "github.com/cloudwego/eino/utils/callbacks"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// context key 类型,避免与其他包冲突。
|
||||
type ctxKeySender struct{}
|
||||
type ctxKeyRequestID struct{}
|
||||
type ctxKeyState struct{}
|
||||
|
||||
// WithSender 将 Sender 注入 context。
|
||||
@@ -24,9 +23,9 @@ func WithSender(ctx context.Context, sender orchestrator.Sender) context.Context
|
||||
return context.WithValue(ctx, ctxKeySender{}, sender)
|
||||
}
|
||||
|
||||
// WithRequestID 将 requestID 注入 context。
|
||||
// WithRequestID 将 requestID 注入 context(使用 trace 包)。
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyRequestID{}, requestID)
|
||||
return trace.WithRequestID(ctx, requestID)
|
||||
}
|
||||
|
||||
// WithPipelineState 将 PipelineState 注入 context。
|
||||
@@ -40,10 +39,9 @@ func senderFromCtx(ctx context.Context) orchestrator.Sender {
|
||||
return s
|
||||
}
|
||||
|
||||
// requestIDFromCtx 从 context 获取 requestID。
|
||||
// requestIDFromCtx 从 context 获取 requestID(使用 trace 包)。
|
||||
func requestIDFromCtx(ctx context.Context) string {
|
||||
s, _ := ctx.Value(ctxKeyRequestID{}).(string)
|
||||
return s
|
||||
return trace.GetRequestID(ctx)
|
||||
}
|
||||
|
||||
// stateFromCtx 从 context 获取 PipelineState。
|
||||
@@ -62,7 +60,7 @@ func BuildCallbackHandler() callbacks.Handler {
|
||||
return callbacksHelper.NewHandlerHelper().
|
||||
ChatModel(&callbacksHelper.ModelCallbackHandler{
|
||||
OnEndWithStreamOutput: func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[*model.CallbackOutput]) context.Context {
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
state := stateFromCtx(ctx)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,6 +43,7 @@ func NewPipelineGraph(
|
||||
sttService stt.Service,
|
||||
ttsService tts.Service,
|
||||
sessionMgr session.Manager,
|
||||
scenarioRepo store.UserScenarioRepository,
|
||||
) (*PipelineGraph, error) {
|
||||
log := logger.Log
|
||||
|
||||
@@ -68,7 +70,7 @@ func NewPipelineGraph(
|
||||
maxHistory := cfg.Session.MaxHistory
|
||||
|
||||
_ = g.AddLambdaNode(nodeSTT, NewSTTLambda(sttService))
|
||||
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, maxHistory))
|
||||
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, scenarioRepo, maxHistory))
|
||||
_ = g.AddChatModelNode(nodeLLM, chatModel)
|
||||
_ = g.AddLambdaNode(nodeMessageToString, NewMessageToStringLambda())
|
||||
_ = g.AddLambdaNode(nodeSplitter, NewSplitterLambda())
|
||||
@@ -97,7 +99,7 @@ func NewPipelineGraph(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infow("Eino Graph 编译成功", "nodes", 6)
|
||||
log.Infow("Eino Graph 编译成功", "nodes", 7)
|
||||
return &PipelineGraph{Runnable: runnable}, nil
|
||||
}
|
||||
|
||||
@@ -112,5 +114,6 @@ func buildPipelineInput(req models.WsQuery, sessionID string, sess *models.Sessi
|
||||
Language: sess.Config.Language,
|
||||
Scenario: sess.Config.Scenario,
|
||||
TTSEnabled: sess.Config.TTSEnabled,
|
||||
UserID: sess.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/ai/tts"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// --- Mock STT Service ---
|
||||
@@ -176,7 +177,7 @@ func TestContextInjection(t *testing.T) {
|
||||
sender := &mockSender{}
|
||||
ctx = WithSender(ctx, sender)
|
||||
ctx = WithRequestID(ctx, "req-123")
|
||||
ctx = WithSessionID(ctx, "sess-456")
|
||||
ctx = trace.WithSessionID(ctx, "sess-456")
|
||||
ctx = WithStartTime(ctx, time.Now())
|
||||
ctx = WithPipelineState(ctx, genLocalState(ctx))
|
||||
|
||||
@@ -214,7 +215,7 @@ func TestNewHistoryLambda_ReturnsNonNil(t *testing.T) {
|
||||
fetcher := func(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
lambda := NewHistoryLambda(fetcher, 10)
|
||||
lambda := NewHistoryLambda(fetcher, nil, 10)
|
||||
require.NotNil(t, lambda)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// ctxKeyStartTime 请求开始时间的 context key。
|
||||
@@ -33,7 +33,7 @@ func latencyFromCtx(ctx context.Context) int64 {
|
||||
// 历史消息追加由适配器负责(避免重复写入)。
|
||||
func NewDoneLambda(defaultModel string) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, _ struct{}) (PipelineOutput, error) {
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
sender := senderFromCtx(ctx)
|
||||
state := stateFromCtx(ctx)
|
||||
|
||||
@@ -70,13 +70,11 @@ func NewDoneLambda(defaultModel string) *compose.Lambda {
|
||||
}
|
||||
}
|
||||
if err := sender.SendLLMDone(done); err != nil {
|
||||
log.Errorw("发送 llm_done 失败", "error", err)
|
||||
log.Errorw("send llm_done failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infow("查询处理完成",
|
||||
"request_id", requestID,
|
||||
"response_length", len(fullResponse))
|
||||
log.Infow("query processing completed", "response_length", len(fullResponse))
|
||||
|
||||
return PipelineOutput{
|
||||
TranscribedText: transcribedText,
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/llm"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
||||
@@ -17,9 +18,13 @@ import (
|
||||
//
|
||||
// 从 PipelineState 读取请求元数据(SessionID、Scenario、ImageData 等),
|
||||
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
|
||||
func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error), maxHistory int) *compose.Lambda {
|
||||
func NewHistoryLambda(
|
||||
historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error),
|
||||
scenarioRepo store.UserScenarioRepository,
|
||||
maxHistory int,
|
||||
) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
// 从 State 读取请求元数据
|
||||
state := stateFromCtx(ctx)
|
||||
@@ -34,10 +39,31 @@ func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string,
|
||||
scenario := state.Scenario
|
||||
detailLevel := state.DetailLevel
|
||||
language := sttOut.Language
|
||||
userID := state.UserID
|
||||
state.mu.Unlock()
|
||||
|
||||
// 构建系统提示词
|
||||
scenarioPrompt := llm.GetScenarioPrompt(scenario, language)
|
||||
// 加载用户自建情景(如果有 userID 和 scenarioRepo)
|
||||
var customScenarios map[string]string
|
||||
var customGreetings map[string]string
|
||||
if userID != "" && scenarioRepo != nil {
|
||||
scenarios, err := scenarioRepo.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.Warnw("load user scenarios failed", "user_id", userID, "error", err)
|
||||
} else if len(scenarios) > 0 {
|
||||
customScenarios = make(map[string]string, len(scenarios))
|
||||
customGreetings = make(map[string]string, len(scenarios))
|
||||
for _, s := range scenarios {
|
||||
customScenarios[s.ID] = s.Prompt
|
||||
if s.Greeting != "" {
|
||||
customGreetings[s.ID] = s.Greeting
|
||||
}
|
||||
}
|
||||
log.Debugw("loaded user scenarios", "user_id", userID, "count", len(scenarios))
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(支持用户自建情景)
|
||||
scenarioPrompt := llm.GetScenarioPrompt(scenario, language, customScenarios)
|
||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||
|
||||
// 构建 system message(仅文本,多模态内容只能放在 user 角色)
|
||||
@@ -52,7 +78,7 @@ func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string,
|
||||
if historyFetcher != nil && sessionID != "" {
|
||||
history, err := historyFetcher(ctx, sessionID, maxHistory)
|
||||
if err != nil {
|
||||
log.Warnw("获取历史消息失败,继续处理", "error", err, "request_id", requestID)
|
||||
log.Warnw("fetch history failed, continuing", "error", err, "request_id", requestID)
|
||||
} else {
|
||||
for _, msg := range history {
|
||||
messages = append(messages, &schema.Message{
|
||||
@@ -95,8 +121,7 @@ func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string,
|
||||
})
|
||||
}
|
||||
|
||||
log.Infow("历史组装完成",
|
||||
"request_id", requestID,
|
||||
log.Debugw("history assembled",
|
||||
"message_count", len(messages),
|
||||
"has_image", len(imageData) > 0,
|
||||
"scenario", scenario)
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/stt"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/hhs/camtalk/internal/util"
|
||||
)
|
||||
|
||||
// NewSTTLambda 创建 STT Lambda 节点。
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
// 识别结果通过 Sender 发送 stt_result 到客户端。
|
||||
func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
|
||||
@@ -39,8 +40,9 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
|
||||
// 文本输入模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
log.Infow("使用文本输入,跳过 STT",
|
||||
"request_id", requestID, "text", input.Text)
|
||||
log.Debugw("text input mode, skipping stt",
|
||||
"text_len", len(input.Text),
|
||||
"text_preview", util.Truncate(input.Text, 50))
|
||||
|
||||
// 发送 stt_result 保持前端消息流一致性
|
||||
if sender != nil {
|
||||
@@ -50,7 +52,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
Text: input.Text,
|
||||
IsFinal: true,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 stt_result 失败", "error", err)
|
||||
log.Errorw("send stt_result failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +75,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
return STTOutput{}, fmt.Errorf("stt: no audio data provided")
|
||||
}
|
||||
|
||||
log.Infow("开始语音识别",
|
||||
"request_id", requestID, "audio_bytes", len(input.AudioData))
|
||||
log.Debugw("stt recognition started", "audio_bytes", len(input.AudioData))
|
||||
|
||||
// 调用 STT 服务
|
||||
text, err := sttService.Recognize(ctx, input.AudioData, stt.Options{
|
||||
@@ -83,7 +84,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorw("语音识别失败", "error", err, "request_id", requestID)
|
||||
log.Errorw("stt recognition failed", "error", err)
|
||||
if sender != nil {
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
@@ -97,11 +98,13 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
|
||||
// STT 返回空文本
|
||||
if strings.TrimSpace(text) == "" {
|
||||
log.Infow("语音识别结果为空", "request_id", requestID)
|
||||
log.Infow("stt returned empty text")
|
||||
text = "(未识别到语音)"
|
||||
}
|
||||
|
||||
log.Infow("语音识别完成", "request_id", requestID, "text", text)
|
||||
log.Debugw("stt recognition completed",
|
||||
"text_len", len(text),
|
||||
"text_preview", util.Truncate(text, 50))
|
||||
|
||||
// 发送 stt_result
|
||||
if sender != nil {
|
||||
@@ -111,7 +114,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
Text: text,
|
||||
IsFinal: true,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 stt_result 失败", "error", err)
|
||||
log.Errorw("send stt_result failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/tts"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// NewTTSLambda 创建 TTS Transform Lambda 节点。
|
||||
@@ -26,7 +26,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
defer sw.Close()
|
||||
defer input.Close()
|
||||
|
||||
log := logger.Log
|
||||
log := trace.FromContext(ctx)
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
|
||||
@@ -48,7 +48,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Errorw("TTS: stream recv error", "error", err, "request_id", requestID)
|
||||
log.Errorw("TTS: stream recv error", "error", err)
|
||||
break
|
||||
}
|
||||
if sentence != "" {
|
||||
@@ -61,7 +61,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
return
|
||||
}
|
||||
|
||||
log.Infow("开始 TTS 合成", "request_id", requestID, "sentence_count", len(sentences))
|
||||
log.Infow("开始 TTS 合成", "sentence_count", len(sentences))
|
||||
|
||||
// 将句子数组转为 channel
|
||||
sentenceCh := make(chan string, len(sentences))
|
||||
@@ -78,7 +78,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
SampleRate: ttsSampleRate,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorw("TTS 合成启动失败(已跳过)", "error", err, "request_id", requestID)
|
||||
log.Errorw("TTS 合成启动失败(已跳过)", "error", err)
|
||||
sw.Send(struct{}{}, nil)
|
||||
return
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
for chunk := range ttsStream {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Infow("TTS 流被中断", "request_id", requestID)
|
||||
log.Debugw("tts stream interrupted")
|
||||
sw.Send(struct{}{}, ctx.Err())
|
||||
return
|
||||
default:
|
||||
@@ -107,7 +107,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
||||
}
|
||||
}
|
||||
|
||||
log.Infow("TTS 合成完成", "request_id", requestID)
|
||||
log.Infow("TTS 合成完成")
|
||||
sw.Send(struct{}{}, nil)
|
||||
}()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type PipelineState struct {
|
||||
DetailLevel string
|
||||
Language string
|
||||
TTSEnabled bool
|
||||
UserID string // 新增:用户 ID,用于加载自建情景
|
||||
}
|
||||
|
||||
// genLocalState 创建每请求的 PipelineState 实例。
|
||||
|
||||
@@ -12,6 +12,7 @@ type PipelineInput struct {
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
TTSEnabled bool
|
||||
UserID string // 用户 ID,用于加载自建情景
|
||||
}
|
||||
|
||||
// PipelineOutput Graph 统一输出。
|
||||
|
||||
43
backend/internal/models/user_scenario.go
Normal file
43
backend/internal/models/user_scenario.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UserScenario 用户自建情景。
|
||||
type UserScenario struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt"`
|
||||
Greeting string `json:"greeting,omitempty"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateUserScenarioRequest 创建用户情景请求。
|
||||
type CreateUserScenarioRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||
Prompt string `json:"prompt" binding:"required,min=10,max=2000"`
|
||||
Greeting string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserScenarioRequest 更新用户情景请求。
|
||||
type UpdateUserScenarioRequest struct {
|
||||
Name *string `json:"name,omitempty" binding:"omitempty,min=2,max=50"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Description *string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||
Prompt *string `json:"prompt,omitempty" binding:"omitempty,min=10,max=2000"`
|
||||
Greeting *string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UserScenarioListResponse 用户情景列表响应。
|
||||
type UserScenarioListResponse struct {
|
||||
Scenarios []*UserScenario `json:"scenarios"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -14,13 +14,11 @@ type Orchestrator interface {
|
||||
// ctx 用于整体超时和中断控制。
|
||||
// sessionID 用于会话管理和历史获取。
|
||||
// req 包含图像和音频数据。
|
||||
// history 是最近的对话历史。
|
||||
// sender 用于向客户端推送消息。
|
||||
ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender Sender,
|
||||
) error
|
||||
}
|
||||
|
||||
172
backend/internal/ratelimit/bucket.go
Normal file
172
backend/internal/ratelimit/bucket.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
)
|
||||
|
||||
// TokenBucket 内存令牌桶,适用于单实例部署。
|
||||
type TokenBucket struct {
|
||||
capacity int // 桶容量
|
||||
rate float64 // 每秒填充令牌数
|
||||
tokens float64 // 当前令牌数
|
||||
lastRefill time.Time // 上次填充时间
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newTokenBucket 创建令牌桶。
|
||||
func newTokenBucket(capacity int, rate float64) *TokenBucket {
|
||||
return &TokenBucket{
|
||||
capacity: capacity,
|
||||
rate: rate,
|
||||
tokens: float64(capacity), // 初始满桶
|
||||
lastRefill: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// allow 尝试消耗一个令牌。
|
||||
func (b *TokenBucket) allow() (bool, time.Duration) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(b.lastRefill).Seconds()
|
||||
|
||||
// 补充令牌
|
||||
newTokens := elapsed * b.rate
|
||||
b.tokens = min(float64(b.capacity), b.tokens+newTokens)
|
||||
b.lastRefill = now
|
||||
|
||||
// 尝试消耗一个令牌
|
||||
if b.tokens >= 1 {
|
||||
b.tokens -= 1
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// 计算需要等待的时间
|
||||
if b.rate == 0 {
|
||||
// rate=0 时永远无法补充令牌
|
||||
return false, 24 * time.Hour // 返回一个很大的值
|
||||
}
|
||||
retryAfter := time.Duration((1-b.tokens)/b.rate*1000) * time.Millisecond
|
||||
return false, retryAfter
|
||||
}
|
||||
|
||||
// MemoryLimiter 管理多个用户的令牌桶。
|
||||
type MemoryLimiter struct {
|
||||
buckets map[string]*TokenBucket
|
||||
config config.RateLimitConfig
|
||||
mu sync.RWMutex
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewMemoryLimiter 创建内存限流器。
|
||||
func NewMemoryLimiter(cfg config.RateLimitConfig) *MemoryLimiter {
|
||||
limiter := &MemoryLimiter{
|
||||
buckets: make(map[string]*TokenBucket),
|
||||
config: cfg,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 启动后台清理 goroutine
|
||||
go limiter.cleanup()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Allow 实现 Limiter 接口。
|
||||
func (l *MemoryLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
||||
bucket := l.getOrCreateBucket(key)
|
||||
return bucket.allow()
|
||||
}
|
||||
|
||||
// Stop 实现 Limiter 接口。
|
||||
func (l *MemoryLimiter) Stop() {
|
||||
l.stopOnce.Do(func() {
|
||||
close(l.done)
|
||||
})
|
||||
}
|
||||
|
||||
// getOrCreateBucket 获取或创建令牌桶。
|
||||
func (l *MemoryLimiter) getOrCreateBucket(key string) *TokenBucket {
|
||||
// 先尝试读锁
|
||||
l.mu.RLock()
|
||||
bucket, exists := l.buckets[key]
|
||||
l.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return bucket
|
||||
}
|
||||
|
||||
// 需要创建新桶,升级为写锁
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// 双重检查(可能其他 goroutine 已创建)
|
||||
bucket, exists = l.buckets[key]
|
||||
if exists {
|
||||
return bucket
|
||||
}
|
||||
|
||||
// 根据 key 确定配置(简化版:假设 key 格式为 "userID:action")
|
||||
cfg := l.getBucketConfig(key)
|
||||
bucket = newTokenBucket(cfg.Capacity, cfg.Rate)
|
||||
l.buckets[key] = bucket
|
||||
|
||||
return bucket
|
||||
}
|
||||
|
||||
// getBucketConfig 根据 key 获取桶配置。
|
||||
func (l *MemoryLimiter) getBucketConfig(key string) config.BucketConfig {
|
||||
// 简化实现:从 key 后缀判断动作类型
|
||||
// 实际使用时调用方会传递正确的 key
|
||||
// 默认使用 query 配置
|
||||
return l.config.Query
|
||||
}
|
||||
|
||||
// cleanup 定期清理不活跃的桶。
|
||||
func (l *MemoryLimiter) cleanup() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
l.removeInactiveBuckets()
|
||||
case <-l.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeInactiveBuckets 移除超过 10 分钟无活动的桶。
|
||||
func (l *MemoryLimiter) removeInactiveBuckets() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, bucket := range l.buckets {
|
||||
bucket.mu.Lock()
|
||||
inactive := now.Sub(bucket.lastRefill) > 10*time.Minute
|
||||
bucket.mu.Unlock()
|
||||
|
||||
if inactive {
|
||||
delete(l.buckets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// min 返回两个 float64 中的较小值。
|
||||
func min(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 编译期接口检查
|
||||
var _ Limiter = (*MemoryLimiter)(nil)
|
||||
203
backend/internal/ratelimit/bucket_test.go
Normal file
203
backend/internal/ratelimit/bucket_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenBucket_Allow_FirstRequest(t *testing.T) {
|
||||
bucket := newTokenBucket(5, 0.2)
|
||||
|
||||
allowed, retryAfter := bucket.allow()
|
||||
|
||||
assert.True(t, allowed)
|
||||
assert.Equal(t, time.Duration(0), retryAfter)
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_ConsumeUntilEmpty(t *testing.T) {
|
||||
bucket := newTokenBucket(3, 0.2)
|
||||
|
||||
// 连续消耗 3 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := bucket.allow()
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个请求应被拒绝
|
||||
allowed, retryAfter := bucket.allow()
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_RetryAfterCorrect(t *testing.T) {
|
||||
bucket := newTokenBucket(1, 1.0) // 每秒 1 个令牌
|
||||
|
||||
// 消耗唯一的令牌
|
||||
allowed, _ := bucket.allow()
|
||||
require.True(t, allowed)
|
||||
|
||||
// 立即再次请求应被拒绝
|
||||
allowed, retryAfter := bucket.allow()
|
||||
assert.False(t, allowed)
|
||||
// retryAfter 应约为 1 秒(允许一定误差)
|
||||
assert.InDelta(t, 1000, retryAfter.Milliseconds(), 100)
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_RefillAfterWait(t *testing.T) {
|
||||
bucket := newTokenBucket(2, 10.0) // 每秒 10 个令牌(每 100ms 一个)
|
||||
|
||||
// 消耗 2 个令牌
|
||||
bucket.allow()
|
||||
bucket.allow()
|
||||
|
||||
// 等待 150ms,应补充至少 1 个令牌
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
allowed, _ := bucket.allow()
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_CapacityLimit(t *testing.T) {
|
||||
bucket := newTokenBucket(3, 1.0)
|
||||
|
||||
// 等待足够长时间让桶"溢出"
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 但最多只能消耗 capacity 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := bucket.allow()
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个应被拒绝
|
||||
allowed, _ := bucket.allow()
|
||||
assert.False(t, allowed)
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_ConcurrentSafe(t *testing.T) {
|
||||
bucket := newTokenBucket(100, 10.0)
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// 100 个并发请求
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
allowed, _ := bucket.allow()
|
||||
if allowed {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 应该正好 100 个成功(桶容量为 100)
|
||||
assert.Equal(t, 100, successCount)
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_ZeroCapacity(t *testing.T) {
|
||||
bucket := newTokenBucket(0, 1.0)
|
||||
|
||||
allowed, retryAfter := bucket.allow()
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestTokenBucket_Allow_ZeroRate(t *testing.T) {
|
||||
bucket := newTokenBucket(1, 0.0)
|
||||
|
||||
// 第一个通过
|
||||
allowed, _ := bucket.allow()
|
||||
assert.True(t, allowed)
|
||||
|
||||
// 第二个被拒绝,且 retryAfter 应为无限大(实际上会很大)
|
||||
allowed, retryAfter := bucket.allow()
|
||||
assert.False(t, allowed)
|
||||
// rate=0 时,retryAfter 理论上无限大,实际会是一个很大的值
|
||||
assert.Greater(t, retryAfter, 1*time.Hour)
|
||||
}
|
||||
|
||||
func TestMemoryLimiter_Allow_DifferentKeys(t *testing.T) {
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 2, Rate: 1.0},
|
||||
}
|
||||
limiter := NewMemoryLimiter(cfg)
|
||||
defer limiter.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// user1 消耗 2 个令牌
|
||||
allowed, _ := limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
|
||||
// user1 第 3 个被拒绝
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.False(t, allowed)
|
||||
|
||||
// user2 应该不受影响
|
||||
allowed, _ = limiter.Allow(ctx, "user2:query")
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestMemoryLimiter_Cleanup(t *testing.T) {
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewMemoryLimiter(cfg)
|
||||
defer limiter.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建一个桶
|
||||
limiter.Allow(ctx, "user1:query")
|
||||
|
||||
// 验证桶已创建
|
||||
limiter.mu.RLock()
|
||||
initialCount := len(limiter.buckets)
|
||||
limiter.mu.RUnlock()
|
||||
assert.Equal(t, 1, initialCount)
|
||||
|
||||
// 手动触发清理(模拟 10 分钟后)
|
||||
limiter.mu.Lock()
|
||||
for _, bucket := range limiter.buckets {
|
||||
bucket.mu.Lock()
|
||||
bucket.lastRefill = time.Now().Add(-11 * time.Minute)
|
||||
bucket.mu.Unlock()
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
|
||||
limiter.removeInactiveBuckets()
|
||||
|
||||
// 验证桶已被清理
|
||||
limiter.mu.RLock()
|
||||
finalCount := len(limiter.buckets)
|
||||
limiter.mu.RUnlock()
|
||||
assert.Equal(t, 0, finalCount)
|
||||
}
|
||||
|
||||
func TestMemoryLimiter_Stop(t *testing.T) {
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewMemoryLimiter(cfg)
|
||||
|
||||
// 多次调用 Stop 不应 panic
|
||||
limiter.Stop()
|
||||
limiter.Stop()
|
||||
}
|
||||
17
backend/internal/ratelimit/limiter.go
Normal file
17
backend/internal/ratelimit/limiter.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter 速率限制器接口。
|
||||
type Limiter interface {
|
||||
// Allow 判断 key 是否允许执行一次操作。
|
||||
// key 通常为 "userID:action" 格式。
|
||||
// 返回 (allowed, retryAfter)。retryAfter 表示需要等待的时间。
|
||||
Allow(ctx context.Context, key string) (bool, time.Duration)
|
||||
|
||||
// Stop 停止限流器,清理资源(如后台 goroutine)。
|
||||
Stop()
|
||||
}
|
||||
51
backend/internal/ratelimit/middleware.go
Normal file
51
backend/internal/ratelimit/middleware.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// Middleware 返回 Gin 中间件,按 key 维度限流。
|
||||
// keyFunc 从请求中提取限流 key(如 IP、用户 ID)。
|
||||
func Middleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if limiter == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := keyFunc(c)
|
||||
if key == "" {
|
||||
// key 为空时跳过限流
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
allowed, retryAfter := limiter.Allow(c.Request.Context(), key)
|
||||
|
||||
if !allowed {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
log.Warnw("rate limited",
|
||||
"client_ip", c.ClientIP(),
|
||||
"path", c.Request.URL.Path,
|
||||
"limit_key", key,
|
||||
"retry_after_sec", int(retryAfter.Seconds()+0.5))
|
||||
|
||||
// 设置 Retry-After header(秒)
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": "RATE_LIMITED",
|
||||
"message": fmt.Sprintf("too many requests, retry after %s", retryAfter.Round(1)),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
196
backend/internal/ratelimit/middleware_test.go
Normal file
196
backend/internal/ratelimit/middleware_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockLimiter 用于测试的 mock 限流器。
|
||||
type mockLimiter struct {
|
||||
allowFunc func(ctx context.Context, key string) (bool, time.Duration)
|
||||
}
|
||||
|
||||
func (m *mockLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
||||
if m.allowFunc != nil {
|
||||
return m.allowFunc(ctx, key)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (m *mockLimiter) Stop() {}
|
||||
|
||||
// 编译期接口检查
|
||||
var _ Limiter = (*mockLimiter)(nil)
|
||||
|
||||
func TestMiddleware_Allow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
limiter := &mockLimiter{
|
||||
allowFunc: func(ctx context.Context, key string) (bool, time.Duration) {
|
||||
return true, 0
|
||||
},
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(limiter, func(c *gin.Context) string {
|
||||
return "user1:test"
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp["status"])
|
||||
}
|
||||
|
||||
func TestMiddleware_Deny(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
limiter := &mockLimiter{
|
||||
allowFunc: func(ctx context.Context, key string) (bool, time.Duration) {
|
||||
return false, 5 * time.Second
|
||||
},
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(limiter, func(c *gin.Context) string {
|
||||
return "user1:test"
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 验证返回 429
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
|
||||
// 验证 Retry-After header
|
||||
assert.Equal(t, "5", w.Header().Get("Retry-After"))
|
||||
|
||||
// 验证响应体
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "RATE_LIMITED", resp["code"])
|
||||
assert.Contains(t, resp["message"], "retry after")
|
||||
}
|
||||
|
||||
func TestMiddleware_NilLimiter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(nil, func(c *gin.Context) string {
|
||||
return "user1:test"
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// nil limiter 应该放行
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_EmptyKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
limiter := &mockLimiter{
|
||||
allowFunc: func(ctx context.Context, key string) (bool, time.Duration) {
|
||||
// 不应该被调用
|
||||
t.Error("Allow should not be called with empty key")
|
||||
return false, 0
|
||||
},
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(limiter, func(c *gin.Context) string {
|
||||
return "" // 返回空 key
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 空 key 应该放行
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_KeyFunc(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var capturedKey string
|
||||
limiter := &mockLimiter{
|
||||
allowFunc: func(ctx context.Context, key string) (bool, time.Duration) {
|
||||
capturedKey = key
|
||||
return true, 0
|
||||
},
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(limiter, func(c *gin.Context) string {
|
||||
// 从 query 参数提取 user_id
|
||||
userID := c.Query("user_id")
|
||||
return userID + ":test"
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test?user_id=user123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "user123:test", capturedKey)
|
||||
}
|
||||
|
||||
func TestMiddleware_RetryAfterRounding(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
limiter := &mockLimiter{
|
||||
allowFunc: func(ctx context.Context, key string) (bool, time.Duration) {
|
||||
return false, 2500 * time.Millisecond // 2.5 秒
|
||||
},
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(Middleware(limiter, func(c *gin.Context) string {
|
||||
return "user1:test"
|
||||
}))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
// 2.5 秒向上取整为 3 秒
|
||||
assert.Equal(t, "3", w.Header().Get("Retry-After"))
|
||||
}
|
||||
132
backend/internal/ratelimit/redis_bucket.go
Normal file
132
backend/internal/ratelimit/redis_bucket.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// luaScript 是 Redis 令牌桶算法的 Lua 脚本。
|
||||
// 保证原子性:读取-计算-回写在一个事务中完成。
|
||||
const luaScript = `
|
||||
-- KEYS[1] = 限流 key
|
||||
-- ARGV[1] = capacity(桶容量)
|
||||
-- ARGV[2] = rate(每秒填充数)
|
||||
-- ARGV[3] = now(当前时间戳,秒,浮点)
|
||||
-- ARGV[4] = ttl(key 过期时间,秒)
|
||||
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local ttl = tonumber(ARGV[4])
|
||||
|
||||
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
|
||||
local tokens = tonumber(data[1]) or capacity
|
||||
local last_refill = tonumber(data[2]) or now
|
||||
|
||||
-- 计算新令牌
|
||||
local elapsed = math.max(0, now - last_refill)
|
||||
tokens = math.min(capacity, tokens + elapsed * rate)
|
||||
|
||||
local allowed = 0
|
||||
local retry_after = 0
|
||||
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
allowed = 1
|
||||
else
|
||||
if rate == 0 then
|
||||
retry_after = 86400 -- 24小时
|
||||
else
|
||||
retry_after = (1 - tokens) / rate
|
||||
end
|
||||
end
|
||||
|
||||
-- 回写状态
|
||||
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
|
||||
return {allowed, tostring(retry_after)}
|
||||
`
|
||||
|
||||
// RedisLimiter Redis 令牌桶限流器。
|
||||
type RedisLimiter struct {
|
||||
client *redis.Client
|
||||
config config.RateLimitConfig
|
||||
script *redis.Script
|
||||
}
|
||||
|
||||
// NewRedisLimiter 创建 Redis 限流器。
|
||||
func NewRedisLimiter(client *redis.Client, cfg config.RateLimitConfig) *RedisLimiter {
|
||||
return &RedisLimiter{
|
||||
client: client,
|
||||
config: cfg,
|
||||
script: redis.NewScript(luaScript),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 实现 Limiter 接口。
|
||||
func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
||||
log := trace.FromContext(ctx)
|
||||
cfg := l.getBucketConfig(key)
|
||||
|
||||
now := float64(time.Now().UnixNano()) / 1e9 // 秒,浮点
|
||||
ttl := 600 // key 过期时间 10 分钟
|
||||
|
||||
result, err := l.script.Run(ctx, l.client, []string{key},
|
||||
cfg.Capacity, cfg.Rate, now, ttl).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Errorw("rate limit check failed", "key", key, "error", err)
|
||||
// Redis 错误时降级:允许请求(fail-open 策略)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// 解析返回值
|
||||
vals, ok := result.([]interface{})
|
||||
if !ok || len(vals) != 2 {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
allowed, _ := vals[0].(int64)
|
||||
retryAfterStr, _ := vals[1].(string)
|
||||
retryAfterSec, _ := strconv.ParseFloat(retryAfterStr, 64)
|
||||
|
||||
if allowed == 1 {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
retryAfter := time.Duration(retryAfterSec*1000) * time.Millisecond
|
||||
log.Warnw("rate limit triggered", "key", key, "retry_after_sec", retryAfterSec)
|
||||
return false, retryAfter
|
||||
}
|
||||
|
||||
// Stop 实现 Limiter 接口(Redis 不需要清理资源)。
|
||||
func (l *RedisLimiter) Stop() {
|
||||
// Redis 客户端由外部管理,这里不需要操作
|
||||
}
|
||||
|
||||
// getBucketConfig 根据 key 获取桶配置。
|
||||
func (l *RedisLimiter) getBucketConfig(key string) config.BucketConfig {
|
||||
// 简化实现:默认使用 query 配置
|
||||
return l.config.Query
|
||||
}
|
||||
|
||||
// KeyPrefix 返回限流 key 的前缀。
|
||||
func KeyPrefix() string {
|
||||
return "ratelimit:"
|
||||
}
|
||||
|
||||
// FormatKey 格式化限流 key。
|
||||
func FormatKey(userID, action string) string {
|
||||
return fmt.Sprintf("%s%s:%s", KeyPrefix(), userID, action)
|
||||
}
|
||||
|
||||
// 编译期接口检查
|
||||
var _ Limiter = (*RedisLimiter)(nil)
|
||||
228
backend/internal/ratelimit/redis_bucket_test.go
Normal file
228
backend/internal/ratelimit/redis_bucket_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupMiniRedis 创建一个内存 Redis 实例用于测试。
|
||||
func setupMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
client.Close()
|
||||
mr.Close()
|
||||
})
|
||||
|
||||
return mr, client
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_FirstRequest(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 5, Rate: 0.2},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
allowed, retryAfter := limiter.Allow(ctx, "user1:query")
|
||||
|
||||
assert.True(t, allowed)
|
||||
assert.Equal(t, time.Duration(0), retryAfter)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_ConsumeUntilEmpty(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 3, Rate: 0.2},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 连续消耗 3 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个请求应被拒绝
|
||||
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_DifferentKeys(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 2, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// user1 消耗 2 个令牌
|
||||
allowed, _ := limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
|
||||
// user1 第 3 个被拒绝
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.False(t, allowed)
|
||||
|
||||
// user2 应该不受影响
|
||||
allowed, _ = limiter.Allow(ctx, "user2:query")
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_RefillAfterWait(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 2, Rate: 10.0}, // 每秒 10 个令牌
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 消耗 2 个令牌
|
||||
limiter.Allow(ctx, key)
|
||||
limiter.Allow(ctx, key)
|
||||
|
||||
// 真实等待 150ms(Lua 脚本使用系统时间)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// 应该补充了至少 1 个令牌
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_CapacityLimit(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 3, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 真实等待让桶"溢出"
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 但最多只能消耗 capacity 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个应被拒绝
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_ZeroRate(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 0.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 第一个通过
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed)
|
||||
|
||||
// 第二个被拒绝,retryAfter 应该很大
|
||||
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, 1*time.Hour)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_KeyTTL(t *testing.T) {
|
||||
mr, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 5, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 第一次请求
|
||||
limiter.Allow(ctx, key)
|
||||
|
||||
// 验证 key 已设置 TTL
|
||||
ttl := mr.TTL(key)
|
||||
assert.Greater(t, ttl, time.Duration(0))
|
||||
assert.LessOrEqual(t, ttl, 600*time.Second)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_FailOpen(t *testing.T) {
|
||||
mr, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 关闭 Redis 模拟故障
|
||||
mr.Close()
|
||||
|
||||
// 应该 fail-open(允许请求)
|
||||
allowed, retryAfter := limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
assert.Equal(t, time.Duration(0), retryAfter)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Stop(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
// Stop 应该不会 panic(即使多次调用)
|
||||
limiter.Stop()
|
||||
limiter.Stop()
|
||||
}
|
||||
|
||||
func TestFormatKey(t *testing.T) {
|
||||
key := FormatKey("user123", "query")
|
||||
assert.Equal(t, "ratelimit:user123:query", key)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type ConversationSummary struct {
|
||||
Title string `json:"title"`
|
||||
LastMessage string `json:"last_message"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -142,11 +142,11 @@ func (m *MemoryManager) Create(ctx context.Context, userID string, config models
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步写 PG
|
||||
// Write-Through:异步写 PG(使用 Background context,避免 HTTP 请求结束后 context 被取消)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
cfgJSON, _ := json.Marshal(config)
|
||||
if err := m.sessRepo.Save(ctx, store.SessionRecord{
|
||||
if err := m.sessRepo.Save(context.Background(), store.SessionRecord{
|
||||
ID: id, UserID: userID, Title: models.DefaultSessionTitle,
|
||||
Config: cfgJSON, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
@@ -204,11 +204,11 @@ func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patc
|
||||
cfg := entry.session.Config
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
// Write-Through:异步更新 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
cfgJSON, _ := json.Marshal(cfg)
|
||||
if err := m.sessRepo.UpdateConfig(ctx, sessionID, cfgJSON); err != nil {
|
||||
if err := m.sessRepo.UpdateConfig(context.Background(), sessionID, cfgJSON); err != nil {
|
||||
logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
@@ -233,10 +233,10 @@ func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title
|
||||
entry.lastActive = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
// Write-Through:异步更新 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
if err := m.sessRepo.UpdateTitle(ctx, sessionID, title); err != nil {
|
||||
if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, title); err != nil {
|
||||
logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
@@ -271,6 +271,7 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz
|
||||
list = append(list, ConversationSummary{
|
||||
ID: rec.ID,
|
||||
Title: rec.Title,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
sessionIDs = append(sessionIDs, rec.ID)
|
||||
@@ -320,6 +321,7 @@ func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string,
|
||||
summary := ConversationSummary{
|
||||
ID: entry.session.ID,
|
||||
Title: entry.session.Title,
|
||||
CreatedAt: entry.session.CreatedAt,
|
||||
UpdatedAt: entry.lastActive,
|
||||
}
|
||||
summary.MessageCount = len(entry.history)
|
||||
@@ -394,8 +396,10 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
|
||||
entry.history = append(entry.history, msg)
|
||||
|
||||
// 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符
|
||||
titleUpdated := false
|
||||
if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle {
|
||||
entry.session.Title = generateTitle(msg.Content)
|
||||
titleUpdated = true
|
||||
}
|
||||
|
||||
// 超过上限时裁剪,保留最新的 maxHistory 条
|
||||
@@ -406,13 +410,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
|
||||
now := time.Now()
|
||||
entry.lastActive = now
|
||||
entry.session.UpdatedAt = now
|
||||
|
||||
// 复制标题(释放锁后安全使用)
|
||||
persistTitle := entry.session.Title
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步写冷存储,不阻塞调用方
|
||||
// Write-Through:消息同步写入 PostgreSQL(保证调用顺序 = 插入顺序,
|
||||
// 避免用户消息和 AI 消息的异步 goroutine 执行顺序不确定导致排序错乱)
|
||||
if m.msgRepo != nil {
|
||||
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
|
||||
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write-Through:异步更新会话元数据(标题 + updated_at)到 PostgreSQL
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
|
||||
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
|
||||
if titleUpdated {
|
||||
if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, persistTitle); err != nil {
|
||||
logger.Log.Warnw("persist session title failed", "session", sessionID, "error", err)
|
||||
}
|
||||
} else {
|
||||
// 即使标题没变,也要刷新 updated_at(保证列表排序正确)
|
||||
if err := m.sessRepo.Touch(context.Background(), sessionID); err != nil {
|
||||
logger.Log.Warnw("touch session in DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -540,10 +562,10 @@ func (m *MemoryManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
delete(m.sessions, sessionID)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步删除 PG
|
||||
// Write-Through:异步删除 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
if err := m.sessRepo.Delete(ctx, sessionID); err != nil {
|
||||
if err := m.sessRepo.Delete(context.Background(), sessionID); err != nil {
|
||||
logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
"github.com/hhs/camtalk/internal/util"
|
||||
)
|
||||
|
||||
// RedisManager 基于 Redis 的 SessionManager 实现。
|
||||
@@ -87,7 +88,8 @@ func (m *RedisManager) CreateWithID(ctx context.Context, id string, userID strin
|
||||
return "", fmt.Errorf("redis create session: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Debugw("redis session created", "session", id, "user_id", userID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Debugw("redis session created", "session_id", id, "user_id", userID)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -96,8 +98,11 @@ const placeholderHistoryMark = "__placeholder__"
|
||||
|
||||
// Get 获取会话。
|
||||
func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
vals, err := m.rdb.HGetAll(ctx, metaKey(sessionID)).Result()
|
||||
if err != nil {
|
||||
log.Errorw("redis get session failed", "session_id", sessionID, "error", err)
|
||||
return nil, fmt.Errorf("redis get session: %w", err)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
@@ -115,6 +120,7 @@ func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Sessi
|
||||
sess.Config.DetailLevel = vals["config.detail_level"]
|
||||
sess.Config.Language = vals["config.language"]
|
||||
|
||||
log.Debugw("redis session retrieved", "session_id", sessionID)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
@@ -150,7 +156,9 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
|
||||
|
||||
// 刷新 TTL
|
||||
m.rdb.Expire(ctx, metaKey(sessionID), m.ttl)
|
||||
logger.Log.Debugw("redis session config updated", "session", sessionID)
|
||||
|
||||
log := trace.FromContext(ctx)
|
||||
log.Debugw("redis session config updated", "session_id", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -170,7 +178,9 @@ func (m *RedisManager) UpdateTitle(ctx context.Context, sessionID string, title
|
||||
}
|
||||
|
||||
m.rdb.Expire(ctx, metaKey(sessionID), m.ttl)
|
||||
logger.Log.Debugw("redis session title updated", "session", sessionID, "title", title)
|
||||
|
||||
log := trace.FromContext(ctx)
|
||||
log.Debugw("redis session title updated", "session_id", sessionID, "title", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,7 +295,11 @@ func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit i
|
||||
}
|
||||
var msg models.Message
|
||||
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
||||
logger.Log.Warnw("invalid history entry", "session", sessionID, "raw", raw)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("invalid history entry",
|
||||
"session_id", sessionID,
|
||||
"raw_len", len(raw),
|
||||
"raw_preview", util.Truncate(raw, 100))
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
@@ -436,7 +450,8 @@ func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
m.rdb.SRem(ctx, userSessKey(userID), sessionID)
|
||||
}
|
||||
|
||||
logger.Log.Debugw("redis session destroyed", "session", sessionID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Debugw("redis session destroyed", "session_id", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
172
backend/internal/store/cached_user.go
Normal file
172
backend/internal/store/cached_user.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// Redis key 前缀。
|
||||
const (
|
||||
refreshTokenPrefix = "auth:refresh:" // auth:refresh:{token_hash} → user_id
|
||||
userRefreshPrefix = "auth:user_refresh:" // auth:user_refresh:{user_id} → Set of token_hash
|
||||
)
|
||||
|
||||
// CachedUserRepository 装饰器,为 UserRepository 的 refresh token 操作增加 Redis 缓存。
|
||||
// 读路径:Redis miss → DB → 回填 Redis。
|
||||
// 写路径:同步双写 Redis + DB。
|
||||
// 删路径:同步双删 Redis + DB。
|
||||
// Redis 操作失败时降级到纯 DB,不阻断主流程。
|
||||
type CachedUserRepository struct {
|
||||
inner UserRepository
|
||||
rdb *redis.Client
|
||||
backfillTTL time.Duration // DB 回填 Redis 时使用的默认 TTL
|
||||
}
|
||||
|
||||
// NewCachedUserRepository 创建带 Redis 缓存的 UserRepository 装饰器。
|
||||
// backfillTTL: 从 DB 回填 Redis 时使用的 TTL(因 DB 接口不返回 expiresAt)。
|
||||
func NewCachedUserRepository(inner UserRepository, rdb *redis.Client, backfillTTL time.Duration) *CachedUserRepository {
|
||||
if backfillTTL <= 0 {
|
||||
backfillTTL = 24 * time.Hour
|
||||
}
|
||||
return &CachedUserRepository{
|
||||
inner: inner,
|
||||
rdb: rdb,
|
||||
backfillTTL: backfillTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// refreshTokenKey 生成 refresh token 的 Redis key。
|
||||
func refreshTokenKey(tokenHash string) string {
|
||||
return refreshTokenPrefix + tokenHash
|
||||
}
|
||||
|
||||
// userRefreshKey 生成用户 refresh token 集合的 Redis key。
|
||||
func userRefreshKey(userID string) string {
|
||||
return userRefreshPrefix + userID
|
||||
}
|
||||
|
||||
// --- 委托方法(不做缓存) ---
|
||||
|
||||
func (r *CachedUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) {
|
||||
return r.inner.Create(ctx, username, passwordHash)
|
||||
}
|
||||
|
||||
func (r *CachedUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||
return r.inner.FindByUsername(ctx, username)
|
||||
}
|
||||
|
||||
func (r *CachedUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
return r.inner.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// --- 缓存方法 ---
|
||||
|
||||
// SaveRefreshToken Write-Through:先写 DB,再写 Redis。
|
||||
func (r *CachedUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
// 先写 DB
|
||||
if err := r.inner.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写 Redis(SET + SADD),设置 TTL 为 token 剩余有效期
|
||||
ttl := time.Until(expiresAt)
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := refreshTokenKey(tokenHash)
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Set(ctx, key, userID, ttl)
|
||||
pipe.SAdd(ctx, userRefreshKey(userID), tokenHash)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("redis cache write failed for refresh token", "error", err)
|
||||
// 降级:DB 已写入成功,Redis 失败不影响正确性
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindRefreshToken Read-Through:先查 Redis,miss 时查 DB 并回填。
|
||||
func (r *CachedUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||
key := refreshTokenKey(tokenHash)
|
||||
|
||||
// 查 Redis
|
||||
userID, err := r.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return userID, nil
|
||||
}
|
||||
// redis.Nil 表示 key 不存在,其他错误记录日志后降级到 DB
|
||||
if err != redis.Nil {
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("redis cache read failed for refresh token", "error", err)
|
||||
}
|
||||
|
||||
// 降级到 DB
|
||||
userID, err = r.inner.FindRefreshToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 回填 Redis(SET + SADD),TTL 使用保守默认值
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Set(bgCtx, key, userID, r.backfillTTL)
|
||||
pipe.SAdd(bgCtx, userRefreshKey(userID), tokenHash)
|
||||
_, _ = pipe.Exec(bgCtx)
|
||||
}()
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// DeleteRefreshToken 双删:先删 DB,再删 Redis。
|
||||
func (r *CachedUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
// 先从 Redis 获取 user_id(用于从集合中移除)
|
||||
userID, _ := r.rdb.Get(ctx, refreshTokenKey(tokenHash)).Result()
|
||||
|
||||
// 删 DB
|
||||
if err := r.inner.DeleteRefreshToken(ctx, tokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删 Redis
|
||||
key := refreshTokenKey(tokenHash)
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Del(ctx, key)
|
||||
if userID != "" {
|
||||
pipe.SRem(ctx, userRefreshKey(userID), tokenHash)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("redis cache delete failed for refresh token", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserRefreshTokens 批量清理:先从 Redis 获取集合,逐个删缓存,再删 DB。
|
||||
func (r *CachedUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error {
|
||||
userKey := userRefreshKey(userID)
|
||||
|
||||
// 从 Redis 获取该用户所有 token hash
|
||||
hashes, _ := r.rdb.SMembers(ctx, userKey).Result()
|
||||
|
||||
// 批量删除 Redis 缓存
|
||||
if len(hashes) > 0 {
|
||||
keys := make([]string, 0, len(hashes)+1)
|
||||
for _, h := range hashes {
|
||||
keys = append(keys, refreshTokenKey(h))
|
||||
}
|
||||
keys = append(keys, userKey)
|
||||
if err := r.rdb.Del(ctx, keys...).Err(); err != nil {
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("redis cache batch delete failed for user refresh tokens", "error", err, "user_id", userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 删 DB(无论 Redis 是否成功都执行)
|
||||
return r.inner.DeleteUserRefreshTokens(ctx, userID)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// PgMessageRepository 基于 PostgreSQL 的 MessageRepository 实现。
|
||||
@@ -21,14 +22,24 @@ func NewPgMessageRepository(pool *pgxpool.Pool) *PgMessageRepository {
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`,
|
||||
sessionID, msg.Role, msg.Content, tokensUsed,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
log.Errorw("save message failed", "session_id", sessionID, "role", msg.Role, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("message saved", "session_id", sessionID, "role", msg.Role, "tokens_used", tokensUsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -56,6 +67,7 @@ func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("get messages failed", "session_id", sessionID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -64,31 +76,39 @@ func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string,
|
||||
rows[i], rows[j] = rows[j], rows[i]
|
||||
}
|
||||
|
||||
log.Debugw("messages retrieved", "session_id", sessionID, "count", len(rows))
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, args ...any) ([]StoredMessage, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
pgxRows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
log.Errorw("query messages failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
defer pgxRows.Close()
|
||||
|
||||
var messages []StoredMessage
|
||||
messages := make([]StoredMessage, 0)
|
||||
for pgxRows.Next() {
|
||||
var m StoredMessage
|
||||
if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil {
|
||||
log.Errorw("scan message row failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if err := pgxRows.Err(); err != nil {
|
||||
log.Errorw("iterate message rows failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var m StoredMessage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, session_id, role, content, tokens_used, created_at
|
||||
@@ -102,24 +122,34 @@ func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID stri
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("get last message failed", "session_id", sessionID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugw("last message retrieved", "session_id", sessionID, "message_id", m.ID)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
log.Errorw("get message count failed", "session_id", sessionID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Debugw("message count retrieved", "session_id", sessionID, "count", count)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
if len(sessionIDs) == 0 {
|
||||
return map[string]SessionMessageStats{}, nil
|
||||
}
|
||||
@@ -143,6 +173,7 @@ func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessio
|
||||
sessionIDs,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("get session message stats failed", "session_count", len(sessionIDs), "error", err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -152,12 +183,16 @@ func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessio
|
||||
var sid string
|
||||
var stats SessionMessageStats
|
||||
if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil {
|
||||
log.Errorw("scan message stats row failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
result[sid] = stats
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Errorw("iterate message stats rows failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugw("session message stats retrieved", "session_count", len(sessionIDs), "result_count", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// PgSessionRepository 基于 PostgreSQL 的 SessionRepository 实现。
|
||||
@@ -19,6 +21,8 @@ func NewPgSessionRepository(pool *pgxpool.Pool) *PgSessionRepository {
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO sessions (id, user_id, title, config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
@@ -28,10 +32,18 @@ func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
s.ID, s.UserID, s.Title, s.Config, s.CreatedAt, s.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
log.Errorw("save session failed", "session_id", s.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("session saved", "session_id", s.ID, "user_id", s.UserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) FindByID(ctx context.Context, id string) (*SessionRecord, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var s SessionRecord
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, title, config, created_at, updated_at
|
||||
@@ -41,12 +53,17 @@ func (r *PgSessionRepository) FindByID(ctx context.Context, id string) (*Session
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find session failed", "session_id", id, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugw("session found", "session_id", id)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
@@ -60,6 +77,7 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM sessions WHERE user_id = $1`, userID,
|
||||
).Scan(&total); err != nil {
|
||||
log.Errorw("count user sessions failed", "user_id", userID, "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -73,6 +91,7 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
||||
userID, size, offset,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("find user sessions failed", "user_id", userID, "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -81,66 +100,90 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
||||
for rows.Next() {
|
||||
var s SessionRecord
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
log.Errorw("scan session row failed", "user_id", userID, "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Errorw("iterate session rows failed", "user_id", userID, "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
log.Debugw("user sessions found", "user_id", userID, "count", len(list), "total", total)
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) UpdateTitle(ctx context.Context, id string, title string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET title = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, title,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("update session title failed", "session_id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
log.Debugw("session title updated", "session_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) UpdateConfig(ctx context.Context, id string, configJSON []byte) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET config = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, configJSON,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("update session config failed", "session_id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
log.Debugw("session config updated", "session_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Touch(ctx context.Context, id string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET updated_at = NOW() WHERE id = $1`, id,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("touch session failed", "session_id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
log.Debugw("session touched", "session_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Delete(ctx context.Context, id string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM sessions WHERE id = $1`, id,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("delete session failed", "session_id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
log.Debugw("session deleted", "session_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// PgUserRepository 基于 PostgreSQL 的 UserRepository 实现。
|
||||
@@ -20,18 +22,25 @@ func NewPgUserRepository(pool *pgxpool.Pool) *PgUserRepository {
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
|
||||
username, passwordHash,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
log.Errorw("create user failed", "username", username, "error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Debugw("user created", "user_id", id, "username", username)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var u User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1`,
|
||||
@@ -41,12 +50,17 @@ func (r *PgUserRepository) FindByUsername(ctx context.Context, username string)
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find user by username failed", "username", username, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugw("user found by username", "user_id", u.ID, "username", username)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var u User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1`,
|
||||
@@ -56,20 +70,33 @@ func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, erro
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find user by id failed", "user_id", id, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugw("user found by id", "user_id", id)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, tokenHash, expiresAt,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
log.Errorw("save refresh token failed", "user_id", userID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("refresh token saved", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
var userID string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`,
|
||||
@@ -79,23 +106,42 @@ func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash strin
|
||||
return "", ErrRefreshTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find refresh token failed", "error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Debugw("refresh token found", "user_id", userID)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
log.Errorw("delete refresh token failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("refresh token deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
log.Errorw("delete user refresh tokens failed", "user_id", userID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("user refresh tokens deleted", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
276
backend/internal/store/user_scenario_repository.go
Normal file
276
backend/internal/store/user_scenario_repository.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// UserScenarioRepository 用户自建情景仓储接口。
|
||||
type UserScenarioRepository interface {
|
||||
Create(ctx context.Context, scenario *models.UserScenario) error
|
||||
FindByID(ctx context.Context, id string) (*models.UserScenario, error)
|
||||
FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error)
|
||||
Update(ctx context.Context, scenario *models.UserScenario) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
CountByUserID(ctx context.Context, userID string) (int, error)
|
||||
}
|
||||
|
||||
// PostgresUserScenarioRepo PostgreSQL 实现。
|
||||
type PostgresUserScenarioRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresUserScenarioRepo 创建 PostgreSQL 用户情景仓储。
|
||||
func NewPostgresUserScenarioRepo(pool *pgxpool.Pool) UserScenarioRepository {
|
||||
return &PostgresUserScenarioRepo{pool: pool}
|
||||
}
|
||||
|
||||
// Create 创建用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.UserScenario) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `
|
||||
INSERT INTO user_scenarios (id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, NULLIF($7, ''), $8, $9, $10)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
scenario.CreatedAt = now
|
||||
scenario.UpdatedAt = now
|
||||
|
||||
if scenario.ID == "" {
|
||||
scenario.ID = uuid.New().String()
|
||||
}
|
||||
if scenario.Icon == "" {
|
||||
scenario.Icon = "✨"
|
||||
}
|
||||
if scenario.Language == "" {
|
||||
scenario.Language = "zh-CN"
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
scenario.ID,
|
||||
scenario.UserID,
|
||||
scenario.Name,
|
||||
scenario.Icon,
|
||||
scenario.Description,
|
||||
scenario.Prompt,
|
||||
scenario.Greeting,
|
||||
scenario.Language,
|
||||
scenario.CreatedAt,
|
||||
scenario.UpdatedAt,
|
||||
).Scan(&scenario.ID, &scenario.CreatedAt, &scenario.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Errorw("create user scenario failed", "user_id", scenario.UserID, "name", scenario.Name, "error", err)
|
||||
return fmt.Errorf("create user scenario: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenario created", "scenario_id", scenario.ID, "user_id", scenario.UserID, "name", scenario.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByID 根据 ID 查找情景。
|
||||
func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*models.UserScenario, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var scenario models.UserScenario
|
||||
err := r.pool.QueryRow(ctx, query, id).Scan(
|
||||
&scenario.ID,
|
||||
&scenario.UserID,
|
||||
&scenario.Name,
|
||||
&scenario.Icon,
|
||||
&scenario.Description,
|
||||
&scenario.Prompt,
|
||||
&scenario.Greeting,
|
||||
&scenario.Language,
|
||||
&scenario.CreatedAt,
|
||||
&scenario.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("user scenario not found: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find user scenario failed", "scenario_id", id, "error", err)
|
||||
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenario found", "scenario_id", id)
|
||||
return &scenario, nil
|
||||
}
|
||||
|
||||
// FindByIDAndUserID 根据 ID 和用户 ID 查找情景(权限校验)。
|
||||
func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
var scenario models.UserScenario
|
||||
err := r.pool.QueryRow(ctx, query, id, userID).Scan(
|
||||
&scenario.ID,
|
||||
&scenario.UserID,
|
||||
&scenario.Name,
|
||||
&scenario.Icon,
|
||||
&scenario.Description,
|
||||
&scenario.Prompt,
|
||||
&scenario.Greeting,
|
||||
&scenario.Language,
|
||||
&scenario.CreatedAt,
|
||||
&scenario.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("user scenario not found or no permission")
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find user scenario by id and user failed", "scenario_id", id, "user_id", userID, "error", err)
|
||||
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenario found by id and user", "scenario_id", id, "user_id", userID)
|
||||
return &scenario, nil
|
||||
}
|
||||
|
||||
// FindByUserID 查找用户的所有情景。
|
||||
func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
log.Errorw("find user scenarios failed", "user_id", userID, "error", err)
|
||||
return nil, fmt.Errorf("find user scenarios: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scenarios []*models.UserScenario
|
||||
for rows.Next() {
|
||||
var s models.UserScenario
|
||||
err := rows.Scan(
|
||||
&s.ID,
|
||||
&s.UserID,
|
||||
&s.Name,
|
||||
&s.Icon,
|
||||
&s.Description,
|
||||
&s.Prompt,
|
||||
&s.Greeting,
|
||||
&s.Language,
|
||||
&s.CreatedAt,
|
||||
&s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorw("scan user scenario row failed", "user_id", userID, "error", err)
|
||||
return nil, fmt.Errorf("scan user scenario: %w", err)
|
||||
}
|
||||
scenarios = append(scenarios, &s)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Errorw("iterate user scenarios failed", "user_id", userID, "error", err)
|
||||
return nil, fmt.Errorf("iterate user scenarios: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenarios found", "user_id", userID, "count", len(scenarios))
|
||||
return scenarios, nil
|
||||
}
|
||||
|
||||
// Update 更新用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.UserScenario) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `
|
||||
UPDATE user_scenarios
|
||||
SET name = $1, icon = $2, description = $3, prompt = $4, greeting = $5, language = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
scenario.UpdatedAt = time.Now()
|
||||
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
scenario.Name,
|
||||
scenario.Icon,
|
||||
scenario.Description,
|
||||
scenario.Prompt,
|
||||
scenario.Greeting,
|
||||
scenario.Language,
|
||||
scenario.UpdatedAt,
|
||||
scenario.ID,
|
||||
scenario.UserID,
|
||||
).Scan(&scenario.UpdatedAt)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return fmt.Errorf("user scenario not found or no permission")
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("update user scenario failed", "scenario_id", scenario.ID, "user_id", scenario.UserID, "error", err)
|
||||
return fmt.Errorf("update user scenario: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenario updated", "scenario_id", scenario.ID, "user_id", scenario.UserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Delete(ctx context.Context, id string) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `DELETE FROM user_scenarios WHERE id = $1`
|
||||
|
||||
result, err := r.pool.Exec(ctx, query, id)
|
||||
if err != nil {
|
||||
log.Errorw("delete user scenario failed", "scenario_id", id, "error", err)
|
||||
return fmt.Errorf("delete user scenario: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("user scenario not found")
|
||||
}
|
||||
|
||||
log.Debugw("user scenario deleted", "scenario_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUserID 统计用户的情景数量。
|
||||
func (r *PostgresUserScenarioRepo) CountByUserID(ctx context.Context, userID string) (int, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
query := `SELECT COUNT(*) FROM user_scenarios WHERE user_id = $1`
|
||||
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, query, userID).Scan(&count)
|
||||
if err != nil {
|
||||
log.Errorw("count user scenarios failed", "user_id", userID, "error", err)
|
||||
return 0, fmt.Errorf("count user scenarios: %w", err)
|
||||
}
|
||||
|
||||
log.Debugw("user scenarios counted", "user_id", userID, "count", count)
|
||||
return count, nil
|
||||
}
|
||||
46
backend/internal/trace/context.go
Normal file
46
backend/internal/trace/context.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package trace
|
||||
|
||||
import "context"
|
||||
|
||||
type traceIDKey struct{}
|
||||
type requestIDKey struct{}
|
||||
type sessionIDKey struct{}
|
||||
|
||||
// WithTraceID 将 trace ID 注入 context(连接级/会话级标识)
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey{}, traceID)
|
||||
}
|
||||
|
||||
// GetTraceID 从 context 提取 trace ID
|
||||
func GetTraceID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(traceIDKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithRequestID 将 request ID 注入 context(单次请求/查询标识)
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey{}, requestID)
|
||||
}
|
||||
|
||||
// GetRequestID 从 context 提取 request ID
|
||||
func GetRequestID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithSessionID 将 session ID 注入 context(会话存储标识)
|
||||
func WithSessionID(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, sessionIDKey{}, sessionID)
|
||||
}
|
||||
|
||||
// GetSessionID 从 context 提取 session ID
|
||||
func GetSessionID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(sessionIDKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
42
backend/internal/trace/eino_test.go
Normal file
42
backend/internal/trace/eino_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
func TestEinoContextPropagation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testTraceID := "01J5TEST123456789"
|
||||
ctx = trace.WithTraceID(ctx, testTraceID)
|
||||
|
||||
var capturedTraceID string
|
||||
|
||||
g := compose.NewGraph[string, string]()
|
||||
g.AddLambdaNode("test_node", compose.InvokableLambda(
|
||||
func(ctx context.Context, input string) (string, error) {
|
||||
capturedTraceID = trace.GetTraceID(ctx)
|
||||
return "ok", nil
|
||||
},
|
||||
))
|
||||
g.AddEdge(compose.START, "test_node")
|
||||
g.AddEdge("test_node", compose.END)
|
||||
|
||||
runnable, err := g.Compile(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("compile failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = runnable.Invoke(ctx, "test_input")
|
||||
if err != nil {
|
||||
t.Fatalf("invoke failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedTraceID != testTraceID {
|
||||
t.Errorf("trace_id lost in Eino propagation: got %q, want %q",
|
||||
capturedTraceID, testTraceID)
|
||||
}
|
||||
}
|
||||
63
backend/internal/trace/gin_logger.go
Normal file
63
backend/internal/trace/gin_logger.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GinLogger 记录每个 HTTP 请求的 method/path/status/latency
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
status := c.Writer.Status()
|
||||
log := FromContext(c.Request.Context())
|
||||
|
||||
fields := []interface{}{
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency_ms", latency,
|
||||
"client_ip", c.ClientIP(),
|
||||
}
|
||||
if query != "" {
|
||||
fields = append(fields, "query", query)
|
||||
}
|
||||
if errStr := c.Errors.String(); errStr != "" {
|
||||
fields = append(fields, "errors", errStr)
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
log.Errorw("request completed", fields...)
|
||||
case status >= 400:
|
||||
log.Warnw("request completed", fields...)
|
||||
default:
|
||||
log.Infow("request completed", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GinRecovery 自定义 panic 恢复中间件,使用 zap 记录
|
||||
func GinRecovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log := FromContext(c.Request.Context())
|
||||
log.Errorw("panic recovered",
|
||||
"error", err,
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
"client_ip", c.ClientIP())
|
||||
c.AbortWithStatus(500)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
22
backend/internal/trace/id.go
Normal file
22
backend/internal/trace/id.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var entropyPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return ulid.Monotonic(cryptorand.Reader, 0)
|
||||
},
|
||||
}
|
||||
|
||||
// GenerateTraceID 生成并发安全的 ULID trace ID
|
||||
func GenerateTraceID() string {
|
||||
entropy := entropyPool.Get().(*ulid.MonotonicEntropy)
|
||||
defer entropyPool.Put(entropy)
|
||||
return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
|
||||
}
|
||||
25
backend/internal/trace/logger.go
Normal file
25
backend/internal/trace/logger.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// FromContext 返回自动附加 trace_id/request_id/session_id 的 logger
|
||||
func FromContext(ctx context.Context) *zap.SugaredLogger {
|
||||
log := logger.Log
|
||||
|
||||
if traceID := GetTraceID(ctx); traceID != "" {
|
||||
log = log.With("trace_id", traceID)
|
||||
}
|
||||
if requestID := GetRequestID(ctx); requestID != "" {
|
||||
log = log.With("request_id", requestID)
|
||||
}
|
||||
if sessionID := GetSessionID(ctx); sessionID != "" {
|
||||
log = log.With("session_id", sessionID)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
17
backend/internal/trace/middleware.go
Normal file
17
backend/internal/trace/middleware.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package trace
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// TraceMiddleware 为每个 HTTP 请求生成 trace ID 并注入 context
|
||||
func TraceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
traceID := GenerateTraceID()
|
||||
ctx := WithTraceID(c.Request.Context(), traceID)
|
||||
ctx = WithRequestID(ctx, traceID) // REST: trace_id == request_id
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Header("X-Trace-ID", traceID) // 返回给客户端用于排查
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
9
backend/internal/util/string.go
Normal file
9
backend/internal/util/string.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package util
|
||||
|
||||
// Truncate 截断字符串到指定长度,超出部分用 "..." 替换
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ws
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -10,13 +11,16 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/llm"
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/hhs/camtalk/internal/errors"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/ratelimit"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// newUpgrader 根据配置创建 WebSocket upgrader。
|
||||
@@ -40,12 +44,12 @@ func newUpgrader(cfg *config.Config) websocket.Upgrader {
|
||||
|
||||
// Client 代表一个 WebSocket 客户端连接。
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
sessionMgr session.Manager
|
||||
orchestrator orchestrator.Orchestrator
|
||||
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
sessionMgr session.Manager
|
||||
orchestrator orchestrator.Orchestrator
|
||||
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SendJSON 向客户端发送 JSON 消息(公开以便 errors 包调用)。
|
||||
@@ -92,21 +96,19 @@ func (w *WSClient) SendError(err models.WsError) error {
|
||||
}
|
||||
|
||||
// ServeWS 处理 WebSocket 升级请求。
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager) gin.HandlerFunc {
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) 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) {
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr)
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr, limiter, scenarioRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) {
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) {
|
||||
|
||||
// --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) ---
|
||||
token := c.Query("token")
|
||||
@@ -132,9 +134,20 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
}
|
||||
}
|
||||
|
||||
// 生成连接级 trace ID(整个 WebSocket 生命周期使用)
|
||||
ctx := c.Request.Context()
|
||||
traceID := trace.GetTraceID(ctx)
|
||||
if traceID == "" {
|
||||
// 如果 REST 中间件未生成(不应发生),fallback 生成
|
||||
traceID = trace.GenerateTraceID()
|
||||
ctx = trace.WithTraceID(ctx, traceID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Errorw("websocket upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
@@ -143,13 +156,17 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
var sessionID string
|
||||
if conversationID != "" {
|
||||
sessionID = conversationID
|
||||
logger.Log.Infow("resuming conversation", "session", sessionID, "user_id", userID)
|
||||
ctx = trace.WithSessionID(ctx, sessionID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Infow("resuming conversation", "user_id", userID)
|
||||
} else {
|
||||
sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig())
|
||||
if err != nil {
|
||||
logger.Log.Errorw("create session failed", "error", err)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Errorw("create session failed", "error", err)
|
||||
return
|
||||
}
|
||||
ctx = trace.WithSessionID(ctx, sessionID)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
@@ -166,7 +183,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
SessionID: sessionID,
|
||||
ServerVersion: version,
|
||||
})
|
||||
logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Infow("client connected", "user_id", userID, "username", username)
|
||||
|
||||
// 心跳检测
|
||||
lastPong := time.Now()
|
||||
@@ -184,7 +202,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if time.Since(lastPong) > heartbeatTimeout {
|
||||
logger.Log.Warnw("heartbeat timeout", "session", sessionID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("heartbeat timeout")
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
@@ -199,7 +218,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
logger.Log.Warnw("ws read error", "error", err)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("ws read error", "error", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -224,23 +244,36 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
errors.SendWSError(client, errors.CodeInvalidMessage, msg.RequestID, err)
|
||||
continue
|
||||
}
|
||||
logger.Log.Infow("query received", "session", sessionID, "request", msg.RequestID)
|
||||
|
||||
// 注入 request ID 到 context
|
||||
queryCtx := trace.WithRequestID(ctx, msg.RequestID)
|
||||
log := trace.FromContext(queryCtx)
|
||||
log.Infow("query received", "has_image", msg.Image != "", "has_audio", msg.Audio != "")
|
||||
|
||||
// 限流检查
|
||||
if limiter != nil {
|
||||
key := fmt.Sprintf("%s:query", userID)
|
||||
allowed, retryAfter := limiter.Allow(context.Background(), key)
|
||||
if !allowed {
|
||||
log.Warnw("rate limited", "user_id", userID, "retry_after", retryAfter)
|
||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新会话 TTL
|
||||
if err := client.sessionMgr.Touch(context.Background(), sessionID); err != nil {
|
||||
logger.Log.Warnw("touch session failed", "session", sessionID, "error", err)
|
||||
log.Warnw("touch session failed", "error", err)
|
||||
}
|
||||
|
||||
// 标记活跃请求
|
||||
if err := client.sessionMgr.SetActiveRequest(context.Background(), sessionID, msg.RequestID); err != nil {
|
||||
logger.Log.Warnw("set active request failed", "session", sessionID, "error", err)
|
||||
log.Warnw("set active request failed", "error", err)
|
||||
}
|
||||
|
||||
// 获取对话历史
|
||||
history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory)
|
||||
|
||||
// 创建可取消的 context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
processCtx, cancel := context.WithCancel(queryCtx)
|
||||
client.mu.Lock()
|
||||
client.cancelFuncs[msg.RequestID] = cancel
|
||||
client.mu.Unlock()
|
||||
@@ -260,8 +293,9 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
|
||||
}()
|
||||
|
||||
if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, history, sender); err != nil {
|
||||
logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err)
|
||||
if err := client.orchestrator.ProcessQuery(processCtx, sessionID, msg, sender); err != nil {
|
||||
log := trace.FromContext(processCtx)
|
||||
log.Errorw("process query failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -282,10 +316,66 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
errors.SendWSError(client, errors.CodeInternalError, "", err)
|
||||
continue
|
||||
}
|
||||
logger.Log.Infow("config updated", "session", sessionID)
|
||||
|
||||
scenarioID := ""
|
||||
if msg.Payload.Scenario != nil {
|
||||
scenarioID = *msg.Payload.Scenario
|
||||
}
|
||||
log := trace.FromContext(ctx)
|
||||
log.Infow("config updated", "scenario", scenarioID)
|
||||
|
||||
// 如果切换了情景(非自由对话),返回首句引导
|
||||
if scenarioID != "" && scenarioID != "free_chat" {
|
||||
sess, err := client.sessionMgr.Get(context.Background(), sessionID)
|
||||
if err == nil && sess != nil {
|
||||
// 加载用户自建情景
|
||||
var customGreetings map[string]string
|
||||
if sess.UserID != "" && scenarioRepo != nil {
|
||||
scenarios, err := scenarioRepo.FindByUserID(context.Background(), sess.UserID)
|
||||
if err == nil && len(scenarios) > 0 {
|
||||
customGreetings = make(map[string]string, len(scenarios))
|
||||
for _, s := range scenarios {
|
||||
if s.Greeting != "" {
|
||||
customGreetings[s.ID] = s.Greeting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language, customGreetings)
|
||||
if greeting != "" {
|
||||
// 发送首句作为 AI 消息
|
||||
_ = client.SendJSON(models.WsLLMChunk{
|
||||
Type: "llm_chunk",
|
||||
RequestID: "scenario_greeting",
|
||||
Delta: greeting,
|
||||
Role: "assistant",
|
||||
})
|
||||
|
||||
doneMsg := models.WsLLMDone{
|
||||
Type: "llm_done",
|
||||
RequestID: "scenario_greeting",
|
||||
FullText: greeting,
|
||||
Model: "",
|
||||
LatencyMs: 0,
|
||||
}
|
||||
doneMsg.TokensUsed.Prompt = 0
|
||||
doneMsg.TokensUsed.Completion = 0
|
||||
doneMsg.TokensUsed.Total = 0
|
||||
_ = client.SendJSON(doneMsg)
|
||||
|
||||
// 追加首句到历史记录
|
||||
_ = client.sessionMgr.AppendMessage(context.Background(), sessionID, models.Message{
|
||||
Role: "assistant",
|
||||
Content: greeting,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "interrupt":
|
||||
logger.Log.Infow("interrupt received", "session", sessionID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Infow("interrupt received")
|
||||
|
||||
// 获取活跃请求 ID 并取消
|
||||
reqID, _ := client.sessionMgr.GetActiveRequestID(context.Background(), sessionID)
|
||||
@@ -313,12 +403,14 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
// 取消所有活跃请求
|
||||
client.mu.Lock()
|
||||
for reqID, cancel := range client.cancelFuncs {
|
||||
logger.Log.Infow("canceling active request on disconnect", "session", sessionID, "request", reqID)
|
||||
log := trace.FromContext(ctx)
|
||||
log.Infow("canceling active request on disconnect", "request", reqID)
|
||||
cancel()
|
||||
}
|
||||
client.cancelFuncs = make(map[string]context.CancelFunc)
|
||||
client.mu.Unlock()
|
||||
|
||||
// 断开连接时不销毁会话,让其自然过期(支持重连恢复)
|
||||
logger.Log.Infow("client disconnected", "session", sessionID)
|
||||
log = trace.FromContext(ctx)
|
||||
log.Infow("client disconnected")
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"context"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
@@ -48,7 +48,6 @@ func (m *MockOrchestrator) ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender orchestrator.Sender,
|
||||
) error {
|
||||
if m.Err != nil {
|
||||
@@ -149,7 +148,7 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
|
||||
@@ -222,9 +221,9 @@ func TestWS_QueryFullFlow(t *testing.T) {
|
||||
imageB64 := base64.StdEncoding.EncodeToString([]byte("fake-image-data"))
|
||||
|
||||
mock := &MockOrchestrator{
|
||||
STTResult: "你好,世界",
|
||||
LLMDeltas: []string{"你好", ",世界!"},
|
||||
TTSAudios: []string{base64.StdEncoding.EncodeToString([]byte("mp3-data-1")), base64.StdEncoding.EncodeToString([]byte("mp3-data-2"))},
|
||||
STTResult: "你好,世界",
|
||||
LLMDeltas: []string{"你好", ",世界!"},
|
||||
TTSAudios: []string{base64.StdEncoding.EncodeToString([]byte("mp3-data-1")), base64.StdEncoding.EncodeToString([]byte("mp3-data-2"))},
|
||||
}
|
||||
|
||||
srv, wsURL := setupTestServer(t, mock)
|
||||
@@ -333,7 +332,7 @@ func TestWS_UnknownMessageType(t *testing.T) {
|
||||
err := conn.WriteJSON(map[string]string{"type": "unknown_type"})
|
||||
require.NoError(t, err)
|
||||
|
||||
errMsg := readJSON(t, conn)
|
||||
errMsg := readJSON(t, conn)
|
||||
assert.Equal(t, "error", errMsg["type"])
|
||||
assert.Equal(t, "INVALID_MESSAGE", errMsg["code"])
|
||||
assert.Contains(t, errMsg["message"], "unknown message type")
|
||||
@@ -592,7 +591,7 @@ func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
return srv, tokenMgr, sessionMgr
|
||||
@@ -643,7 +642,7 @@ func TestWS_AuthExpiredToken(t *testing.T) {
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr))
|
||||
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr, nil, nil))
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- 用户名索引(用于登录查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- 表和列注释
|
||||
COMMENT ON TABLE users IS '用户表,存储系统所有注册用户的基本信息';
|
||||
COMMENT ON COLUMN users.id IS '用户唯一标识符 (UUID)';
|
||||
COMMENT ON COLUMN users.username IS '用户名,最大 64 字符,全局唯一';
|
||||
COMMENT ON COLUMN users.password_hash IS '密码哈希值,使用 bcrypt 算法(cost=10)';
|
||||
COMMENT ON COLUMN users.created_at IS '用户注册时间';
|
||||
COMMENT ON COLUMN users.updated_at IS '用户信息最后更新时间';
|
||||
|
||||
-- Refresh Token 表
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -24,3 +32,11 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token
|
||||
|
||||
-- 用户 ID 索引(用于登出所有设备)
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
|
||||
-- 表和列注释
|
||||
COMMENT ON TABLE refresh_tokens IS 'Refresh Token 表,用于 JWT 双 token 机制的长期身份验证';
|
||||
COMMENT ON COLUMN refresh_tokens.id IS 'Token 唯一标识符 (UUID)';
|
||||
COMMENT ON COLUMN refresh_tokens.user_id IS '所属用户 ID,外键关联 users 表,用户删除时级联删除';
|
||||
COMMENT ON COLUMN refresh_tokens.token_hash IS 'Token 哈希值,使用 SHA-256 算法,十六进制编码 (64 字符)';
|
||||
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Token 过期时间,默认有效期 7 天';
|
||||
COMMENT ON COLUMN refresh_tokens.created_at IS 'Token 创建时间';
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL,
|
||||
role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system"
|
||||
role VARCHAR(10) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT check_tokens_non_negative CHECK (tokens_used >= 0)
|
||||
);
|
||||
|
||||
-- 按会话查询消息(分页核心索引)
|
||||
@@ -15,3 +17,12 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id_created_at
|
||||
-- 按会话查询最后一条消息
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc
|
||||
ON messages(session_id, id DESC);
|
||||
|
||||
-- 表和列注释
|
||||
COMMENT ON TABLE messages IS '消息表,存储所有会话的消息记录';
|
||||
COMMENT ON COLUMN messages.id IS '消息唯一标识符,自增序列';
|
||||
COMMENT ON COLUMN messages.session_id IS '所属会话 ID,关联 sessions 表';
|
||||
COMMENT ON COLUMN messages.role IS '消息角色,可选值: ''user'' (用户), ''assistant'' (AI 助手), ''system'' (系统)';
|
||||
COMMENT ON COLUMN messages.content IS '消息内容,无长度限制';
|
||||
COMMENT ON COLUMN messages.tokens_used IS '消息消耗的 token 数量,用于计费统计';
|
||||
COMMENT ON COLUMN messages.created_at IS '消息创建时间';
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
title VARCHAR(256) NOT NULL DEFAULT '新对话',
|
||||
title VARCHAR(100) NOT NULL DEFAULT '新对话',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT check_title_length CHECK (char_length(title) >= 1 AND char_length(title) <= 100)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON sessions (user_id, updated_at DESC);
|
||||
|
||||
-- 表和列注释
|
||||
COMMENT ON TABLE sessions IS '会话表,存储用户的对话会话信息';
|
||||
COMMENT ON COLUMN sessions.id IS '会话唯一标识符 (UUID)';
|
||||
COMMENT ON COLUMN sessions.user_id IS '所属用户 ID,关联 users 表';
|
||||
COMMENT ON COLUMN sessions.title IS '会话标题,默认为"新对话",长度 1-100 字符';
|
||||
COMMENT ON COLUMN sessions.config IS '会话配置 (JSONB),包含: tts_enabled (布尔), detail_level (''low''/''high''), language (语言代码), scenario (情景 ID)';
|
||||
COMMENT ON COLUMN sessions.created_at IS '会话创建时间';
|
||||
COMMENT ON COLUMN sessions.updated_at IS '会话最后更新时间';
|
||||
|
||||
6
backend/migrations/004_user_scenarios.down.sql
Normal file
6
backend/migrations/004_user_scenarios.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 004_user_scenarios.down.sql
|
||||
-- 回滚用户自建情景表
|
||||
|
||||
DROP INDEX IF EXISTS idx_user_scenarios_created_at;
|
||||
DROP INDEX IF EXISTS idx_user_scenarios_user_id;
|
||||
DROP TABLE IF EXISTS user_scenarios;
|
||||
40
backend/migrations/004_user_scenarios.up.sql
Normal file
40
backend/migrations/004_user_scenarios.up.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- 004_user_scenarios.up.sql
|
||||
-- 用户自建情景表
|
||||
|
||||
CREATE TABLE user_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
icon VARCHAR(20) DEFAULT '✨',
|
||||
description VARCHAR(100),
|
||||
prompt TEXT NOT NULL,
|
||||
greeting VARCHAR(500),
|
||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||
CONSTRAINT check_description_length CHECK (description IS NULL OR char_length(description) <= 100),
|
||||
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 10),
|
||||
CONSTRAINT check_greeting_length CHECK (greeting IS NULL OR char_length(greeting) <= 500)
|
||||
);
|
||||
|
||||
-- 为用户 ID 创建索引,加速查询
|
||||
CREATE INDEX idx_user_scenarios_user_id ON user_scenarios(user_id);
|
||||
|
||||
-- 为创建时间创建索引,用于排序
|
||||
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||
|
||||
-- 表和列注释
|
||||
COMMENT ON TABLE user_scenarios IS '用户自建情景表,存储用户创建的 AI 对话情景配置';
|
||||
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识符 (UUID)';
|
||||
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID,外键关联 users 表,用户删除时级联删除';
|
||||
COMMENT ON COLUMN user_scenarios.name IS '情景名称 (2-50 字符),如"创意写作导师"';
|
||||
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标 (最多 20 字符),支持复合 Emoji,如"🎨"';
|
||||
COMMENT ON COLUMN user_scenarios.description IS '简短描述 (最多 100 字符),可选,显示在情景卡片上';
|
||||
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt (最少 10 字符,无上限),定义 AI 行为和对话风格';
|
||||
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导 (最多 500 字符),可选,AI 的开场白';
|
||||
COMMENT ON COLUMN user_scenarios.language IS '默认语言代码 (如 zh-CN、en-US、ja-JP)';
|
||||
COMMENT ON COLUMN user_scenarios.created_at IS '情景创建时间';
|
||||
COMMENT ON COLUMN user_scenarios.updated_at IS '情景最后更新时间';
|
||||
21
deploy.sh
21
deploy.sh
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# .env 固定路径(独立于项目目录,保证持久性)
|
||||
# .env 固定路径(act_runner 容器已挂载 /opt/camtalk)
|
||||
ENV_FILE="/opt/camtalk/.env"
|
||||
|
||||
# 颜色输出
|
||||
@@ -15,18 +15,13 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
|
||||
# .env 检查:首次部署时从 .env.example 复制模板,提示用户填写
|
||||
check_env() {
|
||||
local env_example="$PROJECT_DIR/backend/.env.example"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
if [ -f "$env_example" ]; then
|
||||
cp "$env_example" "$ENV_FILE"
|
||||
info "未找到 $ENV_FILE,已从 .env.example 复制模板"
|
||||
echo " 请编辑 $ENV_FILE 填入实际配置后重新运行本脚本"
|
||||
exit 0
|
||||
else
|
||||
echo "错误: $ENV_FILE 和 .env.example 均不存在,请手动创建"
|
||||
exit 1
|
||||
fi
|
||||
echo "=============================================="
|
||||
echo " 错误: 未找到环境变量文件"
|
||||
echo " 路径: $ENV_FILE"
|
||||
echo " 模板参考: backend/.env.example"
|
||||
echo "=============================================="
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -98,4 +93,4 @@ case "${1:-}" in
|
||||
logs) shift; cmd_logs "$@" ;;
|
||||
status) cmd_status ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
esac
|
||||
@@ -20,6 +20,8 @@ services:
|
||||
env_file:
|
||||
- /opt/camtalk/.env
|
||||
environment:
|
||||
# 运行环境(强制生产环境)
|
||||
- APP_ENV=prod
|
||||
# 三级存储配置(敏感信息通过 env_file 注入)
|
||||
- CAMTALK_STORAGE_REDIS_ENABLED=${CAMTALK_STORAGE_REDIS_ENABLED:-true}
|
||||
- CAMTALK_STORAGE_PERSISTENCE_ENABLED=${CAMTALK_STORAGE_PERSISTENCE_ENABLED:-true}
|
||||
|
||||
178
docs/01-架构设计.md
178
docs/01-架构设计.md
@@ -199,35 +199,70 @@ graph LR
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| WebSocket Handler | 管理客户端连接生命周期,JWT 认证,conversation_id 恢复,单播消息推送 |
|
||||
| Session Manager | 维护用户会话状态、对话历史。Memory(默认)/ Redis(可切换),30 分钟 TTL,Write-Through 到 PG |
|
||||
| Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排。7 节点 DAG(STT→History→ChatModel→Msg2Str→Splitter→TTS→Done),Stream 模式调用,Callback 实现 LLM token 实时推送 |
|
||||
| AI Orchestrator | `EinoOrchestrator` 适配器,包装 Eino Graph 实现 `Orchestrator` 接口。context 取消 + 超时控制 |
|
||||
| Session Manager | 维护用户会话状态、对话历史,三级存储架构,30 分钟 TTL |
|
||||
| Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排,7 节点 DAG 流水线,Stream 模式调用 |
|
||||
| AI Orchestrator | EinoOrchestrator 适配器,包装 Eino Graph 实现 Orchestrator 接口 |
|
||||
| AI Service Layer | AI 服务抽象层,多 provider 支持(Deepgram/MiMo/OpenAI 等) |
|
||||
| Auth | 用户认证与授权。JWT (HS256) 双 token 轮转,bcrypt 密码哈希,Gin 中间件 |
|
||||
| Store | 持久化存储层。UserRepository / MessageRepository / SessionRepository,内存 + PostgreSQL 双实现 |
|
||||
| Auth | 用户认证与授权,JWT 双 token 轮转,bcrypt 密码哈希 |
|
||||
| Store | 持久化存储层,Repository 接口与实现(内存 + PostgreSQL) |
|
||||
| REST API | 健康检查、认证、对话管理端点 |
|
||||
| Logger | Zap 结构化日志 |
|
||||
| Models | 数据模型定义 |
|
||||
| Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 |
|
||||
| Migrations | 数据库版本化迁移 |
|
||||
| Model Router | 根据请求类型选择 AI 模型(待实现) |
|
||||
| Rate Limiter | 令牌桶限流(待实现) |
|
||||
| Rate Limiter | 令牌桶限流,详见 [11-令牌桶限流.md](./11-令牌桶限流.md) |
|
||||
|
||||
## 前端组件
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| AuthPage | 登录/注册表单 |
|
||||
| LandingPage | 未登录时的着陆页,内嵌 LoginModal 登录/注册弹窗 |
|
||||
| CameraManager | 摄像头流采集 |
|
||||
| MicManager | 麦克风音频采集 |
|
||||
| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) |
|
||||
| WebSocketManager | WS 连接生命周期管理 |
|
||||
| EdgeProcessor | VAD + 关键帧检测 |
|
||||
| WebSocketManager | WebSocket 连接生命周期管理 |
|
||||
| ChatPanel | 消息展示、流式回复、文本输入、场景选择 |
|
||||
| VideoPreview | 摄像头画面预览 |
|
||||
| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除) |
|
||||
| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言、场景、账户) |
|
||||
| Toast | 轻量通知提示(3 秒自动消失) |
|
||||
| SessionSidebar | 左侧对话列表(搜索、重命名、删除、时间分组) |
|
||||
| ConfigPanel | 右侧配置面板(主题、TTS 开关、detail level、语言、场景、登出) |
|
||||
| Toast | 轻量通知提示 |
|
||||
|
||||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。
|
||||
核心 Hook:`useVisionSession()` 封装完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。`useSessionList()` 通过 REST API 管理对话列表 CRUD。
|
||||
|
||||
### 前端会话状态模型(三态)
|
||||
|
||||
前端 UI 存在三个会话状态,由 `isConnected` 和 `isCameraOn` 联合决定:
|
||||
|
||||
```
|
||||
┌──────────┐ startSession() ┌──────────┐
|
||||
│ initial │ ──────────────────→ │ video │
|
||||
│ 初始态 │ │ 视频通话 │
|
||||
└──────────┘ └──────────┘
|
||||
↑ │
|
||||
│ stopSession() stopVideo()
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────┐
|
||||
└──────────────────────── │ textOnly │
|
||||
│ 文字对话 │
|
||||
└──────────┘
|
||||
│
|
||||
startSession()
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ video │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
| 状态 | 条件 | WebSocket | 摄像头 | 消息 | 文字输入 |
|
||||
|------|------|-----------|--------|------|---------|
|
||||
| `initial` | `!isConnected && messages.length === 0` | 断开 | 关闭 | 空 | 可用(自动连接) |
|
||||
| `video` | `isConnected && isCameraOn` | 连接 | 开启 | 有 | 可用 |
|
||||
| `textOnly` | `isConnected && !isCameraOn` | 连接 | 关闭 | 保留 | 可用 |
|
||||
|
||||
- **`stopVideo()`**:停止摄像头/麦克风/VAD,保持 WebSocket 连接和消息历史,用户可继续文字对话
|
||||
- **`stopSession()`**:完全断开 WebSocket、清空消息、重置状态,回到初始态
|
||||
|
||||
## 数据库设计
|
||||
|
||||
@@ -274,139 +309,56 @@ erDiagram
|
||||
}
|
||||
```
|
||||
|
||||
### 表结构
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 会话表
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(128) DEFAULT '新对话',
|
||||
config JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 消息表
|
||||
CREATE TABLE messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 刷新令牌表
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(256) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
```
|
||||
系统采用关系型数据库存储持久化数据,包括用户账户、对话会话、消息记录和刷新令牌。数据库表定义详见 `backend/migrations/` 目录下的 SQL 迁移文件。
|
||||
|
||||
### 存储策略
|
||||
|
||||
| 场景 | 存储方案 | 说明 |
|
||||
|------|---------|------|
|
||||
| 默认 | Memory(进程内) | 零依赖,快速启动。MemoryManager 支持 Write-Through 到 PG |
|
||||
| 持久化 | Memory + PostgreSQL | 通过 `storage.persistence.enabled: true` 启用,MemoryManager 注入 PG Repository |
|
||||
| 多实例 | Redis(独立) | 通过配置切换到 RedisManager,适合多实例部署 |
|
||||
| 三级存储 | TieredManager | L1 Memory → L2 Redis → L3 PostgreSQL,自动降级 |
|
||||
系统采用**三级存储架构**(TieredManager)实现会话状态管理,平衡性能与可靠性:
|
||||
|
||||
**三级存储架构**(`TieredManager`):
|
||||
- **L1 Memory**:进程内缓存,提供微秒级读写性能
|
||||
- **L2 Redis**:分布式缓存层,支持多实例部署,提供毫秒级访问
|
||||
- **L3 PostgreSQL**:持久化存储层,确保数据可靠性
|
||||
|
||||
```
|
||||
TieredManager
|
||||
├── L1: Memory(进程内缓存,微秒级读写)
|
||||
├── L2: Redis(分布式缓存,毫秒级读写)
|
||||
└── L3: PostgreSQL(持久化存储,冷数据)
|
||||
```
|
||||
|
||||
- **读取路径**:L1 → L2 → L3,逐级回源,命中后向上回填
|
||||
- **写入路径**:L1 → L2(同步) → L3(异步)
|
||||
- **健康检查**:后台 goroutine 每 30 秒 ping Redis,故障时自动降级为 L1+L3 模式
|
||||
- **冷热分离**:L1/L2 存"热数据"(当前对话上下文),L3 存"冷数据"(历史记录)
|
||||
会话数据按 TTL(默认 30 分钟)在三级存储间流转,支持 Redis 故障时自动降级到 Memory + PostgreSQL 模式。配置灵活,可根据部署规模选择单级(Memory)、双级(Memory + PostgreSQL)或完整三级存储方案。
|
||||
|
||||
## 认证设计
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 客户端
|
||||
participant G as Go 网关
|
||||
participant DB as PostgreSQL
|
||||
系统采用 **JWT 双 token 轮转认证机制**,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略。
|
||||
|
||||
Note over C,DB: 注册流程
|
||||
C->>G: POST /api/auth/register {username, password}
|
||||
G->>G: bcrypt hash 密码
|
||||
G->>DB: INSERT users
|
||||
G->>G: 生成 access_token + refresh_token
|
||||
G->>DB: 存 SHA256(refresh_token)
|
||||
G-->>C: {user, access_token, refresh_token}
|
||||
|
||||
Note over C,DB: 登录流程
|
||||
C->>G: POST /api/auth/login {username, password}
|
||||
G->>DB: 查 users by username
|
||||
G->>G: bcrypt.CompareHashAndPassword
|
||||
G->>G: 生成 token pair
|
||||
G->>DB: 存 SHA256(refresh_token)
|
||||
G-->>C: {user, access_token, refresh_token}
|
||||
|
||||
Note over C,DB: Token 刷新(轮转)
|
||||
C->>G: POST /api/auth/refresh {refresh_token}
|
||||
G->>G: 校验签名和过期
|
||||
G->>DB: 验证 hash 存在
|
||||
G->>DB: 撤销旧 refresh_token
|
||||
G->>G: 生成新 token pair
|
||||
G->>DB: 存新 refresh_token hash
|
||||
G-->>C: {access_token, refresh_token}
|
||||
```
|
||||
|
||||
**Token 策略**:access_token 15 分钟有效,refresh_token 7 天有效。refresh 时旧 token 失效(轮转),防止重放攻击。
|
||||
|
||||
**WebSocket 认证**:连接地址 `ws://host/ws?token=<access_token>&conversation_id=<uuid>`。HTTP Upgrade 前校验 token,失败返回 401。
|
||||
核心机制包括:双 token 轮转(access_token 15 分钟 + refresh_token 7 天)、密码安全(bcrypt cost=10)、token 安全(SHA256 哈希存储、复用检测)、WebSocket 连接认证(基于 access_token 的 HTTP Upgrade 校验)等。认证流程、安全机制、配置要求等详细设计见 [10-鉴权体系.md](./10-鉴权体系.md)。
|
||||
|
||||
## 部署架构
|
||||
|
||||
系统采用分层部署架构,支持单实例和多实例水平扩展:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
User["用户浏览器"] --> Nginx
|
||||
|
||||
|
||||
subgraph Nginx["Nginx 反向代理"]
|
||||
Static["/ → 前端静态资源"]
|
||||
API["/api/* → Go Gateway"]
|
||||
WS_Proxy["/ws → Go Gateway"]
|
||||
end
|
||||
|
||||
|
||||
subgraph Gateway_Pool["Go Gateway 实例"]
|
||||
G1["Gateway-1"]
|
||||
G2["Gateway-2"]
|
||||
GN["Gateway-N"]
|
||||
end
|
||||
|
||||
|
||||
Nginx --> G1
|
||||
Nginx --> G2
|
||||
Nginx --> GN
|
||||
|
||||
|
||||
G1 --> Redis
|
||||
G2 --> Redis
|
||||
GN --> Redis
|
||||
|
||||
|
||||
G1 --> PG_DB["PostgreSQL"]
|
||||
G2 --> PG_DB
|
||||
GN --> PG_DB
|
||||
|
||||
|
||||
G1 --> AI_Services["AI Services(外部 API)"]
|
||||
G2 --> AI_Services
|
||||
GN --> AI_Services
|
||||
|
||||
1450
docs/02-接口文档.md
1450
docs/02-接口文档.md
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,16 @@
|
||||
|
||||
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
|
||||
|
||||
**定位**:本文档记录各项技术的选型过程和决策理由。
|
||||
各技术选型章节包含关键术语解释,帮助快速理解技术概念。
|
||||
|
||||
### 后端核心技术栈
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **Go (Golang)** | 高并发后端语言,Google 开发,杀手锏是 goroutine——极轻量协程,一个程序可轻松开几万个,每个只占几 KB 内存,适合管理大量 WebSocket 长连接 |
|
||||
| **gorilla/websocket** | Go WebSocket 库,Go 标准库无内置 WebSocket 支持,此库是社区最成熟的选择,处理了协议握手、帧解析等底层细节 |
|
||||
| **Viper** | Go 配置管理库,读取 JSON/YAML/TOML 配置,支持环境变量覆盖,方便开发/测试/生产环境用不同配置 |
|
||||
| **Zap** | Go 结构化日志库,Uber 开源,输出 JSON 格式日志,方便工具搜索分析,性能远超标准库 log |
|
||||
|
||||
```
|
||||
技术选型
|
||||
@@ -34,6 +43,18 @@
|
||||
|
||||
## 一、AI 编排框架选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **Eino** | 字节跳动开源的 Go AI 应用开发框架(CloudWeGo Eino),提供 Graph DAG 编排、组件抽象(ChatModel/Tool 等)、流式处理和 Callback AOP 机制 |
|
||||
| **compose.Graph** | Eino 的 DAG 编排器,声明式有向无环图,节点可以是 Lambda、ChatModel、ToolsNode 等,边定义数据流向 |
|
||||
| **Lambda** | Graph 中的可组合函数单元,四种模式:InvokableLambda(同步)、StreamableLambda(流式输出)、CollectableLambda(流式输入)、TransformableLambda(双向流式) |
|
||||
| **StreamReader** | Eino 的流式数据抽象 `schema.StreamReader[T]`,类似 io.Reader 的语义,`Recv()` 读取一帧,`io.EOF` 表示流结束 |
|
||||
| **Callback** | Eino 的 AOP 机制,类似中间件的钩子,支持节点生命周期回调(OnStart/OnEnd/OnError/OnEndWithStreamOutput) |
|
||||
|
||||
> 更多 Eino 相关概念详见 [10-Eino框架与编排设计.md](10-Eino框架与编排设计.md)
|
||||
|
||||
### 候选方案对比
|
||||
|
||||
| 框架 | 语言 | 特点 | CamTalk 适用性 |
|
||||
@@ -74,6 +95,14 @@ github.com/cloudwego/eino-ext/components/model/openai v0.1.13 # OpenAI 兼容 C
|
||||
|
||||
## 二、AI 服务栈选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **多模态 LLM** | 能读文字又能看图片的大语言模型,如 GPT-4o(OpenAI)、Claude Sonnet(Anthropic),给照片+问题能"看懂"照片再回答 |
|
||||
| **STT** | Speech-to-Text,语音转文字。流式识别延迟可低于 500ms |
|
||||
| **TTS** | Text-to-Speech,文字转语音。支持流式——边生成边读,不必等全部生成完 |
|
||||
|
||||
### STT(语音识别)
|
||||
|
||||
| 方案 | 延迟 | 成本 | 特点 |
|
||||
@@ -106,7 +135,15 @@ LLM 通过 Eino 框架的 `eino-ext/components/model/openai` ChatModel 组件接
|
||||
|
||||
---
|
||||
|
||||
## 二、持久化层选型
|
||||
## 三、持久化层选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **PostgreSQL** | 关系型数据库,支持 JSONB(JSON 二进制格式,可建索引)、窗口函数、CTE 等高级特性 |
|
||||
| **Redis** | 内存 KV 数据库,数据放在内存里,读写微秒级。支持 TTL 过期自动清理 |
|
||||
| **MVCC** | Multi-Version Concurrency Control,多版本并发控制,PostgreSQL 用此实现高并发读写而不阻塞 |
|
||||
|
||||
### 数据特征分析
|
||||
|
||||
@@ -220,7 +257,19 @@ Go Gateway (TieredManager)
|
||||
|
||||
---
|
||||
|
||||
## 二、前端边缘处理层选型
|
||||
## 四、前端边缘处理层选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **React 18** | 组件化 UI 框架,Facebook 开源,把页面拆成组件搭积木拼装。18 版本支持并发渲染 |
|
||||
| **TypeScript** | 带类型的 JavaScript,在 JS 基础上增加类型声明,编译阶段就能发现类型错误 |
|
||||
| **Vite** | 前端构建工具,利用浏览器原生 ES Module,开发时毫秒级热更新(HMR),构建产物小 |
|
||||
| **WebSocket** | 浏览器与服务器的双向通道。HTTP 是"一问一答",WebSocket 像打电话——接通后双方随时互发消息,适合实时对话场景 |
|
||||
| **ONNX Runtime Web** | 浏览器端 AI 推理引擎,微软定义的通用模型格式 ONNX 的运行引擎,可在浏览器中用 WASM 加速跑轻量模型(如 VAD、关键帧检测),零延迟、不耗服务器资源 |
|
||||
| **VAD** | Voice Activity Detection,语音活动检测,检测"人有没有在说话"。WebRTC 内置了高效的 VAD 算法 |
|
||||
| **MediaDevices API** | 浏览器摄像头/麦克风接口,`navigator.mediaDevices.getUserMedia()` 是浏览器音视频采集的唯一标准入口,无需插件 |
|
||||
|
||||
### 总览
|
||||
|
||||
@@ -270,7 +319,16 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC
|
||||
|
||||
---
|
||||
|
||||
## 四、认证与用户系统选型
|
||||
## 五、认证与用户系统选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **JWT** | JSON Web Token,无状态 token,服务端不存 session,分布式友好 |
|
||||
| **HS256** | HMAC-SHA256,JWT 对称签名算法,用同一密钥签名和验证 |
|
||||
| **bcrypt** | 密码哈希算法,自适应 cost factor,抗暴力破解 |
|
||||
| **pgx** | Go 生态性能最优的 PostgreSQL 驱动,原生协议实现,内置连接池 pgxpool |
|
||||
|
||||
### 总览
|
||||
|
||||
|
||||
@@ -26,14 +26,7 @@
|
||||
| 用户触发 | 高 | 低 | 只在用户提问时拍照 |
|
||||
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
|
||||
|
||||
```typescript
|
||||
// 混合策略:定时低频 + 事件高频(sampling.ts)
|
||||
const IDLE_INTERVAL = 5000; // 空闲 5 秒一帧
|
||||
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||
|
||||
// SamplingController 根据 VAD 状态切换采样间隔
|
||||
// detail_level 通过 session config 静态配置,不随说话状态动态变化
|
||||
```
|
||||
**实现细节**:参见 `frontend/src/lib/sampling.ts` 中的 SamplingController,根据 VAD 状态在空闲模式(5s/帧)和活跃模式(1s/帧)之间切换。
|
||||
|
||||
## 策略二:端云协同——把计算推到边缘
|
||||
|
||||
@@ -55,7 +48,7 @@ const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||
└── 代码/推理 → 更强模型(如 o1)
|
||||
```
|
||||
|
||||
> 当前 MVP 阶段使用单一模型(默认 DashScope qwen3-vl-plus),模型分级路由为未来优化方向。通过配置 `ai.llm.model` 可手动切换模型。LLM 通过 Eino 框架的 eino-ext ChatModel 组件接入,支持任何 OpenAI 兼容接口。
|
||||
> 当前 MVP 阶段使用单一模型(默认 DashScope qwen3-vl-plus),模型分级路由为未来优化方向。LLM 通过 Eino ChatModel 接入,支持任何 OpenAI 兼容接口。
|
||||
|
||||
## 策略四:缓存与复用(待实现)
|
||||
|
||||
|
||||
523
docs/08-Eino框架与编排设计.md
Normal file
523
docs/08-Eino框架与编排设计.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# CamTalk Eino 框架与编排设计
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 为什么选择 Eino
|
||||
|
||||
[CloudWeGo Eino](https://github.com/cloudwego/eino) 是字节跳动 CloudWeGo 团队开源的 AI 应用开发框架,提供基于图(Graph)的编排能力、组件抽象和流式处理支持。
|
||||
|
||||
CamTalk 使用 Eino 替代原有的手写 goroutine 管道,实现 STT → LLM → TTS 的声明式编排。
|
||||
|
||||
**技术选型对比:**
|
||||
|
||||
| 维度 | 手写 goroutine(旧方案) | Eino Graph(新方案) |
|
||||
|------|------------------------|---------------------|
|
||||
| 编排方式 | 手动 `go func()` + `sync.WaitGroup` | 声明式 DAG,类型安全 |
|
||||
| 流式处理 | 自定义 `chan` 传递 | `StreamReader` + `Pipe`,自动转换 |
|
||||
| 错误处理 | 各节点独立处理,不一致 | Graph 级别统一错误传播 |
|
||||
| 回调/AOP | 日志散落各处 | `callbacks.Handler` 统一注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 `Option` 动态注入 |
|
||||
| 可测试性 | 需启动 goroutine | `Graph.Invoke()` 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无侵入 |
|
||||
| 并发安全 | 手动 `sync` | State 自动加锁 |
|
||||
|
||||
**选择 Eino 的核心理由:**
|
||||
1. Go 原生,泛型支持,编译时类型检查
|
||||
2. 原生流式处理(`StreamReader`),适合 LLM token 级推送
|
||||
3. Graph 支持分支、并行、循环,满足当前和未来需求
|
||||
4. Callback 机制实现 AOP(日志、指标、消息推送)
|
||||
5. eino-ext 提供 OpenAI ChatModel 实现,直接对接 DashScope
|
||||
|
||||
### 1.2 旧方案的问题
|
||||
|
||||
当前后端 AI 编排层(`internal/orchestrator/pipeline.go`)为手写 goroutine 管道存在以下问题:
|
||||
|
||||
1. **编排逻辑硬编码**:STT→LLM→TTS 流程写死,扩展困难
|
||||
2. **并发控制粗糙**:手动 goroutine 调度,缺乏结构化流式传递
|
||||
3. **无回调/AOP 机制**:日志、指标、追踪散落各处
|
||||
4. **配置耦合**:模型名、TTS 参数等硬编码在结构体
|
||||
5. **错误处理不一致**:TTS 错误静默吞掉,STT/LLM 错误通过 Sender 发送
|
||||
|
||||
### 1.3 核心依赖版本
|
||||
|
||||
```go
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
```
|
||||
|
||||
## 2. Eino 核心概念
|
||||
|
||||
### 2.1 Lambda
|
||||
|
||||
Lambda 是 Graph 中的可组合函数单元,支持四种模式:
|
||||
|
||||
| 模式 | 函数签名 | 构造方法 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| Invoke | `I → O` | `compose.InvokableLambda()` | 同步调用 |
|
||||
| Stream | `I → StreamReader[O]` | `compose.StreamableLambda()` | 流式输出 |
|
||||
| Collect | `StreamReader[I] → O` | `compose.CollectableLambda()` | 流式输入 |
|
||||
| Transform | `StreamReader[I] → StreamReader[O]` | `compose.TransformableLambda()` | 双向流式 |
|
||||
|
||||
**返回类型**:所有 Lambda 构造函数返回 `*compose.Lambda`。
|
||||
|
||||
### 2.2 Graph
|
||||
|
||||
Graph 是有向无环图(DAG)编排器,支持:
|
||||
- **节点**:Lambda、ChatModel、ToolsNode 等
|
||||
- **边**:`g.AddEdge(from, to)` 定义数据流向
|
||||
- **分支**:`g.AddBranch()` 条件路由
|
||||
- **State**:`compose.WithGenLocalState()` 跨节点共享状态
|
||||
|
||||
```go
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
g.AddLambdaNode("stt", sttLambda)
|
||||
g.AddChatModelNode("llm", chatModel)
|
||||
g.AddEdge(compose.START, "stt")
|
||||
g.AddEdge("stt", "llm")
|
||||
g.AddEdge("llm", compose.END)
|
||||
|
||||
runnable, err := g.Compile(ctx)
|
||||
output, err := runnable.Invoke(ctx, input) // 同步调用
|
||||
stream, err := runnable.Stream(ctx, input) // 流式调用
|
||||
```
|
||||
|
||||
### 2.3 ChatModel
|
||||
|
||||
ChatModel 是 LLM 组件抽象,接口定义:
|
||||
|
||||
```go
|
||||
type BaseChatModel interface {
|
||||
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
|
||||
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `eino-ext/components/model/openai` 实现,通过 `BaseURL` 对接 DashScope:
|
||||
|
||||
```go
|
||||
chatModel, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
APIKey: cfg.AI.LLM.APIKey,
|
||||
Model: cfg.AI.LLM.Model,
|
||||
BaseURL: cfg.AI.LLM.Endpoint, // "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
})
|
||||
```
|
||||
|
||||
### 2.4 StreamReader
|
||||
|
||||
`schema.StreamReader[T]` 是 Eino 的流式数据抽象:
|
||||
- `sr.Recv()` 读取一帧,`io.EOF` 表示流结束
|
||||
- `schema.Pipe[T](bufSize)` 创建 `StreamReader` + `StreamWriter` 对
|
||||
- 框架自动处理 `T ↔ StreamReader[T]` 的转换(装箱/concat)
|
||||
|
||||
### 2.5 Callback
|
||||
|
||||
Callback 是 Eino 的 AOP 机制,支持节点生命周期钩子:
|
||||
|
||||
```go
|
||||
type Handler interface {
|
||||
OnStart(ctx, *RunInfo, CallbackInput) context.Context
|
||||
OnEnd(ctx, *RunInfo, CallbackOutput) context.Context
|
||||
OnError(ctx, *RunInfo, error) context.Context
|
||||
OnStartWithStreamInput(ctx, *RunInfo, *StreamReader[CallbackInput]) context.Context
|
||||
OnEndWithStreamOutput(ctx, *RunInfo, *StreamReader[CallbackOutput]) context.Context
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `utils/callbacks.NewHandlerHelper()` 构建 typed handler:
|
||||
- `ModelCallbackHandler.OnEndWithStreamOutput`:逐 token 推送 `llm_chunk`
|
||||
|
||||
### 2.6 State
|
||||
|
||||
Graph 全局状态,通过 `WithGenLocalState` 注册:
|
||||
|
||||
```go
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
|
||||
g := compose.NewGraph[I, O](compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}))
|
||||
```
|
||||
|
||||
节点通过 `compose.ProcessState` 读写 State。
|
||||
|
||||
## 3. CamTalk Graph 设计
|
||||
|
||||
### 3.1 拓扑结构
|
||||
|
||||
```
|
||||
START → STT → History → ChatModel → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
| 节点 | 类型 | 输入 → 输出 | 职责 |
|
||||
|------|------|------------|------|
|
||||
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别,写入 State |
|
||||
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装提示词和历史 |
|
||||
| ChatModel | ChatModel(原生) | `[]*schema.Message → StreamReader[*Message]` | LLM 流式推理 |
|
||||
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | 句子切分 |
|
||||
| TTS | InvokableLambda | `[]string → struct{}` | 语音合成,推送音频 |
|
||||
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 llm_done |
|
||||
|
||||
### 3.2 数据类型定义
|
||||
|
||||
```go
|
||||
// Graph 统一输入
|
||||
type PipelineInput struct {
|
||||
AudioData []byte // base64 解码后的音频(可选)
|
||||
ImageData []byte // base64 解码后的图像(可选)
|
||||
Text string // 直接文本输入(可选,跳过 STT)
|
||||
SessionID string
|
||||
RequestID string
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
}
|
||||
|
||||
// Graph 统一输出
|
||||
type PipelineOutput struct {
|
||||
TranscribedText string // STT 结果
|
||||
FullResponse string // LLM 完整回复
|
||||
}
|
||||
|
||||
// Pipeline State(跨节点共享)
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 流式模式
|
||||
|
||||
Graph 使用 **Stream 模式**调用:
|
||||
- 内部所有节点以 Transform 模式运行
|
||||
- ChatModel 的 `Stream()` 方法实现真正的 token 级流式
|
||||
- 适配器消费 `StreamReader[PipelineOutput]` 触发整条链路
|
||||
|
||||
### 3.4 消息推送机制
|
||||
|
||||
| 消息 | 推送方式 | 时机 |
|
||||
|------|---------|------|
|
||||
| `stt_result` | Lambda 内部直接调用 Sender | STT 完成后 |
|
||||
| `llm_chunk` | Callback `OnEndWithStreamOutput` | ChatModel 逐 token |
|
||||
| `tts_audio` | Lambda 内部直接调用 Sender | TTS 逐句合成 |
|
||||
| `llm_done` | Lambda 内部直接调用 Sender | Done 节点执行时 |
|
||||
|
||||
**Context 注入**:Sender、RequestID、SessionID、PipelineState 通过 `context.WithValue` 传递。
|
||||
|
||||
### 3.5 多模态支持
|
||||
|
||||
History 节点将图片构建为 `schema.Message.UserInputMultiContent`:
|
||||
|
||||
```go
|
||||
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeImageURL,
|
||||
Image: &schema.MessageInputImage{
|
||||
MessagePartCommon: schema.MessagePartCommon{
|
||||
Base64Data: &base64Str,
|
||||
MIMEType: "image/jpeg",
|
||||
},
|
||||
Detail: schema.ImageURLDetailAuto,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 实现要点
|
||||
|
||||
### 4.1 目录结构
|
||||
|
||||
```
|
||||
backend/internal/eino/
|
||||
├── types.go # PipelineInput/Output、STTOutput、TokenUsage
|
||||
├── state.go # PipelineState(跨节点状态)
|
||||
├── callback.go # Callback handler(LLM token 推送)
|
||||
├── graph.go # Graph 构建与编译
|
||||
├── adapter.go # EinoOrchestrator(Orchestrator 接口适配器)
|
||||
├── nodes_stt.go # STT Lambda
|
||||
├── nodes_history.go # 历史组装 Lambda
|
||||
├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
├── nodes_tts.go # TTS Lambda
|
||||
├── nodes_done.go # Done Lambda
|
||||
└── graph_test.go # 单元测试
|
||||
```
|
||||
|
||||
### 4.2 关键节点实现
|
||||
|
||||
#### STT Lambda(可选跳过)
|
||||
|
||||
```go
|
||||
func sttLambda(sttSvc stt.Service) func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
return func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
// 文本模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
return STTOutput{Text: input.Text, Language: input.Language}, nil
|
||||
}
|
||||
|
||||
// 调用 STT 服务
|
||||
result, err := sttSvc.Recognize(ctx, input.AudioData, stt.Options{
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
return STTOutput{}, fmt.Errorf("STT error: %w", err)
|
||||
}
|
||||
|
||||
return STTOutput{Text: result.Text, Language: result.Language}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Splitter Transform Lambda(句子切分)
|
||||
|
||||
```go
|
||||
func splitterLambda() func(ctx, *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
|
||||
sr, sw := schema.Pipe[[]string](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
var buffer []rune
|
||||
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
if len(buffer) > 0 {
|
||||
sw.Send([]string{string(buffer)}, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
sw.Send(nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range chunk.Content {
|
||||
buffer = append(buffer, r)
|
||||
if isSentenceDelimiter(r) {
|
||||
sw.Send([]string{string(buffer)}, nil)
|
||||
buffer = buffer[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### TTS Lambda(并行合成)
|
||||
|
||||
```go
|
||||
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx, []string) (struct{}, error) {
|
||||
return func(ctx context.Context, sentences []string) (struct{}, error) {
|
||||
for _, sentence := range sentences {
|
||||
if sentence == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 TTS 服务
|
||||
audioData, err := ttsSvc.Synthesize(ctx, sentence, tts.Options{})
|
||||
if err != nil {
|
||||
// TTS 失败不中断流程,仅记录日志
|
||||
log.Warn("TTS synthesis failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送音频到客户端
|
||||
sender.SendTTSAudio(orchestrator.TTSAudioPayload{
|
||||
Audio: audioData,
|
||||
Format: "mp3",
|
||||
})
|
||||
}
|
||||
|
||||
return struct{}{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Callback 集成
|
||||
|
||||
```go
|
||||
// ModelCallbackHandler 用于 LLM token 推送
|
||||
type ModelCallbackHandler struct {
|
||||
sender orchestrator.Sender
|
||||
}
|
||||
|
||||
func (h *ModelCallbackHandler) OnEndWithStreamOutput(
|
||||
ctx context.Context,
|
||||
info *callbacks.RunInfo,
|
||||
output *schema.StreamReader[*schema.Message],
|
||||
) context.Context {
|
||||
// 逐 token 推送到客户端
|
||||
for {
|
||||
msg, err := output.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
h.sender.SendLLMChunk(orchestrator.LLMChunkPayload{
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 按请求动态配置
|
||||
|
||||
```go
|
||||
// 运行时 Option:每请求可变
|
||||
func WithModelName(name string) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithModel(name))
|
||||
}
|
||||
|
||||
func WithTemperature(temp float32) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithTemperature(temp))
|
||||
}
|
||||
|
||||
// WebSocket Handler 中的调用
|
||||
func (c *Client) handleQuery(req QueryRequest) {
|
||||
opts := []compose.Option{}
|
||||
|
||||
if req.Model != "" {
|
||||
opts = append(opts, WithModelName(req.Model))
|
||||
}
|
||||
|
||||
output, err := c.pipeline.Stream(ctx, PipelineInput{...}, opts...)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 注意事项
|
||||
|
||||
#### 值类型 vs 指针类型
|
||||
Graph 泛型参数必须使用值类型(`PipelineInput`/`PipelineOutput`),所有 Lambda 的输入输出也使用值类型。框架在 Transform 模式下会自动处理 `T` 和 `StreamReader[T]` 的转换。
|
||||
|
||||
#### Callback 运行时传入
|
||||
Callback 通过 `Stream()` 的 option 传入,不在 `Compile()` 时注册:
|
||||
|
||||
```go
|
||||
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
|
||||
```
|
||||
|
||||
#### eino-ext 与 DashScope 兼容性
|
||||
eino-ext OpenAI ChatModel 通过 `BaseURL` 对接 DashScope 兼容接口。需注意:
|
||||
- 多模态图片使用 `Base64Data` + `MIMEType` 格式
|
||||
- `Timeout` 控制单次请求超时
|
||||
- 流式输出通过 `Stream()` 方法获取 `StreamReader[*schema.Message]`
|
||||
|
||||
#### 框架自动类型转换
|
||||
Eino 框架在编排场景中自动处理以下转换:
|
||||
- **T → StreamReader[T]**:将完整值装箱为单帧流(非流式 → 假流式)
|
||||
- **StreamReader[T] → T**:将流 concat 为完整值(流式 → 非流式)
|
||||
|
||||
这使得不同流式模式的节点可以无缝连接。
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
```go
|
||||
func TestPipelineGraph_WithTextInput(t *testing.T) {
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, err := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
Sender: mockSender,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
Text: "你好",
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "你好!", output.FullResponse)
|
||||
assert.True(t, mockSender.LLMDoneSent)
|
||||
}
|
||||
|
||||
func TestPipelineGraph_WithAudioInput(t *testing.T) {
|
||||
mockSTT := &mockSTT{text: "你好"}
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockTTS := &mockTTS{audio: []byte("fake-audio")}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, _ := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
STTService: mockSTT,
|
||||
TTSService: mockTTS,
|
||||
Sender: mockSender,
|
||||
})
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
AudioData: []byte("fake-audio-data"),
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockSender.TTSAudioSent)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 集成测试
|
||||
|
||||
- 启动真实 OpenAI API 调用(使用测试 key)
|
||||
- 验证 WebSocket 消息序列:`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
|
||||
- 验证 interrupt 取消功能
|
||||
- 验证多并发请求隔离
|
||||
|
||||
## 6. 未来扩展路径
|
||||
|
||||
基于 Eino Graph 的重构完成后,可无缝扩展:
|
||||
|
||||
1. **ReAct Agent**:Graph 添加 Branch 节点,实现 LLM → Tool → LLM 循环
|
||||
2. **多模态理解**:添加视觉分析 Lambda 节点(图像描述 → 上下文注入)
|
||||
3. **Model Router**:Graph 前置分支节点,按场景/成本路由不同 LLM
|
||||
4. **Rate Limiter**:通过 Callback 的 OnStart 实现令牌桶
|
||||
5. **Checkpoint/Resume**:利用 Eino 的 CheckpointStore 实现断点续传
|
||||
6. **Multi-Agent**:利用 ADK 的 Supervisor/SequentialAgent 编排复杂对话流程
|
||||
|
||||
## 附录:关键 Eino API 参考
|
||||
|
||||
```go
|
||||
// 构建 Graph
|
||||
g := compose.NewGraph[I, O](opts...)
|
||||
g.AddChatModelNode(key, chatModel)
|
||||
g.AddLambdaNode(key, lambda, opts...)
|
||||
g.AddEdge(from, to)
|
||||
g.AddBranch(from, branchFunc, mapping)
|
||||
|
||||
// 编译
|
||||
runnable, err := g.Compile(ctx, opts...)
|
||||
|
||||
// 执行四种模式
|
||||
output, err := runnable.Invoke(ctx, input, opts...)
|
||||
stream, err := runnable.Stream(ctx, input, opts...)
|
||||
output, err := runnable.Collect(ctx, inputStream, opts...)
|
||||
stream, err := runnable.Transform(ctx, inputStream, opts...)
|
||||
|
||||
// Lambda 四种构造器
|
||||
lambda := compose.InvokableLambda(fn) // I → O
|
||||
lambda := compose.StreamableLambda(fn) // I → StreamReader[O]
|
||||
lambda := compose.CollectableLambda(fn) // StreamReader[I] → O
|
||||
lambda := compose.TransformableLambda(fn) // StreamReader[I] → StreamReader[O]
|
||||
|
||||
// Stream 操作
|
||||
sr, sw := schema.Pipe[T](bufSize)
|
||||
sw.Send(chunk, err)
|
||||
chunk, err := sr.Recv()
|
||||
sw.Close()
|
||||
|
||||
// Option
|
||||
compose.WithCallbacks(handler)
|
||||
compose.WithCallbacks(handler).DesignateNode("node_key")
|
||||
compose.WithChatModelOption(model.WithTemperature(0.7))
|
||||
compose.WithGenLocalState(genFunc)
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
1.视频录制
|
||||
2.对话翻译
|
||||
3.对话总结
|
||||
4.手动对话功能
|
||||
5.视频框大小可调整,可最小化然后拖动
|
||||
348
docs/09-情景切换.md
Normal file
348
docs/09-情景切换.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# 情景切换功能
|
||||
|
||||
## 功能概述
|
||||
|
||||
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
||||
|
||||
| 情景 | AI 角色 | 主要功能 |
|
||||
|------|---------|---------|
|
||||
| 🎯 模拟面试官 | 资深面试官 | 提出面试问题,评估候选人能力,给出反馈 |
|
||||
| 📚 英语老师 | 英语外教 | 全英文对话,纠正语法错误,引导深入交流 |
|
||||
| ⚔️ 辩论对手 | 辩论选手 | 站在反方立场,用逻辑和证据反驳观点 |
|
||||
| 🌐 同声翻译 | 翻译员 | 实时中英互译,口语化翻译,无额外解释 |
|
||||
| 💬 自由对话 | 视觉助手 | 通用视觉对话助手(默认) |
|
||||
|
||||
### 核心特性
|
||||
|
||||
1. **情景首句引导**:切换情景后,AI 自动发送第一句话引导用户进入角色
|
||||
2. **情景提示卡片**:对话顶部显示当前情景模式的蓝色提示卡片
|
||||
3. **增强 System Prompt**:每个情景有详细的角色定位、交互规则和约束
|
||||
4. **多语言支持**:完整支持中文、英文、日文界面
|
||||
|
||||
---
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. 情景 Prompt 定义
|
||||
|
||||
**文件**: `backend/internal/ai/llm/scenarios.go`
|
||||
|
||||
- 扩展 `scenarioPrompt` 结构体,新增首句引导字段(GreetingZH/EN/JA)
|
||||
- 增强所有情景的 System Prompt(添加角色定位、交互规则、约束)
|
||||
- 新增函数 `GetScenarioGreeting(scenarioID, language string) string`
|
||||
|
||||
**示例 Prompt**(模拟面试官):
|
||||
|
||||
```go
|
||||
"interviewer": {
|
||||
ZH: `你是一位资深面试官。你通过摄像头观察面试者...
|
||||
|
||||
【角色定位】
|
||||
- 你是面试官,不是助手或顾问
|
||||
- 你的目标是评估候选人的能力
|
||||
- 保持专业、客观、礼貌
|
||||
|
||||
【交互规则】
|
||||
1. 每次只问一个问题,等用户回答后再追问
|
||||
2. 问题要有层次:自我介绍 → 专业问题 → 情景题
|
||||
3. 对用户的回答给出简短点评,然后追问
|
||||
...`,
|
||||
GreetingZH: "你好!我是今天的面试官。让我们先从自我介绍开始...",
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 首句引导推送
|
||||
|
||||
**文件**: `backend/internal/ws/handler.go`
|
||||
|
||||
在处理 `config` 消息时,如果切换到非自由对话情景,自动返回首句引导:
|
||||
|
||||
```go
|
||||
case "config":
|
||||
// ... 更新配置 ...
|
||||
|
||||
// 如果切换了情景(非自由对话),返回首句引导
|
||||
if scenarioID != "" && scenarioID != "free_chat" {
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language)
|
||||
if greeting != "" {
|
||||
// 发送 llm_chunk 和 llm_done 消息
|
||||
// 追加到历史记录
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. State 初始化
|
||||
|
||||
**文件**: `backend/internal/eino/adapter.go`
|
||||
|
||||
从 `PipelineInput` 复制元数据到 `PipelineState`,确保情景配置正确传递到所有节点:
|
||||
|
||||
```go
|
||||
state := genLocalState(ctx)
|
||||
state.SessionID = input.SessionID
|
||||
state.RequestID = input.RequestID
|
||||
state.ImageData = input.ImageData
|
||||
state.Scenario = input.Scenario // 关键:复制情景配置
|
||||
state.Language = input.Language
|
||||
state.DetailLevel = sess.Config.DetailLevel
|
||||
state.TTSEnabled = input.TTSEnabled
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 情景提示卡片
|
||||
|
||||
**文件**: `frontend/src/components/ChatPanel/index.tsx`
|
||||
|
||||
在对话列表顶部(非空状态 + 非自由对话模式)添加情景提示卡片:
|
||||
|
||||
```tsx
|
||||
{messages.length > 0 && !isFreeChat && (
|
||||
<div className="chat-panel__scenario-hint">
|
||||
<div className="scenario-hint-card">
|
||||
<span className="scenario-hint-card__icon">
|
||||
{scenarios.find(s => s.id === activeScenario)?.icon}
|
||||
</span>
|
||||
<div className="scenario-hint-card__text">
|
||||
<strong>{t(scenarios.find(s => s.id === activeScenario)?.nameKey || "")}</strong>
|
||||
<p>{t(`scenario.${activeScenario}.hint`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**显示效果**:
|
||||
- 蓝色渐变背景(135deg 从蓝到紫)
|
||||
- 左侧大图标 + 右侧标题和说明
|
||||
- 最大宽度 520px,响应式布局
|
||||
- 柔和阴影和半透明边框
|
||||
|
||||
#### 2. WebSocket 消息发送
|
||||
|
||||
**文件**: `frontend/src/hooks/useVisionSession.ts`
|
||||
|
||||
发送 config 消息时包含 `scenario` 字段:
|
||||
|
||||
```typescript
|
||||
send({
|
||||
type: "config",
|
||||
payload: {
|
||||
tts_enabled: config.ttsEnabled,
|
||||
detail_level: config.detailLevel,
|
||||
language: config.language,
|
||||
scenario: config.scenario, // 情景配置
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 样式实现
|
||||
|
||||
**文件**: `frontend/src/App.css`
|
||||
|
||||
情景提示卡片样式:
|
||||
|
||||
```css
|
||||
.scenario-hint-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(99, 102, 241, 0.08) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 多语言翻译
|
||||
|
||||
**文件**: `frontend/src/lib/i18n/{zh-CN,en-US,ja-JP}.ts`
|
||||
|
||||
新增翻译 key:
|
||||
|
||||
```typescript
|
||||
"scenario.interviewer.hint": "AI 会扮演面试官,逐步提出专业问题并点评你的回答",
|
||||
"scenario.englishTeacher.hint": "AI 会用英语对话,纠正语法错误并引导深入交流",
|
||||
"scenario.debate.hint": "AI 会站在反方立场,用逻辑和证据反驳你的观点",
|
||||
"scenario.interpreter.hint": "AI 会实时翻译你的话(中英互译),无解释评论",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
### WebSocket 协议
|
||||
|
||||
**客户端 → 服务端**(config 消息):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
"language": "zh-CN",
|
||||
"scenario": "interviewer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**服务端 → 客户端**(首句引导):
|
||||
|
||||
```json
|
||||
// llm_chunk
|
||||
{
|
||||
"type": "llm_chunk",
|
||||
"request_id": "scenario_greeting",
|
||||
"delta": "你好!我是今天的面试官...",
|
||||
"role": "assistant"
|
||||
}
|
||||
|
||||
// llm_done
|
||||
{
|
||||
"type": "llm_done",
|
||||
"request_id": "scenario_greeting",
|
||||
"full_text": "你好!我是今天的面试官...",
|
||||
"tokens_used": {"prompt": 0, "completion": 0, "total": 0}
|
||||
}
|
||||
```
|
||||
|
||||
### System Prompt 构建流程
|
||||
|
||||
```
|
||||
sess.Config.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineInput.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineState.Scenario = "interviewer" (adapter.go 复制)
|
||||
↓
|
||||
nodes_history.go 读取 state.Scenario
|
||||
↓
|
||||
scenarioPrompt := llm.GetScenarioPrompt("interviewer", "zh-CN")
|
||||
↓
|
||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||
↓
|
||||
messages[0] = {Role: "system", Content: systemPrompt}
|
||||
↓
|
||||
ChatModel 接收到情景 Prompt
|
||||
↓
|
||||
LLM 按情景角色生成回复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 快速验证
|
||||
|
||||
1. **打开浏览器** → http://localhost:5173
|
||||
2. **登录系统**
|
||||
3. **切换情景** → 右侧配置面板 → 对话情景 → 模拟面试官
|
||||
4. **观察现象**:
|
||||
- ✨ AI 立即说:"你好!我是今天的面试官。让我们先从自我介绍开始..."
|
||||
- ✨ 对话框顶部显示蓝色提示卡片
|
||||
5. **验证效果** → 发送:"你是谁?"
|
||||
- ✅ **正确回复**:"我是今天的面试官..."
|
||||
- ❌ **错误回复**:"我是通义千问..."
|
||||
|
||||
### 功能测试清单
|
||||
|
||||
| 测试项 | 操作步骤 | 预期结果 |
|
||||
|--------|---------|---------|
|
||||
| **首句引导** | 切换到"模拟面试官" | AI 自动说:"你好!我是今天的面试官..." |
|
||||
| **情景生效** | 问 "你是谁?" | AI 回答:"我是今天的面试官..." |
|
||||
| **提示卡片** | 发送一条消息后查看顶部 | 显示蓝色卡片:"🎯 模拟面试官 \| AI 会扮演面试官..." |
|
||||
| **语言联动** | 切换到"英语老师" | 语言自动切换到 en-US,AI 用英语回复 |
|
||||
| **持久化** | 切换情景后刷新页面 | 情景配置保持,首句仍在历史中 |
|
||||
| **多情景** | 依次测试所有情景 | 每个情景 AI 回复风格明显不同 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 如果情景不生效
|
||||
|
||||
1. **检查后端日志**:
|
||||
```bash
|
||||
grep "config updated" /tmp/camtalk_server.log | tail -5
|
||||
grep "历史组装完成" /tmp/camtalk_server.log | tail -5
|
||||
```
|
||||
|
||||
- 如果 `scenario=` 是空的,说明前端未发送或后端未接收
|
||||
- 如果 `scenario=interviewer` 正确,但 AI 回复仍是通用的,可能是 LLM 模型问题
|
||||
|
||||
2. **检查前端 WebSocket 消息**(浏览器 DevTools → Network → WS):
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"scenario": "interviewer" // 确认存在
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **检查会话配置是否保存**:
|
||||
- 切换情景后,LocalStorage 中应该有 `camtalk_config`
|
||||
- 内容应包含 `"scenario": "interviewer"`
|
||||
|
||||
4. **清除缓存重试**:
|
||||
```bash
|
||||
# 浏览器:清除 LocalStorage
|
||||
# 后端:重启服务
|
||||
# 前端:刷新页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### P2(强烈推荐)
|
||||
|
||||
1. **情景切换时创建新会话**
|
||||
- 避免历史对话干扰新情景
|
||||
- 弹窗确认:"切换情景会创建新会话,当前对话将保存。是否继续?"
|
||||
- 实现难度:⭐⭐
|
||||
- 用户价值:⭐⭐⭐⭐
|
||||
|
||||
2. **进一步增强 System Prompt**
|
||||
- 增加示例对话(Few-shot Prompting)
|
||||
- 增加"禁止事项"列表
|
||||
- 实现难度:⭐
|
||||
- 效果提升:⭐⭐⭐
|
||||
|
||||
### P3(可选)
|
||||
|
||||
1. **情景专属 UI 主题色**
|
||||
- 面试官 → 深蓝色
|
||||
- 英语老师 → 绿色
|
||||
- 辩论 → 红色
|
||||
- 翻译 → 紫色
|
||||
|
||||
2. **切换动画与音效**
|
||||
- 切换时播放短音效
|
||||
- 聊天面板淡出淡入动画
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 后端(3 个文件)
|
||||
|
||||
- `backend/internal/eino/adapter.go` — 修复 State 初始化
|
||||
- `backend/internal/ws/handler.go` — 添加首句引导
|
||||
- `backend/internal/ai/llm/scenarios.go` — 增强 Prompt + 首句
|
||||
|
||||
### 前端(5 个文件)
|
||||
|
||||
- `frontend/src/hooks/useVisionSession.ts` — 修复 scenario 发送
|
||||
- `frontend/src/components/ChatPanel/index.tsx` — 添加提示卡片
|
||||
- `frontend/src/App.css` — 卡片样式
|
||||
- `frontend/src/lib/i18n/zh-CN.ts` — 中文翻译
|
||||
- `frontend/src/lib/i18n/en-US.ts` — 英文翻译
|
||||
- `frontend/src/lib/i18n/ja-JP.ts` — 日文翻译
|
||||
@@ -1,49 +0,0 @@
|
||||
# 技术名词解释
|
||||
|
||||
对架构文档中技术选型表里出现的所有关键名词的简明解释。
|
||||
|
||||
---
|
||||
|
||||
## 前端相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **React 18** | 组件化 UI 框架 | Facebook 开源,把页面拆成组件搭积木拼装。18 版本支持并发渲染。 |
|
||||
| **TypeScript** | 带类型的 JavaScript | 在 JS 基础上增加类型声明,编译阶段就能发现类型错误。 |
|
||||
| **Vite** | 前端构建工具 | 利用浏览器原生 ES Module,开发时毫秒级热更新(HMR),构建产物小。 |
|
||||
| **WebSocket** | 浏览器与服务器的双向通道 | HTTP 是"一问一答",WebSocket 像打电话——接通后双方随时互发消息,适合实时对话场景。 |
|
||||
| **ONNX Runtime Web** | 浏览器端 AI 推理引擎 | 微软定义的通用模型格式 ONNX 的运行引擎,可在浏览器中用 WASM 加速跑轻量模型(如 VAD、关键帧检测),零延迟、不耗服务器资源。 |
|
||||
| **VAD** | 语音活动检测 | Voice Activity Detection,检测"人有没有在说话"。WebRTC 内置了高效的 VAD 算法,本项目用 @ricky0123/vad-web 包装。 |
|
||||
| **MediaDevices API** | 浏览器摄像头/麦克风接口 | `navigator.mediaDevices.getUserMedia()` 是浏览器音视频采集的唯一标准入口,无需插件。 |
|
||||
|
||||
## 后端相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **Go (Golang)** | 高并发后端语言 | Google 开发,杀手锏是 goroutine——极轻量协程,一个程序可轻松开几万个,每个只占几 KB 内存,适合管理大量 WebSocket 长连接。 |
|
||||
| **gorilla/websocket** | Go WebSocket 库 | Go 标准库无内置 WebSocket 支持,此库是社区最成熟的选择,处理了协议握手、帧解析等底层细节。 |
|
||||
| **Redis** | 内存 KV 数据库 | 数据放在内存里,读写微秒级。本项目用于会话状态和对话上下文缓存,支持 TTL 过期自动清理。多 Gateway 实例通过 Redis 共享状态。 |
|
||||
| **Viper** | Go 配置管理 | 读取 JSON/YAML/TOML 配置,支持环境变量覆盖,方便开发/测试/生产环境用不同配置。 |
|
||||
| **Zap** | Go 结构化日志 | Uber 开源,输出 JSON 格式日志,方便工具搜索分析,性能远超标准库 log。 |
|
||||
|
||||
## AI 服务相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **多模态 LLM** | 能读文字又能看图片的大语言模型 | GPT-4o(OpenAI)/ Claude Sonnet(Anthropic),给照片+问题能"看懂"照片再回答。 |
|
||||
| **STT** | 语音转文字 | Speech-to-Text。Deepgram 流式识别延迟 <500ms。备选 FunASR(阿里开源,可自部署)。 |
|
||||
| **TTS** | 文字转语音 | Text-to-Speech。OpenAI TTS 音质接近真人。Edge TTS 免费。支持流式——边生成边读,不必等全部生成完。 |
|
||||
| **GPT-4o-mini** | 轻量分类模型 | 又快又便宜的小模型,用于模型路由——先用小模型判断问题复杂度,简单问题走小模型省 API 费用。 |
|
||||
|
||||
## AI 编排框架相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **Eino** | 字节跳动开源的 Go AI 应用开发框架 | CloudWeGo Eino,提供 Graph DAG 编排、组件抽象(ChatModel/Tool 等)、流式处理(StreamReader)和 Callback AOP 机制。CamTalk 用它替代手写 goroutine 管道。 |
|
||||
| **compose.Graph** | Eino 的 DAG 编排器 | 声明式有向无环图,节点可以是 Lambda、ChatModel、ToolsNode 等,边定义数据流向。支持分支(AddBranch)、并行和循环。 |
|
||||
| **Lambda** | Graph 中的可组合函数单元 | 四种模式:InvokableLambda(同步)、StreamableLambda(流式输出)、CollectableLambda(流式输入)、TransformableLambda(双向流式)。 |
|
||||
| **StreamReader** | Eino 的流式数据抽象 | `schema.StreamReader[T]`,类似 io.Reader 的语义,`Recv()` 读取一帧,`io.EOF` 表示流结束。`schema.Pipe[T]()` 创建 StreamReader + StreamWriter 对。 |
|
||||
| **Callback** | Eino 的 AOP 机制 | 类似中间件的钩子,支持节点生命周期回调(OnStart/OnEnd/OnError/OnEndWithStreamOutput)。CamTalk 用它实现 LLM token 实时推送到客户端。 |
|
||||
| **ChatModel** | Eino 的 LLM 组件抽象 | 统一接口 `Generate()` 和 `Stream()`,eino-ext 提供 OpenAI 兼容实现,通过 BaseURL 可对接 DashScope 等兼容接口。 |
|
||||
| **eino-ext** | Eino 的组件扩展库 | 提供具体组件实现:OpenAI ChatModel、各种 Tool Backend 等。CamTalk 使用 `eino-ext/components/model/openai`。 |
|
||||
| **PipelineState** | Graph 级别的共享状态 | 通过 `compose.WithGenLocalState` 注册,每请求独立实例,线程安全(sync.Mutex),跨节点共享数据(如 LLM 完整回复、Token 用量)。 |
|
||||
@@ -1,810 +0,0 @@
|
||||
# CamTalk 后端 AI 编排层 Eino 重构方案
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已实施(实施记录见 [12-Eino重构实施记录](12-Eino重构实施记录.md))
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状问题
|
||||
|
||||
当前后端 AI 编排层(`internal/orchestrator/pipeline.go`)为手写 goroutine 管道:
|
||||
|
||||
```
|
||||
STT → LLM(Stream) ──→ Splitter → TTS(Stream) → Sender
|
||||
└→ Sender(LLMChunk)
|
||||
```
|
||||
|
||||
存在以下问题:
|
||||
|
||||
1. **编排逻辑硬编码**:STT→LLM→TTS 流程写死在 `ProcessQuery()` 中,扩展新流程(如视觉分析链路、多轮工具调用)需要重写 goroutine 调度
|
||||
2. **并发控制粗糙**:手动 `go func()` + `sync.WaitGroup`,缺乏结构化的流式数据传递
|
||||
3. **无回调/AOP 机制**:日志、指标、追踪散落在各处,无法统一注入
|
||||
4. **配置耦合**:模型名、TTS 参数等硬编码在 Pipeline 结构体,无法按请求动态切换
|
||||
5. **错误处理不一致**:TTS 错误被静默吞掉,STT/LLM 错误通过 Sender 发送,缺乏统一模式
|
||||
|
||||
### 1.2 重构目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| 用 Eino Graph 替换手写 Pipeline | 声明式编排,类型安全,可组合 |
|
||||
| 流式处理原生支持 | 利用 Eino 的 Transform/Stream 模式,替代手动 goroutine |
|
||||
| 统一回调机制 | 通过 Eino Callback 实现日志、指标、追踪的 AOP |
|
||||
| 按请求动态配置 | 利用 Eino Option 机制,支持每请求切换模型/参数 |
|
||||
| 保持 API 兼容 | WebSocket 协议、REST API、Session 管理不变 |
|
||||
| 渐进式迁移 | 可分阶段实施,新旧编排器并存 |
|
||||
|
||||
## 2. Eino 编排模型选择
|
||||
|
||||
### 2.1 为什么选 Graph 而非 Chain 或 Workflow
|
||||
|
||||
| 编排模式 | 适用场景 | CamTalk 适用性 |
|
||||
|----------|----------|----------------|
|
||||
| **Chain** | 线性流水线 | ❌ LLM 和 TTS 需要并行执行,非纯线性 |
|
||||
| **Workflow** | DAG + 字段映射 | ⚠️ 不支持循环,未来 ReAct Agent 需要循环 |
|
||||
| **Graph** | 任意有向图,支持分支/并行/循环 | ✅ 完美匹配,支持当前并行需求和未来扩展 |
|
||||
|
||||
**选择 Graph**,理由:
|
||||
- LLM Stream 输出需要同时分发给 TTS 和客户端(多下游分支)
|
||||
- 未来需要支持 ReAct Agent 循环(Graph + Branch)
|
||||
- 支持 Pregel 执行引擎,兼容未来有状态节点
|
||||
|
||||
### 2.2 Graph 拓扑设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ CamTalk Pipeline Graph │
|
||||
│ │
|
||||
START │ │ END
|
||||
│ │ ▲
|
||||
▼ │ │
|
||||
┌──────┴──────┐ │
|
||||
│ STT Node │ (Lambda: audio → text) │
|
||||
│ (可选跳过) │ │
|
||||
└──────┬──────┘ │
|
||||
│ text │
|
||||
▼ │
|
||||
┌──────────────┐ │
|
||||
│ History Node │ (Lambda: 组装对话历史) │
|
||||
└──────┬───────┘ │
|
||||
│ []*schema.Message │
|
||||
▼ │
|
||||
┌──────────────┐ ┌────────────────┐ │
|
||||
│ LLM Node │─────→│ Sentence Split │───┐ │
|
||||
│ (ChatModel) │stream│ Node (Lambda) │ │ │
|
||||
└──────┬───────┘ └────────────────┘ │ │
|
||||
│ stream │ │
|
||||
▼ ▼ │
|
||||
┌──────────────┐ ┌──────────────┐│
|
||||
│ Chunk Sender │ │ TTS Node ││
|
||||
│ Node (Lambda)│ │ (Lambda) ││
|
||||
└──────────────┘ └──────┬───────┘│
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────┐ │
|
||||
│Audio Sender │──┘
|
||||
│Node (Lambda) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**关键设计决策:**
|
||||
|
||||
- STT 作为起始 Lambda 节点(非 Eino 原生组件,需封装)
|
||||
- LLM 使用 Eino 原生 ChatModel 组件(`eino-ext` 的 OpenAI 实现)
|
||||
- LLM 输出通过 Graph 的多下游边分发:一条到 Chunk Sender(推文字),一条到 Sentence Split → TTS(推语音)
|
||||
- TTS 封装为 Lambda 节点
|
||||
- 所有 Sender 操作封装为 Lambda 节点,注入 `Sender` 依赖
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 数据类型定义
|
||||
|
||||
```go
|
||||
// internal/eino/types.go
|
||||
|
||||
// Graph 统一输入
|
||||
type PipelineInput struct {
|
||||
AudioData []byte // base64 解码后的音频(可选)
|
||||
ImageData []byte // base64 解码后的图像(可选)
|
||||
Text string // 直接文本输入(可选,跳过 STT)
|
||||
SessionID string
|
||||
RequestID string
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
}
|
||||
|
||||
// Graph 统一输出
|
||||
type PipelineOutput struct {
|
||||
TranscribedText string // STT 结果
|
||||
FullResponse string // LLM 完整回复
|
||||
}
|
||||
|
||||
// STT 节点输出
|
||||
type STTOutput struct {
|
||||
Text string
|
||||
Language string
|
||||
}
|
||||
|
||||
// LLM 节点输入(组装好的对话历史)
|
||||
type LLMInput struct {
|
||||
Messages []*schema.Message
|
||||
}
|
||||
|
||||
// 句子分割中间类型
|
||||
type SentenceChunk struct {
|
||||
Sentence string
|
||||
IsLast bool
|
||||
}
|
||||
|
||||
// TTS 节点输出
|
||||
type TTSAudioChunk struct {
|
||||
AudioData []byte
|
||||
Format string
|
||||
Sentence string
|
||||
IsLast bool
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Eino Graph 构建
|
||||
|
||||
```go
|
||||
// internal/eino/graph.go
|
||||
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// GraphOption 图级别配置
|
||||
type GraphOption struct {
|
||||
ChatModel model.ToolCallingChatModel // Eino 原生 ChatModel
|
||||
STTService stt.Service // 现有 STT 接口
|
||||
TTSService tts.Service // 现有 TTS 接口
|
||||
SessionMgr session.Manager // 会话管理
|
||||
Sender orchestrator.Sender // WS 消息推送
|
||||
PromptCfg *PromptConfig // 提示词配置
|
||||
}
|
||||
|
||||
// NewPipelineGraph 构建编排图
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
|
||||
// 1. STT 节点(Lambda)
|
||||
sttNode := compose.InvokableLambda(sttLambda(opt.STTService))
|
||||
g.AddLambdaNode("stt", sttNode)
|
||||
|
||||
// 2. 历史组装节点(Lambda)
|
||||
historyNode := compose.InvokableLambda(historyLambda(opt.SessionMgr, opt.PromptCfg))
|
||||
g.AddLambdaNode("history", historyNode)
|
||||
|
||||
// 3. LLM 节点(ChatModel,原生流式)
|
||||
g.AddChatModelNode("llm", opt.ChatModel)
|
||||
|
||||
// 4. 句子分割节点(Transform Lambda:stream → stream)
|
||||
splitterNode := compose.TransformableLambda(splitterLambda())
|
||||
g.AddLambdaNode("splitter", splitterNode)
|
||||
|
||||
// 5. LLM Chunk 推送节点(Transform Lambda)
|
||||
chunkSenderNode := compose.TransformableLambda(chunkSenderLambda(opt.Sender))
|
||||
g.AddLambdaNode("chunk_sender", chunkSenderNode)
|
||||
|
||||
// 6. TTS 节点(Collect Lambda:stream → non-stream)
|
||||
ttsNode := compose.CollectableLambda(ttsLambda(opt.TTSService, opt.Sender))
|
||||
g.AddLambdaNode("tts", ttsNode)
|
||||
|
||||
// 7. 完成通知节点(Invokable Lambda)
|
||||
doneNode := compose.InvokableLambda(doneLambda(opt.Sender))
|
||||
g.AddLambdaNode("done", doneNode)
|
||||
|
||||
// === 边连接 ===
|
||||
|
||||
// START → STT
|
||||
g.AddEdge(compose.START, "stt")
|
||||
// STT → History
|
||||
g.AddEdge("stt", "history")
|
||||
// History → LLM
|
||||
g.AddEdge("history", "llm")
|
||||
|
||||
// LLM 输出分发到两个下游(利用 Graph 多下游边)
|
||||
// LLM → Chunk Sender(推送原始 token)
|
||||
g.AddEdge("llm", "chunk_sender")
|
||||
// LLM → Splitter → TTS(句子级语音合成)
|
||||
g.AddEdge("llm", "splitter")
|
||||
g.AddEdge("splitter", "tts")
|
||||
|
||||
// Chunk Sender 和 TTS 都汇入 Done
|
||||
g.AddEdge("chunk_sender", "done")
|
||||
g.AddEdge("tts", "done")
|
||||
|
||||
// Done → END
|
||||
g.AddEdge("done", compose.END)
|
||||
|
||||
// 编译
|
||||
return g.Compile(ctx,
|
||||
compose.WithGraphName("camtalk_pipeline"),
|
||||
compose.WithMaxRunSteps(50),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 节点实现
|
||||
|
||||
#### 3.3.1 STT Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_stt.go
|
||||
|
||||
func sttLambda(sttSvc stt.Service) func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
return func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
// 文本模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
return STTOutput{Text: input.Text, Language: input.Language}, nil
|
||||
}
|
||||
|
||||
if len(input.AudioData) == 0 {
|
||||
return STTOutput{}, fmt.Errorf("no audio data provided")
|
||||
}
|
||||
|
||||
// 调用现有 STT 服务
|
||||
result, err := sttSvc.Recognize(ctx, input.AudioData, stt.Options{
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
return STTOutput{}, fmt.Errorf("STT error: %w", err)
|
||||
}
|
||||
|
||||
return STTOutput{
|
||||
Text: result.Text,
|
||||
Language: result.Language,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 历史组装 Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_history.go
|
||||
|
||||
func historyLambda(sessionMgr session.Manager, promptCfg *PromptConfig) func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||
return func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||
sessionID := getSessionID(ctx) // 从 context 或 state 获取
|
||||
|
||||
history, err := sessionMgr.GetHistory(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get history error: %w", err)
|
||||
}
|
||||
|
||||
// 构建系统提示词
|
||||
systemPrompt := promptCfg.BuildSystemPrompt(input.Language, getScenario(ctx))
|
||||
|
||||
messages := []*schema.Message{
|
||||
{Role: schema.System, Content: systemPrompt,
|
||||
MultiContent: buildVisionContent(getImageData(ctx))},
|
||||
}
|
||||
|
||||
// 追加历史消息
|
||||
for _, msg := range history {
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.Role(msg.Role),
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// 追加当前用户输入
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: input.Text,
|
||||
})
|
||||
|
||||
// 保存用户消息到历史
|
||||
_ = sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "user",
|
||||
Content: input.Text,
|
||||
})
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.3 句子分割 Transform Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_splitter.go
|
||||
|
||||
func splitterLambda() func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||
sr, sw := schema.Pipe[SentenceChunk](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
var buffer []rune
|
||||
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
// 流结束,发送剩余缓冲
|
||||
if len(buffer) > 0 {
|
||||
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: true}, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
sw.Send(SentenceChunk{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range chunk.Content {
|
||||
buffer = append(buffer, r)
|
||||
if isSentenceDelimiter(r) {
|
||||
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: false}, nil)
|
||||
buffer = buffer[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.4 TTS Collect Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_tts.go
|
||||
|
||||
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
return struct{}{}, err
|
||||
}
|
||||
|
||||
if chunk.Sentence == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 TTS 服务
|
||||
audioData, err := ttsSvc.Synthesize(ctx, chunk.Sentence, tts.Options{
|
||||
// 从 Option 或 Config 获取
|
||||
})
|
||||
if err != nil {
|
||||
// TTS 失败不中断流程,仅记录日志
|
||||
log.Warn("TTS synthesis failed", zap.Error(err),
|
||||
zap.String("sentence", chunk.Sentence))
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送音频到客户端
|
||||
sender.SendTTSAudio(orchestrator.TTSAudioPayload{
|
||||
Audio: audioData,
|
||||
Format: "mp3",
|
||||
IsLast: chunk.IsLast,
|
||||
})
|
||||
}
|
||||
|
||||
return struct{}{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.5 Chunk Sender Transform Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_sender.go
|
||||
|
||||
func chunkSenderLambda(sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr, sw := schema.Pipe[*schema.Message](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return
|
||||
}
|
||||
sw.Send(nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 推送 LLM 文本 chunk 到客户端
|
||||
sender.SendLLMChunk(orchestrator.LLMChunkPayload{
|
||||
Content: msg.Content,
|
||||
})
|
||||
|
||||
// 透传给下游
|
||||
sw.Send(msg, nil)
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.6 Done Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_done.go
|
||||
|
||||
func doneLambda(sender orchestrator.Sender) func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||
return func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||
// 通知客户端 LLM 回复完成
|
||||
sender.SendLLMDone(orchestrator.LLMDonePayload{})
|
||||
|
||||
// 保存助手消息到历史
|
||||
// 注意:完整回复需要从某处收集,可通过 State 机制实现
|
||||
return PipelineOutput{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 State 机制(收集完整回复)
|
||||
|
||||
由于 LLM 输出被分发到两个下游,完整回复文本需要通过 Graph State 收集:
|
||||
|
||||
```go
|
||||
// internal/eino/state.go
|
||||
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
SessionID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func genLocalState(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}
|
||||
|
||||
// 在构建 Graph 时注册 State
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput](
|
||||
compose.WithGenLocalState(genLocalState),
|
||||
)
|
||||
|
||||
// ... 添加节点 ...
|
||||
|
||||
// Chunk Sender 的 StatePostHandler 累积完整回复
|
||||
g.AddLambdaNode("chunk_sender", chunkSenderNode,
|
||||
compose.WithStatePostHandler(func(ctx context.Context, output *schema.Message, state *PipelineState) *schema.Message {
|
||||
state.FullResponse.WriteString(output.Content)
|
||||
return output
|
||||
}),
|
||||
)
|
||||
|
||||
// Done 节点的 StatePreHandler 读取完整回复
|
||||
g.AddLambdaNode("done", doneNode,
|
||||
compose.WithStatePreHandler(func(ctx context.Context, input struct{}, state *PipelineState) struct{} {
|
||||
// 将完整回复存入 state 供 done 节点使用
|
||||
return input
|
||||
}),
|
||||
)
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Callback 集成(日志/指标/追踪)
|
||||
|
||||
```go
|
||||
// internal/eino/callback.go
|
||||
|
||||
type MetricsCallback struct {
|
||||
logger *zap.Logger
|
||||
metrics *MetricsCollector // Prometheus 等
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnStart(ctx context.Context, info *compose.RunInfo, input compose.CallbackInput) context.Context {
|
||||
m.logger.Debug("node started",
|
||||
zap.String("node", info.Name),
|
||||
zap.String("graph", info.GraphName))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnEnd(ctx context.Context, info *compose.RunInfo, output compose.CallbackOutput) context.Context {
|
||||
m.logger.Debug("node completed",
|
||||
zap.String("node", info.Name))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnError(ctx context.Context, info *compose.RunInfo, err error) context.Context {
|
||||
m.logger.Error("node failed",
|
||||
zap.String("node", info.Name),
|
||||
zap.Error(err))
|
||||
m.metrics.IncrementError(info.Name)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 注册到 Graph
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
// ...
|
||||
callback := &MetricsCallback{logger: opt.Logger, metrics: opt.Metrics}
|
||||
|
||||
return g.Compile(ctx,
|
||||
compose.WithCallbacks(callback), // 全局回调
|
||||
compose.WithCallbacks(llmCallback).DesignateNode("llm"), // LLM 专用回调
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 按请求动态配置
|
||||
|
||||
```go
|
||||
// internal/eino/options.go
|
||||
|
||||
// 运行时 Option:每请求可变
|
||||
func WithModelName(name string) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithModel(name))
|
||||
}
|
||||
|
||||
func WithTemperature(temp float32) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithTemperature(temp))
|
||||
}
|
||||
|
||||
func WithTTSVoice(voice string) compose.Option {
|
||||
return compose.WithCallbacks(&ttsVoiceCallback{voice: voice}).
|
||||
DesignateNode("tts")
|
||||
}
|
||||
|
||||
// WebSocket Handler 中的调用
|
||||
func (c *Client) handleQuery(req QueryRequest) {
|
||||
opts := []compose.Option{}
|
||||
|
||||
// 根据请求配置动态注入
|
||||
if req.Model != "" {
|
||||
opts = append(opts, WithModelName(req.Model))
|
||||
}
|
||||
if req.TTSVoice != "" {
|
||||
opts = append(opts, WithTTSVoice(req.TTSVoice))
|
||||
}
|
||||
|
||||
output, err := c.pipeline.Invoke(ctx, PipelineInput{...}, opts...)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 ChatModel 适配(接入 eino-ext OpenAI)
|
||||
|
||||
```go
|
||||
// internal/eino/chatmodel.go
|
||||
|
||||
import (
|
||||
openaiImpl "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
)
|
||||
|
||||
func NewChatModel(cfg *config.AIConfig) (model.ToolCallingChatModel, error) {
|
||||
return openaiImpl.NewChatModel(context.Background(), &openaiImpl.ChatModelConfig{
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 目录结构变更
|
||||
|
||||
```
|
||||
backend/internal/
|
||||
├── eino/ # 新增:Eino 编排层
|
||||
│ ├── graph.go # Graph 构建与编译
|
||||
│ ├── types.go # 数据类型定义
|
||||
│ ├── state.go # Graph State 定义
|
||||
│ ├── options.go # 运行时 Option
|
||||
│ ├── callback.go # 回调实现(日志/指标)
|
||||
│ ├── chatmodel.go # ChatModel 适配器
|
||||
│ ├── nodes_stt.go # STT Lambda 节点
|
||||
│ ├── nodes_history.go # 历史组装 Lambda 节点
|
||||
│ ├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
│ ├── nodes_tts.go # TTS Collect Lambda 节点
|
||||
│ ├── nodes_sender.go # Chunk Sender Transform Lambda
|
||||
│ ├── nodes_done.go # 完成通知 Lambda 节点
|
||||
│ └── graph_test.go # 集成测试
|
||||
├── orchestrator/ # 保留:兼容层(Phase 1)
|
||||
│ ├── orchestrator.go # 接口定义(不变)
|
||||
│ ├── pipeline.go # 旧实现(Phase 3 移除)
|
||||
│ ├── splitter.go # 被 eino/nodes_splitter.go 替代
|
||||
│ ├── sender.go # Sender 接口(不变,被 eino 层引用)
|
||||
│ └── eino_adapter.go # 新增:Eino 编排器适配为 Orchestrator 接口
|
||||
├── ai/ # 保留:AI 服务接口不变
|
||||
│ ├── llm/ # 保留接口,实现被 eino-ext 替代
|
||||
│ ├── stt/ # 完全保留
|
||||
│ └── tts/ # 完全保留
|
||||
└── ws/ # 保留:WebSocket Handler
|
||||
└── handler.go # 切换到 Eino 编排器
|
||||
```
|
||||
|
||||
## 5. 分阶段实施计划
|
||||
|
||||
### Phase 1:基础设施(预计 2-3 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 引入 Eino 依赖 | `go.mod` | `go get github.com/cloudwego/eino/...` |
|
||||
| 引入 eino-ext OpenAI | `go.mod` | `go get github.com/cloudwego/eino-ext/...` |
|
||||
| 定义数据类型 | `eino/types.go` | PipelineInput/Output、中间类型 |
|
||||
| 定义 State | `eino/state.go` | PipelineState |
|
||||
| 实现 ChatModel 适配器 | `eino/chatmodel.go` | 包装 eino-ext OpenAI |
|
||||
| 编写 Callback 框架 | `eino/callback.go` | 日志 + 指标回调 |
|
||||
|
||||
### Phase 2:节点实现与 Graph 构建(预计 3-4 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| STT Lambda | `eino/nodes_stt.go` | 包装现有 stt.Service |
|
||||
| 历史组装 Lambda | `eino/nodes_history.go` | 对话历史 + 提示词 |
|
||||
| 句子分割 Transform | `eino/nodes_splitter.go` | 重写 splitter.go 为 Eino Lambda |
|
||||
| TTS Collect Lambda | `eino/nodes_tts.go` | 包装现有 tts.Service |
|
||||
| Chunk Sender Transform | `eino/nodes_sender.go` | LLM token 推送 |
|
||||
| Done Lambda | `eino/nodes_done.go` | 完成通知 |
|
||||
| Graph 构建 | `eino/graph.go` | 组装所有节点 |
|
||||
| 单元测试 | `eino/graph_test.go` | Mock 各节点测试图结构 |
|
||||
|
||||
### Phase 3:集成与切换(预计 2-3 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Eino 适配器 | `orchestrator/eino_adapter.go` | 将 Eino Graph 包装为现有 Orchestrator 接口 |
|
||||
| WS Handler 切换 | `ws/handler.go` | 使用新的 Eino 编排器 |
|
||||
| main.go 依赖注入 | `cmd/server/main.go` | 构建 ChatModel + Graph |
|
||||
| 集成测试 | `eino/graph_test.go` | 端到端测试 |
|
||||
| 性能对比 | - | 延迟、内存、CPU 对比 |
|
||||
|
||||
### Phase 4:清理与增强(预计 1-2 天)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| 移除旧 Pipeline | 删除 `orchestrator/pipeline.go`、`splitter.go` |
|
||||
| 更新文档 | 更新架构文档、接口文档 |
|
||||
| 启用 ReAct Agent(可选) | 基于 Graph Branch 实现工具调用循环 |
|
||||
| 动态配置完善 | 按请求切换模型、TTS 参数 |
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| Eino 框架不稳定(v0.x) | 生产故障 | 锁定版本,保留旧 Pipeline 可回退 |
|
||||
| 流式处理延迟增加 | 用户体验下降 | 性能对比测试,必要时绕过 Eino 直接调用 |
|
||||
| LLM 输出多下游分发丢失数据 | TTS 无输入 | 充分测试 Stream Copy 机制,添加监控 |
|
||||
| 学习曲线 | 开发效率 | 先从简单 Chain 开始,逐步过渡到 Graph |
|
||||
| eino-ext OpenAI 不兼容现有 API | 功能回退 | 验证 BaseURL 和参数映射,必要时自定义适配器 |
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
### 7.1 单元测试
|
||||
|
||||
```go
|
||||
// eino/graph_test.go
|
||||
|
||||
func TestPipelineGraph_WithTextInput(t *testing.T) {
|
||||
// Mock STT, LLM, TTS, Sender
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, err := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
Sender: mockSender,
|
||||
// ...
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
Text: "你好",
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "你好!", output.FullResponse)
|
||||
assert.True(t, mockSender.LLMDoneSent)
|
||||
}
|
||||
|
||||
func TestPipelineGraph_WithAudioInput(t *testing.T) {
|
||||
mockSTT := &mockSTT{text: "你好"}
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockTTS := &mockTTS{audio: []byte("fake-audio")}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, _ := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
STTService: mockSTT,
|
||||
TTSService: mockTTS,
|
||||
Sender: mockSender,
|
||||
})
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
AudioData: []byte("fake-audio-data"),
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockSender.TTSAudioSent)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 集成测试
|
||||
|
||||
- 启动真实 OpenAI API 调用(使用测试 key)
|
||||
- 验证 WebSocket 消息序列:`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
|
||||
- 验证 interrupt 取消功能
|
||||
- 验证多并发请求隔离
|
||||
|
||||
## 8. 依赖清单
|
||||
|
||||
```go
|
||||
// go.mod 新增
|
||||
require (
|
||||
github.com/cloudwego/eino v0.4.x // 核心框架
|
||||
github.com/cloudwego/eino-ext v0.1.x // 组件实现
|
||||
)
|
||||
```
|
||||
|
||||
## 9. 未来扩展路径
|
||||
|
||||
基于 Eino Graph 的重构完成后,可无缝扩展:
|
||||
|
||||
1. **ReAct Agent**:Graph 添加 Branch 节点,实现 LLM → Tool → LLM 循环
|
||||
2. **多模态理解**:添加视觉分析 Lambda 节点(图像描述 → 上下文注入)
|
||||
3. **Model Router**:Graph 前置分支节点,按场景/成本路由不同 LLM
|
||||
4. **Rate Limiter**:通过 Callback 的 OnStart 实现令牌桶
|
||||
5. **Checkpoint/Resume**:利用 Eino 的 CheckpointStore 实现断点续传
|
||||
6. **Multi-Agent**:利用 ADK 的 Supervisor/SequentialAgent 编排复杂对话流程
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Eino vs 现有实现对比
|
||||
|
||||
| 维度 | 现有实现 | Eino 重构后 |
|
||||
|------|----------|------------|
|
||||
| 编排方式 | 手写 goroutine + channel | 声明式 Graph,类型安全 |
|
||||
| 流式处理 | 手动 channel 传递 | StreamReader + Pipe,自动转换 |
|
||||
| 错误处理 | 各节点独立处理 | 统一 Callback OnError |
|
||||
| 日志/追踪 | 散落在各处 | AOP Callback 注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 Option 动态注入 |
|
||||
| 可测试性 | 需要启动 goroutine | Graph.Invoke 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无需改已有逻辑 |
|
||||
| 并发安全 | 手动 sync | State 自动加锁 |
|
||||
|
||||
## 附录 B:关键 Eino API 参考
|
||||
|
||||
```go
|
||||
// 构建 Graph
|
||||
g := compose.NewGraph[I, O](opts...)
|
||||
g.AddChatModelNode(key, chatModel)
|
||||
g.AddLambdaNode(key, lambda, opts...)
|
||||
g.AddEdge(from, to)
|
||||
g.AddBranch(from, branchFunc, mapping)
|
||||
|
||||
// 编译
|
||||
runnable, err := g.Compile(ctx, opts...)
|
||||
|
||||
// 执行四种模式
|
||||
output, err := runnable.Invoke(ctx, input, opts...)
|
||||
stream, err := runnable.Stream(ctx, input, opts...)
|
||||
output, err := runnable.Collect(ctx, inputStream, opts...)
|
||||
stream, err := runnable.Transform(ctx, inputStream, opts...)
|
||||
|
||||
// Lambda 四种构造器
|
||||
lambda := compose.InvokableLambda(fn) // I → O
|
||||
lambda := compose.StreamableLambda(fn) // I → StreamReader[O]
|
||||
lambda := compose.CollectableLambda(fn) // StreamReader[I] → O
|
||||
lambda := compose.TransformableLambda(fn) // StreamReader[I] → StreamReader[O]
|
||||
|
||||
// Stream 操作
|
||||
sr, sw := schema.Pipe[T](bufSize)
|
||||
sw.Send(chunk, err)
|
||||
chunk, err := sr.Recv()
|
||||
sw.Close()
|
||||
|
||||
// Option
|
||||
compose.WithCallbacks(handler)
|
||||
compose.WithCallbacks(handler).DesignateNode("node_key")
|
||||
compose.WithChatModelOption(model.WithTemperature(0.7))
|
||||
compose.WithGenLocalState(genFunc)
|
||||
```
|
||||
1111
docs/10-鉴权体系.md
Normal file
1111
docs/10-鉴权体系.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
# CamTalk Eino 框架技术文档
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已实施
|
||||
|
||||
## 1. 框架简介
|
||||
|
||||
[CloudWeGo Eino](https://github.com/cloudwego/eino) 是字节跳动 CloudWeGo 团队开源的 AI 应用开发框架,提供基于图(Graph)的编排能力、组件抽象和流式处理支持。
|
||||
|
||||
CamTalk 使用 Eino 替代原有的手写 goroutine 管道,实现 STT → LLM → TTS 的声明式编排。
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
### 2.1 为什么选 Eino
|
||||
|
||||
| 维度 | 手写 goroutine(旧方案) | Eino Graph(新方案) |
|
||||
|------|------------------------|---------------------|
|
||||
| 编排方式 | 手动 `go func()` + `sync.WaitGroup` | 声明式 DAG,类型安全 |
|
||||
| 流式处理 | 自定义 `chan` 传递 | `StreamReader` + `Pipe`,自动转换 |
|
||||
| 错误处理 | 各节点独立处理,不一致 | Graph 级别统一错误传播 |
|
||||
| 回调/AOP | 日志散落各处 | `callbacks.Handler` 统一注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 `Option` 动态注入 |
|
||||
| 可测试性 | 需启动 goroutine | `Graph.Invoke()` 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无侵入 |
|
||||
| 并发安全 | 手动 `sync` | State 自动加锁 |
|
||||
|
||||
### 2.2 Eino vs 其他编排框架
|
||||
|
||||
| 框架 | 特点 | CamTalk 适用性 |
|
||||
|------|------|---------------|
|
||||
| **Eino** | Go 原生、类型安全、流式原生 | ✅ 完美匹配 |
|
||||
| LangChain Go | 生态丰富但较重 | ❌ 过度抽象 |
|
||||
| 自研编排 | 完全可控 | ❌ 维护成本高 |
|
||||
|
||||
**选择 Eino 的核心理由**:
|
||||
1. Go 原生,泛型支持,编译时类型检查
|
||||
2. 原生流式处理(`StreamReader`),适合 LLM token 级推送
|
||||
3. Graph 支持分支、并行、循环,满足当前和未来需求
|
||||
4. Callback 机制实现 AOP(日志、指标、消息推送)
|
||||
5. eino-ext 提供 OpenAI ChatModel 实现,直接对接 DashScope
|
||||
|
||||
### 2.3 核心依赖版本
|
||||
|
||||
```
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
```
|
||||
|
||||
## 3. Eino 核心概念
|
||||
|
||||
### 3.1 Lambda
|
||||
|
||||
Lambda 是 Graph 中的可组合函数单元,支持四种模式:
|
||||
|
||||
| 模式 | 函数签名 | 构造方法 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| Invoke | `I → O` | `compose.InvokableLambda()` | 同步调用 |
|
||||
| Stream | `I → StreamReader[O]` | `compose.StreamableLambda()` | 流式输出 |
|
||||
| Collect | `StreamReader[I] → O` | `compose.CollectableLambda()` | 流式输入 |
|
||||
| Transform | `StreamReader[I] → StreamReader[O]` | `compose.TransformableLambda()` | 双向流式 |
|
||||
|
||||
**返回类型**:所有 Lambda 构造函数返回 `*compose.Lambda`。
|
||||
|
||||
### 3.2 Graph
|
||||
|
||||
Graph 是有向无环图(DAG)编排器,支持:
|
||||
- **节点**:Lambda、ChatModel、ToolsNode 等
|
||||
- **边**:`g.AddEdge(from, to)` 定义数据流向
|
||||
- **分支**:`g.AddBranch()` 条件路由
|
||||
- **State**:`compose.WithGenLocalState()` 跨节点共享状态
|
||||
|
||||
```go
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
g.AddLambdaNode("stt", sttLambda)
|
||||
g.AddChatModelNode("llm", chatModel)
|
||||
g.AddEdge(compose.START, "stt")
|
||||
g.AddEdge("stt", "llm")
|
||||
g.AddEdge("llm", compose.END)
|
||||
|
||||
runnable, err := g.Compile(ctx)
|
||||
output, err := runnable.Invoke(ctx, input) // 同步调用
|
||||
stream, err := runnable.Stream(ctx, input) // 流式调用
|
||||
```
|
||||
|
||||
### 3.3 ChatModel
|
||||
|
||||
ChatModel 是 LLM 组件抽象,接口定义:
|
||||
|
||||
```go
|
||||
type BaseChatModel interface {
|
||||
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
|
||||
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `eino-ext/components/model/openai` 实现,通过 `BaseURL` 对接 DashScope:
|
||||
|
||||
```go
|
||||
chatModel, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
APIKey: cfg.AI.LLM.APIKey,
|
||||
Model: cfg.AI.LLM.Model,
|
||||
BaseURL: cfg.AI.LLM.Endpoint, // "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4 StreamReader
|
||||
|
||||
`schema.StreamReader[T]` 是 Eino 的流式数据抽象:
|
||||
- `sr.Recv()` 读取一帧,`io.EOF` 表示流结束
|
||||
- `schema.Pipe[T](bufSize)` 创建 `StreamReader` + `StreamWriter` 对
|
||||
- 框架自动处理 `T ↔ StreamReader[T]` 的转换(装箱/concat)
|
||||
|
||||
### 3.5 Callback
|
||||
|
||||
Callback 是 Eino 的 AOP 机制,支持节点生命周期钩子:
|
||||
|
||||
```go
|
||||
type Handler interface {
|
||||
OnStart(ctx, *RunInfo, CallbackInput) context.Context
|
||||
OnEnd(ctx, *RunInfo, CallbackOutput) context.Context
|
||||
OnError(ctx, *RunInfo, error) context.Context
|
||||
OnStartWithStreamInput(ctx, *RunInfo, *StreamReader[CallbackInput]) context.Context
|
||||
OnEndWithStreamOutput(ctx, *RunInfo, *StreamReader[CallbackOutput]) context.Context
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `utils/callbacks.NewHandlerHelper()` 构建 typed handler:
|
||||
- `ModelCallbackHandler.OnEndWithStreamOutput`:逐 token 推送 `llm_chunk`
|
||||
|
||||
### 3.6 State
|
||||
|
||||
Graph 全局状态,通过 `WithGenLocalState` 注册:
|
||||
|
||||
```go
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
|
||||
g := compose.NewGraph[I, O](compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}))
|
||||
```
|
||||
|
||||
节点通过 `compose.ProcessState` 读写 State。
|
||||
|
||||
## 4. CamTalk Graph 设计
|
||||
|
||||
### 4.1 拓扑
|
||||
|
||||
```
|
||||
START → STT → History → ChatModel → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
| 节点 | 类型 | 输入 → 输出 | 职责 |
|
||||
|------|------|------------|------|
|
||||
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别,写入 State |
|
||||
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装提示词和历史 |
|
||||
| ChatModel | ChatModel(原生) | `[]*schema.Message → StreamReader[*Message]` | LLM 流式推理 |
|
||||
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | 句子切分 |
|
||||
| TTS | InvokableLambda | `[]string → struct{}` | 语音合成,推送音频 |
|
||||
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 llm_done |
|
||||
|
||||
### 4.2 流式模式
|
||||
|
||||
Graph 使用 **Stream 模式**调用:
|
||||
- 内部所有节点以 Transform 模式运行
|
||||
- ChatModel 的 `Stream()` 方法实现真正的 token 级流式
|
||||
- 适配器消费 `StreamReader[PipelineOutput]` 触发整条链路
|
||||
|
||||
### 4.3 消息推送机制
|
||||
|
||||
| 消息 | 推送方式 | 时机 |
|
||||
|------|---------|------|
|
||||
| `stt_result` | Lambda 内部直接调用 Sender | STT 完成后 |
|
||||
| `llm_chunk` | Callback `OnEndWithStreamOutput` | ChatModel 逐 token |
|
||||
| `tts_audio` | Lambda 内部直接调用 Sender | TTS 逐句合成 |
|
||||
| `llm_done` | Lambda 内部直接调用 Sender | Done 节点执行时 |
|
||||
|
||||
**Context 注入**:Sender、RequestID、SessionID、PipelineState 通过 `context.WithValue` 传递。
|
||||
|
||||
### 4.4 多模态支持
|
||||
|
||||
History 节点将图片构建为 `schema.Message.UserInputMultiContent`:
|
||||
|
||||
```go
|
||||
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeImageURL,
|
||||
Image: &schema.MessageInputImage{
|
||||
MessagePartCommon: schema.MessagePartCommon{
|
||||
Base64Data: &base64Str,
|
||||
MIMEType: "image/jpeg",
|
||||
},
|
||||
Detail: schema.ImageURLDetailAuto,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 目录结构
|
||||
|
||||
```
|
||||
backend/internal/eino/
|
||||
├── types.go # PipelineInput/Output、STTOutput、TokenUsage
|
||||
├── state.go # PipelineState(跨节点状态)
|
||||
├── callback.go # Callback handler(LLM token 推送)
|
||||
├── graph.go # Graph 构建与编译
|
||||
├── adapter.go # EinoOrchestrator(Orchestrator 接口适配器)
|
||||
├── nodes_stt.go # STT Lambda
|
||||
├── nodes_history.go # 历史组装 Lambda
|
||||
├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
├── nodes_tts.go # TTS Lambda
|
||||
├── nodes_done.go # Done Lambda
|
||||
└── graph_test.go # 单元测试
|
||||
```
|
||||
|
||||
## 6. 注意事项
|
||||
|
||||
### 6.1 值类型 vs 指针类型
|
||||
|
||||
Graph 泛型参数必须使用值类型(`PipelineInput`/`PipelineOutput`),所有 Lambda 的输入输出也使用值类型。框架在 Transform 模式下会自动处理 `T` 和 `StreamReader[T]` 的转换。
|
||||
|
||||
### 6.2 Callback 运行时传入
|
||||
|
||||
Callback 通过 `Stream()` 的 option 传入,不在 `Compile()` 时注册:
|
||||
|
||||
```go
|
||||
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
|
||||
```
|
||||
|
||||
### 6.3 eino-ext 与 DashScope 兼容性
|
||||
|
||||
eino-ext OpenAI ChatModel 通过 `BaseURL` 对接 DashScope 兼容接口。需注意:
|
||||
- 多模态图片使用 `Base64Data` + `MIMEType` 格式
|
||||
- `Timeout` 控制单次请求超时
|
||||
- 流式输出通过 `Stream()` 方法获取 `StreamReader[*schema.Message]`
|
||||
|
||||
### 6.4 框架自动类型转换
|
||||
|
||||
Eino 框架在编排场景中自动处理以下转换:
|
||||
- **T → StreamReader[T]**:将完整值装箱为单帧流(非流式 → 假流式)
|
||||
- **StreamReader[T] → T**:将流 concat 为完整值(流式 → 非流式)
|
||||
|
||||
这使得不同流式模式的节点可以无缝连接。
|
||||
814
docs/11-令牌桶限流.md
Normal file
814
docs/11-令牌桶限流.md
Normal file
@@ -0,0 +1,814 @@
|
||||
# 令牌桶限流设计
|
||||
|
||||
## 概述
|
||||
|
||||
CamTalk 采用令牌桶(Token Bucket)算法实现按用户维度的速率限制,核心目标是**控制 AI 调用成本**,同时为 REST API 提供防暴力破解保护。
|
||||
|
||||
**设计原则**:
|
||||
- **成本优先**:主要限流对象是 WebSocket `query` 消息(每次触发 STT + LLM + TTS 完整调用链)
|
||||
- **用户隔离**:Per-user 维度限流,单用户超限不影响其他用户
|
||||
- **弹性突发**:令牌桶允许合理的突发请求,优于固定窗口的滑动限流
|
||||
- **存储适配**:内存 + Redis 双实现,单实例零依赖,多实例分布式一致
|
||||
|
||||
## 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Entry["入口层"]
|
||||
WS["WebSocket Handler<br/>query 消息"]
|
||||
REST["REST API<br/>login / register"]
|
||||
end
|
||||
|
||||
subgraph LimiterModule["Rate Limiter 模块"]
|
||||
Interface["Limiter 接口<br/>Allow(userID) → (bool, retryAfter)"]
|
||||
MemBucket["TokenBucket<br/>内存令牌桶"]
|
||||
RedisBucket["RedisTokenBucket<br/>Redis 令牌桶(Lua 脚本)"]
|
||||
Middleware["RateLimitMiddleware<br/>Gin 中间件"]
|
||||
end
|
||||
|
||||
subgraph Storage["存储层"]
|
||||
MemSync["sync.RWMutex<br/>进程内 map"]
|
||||
Redis["Redis<br/>分布式计数"]
|
||||
end
|
||||
|
||||
WS -->|"限流检查"| Interface
|
||||
REST -->|"中间件"| Middleware
|
||||
Middleware --> Interface
|
||||
Interface --> MemBucket
|
||||
Interface --> RedisBucket
|
||||
MemBucket --> MemSync
|
||||
RedisBucket --> Redis
|
||||
```
|
||||
|
||||
## 令牌桶算法
|
||||
|
||||
### 原理
|
||||
|
||||
令牌桶以固定速率向桶中添加令牌,桶有最大容量上限。每次请求消耗一个令牌,桶空时拒绝请求。
|
||||
|
||||
```
|
||||
桶容量(capacity) = 允许的突发请求数上限
|
||||
填充速率(rate) = 每秒补充的令牌数
|
||||
|
||||
时间线示例(capacity=5, rate=0.2):
|
||||
t=0s 桶满 5 令牌 → 用户连续发 5 个 query 全部通过
|
||||
t=0s 桶空 → 第 6 个 query 被拒绝,retryAfter=5s
|
||||
t=5s 桶补充 1 令牌 → 可再发 1 个 query
|
||||
t=10s 桶补充 1 令牌 → 可再发 1 个 query
|
||||
```
|
||||
|
||||
### 算法公式
|
||||
|
||||
```
|
||||
elapsed = now - lastRefill
|
||||
newTokens = elapsed * rate
|
||||
currentTokens = min(capacity, lastTokens + newTokens)
|
||||
|
||||
if currentTokens >= 1:
|
||||
currentTokens -= 1
|
||||
allowed = true
|
||||
else:
|
||||
allowed = false
|
||||
retryAfter = (1 - currentTokens) / rate
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. Limiter 接口
|
||||
|
||||
**文件位置**:`backend/internal/ratelimit/limiter.go`
|
||||
|
||||
```go
|
||||
// Limiter 速率限制器接口。
|
||||
type Limiter interface {
|
||||
// Allow 判断 key 是否允许执行一次操作。
|
||||
// key 通常为 "userID:action" 格式。
|
||||
// 返回 (allowed, retryAfter)。retryAfter 表示需要等待的时间。
|
||||
Allow(ctx context.Context, key string) (bool, time.Duration)
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- key 为字符串,不限定格式,由调用方决定维度(用户 ID、IP 地址等)
|
||||
- 返回 `retryAfter` 供客户端/服务端设置 `Retry-After` header
|
||||
- 接受 `context.Context` 支持超时和取消(Redis 实现需要)
|
||||
|
||||
### 2. 内存令牌桶(TokenBucket)
|
||||
|
||||
**文件位置**:`backend/internal/ratelimit/bucket.go`
|
||||
|
||||
```go
|
||||
// TokenBucket 内存令牌桶,适用于单实例部署。
|
||||
type TokenBucket struct {
|
||||
capacity int // 桶容量
|
||||
rate float64 // 每秒填充令牌数
|
||||
tokens float64 // 当前令牌数
|
||||
lastRefill time.Time // 上次填充时间
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Limiter 管理多个用户的令牌桶。
|
||||
type Limiter struct {
|
||||
buckets map[string]*TokenBucket
|
||||
config Config
|
||||
mu sync.RWMutex
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
```
|
||||
|
||||
**并发安全**:
|
||||
- 每个桶内部用 `sync.Mutex` 保护读写
|
||||
- 桶 map 用 `sync.RWMutex` 保护(读多写少场景)
|
||||
- 用户首次请求时惰性创建桶
|
||||
|
||||
**内存回收**:
|
||||
- 后台 goroutine 定期扫描,清理超过 10 分钟无活动的桶
|
||||
- 避免长期运行后内存泄漏
|
||||
|
||||
### 3. Redis 令牌桶(RedisTokenBucket)
|
||||
|
||||
**文件位置**:`backend/internal/ratelimit/redis_bucket.go`
|
||||
|
||||
使用 Redis Lua 脚本保证原子性,避免竞态条件:
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = 限流 key
|
||||
-- ARGV[1] = capacity(桶容量)
|
||||
-- ARGV[2] = rate(每秒填充数)
|
||||
-- ARGV[3] = now(当前时间戳,秒,浮点)
|
||||
-- ARGV[4] = ttl(key 过期时间,秒)
|
||||
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local ttl = tonumber(ARGV[4])
|
||||
|
||||
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
|
||||
local tokens = tonumber(data[1]) or capacity
|
||||
local last_refill = tonumber(data[2]) or now
|
||||
|
||||
-- 计算新令牌
|
||||
local elapsed = math.max(0, now - last_refill)
|
||||
tokens = math.min(capacity, tokens + elapsed * rate)
|
||||
|
||||
local allowed = 0
|
||||
local retry_after = 0
|
||||
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
allowed = 1
|
||||
else
|
||||
retry_after = (1 - tokens) / rate
|
||||
end
|
||||
|
||||
-- 回写状态
|
||||
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
|
||||
return {allowed, tostring(retry_after)}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 每个用户的限流状态存储为一个 Redis Hash(`tokens` + `last_refill`)
|
||||
- TTL 自动过期,无需手动清理
|
||||
- Lua 脚本保证"读取-计算-回写"原子执行
|
||||
|
||||
### 4. Gin 中间件
|
||||
|
||||
**文件位置**:`backend/internal/ratelimit/middleware.go`
|
||||
|
||||
```go
|
||||
// RateLimitMiddleware 返回 Gin 中间件,按 key 维度限流。
|
||||
// keyFunc 从请求中提取限流 key(如 IP、用户 ID)。
|
||||
func RateLimitMiddleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc
|
||||
```
|
||||
|
||||
**使用方式**:
|
||||
|
||||
```go
|
||||
// 按 IP 限流(登录/注册,未登录用户无 userID)
|
||||
loginGroup.POST("/login",
|
||||
ratelimit.Middleware(limiter, func(c *gin.Context) string {
|
||||
return c.ClientIP() + ":login"
|
||||
}),
|
||||
authHandler.Login,
|
||||
)
|
||||
|
||||
// 按用户 ID 限流(已认证的 API)
|
||||
authorized.POST("/conversations",
|
||||
ratelimit.Middleware(limiter, func(c *gin.Context) string {
|
||||
return c.GetString("user_id") + ":conversation"
|
||||
}),
|
||||
convHandler.Create,
|
||||
)
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
REST API 返回 HTTP 429:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "RATE_LIMITED",
|
||||
"message": "too many requests, retry after 5s"
|
||||
}
|
||||
```
|
||||
|
||||
同时设置 `Retry-After` header:
|
||||
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 5
|
||||
```
|
||||
|
||||
## 限流接入点
|
||||
|
||||
### WebSocket query 消息(核心)
|
||||
|
||||
在 `ws/handler.go` 的 `case "query"` 分支中,orchestrator 调用前检查:
|
||||
|
||||
```go
|
||||
case "query":
|
||||
// ... 解析消息 ...
|
||||
|
||||
// 限流检查
|
||||
if limiter != nil {
|
||||
allowed, retryAfter := limiter.Allow(ctx, userID+":query")
|
||||
if !allowed {
|
||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||
fmt.Errorf("rate limited, retry after %s", retryAfter))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// ... 继续处理 query ...
|
||||
```
|
||||
|
||||
### REST API 登录/注册
|
||||
|
||||
在 `api/auth.go` 的路由注册中添加中间件:
|
||||
|
||||
```go
|
||||
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limiter) {
|
||||
auth := rg.Group("/auth")
|
||||
if limiter != nil {
|
||||
auth.POST("/register",
|
||||
ratelimit.Middleware(limiter, ipKeyFunc("register")),
|
||||
h.Register,
|
||||
)
|
||||
auth.POST("/login",
|
||||
ratelimit.Middleware(limiter, ipKeyFunc("login")),
|
||||
h.Login,
|
||||
)
|
||||
} else {
|
||||
auth.POST("/register", h.Register)
|
||||
auth.POST("/login", h.Login)
|
||||
}
|
||||
auth.POST("/refresh", h.Refresh)
|
||||
auth.POST("/logout", h.Logout)
|
||||
}
|
||||
```
|
||||
|
||||
### 不限流的端点
|
||||
|
||||
| 端点 | 原因 |
|
||||
|------|------|
|
||||
| `ping` / `pong` | 心跳保活,无 AI 调用成本 |
|
||||
| `config` | 配置更新,无 AI 调用成本 |
|
||||
| `interrupt` | 中断请求,取消操作不应被限流 |
|
||||
| `GET /api/health` | 健康检查,运维必需 |
|
||||
| `POST /api/auth/refresh` | Token 刷新,限流会导致用户被迫重新登录 |
|
||||
| `POST /api/auth/logout` | 登出,限流会导致用户无法正常退出 |
|
||||
| `GET /api/conversations` | 查询列表,无 AI 调用成本 |
|
||||
|
||||
## 配置设计
|
||||
|
||||
### 配置文件
|
||||
|
||||
```yaml
|
||||
# backend/config/config.yaml 新增
|
||||
ratelimit:
|
||||
enabled: true
|
||||
# WebSocket query 消息限流(核心,控制 AI 成本)
|
||||
query:
|
||||
capacity: 10 # 突发容量:允许连续发 10 个 query
|
||||
rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌
|
||||
# REST API 登录限流(防暴力破解)
|
||||
login:
|
||||
capacity: 5 # 突发容量:允许连续 5 次登录尝试
|
||||
rate: 0.1 # 填充速率:每 10 秒补充 1 次
|
||||
# REST API 注册限流
|
||||
register:
|
||||
capacity: 3 # 突发容量:允许连续 3 次注册
|
||||
rate: 0.05 # 填充速率:每 20 秒补充 1 次
|
||||
```
|
||||
|
||||
### 配置结构体
|
||||
|
||||
```go
|
||||
// config/config.go 新增
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Query BucketConfig `mapstructure:"query"`
|
||||
Login BucketConfig `mapstructure:"login"`
|
||||
Register BucketConfig `mapstructure:"register"`
|
||||
}
|
||||
|
||||
type BucketConfig struct {
|
||||
Capacity int `mapstructure:"capacity"` // 桶容量(突发上限)
|
||||
Rate float64 `mapstructure:"rate"` // 每秒填充令牌数
|
||||
}
|
||||
```
|
||||
|
||||
### 默认值
|
||||
|
||||
```go
|
||||
// setDefaults 新增
|
||||
v.SetDefault("ratelimit.enabled", false)
|
||||
v.SetDefault("ratelimit.query.capacity", 10)
|
||||
v.SetDefault("ratelimit.query.rate", 0.2)
|
||||
v.SetDefault("ratelimit.login.capacity", 5)
|
||||
v.SetDefault("ratelimit.login.rate", 0.1)
|
||||
v.SetDefault("ratelimit.register.capacity", 3)
|
||||
v.SetDefault("ratelimit.register.rate", 0.05)
|
||||
```
|
||||
|
||||
### 参数选择建议
|
||||
|
||||
| 场景 | capacity | rate | 含义 |
|
||||
|------|----------|------|------|
|
||||
| WebSocket query | 10 | 0.2 | 突发 10 个,之后每 5 秒 1 个 |
|
||||
| 登录 | 5 | 0.1 | 突发 5 次,之后每 10 秒 1 次 |
|
||||
| 注册 | 3 | 0.05 | 突发 3 次,之后每 20 秒 1 次 |
|
||||
|
||||
> **调参原则**:capacity 决定"能忍多少次突发",rate 决定"稳态下多久能再请求一次"。query 的 rate 建议根据 AI 调用成本和目标月预算反推。
|
||||
|
||||
## 依赖注入
|
||||
|
||||
### main.go 初始化
|
||||
|
||||
```go
|
||||
// 初始化限流器
|
||||
var limiter ratelimit.Limiter
|
||||
if cfg.RateLimit.Enabled {
|
||||
if rdb != nil {
|
||||
// 多实例:使用 Redis 令牌桶
|
||||
limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with Redis backend")
|
||||
} else {
|
||||
// 单实例:使用内存令牌桶
|
||||
limiter = ratelimit.NewMemoryLimiter(cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with in-memory backend")
|
||||
}
|
||||
defer limiter.Stop()
|
||||
}
|
||||
```
|
||||
|
||||
### 注入到各模块
|
||||
|
||||
```go
|
||||
// WebSocket Handler —— 新增 limiter 参数
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
|
||||
|
||||
// Auth REST —— 新增 limiter 参数
|
||||
authHandler := api.NewAuthHandler(authService, tokenMgr)
|
||||
authHandler.RegisterRoutes(apiGroup, limiter)
|
||||
```
|
||||
|
||||
## 错误码
|
||||
|
||||
复用已有错误码 `RATE_LIMITED`(`backend/internal/errors/codes.go`):
|
||||
|
||||
| 传输层 | HTTP 状态码 | 错误格式 |
|
||||
|--------|-----------|---------|
|
||||
| REST API | 429 Too Many Requests | `{code: "RATE_LIMITED", message: "too many requests, retry after Xs"}` |
|
||||
| WebSocket | — | `{type: "error", code: "RATE_LIMITED", request_id: "...", message: "..."}` |
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
backend/internal/ratelimit/
|
||||
├── limiter.go # Limiter 接口 + Config 类型定义
|
||||
├── bucket.go # 内存令牌桶实现
|
||||
├── bucket_test.go # 内存令牌桶单元测试
|
||||
├── redis_bucket.go # Redis 令牌桶实现(Lua 脚本)
|
||||
├── redis_bucket_test.go# Redis 令牌桶单元测试
|
||||
└── middleware.go # Gin 中间件
|
||||
```
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 单元测试
|
||||
|
||||
**内存令牌桶**(`bucket_test.go`):
|
||||
- 首次请求通过
|
||||
- 连续消耗至桶空
|
||||
- 桶空后拒绝,返回正确 retryAfter
|
||||
- 等待后令牌补充,请求通过
|
||||
- 并发安全性(多个 goroutine 同时 Allow)
|
||||
- 桶容量边界(capacity=0, capacity=1)
|
||||
- 填充速率边界(rate=0, rate 极大值)
|
||||
- 不活跃桶的内存回收
|
||||
|
||||
**Redis 令牌桶**(`redis_bucket_test.go`):
|
||||
- 与内存实现行为一致性
|
||||
- Lua 脚本原子性
|
||||
- key TTL 自动过期
|
||||
- 并发安全性(多个客户端同时请求)
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 限流关闭时不拦截请求
|
||||
- 限流开启后,REST API 登录超限返回 429
|
||||
- 限流开启后,WebSocket query 超限返回 `RATE_LIMITED` 错误
|
||||
- 单实例内存限流 vs 多实例 Redis 限流行为一致
|
||||
- 重启后内存限流重置,Redis 限流保持
|
||||
|
||||
## 扩展点
|
||||
|
||||
### 1. 多级限流
|
||||
|
||||
可扩展为多级限流策略:
|
||||
|
||||
```
|
||||
全局限流(全用户共享) → 用户级限流(当前实现) → 端点级限流(不同 API 不同限制)
|
||||
```
|
||||
|
||||
### 2. 动态调参
|
||||
|
||||
通过配置热更新或管理 API 动态调整限流参数,无需重启:
|
||||
|
||||
```go
|
||||
// 预留接口
|
||||
type DynamicLimiter interface {
|
||||
Limiter
|
||||
UpdateConfig(action string, cfg BucketConfig) error
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 按用户等级差异化
|
||||
|
||||
不同用户等级使用不同的限流参数:
|
||||
|
||||
```yaml
|
||||
ratelimit:
|
||||
query:
|
||||
capacity: 10 # 免费用户
|
||||
rate: 0.2
|
||||
query_premium:
|
||||
capacity: 30 # 付费用户
|
||||
rate: 1.0
|
||||
```
|
||||
|
||||
### 4. 滑动窗口限流
|
||||
|
||||
令牌桶适合允许突发的场景。如果需要更平滑的限流,可增加滑动窗口实现:
|
||||
|
||||
```go
|
||||
type SlidingWindowLimiter struct {
|
||||
windowSize time.Duration
|
||||
maxRequests int
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 分布式全局限流
|
||||
|
||||
当前 Redis 实现是 Per-Instance 独立计数。如需全局精确限流,可改为 Redis 全局计数器(所有实例共享同一个 key)。
|
||||
|
||||
## 监控指标
|
||||
|
||||
### 关键指标
|
||||
|
||||
- **限流触发率**:被拒绝请求数 / 总请求数
|
||||
- **各端点限流分布**:query / login / register 各自的触发率
|
||||
- **等待时长分布**:retryAfter 的 P50/P99
|
||||
- **桶状态**:各用户桶的平均令牌数(反映使用模式)
|
||||
|
||||
### 告警规则
|
||||
|
||||
- **限流触发率突增**:可能表示异常流量或攻击
|
||||
- **单用户持续被限流**:可能表示客户端 bug(死循环请求)
|
||||
|
||||
## 实际实现要点
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
backend/internal/ratelimit/
|
||||
├── limiter.go # Limiter 接口定义
|
||||
├── bucket.go # 内存令牌桶实现 (MemoryLimiter + TokenBucket)
|
||||
├── bucket_test.go # 内存令牌桶单元测试(11 个测试用例)
|
||||
├── redis_bucket.go # Redis 令牌桶实现(Lua 脚本)
|
||||
├── redis_bucket_test.go # Redis 令牌桶单元测试
|
||||
└── middleware.go # Gin 中间件实现
|
||||
```
|
||||
|
||||
### TokenBucket 实现细节
|
||||
|
||||
**核心数据结构**(`bucket.go:12-18`):
|
||||
|
||||
```go
|
||||
type TokenBucket struct {
|
||||
capacity int // 桶容量
|
||||
rate float64 // 每秒填充令牌数
|
||||
tokens float64 // 当前令牌数(浮点数支持小数令牌)
|
||||
lastRefill time.Time // 上次填充时间
|
||||
mu sync.Mutex // 保护并发访问
|
||||
}
|
||||
```
|
||||
|
||||
**并发安全**(`bucket.go:31-56`):
|
||||
- 每个桶内部使用 `sync.Mutex` 保护 `tokens` 和 `lastRefill` 字段
|
||||
- `allow()` 方法的"读取-计算-回写"操作原子执行
|
||||
- 桶 map 使用 `sync.RWMutex` 保护,读多写少优化(`bucket.go:62`)
|
||||
- 双重检查锁(`bucket.go:106-113`):先尝试读锁获取桶,不存在时升级写锁创建
|
||||
|
||||
**内存回收机制**(`bucket.go:132-161`):
|
||||
- 后台 goroutine 每 10 分钟扫描一次(`cleanup()` 方法)
|
||||
- 删除超过 10 分钟无活动的桶(`lastRefill` 超时判断)
|
||||
- 通过 `done` channel 和 `sync.Once` 保证优雅停止
|
||||
|
||||
**惰性创建**(`bucket.go:95-121`):
|
||||
- 用户首次请求时才创建桶,避免预分配内存
|
||||
- `getOrCreateBucket()` 使用读写锁分离,优化热路径性能
|
||||
|
||||
### Gin 中间件实现
|
||||
|
||||
**实际代码**(`middleware.go:12-42`):
|
||||
|
||||
```go
|
||||
func Middleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if limiter == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := keyFunc(c)
|
||||
if key == "" {
|
||||
// key 为空时跳过限流
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
allowed, retryAfter := limiter.Allow(c.Request.Context(), key)
|
||||
|
||||
if !allowed {
|
||||
// 设置 Retry-After header(秒)
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": "RATE_LIMITED",
|
||||
"message": fmt.Sprintf("too many requests, retry after %s", retryAfter.Round(1)),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- `nil` limiter 自动跳过限流(支持配置关闭)
|
||||
- 空 key 跳过限流(支持匿名端点)
|
||||
- `retryAfter` 向上取整到秒(符合 HTTP 标准)
|
||||
- `c.Abort()` 阻止后续 handler 执行
|
||||
|
||||
### WebSocket 限流接入
|
||||
|
||||
**实际接入点**(`internal/ws/handler.go:230-240`):
|
||||
|
||||
```go
|
||||
case "query":
|
||||
// ... 解析消息 ...
|
||||
|
||||
// 限流检查
|
||||
if limiter != nil {
|
||||
key := fmt.Sprintf("%s:query", userID)
|
||||
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||
if !allowed {
|
||||
// 限流触发时自动记录 Warn 日志(在 limiter 内部使用 trace.FromContext)
|
||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// ... 继续处理 query ...
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- key 格式:`userID:query`(用户级限流)
|
||||
- 拒绝时发送 `RATE_LIMITED` 错误到客户端
|
||||
- 限流触发时 `RedisLimiter.Allow` 内部自动记录 Warn 日志(带 trace_id,详见 `docs/13-日志追踪.md`)
|
||||
- 不阻塞其他消息类型(`ping`/`config`/`interrupt` 不限流)
|
||||
|
||||
### 配置加载与依赖注入
|
||||
|
||||
**配置文件路径**:
|
||||
- 基础配置:`backend/config/config.yaml`
|
||||
- 开发环境:`backend/config/config.dev.yaml`
|
||||
- 生产环境:`backend/config/config.prod.yaml`
|
||||
|
||||
**实际配置示例**(`config.yaml:63-76`):
|
||||
|
||||
```yaml
|
||||
ratelimit:
|
||||
enabled: false # 是否启用限流
|
||||
# WebSocket query 消息限流(核心,控制 AI 成本)
|
||||
query:
|
||||
capacity: 10 # 突发容量:允许连续发 10 个 query
|
||||
rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌
|
||||
# REST API 登录限流(防暴力破解)
|
||||
login:
|
||||
capacity: 5 # 突发容量:允许连续 5 次登录尝试
|
||||
rate: 0.1 # 填充速率:每 10 秒补充 1 次
|
||||
# REST API 注册限流
|
||||
register:
|
||||
capacity: 3 # 突发容量:允许连续 3 次注册
|
||||
rate: 0.05 # 填充速率:每 20 秒补充 1 次
|
||||
```
|
||||
|
||||
**依赖注入实现**(`cmd/server/main.go:200-214`):
|
||||
|
||||
```go
|
||||
// 初始化限流器
|
||||
var limiter ratelimit.Limiter
|
||||
if cfg.RateLimit.Enabled {
|
||||
if rdb != nil {
|
||||
// 多实例:使用 Redis 令牌桶
|
||||
limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with Redis backend")
|
||||
} else {
|
||||
// 单实例:使用内存令牌桶
|
||||
limiter = ratelimit.NewMemoryLimiter(cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with in-memory backend")
|
||||
}
|
||||
defer limiter.Stop()
|
||||
} else {
|
||||
logger.Log.Info("rate limiter disabled")
|
||||
}
|
||||
```
|
||||
|
||||
**自动选择策略**:
|
||||
1. 配置关闭(`enabled: false`)→ `limiter = nil`(完全跳过限流)
|
||||
2. Redis 可用 → `NewRedisLimiter`(分布式一致)
|
||||
3. Redis 不可用 → `NewMemoryLimiter`(单实例零依赖)
|
||||
|
||||
**注入到模块**:
|
||||
|
||||
```go
|
||||
// WebSocket Handler
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
|
||||
|
||||
// REST API
|
||||
authHandler.RegisterRoutes(apiGroup, limiter)
|
||||
```
|
||||
|
||||
### 实际测试用例
|
||||
|
||||
**内存令牌桶测试**(`bucket_test.go`,11 个用例):
|
||||
|
||||
| 测试用例 | 验证内容 |
|
||||
|---------|---------|
|
||||
| `TestTokenBucket_Allow_FirstRequest` | 首次请求通过 |
|
||||
| `TestTokenBucket_Allow_ConsumeUntilEmpty` | 连续消耗至桶空 |
|
||||
| `TestTokenBucket_Allow_RetryAfterCorrect` | `retryAfter` 计算准确性 |
|
||||
| `TestTokenBucket_Allow_RefillAfterWait` | 等待后令牌补充 |
|
||||
| `TestTokenBucket_Allow_CapacityLimit` | 桶容量上限限制 |
|
||||
| `TestTokenBucket_Allow_ConcurrentSafe` | 100 并发请求正确性 |
|
||||
| `TestTokenBucket_Allow_ZeroCapacity` | 边界:`capacity=0` |
|
||||
| `TestTokenBucket_Allow_ZeroRate` | 边界:`rate=0` |
|
||||
| `TestMemoryLimiter_Allow_DifferentKeys` | 不同用户隔离 |
|
||||
| `TestMemoryLimiter_Cleanup` | 不活跃桶自动清理 |
|
||||
| `TestMemoryLimiter_Stop` | 多次 `Stop()` 不 panic |
|
||||
|
||||
**中间件测试**(`middleware_test.go`,7 个用例):
|
||||
|
||||
| 测试用例 | 验证内容 |
|
||||
|---------|---------|
|
||||
| `TestMiddleware_Allow` | 允许时正常响应 |
|
||||
| `TestMiddleware_Deny` | 拒绝时返回 429 + `Retry-After` header |
|
||||
| `TestMiddleware_NilLimiter` | `nil` limiter 放行 |
|
||||
| `TestMiddleware_EmptyKey` | 空 key 放行 |
|
||||
| `TestMiddleware_KeyFunc` | `keyFunc` 正确提取 key |
|
||||
| `TestMiddleware_RetryAfterRounding` | `retryAfter` 向上取整 |
|
||||
|
||||
**并发安全性验证**(`bucket_test.go:83-107`):
|
||||
|
||||
```go
|
||||
func TestTokenBucket_Allow_ConcurrentSafe(t *testing.T) {
|
||||
bucket := newTokenBucket(100, 10.0)
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// 100 个并发请求
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
allowed, _ := bucket.allow()
|
||||
if allowed {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 应该正好 100 个成功(桶容量为 100)
|
||||
assert.Equal(t, 100, successCount)
|
||||
}
|
||||
```
|
||||
|
||||
### Redis Lua 脚本实现
|
||||
|
||||
**实际脚本**(`redis_bucket.go:15-53`):
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = 限流 key
|
||||
-- ARGV[1] = capacity(桶容量)
|
||||
-- ARGV[2] = rate(每秒填充数)
|
||||
-- ARGV[3] = now(当前时间戳,秒,浮点)
|
||||
-- ARGV[4] = ttl(key 过期时间,秒)
|
||||
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local ttl = tonumber(ARGV[4])
|
||||
|
||||
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
|
||||
local tokens = tonumber(data[1]) or capacity
|
||||
local last_refill = tonumber(data[2]) or now
|
||||
|
||||
-- 计算新令牌
|
||||
local elapsed = math.max(0, now - last_refill)
|
||||
tokens = math.min(capacity, tokens + elapsed * rate)
|
||||
|
||||
local allowed = 0
|
||||
local retry_after = 0
|
||||
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
allowed = 1
|
||||
else
|
||||
if rate == 0 then
|
||||
retry_after = 86400 -- 24小时
|
||||
else
|
||||
retry_after = (1 - tokens) / rate
|
||||
end
|
||||
end
|
||||
|
||||
-- 回写状态
|
||||
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
|
||||
return {allowed, tostring(retry_after)}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 使用 Hash 存储两个字段:`tokens`(当前令牌数)+ `last_refill`(上次填充时间)
|
||||
- 原子性:整个脚本在 Redis 单线程中执行,无竞态条件
|
||||
- 自动过期:每次操作设置 TTL(默认 10 分钟),无需手动清理
|
||||
- 与内存实现算法一致(便于单元测试验证行为等价性)
|
||||
|
||||
### 编译期接口检查
|
||||
|
||||
**接口契约**(`bucket.go:172`,`middleware_test.go:31`):
|
||||
|
||||
```go
|
||||
// 确保 MemoryLimiter 实现了 Limiter 接口
|
||||
var _ Limiter = (*MemoryLimiter)(nil)
|
||||
|
||||
// 确保 mockLimiter 实现了 Limiter 接口
|
||||
var _ Limiter = (*mockLimiter)(nil)
|
||||
```
|
||||
|
||||
编译器会在类型不匹配时报错,避免运行时接口错误。
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
配置文件中的 `ratelimit` 配置可通过环境变量覆盖:
|
||||
|
||||
```bash
|
||||
export CAMTALK_RATELIMIT_ENABLED=true
|
||||
export CAMTALK_RATELIMIT_QUERY_CAPACITY=20
|
||||
export CAMTALK_RATELIMIT_QUERY_RATE=0.5
|
||||
```
|
||||
|
||||
环境变量优先级高于配置文件(Viper 配置绑定)。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Token Bucket 算法](https://en.wikipedia.org/wiki/Token_bucket)
|
||||
- [Redis Rate Limiting](https://redis.io/glossaries/rate-limiting/)
|
||||
- [Cloudflare - How we built rate limiting capable of scaling to millions of domains](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/)
|
||||
@@ -1,204 +0,0 @@
|
||||
# CamTalk Eino 重构实施记录
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已完成
|
||||
|
||||
## 1. 重构背景
|
||||
|
||||
CamTalk 原 AI 编排层(`internal/orchestrator/pipeline.go`)使用手写 goroutine + WaitGroup + channel 实现 STT → LLM → TTS 流式管道,存在以下问题:
|
||||
|
||||
1. **编排逻辑硬编码**:流程写死在 `ProcessQuery()` 中,扩展需重写 goroutine 调度
|
||||
2. **并发控制粗糙**:手动 `go func()` + `sync.WaitGroup`,缺乏结构化流式传递
|
||||
3. **无回调/AOP 机制**:日志、指标、追踪散落各处
|
||||
4. **配置耦合**:模型名、TTS 参数硬编码在 Pipeline 结构体
|
||||
5. **错误处理不一致**:TTS 错误被静默吞掉,缺乏统一模式
|
||||
|
||||
**重构目标**:使用 Eino Graph 替换手写 Pipeline,实现声明式编排、统一回调、按请求动态配置,保持 WebSocket 协议和 REST API 不变。
|
||||
|
||||
## 2. 整体架构变更
|
||||
|
||||
### 2.1 重构前
|
||||
|
||||
```
|
||||
WS Handler → Orchestrator.Pipeline.ProcessQuery()
|
||||
├→ goroutine: STT.Recognize()
|
||||
├→ goroutine: LLM.ChatStream() ──→ chan chunk ──→ Sender
|
||||
└→ goroutine: Splitter → TTS.SynthesizeStream() ──→ chan audio ──→ Sender
|
||||
WaitGroup.Wait()
|
||||
Sender.SendLLMDone()
|
||||
```
|
||||
|
||||
### 2.2 重构后
|
||||
|
||||
```
|
||||
WS Handler → EinoOrchestrator.ProcessQuery()
|
||||
├→ Graph.Stream(ctx, input)
|
||||
│ ├→ STT Lambda ─→ History Lambda ─→ ChatModel ─→ Splitter ─→ TTS ─→ Done
|
||||
│ │ (State 写入) (Callback (Transform) (Invoke) (Invoke)
|
||||
│ │ 流式推送)
|
||||
│ └→ 消费 StreamReader(触发整条链路惰性执行)
|
||||
└→ 追加助手消息到历史
|
||||
```
|
||||
|
||||
### 2.3 关键设计决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Graph 调用模式 | Stream | ChatModel 需要真正的 token 级流式输出 |
|
||||
| LLM 组件 | eino-ext ChatModel | 原生 Eino 组件,直接对接 DashScope |
|
||||
| 消息推送 | Callback(LLM)+ Sender(其他) | LLM token 流式推送需要 Callback |
|
||||
| 值类型 vs 指针 | 值类型统一 | 避免框架类型转换不匹配 |
|
||||
| 历史追加 | 适配器负责 | Done 节点只负责发送 llm_done |
|
||||
|
||||
## 3. 分阶段实施
|
||||
|
||||
### Phase 1:基础设施(提交 `fd5c771`)
|
||||
|
||||
**目标**:引入 Eino 依赖,创建基础类型和 Callback。
|
||||
|
||||
**任务清单**:
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 引入 Eino 依赖 | `go.mod` | `eino v0.9.9` + `eino-ext/components/model/openai v0.1.13` |
|
||||
| 数据类型定义 | `eino/types.go` | `PipelineInput`、`PipelineOutput`、`STTOutput`、`TokenUsage` |
|
||||
| State 定义 | `eino/state.go` | `PipelineState` 含 `sync.Mutex` 并发保护 |
|
||||
| 消息推送 Callback | `eino/callback.go` | `BuildCallbackHandler()` 使用 `callbacks.NewHandlerHelper()` |
|
||||
|
||||
**关键实现**:
|
||||
- `PipelineState` 使用 `strings.Builder` + `sync.Mutex` 累积 LLM 完整回复
|
||||
- Callback 通过 `ModelCallbackHandler.OnEndWithStreamOutput` 逐 token 推送 `llm_chunk`
|
||||
- Sender/RequestID/PipelineState 通过 `context.WithValue` 注入
|
||||
|
||||
**验证**:`go build ./cmd/server` ✓
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:节点实现(提交 `fd5c771`)
|
||||
|
||||
**目标**:实现 Graph 中的 5 个 Lambda 节点。
|
||||
|
||||
**任务清单**:
|
||||
|
||||
| 任务 | 文件 | Lambda 类型 | 输入 → 输出 |
|
||||
|------|------|------------|------------|
|
||||
| STT Lambda | `eino/nodes_stt.go` | InvokableLambda | `PipelineInput → STTOutput` |
|
||||
| 历史组装 Lambda | `eino/nodes_history.go` | InvokableLambda | `STTOutput → []*schema.Message` |
|
||||
| 句子分割 Lambda | `eino/nodes_splitter.go` | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` |
|
||||
| TTS Lambda | `eino/nodes_tts.go` | InvokableLambda | `[]string → struct{}` |
|
||||
| Done Lambda | `eino/nodes_done.go` | InvokableLambda | `struct{} → PipelineOutput` |
|
||||
|
||||
**关键实现**:
|
||||
- STT 节点将输入元数据写入 State,供下游节点读取
|
||||
- History 节点从 State 读取 SessionID/Scenario/ImageData,构建系统提示词 + 多模态消息
|
||||
- Splitter 使用 `TransformableLambda` 按句子分隔符切分,逐句输出给 TTS
|
||||
- TTS 节点调用 `ttsService.SynthesizeStream()`,逐 chunk 推送 `tts_audio`
|
||||
- Done 节点从 State 读取完整回复,发送 `llm_done`
|
||||
- 所有 Lambda 使用值类型(非指针),返回 `*compose.Lambda`
|
||||
|
||||
**验证**:`go build ./internal/eino/...` ✓
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:Graph 构建与适配器(提交 `4b731b5`)
|
||||
|
||||
**目标**:构建 Graph、实现适配器、切换 main.go。
|
||||
|
||||
**任务清单**:
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Graph 构建 | `eino/graph.go` | `NewPipelineGraph()` 组装 6 个节点 + 边 + 编译 |
|
||||
| 适配器 | `eino/adapter.go` | `EinoOrchestrator` 实现 `orchestrator.Orchestrator` 接口 |
|
||||
| main.go 切换 | `cmd/server/main.go` | 移除旧 LLM + orchestrator,替换为 Eino |
|
||||
|
||||
**Graph 拓扑**:
|
||||
```
|
||||
START → STT → History → ChatModel → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
**适配器职责**:
|
||||
1. 解码 base64 音频/图片
|
||||
2. 获取会话配置
|
||||
3. 注入 Sender/RequestID/SessionID/StartTime/State 到 context
|
||||
4. 追加用户消息到历史
|
||||
5. 调用 `graph.Stream(ctx, input, callbacks)` 触发惰性执行
|
||||
6. 消费 `StreamReader[PipelineOutput]`
|
||||
7. 追加助手消息到历史
|
||||
|
||||
**关键实现**:
|
||||
- eino-ext ChatModel 配置:`BaseURL` 对接 DashScope,`Timeout` 控制请求超时
|
||||
- Callback 在运行时通过 `compose.WithCallbacks()` 传入,不在编译时注册
|
||||
- 元数据(SessionID/Scenario 等)通过 State 跨节点传递,不通过 Graph 边传递
|
||||
|
||||
**变更文件**:
|
||||
- 修改 `state.go`:新增 SessionID/RequestID/ImageData 等字段
|
||||
- 修改 `nodes_stt.go`:写入元数据到 State
|
||||
- 修改 `nodes_history.go`:从 State 读取元数据(移除 HistoryInput 依赖)
|
||||
- 修改 `nodes_done.go`:移除历史追加(由适配器负责)
|
||||
|
||||
**验证**:`go build ./cmd/server` ✓,`go vet ./...` ✓
|
||||
|
||||
---
|
||||
|
||||
### Phase 4:清理与测试(提交 `4ffd845`)
|
||||
|
||||
**目标**:删除旧代码,编写单元测试。
|
||||
|
||||
**删除的文件**:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `orchestrator/pipeline.go` | 旧 STT→LLM→TTS 手写 goroutine 管道(-547 行) |
|
||||
| `orchestrator/splitter.go` | 旧句子切分器(-114 行) |
|
||||
| `orchestrator/pipeline_test.go` | 旧 Pipeline 测试(-309 行) |
|
||||
| `ai/llm/openai.go` | 旧 LLM OpenAI 实现(-548 行) |
|
||||
| `ai/llm/openai_test.go` | 旧 LLM 测试(-143 行) |
|
||||
|
||||
**保留的文件**:
|
||||
|
||||
| 文件 | 保留原因 |
|
||||
|------|---------|
|
||||
| `orchestrator/orchestrator.go` | Orchestrator 接口(ws/handler 依赖) |
|
||||
| `orchestrator/sender.go` | Sender 接口(eino/callback 依赖) |
|
||||
| `ai/llm/llm.go` | Request/Chunk/TokenUsage 类型定义 |
|
||||
| `ai/llm/prompt.go` | BuildSystemPrompt(eino/nodes_history 依赖) |
|
||||
| `ai/llm/scenarios.go` | GetScenarioPrompt(eino/nodes_history 依赖) |
|
||||
|
||||
**新增测试**:`eino/graph_test.go`(13 个测试)
|
||||
|
||||
| 测试 | 覆盖内容 |
|
||||
|------|---------|
|
||||
| `TestDetectImageMimeType` | JPEG/PNG/GIF/WebP/未知格式检测 |
|
||||
| `TestBuildPipelineInput` | 文本输入构建 |
|
||||
| `TestBuildPipelineInput_WithAudioData` | 音频+图片输入构建 |
|
||||
| `TestPipelineState_AppendAndGet` | State 文本追加和读取 |
|
||||
| `TestPipelineState_ConcurrentAccess` | State 并发安全(100 goroutine) |
|
||||
| `TestContextInjection` | Sender/RequestID/State 注入和提取 |
|
||||
| `TestLatencyFromCtx` | 延迟计算 |
|
||||
| `TestEinoOrchestrator_ImplementsInterface` | 接口实现检查 |
|
||||
| `TestNew*Lambda_ReturnsNonNil` | 5 个 Lambda 构造函数非空检查 |
|
||||
|
||||
**验证**:`go build ./...` ✓,`go vet ./...` ✓,`go test ./...` ✓
|
||||
|
||||
## 4. 代码变更统计
|
||||
|
||||
| 阶段 | 提交 | 新增 | 删除 | 净变化 |
|
||||
|------|------|------|------|--------|
|
||||
| Phase 1 + 2 | `fd5c771` | +946 | -24 | +922 |
|
||||
| Phase 3 | `4b731b5` | +395 | -98 | +297 |
|
||||
| Phase 4 | `4ffd845` | +235 | -1661 | -1426 |
|
||||
| **合计** | | **+1576** | **-1783** | **-207** |
|
||||
|
||||
重构后代码量净减少 207 行,同时获得了更好的可维护性、可测试性和可扩展性。
|
||||
|
||||
## 5. 遗留事项
|
||||
|
||||
| 事项 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| eino-ext ChatModel DashScope 兼容性端到端验证 | 高 | 需要真实 API Key 验证流式输出和多模态 |
|
||||
| LLM 超时控制 | 中 | eino-ext ChatModel 的 `Timeout` 配置需验证 |
|
||||
| TTS 流式优化 | 中 | 当前 TTS 是 InvokableLambda,可改为 StreamableLambda |
|
||||
| ReAct Agent 扩展 | 低 | 基于 Graph Branch 实现工具调用循环 |
|
||||
| Model Router | 低 | 按场景/成本路由不同 LLM |
|
||||
| 指标监控 | 低 | 通过 Callback 接入 Prometheus |
|
||||
425
docs/12-自定义情景.md
Normal file
425
docs/12-自定义情景.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# 自建情景功能
|
||||
|
||||
## 概述
|
||||
|
||||
用户可以创建自己的情景,而不仅限于系统预置的 5 种情景。
|
||||
|
||||
**系统预置情景**(不可修改):
|
||||
- 💬 自由对话
|
||||
- 🎯 模拟面试官
|
||||
- 📚 英语老师
|
||||
- ⚔️ 辩论对手
|
||||
- 🌐 同声翻译
|
||||
|
||||
**用户自建情景**(可增删改):
|
||||
- 🎨 创意写作导师
|
||||
- 🧘 心理咨询师
|
||||
- 👨🍳 私人厨师
|
||||
- 📖 历史学家
|
||||
- ... (用户自由创建)
|
||||
|
||||
**用户旅程**:
|
||||
|
||||
```
|
||||
1. 用户点击"创建情景"按钮
|
||||
↓
|
||||
2. 弹出创建对话框
|
||||
↓
|
||||
3. 填写表单:
|
||||
- 情景名称(必填)
|
||||
- 情景图标(可选)
|
||||
- 简短描述(可选)
|
||||
- 角色 Prompt(必填,最少 10 字)
|
||||
- 首句引导(可选)
|
||||
↓
|
||||
4. 点击"创建"
|
||||
↓
|
||||
5. 情景保存到数据库
|
||||
↓
|
||||
6. 情景出现在选择列表中
|
||||
↓
|
||||
7. 用户切换到自建情景
|
||||
↓
|
||||
8. AI 按照用户设定的 Prompt 扮演角色
|
||||
```
|
||||
|
||||
**核心特性**:完整 CRUD 操作(创建/查看/编辑/删除),通过 `user_id` 实现用户数据完全隔离,Eino Graph 管线深度集成(动态加载自建情景 Prompt),中文/英文/日文全覆盖,Modal 对话框 + 图标选择器 + Prompt 编写指南,创建后立即可用无需刷新。
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 数据流
|
||||
|
||||
**创建情景**:
|
||||
|
||||
```
|
||||
用户填写表单 → POST /api/scenarios → Handler 验证
|
||||
→ Repository.Create → PostgreSQL 插入 → 返回情景对象
|
||||
```
|
||||
|
||||
**AI 对话使用自建情景**:
|
||||
|
||||
```
|
||||
WebSocket 连接 → ServeWS 获取 userID
|
||||
→ Eino Graph 初始化 → nodes_history 查询 user_scenarios
|
||||
→ GetScenarioPrompt(customScenarios) → 构建 System Prompt
|
||||
→ LLM 生成回复
|
||||
```
|
||||
|
||||
### Eino 框架集成
|
||||
|
||||
**数据传递链路**:
|
||||
|
||||
```
|
||||
JWT Token → userID
|
||||
↓
|
||||
Session.UserID
|
||||
↓
|
||||
PipelineInput.UserID
|
||||
↓
|
||||
PipelineState.UserID
|
||||
↓
|
||||
nodes_history.go: scenarioRepo.FindByUserID(userID)
|
||||
↓
|
||||
构建 customScenarios map[string]string
|
||||
↓
|
||||
llm.GetScenarioPrompt(scenarioID, language, customScenarios)
|
||||
↓
|
||||
LLM 使用自建情景 Prompt
|
||||
```
|
||||
|
||||
**关键修改文件**:
|
||||
|
||||
| 文件 | 变更说明 |
|
||||
|------|----------|
|
||||
| `backend/internal/eino/state.go` | PipelineState 添加 `UserID` |
|
||||
| `backend/internal/eino/types.go` | PipelineInput 添加 `UserID` |
|
||||
| `backend/internal/eino/graph.go` | 接受 `scenarioRepo` 参数 |
|
||||
| `backend/internal/eino/adapter.go` | 设置 UserID |
|
||||
| `backend/internal/eino/nodes_history.go` | 查询自建情景 |
|
||||
| `backend/internal/ws/handler.go` | 首句引导支持自建情景 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 数据库表结构
|
||||
|
||||
**表名**: `user_scenarios`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
icon VARCHAR(10) DEFAULT '✨',
|
||||
description VARCHAR(100), -- 可选
|
||||
prompt TEXT NOT NULL,
|
||||
greeting VARCHAR(500), -- 可选
|
||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||
CONSTRAINT check_description_length CHECK (description IS NULL OR char_length(description) <= 100),
|
||||
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 10 AND char_length(prompt) <= 2000),
|
||||
CONSTRAINT check_greeting_length CHECK (greeting IS NULL OR char_length(greeting) <= 500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_scenarios_user_id ON user_scenarios(user_id);
|
||||
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 情景唯一标识 |
|
||||
| `user_id` | 所属用户,实现数据隔离 |
|
||||
| `name` | 情景名称(2-50 字符) |
|
||||
| `icon` | Emoji 图标(默认 ✨) |
|
||||
| `description` | 简短描述(可选,最多 100 字符) |
|
||||
| `prompt` | 角色 System Prompt(10-2000 字符) |
|
||||
| `greeting` | 首句引导(可选,最多 500 字符) |
|
||||
| `language` | 默认语言(zh-CN / en-US / ja-JP) |
|
||||
|
||||
### 后端数据模型
|
||||
|
||||
```go
|
||||
// backend/internal/models/user_scenario.go
|
||||
|
||||
type UserScenario struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt"`
|
||||
Greeting string `json:"greeting,omitempty"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateUserScenarioRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||
Prompt string `json:"prompt" binding:"required,min=10,max=2000"`
|
||||
Greeting string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 前端数据结构
|
||||
|
||||
```typescript
|
||||
// frontend/src/lib/api/scenarios.ts
|
||||
|
||||
export interface UserScenario {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
greeting?: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// frontend/src/hooks/useScenarios.ts
|
||||
|
||||
export interface ExtendedScenario {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
description?: string;
|
||||
descKey?: string;
|
||||
isCustom: boolean;
|
||||
prompt?: string;
|
||||
greeting?: string;
|
||||
language?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
### API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/scenarios` | 获取用户的所有自建情景 | 需登录 |
|
||||
| POST | `/api/scenarios` | 创建新情景 | 需登录 |
|
||||
| GET | `/api/scenarios/:id` | 获取单个情景详情 | 需登录 |
|
||||
| PATCH | `/api/scenarios/:id` | 更新情景 | 需登录 |
|
||||
| DELETE | `/api/scenarios/:id` | 删除情景 | 需登录 |
|
||||
|
||||
### API 示例
|
||||
|
||||
**创建情景**:
|
||||
|
||||
```http
|
||||
POST /api/scenarios
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "创意写作导师",
|
||||
"icon": "✨",
|
||||
"description": "帮助构思故事情节和写作技巧",
|
||||
"prompt": "你是一位创意写作导师,帮助用户构思故事情节、人物设定和写作技巧...",
|
||||
"greeting": "你好!我是你的创意写作导师。今天想聊聊什么故事创意呢?",
|
||||
"language": "zh-CN"
|
||||
}
|
||||
```
|
||||
|
||||
响应 201 Created:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-xxx",
|
||||
"user_id": "uuid-user",
|
||||
"name": "创意写作导师",
|
||||
"icon": "✨"
|
||||
}
|
||||
```
|
||||
|
||||
**获取列表**:
|
||||
|
||||
```http
|
||||
GET /api/scenarios
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
响应 200 OK:
|
||||
|
||||
```json
|
||||
{
|
||||
"scenarios": [],
|
||||
"total": 3
|
||||
}
|
||||
```
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 组件结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ ├── CreateScenarioModal/
|
||||
│ │ └── index.tsx # 创建情景对话框
|
||||
│ ├── EditScenarioModal/
|
||||
│ │ └── index.tsx # 编辑情景对话框
|
||||
│ └── ConfigPanel/
|
||||
│ └── index.tsx # 设置面板(改造)
|
||||
├── hooks/
|
||||
│ └── useScenarios.ts # 情景管理 Hook
|
||||
└── lib/
|
||||
└── api/
|
||||
└── scenarios.ts # API 调用封装
|
||||
```
|
||||
|
||||
### 核心 Hook
|
||||
|
||||
```typescript
|
||||
// useScenarios.ts
|
||||
|
||||
export function useScenarios(token: string | null) {
|
||||
const [allScenarios, setAllScenarios] = useState<ExtendedScenario[]>([]);
|
||||
|
||||
// 合并系统预置 + 用户自建
|
||||
useEffect(() => {
|
||||
const systemScenarios = scenarios.map(s => ({...s, isCustom: false}));
|
||||
const customScenarios = customList.map(s => ({...s, isCustom: true}));
|
||||
setAllScenarios([...systemScenarios, ...customScenarios]);
|
||||
}, [customList]);
|
||||
|
||||
return {
|
||||
allScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 创建情景表单
|
||||
|
||||
**表单字段**:
|
||||
|
||||
- 名称(必填,2-50 字符)
|
||||
- 图标(可选,24 个预设 emoji)
|
||||
- 描述(可选,最多 100 字符)
|
||||
- Prompt(必填,10-2000 字符)
|
||||
- 首句引导(可选,最多 500 字符)
|
||||
- 语言(可选,默认 zh-CN)
|
||||
|
||||
**表单验证**:
|
||||
|
||||
- 实时字符计数
|
||||
- 长度限制提示
|
||||
- 必填项高亮
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 后端 API 测试
|
||||
|
||||
```bash
|
||||
# 1. 注册用户
|
||||
curl -X POST http://localhost:8080/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"testuser","password":"test12345"}'
|
||||
|
||||
# 2. 创建情景
|
||||
TOKEN="<access_token>"
|
||||
curl -X POST http://localhost:8080/api/scenarios \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "创意写作导师",
|
||||
"icon": "✨",
|
||||
"prompt": "你是一位创意写作导师...",
|
||||
"language": "zh-CN"
|
||||
}'
|
||||
|
||||
# 3. 获取列表
|
||||
curl -X GET http://localhost:8080/api/scenarios \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. 更新情景
|
||||
curl -X PATCH http://localhost:8080/api/scenarios/<id> \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"高级写作导师"}'
|
||||
|
||||
# 5. 删除情景
|
||||
curl -X DELETE http://localhost:8080/api/scenarios/<id> \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### 前端功能测试
|
||||
|
||||
1. 刷新浏览器(Cmd+Shift+R)
|
||||
2. 登录账户
|
||||
3. 打开设置面板(右上角齿轮)
|
||||
4. 滚动到"我的情景"区域
|
||||
5. 点击"+ 创建新情景"
|
||||
6. 填写表单并提交
|
||||
7. 验证列表中出现新情景
|
||||
8. 切换到自建情景,验证首句引导
|
||||
9. 发送消息,验证 AI 使用自建 Prompt
|
||||
10. 编辑情景,验证数据预填充
|
||||
11. 删除情景,验证二次确认
|
||||
|
||||
## 安全与限制
|
||||
|
||||
### 用户配额
|
||||
|
||||
```go
|
||||
const MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||
```
|
||||
|
||||
### 权限控制
|
||||
|
||||
- 只能查看/编辑/删除自己的情景
|
||||
- 系统预置情景不可编辑/删除
|
||||
- 后端验证 `user_id` 匹配
|
||||
|
||||
### 数据验证
|
||||
|
||||
**后端**:
|
||||
|
||||
- 名称:2-50 字符
|
||||
- 描述:可选,最多 100 字符
|
||||
- Prompt:10-2000 字符
|
||||
- 首句引导:可选,最多 500 字符
|
||||
|
||||
**前端**:
|
||||
|
||||
- 实时字符计数
|
||||
- 超长提示
|
||||
- 必填项高亮
|
||||
|
||||
## 未来优化方向
|
||||
|
||||
**V1.1**:
|
||||
|
||||
- Prompt 模板库
|
||||
- 实时预览效果
|
||||
- 导入导出功能
|
||||
- 情景搜索和筛选
|
||||
|
||||
**V2.0**:
|
||||
|
||||
- 情景市场
|
||||
- 情景分享链接
|
||||
- AI 辅助优化 Prompt
|
||||
- 协作编辑(团队情景)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [CLAUDE.md](../CLAUDE.md) — 项目开发指南
|
||||
- [02-接口文档.md](./02-接口文档.md) — WebSocket 和 REST API
|
||||
- [自建情景功能-权限隔离说明.md](./自建情景功能-权限隔离说明.md) — 安全设计
|
||||
544
docs/13-日志追踪.md
Normal file
544
docs/13-日志追踪.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# 日志追踪系统
|
||||
|
||||
## 概述
|
||||
|
||||
CamTalk 全链路日志追踪系统,通过统一的 trace ID 机制,将 REST API 和 WebSocket 两大入口的所有日志串联起来,实现分布式环境下的请求链路可观测性。
|
||||
|
||||
**核心目标**:
|
||||
- 统一 trace ID 贯穿 REST/WebSocket 两大入口
|
||||
- 所有日志自动附加 trace_id/request_id/session_id
|
||||
- 保护用户隐私,敏感文本截断或降级
|
||||
- 支持按 trace_id 快速定位完整请求链路
|
||||
|
||||
## Trace ID 作用域
|
||||
|
||||
| 标识 | 作用域 | 生成时机 | 用途 |
|
||||
|-----|--------|---------|------|
|
||||
| `trace_id` | **连接级**(整个 WebSocket 生命周期)<br/>**请求级**(单次 REST 请求) | REST: 中间件生成<br/>WebSocket: 升级时生成 | 关联同一连接/请求的所有日志 |
|
||||
| `session_id` | 会话级(对话上下文存储) | ServeWS 时生成 | 标识会话存储 |
|
||||
| `request_id` | 查询级(单次 WebSocket 查询) | 客户端每次查询传入 | 区分同一连接的不同查询 |
|
||||
|
||||
**WebSocket 场景示例**:用户打开页面建立 WebSocket,发起 3 次对话查询:
|
||||
|
||||
```
|
||||
连接建立 trace_id=01J5AAA session_id=uuid-123
|
||||
├─ 查询1 trace_id=01J5AAA request_id=req-001 (问天气)
|
||||
├─ 查询2 trace_id=01J5AAA request_id=req-002 (问新闻)
|
||||
└─ 查询3 trace_id=01J5AAA request_id=req-003 (问股票)
|
||||
```
|
||||
|
||||
**REST 场景示例**:
|
||||
|
||||
```
|
||||
POST /api/auth/login trace_id=01J5BBB request_id=01J5BBB
|
||||
GET /api/conversations trace_id=01J5CCC request_id=01J5CCC
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph trace包["trace 包"]
|
||||
ID["id.go<br/>ULID 生成器"]
|
||||
CTX["context.go<br/>context key 管理"]
|
||||
LOG["logger.go<br/>context-aware logger"]
|
||||
MW["middleware.go<br/>Gin trace 中间件"]
|
||||
end
|
||||
|
||||
subgraph logger包["logger 包"]
|
||||
GINLOG["middleware.go<br/>Gin 请求日志"]
|
||||
GINREC["GinRecovery<br/>panic 恢复"]
|
||||
end
|
||||
|
||||
subgraph 入口层["入口层"]
|
||||
REST["REST API<br/>trace 中间件注入"]
|
||||
WS["WebSocket<br/>ServeWS 注入"]
|
||||
end
|
||||
|
||||
subgraph 业务层["业务层"]
|
||||
HANDLER["Handler"]
|
||||
ADAPTER["Eino Adapter"]
|
||||
NODES["Eino Nodes"]
|
||||
end
|
||||
|
||||
subgraph 存储层["存储层"]
|
||||
PG["PostgreSQL<br/>session/user/message/scenario"]
|
||||
REDIS["Redis<br/>session/cache/ratelimit"]
|
||||
end
|
||||
|
||||
ID --> MW
|
||||
CTX --> LOG
|
||||
LOG --> HANDLER
|
||||
LOG --> ADAPTER
|
||||
LOG --> NODES
|
||||
LOG --> PG
|
||||
LOG --> REDIS
|
||||
MW --> REST
|
||||
GINLOG --> REST
|
||||
WS --> LOG
|
||||
```
|
||||
|
||||
### trace/id.go — ULID 生成器
|
||||
|
||||
使用 ULID(Universally Unique Lexicographically Sortable Identifier)作为 trace ID:
|
||||
- 时间排序:前 48 位是毫秒时间戳,天然按时间排序
|
||||
- 唯一性:后 80 位随机数,冲突概率极低
|
||||
- 并发安全:使用 `crypto/rand` + `sync.Pool` 复用 entropy 对象
|
||||
|
||||
```go
|
||||
package trace
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"sync"
|
||||
"time"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var entropyPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return ulid.Monotonic(cryptorand.Reader, 0)
|
||||
},
|
||||
}
|
||||
|
||||
// GenerateTraceID 生成并发安全的 ULID trace ID
|
||||
func GenerateTraceID() string {
|
||||
entropy := entropyPool.Get().(*ulid.MonotonicEntropy)
|
||||
defer entropyPool.Put(entropy)
|
||||
return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
|
||||
}
|
||||
```
|
||||
|
||||
### trace/context.go — Context Key 管理
|
||||
|
||||
统一管理所有 trace 相关的 context key:
|
||||
|
||||
```go
|
||||
package trace
|
||||
|
||||
import "context"
|
||||
|
||||
type traceIDKey struct{}
|
||||
type requestIDKey struct{}
|
||||
type sessionIDKey struct{}
|
||||
|
||||
// WithTraceID 将 trace ID 注入 context
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey{}, traceID)
|
||||
}
|
||||
|
||||
func GetTraceID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(traceIDKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 类似定义 WithRequestID/GetRequestID 和 WithSessionID/GetSessionID
|
||||
```
|
||||
|
||||
### trace/logger.go — Context-Aware Logger
|
||||
|
||||
自动从 context 提取 trace 字段并附加到日志:
|
||||
|
||||
```go
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// FromContext 返回自动附加 trace_id/request_id/session_id 的 logger
|
||||
func FromContext(ctx context.Context) *zap.SugaredLogger {
|
||||
log := logger.Log
|
||||
|
||||
if traceID := GetTraceID(ctx); traceID != "" {
|
||||
log = log.With("trace_id", traceID)
|
||||
}
|
||||
if requestID := GetRequestID(ctx); requestID != "" {
|
||||
log = log.With("request_id", requestID)
|
||||
}
|
||||
if sessionID := GetSessionID(ctx); sessionID != "" {
|
||||
log = log.With("session_id", sessionID)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
```
|
||||
|
||||
**使用模式对比**:
|
||||
|
||||
```go
|
||||
// Before: 手动传递字段
|
||||
logger.Log.Infow("message", "session", sessionID, "request", requestID)
|
||||
|
||||
// After: 自动附加
|
||||
trace.FromContext(ctx).Infow("message")
|
||||
```
|
||||
|
||||
### trace/middleware.go — Gin Trace 中间件
|
||||
|
||||
为 REST 请求生成 trace ID 并注入 context:
|
||||
|
||||
```go
|
||||
package trace
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// TraceMiddleware 为每个 HTTP 请求生成 trace ID 并注入 context
|
||||
func TraceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
traceID := GenerateTraceID()
|
||||
ctx := WithTraceID(c.Request.Context(), traceID)
|
||||
ctx = WithRequestID(ctx, traceID) // REST: trace_id == request_id
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Header("X-Trace-ID", traceID) // 返回给客户端用于排查
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### logger/middleware.go — 请求日志与 Panic 恢复
|
||||
|
||||
记录所有 HTTP 请求的 method/path/status/latency:
|
||||
|
||||
```go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hhs/camtalk/internal/trace"
|
||||
)
|
||||
|
||||
// GinLogger 记录每个 HTTP 请求的基础信息
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
status := c.Writer.Status()
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
log.Errorw("request completed", "method", c.Request.Method,
|
||||
"path", path, "status", status, "latency_ms", latency)
|
||||
case status >= 400:
|
||||
log.Warnw("request completed", "method", c.Request.Method,
|
||||
"path", path, "status", status, "latency_ms", latency)
|
||||
default:
|
||||
log.Infow("request completed", "method", c.Request.Method,
|
||||
"path", path, "status", status, "latency_ms", latency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GinRecovery 自定义 panic 恢复中间件
|
||||
func GinRecovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log := trace.FromContext(c.Request.Context())
|
||||
log.Errorw("panic recovered", "error", err,
|
||||
"path", c.Request.URL.Path, "method", c.Request.Method)
|
||||
c.AbortWithStatus(500)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 中间件注册顺序
|
||||
|
||||
在 `cmd/server/main.go` 中,三层中间件按顺序注册:
|
||||
|
||||
```go
|
||||
r := gin.New()
|
||||
r.Use(trace.TraceMiddleware()) // 第一层:生成 trace ID
|
||||
r.Use(logger.GinLogger()) // 第二层:记录请求
|
||||
r.Use(logger.GinRecovery()) // 第三层:panic 恢复
|
||||
```
|
||||
|
||||
## 日志输出示例
|
||||
|
||||
### REST 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "info",
|
||||
"ts": 1718956800.123,
|
||||
"msg": "login success",
|
||||
"trace_id": "01J5A2B3C4D5E6F7G8H9J0K1M",
|
||||
"request_id": "01J5A2B3C4D5E6F7G8H9J0K1M",
|
||||
"username": "test_user"
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket 查询链路(含存储层)
|
||||
|
||||
```json
|
||||
// 1. 查询接收
|
||||
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"query received"}
|
||||
|
||||
// 2. 会话加载(Redis)
|
||||
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"redis session retrieved", "session_id":"abc-123"}
|
||||
|
||||
// 3. STT 完成
|
||||
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"stt recognition completed", "text_len":45}
|
||||
|
||||
// 4. LLM 完成
|
||||
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"llm generation completed", "tokens":150}
|
||||
|
||||
// 5. 消息持久化(PostgreSQL)
|
||||
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"message saved", "role":"user", "tokens_used":45}
|
||||
{"level":"debug", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"message saved", "role":"assistant", "tokens_used":150}
|
||||
|
||||
// 6. Pipeline 完成
|
||||
{"level":"info", "trace_id":"01J5XXX", "session_id":"abc-123", "request_id":"req-456", "msg":"query processing completed", "latency_ms":2340}
|
||||
```
|
||||
|
||||
### 限流触发场景
|
||||
|
||||
```json
|
||||
{"level":"warn", "trace_id":"01J5YYY", "msg":"rate limit triggered", "key":"ratelimit:user-456:query", "retry_after_sec":2.5}
|
||||
```
|
||||
|
||||
## 日志查询操作
|
||||
|
||||
### 按 trace_id 查询完整链路
|
||||
|
||||
**本地开发(文件日志)**:
|
||||
```bash
|
||||
# 查看完整链路
|
||||
grep 'trace_id":"01J5XXX"' backend.log | jq .
|
||||
|
||||
# 查看链路时间线
|
||||
grep 'trace_id":"01J5XXX"' backend.log | jq -r '[.ts, .msg] | @tsv'
|
||||
```
|
||||
|
||||
**Grafana Loki**:
|
||||
```logql
|
||||
{app="camtalk-backend"}
|
||||
|= "trace_id=01J5XXX"
|
||||
| json
|
||||
| line_format "{{.ts}} [{{.level}}] {{.msg}}"
|
||||
```
|
||||
|
||||
### 查询慢请求(延迟 > 5s)
|
||||
|
||||
```logql
|
||||
{app="camtalk-backend"}
|
||||
| json
|
||||
| msg="query processing completed"
|
||||
| latency_ms > 5000
|
||||
```
|
||||
|
||||
### 查询数据库错误
|
||||
|
||||
```logql
|
||||
{app="camtalk-backend"}
|
||||
| json
|
||||
| level="error"
|
||||
| msg=~".*failed"
|
||||
| line_format "{{.trace_id}} {{.msg}} {{.error}}"
|
||||
```
|
||||
|
||||
### 查询 Redis 降级事件
|
||||
|
||||
```logql
|
||||
{app="camtalk-backend"}
|
||||
| json
|
||||
| level="warn"
|
||||
| msg=~"redis.*failed"
|
||||
```
|
||||
|
||||
### 查询错误率
|
||||
|
||||
```logql
|
||||
sum(count_over_time({app="camtalk-backend"} | json | level="error" [5m]))
|
||||
```
|
||||
|
||||
## 敏感内容处理规范
|
||||
|
||||
### 完全禁止记录
|
||||
|
||||
- 用户明文密码
|
||||
- JWT token 完整内容(仅记录 "token_present: true")
|
||||
- API Key 完整值(仅记录前 8 字符 + "...")
|
||||
|
||||
### 截断后记录(最多 50 字符)
|
||||
|
||||
- 用户输入文本 → `text_preview`
|
||||
- LLM 生成文本 → `text_preview`
|
||||
- STT 识别文本 → `text_preview`
|
||||
|
||||
**示例**:
|
||||
```go
|
||||
log.Debugw("stt recognition completed",
|
||||
"text_len", len(text),
|
||||
"text_preview", util.Truncate(text, 50))
|
||||
```
|
||||
|
||||
### 仅记录长度/大小
|
||||
|
||||
- 图片数据 → `image_size_bytes`
|
||||
- 音频数据 → `audio_size_bytes`
|
||||
|
||||
### 降级为 Debug 级别
|
||||
|
||||
所有包含用户文本预览的日志,生产环境默认不输出。
|
||||
|
||||
## 日志级别使用准则
|
||||
|
||||
| 场景 | 级别 | 示例 |
|
||||
|-----|------|-----|
|
||||
| 请求生命周期里程碑 | Info | `"query received"`, `"pipeline completed"` |
|
||||
| 中间步骤详情 | Debug | `"stt recognition completed"`, `"history assembled"` |
|
||||
| 敏感内容相关 | Debug | 所有包含用户文本的日志 |
|
||||
| 预期内的失败 | Warn | `"login failed"`, `"rate limited"` |
|
||||
| 系统错误 | Error | `"database query failed"`, `"tts synthesis failed"` |
|
||||
| 严重故障 | Error + stack | `"panic recovered"` |
|
||||
|
||||
## 存储层日志实现
|
||||
|
||||
### PostgreSQL Repository 层
|
||||
|
||||
所有数据库操作统一使用 `trace.FromContext(ctx)` 记录日志:
|
||||
|
||||
**已实现文件**:
|
||||
- `backend/internal/store/session_pg.go` — 会话 CRUD
|
||||
- `backend/internal/store/user_pg.go` — 用户与 refresh token 操作
|
||||
- `backend/internal/store/message_pg.go` — 对话消息存储
|
||||
- `backend/internal/store/user_scenario_repository.go` — 用户自定义情景
|
||||
|
||||
**日志策略**:
|
||||
|
||||
```go
|
||||
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
_, err := r.pool.Exec(ctx, ...)
|
||||
if err != nil {
|
||||
log.Errorw("save session failed", "session_id", s.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugw("session saved", "session_id", s.ID, "user_id", s.UserID)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**NotFound 处理**:预期内的空结果不记录错误:
|
||||
|
||||
```go
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSessionNotFound // 不记录日志
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorw("find session failed", "session_id", id, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
### Redis 服务层
|
||||
|
||||
**已实现文件**:
|
||||
- `backend/internal/session/redis.go` — RedisManager(会话存储)
|
||||
- `backend/internal/store/cached_user.go` — CachedUserRepository(用户缓存装饰器)
|
||||
- `backend/internal/ratelimit/redis_bucket.go` — RedisLimiter(令牌桶限流器)
|
||||
|
||||
**会话存储日志**(`redis.go`):
|
||||
|
||||
```go
|
||||
func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
vals, err := m.rdb.HGetAll(ctx, metaKey(sessionID)).Result()
|
||||
if err != nil {
|
||||
log.Errorw("redis get session failed", "session_id", sessionID, "error", err)
|
||||
return nil, fmt.Errorf("redis get session: %w", err)
|
||||
}
|
||||
|
||||
if len(vals) == 0 {
|
||||
return nil, ErrSessionNotFound // 不记录日志
|
||||
}
|
||||
|
||||
log.Debugw("redis session retrieved", "session_id", sessionID)
|
||||
return session, nil
|
||||
}
|
||||
```
|
||||
|
||||
**缓存降级日志**(`cached_user.go`):
|
||||
|
||||
```go
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log := trace.FromContext(ctx)
|
||||
log.Warnw("redis cache write failed for refresh token", "error", err)
|
||||
// 降级:DB 已写入成功,Redis 失败不影响正确性
|
||||
}
|
||||
```
|
||||
|
||||
**限流触发日志**(`redis_bucket.go`):
|
||||
|
||||
```go
|
||||
func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
||||
log := trace.FromContext(ctx)
|
||||
|
||||
result, err := l.script.Run(ctx, ...).Result()
|
||||
if err != nil {
|
||||
log.Errorw("rate limit check failed", "key", key, "error", err)
|
||||
return true, 0 // fail-open 策略
|
||||
}
|
||||
|
||||
if allowed == 0 {
|
||||
log.Warnw("rate limit triggered", "key", key, "retry_after_sec", retryAfterSec)
|
||||
return false, retryAfter
|
||||
}
|
||||
|
||||
return true, 0
|
||||
}
|
||||
```
|
||||
|
||||
**级别选择原则**:
|
||||
- **Error**:Redis 连接失败、Lua 脚本执行失败(影响功能)
|
||||
- **Warn**:缓存写入失败(可降级)、限流触发(预期内异常)
|
||||
- **Debug**:正常操作完成(避免 Info 级别噪音)
|
||||
|
||||
## 编码规范
|
||||
|
||||
1. **日志语言**:统一使用英文
|
||||
2. **结构化**:始终使用 `Infow`/`Errorw`/`Warnw`/`Debugw`
|
||||
3. **Context 传递**:使用 `trace.FromContext(ctx)` 而非直接引用 `logger.Log`
|
||||
4. **敏感内容**:禁止在 Info 及以上级别记录用户文本原文
|
||||
5. **错误日志**:采用"调用方记录"原则,底层函数 return wrapped error
|
||||
6. **级别约定**:
|
||||
- `Debug`:内部状态跟踪、开发调试信息(数据库/缓存成功操作)
|
||||
- `Info`:请求/连接生命周期、关键操作里程碑
|
||||
- `Warn`:可降级异常(Redis 故障、限流触发)
|
||||
- `Error`:影响用户的操作失败(数据库错误、Redis 连接失败)
|
||||
- `Fatal`:仅启动阶段不可恢复错误
|
||||
7. **预期内的空结果**:`pgx.ErrNoRows`、`redis.Nil` 等不记录错误日志
|
||||
|
||||
## 性能考量
|
||||
|
||||
### FromContext 开销
|
||||
|
||||
- 有 trace_id:~200-300 ns/op
|
||||
- 无 trace_id:~10-20 ns/op(仅返回全局 logger)
|
||||
- 1000 QPS 场景额外开销约 0.2ms,可接受
|
||||
|
||||
### ULID 生成吞吐量
|
||||
|
||||
- 单线程:~500k ops/s
|
||||
- 并发 8 线程:~2M ops/s
|
||||
|
||||
**验收标准**:1000 QPS 下,trace 系统开销 < 1% CPU,< 0.5ms P99 延迟。
|
||||
@@ -8,23 +8,40 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
||||
|------|------|
|
||||
| [01-架构设计](01-架构设计.md) | 系统架构、技术栈、模块设计、数据库、部署架构(含 Mermaid 图) |
|
||||
| [02-接口文档](02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、Eino 编排器、Session Manager、配置管理、数据模型、错误码 |
|
||||
| [03-技术选型](03-技术选型.md) | 各技术的选型对比与决策理由(含 Eino 框架选型) |
|
||||
| [03-技术选型](03-技术选型.md) | 各技术的选型对比与决策理由(含 Eino 框架选型、关键技术术语) |
|
||||
| [04-用户故事](04-用户故事.md) | P0/P1/P2 用户故事、验收标准 |
|
||||
| [05-语音交互](05-语音交互.md) | VAD → STT → LLM → TTS 全链路、延迟优化 |
|
||||
| [06-视觉理解](06-视觉理解.md) | 帧采样策略、图像编码、多模态 LLM 输入机制 |
|
||||
| [07-成本控制](07-成本控制.md) | 智能采样、端云协同、模型分级、缓存复用 |
|
||||
| [08-功能创意](08-功能创意.md) | 未来功能创意清单 |
|
||||
| [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 |
|
||||
| [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 |
|
||||
| [11-Eino框架技术文档](11-Eino框架技术文档.md) | Eino 框架在 CamTalk 中的使用指南(Graph、Lambda、Callback、State) |
|
||||
| [12-Eino重构实施记录](12-Eino重构实施记录.md) | Eino 重构的四阶段实施记录、代码变更统计、测试覆盖 |
|
||||
| [08-Eino框架与编排设计](08-Eino框架与编排设计.md) | Eino 框架核心概念、Graph 设计、节点实现、测试策略 |
|
||||
| [09-情景切换](09-情景切换.md) | 多情景 AI 角色扮演系统(面试官、英语老师、辩论对手、翻译员、自由对话) |
|
||||
| [10-鉴权体系](10-鉴权体系.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 |
|
||||
| [11-令牌桶限流](11-令牌桶限流.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流 |
|
||||
| [12-自定义情景](12-自定义情景.md) | 用户自定义情景的完整设计 |
|
||||
| [13-日志追踪](13-日志追踪.md) | 全链路日志追踪系统(trace ID、敏感内容保护、日志规范) |
|
||||
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. **01-架构设计** — 理解三层架构、技术栈和模块全貌
|
||||
2. **02-接口文档** — 前后端通信契约,实现时的最高依据
|
||||
3. **03-技术选型** — 了解为什么选这些技术(含 Eino 框架)
|
||||
3. **03-技术选型** — 了解为什么选这些技术(含 Eino 框架、关键技术术语)
|
||||
4. **04-用户故事** — 明确功能优先级
|
||||
5. **05~07** — 各技术领域的详细设计
|
||||
6. **09-技术名词解释** — 遇到不熟悉的名词时查阅
|
||||
7. **10~12** — Eino 重构相关(方案、框架文档、实施记录)
|
||||
6. **08-Eino框架与编排设计** — Eino 框架核心概念、Graph 编排、节点实现
|
||||
7. **09-情景切换** — 多情景 AI 角色扮演系统
|
||||
8. **10-鉴权体系** — 认证授权机制详细设计
|
||||
9. **11-令牌桶限流** — 速率限制设计
|
||||
10. **12-自定义情景** — 用户自定义情景
|
||||
11. **13-日志追踪** — 全链路日志追踪(trace ID、敏感内容保护、开发参考)
|
||||
|
||||
## 功能扩展方向
|
||||
|
||||
以下是未来可能的功能创意和扩展方向:
|
||||
|
||||
1. **视频录制** — 支持录制对话过程中的视频画面
|
||||
2. **对话翻译** — 实时多语言翻译能力
|
||||
3. **对话总结** — 自动生成对话摘要和关键点
|
||||
4. **手动对话功能** — 支持用户手动触发对话而非自动 VAD
|
||||
5. **视频框自定义** — 视频框大小可调整,支持最小化和拖动
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 先复制依赖清单,利用 Docker 缓存层
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 复制源码并构建
|
||||
# --mount=type=cache 复用 npm 缓存,依赖不变时跳过下载
|
||||
# --no-audit 跳过安全审计,提速明显
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit
|
||||
|
||||
# 复制源码并构建(Vite 构建缓存也复用)
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
--mount=type=cache,target=/app/node_modules/.vite \
|
||||
npm run build
|
||||
|
||||
# ---- 运行阶段 ----
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="CamTalk — 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
|
||||
<title>CamTalk — AI 视觉对话助手</title>
|
||||
<meta name="description" content="CamTalk - 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
|
||||
<title>CamTalk - AI 视觉对话助手</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,35 +4,77 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useVisionSession } from "./hooks/useVisionSession";
|
||||
import { useSessionList } from "./hooks/useSessionList";
|
||||
import { useScenarios } from "./hooks/useScenarios";
|
||||
import { VideoPreview } from "./components/VideoPreview";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { SessionSidebar } from "./components/SessionSidebar";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { AuthPage } from "./components/AuthPage";
|
||||
import { LandingPage } from "./components/LandingPage";
|
||||
import { CreateScenarioModal } from "./components/CreateScenarioModal";
|
||||
import { EditScenarioModal } from "./components/EditScenarioModal";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||
import { scenarios } from "./lib/scenarios";
|
||||
import { renderScenarioIconById } from "./lib/scenarioIcons";
|
||||
import type { Locale } from "./lib/i18n";
|
||||
|
||||
/** SVG 图标 ID → emoji 映射(用于纯文本场景,如系统消息) */
|
||||
const ICON_ID_TO_EMOJI: Record<string, string> = {
|
||||
sparkles: "✨", theater: "🎭", palette: "🎨", target: "🎯", film: "🎬",
|
||||
"book-open": "📖", book: "📚", edit: "✏️", clipboard: "📋", "map-pin": "📍",
|
||||
search: "🔍", lightbulb: "💡", briefcase: "💼", code: "💻", "graduation-cap": "🎓",
|
||||
mic: "🎤", music: "🎵", globe: "🌐", heart: "❤️", shield: "🛡️",
|
||||
zap: "⚡", coffee: "☕", tool: "🔧", "message-circle": "💬",
|
||||
};
|
||||
import type { Theme } from "./types";
|
||||
import type { UserScenario } from "./lib/api/scenarios";
|
||||
import "./App.css";
|
||||
|
||||
/** AI 视觉模式 */
|
||||
type VisionMode = "realtime" | "ondemand" | "chat";
|
||||
// ---- 统一 SVG 图标系统(Feather-style,stroke-based) ----
|
||||
|
||||
const SVG_PROPS = { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||||
|
||||
/** 系统情景 SVG 图标映射 */
|
||||
function scenarioSvgIcon(id: string) {
|
||||
switch (id) {
|
||||
case "free_chat":
|
||||
return (<svg {...SVG_PROPS}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>);
|
||||
case "interviewer":
|
||||
return (<svg {...SVG_PROPS}><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>);
|
||||
case "english_teacher":
|
||||
return (<svg {...SVG_PROPS}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>);
|
||||
case "debate":
|
||||
return (<svg {...SVG_PROPS}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 10h8"/><path d="M8 14h4"/></svg>);
|
||||
case "interpreter":
|
||||
return (<svg {...SVG_PROPS}><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showCreateScenario, setShowCreateScenario] = useState(false);
|
||||
const [editingScenario, setEditingScenario] = useState<UserScenario | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ---- 自建情景管理 ----
|
||||
const {
|
||||
allScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
} = useScenarios(accessToken);
|
||||
|
||||
// 切换主题时更新 <html> 的 data-theme 属性
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
@@ -53,12 +95,13 @@ function AppContent() {
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
isLoading: sessionsLoading,
|
||||
createSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
persistSession,
|
||||
selectSession,
|
||||
} = useSessionList();
|
||||
loadSessions,
|
||||
} = useSessionList(accessToken);
|
||||
|
||||
// ---- 视觉会话 ----
|
||||
const {
|
||||
@@ -76,17 +119,19 @@ function AppContent() {
|
||||
config,
|
||||
updateConfig,
|
||||
stats,
|
||||
mode,
|
||||
isObserving,
|
||||
startSession,
|
||||
stopSession,
|
||||
stopVideo,
|
||||
interrupt,
|
||||
isCameraOn,
|
||||
isMicOn,
|
||||
toggleCamera,
|
||||
toggleMic,
|
||||
sendTextMessage,
|
||||
} = useVisionSession(accessToken);
|
||||
cameras,
|
||||
mics,
|
||||
switchDevice,
|
||||
} = useVisionSession(accessToken, activeSessionId);
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
|
||||
@@ -117,58 +162,57 @@ function AppContent() {
|
||||
|
||||
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
|
||||
|
||||
// ---- 初始化:如果没有会话,创建一个 ----
|
||||
// ---- 初始化:加载完成后如果没有会话,创建一个 ----
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initializedRef.current) {
|
||||
if (!sessionsLoading && !initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
if (sessions.length === 0) {
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}, [sessions.length, createSession]);
|
||||
|
||||
// ---- 自动保存:messages 变化时持久化到当前会话 ----
|
||||
const messagesRef = useRef(messages);
|
||||
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||
}, [sessionsLoading, sessions.length, createSession]);
|
||||
|
||||
// ---- 侧边栏打开时刷新会话列表 ----
|
||||
useEffect(() => {
|
||||
if (activeSessionId && messages.length > 0) {
|
||||
persistSession(activeSessionId, messages);
|
||||
if (sidebarOpen) {
|
||||
loadSessions();
|
||||
}
|
||||
}, [messages, activeSessionId, persistSession]);
|
||||
}, [sidebarOpen, loadSessions]);
|
||||
|
||||
// ---- 侧边栏操作 ----
|
||||
const handleNewSession = useCallback(() => {
|
||||
createSession();
|
||||
setMessages([]);
|
||||
// 如果已连接,断开
|
||||
const handleNewSession = useCallback(async () => {
|
||||
// 如果已连接,先断开
|
||||
if (connectionStatus === "connected") {
|
||||
stopSession();
|
||||
await stopSession();
|
||||
}
|
||||
// 通过后端 API 创建会话
|
||||
await createSession();
|
||||
setMessages([]);
|
||||
setSidebarOpen(false);
|
||||
}, [createSession, setMessages, connectionStatus, stopSession]);
|
||||
|
||||
const handleSelectSession = useCallback((id: string) => {
|
||||
// 保存当前会话
|
||||
if (activeSessionId && messagesRef.current.length > 0) {
|
||||
persistSession(activeSessionId, messagesRef.current);
|
||||
}
|
||||
// 如果已连接,断开
|
||||
const handleSelectSession = useCallback(async (id: string) => {
|
||||
// 如果已连接,先断开
|
||||
if (connectionStatus === "connected") {
|
||||
stopSession();
|
||||
await stopSession();
|
||||
}
|
||||
// 加载目标会话
|
||||
const loaded = selectSession(id);
|
||||
// 从后端 API 加载目标会话的消息
|
||||
const loaded = await selectSession(id);
|
||||
setMessages(loaded);
|
||||
}, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]);
|
||||
setSidebarOpen(false);
|
||||
}, [connectionStatus, stopSession, selectSession, setMessages]);
|
||||
|
||||
const handleDeleteSession = useCallback((id: string) => {
|
||||
deleteSession(id);
|
||||
const handleDeleteSession = useCallback(async (id: string) => {
|
||||
await deleteSession(id);
|
||||
if (id === activeSessionId) {
|
||||
// 断开连接并清空消息
|
||||
if (connectionStatus === "connected") {
|
||||
await stopSession();
|
||||
}
|
||||
setMessages([]);
|
||||
}
|
||||
}, [deleteSession, activeSessionId, setMessages]);
|
||||
}, [deleteSession, activeSessionId, setMessages, connectionStatus, stopSession]);
|
||||
|
||||
// ---- 识别画面 ----
|
||||
const handleRecognize = useCallback(() => {
|
||||
@@ -183,25 +227,32 @@ function AppContent() {
|
||||
|
||||
// ---- 情景选择 ----
|
||||
const handleSelectScenario = useCallback((scenarioId: string) => {
|
||||
const sc = scenarios.find(s => s.id === scenarioId);
|
||||
const sc = allScenarios.find((s) => s.id === scenarioId);
|
||||
const updates: Partial<import("./types").SessionConfig> = { scenario: scenarioId };
|
||||
// 如果情景有默认语言,同步切换
|
||||
if (sc?.defaultLanguage) {
|
||||
updates.language = sc.defaultLanguage;
|
||||
// 如果情景有默认语言,同步切换(仅系统预置情景)
|
||||
if (sc && !sc.isCustom) {
|
||||
const systemScenario = sc as any;
|
||||
if (systemScenario.defaultLanguage) {
|
||||
updates.language = systemScenario.defaultLanguage;
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
// 插入系统提示消息
|
||||
const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId;
|
||||
// 插入系统提示消息(自建情景的 icon 是 SVG ID,需转为 emoji)
|
||||
const iconText = sc
|
||||
? (ICON_ID_TO_EMOJI[sc.icon] || sc.icon)
|
||||
: "";
|
||||
const displayName = sc
|
||||
? (sc.nameKey ? tr(sc.nameKey) : sc.name)
|
||||
: scenarioId;
|
||||
const scenarioName = iconText ? `${iconText} ${displayName}` : displayName;
|
||||
setMessages(prev => [...prev, {
|
||||
id: uuidv4(),
|
||||
role: "system",
|
||||
content: tr("chat.scenarioSwitched").replace("{name}", scenarioName),
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
}, [updateConfig, setMessages, tr]);
|
||||
|
||||
// 当前情景对象
|
||||
const activeScenario = scenarios.find(s => s.id === (config.scenario || "free_chat")) || scenarios[0];
|
||||
|
||||
// ---- 键盘快捷键 ----
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -231,7 +282,7 @@ function AppContent() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage />;
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -278,22 +329,52 @@ function AppContent() {
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
open={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen((v) => !v)}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onNewSession={handleNewSession}
|
||||
onSelectSession={handleSelectSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={renameSession}
|
||||
/>
|
||||
|
||||
{showConfig && (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
theme={theme}
|
||||
username={user?.username}
|
||||
onUpdate={updateConfig}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLogout={logout}
|
||||
onClose={() => setShowConfig(false)}
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
theme={theme}
|
||||
username={user?.username}
|
||||
allScenarios={allScenarios}
|
||||
open={showConfig}
|
||||
onUpdate={updateConfig}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLogout={logout}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onCreateScenario={() => {
|
||||
setShowConfig(false);
|
||||
setShowCreateScenario(true);
|
||||
}}
|
||||
onEditScenario={(scenario) => {
|
||||
setShowConfig(false);
|
||||
setEditingScenario(scenario);
|
||||
}}
|
||||
onDeleteScenario={deleteScenario}
|
||||
/>
|
||||
|
||||
{/* 创建情景 Modal */}
|
||||
{showCreateScenario && (
|
||||
<CreateScenarioModal
|
||||
onClose={() => setShowCreateScenario(false)}
|
||||
onSubmit={async (data) => {
|
||||
await createScenario(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑情景 Modal */}
|
||||
{editingScenario && (
|
||||
<EditScenarioModal
|
||||
scenario={editingScenario}
|
||||
onClose={() => setEditingScenario(null)}
|
||||
onSubmit={async (id, data) => {
|
||||
await updateScenario(id, data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -313,13 +394,13 @@ function AppContent() {
|
||||
<div className="detail-badge">HD</div>
|
||||
)}
|
||||
{/* AI 视觉状态指示 */}
|
||||
{isConnected && visionMode !== "chat" && (
|
||||
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
|
||||
{isConnected && (
|
||||
<div className="ai-vision-indicator">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>{isObserving ? tr("video.observing") : "AI"}</span>
|
||||
<span>AI</span>
|
||||
</div>
|
||||
)}
|
||||
{isSpeaking && (
|
||||
@@ -329,7 +410,7 @@ function AppContent() {
|
||||
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
|
||||
)}
|
||||
{vadError && (
|
||||
<div className="video-indicator video-indicator--error">⚠️ {vadError}</div>
|
||||
<div className="video-indicator video-indicator--error"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> {vadError}</div>
|
||||
)}
|
||||
{!isConnected && !stream && (
|
||||
<div className="video-placeholder">
|
||||
@@ -352,6 +433,37 @@ function AppContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 情景选择芯片条(视频下方) ---- */}
|
||||
<div className="scenario-strip">
|
||||
<span className="scenario-strip__label">{tr("settings.scenario")}</span>
|
||||
<div className="scenario-strip__scroll">
|
||||
{allScenarios.map((sc) => (
|
||||
<button
|
||||
key={sc.id}
|
||||
className={`scenario-chip ${config.scenario === sc.id ? "scenario-chip--active" : ""}`}
|
||||
onClick={() => handleSelectScenario(sc.id)}
|
||||
title={sc.description ? sc.description : sc.descKey ? tr(sc.descKey) : sc.name}
|
||||
>
|
||||
<span className="scenario-chip__icon">
|
||||
{scenarioSvgIcon(sc.id) || renderScenarioIconById(sc.icon, 14) || sc.icon}
|
||||
</span>
|
||||
<span className="scenario-chip__name">{sc.nameKey ? tr(sc.nameKey) : sc.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* 新建情景快捷入口 */}
|
||||
<button
|
||||
className="scenario-chip scenario-chip--add"
|
||||
onClick={() => setShowCreateScenario(true)}
|
||||
title={tr("scenario.create.button")}
|
||||
>
|
||||
<span className="scenario-chip__icon">
|
||||
<svg {...SVG_PROPS}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</span>
|
||||
<span className="scenario-chip__name">{tr("scenario.createChip")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频下方控制区(三层结构) */}
|
||||
<div className="video-controls">
|
||||
{!isConnected ? (
|
||||
@@ -360,100 +472,123 @@ function AppContent() {
|
||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||
</button>
|
||||
</>
|
||||
) : isCameraOn ? (
|
||||
<>
|
||||
{/* 视频通话态:核心控制工具栏 */}
|
||||
<div className="video-controls__toolbar">
|
||||
<button
|
||||
className={`btn-ctrl-round ${isCameraOn ? "btn-ctrl-round--on" : "btn-ctrl-round--off"}`}
|
||||
onClick={toggleCamera}
|
||||
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{isCameraOn ? (
|
||||
<>
|
||||
<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" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`btn-ctrl-round ${isMicOn ? "btn-ctrl-round--on" : "btn-ctrl-round--off"} ${isSpeaking ? "btn-ctrl-round--speaking" : ""}`}
|
||||
onClick={toggleMic}
|
||||
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{isMicOn ? (
|
||||
<>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.12 1.49-.34 2.17" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="btn-ctrl-pill btn-ctrl-pill--recognize"
|
||||
onClick={handleRecognize}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
{tr("controls.recognize")}
|
||||
</button>
|
||||
{isProcessing && (
|
||||
<button className="btn-ctrl-pill btn-ctrl-pill--interrupt" onClick={interrupt}>
|
||||
{tr("controls.interrupt")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-ctrl-pill btn-ctrl-pill--stop" onClick={stopVideo}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
{tr("controls.stopVideo")}
|
||||
</button>
|
||||
</div>
|
||||
{/* 设备选择器 */}
|
||||
<div className="video-controls__devices">
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<label className="device-select-label"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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></label>
|
||||
<select
|
||||
className="device-select"
|
||||
value={config.cameraDeviceId || "default"}
|
||||
onChange={(e) => switchDevice("camera", e.target.value === "default" ? "" : e.target.value)}
|
||||
>
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
{cameras.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<label className="device-select-label"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg></label>
|
||||
<select
|
||||
className="device-select"
|
||||
value={config.micDeviceId || "default"}
|
||||
onChange={(e) => switchDevice("mic", e.target.value === "default" ? "" : e.target.value)}
|
||||
>
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
{mics.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 模式切换器 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 通话态:核心控制工具栏 */}
|
||||
<div className="video-controls__toolbar">
|
||||
<button
|
||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||
onClick={toggleCamera}
|
||||
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={toggleMic}
|
||||
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
{/* 识别画面按钮(按需模式下显示) */}
|
||||
{visionMode === "ondemand" && (
|
||||
<button
|
||||
className="btn--recognize"
|
||||
onClick={handleRecognize}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
🔍 {tr("controls.recognize")}
|
||||
{/* 文字对话态:视频已结束 */}
|
||||
<div className="video-controls__text-only">
|
||||
<div className="video-ended-hint">
|
||||
<span className="video-ended-hint__title"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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> {tr("video.ended")}</span>
|
||||
<span className="video-ended-hint__sub">{tr("video.ended.hint")}</span>
|
||||
</div>
|
||||
<div className="video-controls__toolbar">
|
||||
<button className="btn btn--primary" onClick={startSession}>
|
||||
{tr("controls.resumeVideo")}
|
||||
</button>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<button className="btn btn--warning" onClick={interrupt}>
|
||||
{tr("controls.interrupt")}
|
||||
<button className="btn btn--danger btn--outline" onClick={stopSession}>
|
||||
{tr("controls.endSession")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--danger" onClick={stopSession}>
|
||||
{tr("controls.stop")}
|
||||
</button>
|
||||
</div>
|
||||
{/* 通话态模式切换 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -465,19 +600,6 @@ function AppContent() {
|
||||
<div className="chat-panel-header">
|
||||
<span>{tr("chat.title")}</span>
|
||||
<div className="chat-panel-header__right">
|
||||
<select
|
||||
className="scenario-selector"
|
||||
value={activeScenario.id}
|
||||
onChange={(e) => handleSelectScenario(e.target.value)}
|
||||
title={tr("settings.scenario")}
|
||||
>
|
||||
{scenarios.map((sc) => (
|
||||
<option key={sc.id} value={sc.id}>{sc.icon} {tr(sc.nameKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
{isConnected && mode === "observation" && (
|
||||
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
||||
)}
|
||||
{isConnected && stats.queryCount > 0 && (
|
||||
<span className="chat-panel-header__stats">
|
||||
{stats.queryCount} {tr("statusbar.recognitions")}
|
||||
@@ -498,9 +620,11 @@ function AppContent() {
|
||||
currentReply={currentReply}
|
||||
connectionStatus={connectionStatus}
|
||||
currentScenario={config.scenario}
|
||||
allScenarios={allScenarios}
|
||||
isProcessing={isProcessing}
|
||||
isVADReady={isVADReady}
|
||||
vadError={vadError ?? undefined}
|
||||
isCameraOn={isCameraOn}
|
||||
isMicOn={isMicOn}
|
||||
isSpeaking={isSpeaking}
|
||||
onSendText={sendTextMessage}
|
||||
|
||||
@@ -19,10 +19,13 @@ export function useCamera() {
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
const startCamera = useCallback(async (deviceId?: string) => {
|
||||
try {
|
||||
const videoConstraints: MediaTrackConstraints = deviceId
|
||||
? { deviceId: { exact: deviceId }, width: 640, height: 480 }
|
||||
: { facingMode: "environment", width: 640, height: 480 };
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment", width: 640, height: 480 },
|
||||
video: videoConstraints,
|
||||
audio: false,
|
||||
});
|
||||
setStream(mediaStream);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// 增强:空状态情景选择卡片、语音输入按钮
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { scenarios } from "../../lib/scenarios";
|
||||
import type { ExtendedScenario } from "../../hooks/useScenarios";
|
||||
import type { ChatMessage } from "../../types";
|
||||
import type { ConnectionStatus } from "../../lib/websocket";
|
||||
|
||||
@@ -15,9 +16,11 @@ interface ChatPanelProps {
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
currentScenario?: string;
|
||||
allScenarios?: ExtendedScenario[];
|
||||
isProcessing?: boolean;
|
||||
isVADReady?: boolean;
|
||||
vadError?: string | null;
|
||||
isCameraOn?: boolean;
|
||||
isMicOn?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
onSendText?: (text: string) => void;
|
||||
@@ -27,12 +30,13 @@ interface ChatPanelProps {
|
||||
}
|
||||
|
||||
/** 场景卡片数据(视觉分析快捷) */
|
||||
function getSceneCards(t: (key: string) => string) {
|
||||
function getSceneCards(t: (key: string) => string): { icon: ReactNode; titleKey: string; descKey: string; prompt: string }[] {
|
||||
const svgProps = { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||||
return [
|
||||
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
||||
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
||||
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
||||
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
||||
{ icon: <svg {...svgProps}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>, titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
||||
{ icon: <svg {...svgProps}><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>, titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
||||
{ icon: <svg {...svgProps}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>, titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
||||
{ icon: <svg {...svgProps}><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>, titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,15 +45,16 @@ export function ChatPanel({
|
||||
currentReply,
|
||||
connectionStatus,
|
||||
currentScenario,
|
||||
allScenarios,
|
||||
isProcessing,
|
||||
isVADReady,
|
||||
vadError,
|
||||
isCameraOn,
|
||||
isMicOn,
|
||||
isSpeaking,
|
||||
onSendText,
|
||||
onToggleMic,
|
||||
onSceneCard,
|
||||
onSelectScenario,
|
||||
}: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -101,37 +106,38 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// 当前情景对象(优先从 allScenarios 查找,含自建情景;兜底用系统情景)
|
||||
const scenarioList = allScenarios || scenarios;
|
||||
const activeScenarioObj = scenarioList.find(sc => sc.id === activeScenario);
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel__messages" ref={containerRef}>
|
||||
{/* 空状态:情景选择 + 场景卡片 */}
|
||||
{/* 情景提示卡片(非空状态 + 非自由对话) */}
|
||||
{messages.length > 0 && !isFreeChat && (
|
||||
<div className="chat-panel__scenario-hint">
|
||||
<div className="scenario-hint-card">
|
||||
<span className="scenario-hint-card__icon">
|
||||
{activeScenarioObj?.icon}
|
||||
</span>
|
||||
<div className="scenario-hint-card__text">
|
||||
<strong>{activeScenarioObj ? (activeScenarioObj.nameKey ? t(activeScenarioObj.nameKey) : ('name' in activeScenarioObj ? activeScenarioObj.name : '')) : ""}</strong>
|
||||
{activeScenarioObj && (activeScenarioObj.descKey || ('description' in activeScenarioObj && activeScenarioObj.description)) && (
|
||||
<p>{activeScenarioObj.descKey ? t(activeScenarioObj.descKey) : ('description' in activeScenarioObj ? activeScenarioObj.description : '')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态:欢迎信息 */}
|
||||
{isEmpty && (
|
||||
<div className="chat-panel__welcome">
|
||||
<span className="chat-panel__welcome-icon">{scenarios.find(s => s.id === activeScenario)?.icon || "💬"}</span>
|
||||
<span className="chat-panel__welcome-icon">{activeScenarioObj?.icon || <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>}</span>
|
||||
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
||||
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||
|
||||
{/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */}
|
||||
{isFreeChat && onSelectScenario && (
|
||||
<div className="scenario-cards">
|
||||
<div className="scenario-cards__title">{t("scenario.choose")}</div>
|
||||
{scenarios.filter(s => s.id !== "free_chat").map((sc) => (
|
||||
<button
|
||||
key={sc.id}
|
||||
className="scenario-card"
|
||||
onClick={() => onSelectScenario(sc.id)}
|
||||
>
|
||||
<span className="scenario-card__icon">{sc.icon}</span>
|
||||
<div className="scenario-card__text">
|
||||
<span className="scenario-card__title">{t(sc.nameKey)}</span>
|
||||
<span className="scenario-card__desc">{t(sc.descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视觉分析快捷卡片(仅 free_chat 模式) */}
|
||||
{/* 视觉分析快捷卡片(仅 free_chat 模式 + 已连接) */}
|
||||
{isFreeChat && isConnected && (
|
||||
<div className="scene-cards">
|
||||
{sceneCards.map((card) => (
|
||||
@@ -152,13 +158,13 @@ export function ChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
{messages.map((msg) => (
|
||||
msg.role === "system" ? (
|
||||
<div key={index} className="chat-message chat-message--system">
|
||||
<div key={msg.id} className="chat-message chat-message--system">
|
||||
<span className="chat-message--system__text">{msg.content}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div key={msg.id} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div className="chat-message__role">
|
||||
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
||||
</div>
|
||||
@@ -198,7 +204,7 @@ export function ChatPanel({
|
||||
)}
|
||||
|
||||
{/* VAD 初始化中 */}
|
||||
{isConnected && isVADReady === false && !vadError && (
|
||||
{isConnected && isCameraOn && isVADReady === false && !vadError && (
|
||||
<div className="system-message system-message--info">
|
||||
<span className="typing-indicator">
|
||||
<span className="typing-indicator__dot" />
|
||||
@@ -245,7 +251,7 @@ export function ChatPanel({
|
||||
onClick={onToggleMic}
|
||||
title={t("chat.input.voice")}
|
||||
>
|
||||
🎤
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -254,7 +260,7 @@ export function ChatPanel({
|
||||
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
||||
title={t("chat.send")}
|
||||
>
|
||||
➤
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,31 +1,79 @@
|
||||
// ============================================================
|
||||
// ConfigPanel — 右侧抽屉式配置面板
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择(含自建)
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { scenarios } from "../../lib/scenarios";
|
||||
import type { SessionConfig, Theme } from "../../types";
|
||||
import type { ExtendedScenario } from "../../hooks/useScenarios";
|
||||
import type { UserScenario } from "../../lib/api/scenarios";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: SessionConfig;
|
||||
theme: Theme;
|
||||
username?: string;
|
||||
allScenarios: ExtendedScenario[];
|
||||
open: boolean;
|
||||
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
onLogout?: () => void;
|
||||
onClose: () => void;
|
||||
onCreateScenario: () => void;
|
||||
onEditScenario: (scenario: UserScenario) => void;
|
||||
onDeleteScenario: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) {
|
||||
export function ConfigPanel({
|
||||
config,
|
||||
theme,
|
||||
username,
|
||||
allScenarios,
|
||||
open,
|
||||
onUpdate,
|
||||
onThemeChange,
|
||||
onLogout,
|
||||
onClose,
|
||||
onCreateScenario,
|
||||
onEditScenario,
|
||||
onDeleteScenario,
|
||||
}: ConfigPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// 分组:系统预置 vs 自建
|
||||
const systemScenarios = allScenarios.filter((s) => !s.isCustom);
|
||||
const customScenarios = allScenarios.filter((s) => s.isCustom);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (deleteConfirm === id) {
|
||||
onDeleteScenario(id);
|
||||
setDeleteConfirm(null);
|
||||
// 如果当前选中的情景被删除,切换回自由对话
|
||||
if (config.scenario === id) {
|
||||
onUpdate({ scenario: "free_chat" });
|
||||
}
|
||||
} else {
|
||||
setDeleteConfirm(id);
|
||||
// 3秒后自动取消确认
|
||||
setTimeout(() => setDeleteConfirm(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drawer-overlay" onClick={onClose}>
|
||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={`drawer-overlay ${open ? 'drawer-overlay--visible' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`drawer ${open ? 'drawer--open' : 'drawer--collapsed'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="drawer__header">
|
||||
<span className="drawer__title">{t("settings.title")}</span>
|
||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
||||
<button className="drawer__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer__body">
|
||||
@@ -90,21 +138,89 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">{t("settings.scenario")}</span>
|
||||
<span className="config-row__desc">{t("settings.scenario.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.scenario || "free_chat"}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
{/* 系统预置情景 */}
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">{t("settings.scenario.system")}</div>
|
||||
<div className="scenario-list">
|
||||
{systemScenarios.map((sc) => (
|
||||
<label key={sc.id} className="scenario-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="scenario"
|
||||
value={sc.id}
|
||||
checked={config.scenario === sc.id}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
/>
|
||||
<span className="scenario-item__icon">{sc.icon}</span>
|
||||
<span className="scenario-item__name">
|
||||
{sc.nameKey ? t(sc.nameKey) : sc.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自建情景 */}
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">
|
||||
{t("settings.scenario.custom")}
|
||||
<button
|
||||
className="config-create-btn"
|
||||
onClick={onCreateScenario}
|
||||
title={t("scenario.create.button")}
|
||||
>
|
||||
{scenarios.map((sc) => (
|
||||
<option key={sc.id} value={sc.id}>{sc.icon} {t(sc.nameKey)}</option>
|
||||
+ {t("scenario.create.button")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customScenarios.length === 0 ? (
|
||||
<div className="scenario-empty">
|
||||
{t("settings.scenario.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="scenario-list">
|
||||
{customScenarios.map((sc) => (
|
||||
<div key={sc.id} className="scenario-item scenario-item--custom">
|
||||
<label className="scenario-item__radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="scenario"
|
||||
value={sc.id}
|
||||
checked={config.scenario === sc.id}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
/>
|
||||
<span className="scenario-item__icon">{sc.icon}</span>
|
||||
<div className="scenario-item__info">
|
||||
<span className="scenario-item__name">{sc.name}</span>
|
||||
{sc.description && (
|
||||
<span className="scenario-item__desc">{sc.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<div className="scenario-item__actions">
|
||||
<button
|
||||
className="scenario-action-btn scenario-action-btn--edit"
|
||||
onClick={() => onEditScenario(sc as unknown as UserScenario)}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className={`scenario-action-btn scenario-action-btn--delete ${
|
||||
deleteConfirm === sc.id ? "scenario-action-btn--confirm" : ""
|
||||
}`}
|
||||
onClick={() => handleDelete(sc.id)}
|
||||
title={deleteConfirm === sc.id ? t("common.confirmDelete") : t("common.delete")}
|
||||
>
|
||||
{deleteConfirm === sc.id ? "✓" : <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{username && onLogout && (
|
||||
@@ -116,10 +232,7 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
||||
<span className="config-row__desc">{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="config-logout-btn"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<button className="config-logout-btn" onClick={onLogout}>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
257
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
257
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// ============================================================
|
||||
// CreateScenarioModal — 创建自建情景对话框
|
||||
// 职责:提供表单让用户创建新的自定义情景
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { CreateScenarioRequest } from "../../lib/api/scenarios";
|
||||
import { SCENARIO_ICON_SET, DEFAULT_SCENARIO_ICON_ID } from "../../lib/scenarioIcons";
|
||||
|
||||
interface CreateScenarioModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateScenarioRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState(DEFAULT_SCENARIO_ICON_ID);
|
||||
const [description, setDescription] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [language, setLanguage] = useState("zh-CN");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 表单验证
|
||||
if (name.length < 2 || name.length > 50) {
|
||||
setError(t("scenario.error.nameLength"));
|
||||
return;
|
||||
}
|
||||
if (prompt.length < 10 || prompt.length > 2000) {
|
||||
setError(t("scenario.error.promptLength"));
|
||||
return;
|
||||
}
|
||||
if (greeting && greeting.length > 500) {
|
||||
setError(t("scenario.error.greetingLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name,
|
||||
icon,
|
||||
description: description || undefined,
|
||||
prompt,
|
||||
greeting: greeting || undefined,
|
||||
language,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<span className="modal__title">{t("scenario.create.title")}</span>
|
||||
<button className="modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="modal__body" onSubmit={handleSubmit}>
|
||||
{/* 名称 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("scenario.create.namePlaceholder")}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{name.length}/50</span>
|
||||
</div>
|
||||
|
||||
{/* 图标选择 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||
<div className="icon-picker">
|
||||
{SCENARIO_ICON_SET.map((ic) => (
|
||||
<button
|
||||
key={ic.id}
|
||||
type="button"
|
||||
className={`icon-picker__item ${icon === ic.id ? "icon-picker__item--active" : ""}`}
|
||||
onClick={() => setIcon(ic.id)}
|
||||
title={ic.label}
|
||||
>
|
||||
{ic.render(22)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.description")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="form-hint">{description.length}/100</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||
<button
|
||||
type="button"
|
||||
className="form-guide-btn"
|
||||
onClick={() => setShowGuide(!showGuide)}
|
||||
>
|
||||
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||
</button>
|
||||
</label>
|
||||
{showGuide && (
|
||||
<div className="form-guide">
|
||||
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||
<ul>
|
||||
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||
</ul>
|
||||
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||
<pre className="form-guide__code">
|
||||
{`你是一位创意写作导师。
|
||||
帮助用户构思故事情节、人物设定和写作技巧。
|
||||
|
||||
【角色定位】
|
||||
- 你是导师,不是代笔人
|
||||
- 激发用户创意,不直接给答案
|
||||
|
||||
【交互规则】
|
||||
1. 提出启发性问题
|
||||
2. 给出具体、可操作的建议
|
||||
3. 回答控制在3-5句话`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t("scenario.create.promptPlaceholder")}
|
||||
rows={8}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{prompt.length}/2000</span>
|
||||
</div>
|
||||
|
||||
{/* 首句引导 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<span className="form-hint">{greeting.length}/500</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.language")}</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("common.creating") : t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
frontend/src/components/EditScenarioModal/index.tsx
Normal file
270
frontend/src/components/EditScenarioModal/index.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
// ============================================================
|
||||
// EditScenarioModal — 编辑自建情景对话框
|
||||
// 职责:提供表单让用户编辑现有的自定义情景
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
|
||||
import { SCENARIO_ICON_SET } from "../../lib/scenarioIcons";
|
||||
|
||||
interface EditScenarioModalProps {
|
||||
scenario: UserScenario;
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState(scenario.name);
|
||||
const [icon, setIcon] = useState(scenario.icon);
|
||||
const [description, setDescription] = useState(scenario.description);
|
||||
const [prompt, setPrompt] = useState(scenario.prompt);
|
||||
const [greeting, setGreeting] = useState(scenario.greeting);
|
||||
const [language, setLanguage] = useState(scenario.language);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 表单验证
|
||||
if (name.length < 2 || name.length > 50) {
|
||||
setError(t("scenario.error.nameLength"));
|
||||
return;
|
||||
}
|
||||
if (prompt.length < 10 || prompt.length > 2000) {
|
||||
setError(t("scenario.error.promptLength"));
|
||||
return;
|
||||
}
|
||||
if (greeting && greeting.length > 500) {
|
||||
setError(t("scenario.error.greetingLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(scenario.id, {
|
||||
name,
|
||||
icon,
|
||||
description: description || undefined,
|
||||
prompt,
|
||||
greeting: greeting || undefined,
|
||||
language,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[scenario.id, name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<span className="modal__title">{t("scenario.edit.title")}</span>
|
||||
<button className="modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="modal__body" onSubmit={handleSubmit}>
|
||||
{/* 名称 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("scenario.create.namePlaceholder")}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{name.length}/50</span>
|
||||
</div>
|
||||
|
||||
{/* 图标选择 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||
<div className="icon-picker">
|
||||
{/* 如果当前图标是旧版 emoji(不在 SVG 图标集中),显示为可选项 */}
|
||||
{!SCENARIO_ICON_SET.some((ic) => ic.id === icon) && (
|
||||
<button
|
||||
key="__legacy"
|
||||
type="button"
|
||||
className={`icon-picker__item icon-picker__item--active`}
|
||||
onClick={() => {/* already selected, no-op */}}
|
||||
title="当前图标(旧版)"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)}
|
||||
{SCENARIO_ICON_SET.map((ic) => (
|
||||
<button
|
||||
key={ic.id}
|
||||
type="button"
|
||||
className={`icon-picker__item ${icon === ic.id ? "icon-picker__item--active" : ""}`}
|
||||
onClick={() => setIcon(ic.id)}
|
||||
title={ic.label}
|
||||
>
|
||||
{ic.render(22)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.description")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="form-hint">{description.length}/100</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||
<button
|
||||
type="button"
|
||||
className="form-guide-btn"
|
||||
onClick={() => setShowGuide(!showGuide)}
|
||||
>
|
||||
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||
</button>
|
||||
</label>
|
||||
{showGuide && (
|
||||
<div className="form-guide">
|
||||
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||
<ul>
|
||||
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||
</ul>
|
||||
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||
<pre className="form-guide__code">
|
||||
{`你是一位创意写作导师。
|
||||
帮助用户构思故事情节、人物设定和写作技巧。
|
||||
|
||||
【角色定位】
|
||||
- 你是导师,不是代笔人
|
||||
- 激发用户创意,不直接给答案
|
||||
|
||||
【交互规则】
|
||||
1. 提出启发性问题
|
||||
2. 给出具体、可操作的建议
|
||||
3. 回答控制在3-5句话`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t("scenario.create.promptPlaceholder")}
|
||||
rows={8}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{prompt.length}/2000</span>
|
||||
</div>
|
||||
|
||||
{/* 首句引导 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<span className="form-hint">{greeting.length}/500</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.language")}</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("common.saving") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1183
frontend/src/components/LandingPage/LandingPage.css
Normal file
1183
frontend/src/components/LandingPage/LandingPage.css
Normal file
File diff suppressed because it is too large
Load Diff
163
frontend/src/components/LandingPage/LoginModal.tsx
Normal file
163
frontend/src/components/LandingPage/LoginModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ============================================================
|
||||
// LoginModal — 登录 / 注册模态框
|
||||
// 职责:在 LandingPage 上弹出的认证表单,复用现有 auth 系统
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../../lib/auth";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
interface LoginModalProps {
|
||||
initialMode?: AuthMode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ initialMode = "login", onClose }: LoginModalProps) {
|
||||
const { login, register } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (username.length < 3 || username.length > 64) {
|
||||
setError(t("auth.error.usernameLength"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 8 || password.length > 72) {
|
||||
setError(t("auth.error.passwordLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const fn = mode === "login" ? login : register;
|
||||
const result = await fn(username, password);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
// 登录成功时 auth 状态更新,App 自动切到主界面,modal 自然消失
|
||||
},
|
||||
[username, password, mode, login, register, t]
|
||||
);
|
||||
|
||||
const switchMode = useCallback(() => {
|
||||
setMode((m) => (m === "login" ? "register" : "login"));
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="lp-modal-overlay" ref={overlayRef} onClick={handleOverlayClick}>
|
||||
<div className="lp-modal" role="dialog" aria-modal="true">
|
||||
<div className="lp-modal__inner">
|
||||
<button type="button" className="lp-modal__close" onClick={onClose} aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="lp-modal__header">
|
||||
<div className="lp-modal__logo">CamTalk</div>
|
||||
<div className="lp-modal__subtitle">{t("auth.subtitle")}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="lp-modal__tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`lp-modal__tab ${mode === "login" ? "lp-modal__tab--active" : ""}`}
|
||||
onClick={() => { setMode("login"); setError(""); }}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`lp-modal__tab ${mode === "register" ? "lp-modal__tab--active" : ""}`}
|
||||
onClick={() => { setMode("register"); setError(""); }}
|
||||
>
|
||||
{t("auth.register")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="lp-modal__field">
|
||||
<span className="lp-modal__field-label">{t("auth.username")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="lp-modal__field-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("auth.username.placeholder")}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="lp-modal__field">
|
||||
<span className="lp-modal__field-label">{t("auth.password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="lp-modal__field-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("auth.password.placeholder")}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className="lp-modal__error">{error}</div>}
|
||||
|
||||
<button type="submit" className="lp-modal__submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("auth.submitting")
|
||||
: mode === "login"
|
||||
? t("auth.login")
|
||||
: t("auth.register")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="lp-modal__footer">
|
||||
{mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")}
|
||||
<button type="button" className="lp-modal__link" onClick={switchMode}>
|
||||
{mode === "login" ? t("auth.register") : t("auth.login")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
frontend/src/components/LandingPage/index.tsx
Normal file
390
frontend/src/components/LandingPage/index.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
// ============================================================
|
||||
// LandingPage — 官网首页(含登录弹窗)
|
||||
// 职责:未登录用户的落地页,展示产品介绍并提供登录/注册入口
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
import "./LandingPage.css";
|
||||
|
||||
type ModalMode = "login" | "register";
|
||||
|
||||
export function LandingPage() {
|
||||
const [modal, setModal] = useState<{ open: boolean; mode: ModalMode }>({
|
||||
open: false,
|
||||
mode: "login",
|
||||
});
|
||||
|
||||
const openModal = useCallback((mode: ModalMode) => {
|
||||
setModal({ open: true, mode });
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModal((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
// ---- Scroll Reveal ----
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("lp-visible");
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
|
||||
);
|
||||
|
||||
document.querySelectorAll(".lp-fade-in").forEach((el) => {
|
||||
observerRef.current?.observe(el);
|
||||
});
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<div className="lp-bg-grid" />
|
||||
|
||||
{/* ========== Top Navigation ========== */}
|
||||
<nav className="lp-nav">
|
||||
<div className="lp-nav__logo">CamTalk</div>
|
||||
<div className="lp-nav__links">
|
||||
<a href="#problem">痛点</a>
|
||||
<a href="#features">特性</a>
|
||||
<a href="#scenes">场景</a>
|
||||
<a href="#tech">技术</a>
|
||||
</div>
|
||||
<div className="lp-nav__actions">
|
||||
<button type="button" className="lp-nav__btn lp-nav__btn--ghost" onClick={() => openModal("login")}>
|
||||
登录
|
||||
</button>
|
||||
<button type="button" className="lp-nav__btn lp-nav__btn--primary" onClick={() => openModal("register")}>
|
||||
免费注册
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ========== HERO ========== */}
|
||||
<section className="lp-hero">
|
||||
<div className="lp-container lp-hero-content">
|
||||
<div className="lp-hero-badge">
|
||||
<span className="lp-hero-badge__dot" />
|
||||
<span>XEngineers</span>
|
||||
</div>
|
||||
<h1>
|
||||
<span className="lp-gradient">CamTalk</span>
|
||||
<br />
|
||||
给 AI 装上眼睛和耳朵
|
||||
</h1>
|
||||
<p className="lp-hero__sub">
|
||||
多模态实时 AI 视觉对话助手。打开浏览器,对着摄像头说话,AI 实时理解画面和语音,以文字和语音同步回答你。
|
||||
</p>
|
||||
|
||||
<div className="lp-hero-actions">
|
||||
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
|
||||
立即体验 CamTalk
|
||||
</button>
|
||||
<div className="lp-hero-actions__links">
|
||||
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
|
||||
路演视频
|
||||
</a>
|
||||
<a href="http://8.161.227.145:3000/XEngineers/CamTalk" target="_blank" rel="noreferrer">
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Mockup - Double Bezel */}
|
||||
<div className="lp-hero-visual">
|
||||
<div className="lp-hero-visual__inner">
|
||||
<div className="lp-hero-visual__screen">
|
||||
<div className="lp-screen-left">
|
||||
<div className="lp-scan-line" />
|
||||
<div className="lp-camera-ring">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M23 7l-7 5 7 5V7z" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-screen-right">
|
||||
<div className="lp-chat-bubble lp-chat-bubble--user">这道数学题怎么做?</div>
|
||||
<div className="lp-chat-bubble lp-chat-bubble--ai">
|
||||
这是一道二次方程求解题。观察方程 x² - 5x + 6 = 0,可以使用因式分解法……
|
||||
<div className="lp-typing">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-chat-bubble lp-chat-bubble--user">能用求根公式再算一遍吗?</div>
|
||||
<div className="lp-audio-wave">
|
||||
<span /><span /><span /><span /><span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== PROBLEM ========== */}
|
||||
<section id="problem">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<div className="lp-section-header__tag">痛点分析</div>
|
||||
<h2>AI 能说会道,却看不到你眼前的世界</h2>
|
||||
<p>传统 AI 助手存在三大断层,割裂了自然交流的直觉</p>
|
||||
</div>
|
||||
<div className="lp-problem-grid">
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>视觉断层</h3>
|
||||
<p>用户必须先拍照、保存、上传、再打字描述上下文,AI 才能「看到」画面。四步操作,割裂了自然交流的直觉。</p>
|
||||
</div>
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>交互断层</h3>
|
||||
<p>面对外语菜单、数学公式、电路图等复杂内容,打字描述极其低效。用户脑中的问题转不成文字,AI 也就无法作答。</p>
|
||||
</div>
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>成本断层</h3>
|
||||
<p>实时视频流 + 大模型推理的组合让 API 成本居高不下。传统方案月成本高达 $5,000,无法面向普通用户商业化。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== SOLUTION ========== */}
|
||||
<section id="solution">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>看 + 听 + 说 = 真正理解你的 AI</h2>
|
||||
<p>CamTalk 用一个对话界面闭合所有断层</p>
|
||||
</div>
|
||||
<div className="lp-flow-wrapper lp-fade-in">
|
||||
<div className="lp-flow-steps">
|
||||
{[
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={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>, label: "摄像头采集" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a5 5 0 0 1 5 5c0 1.5-.7 2.8-1.7 3.7L12 14l-3.3-3.3A5 5 0 0 1 12 2z"/><path d="M12 14v8"/><path d="M8 18h8"/></svg>, label: "边缘预处理" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>, label: "WebSocket" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>, label: "Eino 编排" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>, label: "视觉理解" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>, label: "流式回复" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>, label: "语音输出" },
|
||||
].map((step, i, arr) => (
|
||||
<div key={i} style={{ display: "contents" }}>
|
||||
<div className="lp-flow-step">
|
||||
<div className="lp-flow-step__node">{step.icon}</div>
|
||||
<div className="lp-flow-step__label">{step.label}</div>
|
||||
</div>
|
||||
{i < arr.length - 1 && <div className="lp-flow-arrow">→</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== FEATURES ========== */}
|
||||
<section id="features">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<div className="lp-section-header__tag">核心亮点</div>
|
||||
<h2>五大技术创新</h2>
|
||||
<p>从端到端架构到成本控制,每一层都经过精心设计</p>
|
||||
</div>
|
||||
<div className="lp-features-grid">
|
||||
{[
|
||||
{
|
||||
num: "01", icon: <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
|
||||
title: "流式并行推送",
|
||||
desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。",
|
||||
},
|
||||
{
|
||||
num: "02", icon: <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a5 5 0 0 1 5 5c0 1.5-.7 2.8-1.7 3.7L12 14l-3.3-3.3A5 5 0 0 1 12 2z"/><path d="M12 14v8"/><path d="M8 18h8"/></svg>,
|
||||
title: "声明式 AI 编排",
|
||||
desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线(STT → History → ChatModel → Splitter → TTS),类型安全、可扩展、易测试。",
|
||||
},
|
||||
{
|
||||
num: "03", icon: <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>,
|
||||
title: "端云协同降本",
|
||||
desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300,降幅 90%。",
|
||||
},
|
||||
{
|
||||
num: "04", icon: <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>,
|
||||
title: "多场景智能模式",
|
||||
desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。",
|
||||
},
|
||||
{
|
||||
num: "05", icon: <svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12" y2="18"/><line x1="12" y1="6" x2="12" y2="6"/><line x1="4" y1="10" x2="20" y2="10"/><line x1="8" y1="2" x2="8" y2="10"/><line x1="16" y1="10" x2="16" y2="18"/></svg>,
|
||||
title: "生产级工程架构",
|
||||
desc: "三级存储自动降级(Memory → Redis → PostgreSQL)、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。",
|
||||
},
|
||||
].map((f) => (
|
||||
<div className="lp-feature-card lp-fade-in" key={f.num}>
|
||||
<div className="lp-feature-card__number">{f.num}</div>
|
||||
<div className="lp-feature-card__icon">
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3>{f.title}</h3>
|
||||
<p>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== USERS ========== */}
|
||||
<section id="users">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>为每一类学习者而设计</h2>
|
||||
<p>无论你是在学英语、准备面试,还是想让 AI 帮你看看眼前的世界</p>
|
||||
</div>
|
||||
<div className="lp-users-grid">
|
||||
{[
|
||||
{ avatar: <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>, title: "语言学习者", desc: "对着课本或实物,与 AI 英语外教用英语自由对话,实时纠正语法和发音" },
|
||||
{ avatar: <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>, title: "面试准备者", desc: "开启模拟面试模式,AI 面试官通过摄像头观察你的表情与状态,给出针对性反馈" },
|
||||
{ avatar: <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>, title: "跨境交流者", desc: "出国旅行时对着外文菜单、路牌实时翻译,AI 语音播报翻译结果" },
|
||||
{ avatar: <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>, title: "视障人士", desc: "AI 实时描述摄像头画面中的环境、障碍物和文字,提供无障碍信息辅助" },
|
||||
{ avatar: <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>, title: "学生 / 教师", desc: "对着题目问「怎么做?」,AI 看到画面后逐步讲解,就像身边有一位私教" },
|
||||
].map((u) => (
|
||||
<div className="lp-user-card lp-fade-in" key={u.title}>
|
||||
<div className="lp-user-card__avatar">{u.avatar}</div>
|
||||
<h4>{u.title}</h4>
|
||||
<p>{u.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== SCENES ========== */}
|
||||
<section id="scenes">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>五种模式,覆盖真实需求</h2>
|
||||
</div>
|
||||
<div className="lp-scenes-list">
|
||||
{[
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>, title: "自由对话", desc: "对着摄像头随意聊天,AI 实时理解画面并语音回答", tag: "通用" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>, title: "英语老师", desc: "AI 外教结合摄像头场景进行英语口语教学,实时纠正语法", tag: "学习" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>, title: "模拟面试", desc: "AI 面试官根据你的回答追问,通过摄像头观察你的表现", tag: "求职" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 10h8"/><path d="M8 14h4"/></svg>, title: "辩论对手", desc: "AI 反驳你的观点,锻炼你的逻辑思维和表达能力", tag: "思维" },
|
||||
{ icon: <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>, title: "同声翻译", desc: "实时识别画面中的外语文字并语音翻译,口语化输出", tag: "工具" },
|
||||
].map((s) => (
|
||||
<div className="lp-scene-row lp-fade-in" key={s.title}>
|
||||
<div className="lp-scene-row__icon">{s.icon}</div>
|
||||
<div>
|
||||
<h4>{s.title}</h4>
|
||||
<div className="lp-scene-row__desc">{s.desc}</div>
|
||||
</div>
|
||||
<div className="lp-scene-row__tag">
|
||||
{s.tag}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== METRICS ========== */}
|
||||
<section id="metrics">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>用数据说话</h2>
|
||||
</div>
|
||||
<div className="lp-metrics-grid">
|
||||
{[
|
||||
{ value: "< 2s", label: "端到端响应延迟" },
|
||||
{ value: "90%", label: "API 成本降幅" },
|
||||
{ value: "70%", label: "带宽节省率" },
|
||||
{ value: "5+3", label: "场景 × 视觉模式" },
|
||||
].map((m) => (
|
||||
<div className="lp-metric-card lp-fade-in" key={m.label}>
|
||||
<div className="lp-metric-card__value">{m.value}</div>
|
||||
<div className="lp-metric-card__label">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TECH STACK ========== */}
|
||||
<section id="tech">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>三层系统,生产级质量</h2>
|
||||
<p>前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用</p>
|
||||
</div>
|
||||
<div className="lp-tech-layers">
|
||||
{[
|
||||
{ badge: "前端层", tags: ["React 18", "TypeScript", "Vite", "WebRTC VAD", "Canvas 关键帧检测", "WebSocket", "i18n (中/英/日)"] },
|
||||
{ badge: "网关层", tags: ["Go + Gin", "gorilla/websocket", "Eino Graph", "JWT 双 Token", "Zap 日志", "Viper 配置"] },
|
||||
{ badge: "存储层", tags: ["L1 Memory", "L2 Redis", "L3 PostgreSQL", "TieredManager 自动降级"] },
|
||||
{ badge: "AI 服务", tags: ["qwen3-vl-plus (LLM)", "MiMo ASR (STT)", "MiMo TTS", "Docker Compose"] },
|
||||
].map((layer) => (
|
||||
<div className="lp-tech-layer lp-fade-in" key={layer.badge}>
|
||||
<div>
|
||||
<div className="lp-tech-layer__badge">
|
||||
{layer.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-tech-layer__tags">
|
||||
{layer.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className="lp-cta-section">
|
||||
<div className="lp-container lp-fade-in">
|
||||
<h2>让 AI 看见你看见的世界</h2>
|
||||
<p>CamTalk,不只是聊天,而是真正的多模态视觉对话。</p>
|
||||
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
|
||||
立即体验 CamTalk
|
||||
</button>
|
||||
<div className="lp-cta-links">
|
||||
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
|
||||
路演视频
|
||||
</a>
|
||||
<a href="http://8.161.227.145:3000/XEngineers/CamTalk" target="_blank" rel="noreferrer">
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== Footer ========== */}
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-container">
|
||||
CamTalk © 2026 XEngineers
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* ========== Login Modal ========== */}
|
||||
{modal.open && (
|
||||
<LoginModal initialMode={modal.mode} onClose={closeModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,15 +12,13 @@ export function useMicrophone() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
const startMic = useCallback(async () => {
|
||||
const startMic = useCallback(async (deviceId?: string) => {
|
||||
try {
|
||||
const audioConstraints: MediaTrackConstraints = deviceId
|
||||
? { deviceId: { exact: deviceId }, sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true };
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
audio: audioConstraints,
|
||||
video: false,
|
||||
});
|
||||
setStream(mediaStream);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user