diff --git a/.env.example b/.env.example index 5a9b6eb..3376377 100644 --- a/.env.example +++ b/.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。 -# 2. 直接运行后端或外接中间件部署: -# `start-external.sh` 也只读取当前文件; +# 2. 半部署 / 应用部署(./scripts/deploy-app-only.sh): +# 只启动 backend/frontend,不启动 MySQL/Redis; # 外接中间件时可直接在这里填写 MYSQL_* / REDIS_*,脚本会转换给后端使用。 # ==================== 数据库配置 ==================== # MySQL 初始化参数(Docker 内置 MySQL)。 # 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。 -# 使用 `./start-external.sh` 时,请改成外部 MySQL 地址或服务名。 +# 使用 `./scripts/deploy-app-only.sh` 时,请改成外部 MySQL 地址或服务名。 MYSQL_HOST=mysql MYSQL_ROOT_PASSWORD=change_this_password MYSQL_DATABASE=imeeting @@ -19,7 +19,7 @@ MYSQL_PORT=3306 # ==================== 缓存配置 ==================== # Redis 初始化参数(Docker 内置 Redis)。 # 当后端也运行在 Docker 中时,Redis 主机应为服务名 `redis`。 -# 使用 `./start-external.sh` 时,请改成外部 Redis 地址或服务名。 +# 使用 `./scripts/deploy-app-only.sh` 时,请改成外部 Redis 地址或服务名。 REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD=change_this_password diff --git a/.gitignore b/.gitignore index 70f3274..4fd6cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,8 @@ backend/uploads/ backend/logs/ backend/venv/ backend/test/ -# Only keep the latest full-deploy SQL entrypoints in repo -backend/sql/* -!backend/sql/imeeting-schema-latest.sql -!backend/sql/imeeting-seed-latest.sql -backend/sql/migrations/ +# Full-deploy SQL entrypoints are kept under scripts/sql/. +backend/sql/ backend/scripts/ 资料/ diff --git a/DOCKER_README.md b/DOCKER_README.md deleted file mode 100644 index 0150d15..0000000 --- a/DOCKER_README.md +++ /dev/null @@ -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 -``` - ---- - -**祝您使用愉快!** 🎉 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index de5cc84..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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 diff --git a/README.md b/README.md index f241cbe..0da2855 100644 --- a/README.md +++ b/README.md @@ -1,178 +1,50 @@ # 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 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` - -#### 使用 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 原生应用 -- [ ] 多语言支持 - 支持中英文等多语言界面 -- [ ] 会议协作 - 支持多人实时协作编辑会议纪要 -- [ ] 数据分析 - 会议统计分析和可视化报表 - -## 许可证 - -[请添加许可证信息] - -## 联系方式 - -[请添加联系方式] +- 后端:Python / FastAPI +- 前端:React / Vite / Ant Design +- 数据库:MySQL +- 缓存:Redis +- 部署:Docker Compose diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 1bd1800..0000000 --- a/ROADMAP.md +++ /dev/null @@ -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 ( -
-

🎧 音频概览

