Compare commits

...

42 Commits

Author SHA1 Message Date
f9a01269d2 Merge pull request 'refactor: 优化数据库迁移文件约束和注释' (#211) from refatcor/table-structure into main
All checks were successful
Deploy / deploy (push) Successful in 39s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/211
2026-06-23 23:42:40 +08:00
hhs
74f1a8eb34 refactor: 优化数据库迁移文件约束和注释
**迁移文件优化**:
- 001_users.up.sql: 添加完整表和列注释,说明密码哈希算法 (bcrypt) 和 token 哈希算法 (SHA-256)
- 002_messages.up.sql: 收紧 role 字段至 VARCHAR(10),添加 tokens_used 非负约束,补充完整注释
- 003_sessions.up.sql: 收紧 title 字段至 VARCHAR(100) 并添加长度约束 (1-100),详细说明 config JSONB 结构
- 004_user_scenarios.up.sql: 修复 greeting 字段冲突 (VARCHAR(500)),扩大 icon 至 VARCHAR(20) 支持复合 Emoji,prompt 改为 TEXT 无上限,优化约束逻辑

**后端代码修改**:
- auth.go: 移除用户名最小长度限制 (3 字符),仅保留最大长度 64

**前端国际化修改**:
- 更新三种语言的登录/注册表单 placeholder 文本,移除字符长度要求提示
  - zh-CN: "请输入用户名" / "请输入密码"
  - en-US: "Enter username" / "Enter password"
  - ja-JP: "ユーザー名を入力" / "パスワードを入力"

**删除文件**:
- 移除临时修复迁移文件 (005_fix_user_scenarios_description.*)
- 删除临时诊断脚本 (fix_user_scenarios.sql, check_constraints.sql)

所有约束修改都是放宽限制,不影响现有数据。
2026-06-23 23:41:49 +08:00
4dd4138197 Merge pull request 'fix: 减少自定义情景中数据库表对 prompt 的约束' (#210) from fix/database into main
All checks were successful
Deploy / deploy (push) Successful in 33s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/210
2026-06-23 22:58:56 +08:00
hhs
5ef059eb40 fix: 减少自定义情景中数据库表对 prompt 的约束 2026-06-23 22:58:02 +08:00
9523fe6650 Merge pull request 'fix: 修复无法正常自定义情景的问题' (#209) from fix/frontend into main
All checks were successful
Deploy / deploy (push) Successful in 39s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/209
2026-06-23 11:11:08 +08:00
hhs
a219934f4b fix: 修复无法正常自定义情景的问题 2026-06-23 11:10:37 +08:00
72a8d4803e Merge pull request 'fix: 修复前端语法问题' (#208) from fix/frontend into main
All checks were successful
Deploy / deploy (push) Successful in 28s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/208
2026-06-22 21:59:14 +08:00
hhs
dcf53a3783 fix: 修复前端语法问题 2026-06-22 21:58:49 +08:00
b5ec6551bf Merge pull request 'styles:美化样式' (#207) from feat/styles into main
Some checks failed
Deploy / deploy (push) Failing after 17s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/207
2026-06-22 19:32:47 +08:00
79d321c78a fix: 自建情景在聊天面板与系统消息中正确显示图标和名称 2026-06-22 16:02:02 +08:00
57bd8c72b8 Merge pull request 'feat: v2 版本' (#206) from v2 into main
All checks were successful
Deploy / deploy (push) Successful in 18s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/206
2026-06-22 16:01:27 +08:00
d640b5b41b Merge pull request 'docs: 优化首页 README 文档' (#205) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 18s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/205
2026-06-22 15:47:39 +08:00
f1b9e4fcc1 fix: 系统消息最大宽度限制与居中对齐 2026-06-22 15:08:26 +08:00
18aa5f1949 refactor: 场景配置与弹窗样式改用 CSS 变量,适配白色主题 2026-06-22 15:03:58 +08:00
a16628119a Merge pull request 'docs: 优化首页部分内容重复' (#204) from docs/readme into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/204
2026-06-22 14:50:11 +08:00
hhs
3888941f85 docs: 优化首页部分内容重复 2026-06-22 14:49:52 +08:00
0a816049fa Merge pull request 'docs: 优化首页 README 项目部署指南' (#203) from docs/readme into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/203
2026-06-22 14:43:34 +08:00
140a178993 fix: 侧栏收起时使用 visibility 隐藏避免闪烁 2026-06-22 14:43:04 +08:00
hhs
31c1720c00 docs: 优化首页 README 项目部署指南 2026-06-22 14:42:56 +08:00
c6093c01c1 Merge pull request 'docs: 优化首页 README' (#202) from docs/readme into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/202
2026-06-22 14:37:50 +08:00
hhs
3267f35edb docs: 优化首页 README 2026-06-22 14:37:32 +08:00
a309c269e0 Merge pull request 'docs: 完善首页 README 文件' (#201) from docs/readme into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/201
2026-06-22 14:36:20 +08:00
hhs
22930f0080 docs: 完善首页 README 文件 2026-06-22 14:35:53 +08:00
c00b8a83d9 Merge pull request 'fix: 优化侧栏弹出延迟问题' (#200) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 28s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/200
2026-06-22 13:17:18 +08:00
36007babcb Merge pull request 'fix: 优化侧栏弹出延迟问题' (#199) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/199
2026-06-22 13:16:59 +08:00
hhs
f01c84d89e fix: 优化侧栏弹出延迟问题 2026-06-22 13:16:31 +08:00
c3808ff38c Merge pull request 'fix: 修复“设置”侧栏双重展开的问题' (#198) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 29s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/198
2026-06-22 13:03:04 +08:00
2740bea755 Merge pull request 'fix: 修复“设置”侧栏双重展开的问题' (#197) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/197
2026-06-22 13:02:43 +08:00
hhs
69b9694cfe fix: 修复“设置”侧栏双重展开的问题 2026-06-22 13:02:05 +08:00
70ee212ea2 Merge pull request 'fix: 修复前端侧边栏无法平滑弹出的问题' (#196) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 28s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/196
2026-06-22 12:55:43 +08:00
f08f547e76 Merge pull request 'fix: 修复前端侧边栏无法平滑弹出的问题' (#195) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/195
2026-06-22 12:55:24 +08:00
hhs
03a19befe1 fix: 修复前端侧边栏无法平滑弹出的问题 2026-06-22 12:53:09 +08:00
c4b68b77d1 Merge pull request 'fix: 修复空会话返回 null 导致前端崩溃的问题' (#194) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 43s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/194
2026-06-22 12:40:37 +08:00
f2a883bda8 Merge pull request 'fix: 修复空会话返回 null 导致前端崩溃的问题' (#193) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/193
2026-06-22 12:40:18 +08:00
hhs
4cc0713459 fix: 修复空会话返回 null 导致前端崩溃的问题
## 问题描述
当用户创建新会话但未发送任何消息就切换到其他会话时,前端控制台报错:
"TypeError: Cannot read properties of null (reading 'map')"

根本原因:Go 后端未初始化的切片序列化为 JSON 时会变成 `null` 而非 `[]`,
前端尝试对 `null` 调用 `.map()` 导致崩溃。

## 修复方案
采用多层防御策略,同时修复后端和前端:

### 后端修复(确保 API 契约正确)
1. message_pg.go:93 - 将 `var messages []StoredMessage` 改为
   `messages := make([]StoredMessage, 0)`,确保空结果序列化为 `[]`
2. conversation.go - 在两个响应路径(PG 查询 + 内存回退)添加防御性 nil 检查

### 前端防御(多层保护)
1. useSessionList.ts - 在 loadMessages 和 loadSessions 中添加 null 合并操作
   `(res.data.messages || [])` 确保即使后端退化也不会崩溃

## 影响范围
- 所有空会话(新建后未发送消息的对话)现在可以正常切换
- API 响应符合 JSON 最佳实践(数组字段永远是 `[]` 而非 `null`)
2026-06-22 12:39:37 +08:00
hhs
2180751a9b fix: 修复前端侧边栏双重展开动画问题
移除条件渲染和 CSS animation 的冲突,改用纯 CSS transition 控制显示/隐藏。

**问题根源:**
- 组件使用 `if (!open) return null;` 条件渲染,导致挂载时立即出现
- CSS 同时使用 `animation: slideInLeft`,触发从 -100% 的二次滑入
- 两个独立显示机制叠加,造成双重动画闪烁

**修复方案:**
- SessionSidebar: 移除条件渲染,始终保持 DOM 存在,通过动态 className 控制状态
- App.css: 移除 `animation` 和 `@keyframes`,改用 `transition: transform`
- 新增 `.sidebar--collapsed` / `.sidebar--open` 类控制 `translateX`
- 新增 `.sidebar-backdrop--visible` 类控制背景遮罩淡入淡出
- 添加 `pointer-events: none` 确保隐藏状态不响应交互

影响范围:
- frontend/src/components/SessionSidebar/index.tsx
- frontend/src/App.css
2026-06-22 12:05:54 +08:00
964c5c967e Merge pull request 'fix: 修复前端侧边栏弹出异常问题' (#192) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 47s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/192
2026-06-22 11:20:01 +08:00
dcd08d031c Merge pull request 'fix: 修复前端侧边栏弹出异常问题' (#191) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/191
2026-06-22 11:19:34 +08:00
hhs
6bca4e0d47 fix: 修复前端侧边栏弹出异常问题 2026-06-22 11:15:41 +08:00
881c3f9853 Merge pull request 'fix: 修复前端 TypeScript 编译错误' (#190) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 23s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/190
2026-06-22 00:30:00 +08:00
2881941be6 Merge pull request 'fix: 修复前端 TypeScript 编译错误' (#189) from fix/frontend into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/189
2026-06-22 00:29:42 +08:00
hhs
1b99d24fc1 fix: 修复前端 TypeScript 编译错误
- 移除 App.tsx 中未使用的 activeScenario 变量
- 修复 ChatPanel 中 ReactNode 的 type-only import
- 移除 ChatPanel 中未使用的 onSelectScenario 参数

修复 CI 构建失败问题
2026-06-22 00:28:59 +08:00
19 changed files with 789 additions and 302 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ Thumbs.db
.claudian/
修改过程笔记/
学习复盘/
docs/follow-up/

584
README.md
View File

@@ -1,181 +1,561 @@
# CamTalk
多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
<div align="center">
- **路演视频**[哔哩哔哩弹幕网——七牛云第四批议题1](https://www.bilibili.com/video/BV1dDJK6cE5S/)
- **线上体验**http://8.161.227.145:9000
**多模态实时 AI 视觉对话助手**
> ⚠️ **注意**:由于线上地址使用 HTTP 协议,浏览器默认禁止在非 HTTPS 环境下调用摄像头和麦克风。需要按以下步骤配置 Chrome 浏览器:
用户通过摄像头和麦克风与 AI 交互AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go)](https://go.dev/)
[![React](https://img.shields.io/badge/React-18-61DAFB?logo=react)](https://react.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript)](https://www.typescriptlang.org/)
[路演视频](https://www.bilibili.com/video/BV1dDJK6cE5S/) • [在线体验](http://8.161.227.145:9000) • [文档](docs/README.md)
</div>
---
> ⚠️ **在线体验提示**:由于演示环境使用 HTTP 协议,需配置 Chrome 允许非 HTTPS 下访问摄像头/麦克风:
>
> 1. 在浏览器地址栏中输入 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`,回车
> 2. 将 **Insecure origins treated as secure** 选项设置为 **Enabled**(已启用)
> 3. 在输入框中输入 `http://8.161.227.145:9000` 地址
> 4. 点击右下角弹出的 **Relaunch** 按钮,自动重启浏览器
>
> 重启后即可在该 HTTP 地址下正常调用摄像头和麦克风。
> 1. 访问 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
> 2. 启用该选项,并在输入框填入 `http://8.161.227.145:9000`
> 3. 点击 **Relaunch** 重启浏览器
![](docs/pictures/1.png)
![CamTalk 界面截图](docs/pictures/1.png)
## 架构
## ✨ 核心特性
三层系统,前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用:
- 🎥 **多模态理解**:摄像头视觉 + 麦克风语音双输入AI 理解完整场景
- 🗣️ **自然对话**:基于 VAD 的端到端语音交互,低延迟流式响应
- 🚀 **实时推送**LLM 文本流 + TTS 音频流并行推送,感知延迟 < 0.5 秒
- 🎭 **情景模式**:自由对话、面试官、英语老师等多场景支持
- 💾 **对话历史**:自动保存会话,支持搜索、重命名、删除、时间分组
- 🔐 **安全认证**JWT 双 token 轮转 + Refresh Token Rotation 防重放
- 📊 **三级存储**Memory → Redis → PostgreSQL 自动降级,保障可靠性
- 🌐 **国际化**:支持中文、英文、日文界面
## 🏗️ 系统架构
CamTalk 采用**三层架构**:前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用
```mermaid
graph TB
subgraph client[浏览器客户端]
A1[媒体采集]
A2[VAD 语音检测]
A3[关键帧检测]
A4[UI 渲染]
subgraph Browser["🌐 浏览器客户端"]
UI["React UI 渲染"]
VAD["VAD 语音检测"]
Media["媒体采集"]
end
subgraph gateway[Go 网关 :8080]
B1[WebSocket Handler]
B2[Session Manager]
B3[AI Orchestrator]
B4[REST API]
B5[Auth 模块]
B6[Store 层]
subgraph Gateway["⚙️ Go 网关 (Eino Graph)"]
WS["WebSocket Handler"]
Auth["JWT 认证"]
Session["会话管理 (三级存储)"]
Orch["AI 编排器 (7节点DAG)"]
end
subgraph cloud[云端 AI 服务]
C1[STT 语音识别]
C2[LLM 多模态推理]
C3[TTS 语音合成]
subgraph AI["☁️ 云端 AI 服务"]
STT["STT (MiMo/Deepgram)"]
LLM["LLM (qwen3-vl-plus)"]
TTS["TTS (MiMo/OpenAI)"]
end
client <-->|WebSocket| gateway
gateway <-->|HTTP| cloud
Browser <-->|"WebSocket<br/>(JWT + query/config)"| Gateway
Orch --> STT
Orch --> LLM
Orch --> TTS
```
**关键模式**LLM 文本流和 TTS 音频流并行推送,用户先看到文字、紧接着听到语音,感知延迟 < 0.5 秒。
### AI 编排流水线Eino Graph
## 技术栈
基于 [CloudWeGo Eino](https://github.com/cloudwego/eino) 框架的声明式 7 节点 DAG
| 层级 | 技术 |
|------|------|
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
| STT | MiMo ASR默认 / Deepgram |
| LLM | DashScope qwen3-vl-plus默认通过 eino-ext OpenAI ChatModel 接入) |
| TTS | MiMo TTS默认 / OpenAI TTS |
```
START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END
```
## 项目结构
**核心优势**
- **流式处理**ChatModel 逐 token 推送Callback AOP 机制实时转发客户端
- **句子级 TTS**Splitter 实时切分句子TTS 逐句并行合成,无需等待完整回复
- **类型安全**Go 泛型 + 编译期检查Graph 拓扑错误在编译时发现
## 🛠️ 技术栈
<table>
<tr>
<td><b>层级</b></td>
<td><b>技术选型</b></td>
<td><b>说明</b></td>
</tr>
<tr>
<td><b>前端</b></td>
<td>React 18 + TypeScript + Vite</td>
<td>组件化开发,类型安全,快速热更新</td>
</tr>
<tr>
<td><b>VAD</b></td>
<td>@ricky0123/vad-web (ONNX Runtime)</td>
<td>浏览器端语音活动检测,零延迟</td>
</tr>
<tr>
<td><b>后端</b></td>
<td>Go 1.25+ + Gin + gorilla/websocket</td>
<td>高并发 goroutine长连接管理</td>
</tr>
<tr>
<td><b>AI 编排</b></td>
<td>CloudWeGo Eino Graph</td>
<td>声明式 DAGStream 模式Callback AOP</td>
</tr>
<tr>
<td><b>STT</b></td>
<td>MiMo ASR默认/ Deepgram</td>
<td>实时语音识别,多语言支持</td>
</tr>
<tr>
<td><b>LLM</b></td>
<td>DashScope qwen3-vl-plus</td>
<td>多模态推理(通过 eino-ext OpenAI 接入)</td>
</tr>
<tr>
<td><b>TTS</b></td>
<td>MiMo TTS默认/ OpenAI TTS</td>
<td>自然语音合成</td>
</tr>
<tr>
<td><b>存储</b></td>
<td>PostgreSQL 15 + Redis 7</td>
<td>三级存储架构Memory → Redis → PG</td>
</tr>
<tr>
<td><b>认证</b></td>
<td>JWT (HS256) + bcrypt</td>
<td>双 token 轮转 + Refresh Token Rotation</td>
</tr>
<tr>
<td><b>配置</b></td>
<td>Viper + godotenv</td>
<td>YAML + .env + 环境变量覆盖</td>
</tr>
<tr>
<td><b>日志</b></td>
<td>Zap</td>
<td>高性能结构化日志 + Trace ID 追踪</td>
</tr>
</table>
## 📁 项目结构
```
CamTalk/
├── frontend/ # 浏览器客户端
├── frontend/ # 🌐 浏览器客户端
│ └── src/
│ ├── components/ # UI 组件
│ │ ├── LandingPage/ # 登录着陆页 + LoginModal
│ │ ├── AuthPage/ # 登录/注册表单
│ │ ├── CameraManager/ # 摄像头流采集
│ │ ├── MicManager/ # 麦克风音频采集
│ │ ├── EdgeProcessor/ # VAD + 关键帧检测
│ │ ├── WebSocketManager/ # WS 连接管理
│ │ ├── ChatPanel/ # 消息展示
│ │ ├── VideoPreview/ # 摄像头画面预览
│ │ ├── MicManager/ # 麦克风音频采集 + VAD
│ │ ├── WebSocketManager/ # WS 连接生命周期
│ │ ├── ChatPanel/ # 消息展示 + 流式回复
│ │ ├── SessionSidebar/ # 对话历史侧边栏
│ │ ── ConfigPanel/ # 配置面板
│ │ └── Toast/ # 通知提示
│ │ ── ConfigPanel/ # 配置面板(主题/TTS/语言/场景)
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useVisionSession.ts # 核心会话 Hook
│ │ ├── useVisionSession.ts # 核心会话 Hook (~500 行)
│ │ ├── useSessionList.ts # 对话列表管理
│ │ └── useObservationMode.ts # 观察模式
│ ├── lib/ # 工具库
│ │ ├── websocket.ts # WebSocket 连接管理
│ │ ├── api.ts # REST API 客户端
│ │ ├── auth.tsx # 认证上下文JWT 管理
│ │ ├── audio.ts # 音频编码
│ │ ── ttsPlayer.ts # TTS 播放器
│ │ ├── i18n/ # 国际化zh-CN/en-US/ja-JP
│ │ └── sampling.ts # 采样策略
│ │ ├── websocket.ts # WebSocket 单例(心跳/重连/订阅)
│ │ ├── api.ts # REST 客户端401拦截+刷新)
│ │ ├── auth.tsx # AuthProviderJWT 自动刷新
│ │ ├── ttsPlayer.ts # TTS 流式播放队列
│ │ ── i18n/ # 国际化zh-CN/en-US/ja-JP
│ └── types/ # TypeScript 类型定义
├── backend/ # Go 网关
│ ├── cmd/server/ # 入口
├── backend/ # ⚙️ Go 网关
│ ├── cmd/server/ # 服务入口main.go
│ └── internal/
│ ├── eino/ # 🔥 Eino Graph 编排层7节点DAG
│ │ ├── graph.go # Graph 构建与编译
│ │ ├── adapter.go # EinoOrchestrator 适配器
│ │ ├── callback.go # LLM token 推送回调
│ │ ├── state.go # 跨节点状态管理
│ │ └── nodes_*.go # STT/History/Splitter/TTS/Done 节点
│ ├── session/ # 会话管理TieredManager 三级存储)
│ ├── store/ # 持久化层Repository 接口 + PG/内存实现)
│ │ ├── user_pg.go # PostgreSQL 实现
│ │ └── cached_user.go # Redis 缓存装饰器
│ ├── auth/ # 认证JWT/bcrypt/中间件)
│ ├── ai/ # AI 服务抽象层
│ │ ├── llm/ # LLM 提示词与场景
│ │ ├── stt/ # STT 服务MiMo/Deepgram
│ │ └── tts/ # TTS 服务MiMo/OpenAI
│ ├── eino/ # Eino Graph 编排层7 节点 DAG
│ ├── orchestrator/ # Orchestrator 接口
│ ├── session/ # 会话管理三级存储Memory/Redis/PG
│ ├── store/ # 持久化层Repository 接口 + PG/内存实现)
│ ├── auth/ # 认证JWT、bcrypt、中间件
│ ├── ws/ # WebSocket Handler
│ ├── api/ # REST APIAuth/Conversation
│ ├── config/ # 配置管理
── models/ # 数据模型
│ ├── errors/ # 错误码
│ └── logger/ # 日志
├── migrations/ # 数据库迁移(嵌入式 SQL
├── docs/ # 设计文档
└── CLAUDE.md # Claude Code 指引
│ ├── config/ # 配置管理Viper
── logger/ # 日志Zap + Trace ID
├── migrations/ # 📊 数据库迁移(嵌入式 SQL
├── docs/ # 📚 设计文档
│ ├── 01-架构设计.md
│ ├── 02-接口文档.md
│ ├── 08-Eino框架与编排设计.md
│ ├── 10-鉴权体系.md
│ └── 13-日志追踪.md
├── deploy.sh # 🐳 部署脚本Docker Compose
├── docker-compose.yml # 容器编排配置
└── CLAUDE.md # 🤖 Claude Code 开发指引
```
## 快速开始
## 🚀 快速开始
### 前置条件
- Node.js >= 18
- Go >= 1.25
- **Node.js** >= 18
- **Go** >= 1.25
- **PostgreSQL** >= 15可选 Docker
- **Redis** >= 7可选用于缓存加速
### 前端
### 本地开发
#### 1. 克隆项目
```bash
cd frontend
npm install
npm run dev # Vite 开发服务器 http://localhost:5173
git clone https://github.com/yourusername/CamTalk.git
cd CamTalk
```
### 后端
#### 2. 配置环境变量
```bash
# 复制环境变量模板
cp backend/.env.example backend/.env
# 编辑 .env 文件,填入以下必需配置:
# - CAMTALK_AUTH_JWT_SECRET使用 openssl rand -hex 32 生成)
# - CAMTALK_STORAGE_DSNPostgreSQL 连接字符串)
# - CAMTALK_AI_LLM_API_KEYDashScope API Key
# - CAMTALK_AI_STT_API_KEYMiMo/Deepgram API Key
# - CAMTALK_AI_TTS_API_KEYMiMo/OpenAI API Key
```
#### 3. 启动后端
```bash
cd backend
# 安装依赖
go mod download
go run ./cmd/server # 启动网关 :8080
```
### 配置
# 运行数据库迁移(自动创建表)
go run ./cmd/server migrate
后端配置文件位于 `backend/config/config.yaml`,支持环境变量覆盖(前缀 `CAMTALK_`)。
```bash
# 最小启动(需要至少一个 AI 服务的 API Key
cd backend
CAMTALK_AI_LLM_API_KEY=sk-xxx \
CAMTALK_AI_STT_API_KEY=xxx \
# 启动服务(监听 :8080
go run ./cmd/server
```
配置优先级:环境变量 > `config.{env}.yaml` > `config.yaml` > `.env`
#### 4. 启动前端
## WebSocket 协议
```bash
cd frontend
连接地址:`ws://localhost:8080/ws`
# 安装依赖
npm install
所有消息为 JSON 文本帧,统一信封格式 `{type, request_id?, timestamp?}`
# 启动开发服务器http://localhost:5173
npm run dev
```
**客户端 → 服务端**`query``config``interrupt``ping`
**服务端 → 客户端**`connected``stt_result``llm_chunk``llm_done``tts_audio``error``pong`
#### 5. 访问应用
完整协议见 [docs/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 逐 tokenCallback |
| `llm_done` | LLM 推理完成 | Done 节点执行 |
| `tts_audio` | TTS 音频片段 | TTS 节点逐句合成 |
| `error` | 错误通知 | 任意节点失败 |
| `pong` | 心跳响应 | 响应 `ping` |
**心跳机制**
- 客户端每 30 秒发送 `ping`
- 服务端 60 秒无消息自动断连
- 断连后自动重连(指数退避 1s → 30s
完整协议定义见 [docs/02-接口文档.md](docs/02-接口文档.md)
## 🔐 认证体系
CamTalk 采用 **JWT 双 token 轮转 + Refresh Token Rotation** 安全机制:
### 双 Token 设计
| Token | 有效期 | 存储位置 | 用途 |
|-------|-------|---------|------|
| `access_token` | 120 分钟 | 前端内存(推荐)/ localStorage | 访问受保护资源 |
| `refresh_token` | 7 天 | httpOnly Cookie推荐/ localStorage | 刷新 access_token |
### Refresh Token Rotation
每次刷新 token 时:
1. 验证 `refresh_token` 签名和有效期
2. 查询数据库中的 SHA256 哈希
3. **如果哈希不存在** → 检测到 token 复用 → **吊销该用户所有 token**
4. 删除旧 refresh_token生成新 token pair
5. 返回新 access_token + refresh_token
**防重放攻击**:旧 refresh_token 立即失效,复用时触发全局吊销,强制所有设备重新登录。
### REST API 端点
- `POST /api/auth/register` — 用户注册
- `POST /api/auth/login` — 用户登录
- `POST /api/auth/refresh` — 刷新 token
- `POST /api/auth/logout` — 登出(需认证)
- `GET /api/conversations` — 获取对话列表(需认证)
- `POST /api/conversations` — 创建对话(需认证)
- `GET /api/health` — 健康检查
详细设计见 [docs/10-鉴权体系.md](docs/10-鉴权体系.md)
## 💾 三级存储架构
**TieredManager** 实现会话状态的三级存储,平衡性能与可靠性:
```
┌─────────────┐
│ L1 Memory │ ← 微秒级读写,进程内缓存
├─────────────┤
│ L2 Redis │ ← 毫秒级访问,跨实例共享
├─────────────┤
│ L3 PostgreSQL│ ← 持久化存储,数据可靠性
└─────────────┘
```
**特性**
-**自动降级**Redis 故障时自动切换到 Memory + PostgreSQL 模式
-**灵活配置**支持单级Memory、双级Memory + PG、完整三级
-**TTL 管理**:会话默认 30 分钟过期,自动清理
-**写穿透**:数据先写 L1异步同步到 L2/L3
## 📊 数据库设计
系统使用 PostgreSQL 存储持久化数据:
### 核心表
| 表名 | 说明 | 关键字段 |
|------|------|---------|
| `users` | 用户账户 | `id (UUID)`, `username (UNIQUE)`, `password_hash (bcrypt)` |
| `sessions` | 对话会话 | `id (UUID)`, `user_id (FK)`, `title`, `config (JSONB)` |
| `messages` | 消息记录 | `id (BIGSERIAL)`, `session_id (FK)`, `role`, `content`, `tokens_used` |
| `refresh_tokens` | 刷新令牌 | `token_hash (PK, SHA256)`, `user_id (FK)`, `expires_at` |
**关系**`users 1:N sessions 1:N messages``users 1:N refresh_tokens`
**迁移管理**:使用嵌入式 SQL 文件(`backend/migrations/`),应用启动时自动执行。
## 🛡️ 安全特性
- 🔒 **密码安全**bcrypt (cost=10) 哈希,自动生成盐值
- 🔑 **Token 安全**JWT HS256 签名refresh_token SHA256 哈希存储
- 🚫 **防重放攻击**Refresh Token Rotation + 复用检测自动吊销
- 🌐 **传输安全**:生产环境强制 HTTPS开发环境 Vite proxy 同源代理
- 🚦 **限流保护**:令牌桶算法(生产环境启用),防暴力破解
- 🔍 **日志追踪**:全链路 Trace ID请求/响应/错误统一记录
## 🌍 部署架构
```
┌─────────────┐
│ Nginx │ ← 反向代理(静态资源 + API + WebSocket
└──────┬──────┘
┌──────┴───────────────────┐
│ Go Gateway 集群 │
│ ├─ Gateway-1 │
│ ├─ Gateway-2 │
│ └─ Gateway-N │
└───┬────────────┬─────────┘
│ │
┌───┴────┐ ┌───┴────────┐
│ Redis │ │ PostgreSQL │
└────────┘ └────────────┘
┌───┴────────────────────┐
│ 外部 AI 服务 │
│ ├─ DashScope (LLM) │
│ ├─ MiMo (STT/TTS) │
│ └─ Deepgram (可选) │
└───────────────────────┘
```
**跨域策略**Nginx 统一反代前后端到同一域名,无跨域问题。
**水平扩展**Gateway 无状态设计,会话状态存储在 Redis/PostgreSQL支持多实例部署。
## 📖 文档
### 核心设计文档
| 文档 | 内容 |
|------|------|
| [01-架构设计](docs/01-架构设计.md) | 三层架构、技术栈、数据库设计、部署方案 |
| [02-接口文档](docs/02-接口文档.md) | 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 服务栈、持久化层、前端边缘处理选型 |
| [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 |
| [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
| [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
| [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 |
| [08-功能创意](docs/08-功能创意.md) | 功能创意与规划 |
| [对话历史技术设计](docs/conversation-history-technical-design.md) | 对话历史功能的前端技术方案 |
| [09-情景切换](docs/09-情景切换.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>

View File

@@ -12,6 +12,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/oklog/ulid/v2 v2.1.1
github.com/redis/go-redis/v9 v9.20.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
@@ -53,7 +54,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

View File

@@ -208,8 +208,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// validateCredentials 校验用户名和密码格式。
// 返回空字符串表示校验通过,否则返回错误描述。
func validateCredentials(username, password string) string {
if len(username) < 3 || len(username) > 64 {
return "username must be 3-64 characters"
if len(username) > 64 {
return "username must not exceed 64 characters"
}
if len(password) < 8 || len(password) > 72 {
return "password must be 8-72 characters"

View File

@@ -273,6 +273,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
return
}
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
if messages == nil {
messages = []store.StoredMessage{}
}
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": count,
@@ -314,6 +317,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) {
}
messages := allMessages[start:]
if messages == nil {
messages = []models.Message{}
}
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": total,

View File

@@ -90,7 +90,7 @@ func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, a
}
defer pgxRows.Close()
var messages []StoredMessage
messages := make([]StoredMessage, 0)
for pgxRows.Next() {
var m StoredMessage
if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil {

View File

@@ -10,6 +10,14 @@ CREATE TABLE IF NOT EXISTS users (
-- 用户名索引(用于登录查询)
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- 表和列注释
COMMENT ON TABLE users IS '用户表,存储系统所有注册用户的基本信息';
COMMENT ON COLUMN users.id IS '用户唯一标识符 (UUID)';
COMMENT ON COLUMN users.username IS '用户名,最大 64 字符,全局唯一';
COMMENT ON COLUMN users.password_hash IS '密码哈希值,使用 bcrypt 算法cost=10';
COMMENT ON COLUMN users.created_at IS '用户注册时间';
COMMENT ON COLUMN users.updated_at IS '用户信息最后更新时间';
-- Refresh Token 表
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -24,3 +32,11 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token
-- 用户 ID 索引(用于登出所有设备)
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
-- 表和列注释
COMMENT ON TABLE refresh_tokens IS 'Refresh Token 表,用于 JWT 双 token 机制的长期身份验证';
COMMENT ON COLUMN refresh_tokens.id IS 'Token 唯一标识符 (UUID)';
COMMENT ON COLUMN refresh_tokens.user_id IS '所属用户 ID外键关联 users 表,用户删除时级联删除';
COMMENT ON COLUMN refresh_tokens.token_hash IS 'Token 哈希值,使用 SHA-256 算法,十六进制编码 (64 字符)';
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Token 过期时间,默认有效期 7 天';
COMMENT ON COLUMN refresh_tokens.created_at IS 'Token 创建时间';

View File

@@ -2,10 +2,12 @@
CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL,
role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system"
role VARCHAR(10) NOT NULL,
content TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT check_tokens_non_negative CHECK (tokens_used >= 0)
);
-- 按会话查询消息(分页核心索引)
@@ -15,3 +17,12 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id_created_at
-- 按会话查询最后一条消息
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc
ON messages(session_id, id DESC);
-- 表和列注释
COMMENT ON TABLE messages IS '消息表,存储所有会话的消息记录';
COMMENT ON COLUMN messages.id IS '消息唯一标识符,自增序列';
COMMENT ON COLUMN messages.session_id IS '所属会话 ID关联 sessions 表';
COMMENT ON COLUMN messages.role IS '消息角色,可选值: ''user'' (用户), ''assistant'' (AI 助手), ''system'' (系统)';
COMMENT ON COLUMN messages.content IS '消息内容,无长度限制';
COMMENT ON COLUMN messages.tokens_used IS '消息消耗的 token 数量,用于计费统计';
COMMENT ON COLUMN messages.created_at IS '消息创建时间';

View File

@@ -1,11 +1,22 @@
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '新对话',
title VARCHAR(100) NOT NULL DEFAULT '新对话',
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT check_title_length CHECK (char_length(title) >= 1 AND char_length(title) <= 100)
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON sessions (user_id, updated_at DESC);
-- 表和列注释
COMMENT ON TABLE sessions IS '会话表,存储用户的对话会话信息';
COMMENT ON COLUMN sessions.id IS '会话唯一标识符 (UUID)';
COMMENT ON COLUMN sessions.user_id IS '所属用户 ID关联 users 表';
COMMENT ON COLUMN sessions.title IS '会话标题,默认为"新对话",长度 1-100 字符';
COMMENT ON COLUMN sessions.config IS '会话配置 (JSONB),包含: tts_enabled (布尔), detail_level (''low''/''high''), language (语言代码), scenario (情景 ID)';
COMMENT ON COLUMN sessions.created_at IS '会话创建时间';
COMMENT ON COLUMN sessions.updated_at IS '会话最后更新时间';

View File

@@ -5,18 +5,19 @@ CREATE TABLE user_scenarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
icon VARCHAR(10) DEFAULT '',
description VARCHAR(100) NOT NULL,
icon VARCHAR(20) DEFAULT '',
description VARCHAR(100),
prompt TEXT NOT NULL,
greeting VARCHAR(200),
greeting VARCHAR(500),
language VARCHAR(10) DEFAULT 'zh-CN',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
CONSTRAINT check_description_length CHECK (char_length(description) >= 5 AND char_length(description) <= 100),
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 50 AND char_length(prompt) <= 2000)
CONSTRAINT check_description_length CHECK (description IS NULL OR char_length(description) <= 100),
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 10),
CONSTRAINT check_greeting_length CHECK (greeting IS NULL OR char_length(greeting) <= 500)
);
-- 为用户 ID 创建索引,加速查询
@@ -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);
COMMENT ON TABLE user_scenarios IS '用户自建情景表';
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识';
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID外键关联 users 表';
COMMENT ON COLUMN user_scenarios.name IS '情景名称,如"创意写作导师"';
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标,如"🎨"';
COMMENT ON COLUMN user_scenarios.description IS '简短描述,显示在情景卡片上';
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt定义 AI 行为';
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导,可选';
COMMENT ON COLUMN user_scenarios.language IS '默认语言,如 zh-CN、en-US';
-- 表和列注释
COMMENT ON TABLE user_scenarios IS '用户自建情景表,存储用户创建的 AI 对话情景配置';
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识符 (UUID)';
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID外键关联 users 表,用户删除时级联删除';
COMMENT ON COLUMN user_scenarios.name IS '情景名称 (2-50 字符),如"创意写作导师"';
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标 (最多 20 字符),支持复合 Emoji如"🎨"';
COMMENT ON COLUMN user_scenarios.description IS '简短描述 (最多 100 字符),可选,显示在情景卡片上';
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt (最少 10 字符,无上限),定义 AI 行为和对话风格';
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导 (最多 500 字符)可选AI 的开场白';
COMMENT ON COLUMN user_scenarios.language IS '默认语言代码 (如 zh-CN、en-US、ja-JP)';
COMMENT ON COLUMN user_scenarios.created_at IS '情景创建时间';
COMMENT ON COLUMN user_scenarios.updated_at IS '情景最后更新时间';

View File

@@ -217,7 +217,15 @@ body {
background: rgba(10, 10, 15, 0.45);
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 {
@@ -232,17 +240,21 @@ body {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
overflow: hidden;
animation: slideInLeft 0.3s var(--transition-smooth);
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
transition: transform 0.2s ease-out, visibility 0s linear 0.2s;
}
.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 {
@@ -903,6 +915,8 @@ body {
gap: 10px;
padding: 6px 0;
background: transparent;
max-width: 100%;
align-self: center;
}
.chat-message--system::before,
@@ -1077,7 +1091,15 @@ body {
background: rgba(10, 10, 15, 0.5);
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 {
@@ -1090,8 +1112,18 @@ body {
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
animation: slideInRight 0.35s var(--transition-smooth);
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 {
@@ -1362,9 +1394,9 @@ body {
to { opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Scrollbar ---- */
@@ -2181,7 +2213,7 @@ body {
}
/* ============================================================
Custom Scenarios Styles
Custom Scenarios Styles — 白色商务风格(使用 CSS 变量)
============================================================ */
/* Scenario list */
@@ -2197,16 +2229,16 @@ body {
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
}
.scenario-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
background: var(--color-surface-3);
border-color: var(--color-surface-3);
}
.scenario-item input[type="radio"] {
@@ -2221,12 +2253,12 @@ body {
.scenario-item__name {
flex: 1;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
color: var(--color-text);
}
.scenario-item__desc {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: var(--color-text-muted);
margin-top: 2px;
}
@@ -2259,28 +2291,31 @@ body {
.scenario-action-btn {
padding: 4px 8px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
}
.scenario-action-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
background: var(--color-surface-2);
border-color: var(--color-surface-3);
color: var(--color-text);
}
.scenario-action-btn--confirm {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.3);
background: rgba(248, 113, 113, 0.1);
border-color: rgba(248, 113, 113, 0.2);
color: var(--color-error);
}
.scenario-empty {
padding: 16px;
text-align: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
color: var(--color-text-muted);
font-style: italic;
}
@@ -2289,17 +2324,17 @@ body {
margin-left: auto;
padding: 4px 12px;
font-size: 12px;
background: rgba(196, 97, 47, 0.15);
border: 1px solid rgba(196, 97, 47, 0.3);
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 999px;
color: #C4612F;
color: var(--color-primary);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
}
.config-create-btn:hover {
background: rgba(196, 97, 47, 0.25);
border-color: rgba(196, 97, 47, 0.5);
background: rgba(59, 130, 246, 0.16);
border-color: rgba(59, 130, 246, 0.4);
}
.config-group__title {
@@ -2315,25 +2350,30 @@ body {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(10, 10, 15, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.2s;
}
[data-theme="light"] .modal-overlay {
background: rgba(0, 0, 0, 0.3);
}
.modal {
background: #1F2421;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
width: 90%;
max-width: 600px;
max-height: 85vh;
display: flex;
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;
}
@@ -2346,20 +2386,21 @@ body {
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-size: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
color: var(--color-text);
letter-spacing: -0.01em;
}
.modal__close {
background: transparent;
border: none;
font-size: 24px;
color: rgba(255, 255, 255, 0.6);
font-size: 20px;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
width: 32px;
@@ -2367,13 +2408,13 @@ body {
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.modal__close:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
background: var(--color-surface-2);
color: var(--color-text);
}
.modal__body {
@@ -2387,7 +2428,7 @@ body {
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--color-border);
margin-top: 8px;
}
@@ -2402,12 +2443,12 @@ body {
gap: 8px;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
color: var(--color-text);
margin-bottom: 8px;
}
.form-required {
color: #C4612F;
color: var(--color-primary);
}
.form-input,
@@ -2415,21 +2456,28 @@ body {
.form-select {
width: 100%;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.9);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 14px;
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-textarea:focus,
.form-select:focus {
outline: none;
border-color: rgba(196, 97, 47, 0.5);
background: rgba(255, 255, 255, 0.08);
border-color: var(--color-primary);
background: var(--color-surface);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-textarea {
@@ -2443,15 +2491,15 @@ body {
display: block;
margin-top: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
color: var(--color-text-muted);
}
.form-error {
padding: 12px;
background: rgba(248, 113, 113, 0.15);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 6px;
color: #f87171;
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.2);
border-radius: var(--radius-sm);
color: var(--color-error);
font-size: 13px;
margin-top: 12px;
}
@@ -2471,129 +2519,112 @@ body {
align-items: center;
justify-content: center;
font-size: 22px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
}
.icon-picker__item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
background: var(--color-surface-3);
border-color: var(--color-surface-3);
transform: scale(1.05);
}
.icon-picker__item--active {
background: rgba(196, 97, 47, 0.2);
border-color: rgba(196, 97, 47, 0.5);
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.4);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
/* Prompt guide */
.form-guide-btn {
background: transparent;
border: none;
color: #C4612F;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
border-radius: var(--radius-sm);
margin-left: auto;
transition: all 0.2s;
transition: all var(--transition-fast);
}
.form-guide-btn:hover {
background: rgba(196, 97, 47, 0.1);
background: rgba(59, 130, 246, 0.06);
}
.form-guide {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 16px;
margin-bottom: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--color-text);
}
.form-guide strong {
color: rgba(255, 255, 255, 0.9);
color: var(--color-text);
}
.form-guide ul {
margin: 8px 0;
padding-left: 20px;
color: rgba(255, 255, 255, 0.7);
color: var(--color-text-muted);
}
.form-guide__code {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: var(--color-surface-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 12px;
margin-top: 8px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
color: var(--color-text);
}
/* Buttons */
.btn {
/* Buttons (modal-scoped overrides — only affect modal context) */
.modal .btn {
padding: 10px 20px;
border-radius: 6px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.btn:disabled {
.modal .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: #C4612F;
.modal .btn--primary {
background: var(--color-primary);
color: white;
border-color: #C4612F;
border-color: var(--color-primary);
}
.btn--primary:hover:not(:disabled) {
background: #A94E22;
border-color: #A94E22;
.modal .btn--primary:hover:not(:disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.btn--secondary {
background: transparent;
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.2);
.modal .btn--secondary {
background: var(--color-surface-2);
color: var(--color-text-muted);
border-color: var(--color-border);
}
.btn--secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.9);
.modal .btn--secondary:hover:not(:disabled) {
background: var(--color-surface-3);
border-color: var(--color-surface-3);
color: var(--color-text);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Animations — fadeIn & slideUp defined in main animation section above */

View File

@@ -21,6 +21,15 @@ import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
import { I18nContext, parseLocale, t } from "./lib/i18n";
import { renderScenarioIconById } from "./lib/scenarioIcons";
import type { Locale } from "./lib/i18n";
/** SVG 图标 ID → emoji 映射(用于纯文本场景,如系统消息) */
const ICON_ID_TO_EMOJI: Record<string, string> = {
sparkles: "✨", theater: "🎭", palette: "🎨", target: "🎯", film: "🎬",
"book-open": "📖", book: "📚", edit: "✏️", clipboard: "📋", "map-pin": "📍",
search: "🔍", lightbulb: "💡", briefcase: "💼", code: "💻", "graduation-cap": "🎓",
mic: "🎤", music: "🎵", globe: "🌐", heart: "❤️", shield: "🛡️",
zap: "⚡", coffee: "☕", tool: "🔧", "message-circle": "💬",
};
import type { Theme } from "./types";
import type { UserScenario } from "./lib/api/scenarios";
import "./App.css";
@@ -228,10 +237,14 @@ function AppContent() {
}
}
updateConfig(updates);
// 插入系统提示消息
const scenarioName = sc
? `${sc.icon} ${sc.nameKey ? tr(sc.nameKey) : sc.name}`
// 插入系统提示消息(自建情景的 icon 是 SVG ID需转为 emoji
const iconText = sc
? (ICON_ID_TO_EMOJI[sc.icon] || sc.icon)
: "";
const displayName = sc
? (sc.nameKey ? tr(sc.nameKey) : sc.name)
: scenarioId;
const scenarioName = iconText ? `${iconText} ${displayName}` : displayName;
setMessages(prev => [...prev, {
id: uuidv4(),
role: "system",
@@ -240,9 +253,6 @@ function AppContent() {
}]);
}, [updateConfig, setMessages, tr]);
// 当前情景对象
const activeScenario = allScenarios.find((s) => s.id === (config.scenario || "free_chat")) || allScenarios[0];
// ---- 键盘快捷键 ----
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -319,34 +329,33 @@ function AppContent() {
sessions={sessions}
activeSessionId={activeSessionId}
open={sidebarOpen}
onToggle={() => setSidebarOpen((v) => !v)}
onClose={() => setSidebarOpen(false)}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={renameSession}
/>
{showConfig && (
<ConfigPanel
config={config}
theme={theme}
username={user?.username}
allScenarios={allScenarios}
onUpdate={updateConfig}
onThemeChange={handleThemeChange}
onLogout={logout}
onClose={() => setShowConfig(false)}
onCreateScenario={() => {
setShowConfig(false);
setShowCreateScenario(true);
}}
onEditScenario={(scenario) => {
setShowConfig(false);
setEditingScenario(scenario);
}}
onDeleteScenario={deleteScenario}
/>
)}
<ConfigPanel
config={config}
theme={theme}
username={user?.username}
allScenarios={allScenarios}
open={showConfig}
onUpdate={updateConfig}
onThemeChange={handleThemeChange}
onLogout={logout}
onClose={() => setShowConfig(false)}
onCreateScenario={() => {
setShowConfig(false);
setShowCreateScenario(true);
}}
onEditScenario={(scenario) => {
setShowConfig(false);
setEditingScenario(scenario);
}}
onDeleteScenario={deleteScenario}
/>
{/* 创建情景 Modal */}
{showCreateScenario && (
@@ -611,6 +620,7 @@ function AppContent() {
currentReply={currentReply}
connectionStatus={connectionStatus}
currentScenario={config.scenario}
allScenarios={allScenarios}
isProcessing={isProcessing}
isVADReady={isVADReady}
vadError={vadError ?? undefined}

View File

@@ -4,9 +4,10 @@
// 增强:空状态情景选择卡片、语音输入按钮
// ============================================================
import { ReactNode, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useI18n } from "../../lib/i18n";
import { scenarios } from "../../lib/scenarios";
import type { ExtendedScenario } from "../../hooks/useScenarios";
import type { ChatMessage } from "../../types";
import type { ConnectionStatus } from "../../lib/websocket";
@@ -15,6 +16,7 @@ interface ChatPanelProps {
currentReply?: string;
connectionStatus: ConnectionStatus;
currentScenario?: string;
allScenarios?: ExtendedScenario[];
isProcessing?: boolean;
isVADReady?: boolean;
vadError?: string | null;
@@ -43,6 +45,7 @@ export function ChatPanel({
currentReply,
connectionStatus,
currentScenario,
allScenarios,
isProcessing,
isVADReady,
vadError,
@@ -52,7 +55,6 @@ export function ChatPanel({
onSendText,
onToggleMic,
onSceneCard,
onSelectScenario,
}: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -104,6 +106,10 @@ export function ChatPanel({
}
};
// 当前情景对象(优先从 allScenarios 查找,含自建情景;兜底用系统情景)
const scenarioList = allScenarios || scenarios;
const activeScenarioObj = scenarioList.find(sc => sc.id === activeScenario);
return (
<div className="chat-panel">
<div className="chat-panel__messages" ref={containerRef}>
@@ -112,11 +118,13 @@ export function ChatPanel({
<div className="chat-panel__scenario-hint">
<div className="scenario-hint-card">
<span className="scenario-hint-card__icon">
{scenarios.find(s => s.id === activeScenario)?.icon}
{activeScenarioObj?.icon}
</span>
<div className="scenario-hint-card__text">
<strong>{t(scenarios.find(s => s.id === activeScenario)?.nameKey || "")}</strong>
<p>{t(`scenario.${activeScenario}.hint`)}</p>
<strong>{activeScenarioObj ? (activeScenarioObj.nameKey ? t(activeScenarioObj.nameKey) : ('name' in activeScenarioObj ? activeScenarioObj.name : '')) : ""}</strong>
{activeScenarioObj && (activeScenarioObj.descKey || ('description' in activeScenarioObj && activeScenarioObj.description)) && (
<p>{activeScenarioObj.descKey ? t(activeScenarioObj.descKey) : ('description' in activeScenarioObj ? activeScenarioObj.description : '')}</p>
)}
</div>
</div>
</div>
@@ -125,7 +133,7 @@ export function ChatPanel({
{/* 空状态:欢迎信息 */}
{isEmpty && (
<div className="chat-panel__welcome">
<span className="chat-panel__welcome-icon">{scenarios.find(s => s.id === activeScenario)?.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>
<span className="chat-panel__welcome-icon">{activeScenarioObj?.icon || <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>}</span>
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>

View File

@@ -14,6 +14,7 @@ interface ConfigPanelProps {
theme: Theme;
username?: string;
allScenarios: ExtendedScenario[];
open: boolean;
onUpdate: (partial: Partial<SessionConfig>) => void;
onThemeChange: (theme: Theme) => void;
onLogout?: () => void;
@@ -28,6 +29,7 @@ export function ConfigPanel({
theme,
username,
allScenarios,
open,
onUpdate,
onThemeChange,
onLogout,
@@ -59,8 +61,14 @@ export function ConfigPanel({
};
return (
<div className="drawer-overlay" onClick={onClose}>
<div className="drawer" onClick={(e) => e.stopPropagation()}>
<div
className={`drawer-overlay ${open ? 'drawer-overlay--visible' : ''}`}
onClick={onClose}
>
<div
className={`drawer ${open ? 'drawer--open' : 'drawer--collapsed'}`}
onClick={(e) => e.stopPropagation()}
>
<div className="drawer__header">
<span className="drawer__title">{t("settings.title")}</span>
<button className="drawer__close" onClick={onClose}>

View File

@@ -12,7 +12,7 @@ interface SessionSidebarProps {
sessions: SessionSummary[];
activeSessionId: string | null;
open: boolean;
onToggle: () => void;
onClose: () => void;
onNewSession: () => void;
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
@@ -56,7 +56,7 @@ export function SessionSidebar({
sessions,
activeSessionId,
open,
onToggle,
onClose,
onNewSession,
onSelectSession,
onDeleteSession,
@@ -88,7 +88,7 @@ export function SessionSidebar({
const handleSelect = (id: string) => {
onSelectSession(id);
// 选择后自动收起侧边栏
onToggle();
onClose();
};
// 过滤 + 分组
@@ -118,8 +118,6 @@ export function SessionSidebar({
return groups;
}, [sessions, searchQuery]);
if (!open) return null;
const groupLabels: Record<TimeGroup, string> = {
today: t("sidebar.today"),
yesterday: t("sidebar.yesterday"),
@@ -128,14 +126,17 @@ export function SessionSidebar({
return (
<>
<div className="sidebar-backdrop" onClick={onToggle} />
<div className="sidebar">
<div
className={`sidebar-backdrop ${open ? 'sidebar-backdrop--visible' : ''}`}
onClick={onClose}
/>
<div className={`sidebar ${open ? 'sidebar--open' : 'sidebar--collapsed'}`}>
{/* 头部:新建 + 收起 */}
<div className="sidebar__header">
<button className="sidebar__new-btn" onClick={onNewSession}>
{t("sidebar.new")}
</button>
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
<button className="sidebar__toggle" onClick={onClose} title={t("sidebar.collapse")}>
</button>
</div>

View File

@@ -80,7 +80,7 @@ export function useSessionList(accessToken?: string | null) {
try {
const res = await listConversations(accessToken);
if (res.data) {
const list = res.data.conversations.map(toSessionSummary);
const list = (res.data.conversations || []).map(toSessionSummary);
setSessions(list);
// 恢复上次选中的会话(如果仍然存在)
const lastId = localStorage.getItem(LAST_ACTIVE_KEY);
@@ -193,7 +193,7 @@ export function useSessionList(accessToken?: string | null) {
try {
const res = await getConversationMessages(accessToken, sessionId);
if (res.data) {
return res.data.messages.map(toChatMessage);
return (res.data.messages || []).map(toChatMessage);
}
} catch (err) {
console.error("[SessionList] 加载消息失败:", err);

View File

@@ -155,9 +155,9 @@ export const enUS: TranslationMap = {
"auth.login": "Sign In",
"auth.register": "Sign Up",
"auth.username": "Username",
"auth.username.placeholder": "3-64 characters",
"auth.username.placeholder": "Enter username",
"auth.password": "Password",
"auth.password.placeholder": "8-72 characters",
"auth.password.placeholder": "Enter password",
"auth.submitting": "Please wait...",
"auth.noAccount": "Don't have an account?",
"auth.hasAccount": "Already have an account?",

View File

@@ -155,9 +155,9 @@ export const jaJP: TranslationMap = {
"auth.login": "ログイン",
"auth.register": "新規登録",
"auth.username": "ユーザー名",
"auth.username.placeholder": "3〜64文字",
"auth.username.placeholder": "ユーザー名を入力",
"auth.password": "パスワード",
"auth.password.placeholder": "8〜72文字",
"auth.password.placeholder": "パスワードを入力",
"auth.submitting": "お待ちください...",
"auth.noAccount": "アカウントをお持ちでないですか?",
"auth.hasAccount": "すでにアカウントをお持ちですか?",

View File

@@ -155,9 +155,9 @@ export const zhCN: TranslationMap = {
"auth.login": "登录",
"auth.register": "注册",
"auth.username": "用户名",
"auth.username.placeholder": "3-64 个字符",
"auth.username.placeholder": "请输入用户名",
"auth.password": "密码",
"auth.password.placeholder": "8-72 个字符",
"auth.password.placeholder": "请输入密码",
"auth.submitting": "请稍候...",
"auth.noAccount": "还没有账号?",
"auth.hasAccount": "已有账号?",