v1.1.2
parent
d2ae4b63c6
commit
a943ec5960
10
.env.example
10
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
资料/
|
资料/
|
||||||
|
|
||||||
|
|
|
||||||
439
DOCKER_README.md
439
DOCKER_README.md
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**祝您使用愉快!** 🎉
|
|
||||||
|
|
@ -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
212
README.md
|
|
@ -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 原生应用
|
|
||||||
- [ ] 多语言支持 - 支持中英文等多语言界面
|
|
||||||
- [ ] 会议协作 - 支持多人实时协作编辑会议纪要
|
|
||||||
- [ ] 数据分析 - 会议统计分析和可视化报表
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[请添加许可证信息]
|
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
[请添加联系方式]
|
|
||||||
|
|
|
||||||
908
ROADMAP.md
908
ROADMAP.md
|
|
@ -1,908 +0,0 @@
|
||||||
# iMeeting 产品路线图
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
iMeeting 是一个智能会议管理和知识库系统,旨在通过 AI 技术提升会议效率,自动生成会议纪要,并从多次会议中提炼知识。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: 基础会议管理与知识库系统(已完成)
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
#### 1. 会议管理
|
|
||||||
- **会议创建与录音**:支持创建会议并关联音频文件
|
|
||||||
- **音频转录**:基于阿里云 DashScope 的语音识别,支持异步转录
|
|
||||||
- **AI 总结生成**:使用大语言模型(通义千问)自动生成会议纪要
|
|
||||||
- **提示词模版**:支持预定义和自定义提示词模版,适配不同会议场景
|
|
||||||
- **标签系统**:支持为会议添加标签,方便分类和检索
|
|
||||||
|
|
||||||
#### 2. 知识库系统
|
|
||||||
- **基于会议的知识提炼**:从多个会议纪要中提炼知识库内容
|
|
||||||
- **自定义提示词**:用户可以指定特定的分析角度和总结需求
|
|
||||||
- **Markdown 输出**:知识库内容以 Markdown 格式存储和展示
|
|
||||||
- **标签关联**:支持知识库的标签分类
|
|
||||||
- **共享机制**:支持知识库的个人/共享模式
|
|
||||||
|
|
||||||
#### 3. 用户权限管理
|
|
||||||
- **用户认证**:基于 JWT 的用户登录与会话管理
|
|
||||||
- **角色权限**:区分普通用户和管理员
|
|
||||||
- **菜单权限**:动态菜单权限控制系统
|
|
||||||
|
|
||||||
#### 4. 管理后台
|
|
||||||
- **用户管理**:用户创建、编辑、删除、权限配置
|
|
||||||
- **系统监控**:在线用户、任务监控、系统资源监控
|
|
||||||
- **提示词仓库**:集中管理所有提示词模版
|
|
||||||
|
|
||||||
### 技术架构
|
|
||||||
|
|
||||||
#### 后端技术栈
|
|
||||||
- **框架**:FastAPI(Python)
|
|
||||||
- **数据库**: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-docx(DOCX 文件)
|
|
||||||
- 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 API(0.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" // 已有
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 基础设施
|
|
||||||
- **向量数据库**:Qdrant(Docker 部署)
|
|
||||||
```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 问答)。
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
12
deploy.md
12
deploy.md
|
|
@ -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/...` 相对路径,与音频文件路径保持一致
|
|
||||||
|
|
@ -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 原生应用
|
||||||
|
- [ ] 多语言支持 - 支持中英文等多语言界面
|
||||||
|
- [ ] 会议协作 - 支持多人实时协作编辑会议纪要
|
||||||
|
- [ ] 数据分析 - 会议统计分析和可视化报表
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[请添加许可证信息]
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
[请添加联系方式]
|
||||||
|
|
@ -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`。
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="暂无已同步的热词组,请先在热词管理中同步"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
305
manage.sh
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "imeeting",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{}
|
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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` (
|
||||||
|
|
@ -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`),
|
||||||
|
|
@ -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 ;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
139
start-conda.sh
139
start-conda.sh
|
|
@ -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
|
|
||||||
Loading…
Reference in New Issue