Compare commits
112 Commits
492fb06c08
...
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 | |||
| ad5d90e344 | |||
| 8b4acb3ce7 | |||
| d0f5f5c94d | |||
| 023c834074 | |||
| 6af26ffc91 | |||
| e151c5b665 | |||
| 23e0e22a12 | |||
| f8af2f0ccc | |||
| f5440c9e2d | |||
| 340c26b7a6 | |||
| f86c2560cd | |||
| b7d0edb6da | |||
| 04215e7a53 | |||
| bc7eb7409c | |||
| 0447fdacac | |||
| 32aea44f3b | |||
| 2a13ee9c89 | |||
| ae100c4a75 | |||
| 97e125234b | |||
| dfed964f76 | |||
| 1c65433d40 | |||
| 15cd157f45 | |||
| 1f7cd407a8 | |||
| 52015fa6c6 | |||
| de78d60959 | |||
| 795ace75d6 | |||
| 582f68f68b | |||
| 556666c046 | |||
| 04dac0b673 | |||
| f38fbf0527 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,4 +24,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# ---- Obsidian ----
|
# ---- Obsidian ----
|
||||||
.obsidian/
|
.obsidian/
|
||||||
.claudian/sessions/conv-1781943335504-q62bzosye.meta.json
|
.claudian/
|
||||||
|
修改过程笔记/
|
||||||
|
学习复盘/
|
||||||
|
docs/follow-up/
|
||||||
|
|||||||
584
README.md
584
README.md
@@ -1,181 +1,561 @@
|
|||||||
# CamTalk
|
# CamTalk
|
||||||
|
|
||||||
多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
|
<div align="center">
|
||||||
|
|
||||||
- **路演视频**:[哔哩哔哩弹幕网——七牛云第四批议题1](https://www.bilibili.com/video/BV1dDJK6cE5S/)
|
**多模态实时 AI 视觉对话助手**
|
||||||
- **线上体验**:http://8.161.227.145:9000
|
|
||||||
|
|
||||||
> ⚠️ **注意**:由于线上地址使用 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`,回车
|
> 1. 访问 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
|
||||||
> 2. 将 **Insecure origins treated as secure** 选项设置为 **Enabled**(已启用)
|
> 2. 启用该选项,并在输入框填入 `http://8.161.227.145:9000`
|
||||||
> 3. 在输入框中输入 `http://8.161.227.145:9000` 地址
|
> 3. 点击 **Relaunch** 重启浏览器
|
||||||
> 4. 点击右下角弹出的 **Relaunch** 按钮,自动重启浏览器
|
|
||||||
>
|
|
||||||
> 重启后即可在该 HTTP 地址下正常调用摄像头和麦克风。
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 架构
|
## ✨ 核心特性
|
||||||
|
|
||||||
三层系统,前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用:
|
- 🎥 **多模态理解**:摄像头视觉 + 麦克风语音双输入,AI 理解完整场景
|
||||||
|
- 🗣️ **自然对话**:基于 VAD 的端到端语音交互,低延迟流式响应
|
||||||
|
- 🚀 **实时推送**:LLM 文本流 + TTS 音频流并行推送,感知延迟 < 0.5 秒
|
||||||
|
- 🎭 **情景模式**:自由对话、面试官、英语老师等多场景支持
|
||||||
|
- 💾 **对话历史**:自动保存会话,支持搜索、重命名、删除、时间分组
|
||||||
|
- 🔐 **安全认证**:JWT 双 token 轮转 + Refresh Token Rotation 防重放
|
||||||
|
- 📊 **三级存储**:Memory → Redis → PostgreSQL 自动降级,保障可靠性
|
||||||
|
- 🌐 **国际化**:支持中文、英文、日文界面
|
||||||
|
|
||||||
|
## 🏗️ 系统架构
|
||||||
|
|
||||||
|
CamTalk 采用**三层架构**:前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph client[浏览器客户端]
|
subgraph Browser["🌐 浏览器客户端"]
|
||||||
A1[媒体采集]
|
UI["React UI 渲染"]
|
||||||
A2[VAD 语音检测]
|
VAD["VAD 语音检测"]
|
||||||
A3[关键帧检测]
|
Media["媒体采集"]
|
||||||
A4[UI 渲染]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph gateway[Go 网关 :8080]
|
subgraph Gateway["⚙️ Go 网关 (Eino Graph)"]
|
||||||
B1[WebSocket Handler]
|
WS["WebSocket Handler"]
|
||||||
B2[Session Manager]
|
Auth["JWT 认证"]
|
||||||
B3[AI Orchestrator]
|
Session["会话管理 (三级存储)"]
|
||||||
B4[REST API]
|
Orch["AI 编排器 (7节点DAG)"]
|
||||||
B5[Auth 模块]
|
|
||||||
B6[Store 层]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph cloud[云端 AI 服务]
|
subgraph AI["☁️ 云端 AI 服务"]
|
||||||
C1[STT 语音识别]
|
STT["STT (MiMo/Deepgram)"]
|
||||||
C2[LLM 多模态推理]
|
LLM["LLM (qwen3-vl-plus)"]
|
||||||
C3[TTS 语音合成]
|
TTS["TTS (MiMo/OpenAI)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
client <-->|WebSocket| gateway
|
Browser <-->|"WebSocket<br/>(JWT + query/config)"| Gateway
|
||||||
gateway <-->|HTTP| cloud
|
Orch --> STT
|
||||||
|
Orch --> LLM
|
||||||
|
Orch --> TTS
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键模式**:LLM 文本流和 TTS 音频流并行推送,用户先看到文字、紧接着听到语音,感知延迟 < 0.5 秒。
|
### AI 编排流水线(Eino Graph)
|
||||||
|
|
||||||
## 技术栈
|
基于 [CloudWeGo Eino](https://github.com/cloudwego/eino) 框架的声明式 7 节点 DAG:
|
||||||
|
|
||||||
| 层级 | 技术 |
|
```
|
||||||
|------|------|
|
START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END
|
||||||
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
|
```
|
||||||
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
|
|
||||||
| STT | MiMo ASR(默认) / Deepgram |
|
|
||||||
| LLM | DashScope qwen3-vl-plus(默认,通过 eino-ext OpenAI ChatModel 接入) |
|
|
||||||
| TTS | MiMo TTS(默认) / OpenAI TTS |
|
|
||||||
|
|
||||||
## 项目结构
|
**核心优势**:
|
||||||
|
- **流式处理**: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/
|
CamTalk/
|
||||||
├── frontend/ # 浏览器客户端
|
├── frontend/ # 🌐 浏览器客户端
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── components/ # UI 组件
|
│ ├── components/ # UI 组件
|
||||||
│ │ ├── LandingPage/ # 登录着陆页 + LoginModal
|
│ │ ├── LandingPage/ # 登录着陆页 + LoginModal
|
||||||
│ │ ├── AuthPage/ # 登录/注册表单
|
|
||||||
│ │ ├── CameraManager/ # 摄像头流采集
|
│ │ ├── CameraManager/ # 摄像头流采集
|
||||||
│ │ ├── MicManager/ # 麦克风音频采集
|
│ │ ├── MicManager/ # 麦克风音频采集 + VAD
|
||||||
│ │ ├── EdgeProcessor/ # VAD + 关键帧检测
|
│ │ ├── WebSocketManager/ # WS 连接生命周期
|
||||||
│ │ ├── WebSocketManager/ # WS 连接管理
|
│ │ ├── ChatPanel/ # 消息展示 + 流式回复
|
||||||
│ │ ├── ChatPanel/ # 消息展示
|
|
||||||
│ │ ├── VideoPreview/ # 摄像头画面预览
|
|
||||||
│ │ ├── SessionSidebar/ # 对话历史侧边栏
|
│ │ ├── SessionSidebar/ # 对话历史侧边栏
|
||||||
│ │ ├── ConfigPanel/ # 配置面板
|
│ │ └── ConfigPanel/ # 配置面板(主题/TTS/语言/场景)
|
||||||
│ │ └── Toast/ # 通知提示
|
|
||||||
│ ├── hooks/ # 自定义 Hooks
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
│ │ ├── useVisionSession.ts # 核心会话 Hook
|
│ │ ├── useVisionSession.ts # 核心会话 Hook (~500 行)
|
||||||
│ │ ├── useSessionList.ts # 对话列表管理
|
│ │ ├── useSessionList.ts # 对话列表管理
|
||||||
│ │ └── useObservationMode.ts # 观察模式
|
│ │ └── useObservationMode.ts # 观察模式
|
||||||
│ ├── lib/ # 工具库
|
│ ├── lib/ # 工具库
|
||||||
│ │ ├── websocket.ts # WebSocket 连接管理
|
│ │ ├── websocket.ts # WebSocket 单例(心跳/重连/订阅)
|
||||||
│ │ ├── api.ts # REST API 客户端
|
│ │ ├── api.ts # REST 客户端(401拦截+刷新)
|
||||||
│ │ ├── auth.tsx # 认证上下文(JWT 管理)
|
│ │ ├── auth.tsx # AuthProvider(JWT 自动刷新)
|
||||||
│ │ ├── audio.ts # 音频编码
|
│ │ ├── ttsPlayer.ts # TTS 流式播放队列
|
||||||
│ │ ├── ttsPlayer.ts # TTS 播放器
|
│ │ └── i18n/ # 国际化(zh-CN/en-US/ja-JP)
|
||||||
│ │ ├── i18n/ # 国际化(zh-CN/en-US/ja-JP)
|
|
||||||
│ │ └── sampling.ts # 采样策略
|
|
||||||
│ └── types/ # TypeScript 类型定义
|
│ └── types/ # TypeScript 类型定义
|
||||||
├── backend/ # Go 网关
|
├── backend/ # ⚙️ Go 网关
|
||||||
│ ├── cmd/server/ # 入口
|
│ ├── cmd/server/ # 服务入口(main.go)
|
||||||
│ └── internal/
|
│ └── 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 服务抽象层
|
│ ├── ai/ # AI 服务抽象层
|
||||||
│ │ ├── llm/ # LLM 提示词与场景
|
│ │ ├── llm/ # LLM 提示词与场景
|
||||||
│ │ ├── stt/ # STT 服务(MiMo/Deepgram)
|
│ │ ├── stt/ # STT 服务(MiMo/Deepgram)
|
||||||
│ │ └── tts/ # TTS 服务(MiMo/OpenAI)
|
│ │ └── tts/ # TTS 服务(MiMo/OpenAI)
|
||||||
│ ├── eino/ # Eino Graph 编排层(7 节点 DAG)
|
|
||||||
│ ├── orchestrator/ # Orchestrator 接口
|
|
||||||
│ ├── session/ # 会话管理(三级存储:Memory/Redis/PG)
|
|
||||||
│ ├── store/ # 持久化层(Repository 接口 + PG/内存实现)
|
|
||||||
│ ├── auth/ # 认证(JWT、bcrypt、中间件)
|
|
||||||
│ ├── ws/ # WebSocket Handler
|
│ ├── ws/ # WebSocket Handler
|
||||||
│ ├── api/ # REST API(Auth/Conversation)
|
│ ├── api/ # REST API(Auth/Conversation)
|
||||||
│ ├── config/ # 配置管理
|
│ ├── config/ # 配置管理(Viper)
|
||||||
│ ├── models/ # 数据模型
|
│ └── logger/ # 日志(Zap + Trace ID)
|
||||||
│ ├── errors/ # 错误码
|
├── migrations/ # 📊 数据库迁移(嵌入式 SQL)
|
||||||
│ └── logger/ # 日志
|
├── docs/ # 📚 设计文档
|
||||||
├── migrations/ # 数据库迁移(嵌入式 SQL)
|
│ ├── 01-架构设计.md
|
||||||
├── docs/ # 设计文档
|
│ ├── 02-接口文档.md
|
||||||
└── CLAUDE.md # Claude Code 指引
|
│ ├── 08-Eino框架与编排设计.md
|
||||||
|
│ ├── 10-鉴权体系.md
|
||||||
|
│ └── 13-日志追踪.md
|
||||||
|
├── deploy.sh # 🐳 部署脚本(Docker Compose)
|
||||||
|
├── docker-compose.yml # 容器编排配置
|
||||||
|
└── CLAUDE.md # 🤖 Claude Code 开发指引
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- Node.js >= 18
|
- **Node.js** >= 18
|
||||||
- Go >= 1.25
|
- **Go** >= 1.25
|
||||||
|
- **PostgreSQL** >= 15(可选 Docker)
|
||||||
|
- **Redis** >= 7(可选,用于缓存加速)
|
||||||
|
|
||||||
### 前端
|
### 本地开发
|
||||||
|
|
||||||
|
#### 1. 克隆项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
git clone https://github.com/yourusername/CamTalk.git
|
||||||
npm install
|
cd CamTalk
|
||||||
npm run dev # Vite 开发服务器 http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 后端
|
#### 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
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
go mod download
|
go mod download
|
||||||
go run ./cmd/server # 启动网关 :8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置
|
# 运行数据库迁移(自动创建表)
|
||||||
|
go run ./cmd/server migrate
|
||||||
|
|
||||||
后端配置文件位于 `backend/config/config.yaml`,支持环境变量覆盖(前缀 `CAMTALK_`)。
|
# 启动服务(监听 :8080)
|
||||||
|
|
||||||
```bash
|
|
||||||
# 最小启动(需要至少一个 AI 服务的 API Key)
|
|
||||||
cd backend
|
|
||||||
CAMTALK_AI_LLM_API_KEY=sk-xxx \
|
|
||||||
CAMTALK_AI_STT_API_KEY=xxx \
|
|
||||||
go run ./cmd/server
|
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`
|
#### 5. 访问应用
|
||||||
**服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong`
|
|
||||||
|
|
||||||
完整协议见 [docs/02-接口文档.md](docs/02-接口文档.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) | 三层架构、技术栈、数据库设计、部署方案 |
|
| [01-架构设计](docs/01-架构设计.md) | 三层架构、技术栈、数据库设计、部署方案 |
|
||||||
| [02-接口文档](docs/02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、编排器、配置管理 |
|
| [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 全链路追踪、日志级别 |
|
||||||
|
|
||||||
|
### 功能文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
| [03-技术选型](docs/03-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 |
|
| [03-技术选型](docs/03-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 |
|
||||||
| [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 |
|
| [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 |
|
||||||
| [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
|
| [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
|
||||||
| [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
|
| [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
|
||||||
| [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 |
|
| [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 |
|
||||||
| [08-功能创意](docs/08-功能创意.md) | 功能创意与规划 |
|
| [09-情景切换](docs/09-情景切换.md) | 情景模式设计与实现 |
|
||||||
| [对话历史技术设计](docs/conversation-history-technical-design.md) | 对话历史功能的前端技术方案 |
|
| [12-自定义情景](docs/12-自定义情景.md) | 用户自定义情景功能(规划中) |
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE) © XEngineers
|
## 🐛 问题反馈
|
||||||
|
|
||||||
|
遇到问题?请提交 [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>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ratelimit"
|
"github.com/hhs/camtalk/internal/ratelimit"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
"github.com/hhs/camtalk/internal/store"
|
"github.com/hhs/camtalk/internal/store"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
"github.com/hhs/camtalk/internal/ws"
|
"github.com/hhs/camtalk/internal/ws"
|
||||||
migrations "github.com/hhs/camtalk/migrations"
|
migrations "github.com/hhs/camtalk/migrations"
|
||||||
)
|
)
|
||||||
@@ -226,7 +227,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(trace.TraceMiddleware()) // 第一层:生成 trace ID
|
||||||
|
r.Use(trace.GinLogger()) // 第二层:记录请求
|
||||||
|
r.Use(trace.GinRecovery()) // 第三层:panic 恢复
|
||||||
|
|
||||||
// REST API
|
// REST API
|
||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/joho/godotenv v1.5.1
|
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/redis/go-redis/v9 v9.20.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
|||||||
@@ -129,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/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 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.8.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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
|
"github.com/hhs/camtalk/internal/util"
|
||||||
"go.uber.org/zap"
|
"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)
|
audio, err := m.synthesize(ctx, text, voice)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
|
"github.com/hhs/camtalk/internal/util"
|
||||||
"go.uber.org/zap"
|
"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)
|
audio, err := o.synthesize(ctx, text, voice, speed)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/auth"
|
"github.com/hhs/camtalk/internal/auth"
|
||||||
apperr "github.com/hhs/camtalk/internal/errors"
|
apperr "github.com/hhs/camtalk/internal/errors"
|
||||||
"github.com/hhs/camtalk/internal/ratelimit"
|
"github.com/hhs/camtalk/internal/ratelimit"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler 提供认证相关的 REST 端点。
|
// AuthHandler 提供认证相关的 REST 端点。
|
||||||
@@ -53,6 +54,9 @@ func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limi
|
|||||||
|
|
||||||
// Register POST /api/auth/register — 用户注册。
|
// Register POST /api/auth/register — 用户注册。
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
var req auth.RegisterRequest
|
var req auth.RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -72,15 +76,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := h.authService.Register(c.Request.Context(), req)
|
resp, err := h.authService.Register(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warnw("register failed",
|
||||||
|
"username", req.Username,
|
||||||
|
"client_ip", clientIP,
|
||||||
|
"error", err)
|
||||||
handleAuthError(c, err)
|
handleAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("register success",
|
||||||
|
"username", req.Username,
|
||||||
|
"client_ip", clientIP)
|
||||||
c.JSON(http.StatusCreated, resp)
|
c.JSON(http.StatusCreated, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login POST /api/auth/login — 用户登录。
|
// Login POST /api/auth/login — 用户登录。
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
var req auth.LoginRequest
|
var req auth.LoginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -100,15 +114,24 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := h.authService.Login(c.Request.Context(), req)
|
resp, err := h.authService.Login(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warnw("login failed",
|
||||||
|
"username", req.Username,
|
||||||
|
"client_ip", clientIP,
|
||||||
|
"error", err)
|
||||||
handleAuthError(c, err)
|
handleAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("login success",
|
||||||
|
"username", req.Username,
|
||||||
|
"client_ip", clientIP)
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh POST /api/auth/refresh — 刷新令牌。
|
// Refresh POST /api/auth/refresh — 刷新令牌。
|
||||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
|
|
||||||
var req auth.RefreshRequest
|
var req auth.RefreshRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -128,15 +151,21 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := h.authService.Refresh(c.Request.Context(), req)
|
resp, err := h.authService.Refresh(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warnw("token refresh failed",
|
||||||
|
"client_ip", c.ClientIP(),
|
||||||
|
"error", err)
|
||||||
handleAuthError(c, err)
|
handleAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("token refresh success",
|
||||||
|
"client_ip", c.ClientIP())
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout POST /api/auth/logout — 登出(需要认证)。
|
// Logout POST /api/auth/logout — 登出(需要认证)。
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
userID := c.GetString(auth.ContextKeyUserID)
|
userID := c.GetString(auth.ContextKeyUserID)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -159,6 +188,9 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
|
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{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to logout",
|
"message": "failed to logout",
|
||||||
@@ -166,6 +198,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("logout success",
|
||||||
|
"user_id", userID)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "logged out successfully",
|
"message": "logged out successfully",
|
||||||
})
|
})
|
||||||
@@ -174,8 +208,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
// validateCredentials 校验用户名和密码格式。
|
// validateCredentials 校验用户名和密码格式。
|
||||||
// 返回空字符串表示校验通过,否则返回错误描述。
|
// 返回空字符串表示校验通过,否则返回错误描述。
|
||||||
func validateCredentials(username, password string) string {
|
func validateCredentials(username, password string) string {
|
||||||
if len(username) < 3 || len(username) > 64 {
|
if len(username) > 64 {
|
||||||
return "username must be 3-64 characters"
|
return "username must not exceed 64 characters"
|
||||||
}
|
}
|
||||||
if len(password) < 8 || len(password) > 72 {
|
if len(password) < 8 || len(password) > 72 {
|
||||||
return "password must be 8-72 characters"
|
return "password must be 8-72 characters"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
"github.com/hhs/camtalk/internal/store"
|
"github.com/hhs/camtalk/internal/store"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConversationHandler 提供对话相关的 REST 端点。
|
// ConversationHandler 提供对话相关的 REST 端点。
|
||||||
@@ -47,6 +48,7 @@ func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
|||||||
|
|
||||||
// List GET /api/conversations — 获取当前用户的对话列表。
|
// List GET /api/conversations — 获取当前用户的对话列表。
|
||||||
func (h *ConversationHandler) List(c *gin.Context) {
|
func (h *ConversationHandler) List(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
userID := c.GetString(auth.ContextKeyUserID)
|
userID := c.GetString(auth.ContextKeyUserID)
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
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)
|
summaries, total, err := h.sessionMgr.ListByUser(c.Request.Context(), userID, page, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("list conversations failed",
|
||||||
|
"user_id", userID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to list conversations",
|
"message": "failed to list conversations",
|
||||||
@@ -83,6 +88,7 @@ type CreateConversationRequest struct {
|
|||||||
|
|
||||||
// Create POST /api/conversations — 创建新对话。
|
// Create POST /api/conversations — 创建新对话。
|
||||||
func (h *ConversationHandler) Create(c *gin.Context) {
|
func (h *ConversationHandler) Create(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
userID := c.GetString(auth.ContextKeyUserID)
|
userID := c.GetString(auth.ContextKeyUserID)
|
||||||
|
|
||||||
var req CreateConversationRequest
|
var req CreateConversationRequest
|
||||||
@@ -95,6 +101,9 @@ func (h *ConversationHandler) Create(c *gin.Context) {
|
|||||||
|
|
||||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg)
|
sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("create conversation failed",
|
||||||
|
"user_id", userID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to create conversation",
|
"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)
|
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("retrieve created conversation failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to retrieve created conversation",
|
"message": "failed to retrieve created conversation",
|
||||||
@@ -111,6 +123,9 @@ func (h *ConversationHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("conversation created",
|
||||||
|
"conversation_id", sess.ID,
|
||||||
|
"user_id", userID)
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"id": sess.ID,
|
"id": sess.ID,
|
||||||
"title": sess.Title,
|
"title": sess.Title,
|
||||||
@@ -144,6 +159,7 @@ type UpdateTitleRequest struct {
|
|||||||
|
|
||||||
// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。
|
// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。
|
||||||
func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
sessionID := c.Param("id")
|
sessionID := c.Param("id")
|
||||||
|
|
||||||
// 先校验归属
|
// 先校验归属
|
||||||
@@ -176,6 +192,9 @@ func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Errorw("update title failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to update title",
|
"message": "failed to update title",
|
||||||
@@ -190,6 +209,7 @@ func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
|||||||
|
|
||||||
// Delete DELETE /api/conversations/:id — 删除对话。
|
// Delete DELETE /api/conversations/:id — 删除对话。
|
||||||
func (h *ConversationHandler) Delete(c *gin.Context) {
|
func (h *ConversationHandler) Delete(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
sessionID := c.Param("id")
|
sessionID := c.Param("id")
|
||||||
|
|
||||||
// 先校验归属
|
// 先校验归属
|
||||||
@@ -205,6 +225,9 @@ func (h *ConversationHandler) Delete(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Errorw("delete conversation failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to delete conversation",
|
"message": "failed to delete conversation",
|
||||||
@@ -221,6 +244,7 @@ func (h *ConversationHandler) Delete(c *gin.Context) {
|
|||||||
// - limit: 返回消息数量上限,默认 50
|
// - limit: 返回消息数量上限,默认 50
|
||||||
// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息
|
// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息
|
||||||
func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
sessionID := c.Param("id")
|
sessionID := c.Param("id")
|
||||||
|
|
||||||
// 先校验归属
|
// 先校验归属
|
||||||
@@ -239,6 +263,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
|||||||
if h.msgRepo != nil {
|
if h.msgRepo != nil {
|
||||||
messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID)
|
messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("get messages failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to get messages",
|
"message": "failed to get messages",
|
||||||
@@ -246,6 +273,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
|
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
|
||||||
|
if messages == nil {
|
||||||
|
messages = []store.StoredMessage{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"total": count,
|
"total": count,
|
||||||
@@ -263,6 +293,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Errorw("get messages failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": apperr.CodeInternalError,
|
"code": apperr.CodeInternalError,
|
||||||
"message": "failed to get messages",
|
"message": "failed to get messages",
|
||||||
@@ -284,6 +317,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
messages := allMessages[start:]
|
messages := allMessages[start:]
|
||||||
|
|
||||||
|
if messages == nil {
|
||||||
|
messages = []models.Message{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionHandler 提供会话相关的 REST 端点。
|
// SessionHandler 提供会话相关的 REST 端点。
|
||||||
@@ -27,6 +28,8 @@ type CreateSessionRequest struct {
|
|||||||
|
|
||||||
// CreateSession POST /api/sessions — 创建新会话。
|
// CreateSession POST /api/sessions — 创建新会话。
|
||||||
func (h *SessionHandler) CreateSession(c *gin.Context) {
|
func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
|
|
||||||
var req CreateSessionRequest
|
var req CreateSessionRequest
|
||||||
// 请求体可选,解析失败不报错(使用默认配置)
|
// 请求体可选,解析失败不报错(使用默认配置)
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
@@ -38,6 +41,8 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
|||||||
|
|
||||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg)
|
sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("create session failed",
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": "INTERNAL_ERROR",
|
"code": "INTERNAL_ERROR",
|
||||||
"message": "failed to create session",
|
"message": "failed to create session",
|
||||||
@@ -48,6 +53,9 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
|||||||
// 获取创建后的会话以返回 created_at
|
// 获取创建后的会话以返回 created_at
|
||||||
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("retrieve created session failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": "INTERNAL_ERROR",
|
"code": "INTERNAL_ERROR",
|
||||||
"message": "failed to retrieve created session",
|
"message": "failed to retrieve created session",
|
||||||
@@ -55,6 +63,8 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("session created",
|
||||||
|
"session_id", sess.ID)
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"session_id": sess.ID,
|
"session_id": sess.ID,
|
||||||
"created_at": sess.CreatedAt,
|
"created_at": sess.CreatedAt,
|
||||||
@@ -63,6 +73,7 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
|||||||
|
|
||||||
// DestroySession DELETE /api/sessions/:id — 销毁会话。
|
// DestroySession DELETE /api/sessions/:id — 销毁会话。
|
||||||
func (h *SessionHandler) DestroySession(c *gin.Context) {
|
func (h *SessionHandler) DestroySession(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
sessionID := c.Param("id")
|
sessionID := c.Param("id")
|
||||||
|
|
||||||
err := h.sessionMgr.Destroy(c.Request.Context(), sessionID)
|
err := h.sessionMgr.Destroy(c.Request.Context(), sessionID)
|
||||||
@@ -74,6 +85,9 @@ func (h *SessionHandler) DestroySession(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Errorw("destroy session failed",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": "INTERNAL_ERROR",
|
"code": "INTERNAL_ERROR",
|
||||||
"message": "failed to destroy session",
|
"message": "failed to destroy session",
|
||||||
@@ -81,6 +95,8 @@ func (h *SessionHandler) DestroySession(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infow("session destroyed",
|
||||||
|
"session_id", sessionID)
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey 用于在 Gin context 中存储 Claims 的 key。
|
// contextKey 用于在 Gin context 中存储 Claims 的 key。
|
||||||
@@ -17,8 +19,13 @@ const (
|
|||||||
// 校验成功后将 user_id 和 username 写入 Gin Context。
|
// 校验成功后将 user_id 和 username 写入 Gin Context。
|
||||||
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
log := trace.FromContext(c.Request.Context())
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if authHeader == "" {
|
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{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"code": "INVALID_TOKEN",
|
"code": "INVALID_TOKEN",
|
||||||
"message": "missing authorization header",
|
"message": "missing authorization header",
|
||||||
@@ -29,6 +36,10 @@ func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
|||||||
// 提取 Bearer token
|
// 提取 Bearer token
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
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{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"code": "INVALID_TOKEN",
|
"code": "INVALID_TOKEN",
|
||||||
"message": "invalid authorization format",
|
"message": "invalid authorization format",
|
||||||
@@ -38,6 +49,11 @@ func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
|||||||
|
|
||||||
claims, err := tokenMgr.ValidateAccess(parts[1])
|
claims, err := tokenMgr.ValidateAccess(parts[1])
|
||||||
if err != nil {
|
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{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"code": "INVALID_TOKEN",
|
"code": "INVALID_TOKEN",
|
||||||
"message": "invalid or expired token",
|
"message": "invalid or expired token",
|
||||||
|
|||||||
@@ -8,20 +8,12 @@ import (
|
|||||||
|
|
||||||
"github.com/cloudwego/eino/compose"
|
"github.com/cloudwego/eino/compose"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"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 接口。
|
// EinoOrchestrator 实现 orchestrator.Orchestrator 接口。
|
||||||
// 将 Eino Graph 包装为现有接口,WS Handler 几乎不用改。
|
// 将 Eino Graph 包装为现有接口,WS Handler 几乎不用改。
|
||||||
type EinoOrchestrator struct {
|
type EinoOrchestrator struct {
|
||||||
@@ -48,19 +40,19 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
req models.WsQuery,
|
req models.WsQuery,
|
||||||
sender orchestrator.Sender,
|
sender orchestrator.Sender,
|
||||||
) error {
|
) error {
|
||||||
log := logger.Log
|
log := trace.FromContext(ctx)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// 1. 设置活跃请求
|
// 1. 设置活跃请求
|
||||||
if err := e.sessionMgr.SetActiveRequest(ctx, sessionID, req.RequestID); err != nil {
|
if err := e.sessionMgr.SetActiveRequest(ctx, sessionID, req.RequestID); err != nil {
|
||||||
log.Errorw("设置活跃请求失败", "error", err)
|
return err
|
||||||
}
|
}
|
||||||
defer e.sessionMgr.ClearActiveRequest(ctx, sessionID)
|
defer e.sessionMgr.ClearActiveRequest(ctx, sessionID)
|
||||||
|
|
||||||
// 2. 获取会话配置
|
// 2. 获取会话配置
|
||||||
sess, err := e.sessionMgr.Get(ctx, sessionID)
|
sess, err := e.sessionMgr.Get(ctx, sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("获取会话失败", "error", err)
|
log.Errorw("get session failed", "error", err)
|
||||||
sender.SendError(models.WsError{
|
sender.SendError(models.WsError{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
@@ -75,7 +67,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
if req.Text == "" && req.Audio != "" {
|
if req.Text == "" && req.Audio != "" {
|
||||||
audioData, err = base64.StdEncoding.DecodeString(req.Audio)
|
audioData, err = base64.StdEncoding.DecodeString(req.Audio)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("音频解码失败", "error", err)
|
log.Errorw("audio decode failed", "error", err)
|
||||||
sender.SendError(models.WsError{
|
sender.SendError(models.WsError{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
@@ -90,7 +82,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
if req.Image != "" {
|
if req.Image != "" {
|
||||||
imageData, err = base64.StdEncoding.DecodeString(req.Image)
|
imageData, err = base64.StdEncoding.DecodeString(req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("图片解码失败", "error", err)
|
log.Errorw("image decode failed", "error", err)
|
||||||
sender.SendError(models.WsError{
|
sender.SendError(models.WsError{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
@@ -107,7 +99,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
// 5. 注入 context 值(供 Callback 和 Lambda 节点使用)
|
// 5. 注入 context 值(供 Callback 和 Lambda 节点使用)
|
||||||
ctx = WithSender(ctx, sender)
|
ctx = WithSender(ctx, sender)
|
||||||
ctx = WithRequestID(ctx, req.RequestID)
|
ctx = WithRequestID(ctx, req.RequestID)
|
||||||
ctx = WithSessionID(ctx, sessionID)
|
ctx = trace.WithSessionID(ctx, sessionID)
|
||||||
ctx = WithStartTime(ctx, startTime)
|
ctx = WithStartTime(ctx, startTime)
|
||||||
|
|
||||||
// 创建 State 并从 input 复制元数据
|
// 创建 State 并从 input 复制元数据
|
||||||
@@ -125,7 +117,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||||
streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks)
|
streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("Graph Stream 启动失败", "error", err)
|
log.Errorw("graph stream start failed", "error", err)
|
||||||
sender.SendError(models.WsError{
|
sender.SendError(models.WsError{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
RequestID: req.RequestID,
|
RequestID: req.RequestID,
|
||||||
@@ -143,7 +135,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Errorw("Graph Stream 消费错误", "error", err)
|
log.Errorw("graph stream consume error", "error", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
output = o
|
output = o
|
||||||
@@ -159,7 +151,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userText,
|
Content: userText,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorw("追加用户消息到历史失败", "session", sessionID, "error", err)
|
log.Errorw("append user message failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +161,12 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: output.FullResponse,
|
Content: output.FullResponse,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorw("追加助手消息到历史失败", "session", sessionID, "error", err)
|
log.Errorw("append assistant message failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
latency := time.Since(startTime).Milliseconds()
|
latency := time.Since(startTime).Milliseconds()
|
||||||
log.Infow("Eino 编排完成",
|
log.Infow("eino pipeline completed", "latency_ms", latency)
|
||||||
"request_id", req.RequestID,
|
|
||||||
"latency_ms", latency,
|
|
||||||
"session_id", sessionID)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import (
|
|||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
callbacksHelper "github.com/cloudwego/eino/utils/callbacks"
|
callbacksHelper "github.com/cloudwego/eino/utils/callbacks"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// context key 类型,避免与其他包冲突。
|
// context key 类型,避免与其他包冲突。
|
||||||
type ctxKeySender struct{}
|
type ctxKeySender struct{}
|
||||||
type ctxKeyRequestID struct{}
|
|
||||||
type ctxKeyState struct{}
|
type ctxKeyState struct{}
|
||||||
|
|
||||||
// WithSender 将 Sender 注入 context。
|
// WithSender 将 Sender 注入 context。
|
||||||
@@ -24,9 +23,9 @@ func WithSender(ctx context.Context, sender orchestrator.Sender) context.Context
|
|||||||
return context.WithValue(ctx, ctxKeySender{}, sender)
|
return context.WithValue(ctx, ctxKeySender{}, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithRequestID 将 requestID 注入 context。
|
// WithRequestID 将 requestID 注入 context(使用 trace 包)。
|
||||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||||
return context.WithValue(ctx, ctxKeyRequestID{}, requestID)
|
return trace.WithRequestID(ctx, requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithPipelineState 将 PipelineState 注入 context。
|
// WithPipelineState 将 PipelineState 注入 context。
|
||||||
@@ -40,10 +39,9 @@ func senderFromCtx(ctx context.Context) orchestrator.Sender {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestIDFromCtx 从 context 获取 requestID。
|
// requestIDFromCtx 从 context 获取 requestID(使用 trace 包)。
|
||||||
func requestIDFromCtx(ctx context.Context) string {
|
func requestIDFromCtx(ctx context.Context) string {
|
||||||
s, _ := ctx.Value(ctxKeyRequestID{}).(string)
|
return trace.GetRequestID(ctx)
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateFromCtx 从 context 获取 PipelineState。
|
// stateFromCtx 从 context 获取 PipelineState。
|
||||||
@@ -62,7 +60,7 @@ func BuildCallbackHandler() callbacks.Handler {
|
|||||||
return callbacksHelper.NewHandlerHelper().
|
return callbacksHelper.NewHandlerHelper().
|
||||||
ChatModel(&callbacksHelper.ModelCallbackHandler{
|
ChatModel(&callbacksHelper.ModelCallbackHandler{
|
||||||
OnEndWithStreamOutput: func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[*model.CallbackOutput]) context.Context {
|
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)
|
sender := senderFromCtx(ctx)
|
||||||
requestID := requestIDFromCtx(ctx)
|
requestID := requestIDFromCtx(ctx)
|
||||||
state := stateFromCtx(ctx)
|
state := stateFromCtx(ctx)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ai/tts"
|
"github.com/hhs/camtalk/internal/ai/tts"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock STT Service ---
|
// --- Mock STT Service ---
|
||||||
@@ -176,7 +177,7 @@ func TestContextInjection(t *testing.T) {
|
|||||||
sender := &mockSender{}
|
sender := &mockSender{}
|
||||||
ctx = WithSender(ctx, sender)
|
ctx = WithSender(ctx, sender)
|
||||||
ctx = WithRequestID(ctx, "req-123")
|
ctx = WithRequestID(ctx, "req-123")
|
||||||
ctx = WithSessionID(ctx, "sess-456")
|
ctx = trace.WithSessionID(ctx, "sess-456")
|
||||||
ctx = WithStartTime(ctx, time.Now())
|
ctx = WithStartTime(ctx, time.Now())
|
||||||
ctx = WithPipelineState(ctx, genLocalState(ctx))
|
ctx = WithPipelineState(ctx, genLocalState(ctx))
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/cloudwego/eino/compose"
|
"github.com/cloudwego/eino/compose"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ctxKeyStartTime 请求开始时间的 context key。
|
// ctxKeyStartTime 请求开始时间的 context key。
|
||||||
@@ -33,7 +33,7 @@ func latencyFromCtx(ctx context.Context) int64 {
|
|||||||
// 历史消息追加由适配器负责(避免重复写入)。
|
// 历史消息追加由适配器负责(避免重复写入)。
|
||||||
func NewDoneLambda(defaultModel string) *compose.Lambda {
|
func NewDoneLambda(defaultModel string) *compose.Lambda {
|
||||||
return compose.InvokableLambda(func(ctx context.Context, _ struct{}) (PipelineOutput, error) {
|
return compose.InvokableLambda(func(ctx context.Context, _ struct{}) (PipelineOutput, error) {
|
||||||
log := logger.Log
|
log := trace.FromContext(ctx)
|
||||||
sender := senderFromCtx(ctx)
|
sender := senderFromCtx(ctx)
|
||||||
state := stateFromCtx(ctx)
|
state := stateFromCtx(ctx)
|
||||||
|
|
||||||
@@ -70,13 +70,11 @@ func NewDoneLambda(defaultModel string) *compose.Lambda {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := sender.SendLLMDone(done); err != nil {
|
if err := sender.SendLLMDone(done); err != nil {
|
||||||
log.Errorw("发送 llm_done 失败", "error", err)
|
log.Errorw("send llm_done failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infow("查询处理完成",
|
log.Infow("query processing completed", "response_length", len(fullResponse))
|
||||||
"request_id", requestID,
|
|
||||||
"response_length", len(fullResponse))
|
|
||||||
|
|
||||||
return PipelineOutput{
|
return PipelineOutput{
|
||||||
TranscribedText: transcribedText,
|
TranscribedText: transcribedText,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/ai/llm"
|
"github.com/hhs/camtalk/internal/ai/llm"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/store"
|
"github.com/hhs/camtalk/internal/store"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
||||||
@@ -24,7 +24,7 @@ func NewHistoryLambda(
|
|||||||
maxHistory int,
|
maxHistory int,
|
||||||
) *compose.Lambda {
|
) *compose.Lambda {
|
||||||
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
||||||
log := logger.Log
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
// 从 State 读取请求元数据
|
// 从 State 读取请求元数据
|
||||||
state := stateFromCtx(ctx)
|
state := stateFromCtx(ctx)
|
||||||
@@ -48,7 +48,7 @@ func NewHistoryLambda(
|
|||||||
if userID != "" && scenarioRepo != nil {
|
if userID != "" && scenarioRepo != nil {
|
||||||
scenarios, err := scenarioRepo.FindByUserID(ctx, userID)
|
scenarios, err := scenarioRepo.FindByUserID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnw("加载用户自建情景失败", "user_id", userID, "error", err)
|
log.Warnw("load user scenarios failed", "user_id", userID, "error", err)
|
||||||
} else if len(scenarios) > 0 {
|
} else if len(scenarios) > 0 {
|
||||||
customScenarios = make(map[string]string, len(scenarios))
|
customScenarios = make(map[string]string, len(scenarios))
|
||||||
customGreetings = make(map[string]string, len(scenarios))
|
customGreetings = make(map[string]string, len(scenarios))
|
||||||
@@ -58,7 +58,7 @@ func NewHistoryLambda(
|
|||||||
customGreetings[s.ID] = s.Greeting
|
customGreetings[s.ID] = s.Greeting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Debugw("加载用户自建情景", "user_id", userID, "count", len(scenarios))
|
log.Debugw("loaded user scenarios", "user_id", userID, "count", len(scenarios))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ func NewHistoryLambda(
|
|||||||
if historyFetcher != nil && sessionID != "" {
|
if historyFetcher != nil && sessionID != "" {
|
||||||
history, err := historyFetcher(ctx, sessionID, maxHistory)
|
history, err := historyFetcher(ctx, sessionID, maxHistory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnw("获取历史消息失败,继续处理", "error", err, "request_id", requestID)
|
log.Warnw("fetch history failed, continuing", "error", err, "request_id", requestID)
|
||||||
} else {
|
} else {
|
||||||
for _, msg := range history {
|
for _, msg := range history {
|
||||||
messages = append(messages, &schema.Message{
|
messages = append(messages, &schema.Message{
|
||||||
@@ -121,8 +121,7 @@ func NewHistoryLambda(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infow("历史组装完成",
|
log.Debugw("history assembled",
|
||||||
"request_id", requestID,
|
|
||||||
"message_count", len(messages),
|
"message_count", len(messages),
|
||||||
"has_image", len(imageData) > 0,
|
"has_image", len(imageData) > 0,
|
||||||
"scenario", scenario)
|
"scenario", scenario)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
"github.com/cloudwego/eino/compose"
|
"github.com/cloudwego/eino/compose"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/ai/stt"
|
"github.com/hhs/camtalk/internal/ai/stt"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
|
"github.com/hhs/camtalk/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewSTTLambda 创建 STT Lambda 节点。
|
// NewSTTLambda 创建 STT Lambda 节点。
|
||||||
@@ -20,7 +21,7 @@ import (
|
|||||||
// 识别结果通过 Sender 发送 stt_result 到客户端。
|
// 识别结果通过 Sender 发送 stt_result 到客户端。
|
||||||
func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||||
return compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
return compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||||
log := logger.Log
|
log := trace.FromContext(ctx)
|
||||||
sender := senderFromCtx(ctx)
|
sender := senderFromCtx(ctx)
|
||||||
requestID := requestIDFromCtx(ctx)
|
requestID := requestIDFromCtx(ctx)
|
||||||
|
|
||||||
@@ -39,8 +40,9 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
|||||||
|
|
||||||
// 文本输入模式:跳过 STT
|
// 文本输入模式:跳过 STT
|
||||||
if input.Text != "" {
|
if input.Text != "" {
|
||||||
log.Infow("使用文本输入,跳过 STT",
|
log.Debugw("text input mode, skipping stt",
|
||||||
"request_id", requestID, "text", input.Text)
|
"text_len", len(input.Text),
|
||||||
|
"text_preview", util.Truncate(input.Text, 50))
|
||||||
|
|
||||||
// 发送 stt_result 保持前端消息流一致性
|
// 发送 stt_result 保持前端消息流一致性
|
||||||
if sender != nil {
|
if sender != nil {
|
||||||
@@ -50,7 +52,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
|||||||
Text: input.Text,
|
Text: input.Text,
|
||||||
IsFinal: true,
|
IsFinal: true,
|
||||||
}); err != nil {
|
}); 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")
|
return STTOutput{}, fmt.Errorf("stt: no audio data provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infow("开始语音识别",
|
log.Debugw("stt recognition started", "audio_bytes", len(input.AudioData))
|
||||||
"request_id", requestID, "audio_bytes", len(input.AudioData))
|
|
||||||
|
|
||||||
// 调用 STT 服务
|
// 调用 STT 服务
|
||||||
text, err := sttService.Recognize(ctx, input.AudioData, stt.Options{
|
text, err := sttService.Recognize(ctx, input.AudioData, stt.Options{
|
||||||
@@ -83,7 +84,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
|||||||
Language: input.Language,
|
Language: input.Language,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("语音识别失败", "error", err, "request_id", requestID)
|
log.Errorw("stt recognition failed", "error", err)
|
||||||
if sender != nil {
|
if sender != nil {
|
||||||
sender.SendError(models.WsError{
|
sender.SendError(models.WsError{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
@@ -97,11 +98,13 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
|||||||
|
|
||||||
// STT 返回空文本
|
// STT 返回空文本
|
||||||
if strings.TrimSpace(text) == "" {
|
if strings.TrimSpace(text) == "" {
|
||||||
log.Infow("语音识别结果为空", "request_id", requestID)
|
log.Infow("stt returned empty text")
|
||||||
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
|
// 发送 stt_result
|
||||||
if sender != nil {
|
if sender != nil {
|
||||||
@@ -111,7 +114,7 @@ func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
|||||||
Text: text,
|
Text: text,
|
||||||
IsFinal: true,
|
IsFinal: true,
|
||||||
}); err != nil {
|
}); 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/cloudwego/eino/schema"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/ai/tts"
|
"github.com/hhs/camtalk/internal/ai/tts"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTTSLambda 创建 TTS Transform Lambda 节点。
|
// NewTTSLambda 创建 TTS Transform Lambda 节点。
|
||||||
@@ -26,7 +26,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
|||||||
defer sw.Close()
|
defer sw.Close()
|
||||||
defer input.Close()
|
defer input.Close()
|
||||||
|
|
||||||
log := logger.Log
|
log := trace.FromContext(ctx)
|
||||||
sender := senderFromCtx(ctx)
|
sender := senderFromCtx(ctx)
|
||||||
requestID := requestIDFromCtx(ctx)
|
requestID := requestIDFromCtx(ctx)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
|||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Errorw("TTS: stream recv error", "error", err, "request_id", requestID)
|
log.Errorw("TTS: stream recv error", "error", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if sentence != "" {
|
if sentence != "" {
|
||||||
@@ -61,7 +61,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infow("开始 TTS 合成", "request_id", requestID, "sentence_count", len(sentences))
|
log.Infow("开始 TTS 合成", "sentence_count", len(sentences))
|
||||||
|
|
||||||
// 将句子数组转为 channel
|
// 将句子数组转为 channel
|
||||||
sentenceCh := make(chan string, len(sentences))
|
sentenceCh := make(chan string, len(sentences))
|
||||||
@@ -78,7 +78,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
|||||||
SampleRate: ttsSampleRate,
|
SampleRate: ttsSampleRate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("TTS 合成启动失败(已跳过)", "error", err, "request_id", requestID)
|
log.Errorw("TTS 合成启动失败(已跳过)", "error", err)
|
||||||
sw.Send(struct{}{}, nil)
|
sw.Send(struct{}{}, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, tts
|
|||||||
for chunk := range ttsStream {
|
for chunk := range ttsStream {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Infow("TTS 流被中断", "request_id", requestID)
|
log.Debugw("tts stream interrupted")
|
||||||
sw.Send(struct{}{}, ctx.Err())
|
sw.Send(struct{}{}, ctx.Err())
|
||||||
return
|
return
|
||||||
default:
|
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)
|
sw.Send(struct{}{}, nil)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Middleware 返回 Gin 中间件,按 key 维度限流。
|
// Middleware 返回 Gin 中间件,按 key 维度限流。
|
||||||
@@ -26,6 +28,13 @@ func Middleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerF
|
|||||||
allowed, retryAfter := limiter.Allow(c.Request.Context(), key)
|
allowed, retryAfter := limiter.Allow(c.Request.Context(), key)
|
||||||
|
|
||||||
if !allowed {
|
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(秒)
|
// 设置 Retry-After header(秒)
|
||||||
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))
|
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/config"
|
"github.com/hhs/camtalk/internal/config"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ func NewRedisLimiter(client *redis.Client, cfg config.RateLimitConfig) *RedisLim
|
|||||||
|
|
||||||
// Allow 实现 Limiter 接口。
|
// Allow 实现 Limiter 接口。
|
||||||
func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Duration) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
cfg := l.getBucketConfig(key)
|
cfg := l.getBucketConfig(key)
|
||||||
|
|
||||||
now := float64(time.Now().UnixNano()) / 1e9 // 秒,浮点
|
now := float64(time.Now().UnixNano()) / 1e9 // 秒,浮点
|
||||||
@@ -81,6 +83,7 @@ func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Durati
|
|||||||
cfg.Capacity, cfg.Rate, now, ttl).Result()
|
cfg.Capacity, cfg.Rate, now, ttl).Result()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("rate limit check failed", "key", key, "error", err)
|
||||||
// Redis 错误时降级:允许请求(fail-open 策略)
|
// Redis 错误时降级:允许请求(fail-open 策略)
|
||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
@@ -100,6 +103,7 @@ func (l *RedisLimiter) Allow(ctx context.Context, key string) (bool, time.Durati
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryAfter := time.Duration(retryAfterSec*1000) * time.Millisecond
|
retryAfter := time.Duration(retryAfterSec*1000) * time.Millisecond
|
||||||
|
log.Warnw("rate limit triggered", "key", key, "retry_after_sec", retryAfterSec)
|
||||||
return false, retryAfter
|
return false, retryAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
|
"github.com/hhs/camtalk/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RedisManager 基于 Redis 的 SessionManager 实现。
|
// 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)
|
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
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +98,11 @@ const placeholderHistoryMark = "__placeholder__"
|
|||||||
|
|
||||||
// Get 获取会话。
|
// Get 获取会话。
|
||||||
func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
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()
|
vals, err := m.rdb.HGetAll(ctx, metaKey(sessionID)).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("redis get session failed", "session_id", sessionID, "error", err)
|
||||||
return nil, fmt.Errorf("redis get session: %w", err)
|
return nil, fmt.Errorf("redis get session: %w", err)
|
||||||
}
|
}
|
||||||
if len(vals) == 0 {
|
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.DetailLevel = vals["config.detail_level"]
|
||||||
sess.Config.Language = vals["config.language"]
|
sess.Config.Language = vals["config.language"]
|
||||||
|
|
||||||
|
log.Debugw("redis session retrieved", "session_id", sessionID)
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +156,9 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
|
|||||||
|
|
||||||
// 刷新 TTL
|
// 刷新 TTL
|
||||||
m.rdb.Expire(ctx, metaKey(sessionID), m.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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +178,9 @@ func (m *RedisManager) UpdateTitle(ctx context.Context, sessionID string, title
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.rdb.Expire(ctx, metaKey(sessionID), m.ttl)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +295,11 @@ func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit i
|
|||||||
}
|
}
|
||||||
var msg models.Message
|
var msg models.Message
|
||||||
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
msgs = append(msgs, msg)
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redis key 前缀。
|
// Redis key 前缀。
|
||||||
@@ -83,7 +83,8 @@ func (r *CachedUserRepository) SaveRefreshToken(ctx context.Context, userID, tok
|
|||||||
pipe.Set(ctx, key, userID, ttl)
|
pipe.Set(ctx, key, userID, ttl)
|
||||||
pipe.SAdd(ctx, userRefreshKey(userID), tokenHash)
|
pipe.SAdd(ctx, userRefreshKey(userID), tokenHash)
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
logger.Log.Warnw("Redis cache write failed for refresh token", "error", err)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Warnw("redis cache write failed for refresh token", "error", err)
|
||||||
// 降级:DB 已写入成功,Redis 失败不影响正确性
|
// 降级:DB 已写入成功,Redis 失败不影响正确性
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -100,7 +101,8 @@ func (r *CachedUserRepository) FindRefreshToken(ctx context.Context, tokenHash s
|
|||||||
}
|
}
|
||||||
// redis.Nil 表示 key 不存在,其他错误记录日志后降级到 DB
|
// redis.Nil 表示 key 不存在,其他错误记录日志后降级到 DB
|
||||||
if err != redis.Nil {
|
if err != redis.Nil {
|
||||||
logger.Log.Warnw("Redis cache read failed for refresh token", "error", err)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Warnw("redis cache read failed for refresh token", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 降级到 DB
|
// 降级到 DB
|
||||||
@@ -139,7 +141,8 @@ func (r *CachedUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash
|
|||||||
pipe.SRem(ctx, userRefreshKey(userID), tokenHash)
|
pipe.SRem(ctx, userRefreshKey(userID), tokenHash)
|
||||||
}
|
}
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
logger.Log.Warnw("Redis cache delete failed for refresh token", "error", err)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Warnw("redis cache delete failed for refresh token", "error", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,8 @@ func (r *CachedUserRepository) DeleteUserRefreshTokens(ctx context.Context, user
|
|||||||
}
|
}
|
||||||
keys = append(keys, userKey)
|
keys = append(keys, userKey)
|
||||||
if err := r.rdb.Del(ctx, keys...).Err(); err != nil {
|
if err := r.rdb.Del(ctx, keys...).Err(); err != nil {
|
||||||
logger.Log.Warnw("Redis cache batch delete failed for user refresh tokens", "error", err, "userID", userID)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Warnw("redis cache batch delete failed for user refresh tokens", "error", err, "user_id", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PgMessageRepository 基于 PostgreSQL 的 MessageRepository 实现。
|
// 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 {
|
func (r *PgMessageRepository) SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`,
|
`INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`,
|
||||||
sessionID, msg.Role, msg.Content, tokensUsed,
|
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) {
|
func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
@@ -56,6 +67,7 @@ func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("get messages failed", "session_id", sessionID, "error", err)
|
||||||
return nil, 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]
|
rows[i], rows[j] = rows[j], rows[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("messages retrieved", "session_id", sessionID, "count", len(rows))
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, args ...any) ([]StoredMessage, error) {
|
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...)
|
pgxRows, err := r.pool.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("query messages failed", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer pgxRows.Close()
|
defer pgxRows.Close()
|
||||||
|
|
||||||
var messages []StoredMessage
|
messages := make([]StoredMessage, 0)
|
||||||
for pgxRows.Next() {
|
for pgxRows.Next() {
|
||||||
var m StoredMessage
|
var m StoredMessage
|
||||||
if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
messages = append(messages, m)
|
messages = append(messages, m)
|
||||||
}
|
}
|
||||||
if err := pgxRows.Err(); err != nil {
|
if err := pgxRows.Err(); err != nil {
|
||||||
|
log.Errorw("iterate message rows failed", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) {
|
func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var m StoredMessage
|
var m StoredMessage
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, session_id, role, content, tokens_used, created_at
|
`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
|
return nil, ErrMessageNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("get last message failed", "session_id", sessionID, "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("last message retrieved", "session_id", sessionID, "message_id", m.ID)
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) {
|
func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM messages WHERE session_id = $1`,
|
`SELECT COUNT(*) FROM messages WHERE session_id = $1`,
|
||||||
sessionID,
|
sessionID,
|
||||||
).Scan(&count)
|
).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("get message count failed", "session_id", sessionID, "error", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("message count retrieved", "session_id", sessionID, "count", count)
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) {
|
func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
if len(sessionIDs) == 0 {
|
if len(sessionIDs) == 0 {
|
||||||
return map[string]SessionMessageStats{}, nil
|
return map[string]SessionMessageStats{}, nil
|
||||||
}
|
}
|
||||||
@@ -143,6 +173,7 @@ func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessio
|
|||||||
sessionIDs,
|
sessionIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("get session message stats failed", "session_count", len(sessionIDs), "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -152,12 +183,16 @@ func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessio
|
|||||||
var sid string
|
var sid string
|
||||||
var stats SessionMessageStats
|
var stats SessionMessageStats
|
||||||
if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil {
|
if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil {
|
||||||
|
log.Errorw("scan message stats row failed", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result[sid] = stats
|
result[sid] = stats
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Errorw("iterate message stats rows failed", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session message stats retrieved", "session_count", len(sessionIDs), "result_count", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PgSessionRepository 基于 PostgreSQL 的 SessionRepository 实现。
|
// PgSessionRepository 基于 PostgreSQL 的 SessionRepository 实现。
|
||||||
@@ -19,6 +21,8 @@ func NewPgSessionRepository(pool *pgxpool.Pool) *PgSessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO sessions (id, user_id, title, config, created_at, updated_at)
|
`INSERT INTO sessions (id, user_id, title, config, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
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`,
|
updated_at = EXCLUDED.updated_at`,
|
||||||
s.ID, s.UserID, s.Title, s.Config, s.CreatedAt, s.UpdatedAt,
|
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) {
|
func (r *PgSessionRepository) FindByID(ctx context.Context, id string) (*SessionRecord, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var s SessionRecord
|
var s SessionRecord
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, user_id, title, config, created_at, updated_at
|
`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
|
return nil, ErrSessionNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find session failed", "session_id", id, "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session found", "session_id", id)
|
||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) {
|
func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
if page <= 0 {
|
if page <= 0 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -60,6 +77,7 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
|||||||
if err := r.pool.QueryRow(ctx,
|
if err := r.pool.QueryRow(ctx,
|
||||||
`SELECT COUNT(*) FROM sessions WHERE user_id = $1`, userID,
|
`SELECT COUNT(*) FROM sessions WHERE user_id = $1`, userID,
|
||||||
).Scan(&total); err != nil {
|
).Scan(&total); err != nil {
|
||||||
|
log.Errorw("count user sessions failed", "user_id", userID, "error", err)
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +91,7 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
|||||||
userID, size, offset,
|
userID, size, offset,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find user sessions failed", "user_id", userID, "error", err)
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -81,66 +100,90 @@ func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, pag
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s SessionRecord
|
var s SessionRecord
|
||||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
list = append(list, s)
|
list = append(list, s)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Errorw("iterate session rows failed", "user_id", userID, "error", err)
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user sessions found", "user_id", userID, "count", len(list), "total", total)
|
||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) UpdateTitle(ctx context.Context, id string, title string) error {
|
func (r *PgSessionRepository) UpdateTitle(ctx context.Context, id string, title string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, err := r.pool.Exec(ctx,
|
||||||
`UPDATE sessions SET title = $2, updated_at = NOW() WHERE id = $1`,
|
`UPDATE sessions SET title = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
id, title,
|
id, title,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("update session title failed", "session_id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrSessionNotFound
|
return ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session title updated", "session_id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) UpdateConfig(ctx context.Context, id string, configJSON []byte) error {
|
func (r *PgSessionRepository) UpdateConfig(ctx context.Context, id string, configJSON []byte) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, err := r.pool.Exec(ctx,
|
||||||
`UPDATE sessions SET config = $2, updated_at = NOW() WHERE id = $1`,
|
`UPDATE sessions SET config = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
id, configJSON,
|
id, configJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("update session config failed", "session_id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrSessionNotFound
|
return ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session config updated", "session_id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) Touch(ctx context.Context, id string) error {
|
func (r *PgSessionRepository) Touch(ctx context.Context, id string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, err := r.pool.Exec(ctx,
|
||||||
`UPDATE sessions SET updated_at = NOW() WHERE id = $1`, id,
|
`UPDATE sessions SET updated_at = NOW() WHERE id = $1`, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("touch session failed", "session_id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrSessionNotFound
|
return ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session touched", "session_id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgSessionRepository) Delete(ctx context.Context, id string) error {
|
func (r *PgSessionRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, err := r.pool.Exec(ctx,
|
||||||
`DELETE FROM sessions WHERE id = $1`, id,
|
`DELETE FROM sessions WHERE id = $1`, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("delete session failed", "session_id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrSessionNotFound
|
return ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("session deleted", "session_id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PgUserRepository 基于 PostgreSQL 的 UserRepository 实现。
|
// 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) {
|
func (r *PgUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var id string
|
var id string
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
|
||||||
username, passwordHash,
|
username, passwordHash,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("create user failed", "username", username, "error", err)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user created", "user_id", id, "username", username)
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var u User
|
var u User
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1`,
|
`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
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find user by username failed", "username", username, "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user found by username", "user_id", u.ID, "username", username)
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var u User
|
var u User
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1`,
|
`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
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find user by id failed", "user_id", id, "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user found by id", "user_id", id)
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||||
userID, tokenHash, expiresAt,
|
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) {
|
func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
var userID string
|
var userID string
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`,
|
`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
|
return "", ErrRefreshTokenNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find refresh token failed", "error", err)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("refresh token found", "user_id", userID)
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
|
func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
`DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
||||||
tokenHash,
|
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 {
|
func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||||
userID,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserScenarioRepository 用户自建情景仓储接口。
|
// UserScenarioRepository 用户自建情景仓储接口。
|
||||||
@@ -35,6 +36,8 @@ func NewPostgresUserScenarioRepo(pool *pgxpool.Pool) UserScenarioRepository {
|
|||||||
|
|
||||||
// Create 创建用户情景。
|
// Create 创建用户情景。
|
||||||
func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.UserScenario) error {
|
func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.UserScenario) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO user_scenarios (id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at)
|
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)
|
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, NULLIF($7, ''), $8, $9, $10)
|
||||||
@@ -69,13 +72,18 @@ func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.
|
|||||||
).Scan(&scenario.ID, &scenario.CreatedAt, &scenario.UpdatedAt)
|
).Scan(&scenario.ID, &scenario.CreatedAt, &scenario.UpdatedAt)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByID 根据 ID 查找情景。
|
// FindByID 根据 ID 查找情景。
|
||||||
func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*models.UserScenario, error) {
|
func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*models.UserScenario, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
FROM user_scenarios
|
FROM user_scenarios
|
||||||
@@ -100,13 +108,18 @@ func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*mo
|
|||||||
return nil, fmt.Errorf("user scenario not found: %s", id)
|
return nil, fmt.Errorf("user scenario not found: %s", id)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find user scenario failed", "scenario_id", id, "error", err)
|
||||||
return nil, fmt.Errorf("find user scenario: %w", err)
|
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user scenario found", "scenario_id", id)
|
||||||
return &scenario, nil
|
return &scenario, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByIDAndUserID 根据 ID 和用户 ID 查找情景(权限校验)。
|
// FindByIDAndUserID 根据 ID 和用户 ID 查找情景(权限校验)。
|
||||||
func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error) {
|
func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
FROM user_scenarios
|
FROM user_scenarios
|
||||||
@@ -131,13 +144,18 @@ func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, us
|
|||||||
return nil, fmt.Errorf("user scenario not found or no permission")
|
return nil, fmt.Errorf("user scenario not found or no permission")
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
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
|
return &scenario, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByUserID 查找用户的所有情景。
|
// FindByUserID 查找用户的所有情景。
|
||||||
func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error) {
|
func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error) {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
FROM user_scenarios
|
FROM user_scenarios
|
||||||
@@ -147,6 +165,7 @@ func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID stri
|
|||||||
|
|
||||||
rows, err := r.pool.Query(ctx, query, userID)
|
rows, err := r.pool.Query(ctx, query, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("find user scenarios failed", "user_id", userID, "error", err)
|
||||||
return nil, fmt.Errorf("find user scenarios: %w", err)
|
return nil, fmt.Errorf("find user scenarios: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -167,19 +186,25 @@ func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID stri
|
|||||||
&s.UpdatedAt,
|
&s.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("scan user scenario row failed", "user_id", userID, "error", err)
|
||||||
return nil, fmt.Errorf("scan user scenario: %w", err)
|
return nil, fmt.Errorf("scan user scenario: %w", err)
|
||||||
}
|
}
|
||||||
scenarios = append(scenarios, &s)
|
scenarios = append(scenarios, &s)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
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)
|
return nil, fmt.Errorf("iterate user scenarios: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user scenarios found", "user_id", userID, "count", len(scenarios))
|
||||||
return scenarios, nil
|
return scenarios, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新用户情景。
|
// Update 更新用户情景。
|
||||||
func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.UserScenario) error {
|
func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.UserScenario) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE user_scenarios
|
UPDATE user_scenarios
|
||||||
SET name = $1, icon = $2, description = $3, prompt = $4, greeting = $5, language = $6, updated_at = $7
|
SET name = $1, icon = $2, description = $3, prompt = $4, greeting = $5, language = $6, updated_at = $7
|
||||||
@@ -205,34 +230,47 @@ func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.
|
|||||||
return fmt.Errorf("user scenario not found or no permission")
|
return fmt.Errorf("user scenario not found or no permission")
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("update user scenario: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user scenario updated", "scenario_id", scenario.ID, "user_id", scenario.UserID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除用户情景。
|
// Delete 删除用户情景。
|
||||||
func (r *PostgresUserScenarioRepo) Delete(ctx context.Context, id string) error {
|
func (r *PostgresUserScenarioRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
log := trace.FromContext(ctx)
|
||||||
|
|
||||||
query := `DELETE FROM user_scenarios WHERE id = $1`
|
query := `DELETE FROM user_scenarios WHERE id = $1`
|
||||||
|
|
||||||
result, err := r.pool.Exec(ctx, query, id)
|
result, err := r.pool.Exec(ctx, query, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("delete user scenario failed", "scenario_id", id, "error", err)
|
||||||
return fmt.Errorf("delete user scenario: %w", err)
|
return fmt.Errorf("delete user scenario: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RowsAffected() == 0 {
|
if result.RowsAffected() == 0 {
|
||||||
return fmt.Errorf("user scenario not found")
|
return fmt.Errorf("user scenario not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user scenario deleted", "scenario_id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountByUserID 统计用户的情景数量。
|
// CountByUserID 统计用户的情景数量。
|
||||||
func (r *PostgresUserScenarioRepo) CountByUserID(ctx context.Context, userID string) (int, error) {
|
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`
|
query := `SELECT COUNT(*) FROM user_scenarios WHERE user_id = $1`
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
err := r.pool.QueryRow(ctx, query, userID).Scan(&count)
|
err := r.pool.QueryRow(ctx, query, userID).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Errorw("count user scenarios failed", "user_id", userID, "error", err)
|
||||||
return 0, fmt.Errorf("count user scenarios: %w", err)
|
return 0, fmt.Errorf("count user scenarios: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debugw("user scenarios counted", "user_id", userID, "count", count)
|
||||||
return count, nil
|
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] + "..."
|
||||||
|
}
|
||||||
@@ -15,12 +15,12 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/auth"
|
"github.com/hhs/camtalk/internal/auth"
|
||||||
"github.com/hhs/camtalk/internal/config"
|
"github.com/hhs/camtalk/internal/config"
|
||||||
"github.com/hhs/camtalk/internal/errors"
|
"github.com/hhs/camtalk/internal/errors"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
"github.com/hhs/camtalk/internal/ratelimit"
|
"github.com/hhs/camtalk/internal/ratelimit"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
"github.com/hhs/camtalk/internal/store"
|
"github.com/hhs/camtalk/internal/store"
|
||||||
|
"github.com/hhs/camtalk/internal/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newUpgrader 根据配置创建 WebSocket upgrader。
|
// newUpgrader 根据配置创建 WebSocket upgrader。
|
||||||
@@ -134,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)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Errorw("websocket upgrade failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
@@ -145,13 +156,17 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
var sessionID string
|
var sessionID string
|
||||||
if conversationID != "" {
|
if conversationID != "" {
|
||||||
sessionID = 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 {
|
} else {
|
||||||
sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig())
|
sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Errorw("create session failed", "error", err)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Errorw("create session failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx = trace.WithSessionID(ctx, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
@@ -168,7 +183,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ServerVersion: version,
|
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()
|
lastPong := time.Now()
|
||||||
@@ -186,7 +202,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if time.Since(lastPong) > heartbeatTimeout {
|
if time.Since(lastPong) > heartbeatTimeout {
|
||||||
logger.Log.Warnw("heartbeat timeout", "session", sessionID)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Warnw("heartbeat timeout")
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,7 +218,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
_, message, err := conn.ReadMessage()
|
_, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
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
|
break
|
||||||
}
|
}
|
||||||
@@ -226,14 +244,18 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
errors.SendWSError(client, errors.CodeInvalidMessage, msg.RequestID, err)
|
errors.SendWSError(client, errors.CodeInvalidMessage, msg.RequestID, err)
|
||||||
continue
|
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 {
|
if limiter != nil {
|
||||||
key := fmt.Sprintf("%s:query", userID)
|
key := fmt.Sprintf("%s:query", userID)
|
||||||
allowed, retryAfter := limiter.Allow(context.Background(), key)
|
allowed, retryAfter := limiter.Allow(context.Background(), key)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
logger.Log.Warnw("rate limited", "user_id", userID, "retry_after", retryAfter)
|
log.Warnw("rate limited", "user_id", userID, "retry_after", retryAfter)
|
||||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||||
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
||||||
continue
|
continue
|
||||||
@@ -242,16 +264,16 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
|
|
||||||
// 刷新会话 TTL
|
// 刷新会话 TTL
|
||||||
if err := client.sessionMgr.Touch(context.Background(), sessionID); err != nil {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建可取消的 context
|
// 创建可取消的 context
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
processCtx, cancel := context.WithCancel(queryCtx)
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
client.cancelFuncs[msg.RequestID] = cancel
|
client.cancelFuncs[msg.RequestID] = cancel
|
||||||
client.mu.Unlock()
|
client.mu.Unlock()
|
||||||
@@ -271,8 +293,9 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
|
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, sender); err != nil {
|
if err := client.orchestrator.ProcessQuery(processCtx, sessionID, msg, sender); err != nil {
|
||||||
logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err)
|
log := trace.FromContext(processCtx)
|
||||||
|
log.Errorw("process query failed", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -298,7 +321,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
if msg.Payload.Scenario != nil {
|
if msg.Payload.Scenario != nil {
|
||||||
scenarioID = *msg.Payload.Scenario
|
scenarioID = *msg.Payload.Scenario
|
||||||
}
|
}
|
||||||
logger.Log.Infow("config updated", "session", sessionID, "scenario", scenarioID)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Infow("config updated", "scenario", scenarioID)
|
||||||
|
|
||||||
// 如果切换了情景(非自由对话),返回首句引导
|
// 如果切换了情景(非自由对话),返回首句引导
|
||||||
if scenarioID != "" && scenarioID != "free_chat" {
|
if scenarioID != "" && scenarioID != "free_chat" {
|
||||||
@@ -350,7 +374,8 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "interrupt":
|
case "interrupt":
|
||||||
logger.Log.Infow("interrupt received", "session", sessionID)
|
log := trace.FromContext(ctx)
|
||||||
|
log.Infow("interrupt received")
|
||||||
|
|
||||||
// 获取活跃请求 ID 并取消
|
// 获取活跃请求 ID 并取消
|
||||||
reqID, _ := client.sessionMgr.GetActiveRequestID(context.Background(), sessionID)
|
reqID, _ := client.sessionMgr.GetActiveRequestID(context.Background(), sessionID)
|
||||||
@@ -378,12 +403,14 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
// 取消所有活跃请求
|
// 取消所有活跃请求
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
for reqID, cancel := range client.cancelFuncs {
|
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()
|
cancel()
|
||||||
}
|
}
|
||||||
client.cancelFuncs = make(map[string]context.CancelFunc)
|
client.cancelFuncs = make(map[string]context.CancelFunc)
|
||||||
client.mu.Unlock()
|
client.mu.Unlock()
|
||||||
|
|
||||||
// 断开连接时不销毁会话,让其自然过期(支持重连恢复)
|
// 断开连接时不销毁会话,让其自然过期(支持重连恢复)
|
||||||
logger.Log.Infow("client disconnected", "session", sessionID)
|
log = trace.FromContext(ctx)
|
||||||
|
log.Infow("client disconnected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"context"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/auth"
|
"github.com/hhs/camtalk/internal/auth"
|
||||||
"github.com/hhs/camtalk/internal/config"
|
"github.com/hhs/camtalk/internal/config"
|
||||||
@@ -148,7 +148,7 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se
|
|||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil))
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|
||||||
@@ -221,9 +221,9 @@ func TestWS_QueryFullFlow(t *testing.T) {
|
|||||||
imageB64 := base64.StdEncoding.EncodeToString([]byte("fake-image-data"))
|
imageB64 := base64.StdEncoding.EncodeToString([]byte("fake-image-data"))
|
||||||
|
|
||||||
mock := &MockOrchestrator{
|
mock := &MockOrchestrator{
|
||||||
STTResult: "你好,世界",
|
STTResult: "你好,世界",
|
||||||
LLMDeltas: []string{"你好", ",世界!"},
|
LLMDeltas: []string{"你好", ",世界!"},
|
||||||
TTSAudios: []string{base64.StdEncoding.EncodeToString([]byte("mp3-data-1")), base64.StdEncoding.EncodeToString([]byte("mp3-data-2"))},
|
TTSAudios: []string{base64.StdEncoding.EncodeToString([]byte("mp3-data-1")), base64.StdEncoding.EncodeToString([]byte("mp3-data-2"))},
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, wsURL := setupTestServer(t, mock)
|
srv, wsURL := setupTestServer(t, mock)
|
||||||
@@ -332,7 +332,7 @@ func TestWS_UnknownMessageType(t *testing.T) {
|
|||||||
err := conn.WriteJSON(map[string]string{"type": "unknown_type"})
|
err := conn.WriteJSON(map[string]string{"type": "unknown_type"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
errMsg := readJSON(t, conn)
|
errMsg := readJSON(t, conn)
|
||||||
assert.Equal(t, "error", errMsg["type"])
|
assert.Equal(t, "error", errMsg["type"])
|
||||||
assert.Equal(t, "INVALID_MESSAGE", errMsg["code"])
|
assert.Equal(t, "INVALID_MESSAGE", errMsg["code"])
|
||||||
assert.Contains(t, errMsg["message"], "unknown message type")
|
assert.Contains(t, errMsg["message"], "unknown message type")
|
||||||
@@ -591,7 +591,7 @@ func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.
|
|||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil))
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
return srv, tokenMgr, sessionMgr
|
return srv, tokenMgr, sessionMgr
|
||||||
@@ -642,7 +642,7 @@ func TestWS_AuthExpiredToken(t *testing.T) {
|
|||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr, nil))
|
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr, nil, nil))
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
-- 用户名索引(用于登录查询)
|
-- 用户名索引(用于登录查询)
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
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 表
|
-- Refresh Token 表
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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 索引(用于登出所有设备)
|
-- 用户 ID 索引(用于登出所有设备)
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_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 (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
session_id UUID NOT NULL,
|
session_id UUID NOT NULL,
|
||||||
role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system"
|
role VARCHAR(10) NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
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
|
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc
|
||||||
ON 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 (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
title VARCHAR(256) NOT NULL DEFAULT '新对话',
|
title VARCHAR(100) NOT NULL DEFAULT '新对话',
|
||||||
config JSONB NOT NULL DEFAULT '{}',
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
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_id ON sessions (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON sessions (user_id, updated_at DESC);
|
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 '会话最后更新时间';
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ CREATE TABLE user_scenarios (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
icon VARCHAR(10) DEFAULT '✨',
|
icon VARCHAR(20) DEFAULT '✨',
|
||||||
description VARCHAR(100) NOT NULL,
|
description VARCHAR(100),
|
||||||
prompt TEXT NOT NULL,
|
prompt TEXT NOT NULL,
|
||||||
greeting VARCHAR(200),
|
greeting VARCHAR(500),
|
||||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||||
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||||
CONSTRAINT check_description_length CHECK (char_length(description) >= 5 AND char_length(description) <= 100),
|
CONSTRAINT check_description_length CHECK (description IS NULL OR char_length(description) <= 100),
|
||||||
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 50 AND char_length(prompt) <= 2000)
|
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 10),
|
||||||
|
CONSTRAINT check_greeting_length CHECK (greeting IS NULL OR char_length(greeting) <= 500)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 为用户 ID 创建索引,加速查询
|
-- 为用户 ID 创建索引,加速查询
|
||||||
@@ -25,12 +26,15 @@ 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);
|
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||||
|
|
||||||
COMMENT ON TABLE user_scenarios IS '用户自建情景表';
|
-- 表和列注释
|
||||||
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识';
|
COMMENT ON TABLE user_scenarios IS '用户自建情景表,存储用户创建的 AI 对话情景配置';
|
||||||
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID,外键关联 users 表';
|
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识符 (UUID)';
|
||||||
COMMENT ON COLUMN user_scenarios.name IS '情景名称,如"创意写作导师"';
|
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID,外键关联 users 表,用户删除时级联删除';
|
||||||
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标,如"🎨"';
|
COMMENT ON COLUMN user_scenarios.name IS '情景名称 (2-50 字符),如"创意写作导师"';
|
||||||
COMMENT ON COLUMN user_scenarios.description IS '简短描述,显示在情景卡片上';
|
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标 (最多 20 字符),支持复合 Emoji,如"🎨"';
|
||||||
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt,定义 AI 行为';
|
COMMENT ON COLUMN user_scenarios.description IS '简短描述 (最多 100 字符),可选,显示在情景卡片上';
|
||||||
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导,可选';
|
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt (最少 10 字符,无上限),定义 AI 行为和对话风格';
|
||||||
COMMENT ON COLUMN user_scenarios.language IS '默认语言,如 zh-CN、en-US';
|
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 '情景最后更新时间';
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# CamTalk Eino 框架与编排设计
|
# CamTalk Eino 框架与编排设计
|
||||||
|
|
||||||
> 创建日期:2026-06-19
|
|
||||||
> 状态:已实施
|
|
||||||
> 合并自:`10-Eino重构方案.md` + `11-Eino框架技术文档.md`
|
|
||||||
|
|
||||||
## 1. 概述
|
## 1. 概述
|
||||||
|
|
||||||
### 1.1 为什么选择 Eino
|
### 1.1 为什么选择 Eino
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# 情景切换功能
|
# 情景切换功能
|
||||||
|
|
||||||
**状态**: ✅ 已完成
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 功能概述
|
## 功能概述
|
||||||
|
|
||||||
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
||||||
|
|||||||
@@ -805,8 +805,9 @@ func (r *CachedUserRepository) DeleteRefreshToken(ctx, tokenHash) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**降级策略**:
|
**降级策略**:
|
||||||
- Redis 操作失败时记录日志,但不阻断主流程
|
- Redis 操作失败时使用 `trace.FromContext(ctx)` 记录 Warn 日志(带 trace_id),但不阻断主流程
|
||||||
- DB 是唯一真实数据源,Redis 仅用于加速
|
- DB 是唯一真实数据源,Redis 仅用于加速
|
||||||
|
- 详见 `docs/13-日志追踪.md` — 存储层日志实现
|
||||||
|
|
||||||
### 6. Gin 中间件实现
|
### 6. Gin 中间件实现
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ if cfg.RateLimit.Enabled {
|
|||||||
logger.Log.Info("rate limiter initialized with Redis backend")
|
logger.Log.Info("rate limiter initialized with Redis backend")
|
||||||
} else {
|
} else {
|
||||||
// 单实例:使用内存令牌桶
|
// 单实例:使用内存令牌桶
|
||||||
limiter = ratelimit.NewLimiter(cfg.RateLimit)
|
limiter = ratelimit.NewMemoryLimiter(cfg.RateLimit)
|
||||||
logger.Log.Info("rate limiter initialized with in-memory backend")
|
logger.Log.Info("rate limiter initialized with in-memory backend")
|
||||||
}
|
}
|
||||||
defer limiter.Stop()
|
defer limiter.Stop()
|
||||||
@@ -590,9 +590,9 @@ case "query":
|
|||||||
// 限流检查
|
// 限流检查
|
||||||
if limiter != nil {
|
if limiter != nil {
|
||||||
key := fmt.Sprintf("%s:query", userID)
|
key := fmt.Sprintf("%s:query", userID)
|
||||||
allowed, retryAfter := limiter.Allow(context.Background(), key)
|
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
logger.Log.Warnw("rate limited", "user_id", userID, "retry_after", retryAfter)
|
// 限流触发时自动记录 Warn 日志(在 limiter 内部使用 trace.FromContext)
|
||||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||||
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
||||||
continue
|
continue
|
||||||
@@ -605,7 +605,7 @@ case "query":
|
|||||||
**设计要点**:
|
**设计要点**:
|
||||||
- key 格式:`userID:query`(用户级限流)
|
- key 格式:`userID:query`(用户级限流)
|
||||||
- 拒绝时发送 `RATE_LIMITED` 错误到客户端
|
- 拒绝时发送 `RATE_LIMITED` 错误到客户端
|
||||||
- 记录警告日志(便于监控告警)
|
- 限流触发时 `RedisLimiter.Allow` 内部自动记录 Warn 日志(带 trace_id,详见 `docs/13-日志追踪.md`)
|
||||||
- 不阻塞其他消息类型(`ping`/`config`/`interrupt` 不限流)
|
- 不阻塞其他消息类型(`ping`/`config`/`interrupt` 不限流)
|
||||||
|
|
||||||
### 配置加载与依赖注入
|
### 配置加载与依赖注入
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
# 自建情景功能完整文档
|
# 自建情景功能
|
||||||
|
|
||||||
**最后更新**: 2026-06-21
|
## 概述
|
||||||
**开发者**: Claude Code + cfy
|
|
||||||
**状态**: ✅ 开发完成(80%),待测试验证
|
|
||||||
|
|
||||||
---
|
用户可以创建自己的情景,而不仅限于系统预置的 5 种情景。
|
||||||
|
|
||||||
## 📊 总体进度
|
|
||||||
|
|
||||||
**当前状态**: ✅ **Phase 1-4 已完成**
|
|
||||||
**完成度**: 🟢 **80%** (4/5 Phases)
|
|
||||||
**剩余**: Phase 5 测试验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、功能概述
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
用户可以创建自己的情景,而不仅限于系统预置的 5 种情景:
|
|
||||||
|
|
||||||
**系统预置情景**(不可修改):
|
**系统预置情景**(不可修改):
|
||||||
- 💬 自由对话
|
- 💬 自由对话
|
||||||
@@ -34,7 +18,7 @@
|
|||||||
- 📖 历史学家
|
- 📖 历史学家
|
||||||
- ... (用户自由创建)
|
- ... (用户自由创建)
|
||||||
|
|
||||||
### 用户旅程
|
**用户旅程**:
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 用户点击"创建情景"按钮
|
1. 用户点击"创建情景"按钮
|
||||||
@@ -59,29 +43,32 @@
|
|||||||
8. AI 按照用户设定的 Prompt 扮演角色
|
8. AI 按照用户设定的 Prompt 扮演角色
|
||||||
```
|
```
|
||||||
|
|
||||||
**权限隔离**: 每个用户只能看到和管理自己创建的情景,通过 `user_id` 实现数据隔离。
|
**核心特性**:完整 CRUD 操作(创建/查看/编辑/删除),通过 `user_id` 实现用户数据完全隔离,Eino Graph 管线深度集成(动态加载自建情景 Prompt),中文/英文/日文全覆盖,Modal 对话框 + 图标选择器 + Prompt 编写指南,创建后立即可用无需刷新。
|
||||||
|
|
||||||
---
|
## 技术架构
|
||||||
|
|
||||||
## 二、技术实现架构
|
### 数据流
|
||||||
|
|
||||||
### 2.1 数据流图
|
**创建情景**:
|
||||||
|
|
||||||
```
|
```
|
||||||
【创建情景】
|
|
||||||
用户填写表单 → POST /api/scenarios → Handler 验证
|
用户填写表单 → POST /api/scenarios → Handler 验证
|
||||||
→ Repository.Create → PostgreSQL 插入 → 返回情景对象
|
→ Repository.Create → PostgreSQL 插入 → 返回情景对象
|
||||||
|
```
|
||||||
|
|
||||||
【AI 对话使用自建情景】
|
**AI 对话使用自建情景**:
|
||||||
|
|
||||||
|
```
|
||||||
WebSocket 连接 → ServeWS 获取 userID
|
WebSocket 连接 → ServeWS 获取 userID
|
||||||
→ Eino Graph 初始化 → nodes_history 查询 user_scenarios
|
→ Eino Graph 初始化 → nodes_history 查询 user_scenarios
|
||||||
→ GetScenarioPrompt(customScenarios) → 构建 System Prompt
|
→ GetScenarioPrompt(customScenarios) → 构建 System Prompt
|
||||||
→ LLM 生成回复
|
→ LLM 生成回复
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Eino 框架集成
|
### Eino 框架集成
|
||||||
|
|
||||||
|
**数据传递链路**:
|
||||||
|
|
||||||
**数据传递链路**:
|
|
||||||
```
|
```
|
||||||
JWT Token → userID
|
JWT Token → userID
|
||||||
↓
|
↓
|
||||||
@@ -100,19 +87,20 @@ llm.GetScenarioPrompt(scenarioID, language, customScenarios)
|
|||||||
LLM 使用自建情景 Prompt
|
LLM 使用自建情景 Prompt
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键修改文件**:
|
**关键修改文件**:
|
||||||
1. `backend/internal/eino/state.go` — PipelineState 添加 `UserID`
|
|
||||||
2. `backend/internal/eino/types.go` — PipelineInput 添加 `UserID`
|
|
||||||
3. `backend/internal/eino/graph.go` — 接受 `scenarioRepo` 参数
|
|
||||||
4. `backend/internal/eino/adapter.go` — 设置 UserID
|
|
||||||
5. `backend/internal/eino/nodes_history.go` — 查询自建情景
|
|
||||||
6. `backend/internal/ws/handler.go` — 首句引导支持自建情景
|
|
||||||
|
|
||||||
---
|
| 文件 | 变更说明 |
|
||||||
|
|------|----------|
|
||||||
|
| `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` | 首句引导支持自建情景 |
|
||||||
|
|
||||||
## 三、数据模型设计
|
## 数据模型
|
||||||
|
|
||||||
### 3.1 数据库表结构
|
### 数据库表结构
|
||||||
|
|
||||||
**表名**: `user_scenarios`
|
**表名**: `user_scenarios`
|
||||||
|
|
||||||
@@ -128,7 +116,7 @@ CREATE TABLE user_scenarios (
|
|||||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||||
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
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_description_length CHECK (description IS NULL OR char_length(description) <= 100),
|
||||||
@@ -141,16 +129,19 @@ 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)
|
|
||||||
|
|
||||||
### 3.2 后端数据模型
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `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
|
```go
|
||||||
// backend/internal/models/user_scenario.go
|
// backend/internal/models/user_scenario.go
|
||||||
@@ -178,7 +169,7 @@ type CreateUserScenarioRequest struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 前端数据结构
|
### 前端数据结构
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/lib/api/scenarios.ts
|
// frontend/src/lib/api/scenarios.ts
|
||||||
@@ -212,11 +203,9 @@ export interface ExtendedScenario {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## REST API
|
||||||
|
|
||||||
## 四、REST API 设计
|
### API 端点
|
||||||
|
|
||||||
### 4.1 API 端点
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 权限 |
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
@@ -226,9 +215,10 @@ export interface ExtendedScenario {
|
|||||||
| PATCH | `/api/scenarios/:id` | 更新情景 | 需登录 |
|
| PATCH | `/api/scenarios/:id` | 更新情景 | 需登录 |
|
||||||
| DELETE | `/api/scenarios/:id` | 删除情景 | 需登录 |
|
| DELETE | `/api/scenarios/:id` | 删除情景 | 需登录 |
|
||||||
|
|
||||||
### 4.2 API 示例
|
### API 示例
|
||||||
|
|
||||||
|
**创建情景**:
|
||||||
|
|
||||||
#### 创建情景
|
|
||||||
```http
|
```http
|
||||||
POST /api/scenarios
|
POST /api/scenarios
|
||||||
Authorization: Bearer <access_token>
|
Authorization: Bearer <access_token>
|
||||||
@@ -244,36 +234,36 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应**: 201 Created
|
响应 201 Created:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid-xxx",
|
"id": "uuid-xxx",
|
||||||
"user_id": "uuid-user",
|
"user_id": "uuid-user",
|
||||||
"name": "创意写作导师",
|
"name": "创意写作导师",
|
||||||
"icon": "✨",
|
"icon": "✨"
|
||||||
...
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 获取列表
|
**获取列表**:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/scenarios
|
GET /api/scenarios
|
||||||
Authorization: Bearer <access_token>
|
Authorization: Bearer <access_token>
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应**: 200 OK
|
响应 200 OK:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scenarios": [...],
|
"scenarios": [],
|
||||||
"total": 3
|
"total": 3
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 前端实现
|
||||||
|
|
||||||
## 五、前端实现
|
### 组件结构
|
||||||
|
|
||||||
### 5.1 组件结构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
@@ -291,21 +281,21 @@ frontend/src/
|
|||||||
└── scenarios.ts # API 调用封装
|
└── scenarios.ts # API 调用封装
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 核心 Hook
|
### 核心 Hook
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// useScenarios.ts
|
// useScenarios.ts
|
||||||
|
|
||||||
export function useScenarios(token: string | null) {
|
export function useScenarios(token: string | null) {
|
||||||
const [allScenarios, setAllScenarios] = useState<ExtendedScenario[]>([]);
|
const [allScenarios, setAllScenarios] = useState<ExtendedScenario[]>([]);
|
||||||
|
|
||||||
// 合并系统预置 + 用户自建
|
// 合并系统预置 + 用户自建
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const systemScenarios = scenarios.map(s => ({...s, isCustom: false}));
|
const systemScenarios = scenarios.map(s => ({...s, isCustom: false}));
|
||||||
const customScenarios = customList.map(s => ({...s, isCustom: true}));
|
const customScenarios = customList.map(s => ({...s, isCustom: true}));
|
||||||
setAllScenarios([...systemScenarios, ...customScenarios]);
|
setAllScenarios([...systemScenarios, ...customScenarios]);
|
||||||
}, [customList]);
|
}, [customList]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allScenarios,
|
allScenarios,
|
||||||
createScenario,
|
createScenario,
|
||||||
@@ -315,9 +305,10 @@ export function useScenarios(token: string | null) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.3 创建情景表单
|
### 创建情景表单
|
||||||
|
|
||||||
|
**表单字段**:
|
||||||
|
|
||||||
**表单字段**:
|
|
||||||
- 名称(必填,2-50 字符)
|
- 名称(必填,2-50 字符)
|
||||||
- 图标(可选,24 个预设 emoji)
|
- 图标(可选,24 个预设 emoji)
|
||||||
- 描述(可选,最多 100 字符)
|
- 描述(可选,最多 100 字符)
|
||||||
@@ -325,175 +316,15 @@ export function useScenarios(token: string | null) {
|
|||||||
- 首句引导(可选,最多 500 字符)
|
- 首句引导(可选,最多 500 字符)
|
||||||
- 语言(可选,默认 zh-CN)
|
- 语言(可选,默认 zh-CN)
|
||||||
|
|
||||||
**表单验证**:
|
**表单验证**:
|
||||||
|
|
||||||
- 实时字符计数
|
- 实时字符计数
|
||||||
- 长度限制提示
|
- 长度限制提示
|
||||||
- 必填项高亮
|
- 必填项高亮
|
||||||
|
|
||||||
---
|
## 使用指南
|
||||||
|
|
||||||
## 六、实施进度
|
### 后端 API 测试
|
||||||
|
|
||||||
### ✅ Phase 1: 后端基础(100% 完成)
|
|
||||||
|
|
||||||
**1.1 数据库迁移** ✅
|
|
||||||
- 文件: `backend/migrations/004_user_scenarios.up.sql`
|
|
||||||
- 创建 `user_scenarios` 表
|
|
||||||
- 添加索引和约束
|
|
||||||
|
|
||||||
**1.2 数据模型** ✅
|
|
||||||
- 文件: `backend/internal/models/user_scenario.go`
|
|
||||||
- 定义 `UserScenario` 结构体
|
|
||||||
- 定义请求/响应模型
|
|
||||||
|
|
||||||
**1.3 Repository 层** ✅
|
|
||||||
- 文件: `backend/internal/store/user_scenario_repository.go`
|
|
||||||
- 实现 `UserScenarioRepository` 接口
|
|
||||||
- CRUD 操作 + 权限校验
|
|
||||||
|
|
||||||
**1.4 REST API** ✅
|
|
||||||
- 文件: `backend/internal/api/user_scenario_handler.go`
|
|
||||||
- 5 个 HTTP 端点(创建/列表/详情/更新/删除)
|
|
||||||
- 输入验证和错误处理
|
|
||||||
|
|
||||||
### ✅ Phase 2: 后端集成(100% 完成)
|
|
||||||
|
|
||||||
**2.1 Prompt 加载逻辑** ✅
|
|
||||||
- 修改: `backend/internal/ai/llm/scenarios.go`
|
|
||||||
- `GetScenarioPrompt` 支持自建情景
|
|
||||||
- `GetScenarioGreeting` 支持自建情景
|
|
||||||
|
|
||||||
**2.2 Eino 框架集成** ✅
|
|
||||||
- 修改 7 个文件,完整数据链路
|
|
||||||
- PipelineState 添加 UserID
|
|
||||||
- nodes_history 查询用户自建情景
|
|
||||||
- 动态构建 System Prompt
|
|
||||||
|
|
||||||
### ✅ Phase 3: 前端 UI(100% 完成)
|
|
||||||
|
|
||||||
**3.1 API 封装** ✅
|
|
||||||
- 文件: `frontend/src/lib/api/scenarios.ts`
|
|
||||||
- 5 个 API 调用函数
|
|
||||||
|
|
||||||
**3.2 Hook 封装** ✅
|
|
||||||
- 文件: `frontend/src/hooks/useScenarios.ts`
|
|
||||||
- `useScenarios` Hook
|
|
||||||
- 合并系统预置 + 自建情景
|
|
||||||
|
|
||||||
**3.3 组件实现** ✅
|
|
||||||
- `CreateScenarioModal` — 创建对话框
|
|
||||||
- `EditScenarioModal` — 编辑对话框
|
|
||||||
- `ConfigPanel` 改造 — 分组显示 + 编辑/删除
|
|
||||||
|
|
||||||
**3.4 i18n 支持** ✅
|
|
||||||
- 中文/英文/日文翻译(+40 条)
|
|
||||||
|
|
||||||
**3.5 样式实现** ✅
|
|
||||||
- Modal、表单、图标选择器样式
|
|
||||||
|
|
||||||
### ✅ Phase 4: 前端集成(100% 完成)
|
|
||||||
|
|
||||||
**4.1 主应用集成** ✅
|
|
||||||
- 文件: `frontend/src/App.tsx`
|
|
||||||
- 集成 `useScenarios` Hook
|
|
||||||
- 渲染 Modal 组件
|
|
||||||
- 情景选择联动
|
|
||||||
|
|
||||||
**4.2 编译验证** ✅
|
|
||||||
- 前端: 669.96 kB JS + 55.80 kB CSS
|
|
||||||
- 后端: 48MB 二进制
|
|
||||||
|
|
||||||
### ⏳ Phase 5: 测试验证(待进行)
|
|
||||||
|
|
||||||
**5.1 后端测试**
|
|
||||||
- [ ] 数据库迁移验证
|
|
||||||
- [ ] REST API CRUD 测试
|
|
||||||
- [ ] 权限隔离测试
|
|
||||||
- [ ] Eino Graph 自建情景加载测试
|
|
||||||
|
|
||||||
**5.2 前端测试**
|
|
||||||
- [ ] 创建情景表单验证
|
|
||||||
- [ ] 编辑情景数据预填充
|
|
||||||
- [ ] 删除情景二次确认
|
|
||||||
- [ ] 情景列表实时更新
|
|
||||||
|
|
||||||
**5.3 集成测试**
|
|
||||||
- [ ] 创建自建情景后立即可用
|
|
||||||
- [ ] 切换到自建情景显示首句引导
|
|
||||||
- [ ] AI 对话使用自建 Prompt
|
|
||||||
- [ ] 多用户并发隔离
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、已完成文件清单
|
|
||||||
|
|
||||||
### 新增文件(13 个)
|
|
||||||
|
|
||||||
**后端(5 个)**:
|
|
||||||
1. `backend/migrations/004_user_scenarios.up.sql`
|
|
||||||
2. `backend/migrations/004_user_scenarios.down.sql`
|
|
||||||
3. `backend/internal/models/user_scenario.go`
|
|
||||||
4. `backend/internal/store/user_scenario_repository.go`
|
|
||||||
5. `backend/internal/api/user_scenario_handler.go`
|
|
||||||
|
|
||||||
**前端(5 个)**:
|
|
||||||
6. `frontend/src/lib/api/scenarios.ts`
|
|
||||||
7. `frontend/src/hooks/useScenarios.ts`
|
|
||||||
8. `frontend/src/components/CreateScenarioModal/index.tsx`
|
|
||||||
9. `frontend/src/components/EditScenarioModal/index.tsx`
|
|
||||||
|
|
||||||
**文档(3 个)**:
|
|
||||||
10. `docs/自建情景功能设计方案.md`
|
|
||||||
11. `docs/自建情景功能-权限隔离说明.md`
|
|
||||||
12. `docs/自建情景功能实施进度.md`
|
|
||||||
13. `docs/自建情景功能完整文档.md` (本文件)
|
|
||||||
|
|
||||||
### 修改文件(14 个)
|
|
||||||
|
|
||||||
**后端(8 个)**:
|
|
||||||
1. `backend/cmd/server/main.go` — 注册 API 路由 + 传递 scenarioRepo
|
|
||||||
2. `backend/internal/ai/llm/scenarios.go` — Prompt/Greeting 加载支持自建
|
|
||||||
3. `backend/internal/eino/state.go` — 添加 UserID 字段
|
|
||||||
4. `backend/internal/eino/types.go` — PipelineInput 添加 UserID
|
|
||||||
5. `backend/internal/eino/graph.go` — 接受并传递 scenarioRepo
|
|
||||||
6. `backend/internal/eino/adapter.go` — 复制 UserID 到 State
|
|
||||||
7. `backend/internal/eino/nodes_history.go` — 加载自建情景
|
|
||||||
8. `backend/internal/ws/handler.go` — 首句引导支持自建情景
|
|
||||||
|
|
||||||
**前端(6 个)**:
|
|
||||||
9. `frontend/src/App.tsx` — 集成自建情景管理
|
|
||||||
10. `frontend/src/components/ConfigPanel/index.tsx` — 分组显示 + 编辑/删除
|
|
||||||
11. `frontend/src/lib/i18n/zh-CN.ts` — 新增翻译
|
|
||||||
12. `frontend/src/lib/i18n/en-US.ts` — 新增翻译
|
|
||||||
13. `frontend/src/lib/i18n/ja-JP.ts` — 新增翻译
|
|
||||||
14. `frontend/src/App.css` — 新增样式
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、问题解决记录
|
|
||||||
|
|
||||||
### 8.1 CORS 错误
|
|
||||||
|
|
||||||
**问题**: 前端直接访问 `http://localhost:8080` 触发 CORS
|
|
||||||
**解决**: 将 `API_BASE` 改为空字符串,使用 Vite 代理
|
|
||||||
|
|
||||||
### 8.2 验证规则不一致
|
|
||||||
|
|
||||||
**问题**: 后端要求 `description` 必填,`prompt` 最小 50 字符
|
|
||||||
**解决**: 统一为 `description` 可选,`prompt` 最小 10 字符
|
|
||||||
|
|
||||||
### 8.3 数据库约束错误
|
|
||||||
|
|
||||||
**问题**: 空字符串 `""` 不满足 `char_length >= 1` 约束
|
|
||||||
**解决**:
|
|
||||||
1. 更新约束允许 `description IS NULL`
|
|
||||||
2. Repository 使用 `NULLIF($5, '')` 将空字符串转为 NULL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、测试指南
|
|
||||||
|
|
||||||
### 9.1 后端 API 测试
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 注册用户
|
# 1. 注册用户
|
||||||
@@ -528,9 +359,8 @@ curl -X DELETE http://localhost:8080/api/scenarios/<id> \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.2 前端功能测试
|
### 前端功能测试
|
||||||
|
|
||||||
**操作步骤**:
|
|
||||||
1. 刷新浏览器(Cmd+Shift+R)
|
1. 刷新浏览器(Cmd+Shift+R)
|
||||||
2. 登录账户
|
2. 登录账户
|
||||||
3. 打开设置面板(右上角齿轮)
|
3. 打开设置面板(右上角齿轮)
|
||||||
@@ -543,72 +373,53 @@ curl -X DELETE http://localhost:8080/api/scenarios/<id> \
|
|||||||
10. 编辑情景,验证数据预填充
|
10. 编辑情景,验证数据预填充
|
||||||
11. 删除情景,验证二次确认
|
11. 删除情景,验证二次确认
|
||||||
|
|
||||||
---
|
## 安全与限制
|
||||||
|
|
||||||
## 十、功能亮点
|
### 用户配额
|
||||||
|
|
||||||
✅ **完整的 CRUD** — 创建、查看、编辑、删除自建情景
|
|
||||||
✅ **权限隔离** — 用户数据完全隔离,无法互相访问
|
|
||||||
✅ **Eino 深度集成** — 在 Graph Pipeline 中动态加载自建情景
|
|
||||||
✅ **多语言支持** — 中文、英文、日文全覆盖
|
|
||||||
✅ **优雅的 UI** — Modal 对话框 + 图标选择器 + Prompt 编写指南
|
|
||||||
✅ **实时生效** — 创建后立即可用,无需刷新
|
|
||||||
✅ **表单验证** — 字符计数、长度限制、必填项提示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、安全与限制
|
|
||||||
|
|
||||||
### 11.1 用户配额
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
const MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
const MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.2 权限控制
|
### 权限控制
|
||||||
|
|
||||||
- 只能查看/编辑/删除自己的情景
|
- 只能查看/编辑/删除自己的情景
|
||||||
- 系统预置情景不可编辑/删除
|
- 系统预置情景不可编辑/删除
|
||||||
- 后端验证 `user_id` 匹配
|
- 后端验证 `user_id` 匹配
|
||||||
|
|
||||||
### 11.3 数据验证
|
### 数据验证
|
||||||
|
|
||||||
**后端**:
|
**后端**:
|
||||||
- 名称: 2-50 字符
|
|
||||||
- 描述: 可选,最多 100 字符
|
- 名称:2-50 字符
|
||||||
- Prompt: 10-2000 字符
|
- 描述:可选,最多 100 字符
|
||||||
- 首句: 可选,最多 500 字符
|
- Prompt:10-2000 字符
|
||||||
|
- 首句引导:可选,最多 500 字符
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
|
||||||
**前端**:
|
|
||||||
- 实时字符计数
|
- 实时字符计数
|
||||||
- 超长提示
|
- 超长提示
|
||||||
- 必填项高亮
|
- 必填项高亮
|
||||||
|
|
||||||
---
|
## 未来优化方向
|
||||||
|
|
||||||
## 十二、未来优化方向
|
**V1.1**:
|
||||||
|
|
||||||
### V1.1 功能(推荐)
|
|
||||||
- Prompt 模板库
|
- Prompt 模板库
|
||||||
- 实时预览效果
|
- 实时预览效果
|
||||||
- 导入导出功能
|
- 导入导出功能
|
||||||
- 情景搜索和筛选
|
- 情景搜索和筛选
|
||||||
|
|
||||||
### V2.0 功能(长期)
|
**V2.0**:
|
||||||
|
|
||||||
- 情景市场
|
- 情景市场
|
||||||
- 情景分享链接
|
- 情景分享链接
|
||||||
- AI 辅助优化 Prompt
|
- AI 辅助优化 Prompt
|
||||||
- 协作编辑(团队情景)
|
- 协作编辑(团队情景)
|
||||||
|
|
||||||
---
|
## 参考资料
|
||||||
|
|
||||||
## 十三、参考资料
|
|
||||||
|
|
||||||
- [CLAUDE.md](../CLAUDE.md) — 项目开发指南
|
- [CLAUDE.md](../CLAUDE.md) — 项目开发指南
|
||||||
- [02-接口文档.md](./02-接口文档.md) — WebSocket 和 REST API
|
- [02-接口文档.md](./02-接口文档.md) — WebSocket 和 REST API
|
||||||
- [自建情景功能-权限隔离说明.md](./自建情景功能-权限隔离说明.md) — 安全设计
|
- [自建情景功能-权限隔离说明.md](./自建情景功能-权限隔离说明.md) — 安全设计
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**开发完成日期**: 2026-06-21
|
|
||||||
**下一步行动**: 启动服务进行人工测试验证
|
|
||||||
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 延迟。
|
||||||
@@ -17,6 +17,8 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
|||||||
| [09-情景切换](09-情景切换.md) | 多情景 AI 角色扮演系统(面试官、英语老师、辩论对手、翻译员、自由对话) |
|
| [09-情景切换](09-情景切换.md) | 多情景 AI 角色扮演系统(面试官、英语老师、辩论对手、翻译员、自由对话) |
|
||||||
| [10-鉴权体系](10-鉴权体系.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 |
|
| [10-鉴权体系](10-鉴权体系.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 |
|
||||||
| [11-令牌桶限流](11-令牌桶限流.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流 |
|
| [11-令牌桶限流](11-令牌桶限流.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流 |
|
||||||
|
| [12-自定义情景](12-自定义情景.md) | 用户自定义情景的完整设计 |
|
||||||
|
| [13-日志追踪](13-日志追踪.md) | 全链路日志追踪系统(trace ID、敏感内容保护、日志规范) |
|
||||||
|
|
||||||
|
|
||||||
## 推荐阅读顺序
|
## 推荐阅读顺序
|
||||||
@@ -30,6 +32,8 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
|||||||
7. **09-情景切换** — 多情景 AI 角色扮演系统
|
7. **09-情景切换** — 多情景 AI 角色扮演系统
|
||||||
8. **10-鉴权体系** — 认证授权机制详细设计
|
8. **10-鉴权体系** — 认证授权机制详细设计
|
||||||
9. **11-令牌桶限流** — 速率限制设计
|
9. **11-令牌桶限流** — 速率限制设计
|
||||||
|
10. **12-自定义情景** — 用户自定义情景
|
||||||
|
11. **13-日志追踪** — 全链路日志追踪(trace ID、敏感内容保护、开发参考)
|
||||||
|
|
||||||
## 功能扩展方向
|
## 功能扩展方向
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,15 @@ body {
|
|||||||
background: rgba(10, 10, 15, 0.45);
|
background: rgba(10, 10, 15, 0.45);
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
-webkit-backdrop-filter: blur(3px);
|
-webkit-backdrop-filter: blur(3px);
|
||||||
animation: fadeIn 0.2s ease;
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -232,17 +240,21 @@ body {
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: slideInLeft 0.3s var(--transition-smooth);
|
|
||||||
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
|
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
|
||||||
}
|
transition: transform 0.2s ease-out, visibility 0s linear 0.2s;
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
|
||||||
from { transform: translateX(-100%); }
|
|
||||||
to { transform: translateX(0); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar--collapsed {
|
.sidebar--collapsed {
|
||||||
display: none;
|
transform: translateX(-100%);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
visibility: visible;
|
||||||
|
transition: transform 0.2s ease-out, visibility 0s linear 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__header {
|
.sidebar__header {
|
||||||
@@ -495,7 +507,10 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
@@ -652,7 +667,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 0;
|
padding: 10px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-controls__row {
|
.video-controls__row {
|
||||||
@@ -900,6 +915,8 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
max-width: 100%;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message--system::before,
|
.chat-message--system::before,
|
||||||
@@ -1074,7 +1091,15 @@ body {
|
|||||||
background: rgba(10, 10, 15, 0.5);
|
background: rgba(10, 10, 15, 0.5);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
-webkit-backdrop-filter: blur(4px);
|
-webkit-backdrop-filter: blur(4px);
|
||||||
animation: fadeIn 0.25s var(--transition-smooth);
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
@@ -1087,8 +1112,18 @@ body {
|
|||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: slideInRight 0.35s var(--transition-smooth);
|
|
||||||
box-shadow: -8px 0 40px rgba(10, 10, 15, 0.5);
|
box-shadow: -8px 0 40px rgba(10, 10, 15, 0.5);
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer--collapsed {
|
||||||
|
transform: translateX(100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__header {
|
.drawer__header {
|
||||||
@@ -1359,9 +1394,9 @@ body {
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInRight {
|
@keyframes slideUp {
|
||||||
from { transform: translateX(100%); }
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
to { transform: translateX(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Scrollbar ---- */
|
/* ---- Scrollbar ---- */
|
||||||
@@ -1387,11 +1422,258 @@ body {
|
|||||||
|
|
||||||
.video-controls__toolbar {
|
.video-controls__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Round Control Buttons (Camera / Mic) ---- */
|
||||||
|
|
||||||
|
.btn-ctrl-round {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round:hover {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round--on {
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
border-color: rgba(52, 211, 153, 0.3);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round--on:hover {
|
||||||
|
background: rgba(52, 211, 153, 0.18);
|
||||||
|
border-color: rgba(52, 211, 153, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round--off {
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border-color: rgba(248, 113, 113, 0.2);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round--off:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-round--speaking {
|
||||||
|
animation: micPulse 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||||
|
box-shadow: 0 0 14px rgba(52, 211, 153, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pill Control Buttons (Recognize / Interrupt / Stop) ---- */
|
||||||
|
|
||||||
|
.btn-ctrl-pill {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--recognize {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--recognize:hover:not(:disabled) {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border-color: rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--interrupt {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-color: rgba(251, 191, 36, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--interrupt:hover:not(:disabled) {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
border-color: rgba(251, 191, 36, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--stop {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: rgba(248, 113, 113, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ctrl-pill--stop:hover:not(:disabled) {
|
||||||
|
background: rgba(248, 113, 113, 0.18);
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scenario Chip Strip (horizontal scroll, video panel) ---- */
|
||||||
|
|
||||||
|
.scenario-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
/* Thin scrollbar - visible but unobtrusive */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__scroll::-webkit-scrollbar {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-strip__scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual scenario chip */
|
||||||
|
.scenario-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip:hover {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
border-color: var(--color-surface-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(10, 10, 15, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active chip */
|
||||||
|
.scenario-chip--active {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: rgba(59, 130, 246, 0.35);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip--active:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.16);
|
||||||
|
border-color: rgba(59, 130, 246, 0.45);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "+" create new scenario chip */
|
||||||
|
.scenario-chip--add {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip--add:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip--add .scenario-chip__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip__icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chip__name {
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Legacy button styles kept for backward compat ---- */
|
||||||
|
|
||||||
.btn--recognize {
|
.btn--recognize {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@@ -1416,35 +1698,40 @@ body {
|
|||||||
/* Device selectors */
|
/* Device selectors */
|
||||||
.video-controls__devices {
|
.video-controls__devices {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-select-wrapper {
|
.device-select-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-select-label {
|
.device-select-label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-weight: 500;
|
flex-shrink: 0;
|
||||||
letter-spacing: 0.01em;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-select {
|
.device-select {
|
||||||
padding: 5px 10px;
|
flex: 1;
|
||||||
font-size: 0.72rem;
|
min-width: 0;
|
||||||
border-radius: var(--radius-sm);
|
padding: 5px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
min-width: 120px;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-select:hover {
|
.device-select:hover {
|
||||||
@@ -1926,7 +2213,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Custom Scenarios Styles
|
Custom Scenarios Styles — 白色商务风格(使用 CSS 变量)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
/* Scenario list */
|
/* Scenario list */
|
||||||
@@ -1942,16 +2229,16 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--color-surface-2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-item:hover {
|
.scenario-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--color-surface-3);
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
border-color: var(--color-surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-item input[type="radio"] {
|
.scenario-item input[type="radio"] {
|
||||||
@@ -1966,12 +2253,12 @@ body {
|
|||||||
.scenario-item__name {
|
.scenario-item__name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-item__desc {
|
.scenario-item__desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--color-text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,28 +2291,31 @@ body {
|
|||||||
.scenario-action-btn {
|
.scenario-action-btn {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-action-btn:hover {
|
.scenario-action-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--color-surface-2);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--color-surface-3);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-action-btn--confirm {
|
.scenario-action-btn--confirm {
|
||||||
background: rgba(248, 113, 113, 0.15);
|
background: rgba(248, 113, 113, 0.1);
|
||||||
border-color: rgba(248, 113, 113, 0.3);
|
border-color: rgba(248, 113, 113, 0.2);
|
||||||
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-empty {
|
.scenario-empty {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2034,17 +2324,17 @@ body {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(196, 97, 47, 0.15);
|
background: rgba(59, 130, 246, 0.08);
|
||||||
border: 1px solid rgba(196, 97, 47, 0.3);
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #C4612F;
|
color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-create-btn:hover {
|
.config-create-btn:hover {
|
||||||
background: rgba(196, 97, 47, 0.25);
|
background: rgba(59, 130, 246, 0.16);
|
||||||
border-color: rgba(196, 97, 47, 0.5);
|
border-color: rgba(59, 130, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-group__title {
|
.config-group__title {
|
||||||
@@ -2060,25 +2350,30 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(10, 10, 15, 0.45);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
animation: fadeIn 0.2s;
|
animation: fadeIn 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #1F2421;
|
background: var(--color-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 16px 48px rgba(10, 10, 15, 0.25);
|
||||||
animation: slideUp 0.3s;
|
animation: slideUp 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2091,20 +2386,21 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__title {
|
.modal__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__close {
|
.modal__close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@@ -2112,13 +2408,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__close:hover {
|
.modal__close:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--color-surface-2);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__body {
|
.modal__body {
|
||||||
@@ -2132,7 +2428,7 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid var(--color-border);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2147,12 +2443,12 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: var(--color-text);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-required {
|
.form-required {
|
||||||
color: #C4612F;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input,
|
.form-input,
|
||||||
@@ -2160,21 +2456,28 @@ body {
|
|||||||
.form-select {
|
.form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--color-surface-2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--color-text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder,
|
||||||
|
.form-textarea::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus,
|
.form-input:focus,
|
||||||
.form-textarea:focus,
|
.form-textarea:focus,
|
||||||
.form-select:focus {
|
.form-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(196, 97, 47, 0.5);
|
border-color: var(--color-primary);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--color-surface);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
@@ -2188,15 +2491,15 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(248, 113, 113, 0.15);
|
background: rgba(248, 113, 113, 0.08);
|
||||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
color: #f87171;
|
color: var(--color-error);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -2216,129 +2519,112 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--color-surface-2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-picker__item:hover {
|
.icon-picker__item:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--color-surface-3);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--color-surface-3);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-picker__item--active {
|
.icon-picker__item--active {
|
||||||
background: rgba(196, 97, 47, 0.2);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
border-color: rgba(196, 97, 47, 0.5);
|
border-color: rgba(59, 130, 246, 0.4);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prompt guide */
|
/* Prompt guide */
|
||||||
.form-guide-btn {
|
.form-guide-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #C4612F;
|
color: var(--color-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-guide-btn:hover {
|
.form-guide-btn:hover {
|
||||||
background: rgba(196, 97, 47, 0.1);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-guide {
|
.form-guide {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--color-surface-2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-guide strong {
|
.form-guide strong {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-guide ul {
|
.form-guide ul {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-guide__code {
|
.form-guide__code {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--color-surface-3);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons (modal-scoped overrides — only affect modal context) */
|
||||||
.btn {
|
.modal .btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.modal .btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary {
|
.modal .btn--primary {
|
||||||
background: #C4612F;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #C4612F;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary:hover:not(:disabled) {
|
.modal .btn--primary:hover:not(:disabled) {
|
||||||
background: #A94E22;
|
background: var(--color-primary-hover);
|
||||||
border-color: #A94E22;
|
border-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--secondary {
|
.modal .btn--secondary {
|
||||||
background: transparent;
|
background: var(--color-surface-2);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--color-text-muted);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--secondary:hover:not(:disabled) {
|
.modal .btn--secondary:hover:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--color-surface-3);
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: var(--color-surface-3);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations — fadeIn & slideUp defined in main animation section above */
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(20px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,11 +19,43 @@ import { EditScenarioModal } from "./components/EditScenarioModal";
|
|||||||
import { AuthProvider, useAuth } from "./lib/auth";
|
import { AuthProvider, useAuth } from "./lib/auth";
|
||||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||||
|
import { renderScenarioIconById } from "./lib/scenarioIcons";
|
||||||
import type { Locale } from "./lib/i18n";
|
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 { Theme } from "./types";
|
||||||
import type { UserScenario } from "./lib/api/scenarios";
|
import type { UserScenario } from "./lib/api/scenarios";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
|
// ---- 统一 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 */
|
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||||
@@ -205,10 +237,14 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConfig(updates);
|
updateConfig(updates);
|
||||||
// 插入系统提示消息
|
// 插入系统提示消息(自建情景的 icon 是 SVG ID,需转为 emoji)
|
||||||
const scenarioName = sc
|
const iconText = sc
|
||||||
? `${sc.icon} ${sc.nameKey ? tr(sc.nameKey) : sc.name}`
|
? (ICON_ID_TO_EMOJI[sc.icon] || sc.icon)
|
||||||
|
: "";
|
||||||
|
const displayName = sc
|
||||||
|
? (sc.nameKey ? tr(sc.nameKey) : sc.name)
|
||||||
: scenarioId;
|
: scenarioId;
|
||||||
|
const scenarioName = iconText ? `${iconText} ${displayName}` : displayName;
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -217,9 +253,6 @@ function AppContent() {
|
|||||||
}]);
|
}]);
|
||||||
}, [updateConfig, setMessages, tr]);
|
}, [updateConfig, setMessages, tr]);
|
||||||
|
|
||||||
// 当前情景对象
|
|
||||||
const activeScenario = allScenarios.find((s) => s.id === (config.scenario || "free_chat")) || allScenarios[0];
|
|
||||||
|
|
||||||
// ---- 键盘快捷键 ----
|
// ---- 键盘快捷键 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -296,34 +329,33 @@ function AppContent() {
|
|||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onToggle={() => setSidebarOpen((v) => !v)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
onNewSession={handleNewSession}
|
onNewSession={handleNewSession}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
onRenameSession={renameSession}
|
onRenameSession={renameSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showConfig && (
|
<ConfigPanel
|
||||||
<ConfigPanel
|
config={config}
|
||||||
config={config}
|
theme={theme}
|
||||||
theme={theme}
|
username={user?.username}
|
||||||
username={user?.username}
|
allScenarios={allScenarios}
|
||||||
allScenarios={allScenarios}
|
open={showConfig}
|
||||||
onUpdate={updateConfig}
|
onUpdate={updateConfig}
|
||||||
onThemeChange={handleThemeChange}
|
onThemeChange={handleThemeChange}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onClose={() => setShowConfig(false)}
|
onClose={() => setShowConfig(false)}
|
||||||
onCreateScenario={() => {
|
onCreateScenario={() => {
|
||||||
setShowConfig(false);
|
setShowConfig(false);
|
||||||
setShowCreateScenario(true);
|
setShowCreateScenario(true);
|
||||||
}}
|
}}
|
||||||
onEditScenario={(scenario) => {
|
onEditScenario={(scenario) => {
|
||||||
setShowConfig(false);
|
setShowConfig(false);
|
||||||
setEditingScenario(scenario);
|
setEditingScenario(scenario);
|
||||||
}}
|
}}
|
||||||
onDeleteScenario={deleteScenario}
|
onDeleteScenario={deleteScenario}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 创建情景 Modal */}
|
{/* 创建情景 Modal */}
|
||||||
{showCreateScenario && (
|
{showCreateScenario && (
|
||||||
@@ -378,7 +410,7 @@ function AppContent() {
|
|||||||
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
|
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
|
||||||
)}
|
)}
|
||||||
{vadError && (
|
{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 && (
|
{!isConnected && !stream && (
|
||||||
<div className="video-placeholder">
|
<div className="video-placeholder">
|
||||||
@@ -401,6 +433,37 @@ function AppContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="video-controls">
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
@@ -415,40 +478,75 @@ function AppContent() {
|
|||||||
{/* 视频通话态:核心控制工具栏 */}
|
{/* 视频通话态:核心控制工具栏 */}
|
||||||
<div className="video-controls__toolbar">
|
<div className="video-controls__toolbar">
|
||||||
<button
|
<button
|
||||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
className={`btn-ctrl-round ${isCameraOn ? "btn-ctrl-round--on" : "btn-ctrl-round--off"}`}
|
||||||
onClick={toggleCamera}
|
onClick={toggleCamera}
|
||||||
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
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>
|
||||||
<button
|
<button
|
||||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
className={`btn-ctrl-round ${isMicOn ? "btn-ctrl-round--on" : "btn-ctrl-round--off"} ${isSpeaking ? "btn-ctrl-round--speaking" : ""}`}
|
||||||
onClick={toggleMic}
|
onClick={toggleMic}
|
||||||
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
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>
|
||||||
{/* 识别画面按钮 */}
|
|
||||||
<button
|
<button
|
||||||
className="btn--recognize"
|
className="btn-ctrl-pill btn-ctrl-pill--recognize"
|
||||||
onClick={handleRecognize}
|
onClick={handleRecognize}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
🔍 {tr("controls.recognize")}
|
<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>
|
</button>
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<button className="btn btn--warning" onClick={interrupt}>
|
<button className="btn-ctrl-pill btn-ctrl-pill--interrupt" onClick={interrupt}>
|
||||||
{tr("controls.interrupt")}
|
{tr("controls.interrupt")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn--danger" onClick={stopVideo}>
|
<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")}
|
{tr("controls.stopVideo")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 设备选择器 */}
|
{/* 设备选择器 */}
|
||||||
<div className="video-controls__devices">
|
<div className="video-controls__devices">
|
||||||
<div className="device-select-wrapper">
|
<div className="device-select-wrapper">
|
||||||
<label className="device-select-label">📷</label>
|
<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
|
<select
|
||||||
className="device-select"
|
className="device-select"
|
||||||
value={config.cameraDeviceId || "default"}
|
value={config.cameraDeviceId || "default"}
|
||||||
@@ -461,7 +559,7 @@ function AppContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="device-select-wrapper">
|
<div className="device-select-wrapper">
|
||||||
<label className="device-select-label">🎤</label>
|
<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
|
<select
|
||||||
className="device-select"
|
className="device-select"
|
||||||
value={config.micDeviceId || "default"}
|
value={config.micDeviceId || "default"}
|
||||||
@@ -480,7 +578,7 @@ function AppContent() {
|
|||||||
{/* 文字对话态:视频已结束 */}
|
{/* 文字对话态:视频已结束 */}
|
||||||
<div className="video-controls__text-only">
|
<div className="video-controls__text-only">
|
||||||
<div className="video-ended-hint">
|
<div className="video-ended-hint">
|
||||||
<span className="video-ended-hint__title">📹 {tr("video.ended")}</span>
|
<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>
|
<span className="video-ended-hint__sub">{tr("video.ended.hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-controls__toolbar">
|
<div className="video-controls__toolbar">
|
||||||
@@ -502,18 +600,6 @@ function AppContent() {
|
|||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
<span>{tr("chat.title")}</span>
|
<span>{tr("chat.title")}</span>
|
||||||
<div className="chat-panel-header__right">
|
<div className="chat-panel-header__right">
|
||||||
<select
|
|
||||||
className="scenario-selector"
|
|
||||||
value={activeScenario.id}
|
|
||||||
onChange={(e) => handleSelectScenario(e.target.value)}
|
|
||||||
title={tr("settings.scenario")}
|
|
||||||
>
|
|
||||||
{allScenarios.map((sc) => (
|
|
||||||
<option key={sc.id} value={sc.id}>
|
|
||||||
{sc.icon} {sc.nameKey ? tr(sc.nameKey) : sc.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{isConnected && stats.queryCount > 0 && (
|
{isConnected && stats.queryCount > 0 && (
|
||||||
<span className="chat-panel-header__stats">
|
<span className="chat-panel-header__stats">
|
||||||
{stats.queryCount} {tr("statusbar.recognitions")}
|
{stats.queryCount} {tr("statusbar.recognitions")}
|
||||||
@@ -534,6 +620,7 @@ function AppContent() {
|
|||||||
currentReply={currentReply}
|
currentReply={currentReply}
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
currentScenario={config.scenario}
|
currentScenario={config.scenario}
|
||||||
|
allScenarios={allScenarios}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
isVADReady={isVADReady}
|
isVADReady={isVADReady}
|
||||||
vadError={vadError ?? undefined}
|
vadError={vadError ?? undefined}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
// 增强:空状态情景选择卡片、语音输入按钮
|
// 增强:空状态情景选择卡片、语音输入按钮
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { scenarios } from "../../lib/scenarios";
|
import { scenarios } from "../../lib/scenarios";
|
||||||
|
import type { ExtendedScenario } from "../../hooks/useScenarios";
|
||||||
import type { ChatMessage } from "../../types";
|
import type { ChatMessage } from "../../types";
|
||||||
import type { ConnectionStatus } from "../../lib/websocket";
|
import type { ConnectionStatus } from "../../lib/websocket";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface ChatPanelProps {
|
|||||||
currentReply?: string;
|
currentReply?: string;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
currentScenario?: string;
|
currentScenario?: string;
|
||||||
|
allScenarios?: ExtendedScenario[];
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
isVADReady?: boolean;
|
isVADReady?: boolean;
|
||||||
vadError?: string | null;
|
vadError?: string | null;
|
||||||
@@ -28,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 [
|
return [
|
||||||
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
{ 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: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
{ 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: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
{ 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: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
{ 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") },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export function ChatPanel({
|
|||||||
currentReply,
|
currentReply,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
currentScenario,
|
currentScenario,
|
||||||
|
allScenarios,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isVADReady,
|
isVADReady,
|
||||||
vadError,
|
vadError,
|
||||||
@@ -51,7 +55,6 @@ export function ChatPanel({
|
|||||||
onSendText,
|
onSendText,
|
||||||
onToggleMic,
|
onToggleMic,
|
||||||
onSceneCard,
|
onSceneCard,
|
||||||
onSelectScenario,
|
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -103,6 +106,10 @@ export function ChatPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当前情景对象(优先从 allScenarios 查找,含自建情景;兜底用系统情景)
|
||||||
|
const scenarioList = allScenarios || scenarios;
|
||||||
|
const activeScenarioObj = scenarioList.find(sc => sc.id === activeScenario);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel">
|
||||||
<div className="chat-panel__messages" ref={containerRef}>
|
<div className="chat-panel__messages" ref={containerRef}>
|
||||||
@@ -111,44 +118,26 @@ export function ChatPanel({
|
|||||||
<div className="chat-panel__scenario-hint">
|
<div className="chat-panel__scenario-hint">
|
||||||
<div className="scenario-hint-card">
|
<div className="scenario-hint-card">
|
||||||
<span className="scenario-hint-card__icon">
|
<span className="scenario-hint-card__icon">
|
||||||
{scenarios.find(s => s.id === activeScenario)?.icon}
|
{activeScenarioObj?.icon}
|
||||||
</span>
|
</span>
|
||||||
<div className="scenario-hint-card__text">
|
<div className="scenario-hint-card__text">
|
||||||
<strong>{t(scenarios.find(s => s.id === activeScenario)?.nameKey || "")}</strong>
|
<strong>{activeScenarioObj ? (activeScenarioObj.nameKey ? t(activeScenarioObj.nameKey) : ('name' in activeScenarioObj ? activeScenarioObj.name : '')) : ""}</strong>
|
||||||
<p>{t(`scenario.${activeScenario}.hint`)}</p>
|
{activeScenarioObj && (activeScenarioObj.descKey || ('description' in activeScenarioObj && activeScenarioObj.description)) && (
|
||||||
|
<p>{activeScenarioObj.descKey ? t(activeScenarioObj.descKey) : ('description' in activeScenarioObj ? activeScenarioObj.description : '')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态:情景选择 + 场景卡片 */}
|
{/* 空状态:欢迎信息 */}
|
||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
<div className="chat-panel__welcome">
|
<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>
|
<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>
|
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||||
|
|
||||||
{/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */}
|
{/* 视觉分析快捷卡片(仅 free_chat 模式 + 已连接) */}
|
||||||
{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 模式) */}
|
|
||||||
{isFreeChat && isConnected && (
|
{isFreeChat && isConnected && (
|
||||||
<div className="scene-cards">
|
<div className="scene-cards">
|
||||||
{sceneCards.map((card) => (
|
{sceneCards.map((card) => (
|
||||||
@@ -262,7 +251,7 @@ export function ChatPanel({
|
|||||||
onClick={onToggleMic}
|
onClick={onToggleMic}
|
||||||
title={t("chat.input.voice")}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -271,7 +260,7 @@ export function ChatPanel({
|
|||||||
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
||||||
title={t("chat.send")}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ConfigPanelProps {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
username?: string;
|
username?: string;
|
||||||
allScenarios: ExtendedScenario[];
|
allScenarios: ExtendedScenario[];
|
||||||
|
open: boolean;
|
||||||
onUpdate: (partial: Partial<SessionConfig>) => void;
|
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||||
onThemeChange: (theme: Theme) => void;
|
onThemeChange: (theme: Theme) => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@@ -28,6 +29,7 @@ export function ConfigPanel({
|
|||||||
theme,
|
theme,
|
||||||
username,
|
username,
|
||||||
allScenarios,
|
allScenarios,
|
||||||
|
open,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -59,8 +61,14 @@ export function ConfigPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="drawer-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
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">
|
<div className="drawer__header">
|
||||||
<span className="drawer__title">{t("settings.title")}</span>
|
<span className="drawer__title">{t("settings.title")}</span>
|
||||||
<button className="drawer__close" onClick={onClose}>
|
<button className="drawer__close" onClick={onClose}>
|
||||||
@@ -197,7 +205,7 @@ export function ConfigPanel({
|
|||||||
onClick={() => onEditScenario(sc as unknown as UserScenario)}
|
onClick={() => onEditScenario(sc as unknown as UserScenario)}
|
||||||
title={t("common.edit")}
|
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>
|
||||||
<button
|
<button
|
||||||
className={`scenario-action-btn scenario-action-btn--delete ${
|
className={`scenario-action-btn scenario-action-btn--delete ${
|
||||||
@@ -206,7 +214,7 @@ export function ConfigPanel({
|
|||||||
onClick={() => handleDelete(sc.id)}
|
onClick={() => handleDelete(sc.id)}
|
||||||
title={deleteConfirm === sc.id ? t("common.confirmDelete") : t("common.delete")}
|
title={deleteConfirm === sc.id ? t("common.confirmDelete") : t("common.delete")}
|
||||||
>
|
>
|
||||||
{deleteConfirm === sc.id ? "✓" : "🗑"}
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,24 +6,17 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import type { CreateScenarioRequest } from "../../lib/api/scenarios";
|
import type { CreateScenarioRequest } from "../../lib/api/scenarios";
|
||||||
|
import { SCENARIO_ICON_SET, DEFAULT_SCENARIO_ICON_ID } from "../../lib/scenarioIcons";
|
||||||
|
|
||||||
interface CreateScenarioModalProps {
|
interface CreateScenarioModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: CreateScenarioRequest) => Promise<void>;
|
onSubmit: (data: CreateScenarioRequest) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预设常用图标
|
|
||||||
const PRESET_ICONS = [
|
|
||||||
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
|
||||||
"📖", "📚", "📝", "📋", "📌", "📍",
|
|
||||||
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
|
||||||
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalProps) {
|
export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [icon, setIcon] = useState("✨");
|
const [icon, setIcon] = useState(DEFAULT_SCENARIO_ICON_ID);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [greeting, setGreeting] = useState("");
|
const [greeting, setGreeting] = useState("");
|
||||||
@@ -134,14 +127,15 @@ export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalPr
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||||
<div className="icon-picker">
|
<div className="icon-picker">
|
||||||
{PRESET_ICONS.map((ic) => (
|
{SCENARIO_ICON_SET.map((ic) => (
|
||||||
<button
|
<button
|
||||||
key={ic}
|
key={ic.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
className={`icon-picker__item ${icon === ic.id ? "icon-picker__item--active" : ""}`}
|
||||||
onClick={() => setIcon(ic)}
|
onClick={() => setIcon(ic.id)}
|
||||||
|
title={ic.label}
|
||||||
>
|
>
|
||||||
{ic}
|
{ic.render(22)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
|
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
|
||||||
|
import { SCENARIO_ICON_SET } from "../../lib/scenarioIcons";
|
||||||
|
|
||||||
interface EditScenarioModalProps {
|
interface EditScenarioModalProps {
|
||||||
scenario: UserScenario;
|
scenario: UserScenario;
|
||||||
@@ -13,14 +14,6 @@ interface EditScenarioModalProps {
|
|||||||
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
|
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预设常用图标
|
|
||||||
const PRESET_ICONS = [
|
|
||||||
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
|
||||||
"📖", "📚", "📝", "📋", "📌", "📍",
|
|
||||||
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
|
||||||
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
|
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [name, setName] = useState(scenario.name);
|
const [name, setName] = useState(scenario.name);
|
||||||
@@ -135,14 +128,27 @@ export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioM
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||||
<div className="icon-picker">
|
<div className="icon-picker">
|
||||||
{PRESET_ICONS.map((ic) => (
|
{/* 如果当前图标是旧版 emoji(不在 SVG 图标集中),显示为可选项 */}
|
||||||
|
{!SCENARIO_ICON_SET.some((ic) => ic.id === icon) && (
|
||||||
<button
|
<button
|
||||||
key={ic}
|
key="__legacy"
|
||||||
type="button"
|
type="button"
|
||||||
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
className={`icon-picker__item icon-picker__item--active`}
|
||||||
onClick={() => setIcon(ic)}
|
onClick={() => {/* already selected, no-op */}}
|
||||||
|
title="当前图标(旧版)"
|
||||||
>
|
>
|
||||||
{ic}
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,13 +180,13 @@ export function LandingPage() {
|
|||||||
<div className="lp-flow-wrapper lp-fade-in">
|
<div className="lp-flow-wrapper lp-fade-in">
|
||||||
<div className="lp-flow-steps">
|
<div className="lp-flow-steps">
|
||||||
{[
|
{[
|
||||||
{ icon: "📷", label: "摄像头采集" },
|
{ 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: "🧠", 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: "🔌", label: "WebSocket" },
|
{ 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: "⚡", label: "Eino 编排" },
|
{ 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: "👁️", label: "视觉理解" },
|
{ 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: "💬", 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: "🔊", 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) => (
|
].map((step, i, arr) => (
|
||||||
<div key={i} style={{ display: "contents" }}>
|
<div key={i} style={{ display: "contents" }}>
|
||||||
<div className="lp-flow-step">
|
<div className="lp-flow-step">
|
||||||
@@ -212,27 +212,27 @@ export function LandingPage() {
|
|||||||
<div className="lp-features-grid">
|
<div className="lp-features-grid">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
num: "01", icon: "⚡",
|
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: "流式并行推送",
|
title: "流式并行推送",
|
||||||
desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。",
|
desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "02", icon: "🧠",
|
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 编排",
|
title: "声明式 AI 编排",
|
||||||
desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线(STT → History → ChatModel → Splitter → TTS),类型安全、可扩展、易测试。",
|
desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线(STT → History → ChatModel → Splitter → TTS),类型安全、可扩展、易测试。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "03", icon: "💰",
|
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: "端云协同降本",
|
title: "端云协同降本",
|
||||||
desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300,降幅 90%。",
|
desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300,降幅 90%。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "04", icon: "🎯",
|
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: "多场景智能模式",
|
title: "多场景智能模式",
|
||||||
desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。",
|
desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "05", icon: "🏗️",
|
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: "生产级工程架构",
|
title: "生产级工程架构",
|
||||||
desc: "三级存储自动降级(Memory → Redis → PostgreSQL)、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。",
|
desc: "三级存储自动降级(Memory → Redis → PostgreSQL)、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。",
|
||||||
},
|
},
|
||||||
@@ -259,11 +259,11 @@ export function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="lp-users-grid">
|
<div className="lp-users-grid">
|
||||||
{[
|
{[
|
||||||
{ avatar: "🧑🎓", 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="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: "💼", 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: "🌍", 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: "👁️", 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: "🔬", 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) => (
|
].map((u) => (
|
||||||
<div className="lp-user-card lp-fade-in" key={u.title}>
|
<div className="lp-user-card lp-fade-in" key={u.title}>
|
||||||
<div className="lp-user-card__avatar">{u.avatar}</div>
|
<div className="lp-user-card__avatar">{u.avatar}</div>
|
||||||
@@ -283,11 +283,11 @@ export function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="lp-scenes-list">
|
<div className="lp-scenes-list">
|
||||||
{[
|
{[
|
||||||
{ icon: "💬", 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"/></svg>, title: "自由对话", desc: "对着摄像头随意聊天,AI 实时理解画面并语音回答", tag: "通用" },
|
||||||
{ icon: "🗣️", 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: "🎤", 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: "⚔️", 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: "🌐", title: "同声翻译", desc: "实时识别画面中的外语文字并语音翻译,口语化输出", 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) => (
|
].map((s) => (
|
||||||
<div className="lp-scene-row lp-fade-in" key={s.title}>
|
<div className="lp-scene-row lp-fade-in" key={s.title}>
|
||||||
<div className="lp-scene-row__icon">{s.icon}</div>
|
<div className="lp-scene-row__icon">{s.icon}</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface SessionSidebarProps {
|
|||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onClose: () => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onSelectSession: (id: string) => void;
|
onSelectSession: (id: string) => void;
|
||||||
onDeleteSession: (id: string) => void;
|
onDeleteSession: (id: string) => void;
|
||||||
@@ -56,7 +56,7 @@ export function SessionSidebar({
|
|||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
open,
|
open,
|
||||||
onToggle,
|
onClose,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
@@ -88,7 +88,7 @@ export function SessionSidebar({
|
|||||||
const handleSelect = (id: string) => {
|
const handleSelect = (id: string) => {
|
||||||
onSelectSession(id);
|
onSelectSession(id);
|
||||||
// 选择后自动收起侧边栏
|
// 选择后自动收起侧边栏
|
||||||
onToggle();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 过滤 + 分组
|
// 过滤 + 分组
|
||||||
@@ -118,8 +118,6 @@ export function SessionSidebar({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [sessions, searchQuery]);
|
}, [sessions, searchQuery]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const groupLabels: Record<TimeGroup, string> = {
|
const groupLabels: Record<TimeGroup, string> = {
|
||||||
today: t("sidebar.today"),
|
today: t("sidebar.today"),
|
||||||
yesterday: t("sidebar.yesterday"),
|
yesterday: t("sidebar.yesterday"),
|
||||||
@@ -128,14 +126,17 @@ export function SessionSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-backdrop" onClick={onToggle} />
|
<div
|
||||||
<div className="sidebar">
|
className={`sidebar-backdrop ${open ? 'sidebar-backdrop--visible' : ''}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className={`sidebar ${open ? 'sidebar--open' : 'sidebar--collapsed'}`}>
|
||||||
{/* 头部:新建 + 收起 */}
|
{/* 头部:新建 + 收起 */}
|
||||||
<div className="sidebar__header">
|
<div className="sidebar__header">
|
||||||
<button className="sidebar__new-btn" onClick={onNewSession}>
|
<button className="sidebar__new-btn" onClick={onNewSession}>
|
||||||
✚ {t("sidebar.new")}
|
✚ {t("sidebar.new")}
|
||||||
</button>
|
</button>
|
||||||
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
|
<button className="sidebar__toggle" onClick={onClose} title={t("sidebar.collapse")}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +188,7 @@ export function SessionSidebar({
|
|||||||
<>
|
<>
|
||||||
{/* 视频标记图标 */}
|
{/* 视频标记图标 */}
|
||||||
{session.messageCount > 5 && (
|
{session.messageCount > 5 && (
|
||||||
<span className="sidebar__item-icon" title={t("sidebar.video")}>📹</span>
|
<span className="sidebar__item-icon" title={t("sidebar.video")}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar__item-content">
|
<div className="sidebar__item-content">
|
||||||
<div className="sidebar__item-title">{session.title}</div>
|
<div className="sidebar__item-title">{session.title}</div>
|
||||||
@@ -207,7 +208,7 @@ export function SessionSidebar({
|
|||||||
}}
|
}}
|
||||||
title={t("sidebar.rename")}
|
title={t("sidebar.rename")}
|
||||||
>
|
>
|
||||||
✏
|
<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>
|
||||||
<button
|
<button
|
||||||
className="sidebar__action-btn sidebar__action-btn--danger"
|
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||||
@@ -217,7 +218,7 @@ export function SessionSidebar({
|
|||||||
}}
|
}}
|
||||||
title={t("sidebar.delete")}
|
title={t("sidebar.delete")}
|
||||||
>
|
>
|
||||||
🗑
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function useSessionList(accessToken?: string | null) {
|
|||||||
try {
|
try {
|
||||||
const res = await listConversations(accessToken);
|
const res = await listConversations(accessToken);
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const list = res.data.conversations.map(toSessionSummary);
|
const list = (res.data.conversations || []).map(toSessionSummary);
|
||||||
setSessions(list);
|
setSessions(list);
|
||||||
// 恢复上次选中的会话(如果仍然存在)
|
// 恢复上次选中的会话(如果仍然存在)
|
||||||
const lastId = localStorage.getItem(LAST_ACTIVE_KEY);
|
const lastId = localStorage.getItem(LAST_ACTIVE_KEY);
|
||||||
@@ -193,7 +193,7 @@ export function useSessionList(accessToken?: string | null) {
|
|||||||
try {
|
try {
|
||||||
const res = await getConversationMessages(accessToken, sessionId);
|
const res = await getConversationMessages(accessToken, sessionId);
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
return res.data.messages.map(toChatMessage);
|
return (res.data.messages || []).map(toChatMessage);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[SessionList] 加载消息失败:", err);
|
console.error("[SessionList] 加载消息失败:", err);
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ export const enUS: TranslationMap = {
|
|||||||
"auth.login": "Sign In",
|
"auth.login": "Sign In",
|
||||||
"auth.register": "Sign Up",
|
"auth.register": "Sign Up",
|
||||||
"auth.username": "Username",
|
"auth.username": "Username",
|
||||||
"auth.username.placeholder": "3-64 characters",
|
"auth.username.placeholder": "Enter username",
|
||||||
"auth.password": "Password",
|
"auth.password": "Password",
|
||||||
"auth.password.placeholder": "8-72 characters",
|
"auth.password.placeholder": "Enter password",
|
||||||
"auth.submitting": "Please wait...",
|
"auth.submitting": "Please wait...",
|
||||||
"auth.noAccount": "Don't have an account?",
|
"auth.noAccount": "Don't have an account?",
|
||||||
"auth.hasAccount": "Already have an account?",
|
"auth.hasAccount": "Already have an account?",
|
||||||
@@ -174,6 +174,7 @@ export const enUS: TranslationMap = {
|
|||||||
"settings.scenario.custom": "My Scenarios",
|
"settings.scenario.custom": "My Scenarios",
|
||||||
"settings.scenario.empty": "No custom scenarios yet. Click the button above to create one.",
|
"settings.scenario.empty": "No custom scenarios yet. Click the button above to create one.",
|
||||||
"scenario.create.button": "New Scenario",
|
"scenario.create.button": "New Scenario",
|
||||||
|
"scenario.createChip": "New",
|
||||||
"scenario.create.title": "Create Custom Scenario",
|
"scenario.create.title": "Create Custom Scenario",
|
||||||
"scenario.edit.title": "Edit Scenario",
|
"scenario.edit.title": "Edit Scenario",
|
||||||
"scenario.create.name": "Scenario Name",
|
"scenario.create.name": "Scenario Name",
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ export const jaJP: TranslationMap = {
|
|||||||
"auth.login": "ログイン",
|
"auth.login": "ログイン",
|
||||||
"auth.register": "新規登録",
|
"auth.register": "新規登録",
|
||||||
"auth.username": "ユーザー名",
|
"auth.username": "ユーザー名",
|
||||||
"auth.username.placeholder": "3〜64文字",
|
"auth.username.placeholder": "ユーザー名を入力",
|
||||||
"auth.password": "パスワード",
|
"auth.password": "パスワード",
|
||||||
"auth.password.placeholder": "8〜72文字",
|
"auth.password.placeholder": "パスワードを入力",
|
||||||
"auth.submitting": "お待ちください...",
|
"auth.submitting": "お待ちください...",
|
||||||
"auth.noAccount": "アカウントをお持ちでないですか?",
|
"auth.noAccount": "アカウントをお持ちでないですか?",
|
||||||
"auth.hasAccount": "すでにアカウントをお持ちですか?",
|
"auth.hasAccount": "すでにアカウントをお持ちですか?",
|
||||||
@@ -174,6 +174,7 @@ export const jaJP: TranslationMap = {
|
|||||||
"settings.scenario.custom": "マイシナリオ",
|
"settings.scenario.custom": "マイシナリオ",
|
||||||
"settings.scenario.empty": "カスタムシナリオはまだありません。上のボタンをクリックして作成してください。",
|
"settings.scenario.empty": "カスタムシナリオはまだありません。上のボタンをクリックして作成してください。",
|
||||||
"scenario.create.button": "新しいシナリオ",
|
"scenario.create.button": "新しいシナリオ",
|
||||||
|
"scenario.createChip": "新規",
|
||||||
"scenario.create.title": "カスタムシナリオを作成",
|
"scenario.create.title": "カスタムシナリオを作成",
|
||||||
"scenario.edit.title": "シナリオを編集",
|
"scenario.edit.title": "シナリオを編集",
|
||||||
"scenario.create.name": "シナリオ名",
|
"scenario.create.name": "シナリオ名",
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export const zhCN: TranslationMap = {
|
|||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
"controls.connecting": "连接中...",
|
"controls.connecting": "连接中...",
|
||||||
"controls.start": "🎙️ 开始对话",
|
"controls.start": "开始对话",
|
||||||
"controls.startVideo": "🎙️ 开始视频通话",
|
"controls.startVideo": "开始视频通话",
|
||||||
"controls.cameraOff": "关闭摄像头",
|
"controls.cameraOff": "关闭摄像头",
|
||||||
"controls.cameraOn": "开启摄像头",
|
"controls.cameraOn": "开启摄像头",
|
||||||
"controls.micOff": "关闭麦克风",
|
"controls.micOff": "关闭麦克风",
|
||||||
@@ -70,7 +70,7 @@ export const zhCN: TranslationMap = {
|
|||||||
"controls.stop": "结束对话",
|
"controls.stop": "结束对话",
|
||||||
"controls.stopVideo": "结束视频",
|
"controls.stopVideo": "结束视频",
|
||||||
"controls.endSession": "结束会话",
|
"controls.endSession": "结束会话",
|
||||||
"controls.resumeVideo": "📹 重新开始视频",
|
"controls.resumeVideo": "重新开始视频",
|
||||||
|
|
||||||
// Chat panel
|
// Chat panel
|
||||||
"chat.title": "对话",
|
"chat.title": "对话",
|
||||||
@@ -155,9 +155,9 @@ export const zhCN: TranslationMap = {
|
|||||||
"auth.login": "登录",
|
"auth.login": "登录",
|
||||||
"auth.register": "注册",
|
"auth.register": "注册",
|
||||||
"auth.username": "用户名",
|
"auth.username": "用户名",
|
||||||
"auth.username.placeholder": "3-64 个字符",
|
"auth.username.placeholder": "请输入用户名",
|
||||||
"auth.password": "密码",
|
"auth.password": "密码",
|
||||||
"auth.password.placeholder": "8-72 个字符",
|
"auth.password.placeholder": "请输入密码",
|
||||||
"auth.submitting": "请稍候...",
|
"auth.submitting": "请稍候...",
|
||||||
"auth.noAccount": "还没有账号?",
|
"auth.noAccount": "还没有账号?",
|
||||||
"auth.hasAccount": "已有账号?",
|
"auth.hasAccount": "已有账号?",
|
||||||
@@ -174,6 +174,7 @@ export const zhCN: TranslationMap = {
|
|||||||
"settings.scenario.custom": "我的情景",
|
"settings.scenario.custom": "我的情景",
|
||||||
"settings.scenario.empty": "还没有自建情景,点击上方按钮创建",
|
"settings.scenario.empty": "还没有自建情景,点击上方按钮创建",
|
||||||
"scenario.create.button": "创建新情景",
|
"scenario.create.button": "创建新情景",
|
||||||
|
"scenario.createChip": "新建",
|
||||||
"scenario.create.title": "创建自建情景",
|
"scenario.create.title": "创建自建情景",
|
||||||
"scenario.edit.title": "编辑情景",
|
"scenario.edit.title": "编辑情景",
|
||||||
"scenario.create.name": "情景名称",
|
"scenario.create.name": "情景名称",
|
||||||
|
|||||||
124
frontend/src/lib/scenarioIcons.tsx
Normal file
124
frontend/src/lib/scenarioIcons.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// ============================================================
|
||||||
|
// scenarioIcons — 自建情景 SVG 图标集(Feather-style)
|
||||||
|
// 职责:为 CreateScenarioModal / EditScenarioModal 提供图标选项
|
||||||
|
// 为情景芯片条渲染自建情景的 SVG 图标
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const P = { viewBox: "0 0 24 24", fill: "none" as const, stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||||||
|
|
||||||
|
export interface ScenarioIconDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
render: (size?: number) => React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自建情景可选的 Feather SVG 图标集 */
|
||||||
|
export const SCENARIO_ICON_SET: ScenarioIconDef[] = [
|
||||||
|
{
|
||||||
|
id: "sparkles", label: "通用助手",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"/><path d="M18 14l.75 2.25L21 17l-2.25.75L18 20l-.75-2.25L15 17l2.25-.75L18 14z"/><path d="M5 17l.5 1.5L7 19l-1.5.5L5 21l-.5-1.5L3 19l1.5-.5L5 17z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "theater", label: "角色扮演",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "palette", label: "创意设计",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.04 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.5-4.5-9.92-10-9.92z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target", label: "目标导向",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "film", label: "影视娱乐",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "book-open", label: "阅读学习",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "book", label: "教程指南",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit", label: "写作编辑",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clipboard", label: "任务管理",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map-pin", label: "导航定位",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search", label: "搜索探索",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lightbulb", label: "灵感启发",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "briefcase", label: "商务办公",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "code", label: "编程开发",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "graduation-cap", label: "教育培训",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mic", label: "语音对话",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music", label: "音乐艺术",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "globe", label: "翻译跨语言",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><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>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heart", label: "健康关怀",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shield", label: "安全防护",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zap", label: "快速高效",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "coffee", label: "休闲聊天",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tool", label: "工具助手",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "message-circle", label: "对话问答",
|
||||||
|
render: (s = 16) => (<svg width={s} height={s} {...P}><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 根据图标 ID 渲染 SVG,找不到返回 null */
|
||||||
|
export function renderScenarioIconById(iconId: string, size = 14): React.ReactElement | null {
|
||||||
|
const def = SCENARIO_ICON_SET.find((d) => d.id === iconId);
|
||||||
|
return def ? def.render(size) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认图标 ID */
|
||||||
|
export const DEFAULT_SCENARIO_ICON_ID = "sparkles";
|
||||||
Reference in New Issue
Block a user