- -
- ); -}; - -// 智能问答组件 -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 ( -
-
- {messages.map((msg, idx) => ( -
-
{msg.content}
- {msg.sources && ( -
-

📚 来源

- {msg.sources.map((src, i) => ( -
- {src.section} -

{src.text}...

-
- ))} -
- )} -
- ))} -
-
- setInput(e.target.value)} - placeholder="向知识库提问..." - onKeyPress={(e) => e.key === 'Enter' && handleAsk()} - /> - -
-
- ); -}; -``` - ---- - -## 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 问答)。 diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py index 5747d01..21733f0 100644 --- a/backend/app/api/endpoints/admin_settings.py +++ b/backend/app/api/endpoints/admin_settings.py @@ -23,15 +23,13 @@ class ParameterUpsertRequest(BaseModel): class LLMModelUpsertRequest(BaseModel): model_code: str model_name: str + model_type: str = "text" provider: str | None = None endpoint_url: str | None = None api_key: str | None = None llm_model_name: str llm_timeout: int = 120 - llm_temperature: float = 0.7 - llm_top_p: float = 0.9 - llm_max_tokens: int = 2048 - llm_system_prompt: str | None = None + extra_config: dict[str, Any] | None = None description: str | None = None is_active: bool = True is_default: bool = False @@ -40,13 +38,12 @@ class LLMModelUpsertRequest(BaseModel): class AudioModelUpsertRequest(BaseModel): model_code: str model_name: str - audio_scene: str + model_type: str provider: str | None = None endpoint_url: str | None = None api_key: str | None = None request_timeout_seconds: int = 300 extra_config: dict[str, Any] | None = None - hot_word_group_id: int | None = None description: str | None = None is_active: bool = True is_default: bool = False diff --git a/backend/app/api/endpoints/hot_words.py b/backend/app/api/endpoints/hot_words.py index aab77dc..46c0c74 100644 --- a/backend/app/api/endpoints/hot_words.py +++ b/backend/app/api/endpoints/hot_words.py @@ -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) async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)): - """删除组(级联删除条目),同时清除关联的 audio_model_config""" + """删除组(级联删除条目),同时清除引用该词表ID的音频模型配置""" try: with get_db_connection() as conn: - cursor = conn.cursor() - # 清除引用该组的音频模型配置 - cursor.execute( - """ - UPDATE audio_model_config - SET hot_word_group_id = NULL, - extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id') - WHERE hot_word_group_id = %s - """, - (id,), - ) + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (id,)) + group_row = cursor.fetchone() + vocabulary_id = group_row.get("vocabulary_id") if group_row else None + if vocabulary_id: + cursor.execute( + """ + UPDATE audio_model_config + SET extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_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_group WHERE id = %s", (id,)) conn.commit() @@ -196,16 +198,6 @@ async def sync_group(id: int, current_user: dict = Depends(get_current_admin_use (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() cursor.close() return create_api_response( diff --git a/backend/app/api/endpoints/terminals.py b/backend/app/api/endpoints/terminals.py index 9567fa6..dd620d1 100644 --- a/backend/app/api/endpoints/terminals.py +++ b/backend/app/api/endpoints/terminals.py @@ -3,7 +3,7 @@ from typing import Optional import traceback from app.core.auth import get_current_admin_user 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 router = APIRouter() @@ -45,32 +45,6 @@ async def get_terminals( 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) async def get_terminal_detail( terminal_id: int, diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index b123ac7..e83003c 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -28,7 +28,7 @@ class MCPPathNormalizeMiddleware: class TerminalCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行 - imei = request.headers.get("Imei") + imei = (request.headers.get("Imei") or "").strip() if not imei: return await call_next(request) @@ -53,9 +53,9 @@ class TerminalCheckMiddleware(BaseHTTPMiddleware): pass # 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 = request.headers.get("deviceInfo", "Unknown Device") + device_info = (request.headers.get("deviceInfo") or "Unknown Device").strip() or "Unknown Device" # 获取客户端IP (考虑代理) client_ip = request.client.host diff --git a/backend/app/services/admin_settings_service.py b/backend/app/services/admin_settings_service.py index 03a01d2..b18508a 100644 --- a/backend/app/services/admin_settings_service.py +++ b/backend/app/services/admin_settings_service.py @@ -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]: extra_config = _parse_json_object(request.extra_config) - if request.audio_scene == "asr": + if request.model_type == "asr": if vocabulary_id: extra_config["vocabulary_id"] = vocabulary_id else: @@ -135,6 +135,47 @@ def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict 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]: 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 -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") - if request.hot_word_group_id: - 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 + return str(vocabulary_id).strip() if vocabulary_id else None def list_parameters(category: str | None = None, keyword: str | None = None): @@ -316,13 +352,13 @@ def list_llm_model_configs(): cursor.execute( """ 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_system_prompt, description, is_active, is_default, created_at, updated_at + llm_model_name, llm_timeout, model_type, extra_config, description, is_active, is_default, + created_at, updated_at 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( code="200", message="获取LLM模型配置成功", @@ -334,38 +370,40 @@ def list_llm_model_configs(): def create_llm_model_config(request): 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: cursor = conn.cursor(dictionary=True) cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,)) if cursor.fetchone(): 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} is_default = bool(request.is_default) or total_row["total"] == 0 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( """ INSERT INTO llm_model_config - (model_code, model_name, provider, endpoint_url, api_key, llm_model_name, - llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, - description, is_active, is_default) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + (model_code, model_name, model_type, provider, endpoint_url, api_key, llm_model_name, + llm_timeout, extra_config, description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( request.model_code, request.model_name, + request.model_type, request.provider, request.endpoint_url, request.api_key, request.llm_model_name, request.llm_timeout, - request.llm_temperature, - request.llm_top_p, - request.llm_max_tokens, - request.llm_system_prompt, + json.dumps(extra_config, ensure_ascii=False), request.description, 1 if request.is_active 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): 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: 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() if not existed: 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 if new_model_code != model_code: @@ -395,16 +438,18 @@ def update_llm_model_config(model_code: str, request): if request.is_default: cursor.execute( - "UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", - (model_code,), + "UPDATE llm_model_config SET is_default = 0 WHERE model_type = %s AND model_code <> %s AND is_default = 1", + (request.model_type, model_code), ) + extra_config = _merge_llm_extra_config(request) + cursor.execute( """ UPDATE llm_model_config 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_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s + llm_model_name = %s, llm_timeout = %s, extra_config = %s, description = %s, + is_active = %s, is_default = %s WHERE model_code = %s """, ( @@ -415,10 +460,7 @@ def update_llm_model_config(model_code: str, request): request.api_key, request.llm_model_name, request.llm_timeout, - request.llm_temperature, - request.llm_top_p, - request.llm_max_tokens, - request.llm_system_prompt, + json.dumps(extra_config, ensure_ascii=False), request.description, 1 if request.is_active 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: cursor = conn.cursor(dictionary=True) sql = """ - SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key, - a.request_timeout_seconds, a.hot_word_group_id, a.extra_config, - 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 + 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.extra_config, + a.description, a.is_active, a.is_default, a.created_at, a.updated_at FROM audio_model_config a - LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id """ params = [] if scene in ("asr", "voiceprint"): - sql += " WHERE a.audio_scene = %s" + sql += " WHERE a.model_type = %s" 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)) rows = [_normalize_audio_row(row) for row in cursor.fetchall()] 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): try: - if request.audio_scene not in ("asr", "voiceprint"): - return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + if request.model_type not in ("asr", "voiceprint"): + return create_api_response(code="400", message="model_type 仅支持 asr 或 voiceprint") with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) @@ -466,34 +506,33 @@ def create_audio_model_config(request): if cursor.fetchone(): 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} is_default = bool(request.is_default) or total_row["total"] == 0 if is_default: cursor.execute( - "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", - (request.audio_scene,), + "UPDATE audio_model_config SET is_default = 0 WHERE model_type = %s AND is_default = 1", + (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) cursor.execute( """ INSERT INTO audio_model_config - (model_code, model_name, audio_scene, provider, endpoint_url, api_key, - request_timeout_seconds, hot_word_group_id, extra_config, description, is_active, is_default) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + (model_code, model_name, model_type, provider, endpoint_url, api_key, + request_timeout_seconds, extra_config, description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( request.model_code, request.model_name, - request.audio_scene, + request.model_type, request.provider, request.endpoint_url, request.api_key, request.request_timeout_seconds, - request.hot_word_group_id, json.dumps(extra_config, ensure_ascii=False), request.description, 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): try: - if request.audio_scene not in ("asr", "voiceprint"): - return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + if request.model_type not in ("asr", "voiceprint"): + return create_api_response(code="400", message="model_type 仅支持 asr 或 voiceprint") with get_db_connection() as conn: 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() if not existed: 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 if new_model_code != model_code: @@ -527,30 +568,29 @@ def update_audio_model_config(model_code: str, request): if request.is_default: cursor.execute( - "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1", - (request.audio_scene, model_code), + "UPDATE audio_model_config SET is_default = 0 WHERE model_type = %s AND model_code <> %s AND is_default = 1", + (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) cursor.execute( """ UPDATE audio_model_config - SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s, - request_timeout_seconds = %s, hot_word_group_id = %s, extra_config = %s, + SET model_code = %s, model_name = %s, model_type = %s, provider = %s, endpoint_url = %s, api_key = %s, + request_timeout_seconds = %s, extra_config = %s, description = %s, is_active = %s, is_default = %s WHERE model_code = %s """, ( new_model_code, request.model_name, - request.audio_scene, + request.model_type, request.provider, request.endpoint_url, request.api_key, request.request_timeout_seconds, - request.hot_word_group_id, json.dumps(extra_config, ensure_ascii=False), request.description, 1 if request.is_active else 0, @@ -605,20 +645,16 @@ def test_llm_model_config(request): def test_audio_model_config(request): try: - if request.audio_scene != "asr": + if request.model_type != "asr": return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试") - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) - + vocabulary_id = _resolve_audio_vocabulary_id(request) extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id) runtime_config = { "provider": request.provider, "endpoint_url": request.endpoint_url, "api_key": request.api_key, - "audio_scene": request.audio_scene, - "hot_word_group_id": request.hot_word_group_id, + "model_type": request.model_type, "request_timeout_seconds": request.request_timeout_seconds, **extra_config, } diff --git a/backend/app/services/meeting_service.py b/backend/app/services/meeting_service.py index 5ceaeb0..750920a 100644 --- a/backend/app/services/meeting_service.py +++ b/backend/app/services/meeting_service.py @@ -1084,7 +1084,12 @@ def list_active_llm_models(current_user: dict): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) 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() return create_api_response(code="200", message="获取模型列表成功", data=models) diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index 753ad6f..e2b30fa 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -174,10 +174,8 @@ class SystemConfigService: cfg["provider"] = audio_row["provider"] if audio_row.get("model_code"): cfg["model_code"] = audio_row["model_code"] - if audio_row.get("audio_scene"): - cfg["audio_scene"] = audio_row["audio_scene"] - 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("model_type"): + cfg["model_type"] = audio_row["model_type"] if audio_row.get("request_timeout_seconds") is not None: cfg["request_timeout_seconds"] = int(audio_row["request_timeout_seconds"]) @@ -195,10 +193,10 @@ class SystemConfigService: cursor = conn.cursor(dictionary=True) cursor.execute( """ - SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, - request_timeout_seconds, hot_word_group_id, extra_config + SELECT model_code, model_name, model_type, provider, endpoint_url, api_key, + request_timeout_seconds, extra_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 LIMIT 1 """, @@ -238,10 +236,9 @@ class SystemConfigService: # 1) llm 专表 cursor.execute( """ - SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, - llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt + SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, extra_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 LIMIT 1 """, @@ -251,10 +248,9 @@ class SystemConfigService: if not llm_row and model_code == "llm_model": cursor.execute( """ - SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, - llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt + SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, extra_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 LIMIT 1 """ @@ -271,14 +267,7 @@ class SystemConfigService: cfg["model_name"] = llm_row["llm_model_name"] if llm_row.get("llm_timeout") is not None: cfg["time_out"] = llm_row["llm_timeout"] - if llm_row.get("llm_temperature") is not None: - 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"] + cfg.update(cls._parse_json_object(llm_row.get("extra_config"))) return cfg # 2) audio 专表 @@ -290,8 +279,8 @@ class SystemConfigService: cursor.execute( """ - SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, - request_timeout_seconds, hot_word_group_id, extra_config + SELECT model_code, model_name, model_type, provider, endpoint_url, api_key, + request_timeout_seconds, extra_config FROM audio_model_config WHERE model_code = %s AND is_active = 1 ORDER BY is_default DESC, config_id ASC @@ -404,7 +393,7 @@ class SystemConfigService: cursor.execute( """ 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 ( 'audio_model', '音频识别模型', @@ -556,7 +545,7 @@ class SystemConfigService: # 便捷方法:获取特定配置 @classmethod 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") if audio_cfg.get("vocabulary_id"): return audio_cfg["vocabulary_id"] diff --git a/backend/app/services/terminal_service.py b/backend/app/services/terminal_service.py index 6eaa7bf..4259b9b 100644 --- a/backend/app/services/terminal_service.py +++ b/backend/app/services/terminal_service.py @@ -1,9 +1,13 @@ from typing import List, Optional, Tuple, Dict, Any from app.core.database import get_db_connection -from app.models.models import Terminal, CreateTerminalRequest, UpdateTerminalRequest +from app.models.models import UpdateTerminalRequest import datetime class TerminalService: + @staticmethod + def _normalize_imei(imei: str) -> str: + return str(imei or "").strip() + def get_terminals(self, page: int = 1, size: int = 20, @@ -34,8 +38,18 @@ class TerminalService: 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) total = cursor.fetchone()['total'] @@ -48,7 +62,7 @@ class TerminalService: cu.username as current_username, cu.caption as current_user_caption, 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 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' @@ -89,42 +103,17 @@ class TerminalService: """ 根据IMEI获取终端详情 """ + normalized_imei = self._normalize_imei(imei) with get_db_connection() as conn: 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() cursor.close() 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: """ 更新终端信息 @@ -198,53 +187,71 @@ 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: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) - - # 检查是否存在 - cursor.execute("SELECT id, status, is_activated FROM terminals WHERE imei = %s", (imei,)) + + cursor.execute( + "SELECT id, status, is_activated FROM terminals WHERE TRIM(imei) = %s ORDER BY id DESC LIMIT 1", + (normalized_imei,) + ) existing = cursor.fetchone() - current_time = datetime.datetime.now() - + if existing: # 检查是否被停用 if existing['status'] == 0: return {"allowed": False, "reason": "设备已被停用", "terminal_id": existing['id']} - + # 更新在线时间、IP、名称(如果变了)、当前用户 update_query = """ UPDATE terminals SET last_online_at = %s, ip_address = %s, current_user_id = %s, - # 如果设备没名字,尝试用上报的名字填充 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) 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() - + 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: print(f"Error in check_and_update_terminal: {e}") diff --git a/deploy.md b/deploy.md deleted file mode 100644 index a22cfec..0000000 --- a/deploy.md +++ /dev/null @@ -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/...` 相对路径,与音频文件路径保持一致 diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..8c7611c --- /dev/null +++ b/design/README.md @@ -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 原生应用 +- [ ] 多语言支持 - 支持中英文等多语言界面 +- [ ] 会议协作 - 支持多人实时协作编辑会议纪要 +- [ ] 数据分析 - 会议统计分析和可视化报表 + +## 许可证 + +[请添加许可证信息] + +## 联系方式 + +[请添加联系方式] diff --git a/AI.md b/design/ai-integration.md similarity index 100% rename from AI.md rename to design/ai-integration.md diff --git a/backend/README.md b/design/backend.md similarity index 100% rename from backend/README.md rename to design/backend.md diff --git a/database.md b/design/database.md similarity index 100% rename from database.md rename to design/database.md diff --git a/design/deployment.md b/design/deployment.md new file mode 100644 index 0000000..46f9a41 --- /dev/null +++ b/design/deployment.md @@ -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`。 diff --git a/frontend/README.md b/design/frontend.md similarity index 100% rename from frontend/README.md rename to design/frontend.md diff --git a/frontend/src/services/MEETING_CACHE_USAGE.md b/design/meeting-cache-usage.md similarity index 100% rename from frontend/src/services/MEETING_CACHE_USAGE.md rename to design/meeting-cache-usage.md diff --git a/project.md b/design/project.md similarity index 100% rename from project.md rename to design/project.md diff --git a/docker-compose.yml b/docker-compose.yml index 3709c7f..1850158 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - "${MYSQL_PORT:-3306}:3306" volumes: - ./data/mysql:/var/lib/mysql - - ./backend/sql:/docker-entrypoint-initdb.d:ro + - ./scripts/sql:/docker-entrypoint-initdb.d:ro command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci diff --git a/frontend/src/components/ToggleSwitch.jsx b/frontend/src/components/ToggleSwitch.jsx index 18aee61..e7c0184 100644 --- a/frontend/src/components/ToggleSwitch.jsx +++ b/frontend/src/components/ToggleSwitch.jsx @@ -1,10 +1,10 @@ import React from 'react'; import { Switch } from 'antd'; -const ToggleSwitch = ({ checked, onChange, disabled = false, size = 'default' }) => { +const ToggleSwitch = ({ checked = false, onChange, disabled = false, size = 'default' }) => { return ( `/api/terminals/${id}`, UPDATE: (id) => `/api/terminals/${id}`, DELETE: (id) => `/api/terminals/${id}`, diff --git a/frontend/src/config/modelProviderCatalog.js b/frontend/src/config/modelProviderCatalog.js index 9e9147d..e1684c6 100644 --- a/frontend/src/config/modelProviderCatalog.js +++ b/frontend/src/config/modelProviderCatalog.js @@ -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 = { asr: [ createProvider({ @@ -97,6 +121,9 @@ export const getProviderOptions = (kind, scene = 'asr') => { if (kind === 'llm') { return LLM_PROVIDER_OPTIONS; } + if (kind === 'vector') { + return VECTOR_PROVIDER_OPTIONS; + } return AUDIO_PROVIDER_OPTIONS[scene] || AUDIO_PROVIDER_OPTIONS.asr; }; @@ -125,7 +152,9 @@ export const buildManagedModelName = ({ kind, providerLabel, serviceModelName, s if (kind === 'llm') { return `${providerLabel} ${serviceModelName}`.trim(); } + if (kind === 'vector') { + return `${providerLabel} 向量 ${serviceModelName}`.trim(); + } const sceneLabel = scene === 'voiceprint' ? '声纹' : '音频识别'; return `${providerLabel} ${sceneLabel} ${serviceModelName}`.trim(); }; - diff --git a/frontend/src/pages/admin/ModelManagement.jsx b/frontend/src/pages/admin/ModelManagement.jsx index 487ca24..7431289 100644 --- a/frontend/src/pages/admin/ModelManagement.jsx +++ b/frontend/src/pages/admin/ModelManagement.jsx @@ -34,12 +34,17 @@ import ActionButton from '../../components/ActionButton'; import StatusTag from '../../components/StatusTag'; import useSystemPageSize from '../../hooks/useSystemPageSize'; -const AUDIO_SCENE_OPTIONS = [ +const AUDIO_TYPE_OPTIONS = [ { label: '全部', value: 'all' }, { label: '音频识别', value: 'asr' }, { label: '声纹模型', value: 'voiceprint' }, ]; +const LLM_TYPE_OPTIONS = [ + { label: '文本模型', value: 'text' }, + { label: '向量模型', value: 'vector' }, +]; + const parseCommaSeparatedText = (value) => String(value || '') .split(',') @@ -56,6 +61,11 @@ const normalizeAudioExtraConfig = (row = {}) => { 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 { message, modal } = App.useApp(); const [activeTab, setActiveTab] = useState('llm'); @@ -70,15 +80,17 @@ const ModelManagement = () => { const [hotWordGroups, setHotWordGroups] = useState([]); const pageSize = useSystemPageSize(10); 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 llmProviderOptions = useMemo(() => getProviderSelectOptions('llm'), []); - const audioProviderOptions = useMemo(() => getProviderSelectOptions('audio', watchedScene || 'asr'), [watchedScene]); - const llmModelOptions = useMemo(() => getProviderModelOptions('llm', watchedProvider), [watchedProvider]); + const llmKind = watchedModelType === 'vector' ? 'vector' : 'llm'; + const audioModelType = ['asr', 'voiceprint'].includes(watchedModelType) ? watchedModelType : 'asr'; + const llmProviderOptions = useMemo(() => getProviderSelectOptions(llmKind), [llmKind]); + const audioProviderOptions = useMemo(() => getProviderSelectOptions('audio', audioModelType), [audioModelType]); + const llmModelOptions = useMemo(() => getProviderModelOptions(llmKind, watchedProvider), [llmKind, watchedProvider]); const audioModelOptions = useMemo( - () => getProviderModelOptions('audio', watchedProvider, watchedScene || 'asr'), - [watchedProvider, watchedScene], + () => getProviderModelOptions('audio', watchedProvider, audioModelType), + [watchedProvider, audioModelType], ); const applyProviderDefaults = (kind, provider, scene = 'asr') => { @@ -94,28 +106,39 @@ const ModelManagement = () => { }; 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(); return { model_code: buildManagedModelCode({ - kind: 'llm', + kind, provider: values.provider, serviceModelName, }), model_name: buildManagedModelName({ - kind: 'llm', + kind, providerLabel: providerConfig?.label || values.provider, serviceModelName, }), + model_type: modelType, provider: values.provider, endpoint_url: values.endpoint_url, api_key: values.api_key, llm_model_name: serviceModelName, llm_timeout: values.llm_timeout, - llm_temperature: values.llm_temperature, - llm_top_p: values.llm_top_p, - llm_max_tokens: values.llm_max_tokens, - llm_system_prompt: values.llm_system_prompt, + extra_config: modelType === 'vector' + ? { + dimensions: values.vector_dimensions, + 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, is_active: values.is_active, is_default: values.is_default, @@ -123,15 +146,16 @@ const ModelManagement = () => { }; 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 generatedDisplayName = buildManagedModelName({ kind: 'audio', - scene: values.audio_scene, + scene: modelType, providerLabel: providerConfig?.label || values.provider, serviceModelName, }); - const extraConfig = values.audio_scene === 'voiceprint' + const extraConfig = modelType === 'voiceprint' ? { model: serviceModelName, template_text: values.vp_template_text, @@ -151,22 +175,22 @@ const ModelManagement = () => { special_word_filter: values.asr_special_word_filter?.trim(), audio_event_detection_enabled: values.asr_audio_event_detection_enabled, phrase_id: values.asr_phrase_id?.trim(), + vocabulary_id: values.asr_vocabulary_id?.trim(), }; return { model_code: buildManagedModelCode({ kind: 'audio', - scene: values.audio_scene, + scene: modelType, provider: values.provider, serviceModelName, }), model_name: generatedDisplayName, - audio_scene: values.audio_scene, + model_type: modelType, provider: values.provider, endpoint_url: values.endpoint_url, api_key: values.api_key, request_timeout_seconds: values.request_timeout_seconds, - hot_word_group_id: values.hot_word_group_id || null, extra_config: extraConfig, description: values.description, is_active: values.is_active, @@ -212,8 +236,10 @@ const ModelManagement = () => { const openCreate = () => { setEditingRow(null); + form.resetFields(); if (activeTab === 'llm') { form.setFieldsValue({ + model_type: 'text', provider: 'dashscope', endpoint_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api_key: '', @@ -223,19 +249,22 @@ const ModelManagement = () => { llm_top_p: 0.9, llm_max_tokens: 2048, llm_system_prompt: '', + vector_dimensions: undefined, + vector_encoding_format: '', + vector_input_type: '', description: '', is_active: true, is_default: false, }); } else { form.setFieldsValue({ - audio_scene: 'asr', + model_type: 'asr', provider: 'dashscope', endpoint_url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription', api_key: '', service_model_name: 'paraformer-v2', request_timeout_seconds: 300, - hot_word_group_id: undefined, + asr_vocabulary_id: '', asr_speaker_count: 10, asr_language_hints: 'zh,en', asr_disfluency_removal_enabled: true, @@ -260,17 +289,27 @@ const ModelManagement = () => { const openEdit = (row) => { setEditingRow(row); + form.resetFields(); if (activeTab === 'llm') { + const extraConfig = normalizeLlmExtraConfig(row); form.setFieldsValue({ ...row, + model_type: row.model_type || 'text', 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 { const extraConfig = normalizeAudioExtraConfig(row); form.setFieldsValue({ ...row, + model_type: row.model_type, 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, asr_speaker_count: extraConfig.speaker_count, asr_language_hints: Array.isArray(extraConfig.language_hints) @@ -283,6 +322,7 @@ const ModelManagement = () => { asr_special_word_filter: extraConfig.special_word_filter, asr_audio_event_detection_enabled: extraConfig.audio_event_detection_enabled ?? false, asr_phrase_id: extraConfig.phrase_id, + asr_vocabulary_id: extraConfig.vocabulary_id, vp_template_text: extraConfig.template_text, vp_duration_seconds: extraConfig.duration_seconds, vp_sample_rate: extraConfig.sample_rate, @@ -381,10 +421,26 @@ const ModelManagement = () => { const llmColumns = [ { title: '编码', dataIndex: 'model_code', key: 'model_code', width: 170 }, { title: '名称', dataIndex: 'model_name', key: 'model_name', width: 180 }, + { + title: '类型', + dataIndex: 'model_type', + key: 'model_type', + width: 100, + render: (v) => {v === 'vector' ? '向量模型' : '文本模型'}, + }, { title: '供应商', dataIndex: 'provider', key: 'provider', width: 130 }, { 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: '参数', 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) => }, { title: '操作', @@ -402,7 +458,7 @@ const ModelManagement = () => { ]; 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], ); @@ -411,8 +467,8 @@ const ModelManagement = () => { { title: '名称', dataIndex: 'model_name', key: 'model_name', width: 170 }, { title: '场景', - dataIndex: 'audio_scene', - key: 'audio_scene', + dataIndex: 'model_type', + key: 'model_type', width: 120, render: (v) => {v === 'voiceprint' ? '声纹模型' : '音频识别'}, }, @@ -423,9 +479,9 @@ const ModelManagement = () => { render: (_, row) => { const extraConfig = normalizeAudioExtraConfig(row); 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 || '-'} 超时=${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) => }, @@ -449,11 +505,12 @@ const ModelManagement = () => { } title="模型管理" - subtitle="按模型类型拆分底表:LLM模型与音频模型(音频识别/声纹)。" + subtitle="统一管理文本模型、向量模型与音频模型(音频识别/声纹)。" 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.filter((it) => it.audio_scene === 'voiceprint').length }, + { label: '声纹模型', value: audioItems.filter((it) => it.model_type === 'voiceprint').length }, ]} > {
-
LLM模型列表
+
文本/向量模型列表
从内置模型目录选择主流供应商模型,自动填充供应商与 Base API。
- +
@@ -505,7 +562,7 @@ const ModelManagement = () => { className="console-segmented" value={audioSceneFilter} onChange={setAudioSceneFilter} - options={AUDIO_SCENE_OPTIONS} + options={AUDIO_TYPE_OPTIONS} /> @@ -536,7 +593,7 @@ const ModelManagement = () => { onClose={() => setDrawerOpen(false)} extra={( - {(activeTab === 'llm' || watchedScene === 'asr') && ( + {((activeTab === 'llm' && watchedModelType !== 'vector') || audioModelType === 'asr') && ( @@ -546,13 +603,33 @@ const ModelManagement = () => { )} >
+ {activeTab === 'llm' && ( + + { const providerOptions = getProviderSelectOptions('audio', value); @@ -566,7 +643,7 @@ const ModelManagement = () => { + + + + + + ) : ( + <> + + String(value) }} /> + + + String(value) }} /> + + + + + + )} - - - + {watchedModelType !== 'vector' && ( + + + + )} - ) : watchedScene === 'voiceprint' ? ( + ) : audioModelType === 'voiceprint' ? ( <> @@ -634,15 +729,15 @@ const ModelManagement = () => { - + + diff --git a/frontend/src/styles/console-theme.css b/frontend/src/styles/console-theme.css index b31cdff..c3b51f4 100644 --- a/frontend/src/styles/console-theme.css +++ b/frontend/src/styles/console-theme.css @@ -903,9 +903,6 @@ body { /* ── Global Switch geometry ── */ .ant-switch { - min-width: 42px; - height: 24px; - padding: 2px; border: 1px solid #cbdcf1; border-radius: 999px !important; background: linear-gradient(180deg, #eef4fc 0%, #dde8f7 100%) !important; @@ -917,13 +914,6 @@ body { 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 { border-radius: 50% !important; background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); @@ -933,13 +923,3 @@ body { .ant-switch-inner { 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; -} diff --git a/manage.sh b/manage.sh deleted file mode 100755 index a713307..0000000 --- a/manage.sh +++ /dev/null @@ -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 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 00ff604..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "imeeting", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/package.json b/package.json deleted file mode 100644 index 0967ef4..0000000 --- a/package.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/start-external.sh b/scripts/deploy-app-only.sh similarity index 95% rename from start-external.sh rename to scripts/deploy-app-only.sh index 0559832..edfb15c 100755 --- a/start-external.sh +++ b/scripts/deploy-app-only.sh @@ -33,13 +33,14 @@ print_banner() { | | | | | __/ __/ |_| | | | | (_| | |_|_| |_|\___|\___|\__|_|_| |_|\__, | |___/ - External Middleware Deployment + App-only Docker Deployment EOF echo -e "${NC}" } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" check_dependencies() { print_info "检查系统依赖..." @@ -158,7 +159,8 @@ wait_for_health() { while [ $waited -lt $max_wait ]; do 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 print_success "前后端服务已就绪" @@ -196,7 +198,7 @@ show_access_info() { echo "" echo -e "${YELLOW}常用命令:${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 "" } diff --git a/start.sh b/scripts/deploy-full.sh similarity index 83% rename from start.sh rename to scripts/deploy-full.sh index 8f1becc..83886c6 100755 --- a/start.sh +++ b/scripts/deploy-full.sh @@ -1,7 +1,7 @@ #!/bin/bash -# iMeeting Docker 快速启动脚本 -# 使用方法: ./start.sh [选项] +# iMeeting Docker 全量部署脚本(包含 MySQL / Redis) +# 使用方法: ./scripts/deploy-full.sh set -e @@ -12,6 +12,10 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + # 打印带颜色的消息 print_info() { echo -e "${BLUE}[INFO]${NC} $1" @@ -39,7 +43,7 @@ print_banner() { | | | | | __/ __/ |_| | | | | (_| | |_|_| |_|\___|\___|\__|_|_| |_|\__, | |___/ - Docker Deployment Script + Full Docker Deployment EOF echo -e "${NC}" } @@ -76,7 +80,7 @@ check_env_file() { fi } -# 检查并创建后端环境变量文件# 创建必要的目录 +# 创建必要的目录 create_directories() { print_info "创建必要的目录..." @@ -104,7 +108,7 @@ start_services() { COMPOSE_CMD="docker-compose" fi - $COMPOSE_CMD up -d + $COMPOSE_CMD up -d --build print_success "服务启动命令已执行" } @@ -123,7 +127,9 @@ wait_for_health() { COMPOSE_CMD="docker-compose" 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 if [ "$healthy_count" -eq "$total_count" ]; then @@ -137,7 +143,7 @@ wait_for_health() { done echo "" - print_warning "服务启动超时,请手动检查状态: docker-compose ps" + print_warning "服务启动超时,请手动检查状态: docker compose ps" return 1 } @@ -166,7 +172,7 @@ show_access_info() { echo "" echo -e "${YELLOW}域名访问(HTTPS):${NC}" echo -e " 需要在接入服务器配置反向代理" - echo -e " 参考说明: ${BLUE}DOCKER_README.md${NC}" + echo -e " 参考说明: ${BLUE}design/deployment.md${NC}" echo "" echo -e "${YELLOW}数据目录:${NC}" echo -e " 数据持久化: ${BLUE}./data/${NC}" @@ -176,13 +182,12 @@ show_access_info() { echo -e " - 日志: ${BLUE}./data/logs/${NC}" echo "" echo -e "${YELLOW}常用命令:${NC}" - echo -e " 管理菜单: ${BLUE}./manage.sh${NC}" - echo -e " 查看日志: ${BLUE}docker-compose logs -f${NC}" - echo -e " 停止服务: ${BLUE}./stop.sh${NC}" - echo -e " 重启服务: ${BLUE}docker-compose restart${NC}" - echo -e " 查看状态: ${BLUE}docker-compose ps${NC}" + echo -e " 查看日志: ${BLUE}docker compose logs -f${NC}" + echo -e " 停止服务: ${BLUE}./scripts/stop-full.sh${NC}" + echo -e " 重启服务: ${BLUE}docker compose restart${NC}" + echo -e " 查看状态: ${BLUE}docker compose ps${NC}" echo "" - echo -e "${YELLOW}更多信息请查看: ${BLUE}DOCKER_README.md${NC}" + echo -e "${YELLOW}更多信息请查看: ${BLUE}design/deployment.md${NC}" echo "" } diff --git a/backend/sql/imeeting-schema-latest.sql b/scripts/sql/imeeting-schema-latest.sql similarity index 97% rename from backend/sql/imeeting-schema-latest.sql rename to scripts/sql/imeeting-schema-latest.sql index 54f3e3d..89f641b 100644 --- a/backend/sql/imeeting-schema-latest.sql +++ b/scripts/sql/imeeting-schema-latest.sql @@ -181,15 +181,13 @@ CREATE TABLE `llm_model_config` ( `config_id` BIGINT NOT NULL AUTO_INCREMENT, `model_code` VARCHAR(128) 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 '供应商', `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '模型接口地址', `api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥', `llm_model_name` VARCHAR(128) NOT NULL COMMENT '供应商模型名', `llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)', - `llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT 'temperature', - `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 '系统提示词', + `extra_config` JSON DEFAULT NULL COMMENT '模型差异化配置', `description` VARCHAR(500) DEFAULT NULL COMMENT '说明', `is_active` TINYINT(1) NOT NULL DEFAULT 1 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 '更新时间', PRIMARY KEY (`config_id`), 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_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` ( `config_id` BIGINT NOT NULL AUTO_INCREMENT, `model_code` VARCHAR(128) 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 '供应商', `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址', `api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥', `request_timeout_seconds` INT NOT NULL DEFAULT 300 COMMENT '请求超时(秒)', - `hot_word_group_id` INT DEFAULT NULL COMMENT '关联热词组', `extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置', `description` VARCHAR(500) DEFAULT NULL 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 '更新时间', PRIMARY KEY (`config_id`), 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_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 + KEY `idx_audio_model_config_default` (`is_default`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='音频模型配置表'; CREATE TABLE `hot_word_item` ( diff --git a/backend/sql/imeeting-seed-latest.sql b/scripts/sql/imeeting-seed-latest.sql similarity index 93% rename from backend/sql/imeeting-seed-latest.sql rename to scripts/sql/imeeting-seed-latest.sql index 90a0c5c..f4e194b 100644 --- a/backend/sql/imeeting-seed-latest.sql +++ b/scripts/sql/imeeting-seed-latest.sql @@ -232,22 +232,24 @@ ON DUPLICATE KEY UPDATE -- 6. 默认模型配置 -- ===================================================================== INSERT INTO `llm_model_config` -(`model_code`, `model_name`, `provider`, `endpoint_url`, `api_key`, `llm_model_name`, - `llm_timeout`, `llm_temperature`, `llm_top_p`, `llm_max_tokens`, `llm_system_prompt`, - `description`, `is_active`, `is_default`, `created_at`, `updated_at`) +(`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 ( 'llm_model', '默认文本模型', + 'text', 'dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/v1', NULL, 'qwen-plus', 120, - 0.70, - 0.90, - 2048, - '你是一名专业的会议与知识整理助手,请基于输入内容给出准确、结构化、可复用的输出。', + JSON_OBJECT( + 'temperature', 0.70, + 'top_p', 0.90, + 'max_tokens', 2048, + 'system_prompt', '你是一名专业的会议与知识整理助手,请基于输入内容给出准确、结构化、可复用的输出。' + ), '系统初始化的默认 LLM 模型配置,API Key 优先使用环境变量。', 1, 1, @@ -256,23 +258,55 @@ VALUES ) 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`), - `llm_temperature` = VALUES(`llm_temperature`), - `llm_top_p` = VALUES(`llm_top_p`), - `llm_max_tokens` = VALUES(`llm_max_tokens`), - `llm_system_prompt` = VALUES(`llm_system_prompt`), + `extra_config` = VALUES(`extra_config`), + `description` = VALUES(`description`), + `is_active` = VALUES(`is_active`), + `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`), `is_active` = VALUES(`is_active`), `is_default` = VALUES(`is_default`), `updated_at` = VALUES(`updated_at`); INSERT INTO `audio_model_config` -(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`, - `request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`, +(`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`, + `request_timeout_seconds`, `extra_config`, `description`, `is_active`, `is_default`, `created_at`, `updated_at`) SELECT 'audio_model', @@ -282,7 +316,6 @@ SELECT 'https://dashscope.aliyuncs.com', NULL, 300, - g.`id`, JSON_OBJECT( 'model', 'paraformer-v2', 'vocabulary_id', g.`vocabulary_id`, @@ -300,12 +333,11 @@ FROM `hot_word_group` g WHERE g.`id` = @default_hot_word_group_id ON DUPLICATE KEY UPDATE `model_name` = VALUES(`model_name`), - `audio_scene` = VALUES(`audio_scene`), + `model_type` = VALUES(`model_type`), `provider` = VALUES(`provider`), `endpoint_url` = VALUES(`endpoint_url`), `api_key` = VALUES(`api_key`), `request_timeout_seconds` = VALUES(`request_timeout_seconds`), - `hot_word_group_id` = VALUES(`hot_word_group_id`), `extra_config` = VALUES(`extra_config`), `description` = VALUES(`description`), `is_active` = VALUES(`is_active`), @@ -313,8 +345,8 @@ ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`); INSERT INTO `audio_model_config` -(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`, - `request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`, +(`model_code`, `model_name`, `model_type`, `provider`, `endpoint_url`, `api_key`, + `request_timeout_seconds`, `extra_config`, `description`, `is_active`, `is_default`, `created_at`, `updated_at`) VALUES ( @@ -325,7 +357,6 @@ VALUES NULL, NULL, 120, - NULL, JSON_OBJECT( 'template_text', '我正在进行声纹采集,这段语音将用于身份识别和验证。声纹技术能够准确识别每个人独特的声音特征。', 'duration_seconds', 12, @@ -341,12 +372,11 @@ VALUES ) ON DUPLICATE KEY UPDATE `model_name` = VALUES(`model_name`), - `audio_scene` = VALUES(`audio_scene`), + `model_type` = VALUES(`model_type`), `provider` = VALUES(`provider`), `endpoint_url` = VALUES(`endpoint_url`), `api_key` = VALUES(`api_key`), `request_timeout_seconds` = VALUES(`request_timeout_seconds`), - `hot_word_group_id` = VALUES(`hot_word_group_id`), `extra_config` = VALUES(`extra_config`), `description` = VALUES(`description`), `is_active` = VALUES(`is_active`), diff --git a/scripts/sql/model-config-field-migration.sql b/scripts/sql/model-config-field-migration.sql new file mode 100644 index 0000000..079ecd6 --- /dev/null +++ b/scripts/sql/model-config-field-migration.sql @@ -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 ; diff --git a/stop-external.sh b/scripts/stop-app-only.sh similarity index 95% rename from stop-external.sh rename to scripts/stop-app-only.sh index d80d4e8..2633164 100755 --- a/stop-external.sh +++ b/scripts/stop-app-only.sh @@ -21,7 +21,8 @@ print_warning() { } 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 COMPOSE_CMD="docker compose" diff --git a/stop.sh b/scripts/stop-full.sh similarity index 90% rename from stop.sh rename to scripts/stop-full.sh index c3a1026..7022e25 100755 --- a/stop.sh +++ b/scripts/stop-full.sh @@ -1,6 +1,6 @@ #!/bin/bash -# iMeeting Docker 停止脚本 +# iMeeting Docker 全量部署停止脚本 set -e @@ -11,6 +11,10 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + print_info() { echo -e "${BLUE}[INFO]${NC} $1" } diff --git a/start-conda.sh b/start-conda.sh deleted file mode 100755 index 3b52083..0000000 --- a/start-conda.sh +++ /dev/null @@ -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