diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bd4a4d --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# AI Service Environment Variables +# Copy this file to .env and modify as needed + +# LLM Configuration (OpenAI) +AI_SERVICE_LLM_PROVIDER=openai +AI_SERVICE_LLM_API_KEY=your-api-key-here +AI_SERVICE_LLM_BASE_URL=https://api.openai.com/v1 +AI_SERVICE_LLM_MODEL=gpt-4o-mini + +# If using DeepSeek +# AI_SERVICE_LLM_PROVIDER=deepseek +# AI_SERVICE_LLM_API_KEY=your-deepseek-api-key +# AI_SERVICE_LLM_MODEL=deepseek-chat + +# Ollama Configuration (for embedding model) +AI_SERVICE_OLLAMA_BASE_URL=http://ollama:11434 +AI_SERVICE_OLLAMA_EMBEDDING_MODEL=nomic-embed-text + +# Frontend API Key (required for admin panel authentication) +# Get this key from the backend logs after first startup, or from /admin/api-keys +VITE_APP_API_KEY=your-frontend-api-key-here diff --git a/.gitignore b/.gitignore index 232ea0c..02f7c61 100644 --- a/.gitignore +++ b/.gitignore @@ -162,5 +162,6 @@ cython_debug/ # Project specific ai-service/uploads/ +ai-service/config/ *.local diff --git a/README.md b/README.md index 1b7672a..f001aee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,294 @@ -# ai-robot-core +# AI Robot Core -ai中台业务的能力支撑 \ No newline at end of file +AI中台业务的能力支撑,提供智能客服、RAG知识库检索、LLM对话等核心能力。 + +## 项目结构 + +``` +ai-robot-core/ +├── ai-service/ # Python 后端服务 +│ ├── app/ # FastAPI 应用 +│ ├── tests/ # 测试用例 +│ ├── Dockerfile # 后端镜像 +│ └── pyproject.toml # Python 依赖 +├── ai-service-admin/ # Vue 前端管理界面 +│ ├── src/ # Vue 源码 +│ ├── Dockerfile # 前端镜像 +│ ├── nginx.conf # Nginx 配置 +│ └── package.json # Node 依赖 +├── docker-compose.yaml # 容器编排 +├── .env.example # 环境变量示例 +└── README.md +``` + +## 功能特性 + +- **多租户支持**: 通过 X-Tenant-Id 头实现租户隔离 +- **RAG 知识库**: 基于 Qdrant 的向量检索增强生成 +- **LLM 集成**: 支持 OpenAI、DeepSeek、Ollama 等多种 LLM 提供商 +- **SSE 流式输出**: 支持 Server-Sent Events 实时响应 +- **置信度评估**: 自动评估回复质量,低置信度时建议转人工 + +## 快速开始 + +### 环境要求 + +- Docker 20.10+ +- Docker Compose 2.0+ + +### 部署步骤 + +#### 1. 克隆代码 + +```bash +git clone http://49.232.209.156:3005/MerCry/ai-robot-core.git +cd ai-robot-core +``` + +#### 2. 配置环境变量 + +```bash +cp .env.example .env +``` + +编辑 `.env` 文件,配置 LLM API: + +```env +# OpenAI 配置 +AI_SERVICE_LLM_PROVIDER=openai +AI_SERVICE_LLM_API_KEY=your-openai-api-key +AI_SERVICE_LLM_BASE_URL=https://api.openai.com/v1 +AI_SERVICE_LLM_MODEL=gpt-4o-mini + +# 或使用 DeepSeek +# AI_SERVICE_LLM_PROVIDER=deepseek +# AI_SERVICE_LLM_API_KEY=your-deepseek-api-key +# AI_SERVICE_LLM_MODEL=deepseek-chat +``` + +#### 3. 启动服务 + +```bash +# Docker Compose V2 (推荐,Docker 内置) +docker compose up -d --build + +# 或 Docker Compose V1 (旧版,需要单独安装) +docker-compose up -d --build +``` + +#### 4. 拉取嵌入模型 + +服务启动后,需要在 Ollama 容器中拉取嵌入模型。推荐使用 `nomic-embed-text-v2-moe`,对中文支持更好: + +```bash +# 进入 Ollama 容器拉取模型 +docker exec -it ai-ollama ollama pull toshk0/nomic-embed-text-v2-moe:Q6_K +``` + +**可选模型**: + +| 模型 | 维度 | 说明 | +|------|------|------| +| `toshk0/nomic-embed-text-v2-moe:Q6_K` | 768 | 推荐,中文支持好,支持任务前缀 | +| `nomic-embed-text:v1.5` | 768 | 原版,支持任务前缀和 Matryoshka | +| `bge-large-zh` | 1024 | 中文专用,效果最好 | + +#### 5. 配置嵌入模型 + +访问前端管理界面,进入 **嵌入模型配置** 页面: + +1. 选择提供者:**Nomic Embed (优化版)** +2. 配置参数: + - **API 地址**:`http://ollama:11434`(Docker 环境)或 `http://localhost:11434`(本地开发) + - **模型名称**:`toshk0/nomic-embed-text-v2-moe:Q6_K` + - **向量维度**:`768` + - **Matryoshka 截断**:`true` +3. 点击 **保存配置** + +> **注意**: +> - 使用 Nomic Embed (优化版) provider 可启用完整的 RAG 优化功能:任务前缀、Matryoshka 多向量、两阶段检索。 +> - 嵌入模型配置会持久化保存到 `ai-service/config/embedding_config.json`,服务重启后自动加载。 +> - **重要**: 切换嵌入模型后,需要删除现有知识库并重新上传文档,因为不同模型生成的向量不兼容。 + +#### 6. 验证服务 + +```bash +# 检查服务状态 +docker ps + +# 查看后端日志,找到自动生成的 API Key +docker logs -f ai-service | grep "Default API Key" +``` + +> **重要**: 后端首次启动时会自动生成一个默认 API Key,请从日志中复制该 Key,用于前端配置。 + +#### 7. 配置前端 API Key + +```bash +# 创建前端环境变量文件 +cd ai-service-admin +cp .env.example .env +``` + +编辑 `ai-service-admin/.env`,将 `VITE_APP_API_KEY` 设置为后端日志中的 API Key: + +```env +VITE_APP_BASE_API=/api +VITE_APP_API_KEY=<从后端日志复制的API Key> +``` + +然后重新构建前端: + +```bash +cd .. +docker compose up -d --build ai-service-admin +``` + +#### 7. 访问服务 + +| 服务 | 地址 | 说明 | +|------|------|------| +| 前端管理界面 | http://服务器IP:8181 | Vue 管理后台 | +| 后端 API | http://服务器IP:8182 | FastAPI 服务(Java渠道侧调用) | +| API 文档 | http://服务器IP:8182/docs | Swagger UI | +| Qdrant 控制台 | http://服务器IP:6333/dashboard | 向量数据库管理 | +| Ollama API | http://服务器IP:11434 | 嵌入模型服务 | + +> **端口说明**: +> - `8181`: 前端管理界面,内部代理后端 API +> - `8182`: 后端 API,供 Java 渠道侧直接调用 + +## 服务架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 用户访问 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ ai-service-admin (端口8181) │ +│ - Nginx 静态文件服务 │ +│ - 反向代理 /api/* → ai-service:8080 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ ai-service (端口8080) │ +│ - FastAPI 后端服务 │ +│ - RAG / LLM / 知识库管理 │ +└─────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ PostgreSQL │ │ Qdrant │ │ Ollama │ +│ (端口5432) │ │ (端口6333) │ │ (端口11434) │ +│ - 会话存储 │ │ - 向量存储 │ │ - nomic-embed │ +│ - 知识库元数据 │ │ - 文档索引 │ │ - 嵌入模型 │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +## 常用命令 + +```bash +# 启动所有服务 +docker compose up -d + +# 重新构建并启动 +docker compose up -d --build + +# 查看服务状态 +docker compose ps + +# 查看日志 +docker compose logs -f ai-service +docker compose logs -f ai-service-admin + +# 重启服务 +docker compose restart ai-service + +# 停止所有服务 +docker compose down + +# 停止并删除数据卷(清空数据) +docker compose down -v +``` + +## 宿主机 Nginx 配置(可选) + +如果需要通过宿主机 Nginx 统一管理入口(配置域名、SSL证书),可参考 `deploy/nginx.conf.example`: + +```bash +# 复制配置文件 +sudo cp deploy/nginx.conf.example /etc/nginx/conf.d/ai-service.conf + +# 修改配置中的域名 +sudo vim /etc/nginx/conf.d/ai-service.conf + +# 测试配置 +sudo nginx -t + +# 重载 Nginx +sudo nginx -s reload +``` + +## 本地开发 + +### 后端开发 + +```bash +cd ai-service + +# 创建虚拟环境 +python -m venv .venv +source .venv/bin/activate # Linux/Mac +# .venv\Scripts\activate # Windows + +# 安装依赖 +pip install -e ".[dev]" + +# 启动开发服务器 +uvicorn app.main:app --reload --port 8000 +``` + +### 前端开发 + +```bash +cd ai-service-admin + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +## API 接口 + +### 核心接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/ai/chat` | POST | AI 对话接口 | +| `/admin/kb` | GET/POST | 知识库管理 | +| `/admin/rag/experiments/run` | POST | RAG 实验室 | +| `/admin/llm/config` | GET/PUT | LLM 配置 | +| `/admin/embedding/config` | GET/PUT | 嵌入模型配置 | + +详细 API 文档请访问 http://服务器IP:8080/docs + +## 环境变量说明 + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| `AI_SERVICE_LLM_PROVIDER` | openai | LLM 提供商 | +| `AI_SERVICE_LLM_API_KEY` | - | API 密钥 | +| `AI_SERVICE_LLM_BASE_URL` | https://api.openai.com/v1 | API 地址 | +| `AI_SERVICE_LLM_MODEL` | gpt-4o-mini | 模型名称 | +| `AI_SERVICE_DATABASE_URL` | postgresql+asyncpg://... | 数据库连接 | +| `AI_SERVICE_QDRANT_URL` | http://qdrant:6333 | Qdrant 地址 | +| `AI_SERVICE_LOG_LEVEL` | INFO | 日志级别 | + +## License + +MIT diff --git a/ai-service-admin/.dockerignore b/ai-service-admin/.dockerignore new file mode 100644 index 0000000..2cde1db --- /dev/null +++ b/ai-service-admin/.dockerignore @@ -0,0 +1,19 @@ +node_modules +dist + +.env +.env.local +.env.*.local + +*.log + +.idea/ +.vscode/ +*.swp +*.swo + +.git +.gitignore + +*.md +!README.md diff --git a/ai-service-admin/.env.example b/ai-service-admin/.env.example new file mode 100644 index 0000000..533aaf3 --- /dev/null +++ b/ai-service-admin/.env.example @@ -0,0 +1,8 @@ +# API Base URL +VITE_APP_BASE_API=/api + +# Default API Key for authentication +# IMPORTANT: You must set this to a valid API key from the backend +# The backend creates a default API key on first startup (check backend logs) +# Or you can create one via the API: POST /admin/api-keys +VITE_APP_API_KEY=your-api-key-here diff --git a/ai-service-admin/Dockerfile b/ai-service-admin/Dockerfile new file mode 100644 index 0000000..bee8070 --- /dev/null +++ b/ai-service-admin/Dockerfile @@ -0,0 +1,28 @@ +# AI Service Admin Frontend Dockerfile +FROM docker.1ms.run/node:20-alpine AS builder + +WORKDIR /app + +ARG VITE_APP_API_KEY +ARG VITE_APP_BASE_API=/api + +ENV VITE_APP_API_KEY=$VITE_APP_API_KEY +ENV VITE_APP_BASE_API=$VITE_APP_BASE_API + +COPY package*.json ./ + +RUN npm install && npm install @rollup/rollup-linux-x64-musl --save-optional + +COPY . . + +RUN npm run build + +FROM docker.1ms.run/nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/ai-service-admin/nginx.conf b/ai-service-admin/nginx.conf new file mode 100644 index 0000000..ced6778 --- /dev/null +++ b/ai-service-admin/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://ai-service:8080/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + proxy_buffering off; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_min_length 1000; +} diff --git a/ai-service-admin/package-lock.json b/ai-service-admin/package-lock.json index 24bc60f..ff69db6 100644 --- a/ai-service-admin/package-lock.json +++ b/ai-service-admin/package-lock.json @@ -17,15 +17,13 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", - "typescript": "^5.2.2", + "typescript": "~5.6.0", "vite": "^5.1.4", - "vue-tsc": "^1.8.27" + "vue-tsc": "^2.1.0" } }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -33,8 +31,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -42,8 +38,6 @@ }, "node_modules/@babel/parser": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -57,8 +51,6 @@ }, "node_modules/@babel/types": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -70,8 +62,6 @@ }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", - "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", "license": "MIT", "engines": { "node": ">=10" @@ -79,391 +69,13 @@ }, "node_modules/@element-plus/icons-vue": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", - "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", "license": "MIT", "peerDependencies": { "vue": "^3.2.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/win32-x64": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -479,8 +91,6 @@ }, "node_modules/@floating-ui/core": { "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" @@ -488,8 +98,6 @@ }, "node_modules/@floating-ui/dom": { "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.4", @@ -498,353 +106,23 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", - "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -857,8 +135,6 @@ }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -871,21 +147,15 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, "node_modules/@types/lodash-es": { "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", "dependencies": { "@types/lodash": "*" @@ -893,14 +163,10 @@ }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", "dev": true, "license": "MIT", "engines": { @@ -912,40 +178,36 @@ } }, "node_modules/@volar/language-core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", - "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "1.11.1" + "@volar/source-map": "2.4.15" } }, "node_modules/@volar/source-map": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", - "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", "dev": true, - "license": "MIT", - "dependencies": { - "muggle-string": "^0.3.1" - } + "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", - "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "1.11.1", - "path-browserify": "^1.0.1" + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", - "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -957,8 +219,6 @@ }, "node_modules/@vue/compiler-dom": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", - "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.29", @@ -967,8 +227,6 @@ }, "node_modules/@vue/compiler-sfc": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", - "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -984,36 +242,42 @@ }, "node_modules/@vue/compiler-ssr": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", - "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/language-core": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", - "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "~1.11.1", - "@volar/source-map": "~1.11.1", - "@vue/compiler-dom": "^3.3.0", - "@vue/shared": "^3.3.0", - "computeds": "^0.0.1", + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", "minimatch": "^9.0.3", - "muggle-string": "^0.3.1", - "path-browserify": "^1.0.1", - "vue-template-compiler": "^2.7.14" + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" @@ -1026,8 +290,6 @@ }, "node_modules/@vue/reactivity": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", - "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "license": "MIT", "dependencies": { "@vue/shared": "3.5.29" @@ -1035,8 +297,6 @@ }, "node_modules/@vue/runtime-core": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", - "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -1045,8 +305,6 @@ }, "node_modules/@vue/runtime-dom": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", - "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -1057,8 +315,6 @@ }, "node_modules/@vue/server-renderer": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", - "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.29", @@ -1070,14 +326,10 @@ }, "node_modules/@vue/shared": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", - "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "license": "MIT" }, "node_modules/@vueuse/core": { "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", - "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.20", @@ -1091,8 +343,6 @@ }, "node_modules/@vueuse/metadata": { "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", - "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -1100,8 +350,6 @@ }, "node_modules/@vueuse/shared": { "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", - "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", "license": "MIT", "dependencies": { "vue-demi": ">=0.14.8" @@ -1110,22 +358,23 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "4.2.5", - "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/axios": { "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -1158,8 +407,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1171,8 +418,6 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1181,23 +426,12 @@ "node": ">= 0.8" } }, - "node_modules/computeds": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", - "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dayjs": { "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/de-indent": { @@ -1209,8 +443,6 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1218,8 +450,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1232,8 +462,6 @@ }, "node_modules/element-plus": { "version": "2.13.2", - "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.2.tgz", - "integrity": "sha512-Zjzm1NnFXGhV4LYZ6Ze9skPlYi2B4KAmN18FL63A3PZcjhDfroHwhtM6RE8BonlOPHXUnPQynH0BgaoEfvhrGw==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^3.4.1", @@ -1257,8 +485,6 @@ }, "node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1269,8 +495,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1278,8 +502,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1287,8 +509,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1299,8 +519,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1314,8 +532,6 @@ }, "node_modules/esbuild": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1353,14 +569,10 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/follow-redirects": { "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -1379,8 +591,6 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1393,25 +603,8 @@ "node": ">= 6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1419,8 +612,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1443,8 +634,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1456,8 +645,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1468,8 +655,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1480,8 +665,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1495,8 +678,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1517,20 +698,14 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", - "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", "license": "MIT", "peerDependencies": { "@types/lodash-es": "*", @@ -1540,8 +715,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1549,8 +722,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1558,14 +729,10 @@ }, "node_modules/memoize-one": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1573,8 +740,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -1584,9 +749,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", + "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", "dev": true, "license": "ISC", "dependencies": { @@ -1600,16 +765,14 @@ } }, "node_modules/muggle-string": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", - "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -1626,8 +789,6 @@ }, "node_modules/normalize-wheel-es": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", - "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "license": "BSD-3-Clause" }, "node_modules/path-browserify": { @@ -1639,14 +800,10 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/pinia": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.3", @@ -1667,8 +824,6 @@ }, "node_modules/postcss": { "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -1695,14 +850,10 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, "node_modules/rollup": { "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1744,32 +895,17 @@ "fsevents": "~2.3.2" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -1782,8 +918,6 @@ }, "node_modules/vite": { "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -1840,10 +974,15 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", - "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.29", @@ -1863,8 +1002,6 @@ }, "node_modules/vue-demi": { "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1889,8 +1026,6 @@ }, "node_modules/vue-router": { "version": "4.6.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.4" @@ -1902,33 +1037,21 @@ "vue": "^3.5.0" } }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", - "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/vue-tsc": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", - "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/typescript": "~1.11.1", - "@vue/language-core": "1.8.27", - "semver": "^7.5.4" + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" }, "bin": { "vue-tsc": "bin/vue-tsc.js" }, "peerDependencies": { - "typescript": "*" + "typescript": ">=5.0.0" } } } diff --git a/ai-service-admin/package.json b/ai-service-admin/package.json index d8fbf9f..8cc1306 100644 --- a/ai-service-admin/package.json +++ b/ai-service-admin/package.json @@ -17,8 +17,8 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", - "typescript": "^5.2.2", + "typescript": "~5.6.0", "vite": "^5.1.4", - "vue-tsc": "^1.8.27" + "vue-tsc": "^2.1.0" } } diff --git a/ai-service-admin/src/App.vue b/ai-service-admin/src/App.vue index b6aa613..347d66a 100644 --- a/ai-service-admin/src/App.vue +++ b/ai-service-admin/src/App.vue @@ -98,7 +98,6 @@ const isValidTenantId = (tenantId: string): boolean => { const fetchTenantList = async () => { loading.value = true try { - // 检查当前租户ID格式是否有效 if (!isValidTenantId(currentTenantId.value)) { console.warn('Invalid tenant ID format, resetting to default:', currentTenantId.value) currentTenantId.value = 'default@ash@2026' @@ -108,7 +107,6 @@ const fetchTenantList = async () => { const response = await getTenantList() tenantList.value = response.tenants || [] - // 如果当前租户不在列表中,默认选择第一个 if (tenantList.value.length > 0 && !tenantList.value.find(t => t.id === currentTenantId.value)) { const firstTenant = tenantList.value[0] currentTenantId.value = firstTenant.id @@ -117,8 +115,7 @@ const fetchTenantList = async () => { } catch (error) { ElMessage.error('获取租户列表失败') console.error('Failed to fetch tenant list:', error) - // 失败时使用默认租户 - tenantList.value = [{ id: 'default@ash@2026', name: 'default (2026)' }] + tenantList.value = [{ id: 'default@ash@2026', name: 'default (2026)', displayName: 'default', year: '2026', createdAt: new Date().toISOString() }] } finally { loading.value = false } diff --git a/ai-service-admin/src/api/tenant.ts b/ai-service-admin/src/api/tenant.ts index 2fce357..f679bd8 100644 --- a/ai-service-admin/src/api/tenant.ts +++ b/ai-service-admin/src/api/tenant.ts @@ -13,7 +13,7 @@ export interface TenantListResponse { total: number } -export function getTenantList() { +export function getTenantList(): Promise { return request({ url: '/admin/tenants', method: 'get' diff --git a/ai-service-admin/src/components/common/ConfigForm.vue b/ai-service-admin/src/components/common/ConfigForm.vue index 569e4af..92885ac 100644 --- a/ai-service-admin/src/components/common/ConfigForm.vue +++ b/ai-service-admin/src/components/common/ConfigForm.vue @@ -92,6 +92,7 @@ const emit = defineEmits<{ const formRef = ref() const formData = ref>({}) +const isUpdating = ref(false) const schemaProperties = computed(() => { return props.schema?.properties || {} @@ -173,8 +174,11 @@ const initFormData = () => { watch( () => props.modelValue, - () => { - initFormData() + (newVal) => { + if (isUpdating.value) return + if (JSON.stringify(newVal) !== JSON.stringify(formData.value)) { + initFormData() + } }, { deep: true } ) @@ -190,7 +194,14 @@ watch( watch( formData, (val) => { - emit('update:modelValue', val) + if (isUpdating.value) return + if (JSON.stringify(val) !== JSON.stringify(props.modelValue)) { + isUpdating.value = true + emit('update:modelValue', val) + Promise.resolve().then(() => { + isUpdating.value = false + }) + } }, { deep: true } ) diff --git a/ai-service-admin/src/components/embedding/EmbeddingConfigForm.vue b/ai-service-admin/src/components/embedding/EmbeddingConfigForm.vue index e491cc8..e0cd7ba 100644 --- a/ai-service-admin/src/components/embedding/EmbeddingConfigForm.vue +++ b/ai-service-admin/src/components/embedding/EmbeddingConfigForm.vue @@ -92,6 +92,7 @@ const emit = defineEmits<{ const formRef = ref() const formData = ref>({}) +const isUpdating = ref(false) const schemaProperties = computed(() => { return props.schema?.properties || {} @@ -173,8 +174,11 @@ const initFormData = () => { watch( () => props.modelValue, - () => { - initFormData() + (newVal) => { + if (isUpdating.value) return + if (JSON.stringify(newVal) !== JSON.stringify(formData.value)) { + initFormData() + } }, { deep: true } ) @@ -190,7 +194,14 @@ watch( watch( formData, (val) => { - emit('update:modelValue', val) + if (isUpdating.value) return + if (JSON.stringify(val) !== JSON.stringify(props.modelValue)) { + isUpdating.value = true + emit('update:modelValue', val) + Promise.resolve().then(() => { + isUpdating.value = false + }) + } }, { deep: true } ) diff --git a/ai-service-admin/src/stores/embedding.ts b/ai-service-admin/src/stores/embedding.ts index 2cfb0ea..3c2af55 100644 --- a/ai-service-admin/src/stores/embedding.ts +++ b/ai-service-admin/src/stores/embedding.ts @@ -74,7 +74,8 @@ export const useEmbeddingStore = defineStore('embedding', () => { provider: currentConfig.value.provider, config: currentConfig.value.config } - await saveConfig(updateData) + const response = await saveConfig(updateData) + return response } catch (error) { console.error('Failed to save config:', error) throw error diff --git a/ai-service-admin/src/utils/request.ts b/ai-service-admin/src/utils/request.ts index 7d64d24..e80ef52 100644 --- a/ai-service-admin/src/utils/request.ts +++ b/ai-service-admin/src/utils/request.ts @@ -1,21 +1,22 @@ -import axios from 'axios' +import axios, { type AxiosRequestConfig } from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import { useTenantStore } from '@/stores/tenant' -// 创建 axios 实例 const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API || '/api', timeout: 60000 }) -// 请求拦截器 service.interceptors.request.use( (config) => { const tenantStore = useTenantStore() if (tenantStore.currentTenantId) { config.headers['X-Tenant-Id'] = tenantStore.currentTenantId } - // TODO: 如果有 token 也可以在这里注入 Authorization + const apiKey = import.meta.env.VITE_APP_API_KEY + if (apiKey) { + config.headers['X-API-Key'] = apiKey + } return config }, (error) => { @@ -24,11 +25,9 @@ service.interceptors.request.use( } ) -// 响应拦截器 service.interceptors.response.use( (response) => { const res = response.data - // 这里可以根据后端的 code 进行统一处理 return res }, (error) => { @@ -42,7 +41,6 @@ service.interceptors.response.use( cancelButtonText: '取消', type: 'warning' }).then(() => { - // TODO: 跳转到登录页或执行退出逻辑 location.href = '/login' }) } else if (status === 403) { @@ -69,4 +67,13 @@ service.interceptors.response.use( } ) -export default service +interface RequestConfig extends AxiosRequestConfig { + url: string + method?: string +} + +function request(config: RequestConfig): Promise { + return service.request(config) +} + +export default request diff --git a/ai-service-admin/src/views/admin/embedding/index.vue b/ai-service-admin/src/views/admin/embedding/index.vue index e38ad24..8ca96d9 100644 --- a/ai-service-admin/src/views/admin/embedding/index.vue +++ b/ai-service-admin/src/views/admin/embedding/index.vue @@ -169,8 +169,19 @@ const handleSave = async () => { saving.value = true try { - await embeddingStore.saveCurrentConfig() + const response: any = await embeddingStore.saveCurrentConfig() ElMessage.success('配置保存成功') + + if (response?.warning || response?.requires_reindex) { + ElMessageBox.alert( + response.warning || '嵌入模型已更改,请重新上传文档以确保检索效果正常。', + '重要提示', + { + confirmButtonText: '我知道了', + type: 'warning', + } + ) + } } catch (error) { ElMessage.error('配置保存失败') } finally { diff --git a/ai-service-admin/src/views/kb/index.vue b/ai-service-admin/src/views/kb/index.vue index a6db08e..8720b4b 100644 --- a/ai-service-admin/src/views/kb/index.vue +++ b/ai-service-admin/src/views/kb/index.vue @@ -102,10 +102,17 @@ interface DocumentItem { createTime: string } +interface IndexJob { + jobId: string + status: string + progress: number + errorMsg?: string +} + const tableData = ref([]) const loading = ref(false) const jobDialogVisible = ref(false) -const currentJob = ref(null) +const currentJob = ref(null) const pollingJobs = ref>(new Set()) let pollingInterval: number | null = null @@ -150,10 +157,15 @@ const fetchDocuments = async () => { } } -const fetchJobStatus = async (jobId: string) => { +const fetchJobStatus = async (jobId: string): Promise => { try { - const res = await getIndexJob(jobId) - return res + const res: any = await getIndexJob(jobId) + return { + jobId: res.jobId || jobId, + status: res.status || 'pending', + progress: res.progress || 0, + errorMsg: res.errorMsg + } } catch (error) { console.error('Failed to fetch job status:', error) return null @@ -246,19 +258,21 @@ const handleFileChange = async (event: Event) => { try { loading.value = true - const res = await uploadDocument(formData) - ElMessage.success(`文档上传成功!任务ID: ${res.jobId}`) + const res: any = await uploadDocument(formData) + const jobId = res.jobId as string + ElMessage.success(`文档上传成功!任务ID: ${jobId}`) console.log('Upload response:', res) const newDoc: DocumentItem = { + docId: res.docId || '', name: file.name, - status: res.status || 'pending', - jobId: res.jobId, + status: (res.status as string) || 'pending', + jobId: jobId, createTime: new Date().toLocaleString('zh-CN') } tableData.value.unshift(newDoc) - startPolling(res.jobId) + startPolling(jobId) } catch (error) { ElMessage.error('文档上传失败') console.error('Upload error:', error) diff --git a/ai-service-admin/src/views/rag-lab/index.vue b/ai-service-admin/src/views/rag-lab/index.vue index b5af4e5..6f27ca1 100644 --- a/ai-service-admin/src/views/rag-lab/index.vue +++ b/ai-service-admin/src/views/rag-lab/index.vue @@ -327,7 +327,7 @@ const runStreamExperiment = async () => { } else if (parsed.type === 'error') { streamError.value = parsed.message || '流式输出错误' streaming.value = false - ElMessage.error(streamError.value) + ElMessage.error(streamError.value || '未知错误') } } catch { streamContent.value += data diff --git a/ai-service-admin/src/vite-env.d.ts b/ai-service-admin/src/vite-env.d.ts new file mode 100644 index 0000000..e933527 --- /dev/null +++ b/ai-service-admin/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_BASE_API: string + readonly VITE_APP_API_KEY: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/ai-service-admin/tsconfig.json b/ai-service-admin/tsconfig.json index 33e425c..aaf75f2 100644 --- a/ai-service-admin/tsconfig.json +++ b/ai-service-admin/tsconfig.json @@ -15,7 +15,8 @@ "baseUrl": ".", "paths": { "@/*": ["src/*"] - } + }, + "types": ["vite/client"] }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/ai-service/.dockerignore b/ai-service/.dockerignore new file mode 100644 index 0000000..980a2b5 --- /dev/null +++ b/ai-service/.dockerignore @@ -0,0 +1,53 @@ +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +.pytest_cache +.coverage +htmlcov/ +.tox/ +.hypothesis/ + +.mypy_cache/ +.ruff_cache/ + +.env +.env.local +.env.*.local + +*.log +*.pot +*.pyc + +.idea/ +.vscode/ +*.swp +*.swo + +tests/ +scripts/ +*.md +!README.md + +.git +.gitignore +.gitea + +check_qdrant.py diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..52aab35 --- /dev/null +++ b/ai-service/Dockerfile @@ -0,0 +1,32 @@ +# AI Service Backend Dockerfile +FROM docker.1ms.run/python:3.11-slim AS builder + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml README.md ./ + +RUN uv pip install --system --no-cache-dir . + +FROM docker.1ms.run/python:3.11-slim + +WORKDIR /app + +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY app ./app + +RUN chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8080 + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/ai-service/app/api/admin/__init__.py b/ai-service/app/api/admin/__init__.py index 40b96bb..5bc4b10 100644 --- a/ai-service/app/api/admin/__init__.py +++ b/ai-service/app/api/admin/__init__.py @@ -1,8 +1,9 @@ """ Admin API routes for AI Service management. -[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints. +[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08, AC-AISVC-50] Admin management endpoints. """ +from app.api.admin.api_key import router as api_key_router from app.api.admin.dashboard import router as dashboard_router from app.api.admin.embedding import router as embedding_router from app.api.admin.kb import router as kb_router @@ -11,4 +12,4 @@ from app.api.admin.rag import router as rag_router from app.api.admin.sessions import router as sessions_router from app.api.admin.tenants import router as tenants_router -__all__ = ["dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"] +__all__ = ["api_key_router", "dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"] diff --git a/ai-service/app/api/admin/api_key.py b/ai-service/app/api/admin/api_key.py new file mode 100644 index 0000000..b73b78b --- /dev/null +++ b/ai-service/app/api/admin/api_key.py @@ -0,0 +1,154 @@ +""" +API Key management endpoints. +[AC-AISVC-50] CRUD operations for API keys. +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.models.entities import ApiKey, ApiKeyCreate +from app.services.api_key import get_api_key_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api-keys", tags=["API Keys"]) + + +class ApiKeyResponse(BaseModel): + """Response model for API key.""" + + id: str = Field(..., description="API key ID") + key: str = Field(..., description="API key value") + name: str = Field(..., description="API key name") + is_active: bool = Field(..., description="Whether the key is active") + created_at: str = Field(..., description="Creation time") + updated_at: str = Field(..., description="Last update time") + + +class ApiKeyListResponse(BaseModel): + """Response model for API key list.""" + + keys: list[ApiKeyResponse] = Field(..., description="List of API keys") + total: int = Field(..., description="Total count") + + +class CreateApiKeyRequest(BaseModel): + """Request model for creating API key.""" + + name: str = Field(..., description="API key name/description") + key: str | None = Field(default=None, description="Custom API key (auto-generated if not provided)") + + +class ToggleApiKeyRequest(BaseModel): + """Request model for toggling API key status.""" + + is_active: bool = Field(..., description="New active status") + + +def api_key_to_response(api_key: ApiKey) -> ApiKeyResponse: + """Convert ApiKey entity to response model.""" + return ApiKeyResponse( + id=str(api_key.id), + key=api_key.key, + name=api_key.name, + is_active=api_key.is_active, + created_at=api_key.created_at.isoformat(), + updated_at=api_key.updated_at.isoformat(), + ) + + +@router.get("", response_model=ApiKeyListResponse) +async def list_api_keys( + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] List all API keys. + """ + service = get_api_key_service() + keys = await service.list_keys(session) + + return ApiKeyListResponse( + keys=[api_key_to_response(k) for k in keys], + total=len(keys), + ) + + +@router.post("", response_model=ApiKeyResponse, status_code=status.HTTP_201_CREATED) +async def create_api_key( + request: CreateApiKeyRequest, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Create a new API key. + """ + service = get_api_key_service() + + key_value = request.key or service.generate_key() + + key_create = ApiKeyCreate( + key=key_value, + name=request.name, + is_active=True, + ) + + api_key = await service.create_key(session, key_create) + logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}") + + return api_key_to_response(api_key) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_api_key( + key_id: str, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Delete an API key. + """ + service = get_api_key_service() + + deleted = await service.delete_key(session, key_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found", + ) + + +@router.patch("/{key_id}/toggle", response_model=ApiKeyResponse) +async def toggle_api_key( + key_id: str, + request: ToggleApiKeyRequest, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Toggle API key active status. + """ + service = get_api_key_service() + + api_key = await service.toggle_key(session, key_id, request.is_active) + + if not api_key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found", + ) + + return api_key_to_response(api_key) + + +@router.post("/reload-cache", status_code=status.HTTP_204_NO_CONTENT) +async def reload_api_key_cache( + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Reload API key cache from database. + """ + service = get_api_key_service() + await service.reload_cache(session) diff --git a/ai-service/app/api/admin/embedding.py b/ai-service/app/api/admin/embedding.py index 206b27d..5937296 100644 --- a/ai-service/app/api/admin/embedding.py +++ b/ai-service/app/api/admin/embedding.py @@ -78,12 +78,32 @@ async def update_embedding_config( manager = get_embedding_config_manager() + old_config = manager.get_full_config() + old_provider = old_config.get("provider") + old_model = old_config.get("config", {}).get("model", "") + + new_model = config.get("model", "") + try: await manager.update_config(provider, config) - return { + + response = { "success": True, "message": f"Configuration updated to use {provider}", } + + if old_provider != provider or old_model != new_model: + response["warning"] = ( + "嵌入模型已更改。由于不同模型生成的向量不兼容," + "请删除现有知识库并重新上传文档,以确保检索效果正常。" + ) + response["requires_reindex"] = True + logger.warning( + f"[EMBEDDING] Model changed from {old_provider}/{old_model} to {provider}/{new_model}. " + f"Documents need to be re-uploaded." + ) + + return response except EmbeddingException as e: raise InvalidRequestException(str(e)) diff --git a/ai-service/app/api/admin/kb.py b/ai-service/app/api/admin/kb.py index e150d72..e185e0e 100644 --- a/ai-service/app/api/admin/kb.py +++ b/ai-service/app/api/admin/kb.py @@ -442,13 +442,15 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt logger.info(f"[INDEX] Total chunks: {len(all_chunks)}") qdrant = await get_qdrant_client() - await qdrant.ensure_collection_exists(tenant_id) + await qdrant.ensure_collection_exists(tenant_id, use_multi_vector=True) + + from app.services.embedding.nomic_provider import NomicEmbeddingProvider + use_multi_vector = isinstance(embedding_provider, NomicEmbeddingProvider) + logger.info(f"[INDEX] Using multi-vector format: {use_multi_vector}") points = [] total_chunks = len(all_chunks) for i, chunk in enumerate(all_chunks): - embedding = await embedding_provider.embed(chunk.text) - payload = { "text": chunk.text, "source": doc_id, @@ -461,13 +463,26 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt if chunk.source: payload["filename"] = chunk.source - points.append( - PointStruct( - id=str(uuid.uuid4()), - vector=embedding, - payload=payload, + if use_multi_vector: + embedding_result = await embedding_provider.embed_document(chunk.text) + points.append({ + "id": str(uuid.uuid4()), + "vector": { + "full": embedding_result.embedding_full, + "dim_256": embedding_result.embedding_256, + "dim_512": embedding_result.embedding_512, + }, + "payload": payload, + }) + else: + embedding = await embedding_provider.embed(chunk.text) + points.append( + PointStruct( + id=str(uuid.uuid4()), + vector=embedding, + payload=payload, + ) ) - ) progress = 20 + int((i + 1) / total_chunks * 70) if i % 10 == 0 or i == total_chunks - 1: @@ -478,7 +493,10 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt if points: logger.info(f"[INDEX] Upserting {len(points)} vectors to Qdrant...") - await qdrant.upsert_vectors(tenant_id, points) + if use_multi_vector: + await qdrant.upsert_multi_vector(tenant_id, points) + else: + await qdrant.upsert_vectors(tenant_id, points) await kb_service.update_job_status( tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100 diff --git a/ai-service/app/core/middleware.py b/ai-service/app/core/middleware.py index 4100813..e1db0c3 100644 --- a/ai-service/app/core/middleware.py +++ b/ai-service/app/core/middleware.py @@ -1,6 +1,6 @@ """ Middleware for AI Service. -[AC-AISVC-10, AC-AISVC-12] X-Tenant-Id header validation and tenant context injection. +[AC-AISVC-10, AC-AISVC-12, AC-AISVC-50] X-Tenant-Id header validation, tenant context injection, and API Key authentication. """ import logging @@ -17,12 +17,20 @@ from app.core.tenant import clear_tenant_context, set_tenant_context logger = logging.getLogger(__name__) TENANT_ID_HEADER = "X-Tenant-Id" +API_KEY_HEADER = "X-API-Key" ACCEPT_HEADER = "Accept" SSE_CONTENT_TYPE = "text/event-stream" -# Tenant ID format: name@ash@year (e.g., szmp@ash@2026) TENANT_ID_PATTERN = re.compile(r'^[^@]+@ash@\d{4}$') +PATHS_SKIP_API_KEY = { + "/health", + "/ai/health", + "/docs", + "/redoc", + "/openapi.json", +} + def validate_tenant_id_format(tenant_id: str) -> bool: """ @@ -41,6 +49,59 @@ def parse_tenant_id(tenant_id: str) -> tuple[str, str]: return parts[0], parts[2] +class ApiKeyMiddleware(BaseHTTPMiddleware): + """ + [AC-AISVC-50] Middleware to validate API Key for all requests. + + Features: + - Validates X-API-Key header against in-memory cache + - Skips validation for health/docs endpoints + - Returns 401 for missing or invalid API key + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if self._should_skip_api_key(request.url.path): + return await call_next(request) + + api_key = request.headers.get(API_KEY_HEADER) + + if not api_key or not api_key.strip(): + logger.warning(f"[AC-AISVC-50] Missing X-API-Key header for {request.url.path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=ErrorResponse( + code=ErrorCode.UNAUTHORIZED.value, + message="Missing required header: X-API-Key", + ).model_dump(exclude_none=True), + ) + + api_key = api_key.strip() + + from app.services.api_key import get_api_key_service + service = get_api_key_service() + + if not service.validate_key(api_key): + logger.warning(f"[AC-AISVC-50] Invalid API key for {request.url.path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=ErrorResponse( + code=ErrorCode.UNAUTHORIZED.value, + message="Invalid API key", + ).model_dump(exclude_none=True), + ) + + return await call_next(request) + + def _should_skip_api_key(self, path: str) -> bool: + """Check if the path should skip API key validation.""" + if path in PATHS_SKIP_API_KEY: + return True + for skip_path in PATHS_SKIP_API_KEY: + if path.startswith(skip_path): + return True + return False + + class TenantContextMiddleware(BaseHTTPMiddleware): """ [AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header. @@ -51,7 +112,7 @@ class TenantContextMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable) -> Response: clear_tenant_context() - if request.url.path == "/ai/health": + if request.url.path in ("/health", "/ai/health"): return await call_next(request) tenant_id = request.headers.get(TENANT_ID_HEADER) @@ -68,7 +129,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): tenant_id = tenant_id.strip() - # Validate tenant ID format if not validate_tenant_id_format(tenant_id): logger.warning(f"[AC-AISVC-10] Invalid tenant ID format: {tenant_id}") return JSONResponse( @@ -79,13 +139,11 @@ class TenantContextMiddleware(BaseHTTPMiddleware): ).model_dump(exclude_none=True), ) - # Auto-create tenant if not exists (for admin endpoints) if request.url.path.startswith("/admin/") or request.url.path.startswith("/ai/"): try: await self._ensure_tenant_exists(request, tenant_id) except Exception as e: logger.error(f"[AC-AISVC-10] Failed to ensure tenant exists: {e}") - # Continue processing even if tenant creation fails set_tenant_context(tenant_id) request.state.tenant_id = tenant_id @@ -112,7 +170,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): name, year = parse_tenant_id(tenant_id) async with async_session_maker() as session: - # Check if tenant exists stmt = select(Tenant).where(Tenant.tenant_id == tenant_id) result = await session.execute(stmt) existing_tenant = result.scalar_one_or_none() @@ -121,7 +178,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): logger.debug(f"[AC-AISVC-10] Tenant already exists: {tenant_id}") return - # Create new tenant new_tenant = Tenant( tenant_id=tenant_id, name=name, diff --git a/ai-service/app/core/qdrant_client.py b/ai-service/app/core/qdrant_client.py index 5742b5a..19b47f4 100644 --- a/ai-service/app/core/qdrant_client.py +++ b/ai-service/app/core/qdrant_client.py @@ -8,7 +8,7 @@ import logging from typing import Any from qdrant_client import AsyncQdrantClient -from qdrant_client.models import Distance, PointStruct, VectorParams, MultiVectorConfig +from qdrant_client.models import Distance, PointStruct, VectorParams, QueryRequest from app.core.config import get_settings @@ -61,8 +61,7 @@ class QdrantClient: collection_name = self.get_collection_name(tenant_id) try: - collections = await client.get_collections() - exists = any(c.name == collection_name for c in collections.collections) + exists = await client.collection_exists(collection_name) if not exists: if use_multi_vector: @@ -176,6 +175,7 @@ class QdrantClient: limit: int = 5, score_threshold: float | None = None, vector_name: str = "full", + with_vectors: bool = False, ) -> list[dict[str, Any]]: """ [AC-AISVC-10] Search vectors in tenant's collection. @@ -189,6 +189,7 @@ class QdrantClient: score_threshold: Minimum score threshold for results vector_name: Name of the vector to search (for multi-vector collections) Default is "full" for 768-dim vectors in Matryoshka setup. + with_vectors: Whether to return vectors in results (for two-stage reranking) """ client = await self.get_client() @@ -211,39 +212,50 @@ class QdrantClient: try: logger.info(f"[AC-AISVC-10] Searching in collection: {collection_name}") + exists = await client.collection_exists(collection_name) + if not exists: + logger.warning(f"[AC-AISVC-10] Collection {collection_name} does not exist") + continue + try: - results = await client.search( + results = await client.query_points( collection_name=collection_name, - query_vector=(vector_name, query_vector), + query=query_vector, + using=vector_name, limit=limit, + with_vectors=with_vectors, + score_threshold=score_threshold, ) except Exception as e: - if "vector name" in str(e).lower() or "Not existing vector" in str(e): + if "vector name" in str(e).lower() or "Not existing vector" in str(e) or "using" in str(e).lower(): logger.info( f"[AC-AISVC-10] Collection {collection_name} doesn't have vector named '{vector_name}', " f"trying without vector name (single-vector mode)" ) - results = await client.search( + results = await client.query_points( collection_name=collection_name, - query_vector=query_vector, + query=query_vector, limit=limit, + with_vectors=with_vectors, + score_threshold=score_threshold, ) else: raise logger.info( - f"[AC-AISVC-10] Collection {collection_name} returned {len(results)} raw results" + f"[AC-AISVC-10] Collection {collection_name} returned {len(results.points)} raw results" ) - hits = [ - { + hits = [] + for result in results.points: + hit = { "id": str(result.id), "score": result.score, "payload": result.payload or {}, } - for result in results - if score_threshold is None or result.score >= score_threshold - ] + if with_vectors and result.vector: + hit["vector"] = result.vector + hits.append(hit) all_hits.extend(hits) if hits: diff --git a/ai-service/app/main.py b/ai-service/app/main.py index c18afba..56d7ccc 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api import chat_router, health_router -from app.api.admin import dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router +from app.api.admin import api_key_router, dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router from app.api.admin.kb_optimized import router as kb_optimized_router from app.core.config import get_settings from app.core.database import close_db, init_db @@ -24,7 +24,7 @@ from app.core.exceptions import ( generic_exception_handler, http_exception_handler, ) -from app.core.middleware import TenantContextMiddleware +from app.core.middleware import ApiKeyMiddleware, TenantContextMiddleware from app.core.qdrant_client import close_qdrant_client settings = get_settings() @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): """ - [AC-AISVC-01, AC-AISVC-11] Application lifespan manager. + [AC-AISVC-01, AC-AISVC-11, AC-AISVC-50] Application lifespan manager. Handles startup and shutdown of database and external connections. """ logger.info(f"[AC-AISVC-01] Starting {settings.app_name} v{settings.app_version}") @@ -51,6 +51,19 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"[AC-AISVC-11] Database initialization skipped: {e}") + try: + from app.core.database import async_session_maker + from app.services.api_key import get_api_key_service + + async with async_session_maker() as session: + api_key_service = get_api_key_service() + await api_key_service.initialize(session) + default_key = await api_key_service.create_default_key(session) + if default_key: + logger.info(f"[AC-AISVC-50] Default API key created: {default_key.key}") + except Exception as e: + logger.warning(f"[AC-AISVC-50] API key initialization skipped: {e}") + yield await close_db() @@ -87,6 +100,7 @@ app.add_middleware( ) app.add_middleware(TenantContextMiddleware) +app.add_middleware(ApiKeyMiddleware) app.add_exception_handler(AIServiceException, ai_service_exception_handler) app.add_exception_handler(HTTPException, http_exception_handler) @@ -113,6 +127,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE app.include_router(health_router) app.include_router(chat_router) +app.include_router(api_key_router) app.include_router(dashboard_router) app.include_router(embedding_router) app.include_router(kb_router) diff --git a/ai-service/app/models/__init__.py b/ai-service/app/models/__init__.py index cbbe9e7..f30da8b 100644 --- a/ai-service/app/models/__init__.py +++ b/ai-service/app/models/__init__.py @@ -50,6 +50,7 @@ class ErrorCode(str, Enum): INVALID_REQUEST = "INVALID_REQUEST" MISSING_TENANT_ID = "MISSING_TENANT_ID" INVALID_TENANT_ID = "INVALID_TENANT_ID" + UNAUTHORIZED = "UNAUTHORIZED" INTERNAL_ERROR = "INTERNAL_ERROR" SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" TIMEOUT = "TIMEOUT" diff --git a/ai-service/app/models/entities.py b/ai-service/app/models/entities.py index cf41595..dd9363a 100644 --- a/ai-service/app/models/entities.py +++ b/ai-service/app/models/entities.py @@ -198,3 +198,27 @@ class DocumentCreate(SQLModel): file_path: str | None = None file_size: int | None = None file_type: str | None = None + + +class ApiKey(SQLModel, table=True): + """ + [AC-AISVC-50] API Key entity for lightweight authentication. + Keys are loaded into memory on startup for fast validation. + """ + + __tablename__ = "api_keys" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + key: str = Field(..., description="API Key (unique)", unique=True, index=True) + name: str = Field(..., description="Key name/description for identification") + is_active: bool = Field(default=True, description="Whether the key is active") + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") + + +class ApiKeyCreate(SQLModel): + """Schema for creating a new API key.""" + + key: str + name: str + is_active: bool = True diff --git a/ai-service/app/services/api_key.py b/ai-service/app/services/api_key.py new file mode 100644 index 0000000..a4c14a6 --- /dev/null +++ b/ai-service/app/services/api_key.py @@ -0,0 +1,249 @@ +""" +API Key management service. +[AC-AISVC-50] Lightweight authentication with in-memory cache. +""" + +import logging +import secrets +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.entities import ApiKey, ApiKeyCreate + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """ + [AC-AISVC-50] API Key management service. + + Features: + - In-memory cache for fast validation + - Database persistence + - Hot-reload support + """ + + def __init__(self): + self._keys_cache: set[str] = set() + self._initialized: bool = False + + async def initialize(self, session: AsyncSession) -> None: + """ + Load all active API keys from database into memory. + Should be called on application startup. + """ + result = await session.execute( + select(ApiKey).where(ApiKey.is_active == True) + ) + keys = result.scalars().all() + + self._keys_cache = {key.key for key in keys} + self._initialized = True + + logger.info(f"[AC-AISVC-50] Loaded {len(self._keys_cache)} API keys into memory") + + def validate_key(self, key: str) -> bool: + """ + Validate an API key against the in-memory cache. + + Args: + key: The API key to validate + + Returns: + True if the key is valid, False otherwise + """ + if not self._initialized: + logger.warning("[AC-AISVC-50] API key service not initialized") + return False + + return key in self._keys_cache + + def generate_key(self) -> str: + """ + Generate a new secure API key. + + Returns: + A URL-safe random string + """ + return secrets.token_urlsafe(32) + + async def create_key( + self, + session: AsyncSession, + key_create: ApiKeyCreate + ) -> ApiKey: + """ + Create a new API key. + + Args: + session: Database session + key_create: Key creation data + + Returns: + The created ApiKey entity + """ + api_key = ApiKey( + key=key_create.key, + name=key_create.name, + is_active=key_create.is_active, + ) + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + if api_key.is_active: + self._keys_cache.add(api_key.key) + + logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}") + return api_key + + async def create_default_key(self, session: AsyncSession) -> Optional[ApiKey]: + """ + Create a default API key if none exists. + + Returns: + The created ApiKey or None if keys already exist + """ + result = await session.execute(select(ApiKey).limit(1)) + existing = result.scalar_one_or_none() + + if existing: + return None + + default_key = secrets.token_urlsafe(32) + api_key = ApiKey( + key=default_key, + name="Default API Key", + is_active=True, + ) + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + self._keys_cache.add(api_key.key) + + logger.info(f"[AC-AISVC-50] Created default API key: {api_key.key}") + return api_key + + async def delete_key( + self, + session: AsyncSession, + key_id: str + ) -> bool: + """ + Delete an API key. + + Args: + session: Database session + key_id: The key ID to delete + + Returns: + True if deleted, False if not found + """ + import uuid + + try: + key_uuid = uuid.UUID(key_id) + except ValueError: + return False + + result = await session.execute( + select(ApiKey).where(ApiKey.id == key_uuid) + ) + api_key = result.scalar_one_or_none() + + if not api_key: + return False + + key_value = api_key.key + await session.delete(api_key) + await session.commit() + + self._keys_cache.discard(key_value) + + logger.info(f"[AC-AISVC-50] Deleted API key: {api_key.name}") + return True + + async def toggle_key( + self, + session: AsyncSession, + key_id: str, + is_active: bool + ) -> Optional[ApiKey]: + """ + Toggle API key active status. + + Args: + session: Database session + key_id: The key ID to toggle + is_active: New active status + + Returns: + The updated ApiKey or None if not found + """ + import uuid + + try: + key_uuid = uuid.UUID(key_id) + except ValueError: + return None + + result = await session.execute( + select(ApiKey).where(ApiKey.id == key_uuid) + ) + api_key = result.scalar_one_or_none() + + if not api_key: + return None + + api_key.is_active = is_active + api_key.updated_at = datetime.utcnow() + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + if is_active: + self._keys_cache.add(api_key.key) + else: + self._keys_cache.discard(api_key.key) + + logger.info(f"[AC-AISVC-50] Toggled API key {api_key.name}: active={is_active}") + return api_key + + async def list_keys(self, session: AsyncSession) -> list[ApiKey]: + """ + List all API keys. + + Args: + session: Database session + + Returns: + List of all ApiKey entities + """ + result = await session.execute(select(ApiKey)) + return list(result.scalars().all()) + + async def reload_cache(self, session: AsyncSession) -> None: + """ + Reload all API keys from database into memory. + """ + self._keys_cache.clear() + await self.initialize(session) + logger.info("[AC-AISVC-50] API key cache reloaded") + + +_api_key_service: ApiKeyService | None = None + + +def get_api_key_service() -> ApiKeyService: + """Get the global API key service instance.""" + global _api_key_service + if _api_key_service is None: + _api_key_service = ApiKeyService() + return _api_key_service diff --git a/ai-service/app/services/embedding/factory.py b/ai-service/app/services/embedding/factory.py index e42e506..90c7285 100644 --- a/ai-service/app/services/embedding/factory.py +++ b/ai-service/app/services/embedding/factory.py @@ -7,7 +7,9 @@ Design reference: progress.md Section 7.1 - Architecture - EmbeddingConfigManager: manages configuration with hot-reload support """ +import json import logging +from pathlib import Path from typing import Any, Type from app.services.embedding.base import EmbeddingException, EmbeddingProvider @@ -17,6 +19,8 @@ from app.services.embedding.nomic_provider import NomicEmbeddingProvider logger = logging.getLogger(__name__) +EMBEDDING_CONFIG_FILE = Path("config/embedding_config.json") + class EmbeddingProviderFactory: """ @@ -74,11 +78,38 @@ class EmbeddingProviderFactory: "nomic": "Nomic-embed-text v1.5 优化版,支持任务前缀和 Matryoshka 维度截断,专为RAG优化", } + raw_schema = temp_instance.get_config_schema() + + properties = {} + required = [] + for key, field in raw_schema.items(): + properties[key] = { + "type": field.get("type", "string"), + "title": field.get("title", key), + "description": field.get("description", ""), + "default": field.get("default"), + } + if field.get("enum"): + properties[key]["enum"] = field.get("enum") + if field.get("minimum") is not None: + properties[key]["minimum"] = field.get("minimum") + if field.get("maximum") is not None: + properties[key]["maximum"] = field.get("maximum") + if field.get("required"): + required.append(key) + + config_schema = { + "type": "object", + "properties": properties, + } + if required: + config_schema["required"] = required + return { "name": name, "display_name": display_names.get(name, name), "description": descriptions.get(name, ""), - "config_schema": temp_instance.get_config_schema(), + "config_schema": config_schema, } @classmethod @@ -125,17 +156,46 @@ class EmbeddingProviderFactory: class EmbeddingConfigManager: """ Manager for embedding configuration. - [AC-AISVC-31] Supports hot-reload of configuration. + [AC-AISVC-31] Supports hot-reload of configuration with persistence. """ def __init__(self, default_provider: str = "ollama", default_config: dict[str, Any] | None = None): - self._provider_name = default_provider - self._config = default_config or { + self._default_provider = default_provider + self._default_config = default_config or { "base_url": "http://localhost:11434", "model": "nomic-embed-text", "dimension": 768, } + self._provider_name = default_provider + self._config = self._default_config.copy() self._provider: EmbeddingProvider | None = None + + self._load_from_file() + + def _load_from_file(self) -> None: + """Load configuration from file if exists.""" + try: + if EMBEDDING_CONFIG_FILE.exists(): + with open(EMBEDDING_CONFIG_FILE, 'r', encoding='utf-8') as f: + saved = json.load(f) + self._provider_name = saved.get("provider", self._default_provider) + self._config = saved.get("config", self._default_config.copy()) + logger.info(f"Loaded embedding config from file: provider={self._provider_name}") + except Exception as e: + logger.warning(f"Failed to load embedding config from file: {e}") + + def _save_to_file(self) -> None: + """Save configuration to file.""" + try: + EMBEDDING_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(EMBEDDING_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump({ + "provider": self._provider_name, + "config": self._config, + }, f, indent=2, ensure_ascii=False) + logger.info(f"Saved embedding config to file: provider={self._provider_name}") + except Exception as e: + logger.error(f"Failed to save embedding config to file: {e}") def get_provider_name(self) -> str: """Get current provider name.""" @@ -174,7 +234,7 @@ class EmbeddingConfigManager: ) -> bool: """ Update embedding configuration. - [AC-AISVC-31, AC-AISVC-40] Supports hot-reload. + [AC-AISVC-31, AC-AISVC-40] Supports hot-reload with persistence. Args: provider: New provider name @@ -202,6 +262,8 @@ class EmbeddingConfigManager: self._config = config self._provider = new_provider_instance + self._save_to_file() + logger.info(f"Updated embedding config: provider={provider}") return True @@ -286,7 +348,7 @@ def get_embedding_config_manager() -> EmbeddingConfigManager: settings = get_settings() _embedding_config_manager = EmbeddingConfigManager( - default_provider="ollama", + default_provider="nomic", default_config={ "base_url": settings.ollama_base_url, "model": settings.ollama_embedding_model, diff --git a/ai-service/app/services/embedding/nomic_provider.py b/ai-service/app/services/embedding/nomic_provider.py index ba6a73b..cd19825 100644 --- a/ai-service/app/services/embedding/nomic_provider.py +++ b/ai-service/app/services/embedding/nomic_provider.py @@ -149,6 +149,7 @@ class NomicEmbeddingProvider(EmbeddingProvider): embedding_256 = self._truncate_and_normalize(embedding, 256) embedding_512 = self._truncate_and_normalize(embedding, 512) + embedding_full = self._truncate_and_normalize(embedding, len(embedding)) logger.debug( f"Generated Nomic embedding: task={task.value}, " @@ -156,7 +157,7 @@ class NomicEmbeddingProvider(EmbeddingProvider): ) return NomicEmbeddingResult( - embedding_full=embedding, + embedding_full=embedding_full, embedding_256=embedding_256, embedding_512=embedding_512, dimension=len(embedding), @@ -259,26 +260,31 @@ class NomicEmbeddingProvider(EmbeddingProvider): return { "base_url": { "type": "string", + "title": "API 地址", "description": "Ollama API 地址", "default": "http://localhost:11434", }, "model": { "type": "string", + "title": "模型名称", "description": "嵌入模型名称(推荐 nomic-embed-text v1.5)", "default": "nomic-embed-text", }, "dimension": { "type": "integer", + "title": "向量维度", "description": "向量维度(支持 256/512/768)", "default": 768, }, "timeout_seconds": { "type": "integer", + "title": "超时时间", "description": "请求超时时间(秒)", "default": 60, }, "enable_matryoshka": { "type": "boolean", + "title": "Matryoshka 截断", "description": "启用 Matryoshka 维度截断", "default": True, }, diff --git a/ai-service/app/services/embedding/ollama_provider.py b/ai-service/app/services/embedding/ollama_provider.py index 39093e6..c57b0a4 100644 --- a/ai-service/app/services/embedding/ollama_provider.py +++ b/ai-service/app/services/embedding/ollama_provider.py @@ -130,21 +130,25 @@ class OllamaEmbeddingProvider(EmbeddingProvider): return { "base_url": { "type": "string", + "title": "API 地址", "description": "Ollama API 地址", "default": "http://localhost:11434", }, "model": { "type": "string", + "title": "模型名称", "description": "嵌入模型名称", "default": "nomic-embed-text", }, "dimension": { "type": "integer", + "title": "向量维度", "description": "向量维度", "default": 768, }, "timeout_seconds": { "type": "integer", + "title": "超时时间", "description": "请求超时时间(秒)", "default": 60, }, diff --git a/ai-service/app/services/embedding/openai_provider.py b/ai-service/app/services/embedding/openai_provider.py index 0e15aab..31b4a00 100644 --- a/ai-service/app/services/embedding/openai_provider.py +++ b/ai-service/app/services/embedding/openai_provider.py @@ -159,28 +159,33 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): return { "api_key": { "type": "string", + "title": "API 密钥", "description": "OpenAI API 密钥", "required": True, "secret": True, }, "model": { "type": "string", + "title": "模型名称", "description": "嵌入模型名称", "default": "text-embedding-3-small", "enum": list(self.MODEL_DIMENSIONS.keys()), }, "base_url": { "type": "string", + "title": "API 地址", "description": "OpenAI API 地址(支持兼容接口)", "default": "https://api.openai.com/v1", }, "dimension": { "type": "integer", + "title": "向量维度", "description": "向量维度(仅 text-embedding-3 系列支持自定义)", "default": 1536, }, "timeout_seconds": { "type": "integer", + "title": "超时时间", "description": "请求超时时间(秒)", "default": 60, }, diff --git a/ai-service/app/services/llm/factory.py b/ai-service/app/services/llm/factory.py index d983c47..25f1073 100644 --- a/ai-service/app/services/llm/factory.py +++ b/ai-service/app/services/llm/factory.py @@ -5,8 +5,10 @@ LLM Provider Factory and Configuration Management. Design pattern: Factory pattern for pluggable LLM providers. """ +import json import logging from dataclasses import dataclass, field +from pathlib import Path from typing import Any from app.services.llm.base import LLMClient, LLMConfig @@ -14,6 +16,8 @@ from app.services.llm.openai_client import OpenAIClient logger = logging.getLogger(__name__) +LLM_CONFIG_FILE = Path("config/llm_config.json") + @dataclass class LLMProviderInfo: @@ -257,7 +261,7 @@ class LLMProviderFactory: class LLMConfigManager: """ Manager for LLM configuration. - [AC-ASA-16, AC-ASA-17, AC-ASA-18] Configuration management with hot-reload. + [AC-ASA-16, AC-ASA-17, AC-ASA-18] Configuration management with hot-reload and persistence. """ def __init__(self): @@ -274,12 +278,41 @@ class LLMConfigManager: "temperature": settings.llm_temperature, } self._client: LLMClient | None = None + + self._load_from_file() + + def _load_from_file(self) -> None: + """Load configuration from file if exists.""" + try: + if LLM_CONFIG_FILE.exists(): + with open(LLM_CONFIG_FILE, 'r', encoding='utf-8') as f: + saved = json.load(f) + self._current_provider = saved.get("provider", self._current_provider) + saved_config = saved.get("config", {}) + if saved_config: + self._current_config.update(saved_config) + logger.info(f"[AC-ASA-16] Loaded LLM config from file: provider={self._current_provider}") + except Exception as e: + logger.warning(f"[AC-ASA-16] Failed to load LLM config from file: {e}") + + def _save_to_file(self) -> None: + """Save configuration to file.""" + try: + LLM_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LLM_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump({ + "provider": self._current_provider, + "config": self._current_config, + }, f, indent=2, ensure_ascii=False) + logger.info(f"[AC-ASA-16] Saved LLM config to file: provider={self._current_provider}") + except Exception as e: + logger.error(f"[AC-ASA-16] Failed to save LLM config to file: {e}") def get_current_config(self) -> dict[str, Any]: """Get current LLM configuration.""" return { "provider": self._current_provider, - "config": self._current_config, + "config": self._current_config.copy(), } async def update_config( @@ -289,7 +322,7 @@ class LLMConfigManager: ) -> bool: """ Update LLM configuration. - [AC-ASA-16] Hot-reload configuration. + [AC-ASA-16] Hot-reload configuration with persistence. Args: provider: Provider name @@ -310,6 +343,8 @@ class LLMConfigManager: self._current_provider = provider self._current_config = validated_config + + self._save_to_file() logger.info(f"[AC-ASA-16] LLM config updated: provider={provider}") return True @@ -365,7 +400,7 @@ class LLMConfigManager: test_provider = provider or self._current_provider test_config = config if config else self._current_config - logger.info(f"[AC-ASA-17] Test connection: provider={test_provider}, config={test_config}") + logger.info(f"[AC-ASA-17] Test connection: provider={test_provider}, model={test_config.get('model')}") if test_provider not in LLM_PROVIDERS: return { diff --git a/ai-service/app/services/orchestrator.py b/ai-service/app/services/orchestrator.py index 42d16dc..9b3d418 100644 --- a/ai-service/app/services/orchestrator.py +++ b/ai-service/app/services/orchestrator.py @@ -119,13 +119,7 @@ class OrchestratorService: max_evidence_tokens=getattr(settings, "rag_max_evidence_tokens", 2000), enable_rag=True, ) - self._llm_config = LLMConfig( - model=getattr(settings, "llm_model", "gpt-4o-mini"), - max_tokens=getattr(settings, "llm_max_tokens", 2048), - temperature=getattr(settings, "llm_temperature", 0.7), - timeout_seconds=getattr(settings, "llm_timeout_seconds", 30), - max_retries=getattr(settings, "llm_max_retries", 3), - ) + self._llm_config: LLMConfig | None = None async def generate( self, @@ -345,7 +339,6 @@ class OrchestratorService: try: ctx.llm_response = await self._llm_client.generate( messages=messages, - config=self._llm_config, ) ctx.diagnostics["llm_mode"] = "live" ctx.diagnostics["llm_model"] = ctx.llm_response.model @@ -627,7 +620,7 @@ class OrchestratorService: """ messages = self._build_llm_messages(ctx) - async for chunk in self._llm_client.stream_generate(messages, self._llm_config): + async for chunk in self._llm_client.stream_generate(messages): if not state_machine.can_send_message(): break diff --git a/ai-service/app/services/retrieval/optimized_retriever.py b/ai-service/app/services/retrieval/optimized_retriever.py index 1c773d8..2150552 100644 --- a/ai-service/app/services/retrieval/optimized_retriever.py +++ b/ai-service/app/services/retrieval/optimized_retriever.py @@ -84,7 +84,13 @@ class RRFCombiner: "bm25_rank": -1, "payload": result.get("payload", {}), "id": chunk_id, + "vector": result.get("vector"), } + else: + combined_scores[chunk_id]["vector_score"] = result.get("score", 0.0) + combined_scores[chunk_id]["vector_rank"] = rank + if result.get("vector"): + combined_scores[chunk_id]["vector"] = result.get("vector") combined_scores[chunk_id]["score"] += rrf_score @@ -101,6 +107,7 @@ class RRFCombiner: "bm25_rank": rank, "payload": result.get("payload", {}), "id": chunk_id, + "vector": result.get("vector"), } else: combined_scores[chunk_id]["bm25_score"] = result.get("score", 0.0) @@ -131,7 +138,6 @@ class OptimizedRetriever(BaseRetriever): def __init__( self, qdrant_client: QdrantClient | None = None, - embedding_provider: NomicEmbeddingProvider | None = None, top_k: int | None = None, score_threshold: float | None = None, min_hits: int | None = None, @@ -141,7 +147,6 @@ class OptimizedRetriever(BaseRetriever): rrf_k: int | None = None, ): self._qdrant_client = qdrant_client - self._embedding_provider = embedding_provider self._top_k = top_k or settings.rag_top_k self._score_threshold = score_threshold or settings.rag_score_threshold self._min_hits = min_hits or settings.rag_min_hits @@ -157,19 +162,17 @@ class OptimizedRetriever(BaseRetriever): return self._qdrant_client async def _get_embedding_provider(self) -> NomicEmbeddingProvider: - if self._embedding_provider is None: - from app.services.embedding.factory import get_embedding_config_manager - manager = get_embedding_config_manager() - provider = await manager.get_provider() - if isinstance(provider, NomicEmbeddingProvider): - self._embedding_provider = provider - else: - self._embedding_provider = NomicEmbeddingProvider( - base_url=settings.ollama_base_url, - model=settings.ollama_embedding_model, - dimension=settings.qdrant_vector_size, - ) - return self._embedding_provider + from app.services.embedding.factory import get_embedding_config_manager + manager = get_embedding_config_manager() + provider = await manager.get_provider() + if isinstance(provider, NomicEmbeddingProvider): + return provider + else: + return NomicEmbeddingProvider( + base_url=settings.ollama_base_url, + model=settings.ollama_embedding_model, + dimension=settings.qdrant_vector_size, + ) async def retrieve(self, ctx: RetrievalContext) -> RetrievalResult: """ @@ -199,7 +202,15 @@ class OptimizedRetriever(BaseRetriever): f"dim_256={'available' if embedding_result.embedding_256 else 'not available'}" ) - if self._two_stage_enabled: + if self._two_stage_enabled and self._hybrid_enabled: + logger.info("[RAG-OPT] Using two-stage + hybrid retrieval strategy") + results = await self._two_stage_hybrid_retrieve( + ctx.tenant_id, + embedding_result, + ctx.query, + self._top_k, + ) + elif self._two_stage_enabled: logger.info("[RAG-OPT] Using two-stage retrieval strategy") results = await self._two_stage_retrieve( ctx.tenant_id, @@ -300,20 +311,27 @@ class OptimizedRetriever(BaseRetriever): stage1_start = time.perf_counter() candidates = await self._search_with_dimension( client, tenant_id, embedding_result.embedding_256, "dim_256", - top_k * self._two_stage_expand_factor + top_k * self._two_stage_expand_factor, + with_vectors=True, ) stage1_latency = (time.perf_counter() - stage1_start) * 1000 - logger.debug( + logger.info( f"[RAG-OPT] Stage 1: {len(candidates)} candidates in {stage1_latency:.2f}ms" ) stage2_start = time.perf_counter() reranked = [] for candidate in candidates: - stored_full_embedding = candidate.get("payload", {}).get("embedding_full", []) - if stored_full_embedding: - import numpy as np + vector_data = candidate.get("vector", {}) + stored_full_embedding = None + + if isinstance(vector_data, dict): + stored_full_embedding = vector_data.get("full", []) + elif isinstance(vector_data, list): + stored_full_embedding = vector_data + + if stored_full_embedding and len(stored_full_embedding) > 0: similarity = self._cosine_similarity( embedding_result.embedding_full, stored_full_embedding @@ -326,7 +344,7 @@ class OptimizedRetriever(BaseRetriever): results = reranked[:top_k] stage2_latency = (time.perf_counter() - stage2_start) * 1000 - logger.debug( + logger.info( f"[RAG-OPT] Stage 2: {len(results)} final results in {stage2_latency:.2f}ms" ) @@ -374,6 +392,92 @@ class OptimizedRetriever(BaseRetriever): return combined[:top_k] + async def _two_stage_hybrid_retrieve( + self, + tenant_id: str, + embedding_result: NomicEmbeddingResult, + query: str, + top_k: int, + ) -> list[dict[str, Any]]: + """ + Two-stage + Hybrid retrieval strategy. + + Stage 1: Fast retrieval with 256-dim vectors + BM25 in parallel + Stage 2: RRF fusion + Precise reranking with 768-dim vectors + + This combines the best of both worlds: + - Two-stage: Speed from 256-dim, precision from 768-dim reranking + - Hybrid: Semantic matching from vectors, keyword matching from BM25 + """ + import time + + client = await self._get_client() + + stage1_start = time.perf_counter() + + vector_task = self._search_with_dimension( + client, tenant_id, embedding_result.embedding_256, "dim_256", + top_k * self._two_stage_expand_factor, + with_vectors=True, + ) + + bm25_task = self._bm25_search(client, tenant_id, query, top_k * self._two_stage_expand_factor) + + vector_results, bm25_results = await asyncio.gather( + vector_task, bm25_task, return_exceptions=True + ) + + if isinstance(vector_results, Exception): + logger.warning(f"[RAG-OPT] Vector search failed: {vector_results}") + vector_results = [] + + if isinstance(bm25_results, Exception): + logger.warning(f"[RAG-OPT] BM25 search failed: {bm25_results}") + bm25_results = [] + + stage1_latency = (time.perf_counter() - stage1_start) * 1000 + logger.info( + f"[RAG-OPT] Two-stage Hybrid Stage 1: vector={len(vector_results)}, bm25={len(bm25_results)}, latency={stage1_latency:.2f}ms" + ) + + stage2_start = time.perf_counter() + + combined = self._rrf_combiner.combine( + vector_results, + bm25_results, + vector_weight=settings.rag_vector_weight, + bm25_weight=settings.rag_bm25_weight, + ) + + reranked = [] + for candidate in combined[:top_k * 2]: + vector_data = candidate.get("vector", {}) + stored_full_embedding = None + + if isinstance(vector_data, dict): + stored_full_embedding = vector_data.get("full", []) + elif isinstance(vector_data, list): + stored_full_embedding = vector_data + + if stored_full_embedding and len(stored_full_embedding) > 0: + similarity = self._cosine_similarity( + embedding_result.embedding_full, + stored_full_embedding + ) + candidate["score"] = similarity + candidate["stage"] = "two_stage_hybrid_reranked" + reranked.append(candidate) + + reranked.sort(key=lambda x: x.get("score", 0), reverse=True) + results = reranked[:top_k] + stage2_latency = (time.perf_counter() - stage2_start) * 1000 + + logger.info( + f"[RAG-OPT] Two-stage Hybrid Stage 2 (reranking): {len(results)} final results in {stage2_latency:.2f}ms" + ) + + return results + async def _vector_retrieve( self, tenant_id: str, @@ -393,45 +497,37 @@ class OptimizedRetriever(BaseRetriever): query_vector: list[float], vector_name: str, limit: int, + with_vectors: bool = False, ) -> list[dict[str, Any]]: """Search using specified vector dimension.""" try: - qdrant = await client.get_client() - collection_name = client.get_collection_name(tenant_id) - logger.info( - f"[RAG-OPT] Searching collection={collection_name}, " - f"vector_name={vector_name}, limit={limit}, vector_dim={len(query_vector)}" + f"[RAG-OPT] Searching with vector_name={vector_name}, " + f"limit={limit}, vector_dim={len(query_vector)}, with_vectors={with_vectors}" ) - results = await qdrant.search( - collection_name=collection_name, - query_vector=(vector_name, query_vector), + results = await client.search( + tenant_id=tenant_id, + query_vector=query_vector, limit=limit, + vector_name=vector_name, + with_vectors=with_vectors, ) logger.info( - f"[RAG-OPT] Search returned {len(results)} results from collection={collection_name}" + f"[RAG-OPT] Search returned {len(results)} results" ) if len(results) > 0: for i, r in enumerate(results[:3]): logger.debug( - f"[RAG-OPT] Result {i+1}: id={r.id}, score={r.score:.4f}" + f"[RAG-OPT] Result {i+1}: id={r['id']}, score={r['score']:.4f}" ) - return [ - { - "id": str(result.id), - "score": result.score, - "payload": result.payload or {}, - } - for result in results - ] + return results except Exception as e: logger.error( - f"[RAG-OPT] Search with {vector_name} failed: {e}, " - f"collection_name={client.get_collection_name(tenant_id)}", + f"[RAG-OPT] Search with {vector_name} failed: {e}", exc_info=True ) return [] diff --git a/ai-service/pyproject.toml b/ai-service/pyproject.toml index ae928af..c497c23 100644 --- a/ai-service/pyproject.toml +++ b/ai-service/pyproject.toml @@ -14,12 +14,13 @@ dependencies = [ "tenacity>=8.2.0", "sqlmodel>=0.0.14", "asyncpg>=0.29.0", - "qdrant-client>=1.7.0", + "qdrant-client>=1.9.0,<2.0.0", "tiktoken>=0.5.0", "openpyxl>=3.1.0", "python-docx>=1.1.0", "pymupdf>=1.23.0", "pdfplumber>=0.10.0", + "python-multipart>=0.0.6", ] [project.optional-dependencies] diff --git a/ai-service/scripts/cleanup_qdrant.py b/ai-service/scripts/cleanup_qdrant.py new file mode 100644 index 0000000..f84e9ef --- /dev/null +++ b/ai-service/scripts/cleanup_qdrant.py @@ -0,0 +1,89 @@ +""" +Script to cleanup Qdrant collections and data. +""" + +import asyncio +import logging +import sys + +sys.path.insert(0, "Q:\\agentProject\\ai-robot-core\\ai-service") + +from app.core.config import get_settings +from app.core.qdrant_client import get_qdrant_client + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def list_collections(): + """List all collections in Qdrant.""" + client = await get_qdrant_client() + qdrant = await client.get_client() + + collections = await qdrant.get_collections() + return [c.name for c in collections.collections] + + +async def delete_collection(collection_name: str): + """Delete a specific collection.""" + client = await get_qdrant_client() + qdrant = await client.get_client() + + try: + await qdrant.delete_collection(collection_name) + logger.info(f"Deleted collection: {collection_name}") + return True + except Exception as e: + logger.error(f"Failed to delete collection {collection_name}: {e}") + return False + + +async def delete_all_collections(): + """Delete all collections.""" + collections = await list_collections() + logger.info(f"Found {len(collections)} collections: {collections}") + + for name in collections: + await delete_collection(name) + + logger.info("All collections deleted") + + +async def delete_tenant_collection(tenant_id: str): + """Delete collection for a specific tenant.""" + client = await get_qdrant_client() + collection_name = client.get_collection_name(tenant_id) + + success = await delete_collection(collection_name) + if success: + logger.info(f"Deleted collection for tenant: {tenant_id}") + return success + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Cleanup Qdrant data") + parser.add_argument("--all", action="store_true", help="Delete all collections") + parser.add_argument("--tenant", type=str, help="Delete collection for specific tenant") + parser.add_argument("--list", action="store_true", help="List all collections") + + args = parser.parse_args() + + if args.list: + collections = asyncio.run(list_collections()) + print(f"Collections: {collections}") + elif args.all: + confirm = input("Are you sure you want to delete ALL collections? (yes/no): ") + if confirm.lower() == "yes": + asyncio.run(delete_all_collections()) + else: + print("Cancelled") + elif args.tenant: + confirm = input(f"Delete collection for tenant '{args.tenant}'? (yes/no): ") + if confirm.lower() == "yes": + asyncio.run(delete_tenant_collection(args.tenant)) + else: + print("Cancelled") + else: + parser.print_help() diff --git a/ai-service/scripts/init_db.sql b/ai-service/scripts/init_db.sql index 9a68dac..691c833 100644 --- a/ai-service/scripts/init_db.sql +++ b/ai-service/scripts/init_db.sql @@ -28,6 +28,13 @@ CREATE TABLE IF NOT EXISTS chat_messages ( session_id VARCHAR NOT NULL, role VARCHAR NOT NULL, content TEXT NOT NULL, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + latency_ms INTEGER, + first_token_ms INTEGER, + is_error BOOLEAN NOT NULL DEFAULT FALSE, + error_message VARCHAR, created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL ); @@ -74,6 +81,18 @@ CREATE TABLE IF NOT EXISTS index_jobs ( updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL ); +-- ============================================ +-- API Keys Table [AC-AISVC-50] +-- ============================================ +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID NOT NULL PRIMARY KEY, + key VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + -- ============================================ -- Indexes -- ============================================ @@ -100,6 +119,10 @@ CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_id ON index_jobs (tenant_id); CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_doc ON index_jobs (tenant_id, doc_id); CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_status ON index_jobs (tenant_id, status); +-- API Keys Indexes [AC-AISVC-50] +CREATE INDEX IF NOT EXISTS ix_api_keys_key ON api_keys (key); +CREATE INDEX IF NOT EXISTS ix_api_keys_is_active ON api_keys (is_active); + -- ============================================ -- Verification -- ============================================ diff --git a/ai-service/scripts/migrate_add_columns.sql b/ai-service/scripts/migrate_add_columns.sql new file mode 100644 index 0000000..c080a68 --- /dev/null +++ b/ai-service/scripts/migrate_add_columns.sql @@ -0,0 +1,29 @@ +-- Migration: Add missing columns to chat_messages table +-- Execute this on existing database to add new columns + +-- Add token tracking columns +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS completion_tokens INTEGER; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS total_tokens INTEGER; + +-- Add latency tracking columns +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS latency_ms INTEGER; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS first_token_ms INTEGER; + +-- Add error tracking columns +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS is_error BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS error_message VARCHAR; + +-- Create API Keys table if not exists +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID NOT NULL PRIMARY KEY, + key VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Create API Keys indexes +CREATE INDEX IF NOT EXISTS ix_api_keys_key ON api_keys (key); +CREATE INDEX IF NOT EXISTS ix_api_keys_is_active ON api_keys (is_active); diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..c67246e --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,134 @@ +# AI Service Nginx Configuration +# 将此文件放置于 /etc/nginx/conf.d/ai-service.conf +# 或 include 到主配置文件中 + +# 后端 API 上游(供 Java 渠道侧调用) +upstream ai_service_backend { + server 127.0.0.1:8182; +} + +# 前端管理界面上游 +upstream ai_service_admin { + server 127.0.0.1:8181; +} + +# 前端管理界面 +server { + listen 80; + server_name your-domain.com; # 替换为你的域名或服务器IP + + # 访问日志 + access_log /var/log/nginx/ai-service-admin.access.log; + error_log /var/log/nginx/ai-service-admin.error.log; + + location / { + proxy_pass http://ai_service_admin; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + + # SSE 流式响应支持 + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + proxy_buffering off; + } +} + +# 后端 API(供 Java 渠道侧调用) +# 如果使用域名,可以用不同的路径或子域名 +# 示例:api.your-domain.com 或 your-domain.com/api/ +server { + listen 80; + server_name api.your-domain.com; # 替换为 API 子域名 + + # 访问日志 + access_log /var/log/nginx/ai-service-api.access.log; + error_log /var/log/nginx/ai-service-api.error.log; + + location / { + proxy_pass http://ai_service_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + + # SSE 流式响应支持 + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + proxy_buffering off; + } +} + +# ============================================================ +# HTTPS 配置示例 (使用 Let's Encrypt) +# ============================================================ + +# server { +# listen 443 ssl http2; +# server_name your-domain.com; +# +# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; +# +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; +# ssl_prefer_server_ciphers off; +# +# location / { +# proxy_pass http://ai_service_admin; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# 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; +# proxy_cache_bypass $http_upgrade; +# proxy_read_timeout 300s; +# proxy_connect_timeout 75s; +# proxy_buffering off; +# } +# } + +# server { +# listen 443 ssl http2; +# server_name api.your-domain.com; +# +# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; +# +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; +# ssl_prefer_server_ciphers off; +# +# location / { +# proxy_pass http://ai_service_backend; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# 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; +# proxy_cache_bypass $http_upgrade; +# proxy_read_timeout 300s; +# proxy_connect_timeout 75s; +# proxy_buffering off; +# } +# } + +# HTTP 重定向到 HTTPS +# server { +# listen 80; +# server_name your-domain.com api.your-domain.com; +# return 301 https://$server_name$request_uri; +# } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..027448b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,108 @@ +services: + ai-service: + build: + context: ./ai-service + dockerfile: Dockerfile + container_name: ai-service + restart: unless-stopped + ports: + - "8182:8080" + environment: + - AI_SERVICE_DEBUG=false + - AI_SERVICE_LOG_LEVEL=INFO + - AI_SERVICE_DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/ai_service + - AI_SERVICE_QDRANT_URL=http://qdrant:6333 + - AI_SERVICE_LLM_PROVIDER=${AI_SERVICE_LLM_PROVIDER:-openai} + - AI_SERVICE_LLM_API_KEY=${AI_SERVICE_LLM_API_KEY:-} + - AI_SERVICE_LLM_BASE_URL=${AI_SERVICE_LLM_BASE_URL:-https://api.openai.com/v1} + - AI_SERVICE_LLM_MODEL=${AI_SERVICE_LLM_MODEL:-gpt-4o-mini} + - AI_SERVICE_OLLAMA_BASE_URL=${AI_SERVICE_OLLAMA_BASE_URL:-http://ollama:11434} + volumes: + - ai_service_config:/app/config + depends_on: + postgres: + condition: service_healthy + qdrant: + condition: service_started + networks: + - ai-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/ai/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + ai-service-admin: + build: + context: ./ai-service-admin + dockerfile: Dockerfile + args: + VITE_APP_API_KEY: ${VITE_APP_API_KEY:-} + VITE_APP_BASE_API: /api + container_name: ai-service-admin + restart: unless-stopped + ports: + - "8183:80" + depends_on: + - ai-service + networks: + - ai-network + + postgres: + image: postgres:15-alpine + container_name: ai-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=ai_service + volumes: + - postgres_data:/var/lib/postgresql/data + - ./ai-service/scripts/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql:ro + ports: + - "5432:5432" + networks: + - ai-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d ai_service"] + interval: 10s + timeout: 5s + retries: 5 + + qdrant: + image: qdrant/qdrant:latest + container_name: ai-qdrant + restart: unless-stopped + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + networks: + - ai-network + + ollama: + image: ollama/ollama:latest + container_name: ai-ollama + restart: unless-stopped + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + networks: + - ai-network + deploy: + resources: + reservations: + memory: 1G + +networks: + ai-network: + driver: bridge + +volumes: + postgres_data: + qdrant_data: + ollama_data: + ai_service_config: