codex/dev
mula.liu 2026-04-28 09:03:31 +08:00
parent d2ae4b63c6
commit a943ec5960
41 changed files with 1130 additions and 2390 deletions

View File

@ -1,14 +1,14 @@
# ==================== 部署模式说明 ==================== # ==================== 部署模式说明 ====================
# 1. 默认 Docker 一体化部署(./start.sh / docker-compose.yml # 1. 全量 Docker 部署(./scripts/deploy-full.sh / docker-compose.yml
# 只使用当前文件(根目录 .env和 Docker Compose 注入的环境变量,不读取 backend/.env。 # 只使用当前文件(根目录 .env和 Docker Compose 注入的环境变量,不读取 backend/.env。
# 2. 直接运行后端或外接中间件部署 # 2. 半部署 / 应用部署(./scripts/deploy-app-only.sh
# `start-external.sh` 也只读取当前文件 # 只启动 backend/frontend不启动 MySQL/Redis
# 外接中间件时可直接在这里填写 MYSQL_* / REDIS_*,脚本会转换给后端使用。 # 外接中间件时可直接在这里填写 MYSQL_* / REDIS_*,脚本会转换给后端使用。
# ==================== 数据库配置 ==================== # ==================== 数据库配置 ====================
# MySQL 初始化参数Docker 内置 MySQL # MySQL 初始化参数Docker 内置 MySQL
# 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。 # 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。
# 使用 `./start-external.sh` 时,请改成外部 MySQL 地址或服务名。 # 使用 `./scripts/deploy-app-only.sh` 时,请改成外部 MySQL 地址或服务名。
MYSQL_HOST=mysql MYSQL_HOST=mysql
MYSQL_ROOT_PASSWORD=change_this_password MYSQL_ROOT_PASSWORD=change_this_password
MYSQL_DATABASE=imeeting MYSQL_DATABASE=imeeting
@ -19,7 +19,7 @@ MYSQL_PORT=3306
# ==================== 缓存配置 ==================== # ==================== 缓存配置 ====================
# Redis 初始化参数Docker 内置 Redis # Redis 初始化参数Docker 内置 Redis
# 当后端也运行在 Docker 中时Redis 主机应为服务名 `redis`。 # 当后端也运行在 Docker 中时Redis 主机应为服务名 `redis`。
# 使用 `./start-external.sh` 时,请改成外部 Redis 地址或服务名。 # 使用 `./scripts/deploy-app-only.sh` 时,请改成外部 Redis 地址或服务名。
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=change_this_password REDIS_PASSWORD=change_this_password

7
.gitignore vendored
View File

@ -29,11 +29,8 @@ backend/uploads/
backend/logs/ backend/logs/
backend/venv/ backend/venv/
backend/test/ backend/test/
# Only keep the latest full-deploy SQL entrypoints in repo # Full-deploy SQL entrypoints are kept under scripts/sql/.
backend/sql/* backend/sql/
!backend/sql/imeeting-schema-latest.sql
!backend/sql/imeeting-seed-latest.sql
backend/sql/migrations/
backend/scripts/ backend/scripts/
资料/ 资料/

View File

@ -1,439 +0,0 @@
# Docker部署快速开始
本目录包含完整的Docker Compose部署配置支持一键部署iMeeting应用。
## 📋 文件说明
| 文件 | 说明 |
|------|------|
| `docker-compose.yml` | Docker Compose配置文件 |
| `.env.example` | 环境变量模板 |
| `.dockerignore` | Docker构建忽略文件 |
| `start.sh` ⭐ | 一键启动脚本(推荐) |
| `stop.sh` | 停止服务脚本 |
| `manage.sh` | 服务管理脚本 |
| `DOCKER_README.md` | 本文档 |
## 🏗️ 系统架构
```
方式一:直接访问
用户 → http://服务器IP → iMeeting Frontend(Web) (80) → Frontend/Backend
方式二域名访问HTTPS
用户 → https://domain → 接入服务器Nginx (SSL) → iMeeting服务器 (80) → Frontend/Backend
```
**4个服务容器**
- **Frontend**: React前端应用
- **Backend**: FastAPI后端服务
- **MySQL**: 数据库
- **Redis**: 缓存
**数据持久化**
- `./data/` - 所有数据存储在此目录
## 🚀 快速开始
### 方式一:使用启动脚本(推荐)
```bash
# 1. 复制并配置环境变量
cp .env.example .env
vim .env # 配置 BASE_URL、密码等
# 2. 一键启动
./start.sh
```
说明:
- 完整 Docker 一体化部署不会读取 `backend/.env`
脚本会自动完成:
- ✅ 检查Docker依赖
- ✅ 创建必要目录
- ✅ 启动所有服务
- ✅ 等待健康检查
说明:
- 后端镜像现在依赖系统级 `ffmpeg/ffprobe` 做音频预处理,已在 `backend/Dockerfile` 中安装,无需宿主机额外安装。
### 方式二:手动启动
```bash
# 1. 配置环境变量
cp .env.example .env
vim .env # 配置主环境变量
# 2. 启动所有服务
docker-compose up -d
# 3. 查看服务状态
docker-compose ps
```
## 🌐 访问地址
启动成功后:
**直接访问HTTP**
- **HTTP访问**: http://localhost 或 http://服务器IP
- **API文档**: http://localhost/docs 或 http://服务器IP/docs
- **API路径**: http://localhost/api/ 或 http://服务器IP/api/
**域名访问HTTPS**
- 需要在接入服务器配置Nginx反向代理
- 参考本文“域名和 HTTPS 配置”章节
- 访问示例https://imeeting.yourdomain.com
## ⚙️ 环境变量配置
### 必须配置项
编辑根目录 `.env` 文件,修改以下配置:
```bash
# Docker 一体化部署下,后端容器访问内置 MySQL/Redis 使用服务名
MYSQL_HOST=mysql
REDIS_HOST=redis
# 生产环境必改密码
MYSQL_ROOT_PASSWORD=change_this_password
MYSQL_PASSWORD=change_this_password
REDIS_PASSWORD=change_this_password
```
### 可选配置项
```bash
# 应用访问地址(用于生成外链,以及音频转录时给云端拉取音频文件)
# 如果使用云端音频转录,必须改成外部可访问的域名或公网地址,不能填写 localhost / 127.0.0.1 / 容器名
BASE_URL=http://localhost # 生产环境改为: https://your-domain.com
# Nginx端口默认80/443
HTTP_PORT=80
HTTPS_PORT=443
# 如需调整容器对外端口
# BACKEND_PORT=8000
# HTTP_PORT=80
```
### 外接 MySQL / Redis 部署
如果使用 `./start-external.sh`,直接修改根目录 `.env` 即可,不需要 `backend/.env`
```bash
cp .env.example .env
vim .env
```
根目录 `.env` 里需要重点配置:
- `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`
- `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD`
- `BASE_URL`
说明:
- `start-external.sh` 会优先读取 `.env` 中的 `DB_*`;如果未设置,则自动回退到 `MYSQL_*`
### 音频预处理依赖
- Docker 部署:后端容器内已安装 `ffmpeg`
- 非 Docker 部署:请确保服务器可执行 `ffmpeg``ffprobe`
## 📦 数据目录
所有数据存储在 `./data/` 目录:
```
data/
├── mysql/ # MySQL数据库文件
├── redis/ # Redis持久化文件
├── uploads/ # 用户上传的文件(音频、图片等)
└── logs/ # 日志文件
├── backend/
├── frontend/
└── frontend/
```
**重要提示**
- 定期备份 `data/` 目录
- 生产环境建议挂载独立数据盘到 `./data/`
## 🔧 日常管理
### 使用管理脚本(推荐)
```bash
./manage.sh
```
交互式菜单提供以下功能:
1. 查看服务状态
2. 查看实时日志
3. 重启所有服务
4. 重启单个服务
5. 进入容器终端
6. **备份数据库**
7. 恢复数据库
8. 清理Redis缓存
9. 更新服务
10. 查看资源使用
11. 导出日志
### 常用命令
```bash
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f # 所有服务
docker-compose logs -f frontend # 前端Web日志
docker-compose logs -f backend # 后端日志
tail -f data/logs/frontend/*.log # 前端容器日志目录
# 重启服务
docker-compose restart # 重启所有
docker-compose restart frontend # 重启前端
# 停止服务
./stop.sh # 交互式停止
docker-compose down # 停止并删除容器
```
## 🔐 域名和HTTPS配置
iMeeting服务器本身仅提供HTTP服务。如需通过域名访问HTTPS需要在接入服务器配置Nginx反向代理。
### 配置接入服务器
完整配置步骤请参考本文档中的反向代理章节
**简要步骤**
1. 在接入服务器安装Nginx
2. 使用Let's Encrypt获取SSL证书
3. 配置Nginx反向代理到iMeeting服务器IP
4. 配置DNS A记录指向接入服务器
**配置示例**
```nginx
server {
listen 443 ssl http2;
server_name imeeting.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/imeeting.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/imeeting.yourdomain.com/privkey.pem;
location / {
proxy_pass http://iMeeting服务器IP:80;
# ... 其他配置
}
}
```
详见:本文档中的反向代理章节
## 🔍 故障排查
### 查看日志
```bash
# 服务日志
docker-compose logs -f
# 文件日志
tail -f data/logs/frontend/*.log
tail -f data/logs/backend/*.log
```
### 检查服务状态
```bash
docker-compose ps
```
所有服务应显示 `healthy` 状态。
### 测试连接
```bash
# 测试Nginx
curl http://localhost/health
# 测试HTTPS
curl -k https://localhost/health
# 测试API文档
curl http://localhost/docs
```
### 常见问题
| 问题 | 解决方法 |
|------|----------|
| 端口被占用 | 修改.env中的HTTP_PORT |
| 502错误 | 检查backend和frontend是否健康 |
| 数据库连接失败 | 检查根目录 `.env` 中的数据库配置;外接中间件模式确认 `MYSQL_HOST` / `DB_HOST` 是否正确 |
| 前端无法访问API | 检查VITE_API_BASE_URL配置 |
| 音频转录拉不到音频文件 | 检查 `BASE_URL` 是否为云端可访问的完整地址 |
| 如何配置HTTPS | 参考本文档中的反向代理章节 |
详见:`DOCKER_README.md` 故障排查章节
## 📊 服务组件
| 服务 | 容器名 | 内部端口 | 外部端口 | 健康检查 |
|------|--------|----------|----------|----------|
| Frontend(Web) | imeeting-frontend | 80 | 80 | ✅ |
| Backend | imeeting-backend | 8000 | 8000 | ✅ |
| MySQL | imeeting-mysql | 3306 | - | ✅ |
| Redis | imeeting-redis | 6379 | - | ✅ |
外部仅暴露Nginx的80端口HTTP其他服务通过内部网络通信。
如需HTTPS请在接入服务器配置SSL。
## 🔄 更新部署
### 更新代码
```bash
# 拉取最新代码
git pull
# 重新构建
docker-compose build
# 重启服务
docker-compose up -d
```
### 仅更新前端
```bash
cd frontend
npm run build
cd ..
docker-compose build frontend
docker-compose up -d frontend
docker-compose restart frontend
```
### 仅更新后端
```bash
docker-compose build backend
docker-compose up -d backend
```
## 💾 备份与恢复
### 备份数据库
```bash
# 使用管理脚本
./manage.sh # 选择"6) 备份数据库"
# 或手动执行
docker-compose exec mysql mysqldump -uroot -p imeeting > backup.sql
```
### 备份所有数据
```bash
# 备份data目录
tar czf imeeting_backup_$(date +%Y%m%d).tar.gz data/
# 备份配置
tar czf imeeting_config_$(date +%Y%m%d).tar.gz .env frontend/nginx.conf
```
### 恢复数据
```bash
# 恢复数据库
docker-compose exec -T mysql mysql -uroot -p imeeting < backup.sql
# 恢复data目录
tar xzf imeeting_backup_20240101.tar.gz
```
## 🛡️ 安全提示
生产环境部署前,请务必:
1. ✅ **修改所有默认密码**
- MYSQL_ROOT_PASSWORD
- MYSQL_PASSWORD
- REDIS_PASSWORD
2. ✅ **配置真实SSL证书**
- 使用Let's Encrypt或商业证书
- 不要使用自签名证书
3. ✅ **设置文件权限**
```bash
chmod 600 .env
确保接入层反向代理证书文件权限正确
```
4. ✅ **启用防火墙**
```bash
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
5. ✅ **定期备份数据**
- 设置自动备份脚本
- 异地备份
6. ✅ **监控日志**
- 定期检查错误日志
- 设置日志轮转
## 📚 更多信息
详细的部署说明、配置选项、性能优化等,请查看:
- **部署说明**: [DOCKER_README.md](DOCKER_README.md)
- **前端代理配置**: [frontend/nginx.conf](frontend/nginx.conf)
## 🆘 获取帮助
如遇问题:
1. 查看日志:`docker-compose logs -f`
2. 查看健康状态:`docker-compose ps`
3. 查看详细文档:`DOCKER_README.md`
4. 提交Issue到项目仓库
## 📞 快速命令参考
```bash
# 启动
./start.sh # 一键启动(推荐)
docker-compose up -d # 启动所有服务
# 管理
./manage.sh # 管理菜单(推荐)
docker-compose ps # 查看状态
docker-compose logs -f # 查看日志
docker-compose restart # 重启服务
# 停止
./stop.sh # 停止服务(推荐)
docker-compose down # 停止并删除容器
# 备份
./manage.sh # 选择备份功能
tar czf backup.tar.gz data/ # 备份所有数据
# 更新
git pull && docker-compose build && docker-compose up -d
```
---
**祝您使用愉快!** 🎉

View File

@ -1,27 +0,0 @@
# UI Modernization & Standardization Plan
## Stage 1: Foundation (Global Theme & Layout)
**Goal**: Establish a consistent visual base and layout structure.
**Success Criteria**:
- Global `ConfigProvider` with a modern theme (v5 tokens).
- A reusable `MainLayout` component replacing duplicated header/sidebar logic.
- Unified navigation experience across Admin and User dashboards.
**Status**: Complete
## Stage 2: Component Standardization
**Goal**: Replace custom, inconsistent components with Ant Design standards.
**Success Criteria**:
- `ListTable` and `DataTable` replaced by `antd.Table`. (Complete)
- `FormModal` and `ConfirmDialog` replaced by `antd.Modal`. (Complete)
- `Toast` and custom notifications replaced by `antd.message` and `antd.notification`. (Complete)
- Custom `Dropdown`, `Breadcrumb`, and `PageLoading` replaced by `antd` equivalents. (Complete)
**Status**: Complete
## Stage 3: Visual Polish & UX
**Goal**: Enhance design details and interactive experience.
**Success Criteria**:
- Modernized dashboard cards with subtle shadows and transitions. (Complete)
- Standardized `Empty` states and `Skeleton` loaders. (Complete)
- Responsive design improvements for various screen sizes. (Complete)
- Clean up redundant CSS files and components. (In Progress)
**Status**: In Progress

212
README.md
View File

@ -1,178 +1,50 @@
# iMeeting - 智慧会议平台 # iMeeting - 智慧会议平台
## 项目简介 iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台支持会议音频上传、异步语音转录、说话人识别、AI 会议纪要生成、知识库沉淀和后台管理。
iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通过自动化的语音转录、说话人识别和 AI 摘要功能,帮助专业人士高效管理会议内容,从繁琐的记录工作中解放出来。 ## 快速入口
## 核心价值 ### 全量部署
- **解放生产力** - 自动化的会议转录和摘要,让用户从繁琐的记录工作中解放出来 启动前端、后端、MySQL 和 Redis
- **信息不丢失** - 精准记录每一次会议的细节,确保关键信息和决策得到妥善保存
- **高效回顾** - 通过时间轴、发言人和关键词,快速定位会议内容 ```bash
- **便捷分享** - 轻松分享会议纪要、材料和关键节点给相关人员 cp .env.example .env
vim .env
./scripts/deploy-full.sh
```
### 半部署
只启动前端和后端MySQL / Redis 使用外部服务:
```bash
cp .env.example .env
vim .env
./scripts/deploy-app-only.sh
```
## 常用文档
- 部署文档:[design/deployment.md](./design/deployment.md)
- 项目设计:[design/project.md](./design/project.md)
- 数据库设计:[design/database.md](./design/database.md)
- AI 集成方案:[design/ai-integration.md](./design/ai-integration.md)
- 代码结构规范:[design/code-structure-standards.md](./design/code-structure-standards.md)
## 目录说明
```text
backend/ 后端 FastAPI 服务
frontend/ 前端 React 应用
scripts/ 部署脚本和全量部署 SQL
design/ 项目设计、部署和技术文档
```
## 技术栈 ## 技术栈
### 平台端 (Backend) - 后端Python / FastAPI
- **框架**: Python 3.9+ / FastAPI - 前端React / Vite / Ant Design
- **数据库**: MySQL 5.7+ - 数据库MySQL
- **缓存**: Redis 5.0+ - 缓存Redis
- **AI服务**: 阿里云通义千问 (Dashscope) - 部署Docker Compose
- 语音识别: Paraformer-v2
- 说话人分离
- 大语言模型摘要
- **存储**: 本地对象存储
- **身份认证**: JWT / Python-JOSE
- **部署**: Docker / Nginx
### 客户端 (Frontend)
- **框架**: React 19.1 + Vite 7.0
- **UI组件**: Ant Design 5.27
- **路由**: React Router DOM 7.7
- **Markdown**: @uiw/react-md-editor 4.0
- **可视化**: Markmap (思维导图)
- **其他工具**:
- Axios (HTTP 客户端)
- html2canvas + jsPDF (导出功能)
- QRCode.react (二维码生成)
- Lucide React (图标库)
## 功能模块
### 平台功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
| **用户管理** | 用户创建、编辑、删除、密码重置、角色权限管理 | ✅ 已完成 |
| **会议管理** | 会议创建、编辑、删除、参会人员管理 | ✅ 已完成 |
| **音频处理** | 音频文件上传、存储、格式验证 | ✅ 已完成 |
| **异步转录服务** | 基于Paraformer的异步语音识别 | ✅ 已完成 |
| **说话人分离** | 基于CAM++ ,支持自定义标签 | ✅ 已完成 |
| **AI 摘要生成** | 异步生成会议纪要,支持自定义 Prompt | ✅ 已完成 |
| **任务状态管理** | 转录任务和 LLM 任务的状态追踪 | ✅ 已完成 |
| **声纹采集** | 用户声纹数据采集和管理 | ✅ 已完成 |
| **身份认证** | JWT Token 认证、登录登出、Token 刷新 | ✅ 已完成 |
| **图片上传** | 会议相关图片上传,支持 Markdown 引用 | ✅ 已完成 |
| **多会议知识模块** | 基于多会议摘要的知识总结 | ✅ 已完成 |
| **完整的对外接口** | 提供基于PC客户端、手机客户端、专用设备的服务接口 | ✅ 已完成 |
| | | |
| *待扩展功能* | *以下功能将在后续版本中添加* | |
| **对话模式的M-Agent** | 对话模式的会议Agent| |
| **平台多租户**| 云平台支持多租户| |
### PC客户端功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
| **用户登录** | 登录界面、Token 存储、自动登录 | ✅ 已完成 |
| **会议列表** | 会议展示、筛选、搜索 | ✅ 已完成 |
| **会议采集** | 支持快速会议和自定义会议 | ✅ 已完成 |
| **导出功能** | 会议纪要预览 | ✅ 已完成 |
| **响应式设计** | 适配不同屏幕尺寸 | ✅ 已完成 |
| | | |
| *待扩展功能* | *以下功能将在后续版本中添加* | |
| **声纹采集界面** | 声纹录制、上传、状态管理 | |
| **会议总结概览** | 获取按会议ID的总结一览| |
### 手机客户端功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
## 快速开始
### 环境要求
- Node.js 22.12+
- Python 3.12+
- MySQL 5.7+
- Redis 5.0+
- Docker (可选)
### 安装与运行
#### 后端启动
```bash
cd backend
pip install -r requirements.txt
python app/main.py
```
默认运行在 `http://localhost:8000`
#### 前端启动
```bash
cd frontend
npm install
npm run dev
```
默认运行在 `http://localhost:5173`
#### 使用 Conda 一键启动前后端
项目根目录提供了 Conda 启动脚本,会分别创建并使用独立环境启动前后端:
- 后端环境: `imetting_backend` (Python 3.12)
- 前端环境: `imetting_frontend` (Node.js 22)
```bash
./start-conda.sh
```
### 配置说明
详细的配置文档请参考:
- 数据库设计: [database.md](./database.md)
- 项目详细设计: [project.md](./project.md)
- AI 集成文档: [AI.md](./AI.md)
## 核心特性
### 异步任务处理
系统采用异步任务架构,支持大文件和长时间处理:
- **语音转录任务**: 基于阿里云 Dashscope 的异步 API,支持任务状态追踪
- **AI 摘要任务**: 使用 FastAPI BackgroundTasks,支持进度更新和轮询
### 数据安全
- JWT Token 认证机制
- 基于角色的权限控制 (RBAC)
- 密码 bcrypt 加密
- Token 黑名单机制
### 高性能
- Redis 缓存任务状态
- 异步处理避免阻塞
- 分页查询优化
- 音频流式传输
## API 文档
启动后端服务后,访问以下地址查看 API 文档:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## 未来规划
- [ ] 实时转录 - 支持对正在进行的会议进行实时语音转文字
- [ ] 日历集成 - 与 Google Calendar、Outlook Calendar 集成
- [ ] 行动项提取 - AI 自动识别会议中的待办事项
- [ ] 跨会议搜索 - 对所有会议内容进行全文语义搜索
- [ ] 移动端应用 - 开发 iOS 和 Android 原生应用
- [ ] 多语言支持 - 支持中英文等多语言界面
- [ ] 会议协作 - 支持多人实时协作编辑会议纪要
- [ ] 数据分析 - 会议统计分析和可视化报表
## 许可证
[请添加许可证信息]
## 联系方式
[请添加联系方式]

View File

@ -1,908 +0,0 @@
# iMeeting 产品路线图
## 项目概述
iMeeting 是一个智能会议管理和知识库系统,旨在通过 AI 技术提升会议效率,自动生成会议纪要,并从多次会议中提炼知识。
---
## Phase 1: 基础会议管理与知识库系统(已完成)
### 核心功能
#### 1. 会议管理
- **会议创建与录音**:支持创建会议并关联音频文件
- **音频转录**:基于阿里云 DashScope 的语音识别,支持异步转录
- **AI 总结生成**:使用大语言模型(通义千问)自动生成会议纪要
- **提示词模版**:支持预定义和自定义提示词模版,适配不同会议场景
- **标签系统**:支持为会议添加标签,方便分类和检索
#### 2. 知识库系统
- **基于会议的知识提炼**:从多个会议纪要中提炼知识库内容
- **自定义提示词**:用户可以指定特定的分析角度和总结需求
- **Markdown 输出**:知识库内容以 Markdown 格式存储和展示
- **标签关联**:支持知识库的标签分类
- **共享机制**:支持知识库的个人/共享模式
#### 3. 用户权限管理
- **用户认证**:基于 JWT 的用户登录与会话管理
- **角色权限**:区分普通用户和管理员
- **菜单权限**:动态菜单权限控制系统
#### 4. 管理后台
- **用户管理**:用户创建、编辑、删除、权限配置
- **系统监控**:在线用户、任务监控、系统资源监控
- **提示词仓库**:集中管理所有提示词模版
### 技术架构
#### 后端技术栈
- **框架**FastAPIPython
- **数据库**MySQL 8.0
- **缓存**Redis 5.0+
- **AI 服务**:阿里云 DashScope通义千问
- **对象存储**:七牛云 OSS
- **异步任务**FastAPI BackgroundTasks
#### 前端技术栈
- **框架**React 18
- **路由**React Router
- **UI 组件**Ant Design + 自定义组件
- **Markdown 渲染**react-markdown
#### 数据模型(核心表)
- `users`:用户表
- `meetings`:会议表
- `knowledge_bases`:知识库表
- `prompts`:提示词模版表
- `transcription_tasks`:转录任务表
- `summary_tasks`:总结任务表
- `knowledge_base_tasks`:知识库生成任务表
- `tags`:标签表
---
## Phase 2: 知识库系统大升级(规划中)
### 升级目标
将知识库系统从单一的"会议纪要汇总"升级为功能完整的"AI 知识助手",参考 NotebookLM 的交互模式,提供多维度的知识管理和音频播客生成能力。
---
### 1. 输入来源扩展
#### 1.1 功能需求
- ✅ **会议来源**(已支持):从多个会议纪要中提炼
- 🆕 **外部文件上传**
- 支持 PDF、Word、TXT、Markdown 文件
- 支持音频文件MP3、WAV、M4A
- 支持视频文件MP4、AVI、MOV提取音频
- 单个知识库可混合多种来源
#### 1.2 技术方案
##### 文档解析
```python
# 依赖库选型
- PDF: PyPDF2 / pdfplumber文本提取
- Word: python-docxDOCX 文件)
- Markdown: 直接读取
- TXT: 直接读取支持多种编码UTF-8、GBK
```
**技术评估**
- ✅ **可行性**Python 生态成熟
- ⚠️ **挑战**
- PDF 中的表格、图片识别(可选用 OCR
- 大文件处理(需要分块上传和处理)
- 💰 **成本**:无额外费用,依赖开源库
##### 音频/视频处理
```python
# 依赖库选型
- 音频提取: ffmpeg-python视频转音频
- 音频格式转换: pydub
```
**技术评估**
- ✅ **可行性**:高,现有转录流程可复用
- ⚠️ **挑战**
- 视频文件较大,需要优化存储和处理
- 需要增加进度反馈
- 💰 **成本**
- 视频存储成本增加(七牛云存储费用)
- 转录费用与现有一致(按时长计费)
##### 数据库设计
```sql
-- 新增表:知识库来源文件表
CREATE TABLE kb_source_files (
file_id INT AUTO_INCREMENT PRIMARY KEY,
kb_id INT NOT NULL COMMENT '关联的知识库ID',
file_type ENUM('pdf', 'docx', 'txt', 'markdown', 'audio', 'video') NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_url VARCHAR(512) NOT NULL COMMENT '文件存储URL',
file_size BIGINT COMMENT '文件大小(字节)',
extracted_text TEXT COMMENT '提取的文本内容',
upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kb_id) REFERENCES knowledge_bases(kb_id) ON DELETE CASCADE
);
-- 修改知识库表
ALTER TABLE knowledge_bases
ADD COLUMN source_type ENUM('meetings', 'files', 'mixed') DEFAULT 'meetings' COMMENT '内容来源类型';
```
---
### 2. 提示词模版升级
#### 2.1 功能需求
- ✅ **选择模版**(已支持):从提示词仓库选择预设模版
- 🆕 **附加提示词**:在模版基础上追加自定义需求
- 🆕 **结构化输出**:生成标题 + 多级内容(章节、小节)
- 🆕 **输出格式控制**支持指定输出结构JSON Schema
#### 2.2 技术方案
##### 提示词组合
```python
def build_knowledge_prompt(
template_prompt: str, # 基础模版
additional_prompt: str, # 附加提示词
output_format: dict # 输出格式控制
) -> str:
"""
组合提示词策略:
1. 基础模版(定义分析维度和风格)
2. 附加提示词(用户自定义需求)
3. 输出格式要求(结构化 JSON 或 Markdown
"""
return f"""
{template_prompt}
## 用户补充要求
{additional_prompt}
## 输出格式要求
请按照以下结构输出Markdown 格式):
# 标题
## 章节1
### 小节1.1
内容...
### 小节1.2
内容...
## 章节2
...
"""
```
**技术评估**
- ✅ **可行性**:高,现有提示词系统可直接扩展
- 📊 **优化方向**
- 使用通义千问的 `response_format` 参数实现结构化输出
- 提供可视化的输出结构编辑器
- 💰 **成本**:无额外成本
##### 结构化输出(通义千问支持)
```python
# 通义千问支持 JSON Schema 格式控制
response_format = {
"type": "json_schema",
"json_schema": {
"name": "knowledge_base_output",
"schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"section_title": {"type": "string"},
"subsections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subsection_title": {"type": "string"},
"content": {"type": "string"}
}
}
}
}
}
}
}
}
}
}
```
---
### 3. 音频概览生成TTS 播客)
#### 3.1 功能需求
- 🆕 **文字转语音**:将知识库内容转换为音频
- 🆕 **播客式对话**(类似 NotebookLM
- 双人对话形式(主持人 + 嘉宾)
- 自然的问答式讲解
- 语气生动,易于理解
#### 3.2 技术方案
##### 方案一:基础 TTS快速实现
**服务选型**
```python
# 1. 阿里云语音合成(推荐)
- 优势:已集成 DashScope音质自然支持多音色
- 成本:约 0.002 元/次100字符
- 特性:支持 SSML 标签控制语速、停顿
# 2. 微软 Azure TTS
- 优势:音质最佳,支持神经网络语音
- 成本:约 $4/百万字符
- 特性:支持情感控制、多语言
# 3. OpenAI TTS
- 优势:音质优秀,与 GPT 集成度高
- 成本:约 $15/百万字符
- 特性6 种不同音色可选
```
**实现流程**
```python
async def generate_basic_audio(kb_content: str) -> str:
"""
基础 TTS 实现
1. 文本预处理(分段、去除特殊字符)
2. 调用 TTS API
3. 音频文件合并
4. 上传到七牛云
"""
# 分段处理(避免单次请求过长)
segments = split_text_by_paragraphs(kb_content, max_length=500)
audio_files = []
for segment in segments:
audio = await tts_service.synthesize(
text=segment,
voice="zhichu", # 阿里云音色
rate=1.0, # 语速
pitch=0 # 音调
)
audio_files.append(audio)
# 合并音频
final_audio = merge_audio_files(audio_files)
audio_url = upload_to_qiniu(final_audio, "kb_audio")
return audio_url
```
**技术评估**
- ✅ **可行性**:高,实现简单
- ⚠️ **局限性**
- 单调朗读,无对话感
- 缺乏停顿和情感
- 💰 **成本**
- 阿里云 TTS约 0.02 元/千字
- 10 万字知识库:约 2 元/次
---
##### 方案二播客式对话NotebookLM 风格)
**技术架构**
```
输入: 知识库内容 (Markdown)
1. 内容理解与脚本生成
- 使用 LLM 将内容改写为对话脚本
- 双人角色:主持人(引导)+ 专家(讲解)
2. 对话脚本优化
- 添加开场白和结束语
- 插入自然的停顿和过渡
- 添加情感标记SSML
3. 多音色 TTS 合成
- 主持人音色:女声,温和亲切
- 专家音色:男声,稳重专业
4. 音频后处理
- 添加背景音乐(轻音乐)
- 音量均衡化
- 添加片头片尾
输出: 播客式音频文件 (MP3)
```
**LLM 脚本生成示例**
```python
PODCAST_SCRIPT_PROMPT = """
你是一个播客制作专家。请将以下知识库内容改写为一段双人对话脚本:
- 主持人Host提出问题引导话题语气轻松友好
- 专家Expert讲解内容回答问题语气专业自信
要求:
1. 开场白介绍今天的主题
2. 通过问答形式逐步展开知识点
3. 加入自然的过渡语("那么..."、"接下来..."
4. 结束时做简短总结
知识库内容:
{kb_content}
请输出 JSON 格式:
{{
"title": "播客标题",
"intro": {{
"host": "开场白...",
"expert": "回应..."
}},
"dialogues": [
{{"speaker": "host", "text": "问题1..."}},
{{"speaker": "expert", "text": "解答1..."}},
...
],
"outro": {{
"host": "总结...",
"expert": "结束语..."
}}
}}
"""
async def generate_podcast_script(kb_content: str) -> dict:
"""使用 LLM 生成播客脚本"""
response = await llm_service.call(
prompt=PODCAST_SCRIPT_PROMPT.format(kb_content=kb_content),
response_format={"type": "json_object"}
)
return json.loads(response)
async def synthesize_podcast(script: dict) -> str:
"""合成播客音频"""
audio_segments = []
# 片头音乐
audio_segments.append(load_intro_music())
# 开场白
for speaker, text in script["intro"].items():
voice = VOICES[speaker] # {"host": "xiaoyun", "expert": "zhichu"}
audio = await tts_service.synthesize(text, voice=voice)
audio_segments.append(audio)
audio_segments.append(silence(duration=0.5)) # 停顿 0.5 秒
# 主体对话
for dialogue in script["dialogues"]:
speaker = dialogue["speaker"]
text = dialogue["text"]
voice = VOICES[speaker]
audio = await tts_service.synthesize(text, voice=voice)
audio_segments.append(audio)
audio_segments.append(silence(duration=0.3))
# 结束语
for speaker, text in script["outro"].items():
voice = VOICES[speaker]
audio = await tts_service.synthesize(text, voice=voice)
audio_segments.append(audio)
# 片尾音乐
audio_segments.append(load_outro_music())
# 合并所有音频
final_audio = merge_with_background_music(audio_segments)
# 上传到七牛云
audio_url = upload_to_qiniu(final_audio, "kb_podcast")
return audio_url
```
**技术评估**
- ✅ **可行性**:中等
- LLM 脚本生成:成熟方案
- 多音色 TTS阿里云、Azure 均支持
- 音频后处理:需要 ffmpeg 或 pydub
- ⚠️ **挑战**
- 脚本质量依赖 LLM prompt 优化
- 音频合成时长较长(需要异步处理)
- 音频文件较大,存储成本增加
- 💰 **成本**(以 5000 字知识库为例):
- LLM 脚本生成(输入 5000 字 + 输出 3000 字):约 0.05 元
- TTS 合成(约 3000 字对话):约 0.06 元
- **总成本:约 0.11 元/次**
**音频处理库**
```python
# 新增依赖
ffmpeg-python==0.2.0 # 音频处理
pydub==0.25.1 # 音频编辑
```
---
##### 方案对比
| 特性 | 方案一:基础 TTS | 方案二:播客对话 |
|------|------------------|------------------|
| **实现难度** | 低1-2 天) | 中3-5 天) |
| **音频质量** | 单调朗读 | 自然对话 |
| **用户体验** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **成本** | 0.02 元/千字 | 0.11 元/5000 字 |
| **适用场景** | 快速阅读 | 深度学习 |
**推荐方案**
- 🚀 **Phase 2.1**:先实现方案一(基础 TTS快速上线
- 🎯 **Phase 2.2**:再实现方案二(播客对话),提升体验
---
### 4. NotebookLM 式交互
#### 4.1 功能需求
- 🆕 **知识库问答**:基于知识库内容的智能问答
- 🆕 **引用溯源**:回答时显示来源(段落、页码)
- 🆕 **相关内容推荐**:基于用户提问推荐相关知识点
- 🆕 **多轮对话**:支持上下文记忆的连续对话
#### 4.2 技术方案
##### 方案一:基于向量数据库的 RAG推荐
**技术架构**
```
知识库内容
1. 文本分块 (Chunking)
- 按段落或语义分块500-1000 字/块)
- 保留元数据(来源、章节、页码)
2. 向量化 (Embedding)
- 使用阿里云通义千问 Embedding API
- 或使用 OpenAI text-embedding-3-small
3. 存储到向量数据库
- Milvus开源功能强大
- 或 Qdrant轻量易部署
- 或 PostgreSQL + pgvector复用现有数据库
4. 用户提问
5. 向量检索
- 将问题向量化
- 检索 Top-K 相似内容块
6. LLM 生成回答
- 将检索结果作为上下文
- 生成准确回答 + 来源引用
```
**实现示例**
```python
from dashscope import TextEmbedding
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
# 1. 向量化服务
class EmbeddingService:
def embed_text(self, text: str) -> List[float]:
"""将文本转换为向量"""
response = TextEmbedding.call(
model='text-embedding-v3',
input=text
)
return response.output['embeddings'][0]['embedding']
# 2. 向量数据库
class VectorStore:
def __init__(self):
self.client = QdrantClient(host="localhost", port=6333)
def create_collection(self, kb_id: int):
"""为知识库创建向量集合"""
self.client.create_collection(
collection_name=f"kb_{kb_id}",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE)
)
def add_chunks(self, kb_id: int, chunks: List[dict]):
"""添加文本块到向量库"""
points = []
for i, chunk in enumerate(chunks):
embedding = embedding_service.embed_text(chunk['text'])
points.append(PointStruct(
id=i,
vector=embedding,
payload={
"text": chunk['text'],
"source": chunk['source'],
"section": chunk['section']
}
))
self.client.upsert(collection_name=f"kb_{kb_id}", points=points)
def search(self, kb_id: int, query: str, top_k: int = 3):
"""搜索相似内容"""
query_vector = embedding_service.embed_text(query)
results = self.client.search(
collection_name=f"kb_{kb_id}",
query_vector=query_vector,
limit=top_k
)
return results
# 3. RAG 问答
async def answer_question(kb_id: int, question: str, chat_history: List[dict] = None):
"""基于知识库回答问题"""
# 检索相关内容
results = vector_store.search(kb_id, question, top_k=3)
# 构建上下文
context = "\n\n".join([
f"[来源: {r.payload['section']}]\n{r.payload['text']}"
for r in results
])
# 构建 prompt
prompt = f"""
基于以下知识库内容回答用户问题。如果无法从内容中找到答案,请明确告知。
知识库内容:
{context}
用户问题:{question}
请提供准确的回答,并在回答中标注来源章节。
"""
# 调用 LLM
response = await llm_service.call(prompt, chat_history=chat_history)
# 返回回答 + 来源
return {
"answer": response,
"sources": [
{"section": r.payload['section'], "text": r.payload['text'][:200]}
for r in results
]
}
```
**技术评估**
- ✅ **可行性**:高
- 向量数据库Qdrant轻量级或 pgvector复用 MySQL
- Embedding阿里云通义千问 Embedding API0.0007 元/千 tokens
- 📊 **优势**
- 回答准确性高(基于实际内容)
- 支持引用溯源
- 检索速度快(毫秒级)
- 💰 **成本**(以 10 万字知识库为例):
- 向量化:约 0.07 元(一次性)
- 每次问答检索:免费(本地向量库)
- LLM 生成:约 0.01 元/次
---
##### 方案二:基于 LLM 长上下文(备选)
**技术方案**
```python
async def answer_with_full_context(kb_content: str, question: str):
"""直接将整个知识库作为上下文"""
prompt = f"""
你是一个知识库助手。请基于以下知识库内容回答用户问题。
知识库内容:
{kb_content}
用户问题:{question}
请提供准确的回答,并标注引用的段落。
"""
response = await llm_service.call(prompt)
return response
```
**技术评估**
- ✅ **可行性**:中等
- 通义千问支持 128K tokens 上下文
- 约 10 万字中文
- ⚠️ **局限性**
- 大文本上下文成本高
- 无法精确定位来源
- 不支持多知识库联合查询
- 💰 **成本**
- 每次问答10 万字上下文):约 0.15 元
- **比 RAG 方案贵 10 倍以上**
**推荐方案****方案一RAG** 为主,方案二作为小规模知识库的快速方案。
---
##### 数据库设计
```sql
-- 知识库对话历史表
CREATE TABLE kb_chat_history (
chat_id INT AUTO_INCREMENT PRIMARY KEY,
kb_id INT NOT NULL COMMENT '关联知识库ID',
user_id INT NOT NULL COMMENT '用户ID',
question TEXT NOT NULL COMMENT '用户提问',
answer TEXT NOT NULL COMMENT 'AI回答',
sources JSON COMMENT '回答来源JSON数组',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kb_id) REFERENCES knowledge_bases(kb_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_kb_user (kb_id, user_id),
INDEX idx_created_at (created_at)
);
-- 知识库向量块表
CREATE TABLE kb_text_chunks (
chunk_id INT AUTO_INCREMENT PRIMARY KEY,
kb_id INT NOT NULL COMMENT '关联知识库ID',
chunk_text TEXT NOT NULL COMMENT '文本块内容',
chunk_metadata JSON COMMENT '元数据(章节、页码等)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kb_id) REFERENCES knowledge_bases(kb_id) ON DELETE CASCADE,
INDEX idx_kb_id (kb_id)
);
```
---
### 5. 前端交互升级
#### 5.1 新增页面/组件
```
知识库详情页
├── 📄 内容展示区
│ ├── Markdown 渲染(已有)
│ └── 音频播放器(新增)
│ ├── 播放/暂停
│ ├── 进度条
│ ├── 倍速调节
│ └── 下载音频
├── 💬 智能问答区(新增)
│ ├── 对话输入框
│ ├── 对话历史列表
│ ├── 来源引用卡片
│ └── 相关问题推荐
└── 📊 知识库信息
├── 来源文件列表(新增)
└── 标签、创建时间等(已有)
```
#### 5.2 技术实现
```jsx
// 音频播放器组件
import ReactAudioPlayer from 'react-audio-player';
const KnowledgeAudioPlayer = ({ audioUrl }) => {
return (
<div className="audio-player-container">
<h3>🎧 音频概览</h3>
<ReactAudioPlayer
src={audioUrl}
controls
autoPlay={false}
controlsList="nodownload"
/>
</div>
);
};
// 智能问答组件
const KnowledgeChatbot = ({ kbId }) => {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const handleAsk = async () => {
setLoading(true);
const response = await apiClient.post(`/knowledge-bases/${kbId}/ask`, {
question: input,
chat_history: messages
});
setMessages([
...messages,
{ role: 'user', content: input },
{
role: 'assistant',
content: response.data.answer,
sources: response.data.sources
}
]);
setInput('');
setLoading(false);
};
return (
<div className="chatbot-container">
<div className="chat-history">
{messages.map((msg, idx) => (
<div key={idx} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
{msg.sources && (
<div className="message-sources">
<h4>📚 来源</h4>
{msg.sources.map((src, i) => (
<div key={i} className="source-card">
<strong>{src.section}</strong>
<p>{src.text}...</p>
</div>
))}
</div>
)}
</div>
))}
</div>
<div className="chat-input">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="向知识库提问..."
onKeyPress={(e) => e.key === 'Enter' && handleAsk()}
/>
<button onClick={handleAsk} disabled={loading}>
{loading ? '思考中...' : '发送'}
</button>
</div>
</div>
);
};
```
---
## Phase 2 技术依赖汇总
### 新增 Python 依赖
```txt
# 文档解析
PyPDF2==3.0.1
pdfplumber==0.10.3
python-docx==1.1.0
# 音频处理
ffmpeg-python==0.2.0
pydub==0.25.1
# 向量数据库(二选一)
qdrant-client==1.7.0 # 推荐:轻量级向量库
# 或
pgvector==0.2.3 # 备选PostgreSQL 扩展
# 阿里云 Embedding可选通义千问已集成
# 无需新增依赖,使用现有 dashscope
```
### 新增前端依赖
```json
{
"react-audio-player": "^0.17.0",
"react-markdown": "^9.0.0" // 已有
}
```
### 基础设施
- **向量数据库**QdrantDocker 部署)
```bash
docker run -p 6333:6333 qdrant/qdrant
```
- **音频处理**FFmpeg系统依赖
```bash
apt-get install ffmpeg # Ubuntu
brew install ffmpeg # macOS
```
---
## 成本预估Phase 2
### 开发成本
| 模块 | 工作量(人天) | 说明 |
|------|---------------|------|
| 文件上传与解析 | 3-5 天 | PDF、Word、音频解析 |
| 提示词升级 | 2-3 天 | 附加提示词、结构化输出 |
| 基础 TTS | 2-3 天 | 文字转语音 |
| 播客对话(可选) | 5-7 天 | LLM 脚本生成 + 多音色合成 |
| RAG 问答系统 | 5-7 天 | 向量化、检索、对话 |
| 前端交互升级 | 3-5 天 | 音频播放器、聊天界面 |
| **总计** | **20-30 天** | 约 1-1.5 个月 |
### 运营成本(月)
| 项目 | 预估用量 | 单价 | 月成本 |
|------|---------|------|--------|
| **阿里云 TTS** | 100 万字 | 0.02 元/千字 | 20 元 |
| **LLM 脚本生成** | 50 次5000 字/次) | 0.05 元/次 | 2.5 元 |
| **Embedding 向量化** | 100 万字 | 0.0007 元/千 tokens | 0.7 元 |
| **LLM 问答** | 1000 次 | 0.01 元/次 | 10 元 |
| **七牛云存储(音频)** | 10 GB | 0.148 元/GB/天 | 44 元 |
| **Qdrant 服务器** | 1 核 2G | - | 0 元(自建) |
| **总计** | - | - | **约 77 元/月** |
*注:基于中小规模使用场景(月活 100-500 用户)*
---
## 技术风险与缓解措施
### 风险 1向量数据库性能
- **风险**:大规模知识库检索延迟
- **缓解**
- 使用 Qdrant 的分片和副本
- 限制单次检索 Top-K 数量
- 增加缓存层Redis
### 风险 2播客生成质量
- **风险**LLM 生成的对话脚本不自然
- **缓解**
- 提供多个脚本模版供用户选择
- 支持用户编辑脚本后再合成
- 引入少样本学习Few-shot优化 prompt
### 风险 3文件解析准确性
- **风险**PDF、Word 解析失败或乱码
- **缓解**
- 提供预览功能,让用户确认提取结果
- 支持手动文本粘贴作为备选
- 增加 OCR 处理扫描件 PDF
### 风险 4成本控制
- **风险**TTS 和 LLM 调用费用超预算
- **缓解**
- 设置单用户每日调用限额
- 音频生成采用队列异步处理
- 提供缓存机制避免重复生成
---
## 实施建议
### Phase 2.1基础版2-3 周)
**优先级高**
1. 文件上传与解析PDF、Word、TXT
2. 基础 TTS 音频生成
3. 提示词附加功能
### Phase 2.2进阶版3-4 周)
🎯 **优先级中**
1. RAG 问答系统(向量检索)
2. 播客式对话生成
3. 前端聊天界面
### Phase 2.3优化版2-3 周)
🔧 **优先级低**
1. 音频后处理(背景音乐、片头片尾)
2. 多轮对话记忆
3. 相关问题推荐
---
## 总结
Phase 2 升级将把 iMeeting 知识库系统从"被动阅读"升级为"主动交互",核心亮点包括:
1. ✅ **多源输入**:会议 + 文件 + 音视频
2. 🎙️ **音频播客**:类 NotebookLM 的对话式音频
3. 💬 **智能问答**:基于 RAG 的精准问答
4. 📊 **结构化输出**:自动生成多级章节
**技术可行性**:高(基于成熟技术栈)
**成本可控性**:高(月运营成本 < 100
**开发周期**1-1.5 个月20-30 人天)
建议采用**分阶段迭代**方式,先上线基础版(文件上传 + 基础 TTS快速验证用户需求再逐步完善高级功能播客对话 + RAG 问答)。

View File

@ -23,15 +23,13 @@ class ParameterUpsertRequest(BaseModel):
class LLMModelUpsertRequest(BaseModel): class LLMModelUpsertRequest(BaseModel):
model_code: str model_code: str
model_name: str model_name: str
model_type: str = "text"
provider: str | None = None provider: str | None = None
endpoint_url: str | None = None endpoint_url: str | None = None
api_key: str | None = None api_key: str | None = None
llm_model_name: str llm_model_name: str
llm_timeout: int = 120 llm_timeout: int = 120
llm_temperature: float = 0.7 extra_config: dict[str, Any] | None = None
llm_top_p: float = 0.9
llm_max_tokens: int = 2048
llm_system_prompt: str | None = None
description: str | None = None description: str | None = None
is_active: bool = True is_active: bool = True
is_default: bool = False is_default: bool = False
@ -40,13 +38,12 @@ class LLMModelUpsertRequest(BaseModel):
class AudioModelUpsertRequest(BaseModel): class AudioModelUpsertRequest(BaseModel):
model_code: str model_code: str
model_name: str model_name: str
audio_scene: str model_type: str
provider: str | None = None provider: str | None = None
endpoint_url: str | None = None endpoint_url: str | None = None
api_key: str | None = None api_key: str | None = None
request_timeout_seconds: int = 300 request_timeout_seconds: int = 300
extra_config: dict[str, Any] | None = None extra_config: dict[str, Any] | None = None
hot_word_group_id: int | None = None
description: str | None = None description: str | None = None
is_active: bool = True is_active: bool = True
is_default: bool = False is_default: bool = False

View File

@ -115,20 +115,22 @@ async def update_group(id: int, request: UpdateGroupRequest, current_user: dict
@router.delete("/admin/hot-word-groups/{id}", response_model=dict) @router.delete("/admin/hot-word-groups/{id}", response_model=dict)
async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)): async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)):
"""删除组(级联删除条目),同时清除关联的 audio_model_config""" """删除组(级联删除条目),同时清除引用该词表ID的音频模型配置"""
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor(dictionary=True)
# 清除引用该组的音频模型配置 cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (id,))
cursor.execute( group_row = cursor.fetchone()
""" vocabulary_id = group_row.get("vocabulary_id") if group_row else None
UPDATE audio_model_config if vocabulary_id:
SET hot_word_group_id = NULL, cursor.execute(
extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id') """
WHERE hot_word_group_id = %s UPDATE audio_model_config
""", SET extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id')
(id,), WHERE JSON_UNQUOTE(JSON_EXTRACT(extra_config, '$.vocabulary_id')) = %s
) """,
(vocabulary_id,),
)
cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,)) cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,))
cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,)) cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,))
conn.commit() conn.commit()
@ -196,16 +198,6 @@ async def sync_group(id: int, current_user: dict = Depends(get_current_admin_use
(vocab_id, id), (vocab_id, id),
) )
# 更新关联该组的所有 audio_model_config.extra_config.vocabulary_id
cursor.execute(
"""
UPDATE audio_model_config
SET extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s)
WHERE hot_word_group_id = %s
""",
(vocab_id, id),
)
conn.commit() conn.commit()
cursor.close() cursor.close()
return create_api_response( return create_api_response(

View File

@ -3,7 +3,7 @@ from typing import Optional
import traceback import traceback
from app.core.auth import get_current_admin_user from app.core.auth import get_current_admin_user
from app.core.response import create_api_response from app.core.response import create_api_response
from app.models.models import CreateTerminalRequest, UpdateTerminalRequest from app.models.models import UpdateTerminalRequest
from app.services.terminal_service import terminal_service from app.services.terminal_service import terminal_service
router = APIRouter() router = APIRouter()
@ -45,32 +45,6 @@ async def get_terminals(
message=f"获取终端列表失败: {str(e)}" message=f"获取终端列表失败: {str(e)}"
) )
@router.post("/terminals", response_model=dict)
async def create_terminal(
request: CreateTerminalRequest,
current_user: dict = Depends(get_current_admin_user)
):
"""
创建新终端设备
"""
try:
# 检查IMEI是否存在
existing = terminal_service.get_terminal_by_imei(request.imei)
if existing:
return create_api_response(code="400", message=f"IMEI {request.imei} 已存在")
terminal_id = terminal_service.create_terminal(request, current_user['user_id'])
return create_api_response(
code="200",
message="创建成功",
data={"id": terminal_id}
)
except Exception as e:
return create_api_response(
code="500",
message=f"创建终端失败: {str(e)}"
)
@router.get("/terminals/{terminal_id}", response_model=dict) @router.get("/terminals/{terminal_id}", response_model=dict)
async def get_terminal_detail( async def get_terminal_detail(
terminal_id: int, terminal_id: int,

View File

@ -28,7 +28,7 @@ class MCPPathNormalizeMiddleware:
class TerminalCheckMiddleware(BaseHTTPMiddleware): class TerminalCheckMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行 # 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行
imei = request.headers.get("Imei") imei = (request.headers.get("Imei") or "").strip()
if not imei: if not imei:
return await call_next(request) return await call_next(request)
@ -53,9 +53,9 @@ class TerminalCheckMiddleware(BaseHTTPMiddleware):
pass pass
# 3. 提取其他设备信息 # 3. 提取其他设备信息
device_type = request.headers.get("deviceType", "UNKNOWN") device_type = (request.headers.get("deviceType") or "UNKNOWN").strip() or "UNKNOWN"
# device_info 可能是 "UNIS iMeeting a7" # device_info 可能是 "UNIS iMeeting a7"
device_info = request.headers.get("deviceInfo", "Unknown Device") device_info = (request.headers.get("deviceInfo") or "Unknown Device").strip() or "Unknown Device"
# 获取客户端IP (考虑代理) # 获取客户端IP (考虑代理)
client_ip = request.client.host client_ip = request.client.host

View File

@ -116,7 +116,7 @@ def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]:
def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict[str, Any]: def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict[str, Any]:
extra_config = _parse_json_object(request.extra_config) extra_config = _parse_json_object(request.extra_config)
if request.audio_scene == "asr": if request.model_type == "asr":
if vocabulary_id: if vocabulary_id:
extra_config["vocabulary_id"] = vocabulary_id extra_config["vocabulary_id"] = vocabulary_id
else: else:
@ -135,6 +135,47 @@ def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict
return _clean_extra_config(merged) return _clean_extra_config(merged)
def _merge_llm_extra_config(request) -> dict[str, Any]:
extra_config = _parse_json_object(request.extra_config)
if request.model_type == "vector":
dimensions = extra_config.get("dimensions")
try:
if dimensions is not None:
extra_config["dimensions"] = int(dimensions)
except (TypeError, ValueError):
extra_config.pop("dimensions", None)
else:
if "temperature" in extra_config:
try:
extra_config["temperature"] = float(extra_config["temperature"])
except (TypeError, ValueError):
extra_config.pop("temperature", None)
if "top_p" in extra_config:
try:
extra_config["top_p"] = float(extra_config["top_p"])
except (TypeError, ValueError):
extra_config.pop("top_p", None)
if "max_tokens" in extra_config:
try:
extra_config["max_tokens"] = int(extra_config["max_tokens"])
except (TypeError, ValueError):
extra_config.pop("max_tokens", None)
return _clean_extra_config(extra_config)
def _normalize_llm_row(row: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(row.get("extra_config"))
row["model_type"] = row.get("model_type") or "text"
row["extra_config"] = extra_config
row["llm_temperature"] = extra_config.get("temperature")
row["llm_top_p"] = extra_config.get("top_p")
row["llm_max_tokens"] = extra_config.get("max_tokens")
row["llm_system_prompt"] = extra_config.get("system_prompt")
return row
def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]: def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(row.get("extra_config")) extra_config = _parse_json_object(row.get("extra_config"))
@ -144,14 +185,9 @@ def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]:
return row return row
def _resolve_hot_word_vocabulary_id(cursor, request) -> str | None: def _resolve_audio_vocabulary_id(request) -> str | None:
vocabulary_id = _parse_json_object(request.extra_config).get("vocabulary_id") vocabulary_id = _parse_json_object(request.extra_config).get("vocabulary_id")
if request.hot_word_group_id: return str(vocabulary_id).strip() if vocabulary_id else None
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
group_row = cursor.fetchone()
if group_row and group_row.get("vocabulary_id"):
vocabulary_id = group_row["vocabulary_id"]
return vocabulary_id
def list_parameters(category: str | None = None, keyword: str | None = None): def list_parameters(category: str | None = None, keyword: str | None = None):
@ -316,13 +352,13 @@ def list_llm_model_configs():
cursor.execute( cursor.execute(
""" """
SELECT config_id, model_code, model_name, provider, endpoint_url, api_key, SELECT config_id, model_code, model_name, provider, endpoint_url, api_key,
llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_model_name, llm_timeout, model_type, extra_config, description, is_active, is_default,
llm_system_prompt, description, is_active, is_default, created_at, updated_at created_at, updated_at
FROM llm_model_config FROM llm_model_config
ORDER BY model_code ASC ORDER BY model_type ASC, model_code ASC
""" """
) )
rows = cursor.fetchall() rows = [_normalize_llm_row(row) for row in cursor.fetchall()]
return create_api_response( return create_api_response(
code="200", code="200",
message="获取LLM模型配置成功", message="获取LLM模型配置成功",
@ -334,38 +370,40 @@ def list_llm_model_configs():
def create_llm_model_config(request): def create_llm_model_config(request):
try: try:
if request.model_type not in ("text", "vector"):
return create_api_response(code="400", message="model_type 仅支持 text 或 vector")
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,)) cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,))
if cursor.fetchone(): if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在") return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config") cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config WHERE model_type = %s", (request.model_type,))
total_row = cursor.fetchone() or {"total": 0} total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0 is_default = bool(request.is_default) or total_row["total"] == 0
if is_default: if is_default:
cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1") cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE model_type = %s AND is_default = 1", (request.model_type,))
extra_config = _merge_llm_extra_config(request)
cursor.execute( cursor.execute(
""" """
INSERT INTO llm_model_config INSERT INTO llm_model_config
(model_code, model_name, provider, endpoint_url, api_key, llm_model_name, (model_code, model_name, model_type, provider, endpoint_url, api_key, llm_model_name,
llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, llm_timeout, extra_config, description, is_active, is_default)
description, is_active, is_default) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
request.model_code, request.model_code,
request.model_name, request.model_name,
request.model_type,
request.provider, request.provider,
request.endpoint_url, request.endpoint_url,
request.api_key, request.api_key,
request.llm_model_name, request.llm_model_name,
request.llm_timeout, request.llm_timeout,
request.llm_temperature, json.dumps(extra_config, ensure_ascii=False),
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description, request.description,
1 if request.is_active else 0, 1 if request.is_active else 0,
1 if is_default else 0, 1 if is_default else 0,
@ -379,12 +417,17 @@ def create_llm_model_config(request):
def update_llm_model_config(model_code: str, request): def update_llm_model_config(model_code: str, request):
try: try:
if request.model_type not in ("text", "vector"):
return create_api_response(code="400", message="model_type 仅支持 text 或 vector")
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) cursor.execute("SELECT config_id, model_type FROM llm_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone() existed = cursor.fetchone()
if not existed: if not existed:
return create_api_response(code="404", message="模型配置不存在") return create_api_response(code="404", message="模型配置不存在")
if request.model_type != (existed.get("model_type") or "text"):
return create_api_response(code="400", message="修改模型时不能变更模型类型")
new_model_code = request.model_code or model_code new_model_code = request.model_code or model_code
if new_model_code != model_code: if new_model_code != model_code:
@ -395,16 +438,18 @@ def update_llm_model_config(model_code: str, request):
if request.is_default: if request.is_default:
cursor.execute( cursor.execute(
"UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", "UPDATE llm_model_config SET is_default = 0 WHERE model_type = %s AND model_code <> %s AND is_default = 1",
(model_code,), (request.model_type, model_code),
) )
extra_config = _merge_llm_extra_config(request)
cursor.execute( cursor.execute(
""" """
UPDATE llm_model_config UPDATE llm_model_config
SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s, SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s,
llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s, llm_model_name = %s, llm_timeout = %s, extra_config = %s, description = %s,
llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s is_active = %s, is_default = %s
WHERE model_code = %s WHERE model_code = %s
""", """,
( (
@ -415,10 +460,7 @@ def update_llm_model_config(model_code: str, request):
request.api_key, request.api_key,
request.llm_model_name, request.llm_model_name,
request.llm_timeout, request.llm_timeout,
request.llm_temperature, json.dumps(extra_config, ensure_ascii=False),
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description, request.description,
1 if request.is_active else 0, 1 if request.is_active else 0,
1 if request.is_default else 0, 1 if request.is_default else 0,
@ -436,18 +478,16 @@ def list_audio_model_configs(scene: str = "all"):
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
sql = """ sql = """
SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key, SELECT a.config_id, a.model_code, a.model_name, a.model_type, a.provider, a.endpoint_url, a.api_key,
a.request_timeout_seconds, a.hot_word_group_id, a.extra_config, a.request_timeout_seconds, a.extra_config,
a.description, a.is_active, a.is_default, a.created_at, a.updated_at, a.description, a.is_active, a.is_default, a.created_at, a.updated_at
g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id
FROM audio_model_config a FROM audio_model_config a
LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id
""" """
params = [] params = []
if scene in ("asr", "voiceprint"): if scene in ("asr", "voiceprint"):
sql += " WHERE a.audio_scene = %s" sql += " WHERE a.model_type = %s"
params.append(scene) params.append(scene)
sql += " ORDER BY a.audio_scene ASC, a.model_code ASC" sql += " ORDER BY a.model_type ASC, a.model_code ASC"
cursor.execute(sql, tuple(params)) cursor.execute(sql, tuple(params))
rows = [_normalize_audio_row(row) for row in cursor.fetchall()] rows = [_normalize_audio_row(row) for row in cursor.fetchall()]
return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)}) return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)})
@ -457,8 +497,8 @@ def list_audio_model_configs(scene: str = "all"):
def create_audio_model_config(request): def create_audio_model_config(request):
try: try:
if request.audio_scene not in ("asr", "voiceprint"): if request.model_type not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") return create_api_response(code="400", message="model_type 仅支持 asr 或 voiceprint")
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@ -466,34 +506,33 @@ def create_audio_model_config(request):
if cursor.fetchone(): if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在") return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,)) cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE model_type = %s", (request.model_type,))
total_row = cursor.fetchone() or {"total": 0} total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0 is_default = bool(request.is_default) or total_row["total"] == 0
if is_default: if is_default:
cursor.execute( cursor.execute(
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", "UPDATE audio_model_config SET is_default = 0 WHERE model_type = %s AND is_default = 1",
(request.audio_scene,), (request.model_type,),
) )
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) asr_vocabulary_id = _resolve_audio_vocabulary_id(request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
cursor.execute( cursor.execute(
""" """
INSERT INTO audio_model_config INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, endpoint_url, api_key, (model_code, model_name, model_type, provider, endpoint_url, api_key,
request_timeout_seconds, hot_word_group_id, extra_config, description, is_active, is_default) request_timeout_seconds, extra_config, description, is_active, is_default)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
request.model_code, request.model_code,
request.model_name, request.model_name,
request.audio_scene, request.model_type,
request.provider, request.provider,
request.endpoint_url, request.endpoint_url,
request.api_key, request.api_key,
request.request_timeout_seconds, request.request_timeout_seconds,
request.hot_word_group_id,
json.dumps(extra_config, ensure_ascii=False), json.dumps(extra_config, ensure_ascii=False),
request.description, request.description,
1 if request.is_active else 0, 1 if request.is_active else 0,
@ -508,15 +547,17 @@ def create_audio_model_config(request):
def update_audio_model_config(model_code: str, request): def update_audio_model_config(model_code: str, request):
try: try:
if request.audio_scene not in ("asr", "voiceprint"): if request.model_type not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") return create_api_response(code="400", message="model_type 仅支持 asr 或 voiceprint")
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) cursor.execute("SELECT config_id, model_type FROM audio_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone() existed = cursor.fetchone()
if not existed: if not existed:
return create_api_response(code="404", message="模型配置不存在") return create_api_response(code="404", message="模型配置不存在")
if request.model_type != existed["model_type"]:
return create_api_response(code="400", message="修改音频模型时不能变更场景类型")
new_model_code = request.model_code or model_code new_model_code = request.model_code or model_code
if new_model_code != model_code: if new_model_code != model_code:
@ -527,30 +568,29 @@ def update_audio_model_config(model_code: str, request):
if request.is_default: if request.is_default:
cursor.execute( cursor.execute(
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1", "UPDATE audio_model_config SET is_default = 0 WHERE model_type = %s AND model_code <> %s AND is_default = 1",
(request.audio_scene, model_code), (request.model_type, model_code),
) )
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) asr_vocabulary_id = _resolve_audio_vocabulary_id(request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
cursor.execute( cursor.execute(
""" """
UPDATE audio_model_config UPDATE audio_model_config
SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s, SET model_code = %s, model_name = %s, model_type = %s, provider = %s, endpoint_url = %s, api_key = %s,
request_timeout_seconds = %s, hot_word_group_id = %s, extra_config = %s, request_timeout_seconds = %s, extra_config = %s,
description = %s, is_active = %s, is_default = %s description = %s, is_active = %s, is_default = %s
WHERE model_code = %s WHERE model_code = %s
""", """,
( (
new_model_code, new_model_code,
request.model_name, request.model_name,
request.audio_scene, request.model_type,
request.provider, request.provider,
request.endpoint_url, request.endpoint_url,
request.api_key, request.api_key,
request.request_timeout_seconds, request.request_timeout_seconds,
request.hot_word_group_id,
json.dumps(extra_config, ensure_ascii=False), json.dumps(extra_config, ensure_ascii=False),
request.description, request.description,
1 if request.is_active else 0, 1 if request.is_active else 0,
@ -605,20 +645,16 @@ def test_llm_model_config(request):
def test_audio_model_config(request): def test_audio_model_config(request):
try: try:
if request.audio_scene != "asr": if request.model_type != "asr":
return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试") return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试")
with get_db_connection() as conn: vocabulary_id = _resolve_audio_vocabulary_id(request)
cursor = conn.cursor(dictionary=True)
vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id) extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id)
runtime_config = { runtime_config = {
"provider": request.provider, "provider": request.provider,
"endpoint_url": request.endpoint_url, "endpoint_url": request.endpoint_url,
"api_key": request.api_key, "api_key": request.api_key,
"audio_scene": request.audio_scene, "model_type": request.model_type,
"hot_word_group_id": request.hot_word_group_id,
"request_timeout_seconds": request.request_timeout_seconds, "request_timeout_seconds": request.request_timeout_seconds,
**extra_config, **extra_config,
} }

View File

@ -1084,7 +1084,12 @@ def list_active_llm_models(current_user: dict):
with get_db_connection() as connection: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cursor.execute( cursor.execute(
"SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC" """
SELECT model_code, model_name, provider, model_type, is_default
FROM llm_model_config
WHERE is_active = 1 AND model_type = 'text'
ORDER BY is_default DESC, model_code ASC
"""
) )
models = cursor.fetchall() models = cursor.fetchall()
return create_api_response(code="200", message="获取模型列表成功", data=models) return create_api_response(code="200", message="获取模型列表成功", data=models)

View File

@ -174,10 +174,8 @@ class SystemConfigService:
cfg["provider"] = audio_row["provider"] cfg["provider"] = audio_row["provider"]
if audio_row.get("model_code"): if audio_row.get("model_code"):
cfg["model_code"] = audio_row["model_code"] cfg["model_code"] = audio_row["model_code"]
if audio_row.get("audio_scene"): if audio_row.get("model_type"):
cfg["audio_scene"] = audio_row["audio_scene"] cfg["model_type"] = audio_row["model_type"]
if audio_row.get("hot_word_group_id") is not None:
cfg["hot_word_group_id"] = audio_row["hot_word_group_id"]
if audio_row.get("request_timeout_seconds") is not None: if audio_row.get("request_timeout_seconds") is not None:
cfg["request_timeout_seconds"] = int(audio_row["request_timeout_seconds"]) cfg["request_timeout_seconds"] = int(audio_row["request_timeout_seconds"])
@ -195,10 +193,10 @@ class SystemConfigService:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute( cursor.execute(
""" """
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, SELECT model_code, model_name, model_type, provider, endpoint_url, api_key,
request_timeout_seconds, hot_word_group_id, extra_config request_timeout_seconds, extra_config
FROM audio_model_config FROM audio_model_config
WHERE audio_scene = %s AND is_active = 1 WHERE model_type = %s AND is_active = 1
ORDER BY is_default DESC, updated_at DESC, config_id ASC ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1 LIMIT 1
""", """,
@ -238,10 +236,9 @@ class SystemConfigService:
# 1) llm 专表 # 1) llm 专表
cursor.execute( cursor.execute(
""" """
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, extra_config
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config FROM llm_model_config
WHERE model_code = %s AND is_active = 1 WHERE model_code = %s AND is_active = 1 AND model_type = 'text'
ORDER BY is_default DESC, config_id ASC ORDER BY is_default DESC, config_id ASC
LIMIT 1 LIMIT 1
""", """,
@ -251,10 +248,9 @@ class SystemConfigService:
if not llm_row and model_code == "llm_model": if not llm_row and model_code == "llm_model":
cursor.execute( cursor.execute(
""" """
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, extra_config
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config FROM llm_model_config
WHERE is_active = 1 WHERE is_active = 1 AND model_type = 'text'
ORDER BY is_default DESC, updated_at DESC, config_id ASC ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1 LIMIT 1
""" """
@ -271,14 +267,7 @@ class SystemConfigService:
cfg["model_name"] = llm_row["llm_model_name"] cfg["model_name"] = llm_row["llm_model_name"]
if llm_row.get("llm_timeout") is not None: if llm_row.get("llm_timeout") is not None:
cfg["time_out"] = llm_row["llm_timeout"] cfg["time_out"] = llm_row["llm_timeout"]
if llm_row.get("llm_temperature") is not None: cfg.update(cls._parse_json_object(llm_row.get("extra_config")))
cfg["temperature"] = float(llm_row["llm_temperature"])
if llm_row.get("llm_top_p") is not None:
cfg["top_p"] = float(llm_row["llm_top_p"])
if llm_row.get("llm_max_tokens") is not None:
cfg["max_tokens"] = llm_row["llm_max_tokens"]
if llm_row.get("llm_system_prompt") is not None:
cfg["system_prompt"] = llm_row["llm_system_prompt"]
return cfg return cfg
# 2) audio 专表 # 2) audio 专表
@ -290,8 +279,8 @@ class SystemConfigService:
cursor.execute( cursor.execute(
""" """
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, SELECT model_code, model_name, model_type, provider, endpoint_url, api_key,
request_timeout_seconds, hot_word_group_id, extra_config request_timeout_seconds, extra_config
FROM audio_model_config FROM audio_model_config
WHERE model_code = %s AND is_active = 1 WHERE model_code = %s AND is_active = 1
ORDER BY is_default DESC, config_id ASC ORDER BY is_default DESC, config_id ASC
@ -404,7 +393,7 @@ class SystemConfigService:
cursor.execute( cursor.execute(
""" """
INSERT INTO audio_model_config INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, request_timeout_seconds, extra_config, description, is_active, is_default) (model_code, model_name, model_type, provider, request_timeout_seconds, extra_config, description, is_active, is_default)
VALUES ( VALUES (
'audio_model', 'audio_model',
'音频识别模型', '音频识别模型',
@ -556,7 +545,7 @@ class SystemConfigService:
# 便捷方法:获取特定配置 # 便捷方法:获取特定配置
@classmethod @classmethod
def get_asr_vocabulary_id(cls) -> Optional[str]: def get_asr_vocabulary_id(cls) -> Optional[str]:
"""获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id""" """获取ASR热词字典ID — 优先从 audio_model_config.extra_config.vocabulary_id 读取"""
audio_cfg = cls.get_active_audio_model_config("asr") audio_cfg = cls.get_active_audio_model_config("asr")
if audio_cfg.get("vocabulary_id"): if audio_cfg.get("vocabulary_id"):
return audio_cfg["vocabulary_id"] return audio_cfg["vocabulary_id"]

View File

@ -1,9 +1,13 @@
from typing import List, Optional, Tuple, Dict, Any from typing import List, Optional, Tuple, Dict, Any
from app.core.database import get_db_connection from app.core.database import get_db_connection
from app.models.models import Terminal, CreateTerminalRequest, UpdateTerminalRequest from app.models.models import UpdateTerminalRequest
import datetime import datetime
class TerminalService: class TerminalService:
@staticmethod
def _normalize_imei(imei: str) -> str:
return str(imei or "").strip()
def get_terminals(self, def get_terminals(self,
page: int = 1, page: int = 1,
size: int = 20, size: int = 20,
@ -34,8 +38,18 @@ class TerminalService:
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1" where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
deduped_terminals = """
FROM terminals t
INNER JOIN (
SELECT MAX(id) AS id
FROM terminals
WHERE imei IS NOT NULL AND TRIM(imei) <> ''
GROUP BY TRIM(imei)
) latest_terminal ON latest_terminal.id = t.id
"""
# 计算总数 # 计算总数
count_query = f"SELECT COUNT(*) as total FROM terminals t WHERE {where_clause}" count_query = f"SELECT COUNT(*) as total {deduped_terminals} WHERE {where_clause}"
cursor.execute(count_query, params) cursor.execute(count_query, params)
total = cursor.fetchone()['total'] total = cursor.fetchone()['total']
@ -48,7 +62,7 @@ class TerminalService:
cu.username as current_username, cu.username as current_username,
cu.caption as current_user_caption, cu.caption as current_user_caption,
dd.label_cn as terminal_type_name dd.label_cn as terminal_type_name
FROM terminals t {deduped_terminals}
LEFT JOIN sys_users u ON t.created_by = u.user_id LEFT JOIN sys_users u ON t.created_by = u.user_id
LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
@ -89,42 +103,17 @@ class TerminalService:
""" """
根据IMEI获取终端详情 根据IMEI获取终端详情
""" """
normalized_imei = self._normalize_imei(imei)
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM terminals WHERE imei = %s", (imei,)) cursor.execute(
"SELECT * FROM terminals WHERE TRIM(imei) = %s ORDER BY id DESC LIMIT 1",
(normalized_imei,)
)
terminal = cursor.fetchone() terminal = cursor.fetchone()
cursor.close() cursor.close()
return terminal return terminal
def create_terminal(self, terminal_data: CreateTerminalRequest, user_id: int) -> int:
"""
创建新终端
"""
with get_db_connection() as conn:
cursor = conn.cursor()
query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, description,
firmware_version, mac_address, status, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(query, (
terminal_data.imei,
terminal_data.terminal_name,
terminal_data.terminal_type,
terminal_data.description,
terminal_data.firmware_version,
terminal_data.mac_address,
terminal_data.status,
user_id
))
new_id = cursor.lastrowid
conn.commit()
cursor.close()
return new_id
def update_terminal(self, terminal_id: int, terminal_data: UpdateTerminalRequest) -> bool: def update_terminal(self, terminal_id: int, terminal_data: UpdateTerminalRequest) -> bool:
""" """
更新终端信息 更新终端信息
@ -198,14 +187,22 @@ class TerminalService:
""" """
检查并更新终端状态中间件调用 检查并更新终端状态中间件调用
""" """
normalized_imei = self._normalize_imei(imei)
normalized_type = str(terminal_type or "UNKNOWN").strip() or "UNKNOWN"
normalized_name = str(terminal_name or "Unknown Device").strip() or "Unknown Device"
if not normalized_imei:
return {"allowed": False, "reason": "IMEI 不能为空", "terminal_id": None}
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
# 检查是否存在 cursor.execute(
cursor.execute("SELECT id, status, is_activated FROM terminals WHERE imei = %s", (imei,)) "SELECT id, status, is_activated FROM terminals WHERE TRIM(imei) = %s ORDER BY id DESC LIMIT 1",
(normalized_imei,)
)
existing = cursor.fetchone() existing = cursor.fetchone()
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
if existing: if existing:
@ -219,32 +216,42 @@ class TerminalService:
SET last_online_at = %s, SET last_online_at = %s,
ip_address = %s, ip_address = %s,
current_user_id = %s, current_user_id = %s,
# 如果设备没名字,尝试用上报的名字填充
terminal_name = IF(terminal_name IS NULL OR terminal_name = '', %s, terminal_name), terminal_name = IF(terminal_name IS NULL OR terminal_name = '', %s, terminal_name),
# 如果设备没类型,尝试用上报的类型填充
terminal_type = IF(terminal_type IS NULL OR terminal_type = '', %s, terminal_type) terminal_type = IF(terminal_type IS NULL OR terminal_type = '', %s, terminal_type)
WHERE id = %s WHERE id = %s
""" """
cursor.execute(update_query, (current_time, ip_address, user_id, terminal_name, terminal_type, existing['id'])) cursor.execute(update_query, (current_time, ip_address, user_id, normalized_name, normalized_type, existing['id']))
conn.commit() conn.commit()
return {"allowed": True, "reason": "", "terminal_id": existing['id']} return {"allowed": True, "reason": "", "terminal_id": existing['id']}
else:
# 新设备自动注册并激活
insert_query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, status,
is_activated, activated_at, last_online_at, ip_address, created_at, current_user_id
) VALUES (%s, %s, %s, 1, 1, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_query, (
imei, terminal_name, terminal_type,
current_time, current_time, ip_address, current_time, user_id
))
new_id = cursor.lastrowid
conn.commit()
return {"allowed": True, "reason": "", "terminal_id": new_id} # 新设备自动注册并激活;依赖 imei 唯一索引处理并发首次注册。
insert_query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, status,
is_activated, activated_at, last_online_at, ip_address, created_at, current_user_id
) VALUES (%s, %s, %s, 1, 1, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id),
last_online_at = IF(status = 0, last_online_at, VALUES(last_online_at)),
ip_address = IF(status = 0, ip_address, VALUES(ip_address)),
current_user_id = IF(status = 0, current_user_id, VALUES(current_user_id)),
terminal_name = IF(terminal_name IS NULL OR terminal_name = '', VALUES(terminal_name), terminal_name),
terminal_type = IF(terminal_type IS NULL OR terminal_type = '', VALUES(terminal_type), terminal_type)
"""
cursor.execute(insert_query, (
normalized_imei, normalized_name, normalized_type,
current_time, current_time, ip_address, current_time, user_id
))
terminal_id = cursor.lastrowid
cursor.execute("SELECT id, status, is_activated FROM terminals WHERE id = %s", (terminal_id,))
terminal = cursor.fetchone()
conn.commit()
if terminal and terminal['status'] == 0:
return {"allowed": False, "reason": "设备已被停用", "terminal_id": terminal['id']}
return {"allowed": True, "reason": "", "terminal_id": terminal_id}
except Exception as e: except Exception as e:
print(f"Error in check_and_update_terminal: {e}") print(f"Error in check_and_update_terminal: {e}")

View File

@ -1,12 +0,0 @@
# 环境
# 组件
+ 数据库 mysql 5.7+ 10.100.51.51:3306 root | Unis@123
+ 缓存 redis 6.2 10.100.51.51:6379 Unis@123
# 升级前确认
+ 后端运行环境需提供 `ffmpeg``ffprobe`
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
+ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql`
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致

183
design/README.md 100644
View File

@ -0,0 +1,183 @@
# iMeeting - 智慧会议平台
## 项目简介
iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通过自动化的语音转录、说话人识别和 AI 摘要功能,帮助专业人士高效管理会议内容,从繁琐的记录工作中解放出来。
## 核心价值
- **解放生产力** - 自动化的会议转录和摘要,让用户从繁琐的记录工作中解放出来
- **信息不丢失** - 精准记录每一次会议的细节,确保关键信息和决策得到妥善保存
- **高效回顾** - 通过时间轴、发言人和关键词,快速定位会议内容
- **便捷分享** - 轻松分享会议纪要、材料和关键节点给相关人员
## 技术栈
### 平台端 (Backend)
- **框架**: Python 3.9+ / FastAPI
- **数据库**: MySQL 5.7+
- **缓存**: Redis 5.0+
- **AI服务**: 阿里云通义千问 (Dashscope)
- 语音识别: Paraformer-v2
- 说话人分离
- 大语言模型摘要
- **存储**: 本地对象存储
- **身份认证**: JWT / Python-JOSE
- **部署**: Docker / Nginx
### 客户端 (Frontend)
- **框架**: React 19.1 + Vite 7.0
- **UI组件**: Ant Design 5.27
- **路由**: React Router DOM 7.7
- **Markdown**: @uiw/react-md-editor 4.0
- **可视化**: Markmap (思维导图)
- **其他工具**:
- Axios (HTTP 客户端)
- html2canvas + jsPDF (导出功能)
- QRCode.react (二维码生成)
- Lucide React (图标库)
## 功能模块
### 平台功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
| **用户管理** | 用户创建、编辑、删除、密码重置、角色权限管理 | ✅ 已完成 |
| **会议管理** | 会议创建、编辑、删除、参会人员管理 | ✅ 已完成 |
| **音频处理** | 音频文件上传、存储、格式验证 | ✅ 已完成 |
| **异步转录服务** | 基于Paraformer的异步语音识别 | ✅ 已完成 |
| **说话人分离** | 基于CAM++ ,支持自定义标签 | ✅ 已完成 |
| **AI 摘要生成** | 异步生成会议纪要,支持自定义 Prompt | ✅ 已完成 |
| **任务状态管理** | 转录任务和 LLM 任务的状态追踪 | ✅ 已完成 |
| **声纹采集** | 用户声纹数据采集和管理 | ✅ 已完成 |
| **身份认证** | JWT Token 认证、登录登出、Token 刷新 | ✅ 已完成 |
| **图片上传** | 会议相关图片上传,支持 Markdown 引用 | ✅ 已完成 |
| **多会议知识模块** | 基于多会议摘要的知识总结 | ✅ 已完成 |
| **完整的对外接口** | 提供基于PC客户端、手机客户端、专用设备的服务接口 | ✅ 已完成 |
| | | |
| *待扩展功能* | *以下功能将在后续版本中添加* | |
| **对话模式的M-Agent** | 对话模式的会议Agent| |
| **平台多租户**| 云平台支持多租户| |
### PC客户端功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
| **用户登录** | 登录界面、Token 存储、自动登录 | ✅ 已完成 |
| **会议列表** | 会议展示、筛选、搜索 | ✅ 已完成 |
| **会议采集** | 支持快速会议和自定义会议 | ✅ 已完成 |
| **导出功能** | 会议纪要预览 | ✅ 已完成 |
| **响应式设计** | 适配不同屏幕尺寸 | ✅ 已完成 |
| | | |
| *待扩展功能* | *以下功能将在后续版本中添加* | |
| **声纹采集界面** | 声纹录制、上传、状态管理 | |
| **会议总结概览** | 获取按会议ID的总结一览| |
### 手机客户端功能
| 功能模块 | 功能描述 | 状态 |
|---------|---------|------|
## 快速开始
### 环境要求
- Node.js 22.12+
- Python 3.12+
- MySQL 5.7+
- Redis 5.0+
- Docker (可选)
### 安装与运行
#### 后端启动
```bash
cd backend
pip install -r requirements.txt
python app/main.py
```
默认运行在 `http://localhost:8000`
#### 前端启动
```bash
cd frontend
npm install
npm run dev
```
默认运行在 `http://localhost:5173`
#### Docker 部署
项目根目录 `scripts/` 下保留两种部署脚本:
```bash
# 全量部署: frontend + backend + mysql + redis
./scripts/deploy-full.sh
# 半部署: 只启动 frontend + backend, MySQL / Redis 使用外部服务
./scripts/deploy-app-only.sh
```
### 配置说明
详细的配置文档请参考:
- 部署文档: [deployment.md](./deployment.md)
- 数据库设计: [database.md](./database.md)
- 项目详细设计: [project.md](./project.md)
- AI 集成文档: [ai-integration.md](./ai-integration.md)
- 后端说明: [backend.md](./backend.md)
- 前端说明: [frontend.md](./frontend.md)
- 会议缓存说明: [meeting-cache-usage.md](./meeting-cache-usage.md)
## 核心特性
### 异步任务处理
系统采用异步任务架构,支持大文件和长时间处理:
- **语音转录任务**: 基于阿里云 Dashscope 的异步 API,支持任务状态追踪
- **AI 摘要任务**: 使用 FastAPI BackgroundTasks,支持进度更新和轮询
### 数据安全
- JWT Token 认证机制
- 基于角色的权限控制 (RBAC)
- 密码 bcrypt 加密
- Token 黑名单机制
### 高性能
- Redis 缓存任务状态
- 异步处理避免阻塞
- 分页查询优化
- 音频流式传输
## API 文档
启动后端服务后,访问以下地址查看 API 文档:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## 未来规划
- [ ] 实时转录 - 支持对正在进行的会议进行实时语音转文字
- [ ] 日历集成 - 与 Google Calendar、Outlook Calendar 集成
- [ ] 行动项提取 - AI 自动识别会议中的待办事项
- [ ] 跨会议搜索 - 对所有会议内容进行全文语义搜索
- [ ] 移动端应用 - 开发 iOS 和 Android 原生应用
- [ ] 多语言支持 - 支持中英文等多语言界面
- [ ] 会议协作 - 支持多人实时协作编辑会议纪要
- [ ] 数据分析 - 会议统计分析和可视化报表
## 许可证
[请添加许可证信息]
## 联系方式
[请添加联系方式]

View File

@ -0,0 +1,199 @@
# iMeeting 部署文档
本文档说明当前保留的两种 Docker 部署方式:
- 全量部署:启动 `frontend`、`backend`、`mysql`、`redis`,适合单机完整部署。
- 半部署:只启动 `frontend`、`backend`MySQL 和 Redis 使用外部已有服务。
所有脚本统一放在根目录 `scripts/` 下,运行脚本时请在项目根目录执行命令。
## 文件说明
| 文件 | 说明 |
|------|------|
| `docker-compose.yml` | Docker Compose 服务定义 |
| `.env.example` | 根目录环境变量模板 |
| `scripts/deploy-full.sh` | 全量部署脚本,包含 MySQL / Redis |
| `scripts/stop-full.sh` | 全量部署停止脚本 |
| `scripts/deploy-app-only.sh` | 半部署脚本,只启动前后端 |
| `scripts/stop-app-only.sh` | 半部署停止脚本 |
| `scripts/sql/` | 全量部署 MySQL 初始化 SQL |
## 环境要求
- Docker
- Docker Compose v2或兼容的 `docker-compose`
- 服务器开放 `HTTP_PORT` 对应端口,默认 `80`
后端镜像已在 `backend/Dockerfile` 中安装 `ffmpeg` / `ffprobe`Docker 部署时宿主机不需要额外安装音频预处理依赖。
## 全量部署:带中间件
全量部署会启动以下服务:
- `mysql`
- `redis`
- `backend`
- `frontend`
全量部署的 MySQL 初始化 SQL 位于 `scripts/sql/`,并通过 `docker-compose.yml` 挂载到 MySQL 容器的 `/docker-entrypoint-initdb.d`。MySQL 官方镜像只会在数据库目录为空、首次初始化时执行该目录下的 `.sql` 文件;已有 `data/mysql/` 数据时,修改 SQL 文件不会自动重放。
操作步骤:
```bash
cp .env.example .env
vim .env
./scripts/deploy-full.sh
```
建议至少修改以下配置:
```bash
MYSQL_ROOT_PASSWORD=change_this_password
MYSQL_PASSWORD=change_this_password
REDIS_PASSWORD=change_this_password
BASE_URL=https://your-domain.com
```
全量部署的数据会写入根目录 `data/`
```text
data/
├── mysql/
├── redis/
├── uploads/
└── logs/
├── backend/
└── frontend/
```
停止全量部署:
```bash
./scripts/stop-full.sh
```
该脚本会交互式提供三种操作:
- 仅停止服务并保留容器和数据
- 停止并删除容器,保留数据目录
- 停止并删除 Compose 数据卷
注意:项目当前使用绑定目录 `./data/mysql``./data/redis` 持久化数据。删除 Compose 数据卷不会自动删除这些绑定目录;如需彻底清理数据,需要额外确认并删除 `data/` 下对应目录。
## 半部署:不含中间件
半部署只启动以下服务:
- `backend`
- `frontend`
MySQL 和 Redis 必须提前准备好,并在根目录 `.env` 中配置连接信息。
操作步骤:
```bash
cp .env.example .env
vim .env
./scripts/deploy-app-only.sh
```
重点配置:
```bash
MYSQL_HOST=your-mysql-host
MYSQL_PORT=3306
MYSQL_DATABASE=imeeting
MYSQL_USER=imeeting
MYSQL_PASSWORD=your-password
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_PASSWORD=your-password
REDIS_DB=0
BASE_URL=https://your-domain.com
```
脚本会把 `MYSQL_*` 转换为后端容器使用的 `DB_*` 环境变量。如果 `.env` 中显式配置了 `DB_HOST`、`DB_PORT`、`DB_USER`、`DB_PASSWORD`、`DB_NAME`,则优先使用 `DB_*`
停止半部署:
```bash
./scripts/stop-app-only.sh
```
该脚本只停止或删除 `backend` / `frontend` 容器,不会操作外部 MySQL 和 Redis。
## 访问地址
默认访问地址:
- Web`http://localhost`
- API 文档:`http://localhost/docs`
- API 代理路径:`http://localhost/api/`
如果使用服务器 IP 或域名访问,请将 `.env` 中的 `BASE_URL` 设置为最终外部访问地址。云端语音转录需要回调或拉取音频时,`BASE_URL` 不能是 `localhost`、`127.0.0.1`、容器名或不可从外部访问的内网地址。
## 常用命令
```bash
# 查看服务状态
docker compose ps
# 查看所有服务日志
docker compose logs -f
# 查看前后端日志
docker compose logs -f backend frontend
# 重启服务
docker compose restart
docker compose restart backend
docker compose restart frontend
```
如果当前环境只支持旧版命令,可把 `docker compose` 替换为 `docker-compose`
## HTTPS 和域名
项目容器默认提供 HTTP 服务。如需 HTTPS建议在接入层 Nginx、负载均衡或网关上配置证书再反向代理到 iMeeting 服务器的 `HTTP_PORT`
示例:
```nginx
server {
listen 443 ssl http2;
server_name imeeting.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/imeeting.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/imeeting.yourdomain.com/privkey.pem;
location / {
proxy_pass http://imeeting-server-ip:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 验证与排查
部署后检查:
```bash
docker compose ps
docker compose logs -f backend frontend
curl -f http://localhost/docs
```
全量部署时,`mysql`、`redis`、`backend`、`frontend` 应处于 healthy 或 running 状态。半部署时,只需要确认 `backend``frontend` 正常,外部 MySQL / Redis 的连通性由 `.env` 配置决定。
常见问题:
- `.env` 不存在:脚本会从 `.env.example` 复制一份,请修改后继续。
- 半部署缺少外部中间件配置:脚本会提示缺失的变量名。
- `BASE_URL` 配置为本地地址:本地访问可用,但云端语音服务可能无法拉取音频。
- 服务启动超时:先看 `docker compose ps`,再看 `docker compose logs -f backend frontend`

View File

@ -17,7 +17,7 @@ services:
- "${MYSQL_PORT:-3306}:3306" - "${MYSQL_PORT:-3306}:3306"
volumes: volumes:
- ./data/mysql:/var/lib/mysql - ./data/mysql:/var/lib/mysql
- ./backend/sql:/docker-entrypoint-initdb.d:ro - ./scripts/sql:/docker-entrypoint-initdb.d:ro
command: command:
- --character-set-server=utf8mb4 - --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { Switch } from 'antd'; import { Switch } from 'antd';
const ToggleSwitch = ({ checked, onChange, disabled = false, size = 'default' }) => { const ToggleSwitch = ({ checked = false, onChange, disabled = false, size = 'default' }) => {
return ( return (
<Switch <Switch
checked={checked} checked={Boolean(checked)}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
size={size === 'medium' ? 'default' : size} size={size === 'medium' ? 'default' : size}

View File

@ -126,7 +126,6 @@ const API_CONFIG = {
}, },
TERMINALS: { TERMINALS: {
LIST: '/api/terminals', LIST: '/api/terminals',
CREATE: '/api/terminals',
DETAIL: (id) => `/api/terminals/${id}`, DETAIL: (id) => `/api/terminals/${id}`,
UPDATE: (id) => `/api/terminals/${id}`, UPDATE: (id) => `/api/terminals/${id}`,
DELETE: (id) => `/api/terminals/${id}`, DELETE: (id) => `/api/terminals/${id}`,

View File

@ -51,6 +51,30 @@ export const LLM_PROVIDER_OPTIONS = [
}), }),
]; ];
export const VECTOR_PROVIDER_OPTIONS = [
createProvider({
value: 'dashscope',
label: '阿里百炼',
endpointUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
defaultModel: 'text-embedding-v4',
models: ['text-embedding-v4', 'text-embedding-v3'],
}),
createProvider({
value: 'openai',
label: 'OpenAI',
endpointUrl: 'https://api.openai.com/v1',
defaultModel: 'text-embedding-3-large',
models: ['text-embedding-3-large', 'text-embedding-3-small'],
}),
createProvider({
value: 'zhipu',
label: '智谱 AI',
endpointUrl: 'https://open.bigmodel.cn/api/paas/v4',
defaultModel: 'embedding-3',
models: ['embedding-3', 'embedding-2'],
}),
];
export const AUDIO_PROVIDER_OPTIONS = { export const AUDIO_PROVIDER_OPTIONS = {
asr: [ asr: [
createProvider({ createProvider({
@ -97,6 +121,9 @@ export const getProviderOptions = (kind, scene = 'asr') => {
if (kind === 'llm') { if (kind === 'llm') {
return LLM_PROVIDER_OPTIONS; return LLM_PROVIDER_OPTIONS;
} }
if (kind === 'vector') {
return VECTOR_PROVIDER_OPTIONS;
}
return AUDIO_PROVIDER_OPTIONS[scene] || AUDIO_PROVIDER_OPTIONS.asr; return AUDIO_PROVIDER_OPTIONS[scene] || AUDIO_PROVIDER_OPTIONS.asr;
}; };
@ -125,7 +152,9 @@ export const buildManagedModelName = ({ kind, providerLabel, serviceModelName, s
if (kind === 'llm') { if (kind === 'llm') {
return `${providerLabel} ${serviceModelName}`.trim(); return `${providerLabel} ${serviceModelName}`.trim();
} }
if (kind === 'vector') {
return `${providerLabel} 向量 ${serviceModelName}`.trim();
}
const sceneLabel = scene === 'voiceprint' ? '声纹' : '音频识别'; const sceneLabel = scene === 'voiceprint' ? '声纹' : '音频识别';
return `${providerLabel} ${sceneLabel} ${serviceModelName}`.trim(); return `${providerLabel} ${sceneLabel} ${serviceModelName}`.trim();
}; };

View File

@ -34,12 +34,17 @@ import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag'; import StatusTag from '../../components/StatusTag';
import useSystemPageSize from '../../hooks/useSystemPageSize'; import useSystemPageSize from '../../hooks/useSystemPageSize';
const AUDIO_SCENE_OPTIONS = [ const AUDIO_TYPE_OPTIONS = [
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
{ label: '音频识别', value: 'asr' }, { label: '音频识别', value: 'asr' },
{ label: '声纹模型', value: 'voiceprint' }, { label: '声纹模型', value: 'voiceprint' },
]; ];
const LLM_TYPE_OPTIONS = [
{ label: '文本模型', value: 'text' },
{ label: '向量模型', value: 'vector' },
];
const parseCommaSeparatedText = (value) => const parseCommaSeparatedText = (value) =>
String(value || '') String(value || '')
.split(',') .split(',')
@ -56,6 +61,11 @@ const normalizeAudioExtraConfig = (row = {}) => {
return typeof extraConfig === 'object' && extraConfig !== null ? extraConfig : {}; return typeof extraConfig === 'object' && extraConfig !== null ? extraConfig : {};
}; };
const normalizeLlmExtraConfig = (row = {}) => {
const extraConfig = row.extra_config || {};
return typeof extraConfig === 'object' && extraConfig !== null ? extraConfig : {};
};
const ModelManagement = () => { const ModelManagement = () => {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [activeTab, setActiveTab] = useState('llm'); const [activeTab, setActiveTab] = useState('llm');
@ -70,15 +80,17 @@ const ModelManagement = () => {
const [hotWordGroups, setHotWordGroups] = useState([]); const [hotWordGroups, setHotWordGroups] = useState([]);
const pageSize = useSystemPageSize(10); const pageSize = useSystemPageSize(10);
const [form] = Form.useForm(); const [form] = Form.useForm();
const watchedScene = Form.useWatch('audio_scene', form); const watchedModelType = Form.useWatch('model_type', form);
const watchedProvider = Form.useWatch('provider', form); const watchedProvider = Form.useWatch('provider', form);
const llmProviderOptions = useMemo(() => getProviderSelectOptions('llm'), []); const llmKind = watchedModelType === 'vector' ? 'vector' : 'llm';
const audioProviderOptions = useMemo(() => getProviderSelectOptions('audio', watchedScene || 'asr'), [watchedScene]); const audioModelType = ['asr', 'voiceprint'].includes(watchedModelType) ? watchedModelType : 'asr';
const llmModelOptions = useMemo(() => getProviderModelOptions('llm', watchedProvider), [watchedProvider]); const llmProviderOptions = useMemo(() => getProviderSelectOptions(llmKind), [llmKind]);
const audioProviderOptions = useMemo(() => getProviderSelectOptions('audio', audioModelType), [audioModelType]);
const llmModelOptions = useMemo(() => getProviderModelOptions(llmKind, watchedProvider), [llmKind, watchedProvider]);
const audioModelOptions = useMemo( const audioModelOptions = useMemo(
() => getProviderModelOptions('audio', watchedProvider, watchedScene || 'asr'), () => getProviderModelOptions('audio', watchedProvider, audioModelType),
[watchedProvider, watchedScene], [watchedProvider, audioModelType],
); );
const applyProviderDefaults = (kind, provider, scene = 'asr') => { const applyProviderDefaults = (kind, provider, scene = 'asr') => {
@ -94,28 +106,39 @@ const ModelManagement = () => {
}; };
const buildLlmPayload = (values) => { const buildLlmPayload = (values) => {
const providerConfig = findProviderConfig('llm', values.provider); const modelType = values.model_type || 'text';
const kind = modelType === 'vector' ? 'vector' : 'llm';
const providerConfig = findProviderConfig(kind, values.provider);
const serviceModelName = values.service_model_name?.trim(); const serviceModelName = values.service_model_name?.trim();
return { return {
model_code: buildManagedModelCode({ model_code: buildManagedModelCode({
kind: 'llm', kind,
provider: values.provider, provider: values.provider,
serviceModelName, serviceModelName,
}), }),
model_name: buildManagedModelName({ model_name: buildManagedModelName({
kind: 'llm', kind,
providerLabel: providerConfig?.label || values.provider, providerLabel: providerConfig?.label || values.provider,
serviceModelName, serviceModelName,
}), }),
model_type: modelType,
provider: values.provider, provider: values.provider,
endpoint_url: values.endpoint_url, endpoint_url: values.endpoint_url,
api_key: values.api_key, api_key: values.api_key,
llm_model_name: serviceModelName, llm_model_name: serviceModelName,
llm_timeout: values.llm_timeout, llm_timeout: values.llm_timeout,
llm_temperature: values.llm_temperature, extra_config: modelType === 'vector'
llm_top_p: values.llm_top_p, ? {
llm_max_tokens: values.llm_max_tokens, dimensions: values.vector_dimensions,
llm_system_prompt: values.llm_system_prompt, encoding_format: values.vector_encoding_format?.trim(),
input_type: values.vector_input_type?.trim(),
}
: {
temperature: values.llm_temperature,
top_p: values.llm_top_p,
max_tokens: values.llm_max_tokens,
system_prompt: values.llm_system_prompt,
},
description: values.description, description: values.description,
is_active: values.is_active, is_active: values.is_active,
is_default: values.is_default, is_default: values.is_default,
@ -123,15 +146,16 @@ const ModelManagement = () => {
}; };
const buildAudioPayload = (values) => { const buildAudioPayload = (values) => {
const providerConfig = findProviderConfig('audio', values.provider, values.audio_scene); const modelType = values.model_type || 'asr';
const providerConfig = findProviderConfig('audio', values.provider, modelType);
const serviceModelName = values.service_model_name?.trim(); const serviceModelName = values.service_model_name?.trim();
const generatedDisplayName = buildManagedModelName({ const generatedDisplayName = buildManagedModelName({
kind: 'audio', kind: 'audio',
scene: values.audio_scene, scene: modelType,
providerLabel: providerConfig?.label || values.provider, providerLabel: providerConfig?.label || values.provider,
serviceModelName, serviceModelName,
}); });
const extraConfig = values.audio_scene === 'voiceprint' const extraConfig = modelType === 'voiceprint'
? { ? {
model: serviceModelName, model: serviceModelName,
template_text: values.vp_template_text, template_text: values.vp_template_text,
@ -151,22 +175,22 @@ const ModelManagement = () => {
special_word_filter: values.asr_special_word_filter?.trim(), special_word_filter: values.asr_special_word_filter?.trim(),
audio_event_detection_enabled: values.asr_audio_event_detection_enabled, audio_event_detection_enabled: values.asr_audio_event_detection_enabled,
phrase_id: values.asr_phrase_id?.trim(), phrase_id: values.asr_phrase_id?.trim(),
vocabulary_id: values.asr_vocabulary_id?.trim(),
}; };
return { return {
model_code: buildManagedModelCode({ model_code: buildManagedModelCode({
kind: 'audio', kind: 'audio',
scene: values.audio_scene, scene: modelType,
provider: values.provider, provider: values.provider,
serviceModelName, serviceModelName,
}), }),
model_name: generatedDisplayName, model_name: generatedDisplayName,
audio_scene: values.audio_scene, model_type: modelType,
provider: values.provider, provider: values.provider,
endpoint_url: values.endpoint_url, endpoint_url: values.endpoint_url,
api_key: values.api_key, api_key: values.api_key,
request_timeout_seconds: values.request_timeout_seconds, request_timeout_seconds: values.request_timeout_seconds,
hot_word_group_id: values.hot_word_group_id || null,
extra_config: extraConfig, extra_config: extraConfig,
description: values.description, description: values.description,
is_active: values.is_active, is_active: values.is_active,
@ -212,8 +236,10 @@ const ModelManagement = () => {
const openCreate = () => { const openCreate = () => {
setEditingRow(null); setEditingRow(null);
form.resetFields();
if (activeTab === 'llm') { if (activeTab === 'llm') {
form.setFieldsValue({ form.setFieldsValue({
model_type: 'text',
provider: 'dashscope', provider: 'dashscope',
endpoint_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', endpoint_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
api_key: '', api_key: '',
@ -223,19 +249,22 @@ const ModelManagement = () => {
llm_top_p: 0.9, llm_top_p: 0.9,
llm_max_tokens: 2048, llm_max_tokens: 2048,
llm_system_prompt: '', llm_system_prompt: '',
vector_dimensions: undefined,
vector_encoding_format: '',
vector_input_type: '',
description: '', description: '',
is_active: true, is_active: true,
is_default: false, is_default: false,
}); });
} else { } else {
form.setFieldsValue({ form.setFieldsValue({
audio_scene: 'asr', model_type: 'asr',
provider: 'dashscope', provider: 'dashscope',
endpoint_url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription', endpoint_url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription',
api_key: '', api_key: '',
service_model_name: 'paraformer-v2', service_model_name: 'paraformer-v2',
request_timeout_seconds: 300, request_timeout_seconds: 300,
hot_word_group_id: undefined, asr_vocabulary_id: '',
asr_speaker_count: 10, asr_speaker_count: 10,
asr_language_hints: 'zh,en', asr_language_hints: 'zh,en',
asr_disfluency_removal_enabled: true, asr_disfluency_removal_enabled: true,
@ -260,17 +289,27 @@ const ModelManagement = () => {
const openEdit = (row) => { const openEdit = (row) => {
setEditingRow(row); setEditingRow(row);
form.resetFields();
if (activeTab === 'llm') { if (activeTab === 'llm') {
const extraConfig = normalizeLlmExtraConfig(row);
form.setFieldsValue({ form.setFieldsValue({
...row, ...row,
model_type: row.model_type || 'text',
service_model_name: row.llm_model_name, service_model_name: row.llm_model_name,
llm_temperature: extraConfig.temperature ?? 0.7,
llm_top_p: extraConfig.top_p ?? 0.9,
llm_max_tokens: extraConfig.max_tokens ?? 2048,
llm_system_prompt: extraConfig.system_prompt,
vector_dimensions: extraConfig.dimensions,
vector_encoding_format: extraConfig.encoding_format,
vector_input_type: extraConfig.input_type,
}); });
} else { } else {
const extraConfig = normalizeAudioExtraConfig(row); const extraConfig = normalizeAudioExtraConfig(row);
form.setFieldsValue({ form.setFieldsValue({
...row, ...row,
model_type: row.model_type,
request_timeout_seconds: row.request_timeout_seconds ?? 300, request_timeout_seconds: row.request_timeout_seconds ?? 300,
hot_word_group_id: row.hot_word_group_id || undefined,
service_model_name: row.service_model_name || extraConfig.model || row.model_name, service_model_name: row.service_model_name || extraConfig.model || row.model_name,
asr_speaker_count: extraConfig.speaker_count, asr_speaker_count: extraConfig.speaker_count,
asr_language_hints: Array.isArray(extraConfig.language_hints) asr_language_hints: Array.isArray(extraConfig.language_hints)
@ -283,6 +322,7 @@ const ModelManagement = () => {
asr_special_word_filter: extraConfig.special_word_filter, asr_special_word_filter: extraConfig.special_word_filter,
asr_audio_event_detection_enabled: extraConfig.audio_event_detection_enabled ?? false, asr_audio_event_detection_enabled: extraConfig.audio_event_detection_enabled ?? false,
asr_phrase_id: extraConfig.phrase_id, asr_phrase_id: extraConfig.phrase_id,
asr_vocabulary_id: extraConfig.vocabulary_id,
vp_template_text: extraConfig.template_text, vp_template_text: extraConfig.template_text,
vp_duration_seconds: extraConfig.duration_seconds, vp_duration_seconds: extraConfig.duration_seconds,
vp_sample_rate: extraConfig.sample_rate, vp_sample_rate: extraConfig.sample_rate,
@ -381,10 +421,26 @@ const ModelManagement = () => {
const llmColumns = [ const llmColumns = [
{ title: '编码', dataIndex: 'model_code', key: 'model_code', width: 170 }, { title: '编码', dataIndex: 'model_code', key: 'model_code', width: 170 },
{ title: '名称', dataIndex: 'model_name', key: 'model_name', width: 180 }, { title: '名称', dataIndex: 'model_name', key: 'model_name', width: 180 },
{
title: '类型',
dataIndex: 'model_type',
key: 'model_type',
width: 100,
render: (v) => <Tag color={v === 'vector' ? 'cyan' : 'blue'}>{v === 'vector' ? '向量模型' : '文本模型'}</Tag>,
},
{ title: '供应商', dataIndex: 'provider', key: 'provider', width: 130 }, { title: '供应商', dataIndex: 'provider', key: 'provider', width: 130 },
{ title: 'API地址', dataIndex: 'endpoint_url', key: 'endpoint_url', width: 220, render: (v) => v || '-' }, { title: 'API地址', dataIndex: 'endpoint_url', key: 'endpoint_url', width: 220, render: (v) => v || '-' },
{ title: '模型', dataIndex: 'llm_model_name', key: 'llm_model_name', width: 140 }, { title: '模型', dataIndex: 'llm_model_name', key: 'llm_model_name', width: 140 },
{ title: '参数', key: 'params', render: (_, r) => `T=${r.llm_temperature} / P=${r.llm_top_p} / TO=${r.llm_timeout}` }, {
title: '参数',
key: 'params',
render: (_, r) => {
const extraConfig = normalizeLlmExtraConfig(r);
return r.model_type === 'vector'
? `维度=${extraConfig.dimensions || '-'} / TO=${r.llm_timeout}`
: `T=${extraConfig.temperature ?? '-'} / P=${extraConfig.top_p ?? '-'} / TO=${r.llm_timeout}`;
},
},
{ title: '状态', dataIndex: 'is_active', key: 'is_active', width: 90, render: (v) => <StatusTag active={v} /> }, { title: '状态', dataIndex: 'is_active', key: 'is_active', width: 90, render: (v) => <StatusTag active={v} /> },
{ {
title: '操作', title: '操作',
@ -402,7 +458,7 @@ const ModelManagement = () => {
]; ];
const filteredAudioItems = useMemo( const filteredAudioItems = useMemo(
() => (audioSceneFilter === 'all' ? audioItems : audioItems.filter((item) => item.audio_scene === audioSceneFilter)), () => (audioSceneFilter === 'all' ? audioItems : audioItems.filter((item) => item.model_type === audioSceneFilter)),
[audioItems, audioSceneFilter], [audioItems, audioSceneFilter],
); );
@ -411,8 +467,8 @@ const ModelManagement = () => {
{ title: '名称', dataIndex: 'model_name', key: 'model_name', width: 170 }, { title: '名称', dataIndex: 'model_name', key: 'model_name', width: 170 },
{ {
title: '场景', title: '场景',
dataIndex: 'audio_scene', dataIndex: 'model_type',
key: 'audio_scene', key: 'model_type',
width: 120, width: 120,
render: (v) => <Tag color={v === 'voiceprint' ? 'purple' : 'blue'}>{v === 'voiceprint' ? '声纹模型' : '音频识别'}</Tag>, render: (v) => <Tag color={v === 'voiceprint' ? 'purple' : 'blue'}>{v === 'voiceprint' ? '声纹模型' : '音频识别'}</Tag>,
}, },
@ -423,9 +479,9 @@ const ModelManagement = () => {
render: (_, row) => { render: (_, row) => {
const extraConfig = normalizeAudioExtraConfig(row); const extraConfig = normalizeAudioExtraConfig(row);
const serviceModelName = row.service_model_name || extraConfig.model || row.model_name; const serviceModelName = row.service_model_name || extraConfig.model || row.model_name;
return row.audio_scene === 'voiceprint' return row.model_type === 'voiceprint'
? `模型=${serviceModelName || '-'} 时长=${extraConfig.duration_seconds || '-'}s 采样=${extraConfig.sample_rate || '-'}` ? `模型=${serviceModelName || '-'} 时长=${extraConfig.duration_seconds || '-'}s 采样=${extraConfig.sample_rate || '-'}`
: `模型=${serviceModelName || '-'} 超时=${row.request_timeout_seconds || 300}s 热词组=${row.hot_word_group_name || '未关联'}`; : `模型=${serviceModelName || '-'} 超时=${row.request_timeout_seconds || 300}s 词表=${extraConfig.vocabulary_id || '未配置'}`;
}, },
}, },
{ title: '状态', dataIndex: 'is_active', key: 'is_active', width: 90, render: (v) => <StatusTag active={v} /> }, { title: '状态', dataIndex: 'is_active', key: 'is_active', width: 90, render: (v) => <StatusTag active={v} /> },
@ -449,11 +505,12 @@ const ModelManagement = () => {
<AdminModuleShell <AdminModuleShell
icon={<RobotOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />} icon={<RobotOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
title="模型管理" title="模型管理"
subtitle="按模型类型拆分底表LLM模型与音频模型(音频识别/声纹)。" subtitle="统一管理文本模型、向量模型与音频模型(音频识别/声纹)。"
stats={[ stats={[
{ label: 'LLM模型', value: llmItems.length }, { label: '文本模型', value: llmItems.filter((it) => (it.model_type || 'text') === 'text').length },
{ label: '向量模型', value: llmItems.filter((it) => it.model_type === 'vector').length },
{ label: '音频模型', value: audioItems.length }, { label: '音频模型', value: audioItems.length },
{ label: '声纹模型', value: audioItems.filter((it) => it.audio_scene === 'voiceprint').length }, { label: '声纹模型', value: audioItems.filter((it) => it.model_type === 'voiceprint').length },
]} ]}
> >
<Tabs <Tabs
@ -469,12 +526,12 @@ const ModelManagement = () => {
<div className="console-tab-panel"> <div className="console-tab-panel">
<div className="console-tab-toolbar"> <div className="console-tab-toolbar">
<div> <div>
<div className="console-section-title" style={{ fontSize: 16 }}>LLM模型列表</div> <div className="console-section-title" style={{ fontSize: 16 }}>文本/向量模型列表</div>
<div className="console-section-subtitle">从内置模型目录选择主流供应商模型自动填充供应商与 Base API</div> <div className="console-section-subtitle">从内置模型目录选择主流供应商模型自动填充供应商与 Base API</div>
</div> </div>
<Space> <Space>
<Button icon={<ReloadOutlined />} className="btn-soft-blue" loading={loading} onClick={fetchAll}>刷新</Button> <Button icon={<ReloadOutlined />} className="btn-soft-blue" loading={loading} onClick={fetchAll}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增LLM模型</Button> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增模型</Button>
</Space> </Space>
</div> </div>
<div className="console-table"> <div className="console-table">
@ -505,7 +562,7 @@ const ModelManagement = () => {
className="console-segmented" className="console-segmented"
value={audioSceneFilter} value={audioSceneFilter}
onChange={setAudioSceneFilter} onChange={setAudioSceneFilter}
options={AUDIO_SCENE_OPTIONS} options={AUDIO_TYPE_OPTIONS}
/> />
<Button icon={<ReloadOutlined />} className="btn-soft-blue" loading={loading} onClick={fetchAll}>刷新</Button> <Button icon={<ReloadOutlined />} className="btn-soft-blue" loading={loading} onClick={fetchAll}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增音频模型</Button> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增音频模型</Button>
@ -536,7 +593,7 @@ const ModelManagement = () => {
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={( extra={(
<Space> <Space>
{(activeTab === 'llm' || watchedScene === 'asr') && ( {((activeTab === 'llm' && watchedModelType !== 'vector') || audioModelType === 'asr') && (
<Button loading={testing} icon={<ExperimentOutlined />} className="btn-soft-violet" onClick={handleTestModel}> <Button loading={testing} icon={<ExperimentOutlined />} className="btn-soft-violet" onClick={handleTestModel}>
{activeTab === 'llm' ? '测试LLM' : '测试ASR'} {activeTab === 'llm' ? '测试LLM' : '测试ASR'}
</Button> </Button>
@ -546,13 +603,33 @@ const ModelManagement = () => {
)} )}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
{activeTab === 'llm' && (
<Form.Item
name="model_type"
label="模型类型"
rules={[{ required: true, message: '请选择模型类型' }]}
>
<Select
disabled={Boolean(editingRow)}
options={LLM_TYPE_OPTIONS}
onChange={(value) => {
const nextKind = value === 'vector' ? 'vector' : 'llm';
const providerOptions = getProviderSelectOptions(nextKind);
const nextProvider = providerOptions[0]?.value || '';
form.setFieldsValue({ provider: nextProvider });
applyProviderDefaults(nextKind, nextProvider);
}}
/>
</Form.Item>
)}
{activeTab === 'audio' && ( {activeTab === 'audio' && (
<Form.Item <Form.Item
name="audio_scene" name="model_type"
label="音频场景" label="音频场景"
rules={[{ required: true, message: '请选择场景' }]} rules={[{ required: true, message: '请选择场景' }]}
> >
<Select <Select
disabled={Boolean(editingRow)}
options={[{ label: '音频识别', value: 'asr' }, { label: '声纹模型', value: 'voiceprint' }]} options={[{ label: '音频识别', value: 'asr' }, { label: '声纹模型', value: 'voiceprint' }]}
onChange={(value) => { onChange={(value) => {
const providerOptions = getProviderSelectOptions('audio', value); const providerOptions = getProviderSelectOptions('audio', value);
@ -566,7 +643,7 @@ const ModelManagement = () => {
<Form.Item name="provider" label="供应商" rules={[{ required: true, message: '请选择供应商' }]}> <Form.Item name="provider" label="供应商" rules={[{ required: true, message: '请选择供应商' }]}>
<Select <Select
options={activeTab === 'llm' ? llmProviderOptions : audioProviderOptions} options={activeTab === 'llm' ? llmProviderOptions : audioProviderOptions}
onChange={(value) => applyProviderDefaults(activeTab === 'llm' ? 'llm' : 'audio', value, watchedScene || 'asr')} onChange={(value) => applyProviderDefaults(activeTab === 'llm' ? llmKind : 'audio', value, audioModelType)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="endpoint_url" label="Base API" rules={[{ required: true, message: '请输入Base API' }]}> <Form.Item name="endpoint_url" label="Base API" rules={[{ required: true, message: '请输入Base API' }]}>
@ -595,21 +672,39 @@ const ModelManagement = () => {
<Form.Item name="llm_timeout" label="超时(秒)" rules={[{ required: true, message: '必填' }]}> <Form.Item name="llm_timeout" label="超时(秒)" rules={[{ required: true, message: '必填' }]}>
<InputNumber min={10} max={600} /> <InputNumber min={10} max={600} />
</Form.Item> </Form.Item>
<Form.Item name="llm_temperature" label="Temperature" rules={[{ required: true, message: '必填' }]} style={{ minWidth: 220 }}> {watchedModelType === 'vector' ? (
<Slider min={0} max={2} step={0.1} tooltip={{ formatter: (value) => String(value) }} /> <>
</Form.Item> <Form.Item name="vector_dimensions" label="向量维度">
<Form.Item name="llm_top_p" label="Top P" rules={[{ required: true, message: '必填' }]} style={{ minWidth: 220 }}> <InputNumber min={1} max={8192} />
<Slider min={0} max={1} step={0.05} tooltip={{ formatter: (value) => String(value) }} /> </Form.Item>
</Form.Item> <Form.Item name="vector_encoding_format" label="编码格式">
<Form.Item name="llm_max_tokens" label="Max Tokens" rules={[{ required: true, message: '必填' }]}> <Input placeholder="如 float / base64" />
<InputNumber min={128} max={32768} step={128} /> </Form.Item>
</Form.Item> <Form.Item name="vector_input_type" label="输入类型">
<Input placeholder="如 query / document" />
</Form.Item>
</>
) : (
<>
<Form.Item name="llm_temperature" label="Temperature" rules={[{ required: true, message: '必填' }]} style={{ minWidth: 220 }}>
<Slider min={0} max={2} step={0.1} tooltip={{ formatter: (value) => String(value) }} />
</Form.Item>
<Form.Item name="llm_top_p" label="Top P" rules={[{ required: true, message: '必填' }]} style={{ minWidth: 220 }}>
<Slider min={0} max={1} step={0.05} tooltip={{ formatter: (value) => String(value) }} />
</Form.Item>
<Form.Item name="llm_max_tokens" label="Max Tokens" rules={[{ required: true, message: '必填' }]}>
<InputNumber min={128} max={32768} step={128} />
</Form.Item>
</>
)}
</Space> </Space>
<Form.Item name="llm_system_prompt" label="系统提示词"> {watchedModelType !== 'vector' && (
<Input.TextArea rows={4} /> <Form.Item name="llm_system_prompt" label="系统提示词">
</Form.Item> <Input.TextArea rows={4} />
</Form.Item>
)}
</> </>
) : watchedScene === 'voiceprint' ? ( ) : audioModelType === 'voiceprint' ? (
<> <>
<Form.Item name="vp_template_text" label="声纹采集模板文本"> <Form.Item name="vp_template_text" label="声纹采集模板文本">
<Input.TextArea rows={4} /> <Input.TextArea rows={4} />
@ -634,15 +729,15 @@ const ModelManagement = () => {
<Form.Item name="request_timeout_seconds" label="转录超时(秒)" rules={[{ required: true, message: '请输入超时秒数' }]}> <Form.Item name="request_timeout_seconds" label="转录超时(秒)" rules={[{ required: true, message: '请输入超时秒数' }]}>
<InputNumber min={10} max={3600} /> <InputNumber min={10} max={3600} />
</Form.Item> </Form.Item>
<Form.Item name="hot_word_group_id" label="热词组"> <Form.Item name="asr_vocabulary_id" label="热词词表">
<Select <Select
allowClear allowClear
placeholder="选择已同步的热词组(可选)" placeholder="选择已同步的词表(可选)"
options={hotWordGroups options={hotWordGroups
.filter((g) => g.status === 1 && g.vocabulary_id) .filter((g) => g.status === 1 && g.vocabulary_id)
.map((g) => ({ .map((g) => ({
label: `${g.name} (词表: ${g.vocabulary_id})`, label: `${g.name} (词表: ${g.vocabulary_id})`,
value: g.id, value: g.vocabulary_id,
}))} }))}
notFoundContent="暂无已同步的热词组,请先在热词管理中同步" notFoundContent="暂无已同步的热词组,请先在热词管理中同步"
/> />

View File

@ -17,7 +17,6 @@ import {
Col, Col,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined,
SaveOutlined, SaveOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
@ -56,7 +55,6 @@ const TerminalManagement = () => {
const [filterActivation, setFilterActivation] = useState('all'); const [filterActivation, setFilterActivation] = useState('all');
const [showDrawer, setShowDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedTerminal, setSelectedTerminal] = useState(null); const [selectedTerminal, setSelectedTerminal] = useState(null);
const fetchTerminalTypes = useCallback(async () => { const fetchTerminalTypes = useCallback(async () => {
@ -93,27 +91,21 @@ const TerminalManagement = () => {
fetchTerminals(); fetchTerminals();
}, [fetchTerminalTypes, fetchTerminals]); }, [fetchTerminalTypes, fetchTerminals]);
const handleOpenModal = (terminal = null) => { const handleOpenModal = (terminal) => {
if (terminal) { setSelectedTerminal(terminal);
setIsEditing(true); form.resetFields();
setSelectedTerminal(terminal); form.setFieldsValue({
form.setFieldsValue({ ...terminal,
...terminal, status: terminal.status === 1,
status: terminal.status === 1, });
});
} else {
setIsEditing(false);
setSelectedTerminal(null);
form.resetFields();
form.setFieldsValue({
terminal_type: terminalTypes[0]?.dict_code,
status: true,
});
}
setShowDrawer(true); setShowDrawer(true);
}; };
const handleSave = async () => { const handleSave = async () => {
if (!selectedTerminal) {
return;
}
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
const payload = { const payload = {
@ -121,13 +113,8 @@ const TerminalManagement = () => {
status: values.status ? 1 : 0, status: values.status ? 1 : 0,
}; };
if (isEditing) { await httpService.put(buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)), payload); message.success('终端更新成功');
message.success('终端更新成功');
} else {
await httpService.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), payload);
message.success('终端创建成功');
}
setShowDrawer(false); setShowDrawer(false);
fetchTerminals(); fetchTerminals();
@ -319,7 +306,6 @@ const TerminalManagement = () => {
rightActions={( rightActions={(
<Space> <Space>
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchTerminals} loading={loading}>刷新</Button> <Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchTerminals} loading={loading}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>添加终端</Button>
</Space> </Space>
)} )}
stats={[ stats={[
@ -400,15 +386,18 @@ const TerminalManagement = () => {
<Drawer <Drawer
open={showDrawer} open={showDrawer}
title={isEditing ? '编辑终端' : '添加终端'} title="编辑终端"
placement="right" placement="right"
width={720} width={720}
onClose={() => setShowDrawer(false)} onClose={() => {
setShowDrawer(false);
setSelectedTerminal(null);
}}
destroyOnClose destroyOnClose
extra={( extra={(
<Space> <Space>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}> <Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
{isEditing ? '保存修改' : '创建终端'} 保存修改
</Button> </Button>
</Space> </Space>
)} )}
@ -417,7 +406,7 @@ const TerminalManagement = () => {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="imei" label="IMEI 号" rules={[{ required: true, message: '请输入 IMEI 号' }]}> <Form.Item name="imei" label="IMEI 号" rules={[{ required: true, message: '请输入 IMEI 号' }]}>
<Input placeholder="设备唯一识别码" disabled={isEditing} /> <Input placeholder="设备唯一识别码" disabled />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>

View File

@ -903,9 +903,6 @@ body {
/* ── Global Switch geometry ── */ /* ── Global Switch geometry ── */
.ant-switch { .ant-switch {
min-width: 42px;
height: 24px;
padding: 2px;
border: 1px solid #cbdcf1; border: 1px solid #cbdcf1;
border-radius: 999px !important; border-radius: 999px !important;
background: linear-gradient(180deg, #eef4fc 0%, #dde8f7 100%) !important; background: linear-gradient(180deg, #eef4fc 0%, #dde8f7 100%) !important;
@ -917,13 +914,6 @@ body {
background: linear-gradient(180deg, #4f8cff 0%, #2f6ae6 100%) !important; background: linear-gradient(180deg, #4f8cff 0%, #2f6ae6 100%) !important;
} }
.ant-switch-handle {
top: 2px !important;
width: 18px !important;
height: 18px !important;
border-radius: 50% !important;
}
.ant-switch .ant-switch-handle::before { .ant-switch .ant-switch-handle::before {
border-radius: 50% !important; border-radius: 50% !important;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
@ -933,13 +923,3 @@ body {
.ant-switch-inner { .ant-switch-inner {
border-radius: 999px !important; border-radius: 999px !important;
} }
.ant-switch.ant-switch-small {
min-width: 34px;
height: 20px;
}
.ant-switch.ant-switch-small .ant-switch-handle {
width: 14px !important;
height: 14px !important;
}

305
manage.sh
View File

@ -1,305 +0,0 @@
#!/bin/bash
# iMeeting Docker 管理脚本
# 用于日常管理和维护
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 确定使用的命令
if docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose"
else
COMPOSE_CMD="docker-compose"
fi
# 显示菜单
show_menu() {
echo ""
echo -e "${BLUE}==================================${NC}"
echo -e "${BLUE} iMeeting 管理菜单${NC}"
echo -e "${BLUE}==================================${NC}"
echo ""
echo "1) 查看服务状态"
echo "2) 查看实时日志"
echo "3) 重启所有服务"
echo "4) 重启单个服务"
echo "5) 进入容器终端"
echo "6) 备份数据库"
echo "7) 恢复数据库"
echo "8) 清理Redis缓存"
echo "9) 更新服务"
echo "10) 查看资源使用"
echo "11) 导出日志"
echo "0) 退出"
echo ""
read -p "请选择操作 (0-11): " choice
}
# 查看服务状态
view_status() {
print_info "服务状态:"
$COMPOSE_CMD ps
echo ""
read -p "按任意键返回菜单..." -n 1
}
# 查看实时日志
view_logs() {
echo "选择要查看的服务:"
echo "1) 所有服务"
echo "2) 前端Web服务"
echo "3) 前端"
echo "4) 后端"
echo "5) MySQL"
echo "6) Redis"
read -p "请选择: " log_choice
case $log_choice in
1) $COMPOSE_CMD logs -f ;;
2) $COMPOSE_CMD logs -f frontend ;;
3) $COMPOSE_CMD logs -f frontend ;;
4) $COMPOSE_CMD logs -f backend ;;
5) $COMPOSE_CMD logs -f mysql ;;
6) $COMPOSE_CMD logs -f redis ;;
*) print_error "无效选择" ;;
esac
}
# 重启服务
restart_services() {
print_info "重启所有服务..."
$COMPOSE_CMD restart
print_success "服务已重启"
read -p "按任意键返回菜单..." -n 1
}
# 重启单个服务
restart_single_service() {
echo "选择要重启的服务:"
echo "1) 前端Web服务"
echo "2) 前端"
echo "3) 后端"
echo "4) MySQL"
echo "5) Redis"
read -p "请选择: " service_choice
case $service_choice in
1) $COMPOSE_CMD restart frontend ;;
2) $COMPOSE_CMD restart frontend ;;
3) $COMPOSE_CMD restart backend ;;
4) $COMPOSE_CMD restart mysql ;;
5) $COMPOSE_CMD restart redis ;;
*) print_error "无效选择"; return ;;
esac
print_success "服务已重启"
read -p "按任意键返回菜单..." -n 1
}
# 进入容器
enter_container() {
echo "选择要进入的容器:"
echo "1) 前端Web服务"
echo "2) 前端"
echo "3) 后端"
echo "4) MySQL"
echo "5) Redis"
read -p "请选择: " container_choice
case $container_choice in
1) $COMPOSE_CMD exec frontend sh ;;
2) $COMPOSE_CMD exec frontend sh ;;
3) $COMPOSE_CMD exec backend bash ;;
4) $COMPOSE_CMD exec mysql bash ;;
5) $COMPOSE_CMD exec redis sh ;;
*) print_error "无效选择" ;;
esac
}
# 备份数据库
backup_database() {
print_info "开始备份数据库..."
mkdir -p backups
backup_file="backups/imeeting_$(date +%Y%m%d_%H%M%S).sql"
source .env 2>/dev/null || true
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-Unis@123}
MYSQL_DATABASE=${MYSQL_DATABASE:-imeeting}
$COMPOSE_CMD exec mysql mysqldump -uroot -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
"${MYSQL_DATABASE}" > "$backup_file"
print_success "数据库已备份到: $backup_file"
read -p "按任意键返回菜单..." -n 1
}
# 恢复数据库
restore_database() {
print_warning "警告: 这将覆盖当前数据库!"
if [ ! -d "backups" ] || [ -z "$(ls -A backups/*.sql 2>/dev/null)" ]; then
print_error "未找到备份文件"
read -p "按任意键返回菜单..." -n 1
return
fi
echo ""
echo "可用的备份文件:"
ls -lh backups/*.sql
echo ""
read -p "请输入备份文件名: " backup_file
if [ ! -f "$backup_file" ]; then
print_error "文件不存在: $backup_file"
read -p "按任意键返回菜单..." -n 1
return
fi
read -p "确认恢复? (yes/no): " confirm
if [ "$confirm" = "yes" ]; then
print_info "开始恢复数据库..."
source .env 2>/dev/null || true
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-Unis@123}
MYSQL_DATABASE=${MYSQL_DATABASE:-imeeting}
$COMPOSE_CMD exec -T mysql mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" "${MYSQL_DATABASE}" < "$backup_file"
print_success "数据库已恢复"
else
print_info "操作已取消"
fi
read -p "按任意键返回菜单..." -n 1
}
# 清理Redis缓存
clear_redis() {
print_warning "这将清空所有Redis缓存"
read -p "确认清空? (yes/no): " confirm
if [ "$confirm" = "yes" ]; then
source .env 2>/dev/null || true
REDIS_PASSWORD=${REDIS_PASSWORD:-Unis@123}
$COMPOSE_CMD exec redis redis-cli -a "${REDIS_PASSWORD}" FLUSHALL
print_success "Redis缓存已清空"
else
print_info "操作已取消"
fi
read -p "按任意键返回菜单..." -n 1
}
# 更新服务
update_services() {
echo "选择更新方式:"
echo "1) 更新所有服务"
echo "2) 仅更新前端"
echo "3) 仅更新后端"
read -p "请选择: " update_choice
case $update_choice in
1)
print_info "更新所有服务..."
$COMPOSE_CMD build
$COMPOSE_CMD up -d
;;
2)
print_info "更新前端..."
$COMPOSE_CMD build frontend
$COMPOSE_CMD up -d frontend
;;
3)
print_info "更新后端..."
$COMPOSE_CMD build backend
$COMPOSE_CMD up -d backend
;;
*)
print_error "无效选择"
;;
esac
print_success "更新完成"
read -p "按任意键返回菜单..." -n 1
}
# 查看资源使用
view_resources() {
print_info "容器资源使用情况:"
docker stats --no-stream $(docker ps --format "{{.Names}}" | grep imeeting)
echo ""
read -p "按任意键返回菜单..." -n 1
}
# 导出日志
export_logs() {
print_info "导出日志..."
mkdir -p logs_export
timestamp=$(date +%Y%m%d_%H%M%S)
$COMPOSE_CMD logs --no-color > "logs_export/all_services_${timestamp}.log"
$COMPOSE_CMD logs --no-color frontend > "logs_export/frontend_web_${timestamp}.log"
$COMPOSE_CMD logs --no-color frontend > "logs_export/frontend_${timestamp}.log"
$COMPOSE_CMD logs --no-color backend > "logs_export/backend_${timestamp}.log"
$COMPOSE_CMD logs --no-color mysql > "logs_export/mysql_${timestamp}.log"
$COMPOSE_CMD logs --no-color redis > "logs_export/redis_${timestamp}.log"
print_success "日志已导出到: logs_export/"
read -p "按任意键返回菜单..." -n 1
}
# 主循环
main() {
while true; do
clear
show_menu
case $choice in
1) view_status ;;
2) view_logs ;;
3) restart_services ;;
4) restart_single_service ;;
5) enter_container ;;
6) backup_database ;;
7) restore_database ;;
8) clear_redis ;;
9) update_services ;;
10) view_resources ;;
11) export_logs ;;
0) print_info "退出管理脚本"; exit 0 ;;
*) print_error "无效选择"; sleep 1 ;;
esac
done
}
# 运行主程序
main

6
package-lock.json generated
View File

@ -1,6 +0,0 @@
{
"name": "imeeting",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -1 +0,0 @@
{}

View File

@ -33,13 +33,14 @@ print_banner() {
| | | | | __/ __/ |_| | | | | (_| | | | | | | __/ __/ |_| | | | | (_| |
|_|_| |_|\___|\___|\__|_|_| |_|\__, | |_|_| |_|\___|\___|\__|_|_| |_|\__, |
|___/ |___/
External Middleware Deployment App-only Docker Deployment
EOF EOF
echo -e "${NC}" echo -e "${NC}"
} }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
check_dependencies() { check_dependencies() {
print_info "检查系统依赖..." print_info "检查系统依赖..."
@ -158,7 +159,8 @@ wait_for_health() {
while [ $waited -lt $max_wait ]; do while [ $waited -lt $max_wait ]; do
local healthy_count local healthy_count
healthy_count=$($COMPOSE_CMD -f docker-compose.yml -f "$OVERRIDE_FILE" ps --format json 2>/dev/null | grep -c '"Health":"healthy"' || echo "0") healthy_count=$($COMPOSE_CMD -f docker-compose.yml -f "$OVERRIDE_FILE" ps --format json 2>/dev/null | grep -c '"Health":"healthy"' || true)
healthy_count=${healthy_count:-0}
if [ "$healthy_count" -eq 2 ]; then if [ "$healthy_count" -eq 2 ]; then
print_success "前后端服务已就绪" print_success "前后端服务已就绪"
@ -196,7 +198,7 @@ show_access_info() {
echo "" echo ""
echo -e "${YELLOW}常用命令:${NC}" echo -e "${YELLOW}常用命令:${NC}"
echo -e " 查看日志: ${BLUE}$COMPOSE_CMD logs -f backend frontend${NC}" echo -e " 查看日志: ${BLUE}$COMPOSE_CMD logs -f backend frontend${NC}"
echo -e " 停止服务: ${BLUE}./stop-external.sh${NC}" echo -e " 停止服务: ${BLUE}./scripts/stop-app-only.sh${NC}"
echo -e " 查看状态: ${BLUE}$COMPOSE_CMD ps backend frontend${NC}" echo -e " 查看状态: ${BLUE}$COMPOSE_CMD ps backend frontend${NC}"
echo "" echo ""
} }

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# iMeeting Docker 快速启动脚本 # iMeeting Docker 全量部署脚本(包含 MySQL / Redis
# 使用方法: ./start.sh [选项] # 使用方法: ./scripts/deploy-full.sh
set -e set -e
@ -12,6 +12,10 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# 打印带颜色的消息 # 打印带颜色的消息
print_info() { print_info() {
echo -e "${BLUE}[INFO]${NC} $1" echo -e "${BLUE}[INFO]${NC} $1"
@ -39,7 +43,7 @@ print_banner() {
| | | | | __/ __/ |_| | | | | (_| | | | | | | __/ __/ |_| | | | | (_| |
|_|_| |_|\___|\___|\__|_|_| |_|\__, | |_|_| |_|\___|\___|\__|_|_| |_|\__, |
|___/ |___/
Docker Deployment Script Full Docker Deployment
EOF EOF
echo -e "${NC}" echo -e "${NC}"
} }
@ -76,7 +80,7 @@ check_env_file() {
fi fi
} }
# 检查并创建后端环境变量文件# 创建必要的目录 # 创建必要的目录
create_directories() { create_directories() {
print_info "创建必要的目录..." print_info "创建必要的目录..."
@ -104,7 +108,7 @@ start_services() {
COMPOSE_CMD="docker-compose" COMPOSE_CMD="docker-compose"
fi fi
$COMPOSE_CMD up -d $COMPOSE_CMD up -d --build
print_success "服务启动命令已执行" print_success "服务启动命令已执行"
} }
@ -123,7 +127,9 @@ wait_for_health() {
COMPOSE_CMD="docker-compose" COMPOSE_CMD="docker-compose"
fi fi
local healthy_count=$($COMPOSE_CMD ps --format json 2>/dev/null | grep -c '"Health":"healthy"' || echo "0") local healthy_count
healthy_count=$($COMPOSE_CMD ps --format json 2>/dev/null | grep -c '"Health":"healthy"' || true)
healthy_count=${healthy_count:-0}
local total_count=4 # mysql, redis, backend, frontend local total_count=4 # mysql, redis, backend, frontend
if [ "$healthy_count" -eq "$total_count" ]; then if [ "$healthy_count" -eq "$total_count" ]; then
@ -137,7 +143,7 @@ wait_for_health() {
done done
echo "" echo ""
print_warning "服务启动超时,请手动检查状态: docker-compose ps" print_warning "服务启动超时,请手动检查状态: docker compose ps"
return 1 return 1
} }
@ -166,7 +172,7 @@ show_access_info() {
echo "" echo ""
echo -e "${YELLOW}域名访问HTTPS:${NC}" echo -e "${YELLOW}域名访问HTTPS:${NC}"
echo -e " 需要在接入服务器配置反向代理" echo -e " 需要在接入服务器配置反向代理"
echo -e " 参考说明: ${BLUE}DOCKER_README.md${NC}" echo -e " 参考说明: ${BLUE}design/deployment.md${NC}"
echo "" echo ""
echo -e "${YELLOW}数据目录:${NC}" echo -e "${YELLOW}数据目录:${NC}"
echo -e " 数据持久化: ${BLUE}./data/${NC}" echo -e " 数据持久化: ${BLUE}./data/${NC}"
@ -176,13 +182,12 @@ show_access_info() {
echo -e " - 日志: ${BLUE}./data/logs/${NC}" echo -e " - 日志: ${BLUE}./data/logs/${NC}"
echo "" echo ""
echo -e "${YELLOW}常用命令:${NC}" echo -e "${YELLOW}常用命令:${NC}"
echo -e " 管理菜单: ${BLUE}./manage.sh${NC}" echo -e " 查看日志: ${BLUE}docker compose logs -f${NC}"
echo -e " 查看日志: ${BLUE}docker-compose logs -f${NC}" echo -e " 停止服务: ${BLUE}./scripts/stop-full.sh${NC}"
echo -e " 停止服务: ${BLUE}./stop.sh${NC}" echo -e " 重启服务: ${BLUE}docker compose restart${NC}"
echo -e " 重启服务: ${BLUE}docker-compose restart${NC}" echo -e " 查看状态: ${BLUE}docker compose ps${NC}"
echo -e " 查看状态: ${BLUE}docker-compose ps${NC}"
echo "" echo ""
echo -e "${YELLOW}更多信息请查看: ${BLUE}DOCKER_README.md${NC}" echo -e "${YELLOW}更多信息请查看: ${BLUE}design/deployment.md${NC}"
echo "" echo ""
} }

View File

@ -181,15 +181,13 @@ CREATE TABLE `llm_model_config` (
`config_id` BIGINT NOT NULL AUTO_INCREMENT, `config_id` BIGINT NOT NULL AUTO_INCREMENT,
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码', `model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称', `model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
`model_type` VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT '模型类型: text / vector',
`provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商', `provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商',
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '模型接口地址', `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '模型接口地址',
`api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥', `api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥',
`llm_model_name` VARCHAR(128) NOT NULL COMMENT '供应商模型名', `llm_model_name` VARCHAR(128) NOT NULL COMMENT '供应商模型名',
`llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)', `llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)',
`llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT 'temperature', `extra_config` JSON DEFAULT NULL COMMENT '模型差异化配置',
`llm_top_p` DECIMAL(5,2) NOT NULL DEFAULT 0.90 COMMENT 'top_p',
`llm_max_tokens` INT NOT NULL DEFAULT 2048 COMMENT '最大token数',
`llm_system_prompt` TEXT DEFAULT NULL COMMENT '系统提示词',
`description` VARCHAR(500) DEFAULT NULL COMMENT '说明', `description` VARCHAR(500) DEFAULT NULL COMMENT '说明',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认', `is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
@ -197,20 +195,20 @@ CREATE TABLE `llm_model_config` (
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`config_id`), PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_llm_model_config_code` (`model_code`), UNIQUE KEY `uk_llm_model_config_code` (`model_code`),
KEY `idx_llm_model_config_type` (`model_type`),
KEY `idx_llm_model_config_active` (`is_active`), KEY `idx_llm_model_config_active` (`is_active`),
KEY `idx_llm_model_config_default` (`is_default`) KEY `idx_llm_model_config_default` (`is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LLM模型配置表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文本与向量模型配置表';
CREATE TABLE `audio_model_config` ( CREATE TABLE `audio_model_config` (
`config_id` BIGINT NOT NULL AUTO_INCREMENT, `config_id` BIGINT NOT NULL AUTO_INCREMENT,
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码', `model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称', `model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
`audio_scene` VARCHAR(32) NOT NULL COMMENT 'asr / voiceprint', `model_type` VARCHAR(32) NOT NULL COMMENT '模型类型: asr / voiceprint',
`provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商', `provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商',
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址', `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址',
`api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥', `api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥',
`request_timeout_seconds` INT NOT NULL DEFAULT 300 COMMENT '请求超时(秒)', `request_timeout_seconds` INT NOT NULL DEFAULT 300 COMMENT '请求超时(秒)',
`hot_word_group_id` INT DEFAULT NULL COMMENT '关联热词组',
`extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置', `extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置',
`description` VARCHAR(500) DEFAULT NULL COMMENT '说明', `description` VARCHAR(500) DEFAULT NULL COMMENT '说明',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
@ -219,13 +217,9 @@ CREATE TABLE `audio_model_config` (
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`config_id`), PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_audio_model_config_code` (`model_code`), UNIQUE KEY `uk_audio_model_config_code` (`model_code`),
KEY `idx_audio_model_config_scene` (`audio_scene`), KEY `idx_audio_model_config_type` (`model_type`),
KEY `idx_audio_model_config_active` (`is_active`), KEY `idx_audio_model_config_active` (`is_active`),
KEY `idx_audio_model_config_default` (`is_default`), KEY `idx_audio_model_config_default` (`is_default`)
KEY `idx_audio_model_config_hot_word_group_id` (`hot_word_group_id`),
CONSTRAINT `fk_audio_model_config_hot_word_group_id`
FOREIGN KEY (`hot_word_group_id`) REFERENCES `hot_word_group` (`id`)
ON DELETE SET NULL ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='音频模型配置表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='音频模型配置表';
CREATE TABLE `hot_word_item` ( CREATE TABLE `hot_word_item` (

View File

@ -232,22 +232,24 @@ ON DUPLICATE KEY UPDATE
-- 6. 默认模型配置 -- 6. 默认模型配置
-- ===================================================================== -- =====================================================================
INSERT INTO `llm_model_config` INSERT INTO `llm_model_config`
(`model_code`, `model_name`, `provider`, `endpoint_url`, `api_key`, `llm_model_name`, (`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`, `llm_model_name`,
`llm_timeout`, `llm_temperature`, `llm_top_p`, `llm_max_tokens`, `llm_system_prompt`, `llm_timeout`, `extra_config`, `description`, `is_active`, `is_default`, `created_at`, `updated_at`)
`description`, `is_active`, `is_default`, `created_at`, `updated_at`)
VALUES VALUES
( (
'llm_model', 'llm_model',
'默认文本模型', '默认文本模型',
'text',
'dashscope', 'dashscope',
'https://dashscope.aliyuncs.com/compatible-mode/v1', 'https://dashscope.aliyuncs.com/compatible-mode/v1',
NULL, NULL,
'qwen-plus', 'qwen-plus',
120, 120,
0.70, JSON_OBJECT(
0.90, 'temperature', 0.70,
2048, 'top_p', 0.90,
'你是一名专业的会议与知识整理助手,请基于输入内容给出准确、结构化、可复用的输出。', 'max_tokens', 2048,
'system_prompt', '你是一名专业的会议与知识整理助手,请基于输入内容给出准确、结构化、可复用的输出。'
),
'系统初始化的默认 LLM 模型配置API Key 优先使用环境变量。', '系统初始化的默认 LLM 模型配置API Key 优先使用环境变量。',
1, 1,
1, 1,
@ -256,23 +258,55 @@ VALUES
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`model_name` = VALUES(`model_name`), `model_name` = VALUES(`model_name`),
`model_type` = VALUES(`model_type`),
`provider` = VALUES(`provider`), `provider` = VALUES(`provider`),
`endpoint_url` = VALUES(`endpoint_url`), `endpoint_url` = VALUES(`endpoint_url`),
`api_key` = VALUES(`api_key`), `api_key` = VALUES(`api_key`),
`llm_model_name` = VALUES(`llm_model_name`), `llm_model_name` = VALUES(`llm_model_name`),
`llm_timeout` = VALUES(`llm_timeout`), `llm_timeout` = VALUES(`llm_timeout`),
`llm_temperature` = VALUES(`llm_temperature`), `extra_config` = VALUES(`extra_config`),
`llm_top_p` = VALUES(`llm_top_p`), `description` = VALUES(`description`),
`llm_max_tokens` = VALUES(`llm_max_tokens`), `is_active` = VALUES(`is_active`),
`llm_system_prompt` = VALUES(`llm_system_prompt`), `is_default` = VALUES(`is_default`),
`updated_at` = VALUES(`updated_at`);
INSERT INTO `llm_model_config`
(`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`, `llm_model_name`,
`llm_timeout`, `extra_config`, `description`, `is_active`, `is_default`, `created_at`, `updated_at`)
VALUES
(
'vector_dashscope_text_embedding_v4',
'阿里百炼 向量 text-embedding-v4',
'vector',
'dashscope',
'https://dashscope.aliyuncs.com/compatible-mode/v1',
NULL,
'text-embedding-v4',
120,
JSON_OBJECT('dimensions', 1024, 'encoding_format', 'float'),
'系统初始化的默认向量模型配置,向量专属参数存储在 extra_config。',
1,
1,
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
`model_name` = VALUES(`model_name`),
`model_type` = VALUES(`model_type`),
`provider` = VALUES(`provider`),
`endpoint_url` = VALUES(`endpoint_url`),
`api_key` = VALUES(`api_key`),
`llm_model_name` = VALUES(`llm_model_name`),
`llm_timeout` = VALUES(`llm_timeout`),
`extra_config` = VALUES(`extra_config`),
`description` = VALUES(`description`), `description` = VALUES(`description`),
`is_active` = VALUES(`is_active`), `is_active` = VALUES(`is_active`),
`is_default` = VALUES(`is_default`), `is_default` = VALUES(`is_default`),
`updated_at` = VALUES(`updated_at`); `updated_at` = VALUES(`updated_at`);
INSERT INTO `audio_model_config` INSERT INTO `audio_model_config`
(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`, (`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`,
`request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`, `request_timeout_seconds`, `extra_config`, `description`,
`is_active`, `is_default`, `created_at`, `updated_at`) `is_active`, `is_default`, `created_at`, `updated_at`)
SELECT SELECT
'audio_model', 'audio_model',
@ -282,7 +316,6 @@ SELECT
'https://dashscope.aliyuncs.com', 'https://dashscope.aliyuncs.com',
NULL, NULL,
300, 300,
g.`id`,
JSON_OBJECT( JSON_OBJECT(
'model', 'paraformer-v2', 'model', 'paraformer-v2',
'vocabulary_id', g.`vocabulary_id`, 'vocabulary_id', g.`vocabulary_id`,
@ -300,12 +333,11 @@ FROM `hot_word_group` g
WHERE g.`id` = @default_hot_word_group_id WHERE g.`id` = @default_hot_word_group_id
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`model_name` = VALUES(`model_name`), `model_name` = VALUES(`model_name`),
`audio_scene` = VALUES(`audio_scene`), `model_type` = VALUES(`model_type`),
`provider` = VALUES(`provider`), `provider` = VALUES(`provider`),
`endpoint_url` = VALUES(`endpoint_url`), `endpoint_url` = VALUES(`endpoint_url`),
`api_key` = VALUES(`api_key`), `api_key` = VALUES(`api_key`),
`request_timeout_seconds` = VALUES(`request_timeout_seconds`), `request_timeout_seconds` = VALUES(`request_timeout_seconds`),
`hot_word_group_id` = VALUES(`hot_word_group_id`),
`extra_config` = VALUES(`extra_config`), `extra_config` = VALUES(`extra_config`),
`description` = VALUES(`description`), `description` = VALUES(`description`),
`is_active` = VALUES(`is_active`), `is_active` = VALUES(`is_active`),
@ -313,8 +345,8 @@ ON DUPLICATE KEY UPDATE
`updated_at` = VALUES(`updated_at`); `updated_at` = VALUES(`updated_at`);
INSERT INTO `audio_model_config` INSERT INTO `audio_model_config`
(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`, (`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`,
`request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`, `request_timeout_seconds`, `extra_config`, `description`,
`is_active`, `is_default`, `created_at`, `updated_at`) `is_active`, `is_default`, `created_at`, `updated_at`)
VALUES VALUES
( (
@ -325,7 +357,6 @@ VALUES
NULL, NULL,
NULL, NULL,
120, 120,
NULL,
JSON_OBJECT( JSON_OBJECT(
'template_text', '我正在进行声纹采集,这段语音将用于身份识别和验证。声纹技术能够准确识别每个人独特的声音特征。', 'template_text', '我正在进行声纹采集,这段语音将用于身份识别和验证。声纹技术能够准确识别每个人独特的声音特征。',
'duration_seconds', 12, 'duration_seconds', 12,
@ -341,12 +372,11 @@ VALUES
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`model_name` = VALUES(`model_name`), `model_name` = VALUES(`model_name`),
`audio_scene` = VALUES(`audio_scene`), `model_type` = VALUES(`model_type`),
`provider` = VALUES(`provider`), `provider` = VALUES(`provider`),
`endpoint_url` = VALUES(`endpoint_url`), `endpoint_url` = VALUES(`endpoint_url`),
`api_key` = VALUES(`api_key`), `api_key` = VALUES(`api_key`),
`request_timeout_seconds` = VALUES(`request_timeout_seconds`), `request_timeout_seconds` = VALUES(`request_timeout_seconds`),
`hot_word_group_id` = VALUES(`hot_word_group_id`),
`extra_config` = VALUES(`extra_config`), `extra_config` = VALUES(`extra_config`),
`description` = VALUES(`description`), `description` = VALUES(`description`),
`is_active` = VALUES(`is_active`), `is_active` = VALUES(`is_active`),

View File

@ -0,0 +1,198 @@
-- Model config field migration
-- Target:
-- 1. Move text-model generation params from llm_model_config columns into extra_config.
-- 2. Rename audio_model_config.audio_scene to audio_model_config.model_type.
-- 3. Keep ASR vocabulary on audio_model_config.extra_config.vocabulary_id.
-- ---------------------------------------------------------------------
-- 1. Add new columns before deploying the new code.
-- Run these only if the columns/indexes do not already exist.
-- ---------------------------------------------------------------------
-- ALTER TABLE `llm_model_config`
-- ADD COLUMN `model_type` VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT '模型类型: text / vector' AFTER `model_name`;
--
-- ALTER TABLE `llm_model_config`
-- ADD COLUMN `extra_config` JSON DEFAULT NULL COMMENT '模型差异化配置' AFTER `llm_timeout`;
--
-- ALTER TABLE `llm_model_config`
-- ADD KEY `idx_llm_model_config_type` (`model_type`);
--
-- ALTER TABLE `audio_model_config`
-- ADD COLUMN `model_type` VARCHAR(32) DEFAULT NULL COMMENT '模型类型: asr / voiceprint' AFTER `model_name`;
--
-- ALTER TABLE `audio_model_config`
-- ADD KEY `idx_audio_model_config_type` (`model_type`);
-- ---------------------------------------------------------------------
-- 2. Data migration.
-- Safe to run after a partial migration: old-column based moves only run
-- when those old columns still exist.
-- ---------------------------------------------------------------------
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_model_config_data $$
CREATE PROCEDURE migrate_model_config_data()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llm_model_config'
AND COLUMN_NAME = 'llm_temperature'
) THEN
UPDATE `llm_model_config`
SET
`model_type` = COALESCE(NULLIF(`model_type`, ''), 'text'),
`extra_config` = CASE
WHEN COALESCE(NULLIF(`model_type`, ''), 'text') = 'text' THEN
JSON_SET(
COALESCE(`extra_config`, JSON_OBJECT()),
'$.temperature', CAST(`llm_temperature` AS DECIMAL(5,2)),
'$.top_p', CAST(`llm_top_p` AS DECIMAL(5,2)),
'$.max_tokens', CAST(`llm_max_tokens` AS UNSIGNED),
'$.system_prompt', `llm_system_prompt`
)
ELSE COALESCE(`extra_config`, JSON_OBJECT())
END;
ELSE
UPDATE `llm_model_config`
SET
`model_type` = COALESCE(NULLIF(`model_type`, ''), 'text'),
`extra_config` = COALESCE(`extra_config`, JSON_OBJECT());
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND COLUMN_NAME = 'audio_scene'
) THEN
UPDATE `audio_model_config`
SET `model_type` = `audio_scene`
WHERE `model_type` IS NULL OR `model_type` = '';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND COLUMN_NAME = 'hot_word_group_id'
) THEN
UPDATE `audio_model_config` a
JOIN `hot_word_group` g ON g.`id` = a.`hot_word_group_id`
SET a.`extra_config` = JSON_SET(
COALESCE(a.`extra_config`, JSON_OBJECT()),
'$.vocabulary_id',
g.`vocabulary_id`
)
WHERE a.`model_type` = 'asr'
AND a.`hot_word_group_id` IS NOT NULL
AND g.`vocabulary_id` IS NOT NULL
AND g.`vocabulary_id` <> '';
END IF;
ALTER TABLE `audio_model_config`
MODIFY COLUMN `model_type` VARCHAR(32) NOT NULL COMMENT '模型类型: asr / voiceprint';
END $$
CALL migrate_model_config_data() $$
DROP PROCEDURE IF EXISTS migrate_model_config_data $$
DELIMITER ;
-- ---------------------------------------------------------------------
-- 3. Cleanup after the new code is deployed and verified.
-- The following block is idempotent for MySQL: it checks the current
-- schema before dropping legacy columns/indexes/constraints.
-- ---------------------------------------------------------------------
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_model_config_cleanup $$
CREATE PROCEDURE migrate_model_config_cleanup()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llm_model_config'
AND COLUMN_NAME = 'llm_temperature'
) THEN
ALTER TABLE `llm_model_config` DROP COLUMN `llm_temperature`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llm_model_config'
AND COLUMN_NAME = 'llm_top_p'
) THEN
ALTER TABLE `llm_model_config` DROP COLUMN `llm_top_p`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llm_model_config'
AND COLUMN_NAME = 'llm_max_tokens'
) THEN
ALTER TABLE `llm_model_config` DROP COLUMN `llm_max_tokens`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llm_model_config'
AND COLUMN_NAME = 'llm_system_prompt'
) THEN
ALTER TABLE `llm_model_config` DROP COLUMN `llm_system_prompt`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND INDEX_NAME = 'idx_audio_model_config_scene'
) THEN
ALTER TABLE `audio_model_config` DROP INDEX `idx_audio_model_config_scene`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND COLUMN_NAME = 'audio_scene'
) THEN
ALTER TABLE `audio_model_config` DROP COLUMN `audio_scene`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND CONSTRAINT_NAME = 'fk_audio_model_config_hot_word_group_id'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
) THEN
ALTER TABLE `audio_model_config` DROP FOREIGN KEY `fk_audio_model_config_hot_word_group_id`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND INDEX_NAME = 'idx_audio_model_config_hot_word_group_id'
) THEN
ALTER TABLE `audio_model_config` DROP INDEX `idx_audio_model_config_hot_word_group_id`;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audio_model_config'
AND COLUMN_NAME = 'hot_word_group_id'
) THEN
ALTER TABLE `audio_model_config` DROP COLUMN `hot_word_group_id`;
END IF;
END $$
CALL migrate_model_config_cleanup() $$
DROP PROCEDURE IF EXISTS migrate_model_config_cleanup $$
DELIMITER ;

View File

@ -21,7 +21,8 @@ print_warning() {
} }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
if docker compose version &> /dev/null; then if docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose" COMPOSE_CMD="docker compose"

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# iMeeting Docker 停止脚本 # iMeeting Docker 全量部署停止脚本
set -e set -e
@ -11,6 +11,10 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
print_info() { print_info() {
echo -e "${BLUE}[INFO]${NC} $1" echo -e "${BLUE}[INFO]${NC} $1"
} }

View File

@ -1,139 +0,0 @@
#!/bin/bash
# Exit on error
set -e
# Project Root Directory
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$PROJECT_ROOT/backend"
FRONTEND_DIR="$PROJECT_ROOT/frontend"
# Define environment names
BACKEND_ENV="imetting_backend"
FRONTEND_ENV="imetting_frontend"
PYTHON_VERSION="3.12"
NODE_VERSION="22"
echo "========================================"
echo "Starting iMeeting with Conda"
echo "========================================"
# Try to find conda if not in PATH
if ! command -v conda &> /dev/null; then
echo "Conda not found in PATH, trying common locations..."
# Check common installation paths
CONDA_PATHS=(
"$HOME/miniconda3/bin"
"$HOME/anaconda3/bin"
"/opt/conda/bin"
"$HOME/miniconda/bin"
"$HOME/anaconda/bin"
"/usr/local/miniconda3/bin"
"/usr/local/anaconda3/bin"
)
for bin_path in "${CONDA_PATHS[@]}"; do
if [ -x "$bin_path/conda" ]; then
export PATH="$bin_path:$PATH"
echo "Found conda at: $bin_path/conda"
break
fi
done
fi
if ! command -v conda &> /dev/null; then
echo "Error: Conda is still not found."
echo "Please ensure Miniconda or Anaconda is installed and accessible."
exit 1
fi
# Initialize conda for bash script
eval "$(conda shell.bash hook)"
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main >/dev/null 2>&1 || true
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r >/dev/null 2>&1 || true
version_gte() {
[ "$(printf '%s\n' "$1" "$2" | sort -V | head -n 1)" = "$2" ]
}
node_version_supported_for_vite() {
local version="$1"
version="${version#v}"
if version_gte "$version" "22.12.0"; then
return 0
fi
if version_gte "$version" "20.19.0" && ! version_gte "$version" "21.0.0"; then
return 0
fi
return 1
}
BACKEND_PYTHON_VERSION=""
if conda info --envs | awk '{print $1}' | grep -qx "$BACKEND_ENV"; then
BACKEND_PYTHON_VERSION="$(conda run -n "$BACKEND_ENV" python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null | tail -n 1 | tr -d '[:space:]')"
if [ "$BACKEND_PYTHON_VERSION" != "$PYTHON_VERSION" ]; then
echo "Backend environment '$BACKEND_ENV' is using Python $BACKEND_PYTHON_VERSION, recreating with Python $PYTHON_VERSION..."
conda remove -y -n "$BACKEND_ENV" --all
else
echo "Backend environment '$BACKEND_ENV' already exists with Python $PYTHON_VERSION."
fi
fi
if ! conda info --envs | awk '{print $1}' | grep -qx "$BACKEND_ENV"; then
echo "Creating Conda environment '$BACKEND_ENV' with Python $PYTHON_VERSION..."
conda create -y -n "$BACKEND_ENV" python=$PYTHON_VERSION -c conda-forge --override-channels
fi
FRONTEND_NODE_VERSION=""
if conda info --envs | awk '{print $1}' | grep -qx "$FRONTEND_ENV"; then
FRONTEND_NODE_VERSION="$(conda run -n "$FRONTEND_ENV" node -p 'process.versions.node' 2>/dev/null | tail -n 1 | tr -d '[:space:]')"
if node_version_supported_for_vite "$FRONTEND_NODE_VERSION"; then
echo "Frontend environment '$FRONTEND_ENV' already exists with Node.js $FRONTEND_NODE_VERSION."
else
echo "Frontend environment '$FRONTEND_ENV' is using Node.js $FRONTEND_NODE_VERSION, recreating with Node.js $NODE_VERSION..."
conda remove -y -n "$FRONTEND_ENV" --all
fi
fi
if ! conda info --envs | awk '{print $1}' | grep -qx "$FRONTEND_ENV"; then
echo "Creating Conda environment '$FRONTEND_ENV' with Node.js $NODE_VERSION..."
conda create -y -n "$FRONTEND_ENV" nodejs=$NODE_VERSION -c conda-forge --override-channels
fi
# Start Backend in a subshell
echo "Starting backend..."
(
eval "$(conda shell.bash hook)"
conda activate "$BACKEND_ENV"
cd "$BACKEND_DIR"
pip install -r requirements.txt
python app/main.py
) &
BACKEND_PID=$!
# Start Frontend in a subshell
echo "Starting frontend..."
(
eval "$(conda shell.bash hook)"
conda activate "$FRONTEND_ENV"
cd "$FRONTEND_DIR"
npm install
npm run dev
) &
FRONTEND_PID=$!
echo "========================================"
echo "iMeeting is starting!"
echo "Backend Env: $BACKEND_ENV (PID: $BACKEND_PID)"
echo "Frontend Env: $FRONTEND_ENV (PID: $FRONTEND_PID)"
echo "Backend URL: http://localhost:8000"
echo "Frontend URL: http://localhost:5173"
echo "Press Ctrl+C to stop both services."
echo "========================================"
# Trap Ctrl+C (SIGINT) and kill both processes
trap "echo -e '\nStopping services...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM
# Wait for background processes to keep the script running
wait $BACKEND_PID $FRONTEND_PID