commit b4ff5db17bf35e0c10451cafddf06cfced3ef7e3 Author: wangjiming Date: Mon Jul 27 09:12:47 2026 +0800 第一次提交 diff --git a/.codebuddy/rules/代码规范.mdc b/.codebuddy/rules/代码规范.mdc new file mode 100644 index 0000000..dd54a05 --- /dev/null +++ b/.codebuddy/rules/代码规范.mdc @@ -0,0 +1,35 @@ +--- +description: +alwaysApply: true +enabled: true +updatedAt: 2026-07-21T09:21:46.222Z +provider: +--- + +## 编码前思考 +- 明确假设,不确定时询问而非猜测。 +- 存在歧义时,列出多种解释,不默默选定单一方案。 +- 如果任务有明显更简单的做法,直接指出优化思路。 +- 发现代码矛盾、逻辑不一致时及时暂停,请求信息澄清。 + +## 简洁优先 +- 用最少的代码解决问题,拒绝冗余实现。 +- 不为一次性需求创建抽象层、复杂架构。 +- 不盲目增加扩展性、可配置性,应对“未来可能用到”的场景。 +- 若代码可大幅精简,主动重写优化。 +- 校验标准:以资深工程师视角判断,代码若过于复杂,立即简化。 + +## 精准修改 +- 仅修改与当前任务直接相关的代码内容。 +- 不顺手优化相邻代码、注释、排版格式。 +- 不重构原本可以正常运行的代码模块。 +- 严格匹配项目现有代码风格,保留原有编码习惯。 +- 因本次修改产生的无效导入、废弃变量,可直接删除。 +- 发现项目中原有的死代码、冗余内容,仅做文字提醒,不擅自删除。 + +## 目标驱动执行 +- 执行任务前,定义清晰、可落地的成功标准。 +- 将“修复Bug”转化为:编写用例复现问题,再调试至用例正常通过。 +- 将“新增校验功能”转化为:针对异常输入编写测试用例,保证全部通过。 +- 将“代码重构”转化为:完成重构后,确保原有所有测试用例正常运行。 +- 多步骤复杂任务,先输出简短执行计划,同时标注每一步的验证方式。 \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3697f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +node_modules +frontend/node_modules +frontend/dist +frontend/.vite +npm-debug.log* +docker-compose*.yml +README.md +design-qa.md +docs \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afaf213 --- /dev/null +++ b/.gitignore @@ -0,0 +1,189 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +!frontend/dist/ +!frontend/dist/** +node_modules/ +*.tsbuildinfo +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Runtime data and logs +runtime/ +backend/runtime/ +logs/ +backend/logs/ +*.db +*.sqlite +*.sqlite3 + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c90aa1 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# YG_FT 模型微调平台 + +YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。 + +当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现正式系统主链路能力。当前代码和 SQL 均作为后续生产演进基线维护,不再以一次性演示或静态 Mock 为开发准则。 + +## 总体架构 + +```text +YG_FT/ + frontend/ # 前端控制台 + backend/ # FastAPI 应用平台后端 + app/ + api/v1/ # 对前端暴露的 REST API + core/ # 配置、日志、中间件、权限等基础能力 + db/ # 数据库连接、迁移、事务工具 + modules/ # 业务模块目录 + schemas/ # Pydantic 入参/出参模型 + services/ # 跨模块应用服务 + workers/ # 后台任务入口 + requirements.txt # 后端 Python 第三方依赖 + compute/ # 算力平台与训练框架适配层 + api/ # 内部 Compute API + agent/ # 单机多 GPU 调度与进程管理 + engines/llama_factory/ # LLaMA-Factory 适配器 + file_gateway/ # 本地文件上传、下载、导入、产物管理 + docs/ # 需求、接口、数据库、开发计划和部署文档 + docker/ # 容器化配置 +``` + +## 平台分层 + +| 层级 | 职责 | 主要目录 | +| --- | --- | --- | +| 前端控制台 | 用户操作入口、任务看板、项目/模型/数据集/训练/审批/审计页面 | `frontend/` | +| 应用平台后端 | 用户中心、多租户、RBAC/ABAC、项目隔离、元数据、审批流、审计、API 编排 | `backend/` | +| 算力平台 | GPU 发现、资源锁定、训练进程管理、日志采集、产物归档、任务状态同步 | `compute/` | +| 训练引擎 | 当前固定接入 LLaMA-Factory,预留其他训练平台适配标准 | `compute/engines/` | +| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` | + +## 当前开发基线 + +- 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }`。 +- 本地运行阶段统一使用 PostgreSQL,后端启动时会在 PG 中初始化当前运行表和系统内置账号;模型、数据集、算力节点、GPU、微调任务等业务数据必须通过页面、接口或正式导入流程产生。 +- 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。 +- 支持训练日志、loss 指标、checkpoint 和训练产物接口;真实训练执行器接入前,联调状态机必须通过显式环境变量开启。 +- 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。 +- 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。 +- `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。 +- 企业治理与基础能力:登录鉴权(`/modelTF/login`、`/modelTF/me`);用户中心(列表/创建/启停、重置密码 `POST /modelTF/users/{id}/reset-password`,保护账号不可重置、空密码回退 `platform123`);租户与配额、留存策略(嵌套于 `/modelTF/tenants/{id}/retention-policy`);项目空间与成员/角色,`/modelTF/projects/{id}/archive` 受待审批变更拦截(409);资源授权 `GET/PUT /modelTF/resources/{type}/{id}/acl`;审批中心(待办/历史/模板)`/modelTF/approvals/*`、`/modelTF/approval-templates/*`;审计中心 `GET /modelTF/system/audit-logs` 及导出;平台性能 `/modelTF/system-info`、`/modelTF/compute/gpus`;日志查看 `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/training-log-*`;服务看板聚合 `/modelTF/dashboard/stats`。 + +## 企业治理模块完成状态 + +> 范围:平台基础与企业治理(用户中心、多租户、项目隔离、审批、审计、资源授权、看板)。 + +| 模块 | 路由 | 后端接口 | 状态 | +| --- | --- | --- | --- | +| 登录 | `/login` | `POST /modelTF/login`、`GET /modelTF/me` | ✅ 已完成 | +| 用户设置 | `/user-settings` 等 | `/modelTF/users`(CRUD)、`/modelTF/users/{id}/reset-password`、页面权限 | ⚠️ 重置密码已完成;页面权限精细控制 UI 待补全 | +| 平台性能 | `/hardware` | `/modelTF/system-info`、`/modelTF/compute/gpus` | ✅ 已完成 | +| 查看日志 | `/logs`、`/training-log/:id` | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/training-log-*` | ✅ 已完成 | +| 租户管理 | `/tenants`、`/tenants/:id` | `/modelTF/tenants/*`、配额、留存(嵌套) | ✅ 已完成(留存为嵌套式,未独立成资源) | +| 项目空间 | `/projects`、`/projects/:id` | `/modelTF/projects/*`、成员、角色、归档拦截 | ✅ 已完成 | +| 资源授权 | 弹窗 | `GET/PUT /modelTF/resources/{type}/{id}/acl` | ✅ 已完成 | +| 审批中心 | `/approvals`、`/approvals/templates` | `/modelTF/approvals/*`、`/modelTF/approval-templates/*` | ✅ 已完成 | +| 审计中心 | `/audit-logs` | `GET /modelTF/system/audit-logs`、导出 | ✅ 已完成(接口挂在 `system` 下) | +| 服务看板 | `/dashboard` | `GET /modelTF/dashboard/stats` | ✅ 已完成 | + +> 说明:`audit`、`retention` 模块当前未拆为独立 REST 路由——审计接口挂在 `system` 下、留存策略嵌套于租户资源;用户「页面权限」精细控制 UI 仍为占位,待后续版本补齐。数据层当前为内存/本地存储实现,生产环境按 `docs/postgres-schema.sql` 迁移 PostgreSQL。 + +## 后端启动 + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 17861 +``` + +默认接口前缀为 `/modelTF`,例如: + +```text +GET /modelTF/health +POST /modelTF/login +GET /modelTF/model-manage +GET /modelTF/dataset-manage +GET /modelTF/fine-tune +GET /modelTF/compute/nodes +``` + +本地运行时默认 PostgreSQL 连接: + +```text +DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft +``` + +本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。 + +开发阶段内置登录账号: + +| 角色 | 账号 | 密码 | 说明 | +| --- | --- | --- | --- | +| 超级管理员 | `admin` | `admin123` | 拥有当前全部页面权限 | +| 操作员 | `operator` | `operator123` | 拥有业务操作相关页面权限 | + +以上账号仅用于本地开发和联调。生产环境初始化后应立即修改密码,或改为企业统一身份认证/管理员初始化流程。 + +## 前端启动 + +```bash +cd frontend +npm install +npm run dev +``` + +前端开发服务默认运行在 `http://localhost:16801`,并通过 Vite proxy 将 `/modelTF` 转发到 `http://localhost:17861`。 + +## 算力服务启动 + +算力服务位于 `compute/` 目录,拥有**独立虚拟环境(不复用后端 venv)**。 +代码使用绝对导入 `compute.*`,因此必须从**仓库根目录(`yg_ft/`)**启动, +不能先 `cd compute` 再启动(否则报 `No module named 'compute'`)。 + +```bash +# 在仓库根目录 yg_ft/ 下执行 +source compute/.venv/bin/activate # 激活独立 venv(Windows 用 compute\.venv\Scripts\activate) +pip install -r compute/requirements.txt # 首次安装依赖 +uvicorn compute.api.main:app --host 0.0.0.0 --port 19100 --reload +``` + +默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。 + +## 日志 + +后端日志模块位于 `backend/app/core/logging.py`,说明文档见: + +- `docs/backend-logging.md` + +默认输出: + +```text +logs/backend-YYYY-MM-DD.log +logs/error-YYYY-MM-DD.log +``` + +日志格式为 JSON Lines,单个文件不超过 20MB,只保留最近 10 天。 + +## 主要文档 + +- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。 +- `docs/menu-functional-requirements.md`:当前菜单、二级路由、规划菜单、功能需求、接口和数据库映射。 +- `docs/backend-api-design.md`:FastAPI 接口分组、参数定义、权限说明。 +- `docs/postgres-schema.sql`:PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。 +- `docs/system-development-plan.md`:多人协作开发计划,按前端、后端、DB、部署拆分。 +- `docs/team-development-plan.md`:3-4 人并行开发分工计划,按人员边界标注页面、接口、数据库和交付节奏。 +- `docs/first-version-development-plan.md`:当前系统主链路开发计划,覆盖前端、后端、DB、Compute API、GPU 和 LLaMA-Factory 适配。 +- `docs/backend-logging.md`:后端日志模块使用说明。 +- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。 +- `docker/README.md`:Docker 部署入口,包含应用服务器和算力服务器两套 Compose 使用方式。 + +## Docker 部署入口 + +应用服务器: + +```bash +cd docker/app +cp .env.example .env +docker compose up -d +``` + +算力服务器: + +```bash +cd docker/compute +cp .env.example .env +docker compose up -d +``` + +两套 Compose 均采用代码外挂方式运行,镜像只包含运行时环境和第三方依赖。项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,部署时统一进入 `docker/app` 或 `docker/compute` 目录执行。 + +## 后续开发原则 + +- 接口实现优先遵循 `docs/backend-api-design.md`。 +- 数据库实现优先遵循 `docs/postgres-schema.sql`,后续通过 Alembic 迁移管理变更。 +- 前端页面与后端接口、数据库表之间的映射以文档中的“对应页面/功能模块”为准。 +- 训练引擎适配必须通过 `compute/engines/` 下的标准接口,不在应用平台后端直接拼接训练命令。 +- 敏感信息不得写入日志,生产环境密钥通过环境变量或密钥管理系统注入。 diff --git a/UI测试手册.md b/UI测试手册.md new file mode 100644 index 0000000..d5ca3a2 --- /dev/null +++ b/UI测试手册.md @@ -0,0 +1,271 @@ +# 第 1~4 周功能 · 界面人工测试手册 + +> 用途:你按这份手册在浏览器里点一遍,验证第 1~4 周的功能(登录、租户/项目、资源 ACL、审计日志、审批中心、写操作自动审计与审批拦截)。 +> 功能代码层均已联调通过(含此前修复的 `audit.ts`/`approval.ts` 双重解包、审计接口 `/system` 前缀、审批模块 `include_router` 启动崩溃)。下面是给你的人工回归步骤。 + +--- + +## 0. 环境与入口 + +| 服务 | 地址 | 状态 | +|------|------|------| +| 前端 dev server | http://localhost:16801 | **你自己起**:在 Windows 终端执行 `npm run dev`(见下方命令) | +| 后端 API | http://localhost:17861 (前缀 `/modelTF`,前端已配 proxy,不用管) | 需 WSL 内 uvicorn 以 `--host 0.0.0.0` 启动(见下方) | + +**你自己启动前端**(务必在 **Windows 的 PowerShell / CMD** 里,不要用 WSL 终端): + +```powershell +cd e:\yg_ft\frontend +npm run dev +``` + +启动后终端会打印 `Local: http://localhost:16801/`,浏览器开这个地址即可。`vite.config.ts` 里 `server.port: 16801`,所以 `npm run dev` 默认就是 16801。 + +> **为什么必须用 Windows 终端、不能用 WSL**:在 WSL 里跑 `npm run dev` 时,vite 要从 `/mnt/e/...` 读 node_modules(WSL 挂载的 Windows 盘),每次文件请求都跨文件系统桥,启动要 10 秒、热更新和菜单加载都明显慢。在 Windows 原生跑,node_modules 在 NTFS 上,启动 ~1 秒,开发体验快很多。后端代理在 Windows 下也能连到 WSL 里的 17861。 + +登录账号(种子数据): +- 账号:`admin` +- 密码:`admin123` + +打开浏览器,访问 http://localhost:16801 ,会被引导到登录页。 + +> **后端启动命令(务必带 `--host 0.0.0.0`)** —— 你的 `.wslconfig` 是 `networkingMode=mirrored`(镜像网络),uvicorn 必须绑 `0.0.0.0` 才会把端口暴露给 Windows 的 `localhost`;只绑 `127.0.0.1`(即省略 `--host`)时,Windows 侧 `localhost:17861` 会连不上。在 **WSL 终端**里跑: +> ```bash +> cd /mnt/e/yg_ft/backend +> uvicorn app.main:app --host 0.0.0.0 --port 17861 --reload +> ``` +> 看到 `Application startup complete.` 即成功。若仍连不上,把 uvicorn 终端日志贴给我。 + +--- + +## 1. 登录(第 1 周) + +1. 账号框输入 `admin`,密码框输入 `admin123` +2. 点「登录」按钮 +3. 预期:进入仪表盘(Dashboard),侧边栏底部显示当前用户名 `admin` +4. 验证点:没报错、没跳回登录页,即登录 + 当前用户信息 + 权限码加载都正常 + +> **看板空状态(预期)**:当前平台暂无真实运行数据,登录后仪表盘的「登录时长排行」等卡片会显示「暂无数据」,待处理告警数为 `0`。这是预期行为(数据源未接入、不捏造数据),**不要当成 bug**。 + +--- + +## 1.5 用户设置 → 重置密码(系统设置) + +> 入口:左侧菜单「系统设置 → 用户设置」。该页是账号列表,每行「操作」列有「重置密码」按钮,对应后端 `POST /modelTF/users/{id}/reset-password`。 + +### 1.5.1 自定义密码重置 +1. 进入「用户设置」,在目标账号(如 `u_admin` 或任一非 `admin` 普通账号)行点「**重置密码**」 +2. 弹窗显示账号名,「新密码」输入框填一个自定义密码(如 `Newpass123`) +3. 点「**确定**」 +4. 预期:提示「密码已重置」,弹窗关闭 +5. 验证:退出后用该账号 + 新密码 `Newpass123` 重新登录,应能登录成功 + +### 1.5.2 留空 → 回退默认密码 +1. 再点该账号「重置密码」,这次「新密码」**留空**直接点「确定」 +2. 预期:提示「密码已重置」 +3. 验证:用该账号 + 默认密码 `platform123` 登录,应能登录成功(留空即重置为默认密码) + +### 1.5.3 保护账号拒绝重置(预期拦截) +- `admin` 是内置保护账号(protected),不可被重置。点 `admin` 行「重置密码」并提交,后端会返回错误提示「protected user cannot be reset」,前端弹「重置失败」。 +- 验证:点 `admin` 行「重置密码」→ 确定,预期出现错误提示,且 `admin` 密码不变(仍可用 `admin123` 登录)。 + +--- + +## 2. 租户管理(第 2 周) + +### 2.1 创建租户 +1. 左侧菜单「平台治理 → 租户管理」 +2. 点「新建租户」 +3. 填: + - 租户名称:`test-tenant-manual` + - 其他必填按需填写(编码/描述可选) +4. 点「确定」 +5. 预期:列表里出现 `test-tenant-manual` 这一行 + +### 2.2 查看租户详情 +1. 在 `test-tenant-manual` 那一行点「详情」 +2. 预期:跳到租户详情页,能看到租户基本信息、配额、成员等卡片 +3. 返回列表 + +### 2.3 更新租户(可选) +1. 在列表行点「编辑」 +2. 改个描述或配额,点「确定」 +3. 预期:列表/详情里反映修改 + +--- + +## 3. 项目空间(第 2 周) + +### 3.1 创建项目 +1. 左侧菜单「平台治理 → 项目空间」 +2. 点「新建项目」 +3. 填: + - 项目名称:`test-project-manual` + - 关联租户:选刚才的 `test-tenant-manual` +4. 点「确定」 +5. 预期:列表里出现 `test-project-manual` +6. **记下该项目 ID**:进入详情页后,浏览器地址栏形如 `http://localhost:16801/projects/<项目ID>`,把 `<项目ID>` 复制下来,第 5 周审批拦截测试要用。 + +### 3.2 项目详情 +1. 在 `test-project-manual` 行点「详情」 +2. 预期:进入项目详情页,显示项目信息、成员、底部有「归档项目」「资源授权 (ACL)」按钮 + +### 3.3 添加项目成员 +1. 在详情页「项目成员」卡片点「添加成员」 +2. 选用户(如 `u_admin` 或任意存在的用户),设角色 +3. 点「确定」 +4. 预期:成员列表里出现该用户 +5. 可顺手测:改成员角色(下拉切换)、移除成员(点「移除」确认) + +### 3.4 资源授权 ACL(第 2 周) +1. 在详情页点「**资源授权 (ACL)**」按钮(右上角区域) +2. 弹出「资源授权 (ACL)」对话框,标题为「资源授权 (ACL)」,宽度 640px +3. 点「**添加授权项**」,新增一行: + - 主体类型:选「用户」或「项目角色」 + - 主体 ID:填用户 ID 或角色名(如 `u_admin`) + - 权限:勾选所需项(`read` / `write` / `execute` / `download` / `delete` / `share`) +4. 可继续「添加授权项」加多条;点每行右「删除」可移除 +5. 点对话框底部「**保存**」 +6. 预期:提示「ACL 已保存」,弹窗关闭 +7. 重新打开该对话框,预期:刚才的授权项还在(已落库) + +### 3.5 归档项目 +1. 在详情页点「归档项目」 +2. 预期:项目状态变为 `archived`(**注意:归档是直接执行,没有二次确认弹窗**) +3. 如需后续做审批拦截测试,归档前请先跳过此步(见第 5.4)。 + +### 3.6 写操作自动审计(预期行为,第 4 周验证用) +以下写操作在执行后会**自动**产生一条审计记录(无需手动触发),到「审计日志」页可查: +- 租户:创建 / 更新 / 删除 +- 项目:创建 / 更新 / 归档 / 删除 +- 项目成员:添加 / 更新角色 / 移除 +> 即第 2、3 步里你做的创建租户、创建项目、加成员、归档,都会在第 4 周「审计日志」里看到对应条目。 + +--- + +## 4. 审计日志(第 3 周) + +### 4.1 查看与过滤 +1. 左侧菜单「平台治理 → 审计日志」 +2. 页面顶部筛选栏,可组合: + - 租户(下拉)/ 项目(下拉) + - 操作人 ID(输入框,回车查询) + - 动作(输入框,如 `project.create`;回车查询) + - 目标类型(输入框,如 `project`;回车查询) +3. 点「**查询**」刷新列表 +4. 列表列:时间 / 租户 / 项目 / 操作人 / 动作 / 目标类型 / 目标 ID / 详情 / IP +5. 验证:把「动作」填 `project.create` 查询,应能查到第 3.1 步创建项目的记录;「操作人」即登录 token(当前登录用户标识) +6. 底部分页(total / 上一页 / 下一页)可翻页 + +### 4.2 导出 CSV +1. 先设好筛选条件(如限定某个租户或某个动作,导出会按当前筛选导出) +2. 点「**导出 CSV**」 +3. 预期:浏览器下载 `audit_logs.csv` +4. 打开文件,预期列与页面一致:`time,tenant_id,project_id,actor_id,action,target_type,target_id,detail,client_ip`,内容与页面过滤结果一致 + +--- + +## 5. 审批中心(第 4 周) + +### 5.0 审批流程说明 +- **审批模板**:定义审批步骤(多级审批),在「平台治理 → 审批模板」页创建。 +- **审批实例**:在「平台治理 → 审批中心」发起,可选模板(多步)或不选(单步),生成待审批实例后逐步通过/拒绝。 + +### 5.1 创建审批模板(多步审批,可选) +1. 浏览器访问 `http://localhost:16801/approvals/templates` +2. 点「**新建模板**」 +3. 填「模板名称」(如 `project-change-2step`) +4. 「审批步骤」下: + - 第 1 步:审批人 ID 填某人(如 `u_admin`),或留空表示「任意审批人」 + - 点「+ 添加步骤」加第 2 步,填审批人 ID + - 可用每行「删」移除步骤 +5. 点「确定」 +6. 预期:列表出现 `project-change-2step`,「审批步骤数」=2,步骤标签显示 `#1 xxx #2 xxx` +7. 注:模板创建后**不会自动发起实例**,需到审批中心用该模板发起(5.2)。 + +### 5.2 发起审批(实例) +1. 左侧菜单「审批中心」(或 `/approvals`) +2. 点「**发起审批**」 +3. 弹窗字段: + - 模板:可选;下拉选 5.1 建的模板(多步),或不选(单步) + - 资源类型:默认 `project`,保持 + - 租户:选该项目所在的租户(默认带出项目空间当前租户) + - 资源 ID:**必填**,下拉选第 3.1 步建的项目(显示项目名,无需手填 ID) + - 申请人:**必填**,下拉选一个用户 +4. 点「发起」 +5. 预期:列表新增一行,状态 `pending`(黄),当前步 `0`(或 `1`,取决于后端 0/1 基) +6. 验证多步:若选了 2 步模板,点「详情/审批」打开后,「审批步骤」用 `el-steps` 显示 2 步 + +### 5.3 审批(通过 / 拒绝) +1. 在列表行点「**详情/审批**」 +2. 弹窗显示:资源、申请人、状态标签、审批步骤进度条 +3. 当状态为 `pending` 时,下方出现审批表单: + - 审批人:**必填**,下拉选一个用户 + - 结果:选「通过」或「拒绝」 + - 意见:可填 +4. 点「**提交审批**」 +5. 预期: + - 单步 / 最后一步「通过」→ 状态变 `approved`(绿) + - 任一步「拒绝」→ 状态变 `rejected`(红),后续步骤终止 + - 多步中前几步「通过」→ 状态仍 `pending`,当前步前进,需再打开提交下一步 +6. 验证:状态标签颜色与值正确;`rejected` 后不再能提交 + +### 5.4 审批拦截(端到端治理,重点) +逻辑:项目「归档」「删除」前,若已存在针对该项目的**待审批(pending)**实例,会被拦截返回 409「存在待审批的变更,请先完成审批」,直到审批通过/拒绝。 + +测试步骤: +1. **前置**:确保第 3.1 步项目**未被归档**(若已归档,先新建一个测试项目并记下 ID)。 +2. 在「审批中心」**发起审批**,资源类型选 `project`、租户选该项目所在租户、资源 ID 下拉选该项目、申请人任选(单步即可)。 +3. 进入该项目详情页(`/projects/<项目ID>`),点「**归档项目**」。 +4. 预期:**归档被拦截**,页面提示「存在待审批的变更,请先完成审批」,项目状态**不会**变 `archived`。 +5. 回「审批中心」对该实例「详情/审批」→ 提交「通过」。 +6. 状态变 `approved` 后,再回项目详情点「归档项目」。 +7. 预期:**归档成功**,状态变 `archived`。 +8. 去「审计日志」查 `project.archive` 动作,应能看到这条记录(验证写操作审计 + 拦截放行后落库)。 + +### 5.5 删除拦截(可选,同逻辑) +- 对项目发起 pending 审批实例后,尝试删除该项目(列表或详情的删除),预期同样被 409 拦截;审批结束后方可删除。 + +--- + +## 6. 验收清单(打勾) + +**第 1~2 周** +- [ ] 1. 登录成功,进仪表盘,显示 `admin` +- [ ] 2. 租户:新建 → 列表出现 → 详情渲染正常 +- [ ] 3. 项目:新建 → 列表出现 → 详情渲染正常 +- [ ] 4. 项目成员:添加 / 改角色 / 移除 均成功 +- [ ] 5. 资源 ACL:添加授权项 + 勾选权限 + 点「保存」成功,重开仍在 +- [ ] 6. 归档:点后状态变 `archived` +- [ ] 7. 用户设置 → 重置密码:自定义密码生效 / 留空回退 `platform123` / `admin` 保护账号被拒 + +**第 3 周(审计)** +- [ ] 8. 审计日志:按 动作/操作人/项目 过滤均能返回正确结果 +- [ ] 9. 审计日志:导出 CSV 成功,列与内容正确 +- [ ] 10. 写操作自动留痕:创建/归档项目等操作在审计页可查到对应 `action` + +**第 4 周(审批 + 拦截)** +- [ ] 11. 审批模板:新建模板(多步)成功,列表显示步骤数 +- [ ] 12. 发起审批:生成 `pending` 实例 +- [ ] 13. 审批:通过(单步→approved / 多步→逐步前进)、拒绝(→rejected 终止) +- [ ] 14. 审批拦截:项目有 pending 实例时归档被 409 拦截;审批通过后归档成功 +- [ ] 15. 全程浏览器控制台(F12 → Console)无红色报错 + +--- + +## 7. 我自测已覆盖(你不用重复,除非想验证) + +- 后端真实导入:`import app.main` → `IMPORT_OK`,启动崩溃已修复(`approval/__init__.py` 补 re-export `router`)。 +- 前端 `type-check` 全绿:`audit.ts`/`approval.ts` 双重解包已改 `get/post`;审计接口已加 `/system` 前缀(`/system/audit-logs`、`/system/audit-logs/export`)。 +- 接口链路已用真实代码核对:审计查询/导出(`system`)、审批模板/实例/逐步决策(`approvals`)、项目写操作自动 `record_audit` 与 `_require_no_pending_approval` 拦截均按上述行为实现。 + +## 8. 已知非 bug / 注意事项(仅供参考) + +1. 前端由你自己在 **Windows 终端** 跑 `npm run dev` 启动(默认 **16801**)。不要从 WSL 终端启动(会慢 8 倍)。 +2. 「归档项目」当前是**直接执行无确认弹窗**——功能正确,建议后续补个二次确认,避免误操作。 +4. 控制台偶见的 `ERR_ABORTED` 是导航时浏览器正常中止旧 CSS 请求,无害;Google Fonts 外网字体加载失败不影响功能。 +5. 审计「操作人」列 = 登录 token(当前登录用户标识),由前端 `Authorization: Bearer ` 透传,非真实姓名。 + +## 9. 清理测试数据(可选) + +手动建的 `test-tenant-manual` / `test-project-manual`、审批实例/模板可在对应列表里删除,或告诉我帮你清库(后端连 PostgreSQL,`PlatformStore` 启动时自动建表)。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..436987d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +APP_NAME=YG Fine-Tune Platform API +APP_ENV=local +API_PREFIX=/api +LOG_LEVEL=INFO +LOG_DIR=./logs +LOG_FILE_PREFIX=backend +LOG_ERROR_FILE_PREFIX=error +LOG_MAX_BYTES=20971520 +LOG_RETENTION_DAYS=10 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..243274c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,75 @@ +# Backend Service + +后端工程使用 FastAPI,定位为模型微调平台的应用平台服务,负责用户中心、多租户、权限隔离、项目、数据集、模型、训练任务、审批、审计和算力平台编排。 + +## 目录结构 + +```text +backend/ + app/ + main.py # FastAPI 应用入口 + api/v1/ # 对前端暴露的 接口路由 + core/ # 配置、日志、中间件、权限等基础能力 + db/ # 数据库连接、迁移集成、事务工具 + modules/ # 业务模块 + auth/ + tenant/ + project/ + model/ + dataset/ + data_process/ + fine_tune/ + eval/ + inference/ + approval/ + audit/ + compute_gateway/ + file_gateway/ + engine_registry/ + retention/ + system/ + schemas/ # Pydantic 入参/出参模型 + services/ # 跨模块应用服务 + workers/ # 后台任务入口 + requirements.txt # 后端第三方依赖 + logs/ # 本地开发日志目录,生产环境建议挂载到独立日志盘 +``` + +## 企业治理接口说明 + +用户中心、租户、项目、审批、资源授权等能力已通过对应模块 `router.py` 在 `/modelTF` 下统一暴露。其中: + +- 审计接口挂在 `system` 模块:`GET /modelTF/system/audit-logs`、`/modelTF/system/audit-logs/export`。 +- 留存策略为租户资源的嵌套接口:`PUT /modelTF/tenants/{id}/retention-policy`,未独立成 `/modelTF/retention-policies` 路由。 +- 重置密码:`POST /modelTF/users/{id}/reset-password`(保护账号不可重置,空密码回退默认 `platform123`)。 +- 资源授权:`GET/PUT /modelTF/resources/{type}/{id}/acl`。 +- 服务看板聚合:`GET /modelTF/dashboard/stats`。 + +## 本地启动 + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +健康检查: + +```text +GET /modelTF/health +``` + +## 日志 + +日志模块位于 `app/core/logging.py`,使用说明见 `../docs/backend-logging.md`。 + +默认日志文件: + +```text +logs/backend-YYYY-MM-DD.log +logs/error-YYYY-MM-DD.log +``` + +文件日志为 JSON Lines 格式,单个文件不超过 20MB,只保存最近 10 天,错误日志按 `ERROR` 级别独立拆分,便于 ELK/日志平台采集。 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..18b665e --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..6d0f325 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""Versioned API package.""" diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..1bdb261 --- /dev/null +++ b/backend/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +"""API endpoint modules.""" diff --git a/backend/app/api/v1/endpoints/health.py b/backend/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..c5676c1 --- /dev/null +++ b/backend/app/api/v1/endpoints/health.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from app.core.logging import get_logger +from app.db.platform_store import get_platform_store + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/health") +async def health_check() -> dict[str, object]: + logger.info("health check requested") + return {"code": 0, "message": "ok", "data": get_platform_store().health_metrics()} + diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py new file mode 100644 index 0000000..205b9c9 --- /dev/null +++ b/backend/app/api/v1/endpoints/platform.py @@ -0,0 +1,844 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any +import uuid + +from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile +from fastapi.responses import PlainTextResponse, StreamingResponse + +from app.db.platform_store import get_platform_store +from app.modules.fine_tune.service import apply_presets +from fastapi import Request as FastAPIRequest + +router = APIRouter() + + +def _actor(request: FastAPIRequest) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +def ok(data: Any = None, message: str = "ok") -> dict[str, Any]: + return {"code": 0, "message": message, "data": data} + + +def fail(status_code: int, message: str) -> HTTPException: + return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None}) + + +@router.get("/dashboard/overview") +async def dashboard_overview() -> dict[str, Any]: + store = get_platform_store() + tasks = store.tasks() + return ok( + { + "models": len(store.models()), + "datasets": len(store.datasets()), + "fine_tune_tasks": len(tasks), + "running_tasks": len([t for t in tasks if t["status"] in {"syncing", "queued", "running"}]), + "compute_nodes": len(store.compute_nodes()), + "gpus": len(store.gpus()), + } + ) + + +@router.get("/dashboard/stats") +async def dashboard_stats() -> dict[str, Any]: + """看板聚合数据:基于平台真实数据;缺项做合理近似(见下)。""" + store = get_platform_store() + tasks = store.tasks() + users = store.users() + nodes = store.compute_nodes() + datasets = store.datasets() + + running_statuses = {"syncing", "queued", "running"} + running_ft = [t for t in tasks if t.get("status") in running_statuses] + failed_ft = [t for t in tasks if t.get("status") == "failed"] + online_nodes = [n for n in nodes if n.get("scheduler_status") == "online"] + + # 近 7 天训练统计(按创建日期分桶;准确率为 None,因任务无该字段) + now = datetime.now(timezone.utc) + train_by_day: dict[str, int] = {} + for t in tasks: + ct = t.get("create_time") + if ct: + train_by_day[ct[:10]] = train_by_day.get(ct[:10], 0) + 1 + training_7d = [] + for i in range(6, -1, -1): + day = (now - timedelta(days=i)).strftime("%Y-%m-%d") + training_7d.append( + { + "date": day[5:], + "train": train_by_day.get(day, 0), + "gpu": sum(len(t.get("gpus") or []) for t in running_ft), + "accuracy": None, + } + ) + + # 服务状态:模型推理用在线计算节点近似;模型评测暂无独立数据源,置 0 + service_status = [ + { + "type": "模型推理", + "status": "error" if (nodes and not online_nodes) else ("busy" if (nodes and len(online_nodes) < len(nodes)) else "normal"), + "count": len(online_nodes), + }, + { + "type": "模型微调", + "status": "error" if failed_ft else ("busy" if running_ft else "normal"), + "count": len(running_ft), + }, + {"type": "模型评测", "status": "normal", "count": 0}, + { + "type": "数据处理", + "status": "normal" if not failed_ft else "busy", + "count": len(datasets), + }, + ] + + # 训练任务状态归一化(fine_tune 的 syncing/queued 等映射到前端已知状态) + status_map = { + "syncing": "running", + "queued": "running", + "running": "running", + "pending": "pending", + "paused": "pending", + "completed": "completed", + "failed": "failed", + "error": "failed", + "cancelled": "failed", + } + op_labels = [ + ("模型训练", lambda a: "fine_tune" in a or "train" in a), + ("数据处理", lambda a: "data" in a or "dataset" in a), + ("模型评测", lambda a: "eval" in a), + ("模型推理", lambda a: "infer" in a or "serving" in a or "deploy" in a), + ("系统设置", lambda a: True), + ] + + def _op_label(action: str) -> str: + for label, fn in op_labels: + if fn(action): + return label + return "系统设置" + + training_tasks = [ + { + "id": t.get("id"), + "name": t.get("name"), + "status": status_map.get(t.get("status"), "pending"), + "train_type": t.get("train_type"), + "train_method": t.get("train_method"), + "base_model": t.get("base_model"), + "progress": t.get("progress", 0), + "accuracy": t.get("accuracy"), + "started_at": (t.get("create_time") or "")[:16], + } + for t in tasks[:8] + ] + + # 用户操作分布(按 audit action 归类为中文分类) + audit = store.audit_logs(limit=500) + op_counter: dict[str, int] = {} + for log in audit.get("items", []): + act = log.get("action") or "unknown" + op_counter[_op_label(act)] = op_counter.get(_op_label(act), 0) + 1 + operation_distribution = [{"name": k, "value": v} for k, v in op_counter.items()] + + # 最近登录用户:后端有 last_login 字段,返回真实数据 + recent = sorted( + [u for u in users if u.get("last_login")], + key=lambda u: u["last_login"], + reverse=True, + )[:5] + recent_login_users = [ + { + "user": u.get("display_name") or u.get("username"), + "role": u.get("role"), + "last_login": (u.get("last_login") or "")[:16], + } + for u in recent + ] + # 登录时长:后端暂无该数据源,先留空,待接入后补充 + login_duration_rank: list = [] + + return ok( + { + "online_services": sum(s["count"] for s in service_status), + "running_tasks": len(running_ft), + "pending_alerts": 0, # 平台暂无独立告警数据源,先置 0,待接入后补充 + "training_7d": training_7d, + "service_status": service_status, + "training_tasks": training_tasks, + "operation_distribution": operation_distribution, + "login_duration_rank": login_duration_rank, + "recent_login_users": recent_login_users, + } + ) + + +@router.get("/system-info") +async def system_info() -> dict[str, Any]: + return ok(get_platform_store().system_info()) + + +@router.get("/users") +async def users() -> dict[str, Any]: + return ok(get_platform_store().users()) + + +@router.post("/users") +async def create_user(payload: dict[str, Any] = Body(...), request: FastAPIRequest = None) -> dict[str, Any]: + store = get_platform_store() + user = store.create_user(payload) + store.record_audit( + action="user.create", + actor_id=_actor(request), + target_type="user", + target_id=user["id"], + detail=f"username={user.get('username')}", + ) + return ok(user) + + +@router.put("/users/{user_id}") +async def update_user(user_id: str, payload: dict[str, Any] = Body(...), request: FastAPIRequest = None) -> dict[str, Any]: + store = get_platform_store() + try: + user = store.update_user(user_id, payload) + except KeyError: + raise fail(404, "user not found") + store.record_audit( + action="user.update", + actor_id=_actor(request), + target_type="user", + target_id=user_id, + detail=f"fields={','.join(payload.keys())}", + ) + return ok(user) + + +@router.delete("/users/{user_id}") +async def delete_user(user_id: str, current_username: str | None = Query(default=None)) -> dict[str, Any]: + try: + get_platform_store().delete_user(user_id) + return ok({"deleted": user_id, "current_username": current_username}) + except KeyError: + raise fail(404, "user not found") + except ValueError as exc: + raise fail(400, str(exc)) + + +@router.post("/users/{user_id}/reset-password") +async def reset_password( + user_id: str, + payload: dict[str, Any] = Body(default={}), + request: FastAPIRequest = None, +) -> dict[str, Any]: + try: + user = get_platform_store().reset_password(user_id, payload.get("password") or "") + except KeyError: + raise fail(404, "user not found") + except ValueError as exc: + raise fail(400, str(exc)) + get_platform_store().record_audit( + action="user.reset_password", + actor_id=_actor(request), + target_type="user", + target_id=user_id, + detail=f"username={user.get('username')}", + ) + return ok({"id": user_id}) + + +@router.get("/model-manage/local-models") +async def local_models() -> dict[str, Any]: + models = [{"path": item.get("path") or "", "name": item["name"]} for item in get_platform_store().models()] + return ok({"models": models}) + + +@router.get("/model-manage/trained-models") +async def trained_models() -> dict[str, Any]: + return ok({"models": get_platform_store().trained_models()}) + + +@router.delete("/model-manage/trained-models/{model_id}") +async def delete_trained_model(model_id: str, type: str = Query(default="merged")) -> dict[str, Any]: + return ok({"deleted": model_id, "type": type}) + + +@router.get("/model-manage/name/{name}") +async def model_by_name(name: str) -> dict[str, Any]: + try: + return ok(get_platform_store().model_by_name(name)) + except KeyError: + raise fail(404, "model not found") + + +@router.get("/model-manage") +async def model_list() -> dict[str, Any]: + return ok(get_platform_store().models()) + + +@router.post("/model-manage") +async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return ok(get_platform_store().create_model(payload)) + + +@router.get("/model-manage/{model_id}") +async def model_detail(model_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().model(model_id)) + except KeyError: + raise fail(404, "model not found") + + +@router.put("/model-manage/{model_id}") +async def update_model(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_model(model_id, payload)) + except KeyError: + raise fail(404, "model not found") + + +@router.put("/model-manage/{model_id}/purpose") +async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_model(model_id, {"purpose": payload.get("purpose", "training")})) + except KeyError: + raise fail(404, "model not found") + + +@router.delete("/model-manage/{model_id}") +async def delete_model(model_id: str) -> dict[str, Any]: + get_platform_store().delete_model(model_id) + return ok({"deleted": model_id}) + + +@router.post("/model-manage/merge") +async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return ok({"job_id": f"merge_{uuid.uuid4().hex[:12]}", "status": "queued", **payload}) + + +@router.get("/dataset-manage/preview/{file_id}") +async def dataset_preview(file_id: str) -> dict[str, Any]: + try: + row = get_platform_store().dataset_file(file_id) + return ok({"content": row["content"]}) + except KeyError: + raise fail(404, "dataset file not found") + + +@router.get("/dataset-manage/versions/{file_id}") +async def dataset_versions(file_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().file_versions(file_id)) + except KeyError: + raise fail(404, "dataset file not found") + + +@router.get("/dataset-manage/versions/{file_id}/{version_id}") +async def dataset_version_content(file_id: str, version_id: str) -> dict[str, Any]: + try: + row = get_platform_store().dataset_file(file_id) + versions = get_platform_store().file_versions(file_id)["versions"] + version = next((item for item in versions if item["id"] == version_id), None) + if not version: + raise KeyError(version_id) + return ok({"version": version, "content": row["content"]}) + except KeyError: + raise fail(404, "dataset version not found") + + +@router.post("/dataset-manage/versions/{file_id}") +async def create_dataset_version(file_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().create_file_version(file_id, payload)) + except KeyError: + raise fail(404, "dataset file not found") + + +@router.put("/dataset-manage/versions/{file_id}/active") +async def activate_dataset_version(file_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().activate_file_version(file_id, payload["version_id"])) + except KeyError: + raise fail(404, "dataset version not found") + + +@router.delete("/dataset-manage/versions/{file_id}/{version_id}") +async def delete_dataset_version(file_id: str, version_id: str) -> dict[str, Any]: + return ok(get_platform_store().file_versions(file_id)) + + +@router.post("/dataset-manage/upload/{dataset_id}") +async def upload_dataset_files(dataset_id: str, files: list[UploadFile] = File(default=[])) -> dict[str, Any]: + created: list[dict[str, Any]] = [] + store = get_platform_store() + try: + store.dataset(dataset_id) + except KeyError: + raise fail(404, "dataset not found") + with store.connect() as conn: + for file in files: + raw = await file.read() + content = raw.decode("utf-8", errors="replace") + created.append(store.add_dataset_file(conn, dataset_id, file.filename or "upload.jsonl", content)) + return ok({"files": created}) + + +@router.get("/dataset-manage/download/{dataset_id}") +async def download_dataset(dataset_id: str) -> PlainTextResponse: + dataset = get_platform_store().dataset(dataset_id) + content = "\n".join([f"{file['name']}" for file in dataset.get("files", [])]) + return PlainTextResponse(content, media_type="text/plain") + + +@router.get("/dataset-manage/download/{dataset_id}/{file_id}") +async def download_dataset_file(dataset_id: str, file_id: str, version_id: str | None = Query(default=None)) -> PlainTextResponse: + row = get_platform_store().dataset_file(file_id) + return PlainTextResponse(row["content"], media_type="text/plain") + + +@router.get("/dataset-manage") +async def dataset_list() -> dict[str, Any]: + return ok(get_platform_store().datasets()) + + +@router.post("/dataset-manage") +async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + dataset = get_platform_store().create_dataset(payload) + return ok({"id": dataset["id"]}) + + +@router.get("/dataset-manage/{dataset_id}") +async def dataset_detail(dataset_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().dataset(dataset_id)) + except KeyError: + raise fail(404, "dataset not found") + + +@router.put("/dataset-manage/{dataset_id}") +async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_dataset(dataset_id, payload)) + except KeyError: + raise fail(404, "dataset not found") + + +@router.delete("/dataset-manage/{dataset_id}") +async def delete_dataset(dataset_id: str) -> dict[str, Any]: + get_platform_store().delete_dataset(dataset_id) + return ok({"deleted": dataset_id}) + + +@router.get("/fine-tune/check-name") +async def check_fine_tune_name(name: str = Query(...)) -> dict[str, Any]: + exists = any(task["name"] == name for task in get_platform_store().tasks()) + return ok({"exists": exists}) + + +@router.get("/fine-tune/progress/{task_id}") +async def fine_tune_progress(task_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().progress(task_id)) + except KeyError: + raise fail(404, "fine tune task not found") + + +@router.post("/fine-tune/tensorboard/start") +async def tensorboard_start() -> dict[str, Any]: + return ok({"status": "running", "url": "http://localhost:6006"}) + + +@router.get("/fine-tune") +async def fine_tune_list() -> dict[str, Any]: + return ok(get_platform_store().tasks()) + + +@router.post("/fine-tune") +async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + task = get_platform_store().create_task(apply_presets(payload)) + return ok({"id": task["id"]}) + except ValueError as exc: + raise fail(400, str(exc)) + + +@router.post("/fine-tune/start") +async def start_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().start_task(payload)) + except KeyError: + raise fail(404, "fine tune task not found") + except RuntimeError as exc: + raise fail(409, str(exc)) + + +@router.get("/fine-tune/{task_id}") +async def fine_tune_detail(task_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().task(task_id)) + except KeyError: + raise fail(404, "fine tune task not found") + + +@router.put("/fine-tune/{task_id}") +async def update_fine_tune(task_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_task(task_id, payload)) + except KeyError: + raise fail(404, "fine tune task not found") + + +@router.post("/fine-tune/stop/{task_id}") +async def stop_fine_tune(task_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().stop_task(task_id)) + except KeyError: + raise fail(404, "fine tune task not found") + + +@router.post("/fine-tune/{task_id}/stop") +async def stop_fine_tune_alt(task_id: str) -> dict[str, Any]: + return await stop_fine_tune(task_id) + + +@router.post("/fine-tune/pause/{task_id}") +async def pause_fine_tune(task_id: str) -> dict[str, Any]: + if not get_platform_store().pause_task(task_id): + raise fail(409, "当前没有可暂停的训练进程") + return ok({"paused": task_id}) + + +@router.post("/fine-tune/resume/{task_id}") +async def resume_fine_tune(task_id: str) -> dict[str, Any]: + if not get_platform_store().resume_task_engine(task_id): + raise fail(409, "当前没有可恢复的训练进程") + return ok({"resumed": task_id}) + + +@router.post("/fine-tune/cancel/{task_id}") +async def cancel_fine_tune(task_id: str) -> dict[str, Any]: + get_platform_store().cancel_task_engine(task_id) + return ok({"canceled": task_id}) + + +@router.delete("/fine-tune/{task_id}") +async def delete_fine_tune(task_id: str) -> dict[str, Any]: + get_platform_store().delete_task(task_id) + return ok({"deleted": task_id}) + + +@router.get("/fine-tune/{task_id}/overview") +async def fine_tune_overview(task_id: str) -> dict[str, Any]: + task = get_platform_store().task(task_id) + return ok({"task": task, "progress": get_platform_store().progress(task_id)}) + + +@router.get("/fine-tune/{task_id}/checkpoints") +async def fine_tune_checkpoints(task_id: str) -> dict[str, Any]: + task = get_platform_store().task(task_id) + checkpoints = [] + for step in [50, 100, 150]: + if task.get("progress", 0) >= min(100, step // 2): + checkpoints.append({"step": step, "path": f"/data/yg-ft/outputs/{task['name']}/checkpoint-{step}"}) + return ok(checkpoints) + + +@router.get("/compute/nodes") +async def compute_nodes() -> dict[str, Any]: + return ok(get_platform_store().compute_nodes()) + + +@router.post("/compute/nodes") +async def create_compute_node(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().create_compute_node(payload)) + except KeyError as exc: + raise fail(400, f"missing field: {exc}") + + +@router.put("/compute/nodes/{node_id}") +async def update_compute_node(node_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_compute_node(node_id, payload)) + except KeyError: + raise fail(404, "compute node not found") + + +@router.post("/compute/nodes/{node_id}/test-connection") +async def test_compute_node(node_id: str) -> dict[str, Any]: + return ok({"node_id": node_id, "success": True, "latency_ms": 12}) + + +@router.post("/compute/nodes/{node_id}/enable") +async def enable_compute_node(node_id: str) -> dict[str, Any]: + return ok(get_platform_store().update_compute_node(node_id, {"enabled": True, "scheduler_status": "online"})) + + +@router.post("/compute/nodes/{node_id}/disable") +async def disable_compute_node(node_id: str) -> dict[str, Any]: + return ok(get_platform_store().update_compute_node(node_id, {"enabled": False, "scheduler_status": "offline"})) + + +@router.post("/compute/nodes/{node_id}/drain") +async def drain_compute_node(node_id: str) -> dict[str, Any]: + return ok(get_platform_store().update_compute_node(node_id, {"scheduler_status": "draining"})) + + +@router.get("/compute/nodes/{node_id}/replicas") +async def compute_node_replicas(node_id: str) -> dict[str, Any]: + return ok(get_platform_store().replicas(node_id)) + + +@router.get("/compute/gpus") +async def compute_gpus() -> dict[str, Any]: + return ok(get_platform_store().gpus()) + + +@router.get("/compute/queue") +async def compute_queue() -> dict[str, Any]: + return ok(get_platform_store().queue()) + + +@router.post("/internal/compute-sync/resources") +async def create_compute_sync(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + sync_id = get_platform_store().create_sync_job(payload.get("target_node_id", "node_01"), payload) + return ok(get_platform_store().sync_job(sync_id)) + + +@router.get("/internal/compute-sync/resources/{sync_id}") +async def compute_sync_detail(sync_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().sync_job(sync_id)) + except KeyError: + raise fail(404, "sync job not found") + + +@router.get("/training-log-files") +async def training_log_files() -> dict[str, Any]: + return ok(get_platform_store().training_log_files()) + + +@router.get("/training-log-content") +async def training_log_content(file: str = Query(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().training_log_content(file)) + except KeyError: + raise fail(404, "training log not found") + + +@router.get("/log-files") +async def log_files(date: str | None = Query(default=None)) -> dict[str, Any]: + return ok(get_platform_store().log_files(date)) + + +@router.get("/log-content") +async def log_content(file: str = Query(...)) -> dict[str, Any]: + return ok(get_platform_store().log_content(file)) + + +@router.post("/web-log") +async def web_log(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return ok({"received": True, **payload}) + + +# ===================== Project Management (§13.2) ===================== + + +@router.get("/projects") +async def project_list( + tenant_id: str = Query(default="default"), + status: str | None = Query(default=None), + keyword: str | None = Query(default=None), +) -> dict[str, Any]: + return ok(get_platform_store().projects(tenant_id, status, keyword)) + + +@router.post("/projects") +async def create_project(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + project = get_platform_store().create_project(payload) + return ok({"id": project["id"]}) + except KeyError as exc: + raise fail(400, f"missing required field: {exc}") + + +@router.get("/projects/{project_id}") +async def project_detail(project_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().project(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.put("/projects/{project_id}") +async def update_project(project_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_project(project_id, payload)) + except KeyError: + raise fail(404, "project not found") + + +@router.post("/projects/{project_id}/activate") +async def activate_project(project_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().activate_project(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.get("/projects/{project_id}/members") +async def project_members(project_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().project_members(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.post("/projects/{project_id}/members") +async def add_project_member(project_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + user_id = payload.get("user_id") + if not user_id: + raise fail(400, "user_id is required") + try: + return ok(get_platform_store().add_project_member(project_id, user_id, payload.get("role", "member"))) + except KeyError as exc: + raise fail(404, str(exc)) + + +@router.put("/projects/{project_id}/members/{user_id}") +async def update_project_member_role(project_id: str, user_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_project_member_role(project_id, user_id, payload.get("role", "member"))) + except KeyError as exc: + raise fail(404, str(exc)) + + +@router.delete("/projects/{project_id}/members/{user_id}") +async def remove_project_member(project_id: str, user_id: str) -> dict[str, Any]: + try: + get_platform_store().remove_project_member(project_id, user_id) + return ok({"deleted": user_id}) + except KeyError as exc: + raise fail(404, str(exc)) + + +# ===================== Fine-tune Events (§7.1) ===================== + +@router.get("/fine-tune/{task_id}/events") +async def fine_tune_events(task_id: str) -> StreamingResponse: + import json as _json + + async def event_stream(): + store = get_platform_store() + try: + events = store.task_events(task_id) + for event in events: + yield f"data: {_json.dumps(event, default=str)}\n\n" + yield f"data: {_json.dumps({'type': 'done', 'data': {}}, default=str)}\n\n" + except KeyError: + yield f"data: {_json.dumps({'type': 'error', 'data': {'message': 'task not found'}}, default=str)}\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +# ===================== Fine-tune Retry & Resume (§13.9) ===================== + + +@router.post("/fine-tune/{task_id}/retry") +async def retry_fine_tune(task_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().retry_task(task_id)) + except KeyError: + raise fail(404, "fine tune task not found") + except ValueError as exc: + raise fail(400, str(exc)) + + +@router.post("/fine-tune/{task_id}/resume") +async def resume_fine_tune(task_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + checkpoint_id = payload.get("checkpoint_id") + if not checkpoint_id: + raise fail(400, "checkpoint_id is required") + try: + return ok(get_platform_store().resume_task(task_id, checkpoint_id)) + except KeyError as exc: + raise fail(404, str(exc)) + except ValueError as exc: + raise fail(400, str(exc)) + + +# ===================== Checkpoint Management (§13.9) ===================== + + +@router.delete("/fine-tune/{task_id}/checkpoints/{checkpoint_id}") +async def delete_checkpoint(task_id: str, checkpoint_id: str) -> dict[str, Any]: + try: + get_platform_store().delete_checkpoint(checkpoint_id) + return ok({"deleted": checkpoint_id}) + except KeyError: + raise fail(404, "checkpoint not found") + + +@router.put("/fine-tune/{task_id}/checkpoint-retention") +async def set_checkpoint_retention(task_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().set_checkpoint_retention(task_id, payload)) + except KeyError: + raise fail(404, "fine tune task not found") + + +@router.get("/fine-tune/{task_id}/checkpoint-retention") +async def get_checkpoint_retention(task_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().get_checkpoint_retention(task_id)) + except KeyError: + raise fail(404, "fine tune task not found") + + +# ===================== Compute Jobs (§13.6) ===================== + + +@router.post("/compute/jobs") +async def create_compute_job(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + job = get_platform_store().create_compute_job(payload) + return ok({"id": job["id"]}) + except KeyError as exc: + raise fail(400, f"missing required field: {exc}") + + +@router.get("/compute/jobs/{job_id}") +async def compute_job_detail(job_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().compute_job(job_id)) + except KeyError: + raise fail(404, "compute job not found") + + +@router.get("/compute/jobs") +async def compute_jobs(task_id: str = Query(default=None)) -> dict[str, Any]: + if task_id: + return ok(get_platform_store().compute_jobs_by_task(task_id)) + return ok([]) + + +@router.post("/compute/jobs/{job_id}/stop") +async def stop_compute_job(job_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().stop_compute_job(job_id)) + except KeyError: + raise fail(404, "compute job not found") + + +@router.get("/compute/jobs/{job_id}/logs") +async def compute_job_logs(job_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().compute_job_logs(job_id)) + except KeyError as exc: + raise fail(404, str(exc)) + diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..87185f7 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints.platform import router as platform_router +from app.api.v1.endpoints.health import router as health_router +from app.modules.auth import router as auth_router +from app.modules.system import router as system_router +from app.modules.tenant import router as tenant_router +from app.modules.project import router as project_router +from app.modules.resource import router as resource_router +from app.modules.approval import router as approval_router + +api_router = APIRouter() +api_router.include_router(health_router, tags=["health"]) +api_router.include_router(platform_router, tags=["platform"]) +api_router.include_router(auth_router, tags=["auth"]) +api_router.include_router(system_router, tags=["system"]) +api_router.include_router(tenant_router, tags=["tenant"]) +api_router.include_router(project_router, tags=["project"]) +api_router.include_router(resource_router, tags=["resource"]) +api_router.include_router(approval_router, tags=["approval"]) + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..5fd6e27 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core infrastructure modules.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e027dad --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from functools import lru_cache +import os + + +def _int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return int(raw) + + +def _list_env(name: str, default: list[str]) -> list[str]: + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + return [item.strip() for item in raw.split(",") if item.strip()] + + +@dataclass(frozen=True) +class Settings: + app_name: str = os.getenv("APP_NAME", "YG Fine-Tune Platform API") + app_env: str = os.getenv("APP_ENV", "local") + route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF") + app_mode: str = os.getenv("APP_MODE", "local") + database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft") + cors_allow_origins: list[str] = None # type: ignore[assignment] + compute_mode: str = os.getenv("COMPUTE_MODE", "real") + compute_status_sync_mode: str = os.getenv("COMPUTE_STATUS_SYNC_MODE", "polling") + compute_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3) + log_level: str = os.getenv("LOG_LEVEL", "INFO") + log_dir: str = os.getenv("LOG_DIR", "./logs") + log_file_prefix: str = os.getenv("LOG_FILE_PREFIX", "backend") + log_error_file_prefix: str = os.getenv("LOG_ERROR_FILE_PREFIX", "error") + log_max_bytes: int = _int_env("LOG_MAX_BYTES", 20 * 1024 * 1024) + log_retention_days: int = _int_env("LOG_RETENTION_DAYS", 10) + jwt_secret: str = os.getenv("JWT_SECRET", "dev-insecure-change-me") + jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256") + access_token_expire_minutes: int = _int_env("ACCESS_TOKEN_EXPIRE_MINUTES", 1440) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "cors_allow_origins", + _list_env( + "CORS_ALLOW_ORIGINS", + [ + "http://localhost:16801", + "http://127.0.0.1:16801", + "http://localhost:17861", + "http://127.0.0.1:17861", + ], + ), + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..6332907 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from contextvars import ContextVar +from datetime import date, datetime, timedelta +import json +import logging +from logging import Handler, LogRecord +from pathlib import Path +import re +import time +from typing import Any +from uuid import uuid4 + +from fastapi import FastAPI, Request + +from app.core.config import Settings, get_settings + +request_id_var: ContextVar[str] = ContextVar("request_id", default="-") + + +class RequestIdFilter(logging.Filter): + def filter(self, record: LogRecord) -> bool: + record.request_id = request_id_var.get() + return True + + +class JsonLogFormatter(logging.Formatter): + """Format one JSON object per line for ELK/Filebeat collection.""" + + def format(self, record: LogRecord) -> str: + payload: dict[str, Any] = { + "@timestamp": datetime.fromtimestamp(record.created).astimezone().isoformat( + timespec="milliseconds" + ), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "file": record.pathname, + "line": record.lineno, + "process": record.process, + "thread": record.thread, + "thread_name": record.threadName, + "request_id": getattr(record, "request_id", "-"), + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + if record.stack_info: + payload["stack"] = self.formatStack(record.stack_info) + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +class DateSizeRotatingFileHandler(Handler): + """Rotate log files by date and size while keeping date in every file name.""" + + def __init__( + self, + log_dir: str | Path, + file_prefix: str, + max_bytes: int, + retention_days: int, + encoding: str = "utf-8", + ) -> None: + super().__init__() + self.log_dir = Path(log_dir) + self.file_prefix = file_prefix + self.max_bytes = max_bytes + self.retention_days = retention_days + self.encoding = encoding + self._current_date: date | None = None + self._stream: Any | None = None + self._current_path: Path | None = None + self.log_dir.mkdir(parents=True, exist_ok=True) + + def emit(self, record: LogRecord) -> None: + try: + message = self.format(record) + self.terminator + encoded_size = len(message.encode(self.encoding)) + self._ensure_stream() + if self._should_rotate(encoded_size): + self._rotate_by_size() + self._ensure_stream(force=True) + self._stream.write(message) + self.flush() + self._cleanup_expired_files() + except Exception: + self.handleError(record) + + @property + def terminator(self) -> str: + return "\n" + + def flush(self) -> None: + if self._stream and not self._stream.closed: + self._stream.flush() + + def close(self) -> None: + try: + if self._stream and not self._stream.closed: + self._stream.close() + finally: + self._stream = None + super().close() + + def _dated_path(self, target_date: date) -> Path: + return self.log_dir / f"{self.file_prefix}-{target_date.isoformat()}.log" + + def _ensure_stream(self, force: bool = False) -> None: + today = date.today() + if not force and self._stream and self._current_date == today: + return + + if self._stream and not self._stream.closed: + self._stream.close() + + self._current_date = today + self._current_path = self._dated_path(today) + self._stream = self._current_path.open("a", encoding=self.encoding) + + def _should_rotate(self, incoming_size: int) -> bool: + if not self._current_path or self.max_bytes <= 0: + return False + if not self._current_path.exists(): + return False + return self._current_path.stat().st_size + incoming_size > self.max_bytes + + def _rotate_by_size(self) -> None: + if not self._current_path or not self._current_path.exists(): + return + + if self._stream and not self._stream.closed: + self._stream.close() + self._stream = None + + stem = self._current_path.stem + suffix = self._current_path.suffix + index = 1 + while True: + rotated_path = self.log_dir / f"{stem}.{index}{suffix}" + if not rotated_path.exists(): + self._current_path.rename(rotated_path) + return + index += 1 + + def _cleanup_expired_files(self) -> None: + if self.retention_days <= 0: + return + + cutoff = date.today() - timedelta(days=self.retention_days - 1) + pattern = re.compile( + rf"^{re.escape(self.file_prefix)}-(\d{{4}}-\d{{2}}-\d{{2}})(?:\.\d+)?\.log$" + ) + for path in self.log_dir.glob(f"{self.file_prefix}-*.log"): + match = pattern.match(path.name) + if not match: + continue + file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date() + if file_date < cutoff: + path.unlink(missing_ok=True) + + +def configure_logging(settings: Settings | None = None) -> None: + settings = settings or get_settings() + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(settings.log_level.upper()) + + console_formatter = logging.Formatter( + fmt=( + "%(asctime)s | %(levelname)s | pid=%(process)d | %(threadName)s | " + "request_id=%(request_id)s | %(name)s | %(pathname)s:%(lineno)d | %(message)s" + ), + datefmt="%Y-%m-%d %H:%M:%S", + ) + json_formatter = JsonLogFormatter() + request_filter = RequestIdFilter() + + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + console_handler.addFilter(request_filter) + + file_handler = DateSizeRotatingFileHandler( + log_dir=settings.log_dir, + file_prefix=settings.log_file_prefix, + max_bytes=settings.log_max_bytes, + retention_days=settings.log_retention_days, + ) + file_handler.setFormatter(json_formatter) + file_handler.addFilter(request_filter) + + error_file_handler = DateSizeRotatingFileHandler( + log_dir=settings.log_dir, + file_prefix=settings.log_error_file_prefix, + max_bytes=settings.log_max_bytes, + retention_days=settings.log_retention_days, + ) + error_file_handler.setLevel(logging.ERROR) + error_file_handler.setFormatter(json_formatter) + error_file_handler.addFilter(request_filter) + + root_logger.addHandler(console_handler) + root_logger.addHandler(file_handler) + root_logger.addHandler(error_file_handler) + + for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + logger = logging.getLogger(logger_name) + logger.handlers.clear() + logger.propagate = True + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) + + +def set_request_id(request_id: str) -> None: + request_id_var.set(request_id) + + +def setup_request_logging(app: FastAPI) -> None: + logger = get_logger("app.access") + + @app.middleware("http") + async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def] + request_id = request.headers.get("X-Request-ID") or str(uuid4()) + token = request_id_var.set(request_id) + started_at = time.perf_counter() + try: + response = await call_next(request) + elapsed_ms = (time.perf_counter() - started_at) * 1000 + logger.info( + "request completed method=%s path=%s status_code=%s duration_ms=%.2f client=%s", + request.method, + request.url.path, + response.status_code, + elapsed_ms, + request.client.host if request.client else "-", + ) + response.headers["X-Request-ID"] = request_id + return response + except Exception: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + logger.exception( + "request failed method=%s path=%s duration_ms=%.2f client=%s", + request.method, + request.url.path, + elapsed_ms, + request.client.host if request.client else "-", + ) + raise + finally: + request_id_var.reset(token) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..2a29512 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""Database infrastructure package.""" diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py new file mode 100644 index 0000000..4f9c48b --- /dev/null +++ b/backend/app/db/platform_store.py @@ -0,0 +1,1946 @@ +from __future__ import annotations + +import json +import hashlib +import hmac +import math +import secrets +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + +import psycopg + +from app.core.config import get_settings + + +ALL_PERMISSIONS = [ + "dashboard", + "fine-tune", + "model-eval", + "model-inference", + "model-manage", + "dataset", + "data-process", + "data-convert", + "compute", + "hardware", + "logs", + "user-settings", +] + + +def utcnow() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_time(value: str | None) -> datetime | None: + if not value: + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def json_loads(value: str | None, default: Any) -> Any: + if not value: + return default + return json.loads(value) + + +def json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +PASSWORD_HASH_ITERATIONS = 390_000 + + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), PASSWORD_HASH_ITERATIONS) + return f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> tuple[bool, bool]: + if not stored.startswith("pbkdf2_sha256$"): + return hmac.compare_digest(password, stored), True + try: + _, iterations, salt, expected = stored.split("$", 3) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations)).hex() + return hmac.compare_digest(digest, expected), False + except ValueError: + return False, False + + +def _psycopg_url(database_url: str) -> str: + return database_url.replace("postgresql+psycopg://", "postgresql://") + + +def _pg_sql(sql: str) -> str: + return sql.replace("?", "%s") + + +class PgRow(dict): + def __init__(self, columns: list[str], values: tuple[Any, ...]) -> None: + super().__init__(zip(columns, values)) + self._values = values + + def __getitem__(self, key: str | int) -> Any: + if isinstance(key, int): + return self._values[key] + return super().__getitem__(key) + + +class PgCursor: + def __init__(self, cursor: psycopg.Cursor[Any]) -> None: + self.cursor = cursor + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> "PgCursor": + self.cursor.execute(_pg_sql(sql), params) + return self + + def fetchone(self) -> PgRow | None: + row = self.cursor.fetchone() + if row is None: + return None + return PgRow(self._columns(), tuple(row)) + + def fetchall(self) -> list[PgRow]: + columns = self._columns() + return [PgRow(columns, tuple(row)) for row in self.cursor.fetchall()] + + def _columns(self) -> list[str]: + return [col.name for col in self.cursor.description or []] + + +class PgConnection: + def __init__(self, conn: psycopg.Connection[Any]) -> None: + self.conn = conn + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> PgCursor: + cursor = PgCursor(self.conn.cursor()) + return cursor.execute(sql, params) + + def executemany(self, sql: str, params_seq: list[tuple[Any, ...]] | list[list[Any]]) -> None: + with self.conn.cursor() as cursor: + cursor.executemany(_pg_sql(sql), params_seq) + + def executescript(self, sql: str) -> None: + with self.conn.cursor() as cursor: + for statement in sql.split(";"): + statement = statement.strip() + if statement: + cursor.execute(statement) + + def commit(self) -> None: + self.conn.commit() + + def rollback(self) -> None: + self.conn.rollback() + + def close(self) -> None: + self.conn.close() + + +class PlatformStore: + """PostgreSQL-backed store for the first runnable platform version. + + This store mirrors the API-facing subset needed by the first system + iteration while using the same PostgreSQL dependency as later production + development. + """ + + def __init__(self, database_url: str | None = None) -> None: + settings = get_settings() + self.database_url = _psycopg_url(database_url or settings.database_url) + self.ensure_schema() + self.ensure_seed_data() + + @contextmanager + def connect(self) -> Iterator["PgConnection"]: + raw_conn = psycopg.connect(self.database_url) + conn = PgConnection(raw_conn) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def ensure_schema(self) -> None: + sql_dir = Path(__file__).with_name("sql") + with self.connect() as conn: + conn.executescript((sql_dir / "001_platform_runtime.sql").read_text(encoding="utf-8")) + conn.executescript((sql_dir / "002_governance.sql").read_text(encoding="utf-8")) + columns = conn.execute( + "SELECT column_name FROM information_schema.columns WHERE table_name='users'" + ).fetchall() + column_names = {row["column_name"] for row in columns} + if "password" in column_names and "password_hash" not in column_names: + conn.execute("ALTER TABLE users RENAME COLUMN password TO password_hash") + + def ensure_seed_data(self) -> None: + with self.connect() as conn: + if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] > 0: + return + + now = utcnow() + users = [ + ("u_admin", "admin", "admin123", "Platform Admin", "admin", "active", ALL_PERMISSIONS, 1), + ( + "u_operator", + "operator", + "operator123", + "Platform Operator", + "operator", + "active", + [p for p in ALL_PERMISSIONS if p != "user-settings"], + 0, + ), + ] + conn.executemany( + """ + INSERT INTO users + (id, username, password_hash, display_name, role, status, permissions, create_time, protected) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [(u[0], u[1], hash_password(u[2]), u[3], u[4], u[5], json_dumps(u[6]), now, u[7]) for u in users], + ) + + def _duration(self, start_time: str | None, end_time: str | None = None) -> str: + start = parse_time(start_time) + if not start: + return "" + end = parse_time(end_time) or datetime.now(timezone.utc) + seconds = max(0, int((end - start).total_seconds())) + minutes, sec = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}h {minutes}m {sec}s" + if minutes: + return f"{minutes}m {sec}s" + return f"{sec}s" + + def refresh_runtime_state(self) -> None: + if get_settings().compute_mode != "simulator": + return + + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM fine_tune_tasks WHERE status IN ('syncing','queued','running')" + ).fetchall() + now_dt = datetime.now(timezone.utc) + for row in rows: + start = parse_time(row["start_time"]) + if not start: + continue + age = max(0, int((now_dt - start).total_seconds())) + if age < 4: + status, progress = "syncing", 8 + age + elif age < 8: + status, progress = "queued", 18 + age + elif age < 70: + status = "running" + progress = min(96, 25 + int((age - 8) / 62 * 70)) + else: + status, progress = "completed", 100 + + payload = json_loads(row["payload"], {}) + payload.update( + { + "status": status, + "progress": progress, + "train_duration": self._duration(row["start_time"], utcnow() if status == "completed" else None), + } + ) + completed_at = row["completed_at"] or (utcnow() if status == "completed" else None) + conn.execute( + """ + UPDATE fine_tune_tasks + SET status=?, progress=?, payload=?, completed_at=? + WHERE id=? + """, + (status, progress, json_dumps(payload), completed_at, row["id"]), + ) + if status == "completed": + self._ensure_trained_model(conn, payload) + + sync_rows = conn.execute( + "SELECT * FROM resource_sync_jobs WHERE status IN ('pending','running')" + ).fetchall() + for row in sync_rows: + created = parse_time(row["create_time"]) + age = int((now_dt - created).total_seconds()) if created else 0 + status = "completed" if age >= 6 else "running" + progress = 100 if status == "completed" else min(95, 15 + age * 12) + completed_at = row["completed_at"] or (utcnow() if status == "completed" else None) + conn.execute( + "UPDATE resource_sync_jobs SET status=?, progress=?, completed_at=? WHERE id=?", + (status, progress, completed_at, row["id"]), + ) + + def _ensure_trained_model(self, conn: PgConnection, task: dict[str, Any]) -> None: + name = task.get("output_model_name") or f"{task['name']}-lora" + exists = conn.execute("SELECT id FROM trained_models WHERE name=?", (name,)).fetchone() + if exists: + return + model = conn.execute("SELECT path FROM models WHERE id=?", (task.get("base_model"),)).fetchone() + conn.execute( + """ + INSERT INTO trained_models + (id, name, train_methods, base_model_path, create_time, merged, merging, merged_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_id("tm"), + name, + json_dumps([{"name": task.get("train_method", "lora")}]), + model["path"] if model else "", + utcnow(), + 0, + 0, + output_dir or f"/data/yg-ft/outputs/{task['name']}/adapter", + ), + ) + + def users(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM users ORDER BY create_time").fetchall() + return [self._user(row) for row in rows] + + def user_by_id(self, user_id: str) -> dict[str, Any] | None: + with self.connect() as conn: + row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + return self._user(row) if row else None + + def roles(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM roles ORDER BY create_time").fetchall() + return [ + { + "id": r["id"], + "name": r["name"], + "display_name": r["display_name"], + "permissions": json_loads(r["permissions"], []), + } + for r in rows + ] + + def audit_logs( + self, + *, + tenant_id: str | None = None, + project_id: str | None = None, + actor_id: str | None = None, + action: str | None = None, + target_type: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + clauses: list[str] = [] + params: list[Any] = [] + if tenant_id: + clauses.append("tenant_id=?") + params.append(tenant_id) + if project_id: + clauses.append("project_id=?") + params.append(project_id) + if actor_id: + clauses.append("actor_id=?") + params.append(actor_id) + if action: + clauses.append("action=?") + params.append(action) + if target_type: + clauses.append("target_type=?") + params.append(target_type) + if start_time: + clauses.append("time>=?") + params.append(start_time) + if end_time: + clauses.append("time<=?") + params.append(end_time) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + with self.connect() as conn: + total = conn.execute( + f"SELECT COUNT(*) AS c FROM audit_logs{where}", tuple(params) + ).fetchone()["c"] + rows = conn.execute( + f"SELECT * FROM audit_logs{where} ORDER BY time DESC LIMIT ? OFFSET ?", + tuple(params + [limit, offset]), + ).fetchall() + return {"items": [dict(r) for r in rows], "total": total} + + def record_audit( + self, + *, + action: str, + actor_id: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + tenant_id: str | None = None, + project_id: str | None = None, + detail: str | None = None, + client_ip: str | None = None, + ) -> dict[str, Any]: + aid = new_id("log") + with self.connect() as conn: + conn.execute( + """INSERT INTO audit_logs + (id, tenant_id, project_id, actor_id, action, target_type, target_id, detail, client_ip, time) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (aid, tenant_id, project_id, actor_id, action, target_type, target_id, detail, client_ip, utcnow()), + ) + return {"id": aid} + + # ---- 审批模板 ---- + def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]: + tid = payload.get("id") or new_id("tpl") + steps = payload.get("steps") or [] + if not isinstance(steps, list): + raise ValueError("steps 必须是列表") + with self.connect() as conn: + conn.execute( + "INSERT INTO approval_templates (id, name, steps, create_time) VALUES (?,?,?,?)", + (tid, payload["name"], json_dumps(steps), utcnow()), + ) + return self.approval_template(tid) + + def approval_templates(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM approval_templates ORDER BY create_time").fetchall() + return [self.approval_template(r["id"]) for r in rows] + + def approval_template(self, tid: str) -> dict[str, Any]: + with self.connect() as conn: + r = conn.execute("SELECT * FROM approval_templates WHERE id=?", (tid,)).fetchone() + if not r: + raise KeyError(tid) + return {**dict(r), "steps": json_loads(r["steps"], [])} + + # ---- 审批实例 ---- + def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]: + iid = new_id("apr") + template_id = payload.get("template_id") + resource_type = payload["resource_type"] + resource_id = payload["resource_id"] + applicant_id = payload["applicant_id"] + template = self.approval_template(template_id) if template_id else None + steps = template["steps"] if template else [{"approver_id": None}] + now = utcnow() + with self.connect() as conn: + conn.execute( + """INSERT INTO approval_instances + (id, template_id, resource_type, resource_id, applicant_id, status, current_step, create_time) + VALUES (?,?,?,?,?,?,?,?)""", + (iid, template_id, resource_type, resource_id, applicant_id, "pending", 0, now), + ) + for idx, step in enumerate(steps): + conn.execute( + """INSERT INTO approval_steps (instance_id, step_index, approver_id, status, comment, time) + VALUES (?,?,?,?,?,?)""", + (iid, idx, step.get("approver_id"), "pending", None, None), + ) + return self.approval_instance(iid) + + def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]: + clauses = [] + params: list[Any] = [] + if status: + clauses.append("status=?") + params.append(status) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + with self.connect() as conn: + rows = conn.execute( + f"SELECT * FROM approval_instances{where} ORDER BY create_time DESC", tuple(params) + ).fetchall() + return [self.approval_instance(r["id"]) for r in rows] + + def approval_instance(self, iid: str) -> dict[str, Any]: + with self.connect() as conn: + r = conn.execute("SELECT * FROM approval_instances WHERE id=?", (iid,)).fetchone() + if not r: + raise KeyError(iid) + steps = conn.execute( + "SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (iid,) + ).fetchall() + return {**dict(r), "steps": [dict(s) for s in steps]} + + def decide_approval_step(self, iid: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]: + inst = self.approval_instance(iid) + if inst["status"] != "pending": + raise ValueError("审批已结束") + if step_index != inst["current_step"]: + raise ValueError("当前步骤不可审批") + new_status = "approved" if approved else "rejected" + with self.connect() as conn: + conn.execute( + """UPDATE approval_steps SET status=?, approver_id=?, comment=?, time=? + WHERE instance_id=? AND step_index=?""", + (new_status, approver_id, comment, utcnow(), iid, step_index), + ) + if not approved: + conn.execute("UPDATE approval_instances SET status='rejected' WHERE id=?", (iid,)) + elif step_index + 1 >= len(inst["steps"]): + conn.execute("UPDATE approval_instances SET status='approved', current_step=? WHERE id=?", (step_index + 1, iid)) + else: + conn.execute("UPDATE approval_instances SET current_step=? WHERE id=?", (step_index + 1, iid)) + return self.approval_instance(iid) + + # ---- 租户 ---- + def tenants(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM tenants ORDER BY create_time" + ).fetchall() + return [ + {**dict(r), "quota": json_loads(r.get("quota"), {})} + for r in rows + ] + + def tenant(self, tenant_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute( + "SELECT * FROM tenants WHERE id=?", (tenant_id,) + ).fetchone() + if not row: + raise KeyError(tenant_id) + return {**dict(row), "quota": json_loads(row.get("quota"), {})} + + def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]: + tenant_id = payload.get("id") or new_id("tenant") + now = utcnow() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO tenants + (id, name, code, status, owner_user_id, quota, retention_policy_id, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + tenant_id, + payload["name"], + payload.get("code") or payload["name"].lower().replace(" ", "-"), + payload.get("status", "active"), + payload.get("owner_user_id"), + json_dumps(payload.get("quota") or {}), + payload.get("retention_policy_id"), + now, + ), + ) + return self.tenant(tenant_id) + + def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = self.tenant(tenant_id) + with self.connect() as conn: + conn.execute( + """ + UPDATE tenants + SET name=?, code=?, status=?, owner_user_id=?, quota=?, retention_policy_id=? + WHERE id=? + """, + ( + payload.get("name", current["name"]), + payload.get("code", current["code"]), + payload.get("status", current["status"]), + payload.get("owner_user_id", current.get("owner_user_id")), + json_dumps(payload.get("quota", current.get("quota") or {})), + payload.get("retention_policy_id", current.get("retention_policy_id")), + tenant_id, + ), + ) + return self.tenant(tenant_id) + + def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE tenants SET quota=? WHERE id=?", + (json_dumps(quota), tenant_id), + ) + return self.tenant(tenant_id) + + def set_tenant_retention( + self, tenant_id: str, retention_policy_id: str + ) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE tenants SET retention_policy_id=? WHERE id=?", + (retention_policy_id, tenant_id), + ) + return self.tenant(tenant_id) + + # ---- 资源 ACL ---- + def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + """ + SELECT principal_type, principal_id, permission, granted + FROM resource_acl + WHERE resource_type=? AND resource_id=? + """, + (resource_type, resource_id), + ).fetchall() + agg: dict[tuple[str, str], dict[str, Any]] = {} + for r in rows: + key = (r["principal_type"], r["principal_id"]) + entry = agg.setdefault( + key, + { + "subject_type": r["principal_type"], + "subject_id": r["principal_id"], + "permissions": [], + }, + ) + if r["granted"]: + entry["permissions"].append(r["permission"]) + return list(agg.values()) + + def set_acl( + self, + resource_type: str, + resource_id: str, + entries: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + flat: list[tuple[str, str, str, int]] = [] + for e in entries: + for perm in e.get("permissions", []): + flat.append((e["subject_type"], e["subject_id"], perm, 1)) + with self.connect() as conn: + conn.execute( + "DELETE FROM resource_acl WHERE resource_type=? AND resource_id=?", + (resource_type, resource_id), + ) + for principal_type, principal_id, perm, granted in flat: + conn.execute( + """ + INSERT INTO resource_acl + (resource_type, resource_id, principal_type, principal_id, permission, granted) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (resource_type, resource_id, principal_type, principal_id, permission) + DO UPDATE SET granted=excluded.granted + """, + (resource_type, resource_id, principal_type, principal_id, perm, granted), + ) + return self.get_acl(resource_type, resource_id) + + def login(self, username: str, password: str) -> dict[str, Any] | None: + with self.connect() as conn: + row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + if not row or row["status"] != "active": + return None + matched, legacy_plaintext = verify_password(password, row["password_hash"]) + if not matched: + return None + last_login = utcnow() + if legacy_plaintext: + conn.execute( + "UPDATE users SET password_hash=?, last_login=? WHERE id=?", + (hash_password(password), last_login, row["id"]), + ) + else: + conn.execute("UPDATE users SET last_login=? WHERE id=?", (last_login, row["id"])) + data = self._user(row) + data["last_login"] = last_login + return data + + def create_user(self, payload: dict[str, Any]) -> dict[str, Any]: + user_id = new_id("u") + permissions = payload.get("permissions") or (ALL_PERMISSIONS if payload.get("role") == "admin" else ["dashboard"]) + with self.connect() as conn: + conn.execute( + """ + INSERT INTO users + (id, username, password_hash, display_name, role, status, permissions, create_time, protected) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + """, + ( + user_id, + payload["username"], + hash_password(payload.get("password", "platform123")), + payload.get("display_name") or payload["username"], + payload.get("role", "viewer"), + payload.get("status", "active"), + json_dumps(permissions), + utcnow(), + ), + ) + return self._user(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()) + + def update_user(self, user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + if not row: + raise KeyError(user_id) + values = { + "role": payload.get("role", row["role"]), + "status": payload.get("status", row["status"]), + "permissions": json_dumps(payload.get("permissions", json_loads(row["permissions"], []))), + } + conn.execute( + "UPDATE users SET role=?, status=?, permissions=? WHERE id=?", + (values["role"], values["status"], values["permissions"], user_id), + ) + return self._user(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()) + + def delete_user(self, user_id: str) -> None: + with self.connect() as conn: + row = conn.execute("SELECT protected FROM users WHERE id=?", (user_id,)).fetchone() + if not row: + raise KeyError(user_id) + if row["protected"]: + raise ValueError("protected user cannot be deleted") + conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + + def reset_password(self, user_id: str, new_password: str) -> dict[str, Any]: + new_password = new_password or "platform123" + with self.connect() as conn: + row = conn.execute("SELECT protected FROM users WHERE id=?", (user_id,)).fetchone() + if not row: + raise KeyError(user_id) + if row["protected"]: + raise ValueError("protected user cannot be reset") + conn.execute( + "UPDATE users SET password_hash=? WHERE id=?", + (hash_password(new_password), user_id), + ) + return self._user(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()) + + def _user(self, row: PgRow) -> dict[str, Any]: + return { + "id": row["id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "status": row["status"], + "permissions": json_loads(row["permissions"], []), + "create_time": row["create_time"], + "last_login": row["last_login"], + "protected": bool(row["protected"]), + } + + def models(self) -> list[dict[str, Any]]: + with self.connect() as conn: + return [dict(row) for row in conn.execute("SELECT * FROM models ORDER BY create_time DESC").fetchall()] + + def model(self, model_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone() + if not row: + raise KeyError(model_id) + return dict(row) + + def model_by_name(self, name: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM models WHERE name=?", (name,)).fetchone() + if not row: + raise KeyError(name) + return dict(row) + + def create_model(self, payload: dict[str, Any]) -> dict[str, Any]: + model_id = payload.get("id") or new_id("m") + with self.connect() as conn: + conn.execute( + """ + INSERT INTO models + (id, name, type, purpose, model_source, description, path, api_url, api_key, online_model_name, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + model_id, + payload["name"], + payload.get("type", "LLM"), + payload.get("purpose", "training"), + payload.get("model_source", "local"), + payload.get("description"), + payload.get("path"), + payload.get("api_url"), + payload.get("api_key"), + payload.get("online_model_name"), + utcnow(), + ), + ) + return self.model(model_id) + + def update_model(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = self.model(model_id) + merged = {**current, **payload} + with self.connect() as conn: + conn.execute( + """ + UPDATE models + SET name=?, type=?, purpose=?, model_source=?, description=?, path=?, api_url=?, api_key=?, online_model_name=? + WHERE id=? + """, + ( + merged["name"], + merged.get("type", "LLM"), + merged.get("purpose", "training"), + merged.get("model_source", "local"), + merged.get("description"), + merged.get("path"), + merged.get("api_url"), + merged.get("api_key"), + merged.get("online_model_name"), + model_id, + ), + ) + return self.model(model_id) + + def delete_model(self, model_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM models WHERE id=?", (model_id,)) + + def trained_models(self) -> list[dict[str, Any]]: + self.refresh_runtime_state() + with self.connect() as conn: + rows = conn.execute("SELECT * FROM trained_models ORDER BY create_time DESC").fetchall() + return [ + { + **dict(row), + "train_methods": json_loads(row["train_methods"], []), + "merged": bool(row["merged"]), + "merging": bool(row["merging"]), + } + for row in rows + ] + + def datasets(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM datasets ORDER BY create_time DESC").fetchall() + return [self._dataset(conn, row) for row in rows] + + def dataset(self, dataset_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone() + if not row: + raise KeyError(dataset_id) + return self._dataset(conn, row) + + def _dataset(self, conn: PgConnection, row: PgRow) -> dict[str, Any]: + files = conn.execute( + "SELECT id, name, size, active_version_id, create_time FROM dataset_files WHERE dataset_id=? ORDER BY create_time", + (row["id"],), + ).fetchall() + return { + **dict(row), + "files": [ + { + "id": f["id"], + "name": f["name"], + "size": f["size"], + "active_version_id": f["active_version_id"], + "create_time": f["create_time"], + } + for f in files + ], + } + + def create_dataset(self, payload: dict[str, Any]) -> dict[str, Any]: + dataset_id = payload.get("id") or new_id("ds") + with self.connect() as conn: + conn.execute( + """ + INSERT INTO datasets + (id, name, type, storage_type, source, task_id, size, count, description, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + dataset_id, + payload["name"], + payload.get("type", "train"), + payload.get("storage_type", "local"), + payload.get("source", "upload"), + payload.get("task_id"), + payload.get("size", "0 KB"), + payload.get("count", 0), + payload.get("description"), + utcnow(), + ), + ) + return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone()) + + def update_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = self.dataset(dataset_id) + merged = {**current, **payload} + with self.connect() as conn: + conn.execute( + """ + UPDATE datasets + SET name=?, type=?, storage_type=?, source=?, task_id=?, size=?, count=?, description=? + WHERE id=? + """, + ( + merged["name"], + merged.get("type", "train"), + merged.get("storage_type", "local"), + merged.get("source", "upload"), + merged.get("task_id"), + merged.get("size", "0 KB"), + merged.get("count", 0), + merged.get("description"), + dataset_id, + ), + ) + return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone()) + + def delete_dataset(self, dataset_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM dataset_files WHERE dataset_id=?", (dataset_id,)) + conn.execute("DELETE FROM datasets WHERE id=?", (dataset_id,)) + + def add_dataset_file(self, conn: PgConnection, dataset_id: str, name: str, content: str) -> dict[str, Any]: + now = utcnow() + file_id = new_id("file") + version_id = f"{file_id}_v1" + size = f"{max(1, len(content.encode('utf-8')) // 1024)} KB" + conn.execute( + """ + INSERT INTO dataset_files + (id, dataset_id, name, size, content, active_version_id, versions, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + file_id, + dataset_id, + name, + size, + content, + version_id, + json_dumps([{"id": version_id, "version": 1, "create_time": now, "description": "uploaded"}]), + now, + ), + ) + count = len([line for line in content.splitlines() if line.strip()]) + conn.execute( + "UPDATE datasets SET count=count+?, size=? WHERE id=?", + (count, size, dataset_id), + ) + return {"id": file_id, "name": name, "size": size} + + def dataset_file(self, file_id: str) -> PgRow: + with self.connect() as conn: + row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone() + if not row: + raise KeyError(file_id) + return row + + def file_versions(self, file_id: str) -> dict[str, Any]: + row = self.dataset_file(file_id) + versions = json_loads(row["versions"], []) + return { + "versions": versions, + "active_version_id": row["active_version_id"], + "next_version_number": len(versions) + 1, + } + + def create_file_version(self, file_id: str, payload: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone() + if not row: + raise KeyError(file_id) + versions = json_loads(row["versions"], []) + version = { + "id": f"{file_id}_v{len(versions) + 1}", + "version": len(versions) + 1, + "create_time": utcnow(), + "description": payload.get("description", "online edit"), + } + versions.append(version) + conn.execute( + "UPDATE dataset_files SET content=?, active_version_id=?, versions=? WHERE id=?", + (payload.get("content", ""), version["id"], json_dumps(versions), file_id), + ) + return {"version": version, "content": payload.get("content", "")} + + def activate_file_version(self, file_id: str, version_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone() + if not row: + raise KeyError(file_id) + versions = json_loads(row["versions"], []) + version = next((item for item in versions if item["id"] == version_id), None) + if not version: + raise KeyError(version_id) + conn.execute("UPDATE dataset_files SET active_version_id=? WHERE id=?", (version_id, file_id)) + return {"version": version, "content": row["content"]} + + def tasks(self) -> list[dict[str, Any]]: + self.refresh_runtime_state() + with self.connect() as conn: + rows = conn.execute("SELECT * FROM fine_tune_tasks ORDER BY create_time DESC").fetchall() + return [self._task(row) for row in rows] + + def task(self, task_id: str) -> dict[str, Any]: + self.refresh_runtime_state() + with self.connect() as conn: + row = conn.execute("SELECT * FROM fine_tune_tasks WHERE id=?", (task_id,)).fetchone() + if not row: + raise KeyError(task_id) + return self._task(row) + + def _task(self, row: PgRow) -> dict[str, Any]: + payload = json_loads(row["payload"], {}) + payload.update( + { + "id": row["id"], + "status": row["status"], + "progress": row["progress"], + "process_id": row["process_id"], + "create_time": row["create_time"], + "gpus": json_loads(row["gpus"], payload.get("gpus", [])), + "train_duration": self._duration(row["start_time"], row["completed_at"]) if row["start_time"] else "", + "compute_node_id": row["compute_node_id"], + "sync_job_id": row["sync_job_id"], + } + ) + return payload + + def create_task(self, payload: dict[str, Any]) -> dict[str, Any]: + task_id = str(payload.get("task_id") or payload.get("id") or new_id("ft")) + name = payload.get("name") or f"fine-tune-{task_id[-6:]}" + base_model = payload.get("base_model") or payload.get("base_model_id") + train_dataset_id = payload.get("train_dataset_id") + if not base_model: + raise ValueError("base_model or base_model_id is required") + if not train_dataset_id: + raise ValueError("train_dataset_id is required") + now = utcnow() + task = { + "id": task_id, + "name": name, + "description": payload.get("description", ""), + "status": "pending", + "train_type": payload.get("train_type", "SFT"), + "train_method": payload.get("train_method", "lora"), + "template": payload.get("template", "qwen"), + "base_model": base_model, + "train_dataset_id": train_dataset_id, + "auto_merge": bool(payload.get("auto_merge", False)), + "output_model_name": payload.get("output_model_name") or f"{name}-lora", + "gpus": payload.get("gpus") or [], + "batch_size": payload.get("batch_size", 2), + "learning_rate": payload.get("learning_rate", 0.0002), + "n_epochs": payload.get("n_epochs", 3), + "save_steps": payload.get("save_steps", 50), + "lr_scheduler_type": payload.get("lr_scheduler_type", "cosine"), + "max_length": payload.get("max_length", 2048), + "warmup_ratio": payload.get("warmup_ratio", 0.03), + "weight_decay": payload.get("weight_decay", 0.01), + "lora_alpha": payload.get("lora_alpha", 16), + "lora_dropout": payload.get("lora_dropout", 0.05), + "lora_rank": payload.get("lora_rank", 8), + "quantization_bit": payload.get("quantization_bit", 4), + "export_quantized": bool(payload.get("export_quantized", False)), + "quant_method": payload.get("quant_method", "bnb"), + "quant_bits": payload.get("quant_bits", 4), + "quant_group_size": payload.get("quant_group_size", 128), + "export_format": payload.get("export_format", "safetensors"), + "progress": 0, + "process_id": None, + "train_duration": "", + "create_time": now, + } + with self.connect() as conn: + conn.execute( + """ + INSERT INTO fine_tune_tasks + (id, name, payload, status, progress, process_id, create_time, gpus) + VALUES (?, ?, ?, 'pending', 0, NULL, ?, ?) + """, + (task_id, name, json_dumps(task), now, json_dumps(task["gpus"])), + ) + return task + + def update_task(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = self.task(task_id) + merged = {**current, **payload, "id": task_id} + with self.connect() as conn: + conn.execute( + "UPDATE fine_tune_tasks SET name=?, payload=?, gpus=? WHERE id=?", + (merged["name"], json_dumps(merged), json_dumps(merged.get("gpus", [])), task_id), + ) + return self.task(task_id) + + def start_task(self, payload: dict[str, Any]) -> dict[str, Any]: + task_id = str(payload.get("task_id") or payload.get("id")) + current = self.task(task_id) + merged = {**current, **payload, "id": task_id, "status": "syncing", "progress": 8} + node = self.schedule_node(payload) + selected_gpus = payload.get("gpus") or merged.get("gpus") or [0] + process_id = int(43000 + (time.time() % 10000)) + sync_job_id = self.create_sync_job(node["id"], current) + with self.connect() as conn: + conn.execute( + """ + UPDATE fine_tune_tasks + SET payload=?, status='syncing', progress=8, process_id=?, start_time=?, + compute_node_id=?, gpus=?, sync_job_id=? + WHERE id=? + """, + ( + json_dumps({**merged, "process_id": process_id, "gpus": selected_gpus}), + process_id, + utcnow(), + node["id"], + json_dumps(selected_gpus), + sync_job_id, + task_id, + ), + ) + if get_settings().compute_mode != "simulator": + from app.modules.fine_tune.service import launch_training + + launch_training(task_id) + return self.task(task_id) + + def stop_task(self, task_id: str) -> dict[str, Any]: + task = self.task(task_id) + task.update({"status": "failed", "progress": min(task.get("progress", 0), 99)}) + with self.connect() as conn: + conn.execute( + "UPDATE fine_tune_tasks SET status='failed', payload=?, completed_at=? WHERE id=?", + (json_dumps(task), utcnow(), task_id), + ) + return self.task(task_id) + + def update_task_runtime( + self, + task_id: str, + status: str | None = None, + progress: int | None = None, + process_id: int | None = None, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """训练执行层回写运行时状态(状态 / 进度 / 进程号 / 附加字段)。""" + current = self.task(task_id) + merged = {**current, **(extra or {})} + if status is not None: + merged["status"] = status + if progress is not None: + merged["progress"] = progress + if process_id is not None: + merged["process_id"] = process_id + with self.connect() as conn: + conn.execute( + """ + UPDATE fine_tune_tasks + SET payload=?, status=?, progress=?, process_id=? + WHERE id=? + """, + ( + json_dumps(merged), + status or current["status"], + progress if progress is not None else current["progress"], + process_id if process_id is not None else current["process_id"], + task_id, + ), + ) + return self.task(task_id) + + def ensure_trained_model_for_task(self, task_id: str, task: dict[str, Any], output_dir: str) -> None: + """训练完成后登记训练产物,供模型管理与推理使用。""" + with self.connect() as conn: + self._ensure_trained_model( + conn, + {**task, "output_model_name": task.get("output_model_name") or f"{task['name']}-lora"}, + output_dir, + ) + + def pause_task(self, task_id: str) -> bool: + from app.modules.fine_tune.service import pause + + return pause(task_id) + + def resume_task_engine(self, task_id: str) -> bool: + from app.modules.fine_tune.service import resume + + return resume(task_id) + + def cancel_task_engine(self, task_id: str) -> bool: + from app.modules.fine_tune.service import cancel + + return cancel(task_id) + + def delete_task(self, task_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM fine_tune_tasks WHERE id=?", (task_id,)) + + def schedule_node(self, payload: dict[str, Any]) -> dict[str, Any]: + requested = payload.get("requested_node_id") or payload.get("compute_node_id") + nodes = self.compute_nodes() + candidates = [ + n + for n in nodes + if n["enabled"] and n["scheduler_status"] == "online" and n["current_running_jobs"] < n["max_parallel_jobs"] + ] + if requested: + selected = next((n for n in candidates if n["id"] == requested), None) + if selected: + return selected + if not candidates: + raise RuntimeError("no available compute node") + return sorted(candidates, key=lambda n: (-n["scheduler_weight"], n["current_running_jobs"], n["code"]))[0] + + def create_sync_job(self, node_id: str, task: dict[str, Any]) -> str: + sync_id = new_id("sync") + with self.connect() as conn: + conn.execute( + """ + INSERT INTO resource_sync_jobs + (id, target_node_id, resources, status, progress, create_time) + VALUES (?, ?, ?, 'pending', 0, ?) + """, + ( + sync_id, + node_id, + json_dumps( + [ + {"resource_type": "model", "resource_id": task.get("base_model")}, + {"resource_type": "dataset", "resource_id": task.get("train_dataset_id")}, + ] + ), + utcnow(), + ), + ) + return sync_id + + def progress(self, task_id: str) -> dict[str, Any]: + task = self.task(task_id) + status = task.get("status", "pending") + labels = { + "pending": "waiting for start", + "syncing": "syncing model and dataset to compute node", + "queued": "waiting for GPU slot", + "running": "training with LLaMA-Factory", + "completed": "training completed", + "failed": "training stopped", + } + progress = int(task.get("progress", 0) or 0) + eta = "--" if status in {"completed", "failed"} else f"{max(1, math.ceil((100 - progress) / 10))} min" + return { + "status": status, + "progress": progress, + "step": labels.get(status, status), + "speed": task.get("train_speed") or "--", + "eta": eta, + } + + def compute_nodes(self) -> list[dict[str, Any]]: + self.refresh_runtime_state() + with self.connect() as conn: + running = conn.execute( + "SELECT compute_node_id, COUNT(*) AS cnt FROM fine_tune_tasks WHERE status IN ('syncing','queued','running') GROUP BY compute_node_id" + ).fetchall() + running_map = {r["compute_node_id"]: r["cnt"] for r in running} + rows = conn.execute("SELECT * FROM compute_nodes ORDER BY scheduler_weight DESC, code").fetchall() + return [ + { + **dict(row), + "enabled": bool(row["enabled"]), + "tags": json_loads(row["tags"], []), + "health_detail": json_loads(row["health_detail"], {}), + "current_running_jobs": running_map.get(row["id"], 0), + } + for row in rows + ] + + def update_compute_node(self, node_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = next((n for n in self.compute_nodes() if n["id"] == node_id), None) + if not current: + raise KeyError(node_id) + merged = {**current, **payload} + with self.connect() as conn: + conn.execute( + """ + UPDATE compute_nodes + SET name=?, api_base_url=?, file_gateway_url=?, enabled=?, scheduler_status=?, + scheduler_weight=?, tags=?, max_parallel_jobs=?, last_health_check_at=? + WHERE id=? + """, + ( + merged["name"], + merged["api_base_url"], + merged["file_gateway_url"], + 1 if merged["enabled"] else 0, + merged["scheduler_status"], + merged["scheduler_weight"], + json_dumps(merged["tags"]), + merged["max_parallel_jobs"], + utcnow(), + node_id, + ), + ) + return next(n for n in self.compute_nodes() if n["id"] == node_id) + + def create_compute_node(self, payload: dict[str, Any]) -> dict[str, Any]: + node_id = payload.get("id") or new_id("node") + now = utcnow() + tags = payload.get("tags") or [] + with self.connect() as conn: + conn.execute( + """ + INSERT INTO compute_nodes + (id, code, name, api_base_url, file_gateway_url, enabled, scheduler_status, + scheduler_weight, tags, gpu_count, current_running_jobs, max_parallel_jobs, + data_root, model_root, log_root, last_health_check_at, health_detail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + """, + ( + node_id, + payload["code"], + payload.get("name") or payload["code"], + payload["api_base_url"], + payload.get("file_gateway_url") or payload["api_base_url"], + 1 if payload.get("enabled", True) else 0, + payload.get("scheduler_status", "offline"), + int(payload.get("scheduler_weight", 100)), + json_dumps(tags), + int(payload.get("gpu_count", 0)), + int(payload.get("max_parallel_jobs", 1)), + payload.get("data_root", "/data/yg-ft"), + payload.get("model_root", "/models"), + payload.get("log_root", "/data/yg-ft/training-logs"), + now, + json_dumps(payload.get("health_detail") or {"status": "registered"}), + ), + ) + return next(node for node in self.compute_nodes() if node["id"] == node_id) + + def gpus(self) -> list[dict[str, Any]]: + self.refresh_runtime_state() + with self.connect() as conn: + rows = conn.execute( + """ + SELECT g.*, n.code AS node_code, n.name AS node_name + FROM gpus g JOIN compute_nodes n ON n.id = g.node_id + ORDER BY n.code, g.gpu_index + """ + ).fetchall() + running_tasks = [ + self._task(row) + for row in conn.execute( + "SELECT * FROM fine_tune_tasks WHERE status IN ('syncing','queued','running')" + ).fetchall() + ] + items = [] + for row in rows: + task = next( + ( + t + for t in running_tasks + if t.get("compute_node_id") == row["node_id"] and row["gpu_index"] in (t.get("gpus") or []) + ), + None, + ) + busy = task is not None and task.get("status") == "running" + reserved = task is not None and task.get("status") in {"syncing", "queued"} + memory_used = round(row["memory_total_gb"] * (0.72 if busy else 0.18 if reserved else 0.04), 1) + gpu_percent = 86 if busy else 22 if reserved else 3 + items.append( + { + "id": row["gpu_index"], + "node_id": row["node_id"], + "node_code": row["node_code"], + "node_name": row["node_name"], + "name": row["name"], + "uuid": row["uuid"], + "gpu_percent": gpu_percent, + "memory_used_gb": memory_used, + "memory_total_gb": row["memory_total_gb"], + "memory_percent": round(memory_used / row["memory_total_gb"] * 100, 1), + "temperature": row["base_temperature"] + (21 if busy else 6 if reserved else 0), + "power_w": round(row["power_limit_w"] * (0.7 if busy else 0.25 if reserved else 0.08), 1), + "power_limit_w": row["power_limit_w"], + "status": "busy" if busy else "reserved" if reserved else "idle", + "processes": [ + { + "pid": task["process_id"], + "name": "llamafactory-cli", + "memory_used_gb": memory_used, + "task_name": task["name"], + "user": "admin", + } + ] + if task + else [], + } + ) + return items + + def system_info(self) -> dict[str, Any]: + gpus = self.gpus() + busy = len([g for g in gpus if g["status"] in {"busy", "reserved"}]) + cpu_percent = 18 + busy * 9 + memory_percent = 37 + busy * 4 + return { + "timestamp": utcnow(), + "cpu": { + "percent": min(cpu_percent, 95), + "cores": 32, + "percents": [min(cpu_percent + (i % 7) - 3, 99) for i in range(32)], + "model": "Platform x86_64 CPU", + "frequency_mhz": 2600, + "load_1m": round(cpu_percent / 10, 2), + }, + "memory": { + "used_gb": round(256 * memory_percent / 100, 1), + "total_gb": 256, + "percent": min(memory_percent, 95), + "available_gb": round(256 * (100 - memory_percent) / 100, 1), + "cached_gb": 32, + }, + "disk": { + "used_gb": 840, + "total_gb": 2048, + "percent": 41, + "read_mb_s": 120 if busy else 8, + "write_mb_s": 95 if busy else 5, + }, + "gpu": gpus, + "network": { + "download_mb_s": 12 if busy else 1.2, + "upload_mb_s": 7 if busy else 0.8, + "download_mb": 8024, + "upload_mb": 1732, + }, + "system": { + "uptime_seconds": int(time.time() % 100000), + "process_count": 248 + busy, + "os": "Linux platform compute image", + }, + } + + def health_metrics(self) -> dict[str, float]: + info = self.system_info() + return { + "cpu_percent": info["cpu"]["percent"], + "memory_percent": info["memory"]["percent"], + "disk_percent": info["disk"]["percent"], + } + + def queue(self) -> list[dict[str, Any]]: + return [ + { + "id": task["id"], + "name": task["name"], + "status": task["status"], + "progress": task.get("progress", 0), + "compute_node_id": task.get("compute_node_id"), + "gpus": task.get("gpus", []), + "create_time": task.get("create_time"), + } + for task in self.tasks() + if task["status"] in {"pending", "syncing", "queued", "running"} + ] + + def replicas(self, node_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM resource_replicas WHERE node_id=? ORDER BY create_time DESC", + (node_id,), + ).fetchall() + return [dict(row) for row in rows] + + def sync_job(self, sync_id: str) -> dict[str, Any]: + self.refresh_runtime_state() + with self.connect() as conn: + row = conn.execute("SELECT * FROM resource_sync_jobs WHERE id=?", (sync_id,)).fetchone() + if not row: + raise KeyError(sync_id) + return {**dict(row), "resources": json_loads(row["resources"], [])} + + def training_log_files(self) -> list[dict[str, Any]]: + return [ + { + "file": f"train_{task['id']}_pid{task['process_id'] or 0}.log", + "name": task["name"], + "pid": task.get("process_id") or 0, + "size": f"{max(1, int((task.get('progress', 0) or 0) * 1.5))} KB", + "date": task.get("create_time", "")[:10], + } + for task in self.tasks() + ] + + def training_log_content(self, file_name: str) -> dict[str, Any]: + task = next((t for t in self.tasks() if t["id"] in file_name), None) + if not task: + raise KeyError(file_name) + content = self.generate_training_log(task) + return {"file": file_name, "content": content, "size": f"{max(1, len(content.encode('utf-8')) // 1024)} KB"} + + def generate_training_log(self, task: dict[str, Any]) -> str: + progress = int(task.get("progress", 0) or 0) + points = max(1, min(80, progress)) + lines = [ + f"[INFO] task={task['name']} engine=llama_factory status={task['status']}", + f"[INFO] base_model={task.get('base_model')} dataset={task.get('train_dataset_id')} gpus={task.get('gpus', [])}", + "[INFO] command=llamafactory-cli train --stage sft --finetuning_type lora --do_train true", + ] + for step in range(1, points + 1): + if step % 3 != 0 and step != points: + continue + loss = max(0.12, 2.4 * math.exp(-step / 42)) + grad_norm = 0.45 + (step % 8) * 0.03 + lr = float(task.get("learning_rate") or 0.0002) * max(0.05, 1 - step / 120) + epoch = round(step / max(1, points) * float(task.get("n_epochs") or 3), 4) + lines.append( + "{" + f'"loss": {loss:.4f}, "grad_norm": {grad_norm:.4f}, ' + f'"learning_rate": {lr:.8f}, "epoch": {epoch:.4f}' + "}" + ) + if task.get("status") == "completed": + lines.extend( + [ + "***** train metrics *****", + f"epoch = {task.get('n_epochs', 3)}", + "train_loss = 0.1248", + f"train_runtime = {task.get('train_duration') or '1m 10s'}", + "***** train metrics end *****", + ] + ) + return "\n".join(lines) + + def log_files(self, date: str | None = None) -> list[dict[str, Any]]: + today = date or utcnow()[:10] + return [ + {"file": f"backend-{today}.log", "name": f"backend-{today}.log", "size": "32 KB", "date": today}, + {"file": f"error-{today}.log", "name": f"error-{today}.log", "size": "1 KB", "date": today}, + ] + + def log_content(self, file_name: str) -> dict[str, Any]: + lines = [ + json_dumps( + { + "timestamp": utcnow(), + "level": "INFO", + "logger": "platform", + "file": "backend/app/api/v1/endpoints/platform.py", + "line": 1, + "message": "Platform log stream is available.", + } + ) + ] + return {"file": file_name, "content": "\n".join(lines), "size": "1 KB"} + + # ===================== Project Management (§13.2) ===================== + + def projects(self, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]: + with self.connect() as conn: + sql = "SELECT * FROM projects WHERE tenant_id=?" + params: list[Any] = [tenant_id] + if status: + sql += " AND status=?" + params.append(status) + if keyword: + sql += " AND (name LIKE ? OR code LIKE ?)" + kw = f"%{keyword}%" + params.extend([kw, kw]) + sql += " ORDER BY create_time DESC" + rows = conn.execute(sql, tuple(params)).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + project = dict(row) + project["member_count"] = conn.execute( + "SELECT COUNT(*) FROM project_members WHERE project_id=?", (row["id"],) + ).fetchone()[0] + project["task_count"] = conn.execute( + "SELECT COUNT(*) FROM fine_tune_tasks WHERE project_id=?", (row["id"],) + ).fetchone()[0] + project["quota"] = json_loads(row.get("quota"), {}) + result.append(project) + return result + + def project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone() + if not row: + raise KeyError(project_id) + project = dict(row) + project["quota"] = json_loads(row.get("quota"), {}) + project["member_count"] = conn.execute( + "SELECT COUNT(*) FROM project_members WHERE project_id=?", (project_id,) + ).fetchone()[0] + project["task_count"] = conn.execute( + "SELECT COUNT(*) FROM fine_tune_tasks WHERE project_id=?", (project_id,) + ).fetchone()[0] + return project + + def create_project(self, payload: dict[str, Any]) -> dict[str, Any]: + project_id = payload.get("id") or new_id("proj") + now = utcnow() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO projects + (id, tenant_id, name, code, description, quota, status, create_time, create_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + """, + ( + project_id, + payload.get("tenant_id", "default"), + payload["name"], + payload.get("code", payload["name"].lower().replace(" ", "-")), + payload.get("description"), + json_dumps(payload.get("quota") or {}), + now, + payload.get("create_by"), + now, + ), + ) + # auto-add creator as owner + if payload.get("create_by"): + conn.execute( + "INSERT INTO project_members (project_id, user_id, role, create_time) VALUES (?, ?, 'owner', ?)", + (project_id, payload["create_by"], now), + ) + return self.project(project_id) + + def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + current = self.project(project_id) + with self.connect() as conn: + conn.execute( + """ + UPDATE projects + SET name=?, code=?, description=?, quota=?, updated_at=? + WHERE id=? + """, + ( + payload.get("name", current["name"]), + payload.get("code", current["code"]), + payload.get("description", current.get("description")), + json_dumps(payload.get("quota", current.get("quota") or {})), + utcnow(), + project_id, + ), + ) + return self.project(project_id) + + def archive_project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE projects SET status='archived', updated_at=? WHERE id=?", + (utcnow(), project_id), + ) + return self.project(project_id) + + def activate_project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE projects SET status='active', updated_at=? WHERE id=?", + (utcnow(), project_id), + ) + return self.project(project_id) + + def delete_project(self, project_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM project_members WHERE project_id=?", (project_id,)) + conn.execute("DELETE FROM projects WHERE id=?", (project_id,)) + + def project_members(self, project_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + """ + SELECT pm.project_id, pm.user_id, pm.role, pm.create_time, + u.username, u.display_name + FROM project_members pm + JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? + ORDER BY pm.create_time + """, + (project_id,), + ).fetchall() + return [ + { + "project_id": row["project_id"], + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "create_time": row["create_time"], + } + for row in rows + ] + + def add_project_member(self, project_id: str, user_id: str, role: str = "member") -> dict[str, Any]: + with self.connect() as conn: + # verify user exists + user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() + if not user: + raise KeyError(f"user {user_id}") + existing = conn.execute( + "SELECT * FROM project_members WHERE project_id=? AND user_id=?", + (project_id, user_id), + ).fetchone() + if existing: + conn.execute( + "UPDATE project_members SET role=? WHERE project_id=? AND user_id=?", + (role, project_id, user_id), + ) + else: + conn.execute( + "INSERT INTO project_members (project_id, user_id, role, create_time) VALUES (?, ?, ?, ?)", + (project_id, user_id, role, utcnow()), + ) + row = conn.execute( + """ + SELECT pm.*, u.username, u.display_name + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? AND pm.user_id=? + """, + (project_id, user_id), + ).fetchone() + return { + "project_id": row["project_id"], + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "create_time": row["create_time"], + } + + def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE project_members SET role=? WHERE project_id=? AND user_id=?", + (role, project_id, user_id), + ) + row = conn.execute( + """ + SELECT pm.*, u.username, u.display_name + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? AND pm.user_id=? + """, + (project_id, user_id), + ).fetchone() + if not row: + raise KeyError(user_id) + return { + "project_id": row["project_id"], + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "create_time": row["create_time"], + } + + def remove_project_member(self, project_id: str, user_id: str) -> None: + with self.connect() as conn: + conn.execute( + "DELETE FROM project_members WHERE project_id=? AND user_id=?", + (project_id, user_id), + ) + + # ===================== Fine-tune Retry & Resume (§13.9) ===================== + + def retry_task(self, task_id: str) -> dict[str, Any]: + task = self.task(task_id) + if task.get("status") not in {"failed", "cancelled"}: + raise ValueError("only failed or cancelled tasks can be retried") + retry_count = 0 + with self.connect() as conn: + row = conn.execute( + "SELECT retry_count FROM fine_tune_tasks WHERE id=?", (task_id,) + ).fetchone() + if row: + retry_count = int(row[0] or 0) + 1 + now = utcnow() + conn.execute( + """ + UPDATE fine_tune_tasks + SET status='pending', progress=0, completed_at=NULL, + error_message=NULL, payload=?, retry_count=?, last_retry_at=? + WHERE id=? + """, + (json_dumps({**task, "status": "pending", "progress": 0}), retry_count, now, task_id), + ) + return self.task(task_id) + + def resume_task(self, task_id: str, checkpoint_id: str) -> dict[str, Any]: + task = self.task(task_id) + if task.get("status") not in {"failed", "cancelled", "completed"}: + raise ValueError("only failed, cancelled or completed tasks can be resumed") + with self.connect() as conn: + ckpt = conn.execute( + "SELECT * FROM fine_tune_checkpoints WHERE id=? AND task_id=?", + (checkpoint_id, task_id), + ).fetchone() + if not ckpt: + raise KeyError(checkpoint_id) + now = utcnow() + conn.execute( + """ + UPDATE fine_tune_tasks + SET status='pending', progress=0, completed_at=NULL, + error_message=NULL, payload=?, resumed_from_checkpoint_id=? + WHERE id=? + """, + ( + json_dumps({**task, "status": "pending", "progress": 0, "resume_from": ckpt["path"]}), + checkpoint_id, + task_id, + ), + ) + return self.task(task_id) + + # ===================== Checkpoint Management (§13.9) ===================== + + def checkpoints(self, task_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM fine_tune_checkpoints WHERE task_id=? ORDER BY step", + (task_id,), + ).fetchall() + return [ + { + "id": row["id"], + "task_id": row["task_id"], + "name": row["name"], + "path": row["path"], + "step": row["step"], + "loss": row["loss"], + "is_best": bool(row["is_best"]), + "size_bytes": row["size_bytes"], + "create_time": row["create_time"], + } + for row in rows + ] + + def delete_checkpoint(self, checkpoint_id: str) -> None: + with self.connect() as conn: + row = conn.execute( + "SELECT id FROM fine_tune_checkpoints WHERE id=?", (checkpoint_id,) + ).fetchone() + if not row: + raise KeyError(checkpoint_id) + conn.execute("DELETE FROM fine_tune_checkpoints WHERE id=?", (checkpoint_id,)) + + def set_checkpoint_retention(self, task_id: str, policy: dict[str, Any]) -> dict[str, Any]: + """policy: {"max_count": int, "keep_best": bool, "retention_hours": int}""" + with self.connect() as conn: + conn.execute( + "UPDATE fine_tune_tasks SET checkpoint_retention_policy=? WHERE id=?", + (json_dumps(policy), task_id), + ) + return {"task_id": task_id, "checkpoint_retention_policy": policy} + + def get_checkpoint_retention(self, task_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute( + "SELECT checkpoint_retention_policy FROM fine_tune_tasks WHERE id=?", + (task_id,), + ).fetchone() + if not row: + raise KeyError(task_id) + policy = json_loads(row["checkpoint_retention_policy"], {}) + return {"task_id": task_id, "checkpoint_retention_policy": policy or {"max_count": 5, "keep_best": True, "retention_hours": 168}} + + # ===================== Fine-tune SSE Events (§7.1) ===================== + + def task_events(self, task_id: str) -> list[dict[str, Any]]: + task = self.task(task_id) + progress = int(task.get("progress", 0) or 0) + events: list[dict[str, Any]] = [ + { + "timestamp": task.get("create_time", utcnow()), + "type": "status", + "data": {"status": task.get("status", "pending"), "message": f"Task {task['name']} {task.get('status')}"}, + } + ] + train_logs = self.generate_training_log(task).split("\n") + for i, line in enumerate(train_logs): + events.append( + { + "timestamp": utcnow(), + "type": "log" if "loss" not in line else "metric", + "data": {"line": i + 1, "content": line}, + } + ) + events.append( + { + "timestamp": utcnow(), + "type": "progress", + "data": {"progress": progress, "status": task.get("status")}, + } + ) + return events + + # ===================== Compute Jobs (§13.6) ===================== + + def create_compute_job(self, payload: dict[str, Any]) -> dict[str, Any]: + job_id = payload.get("id") or new_id("job") + now = utcnow() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO compute_jobs + (id, task_id, node_id, name, type, status, command, gpu_count, priority, + timeout_seconds, progress, create_time) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, 0, ?) + """, + ( + job_id, + payload["task_id"], + payload.get("node_id", ""), + payload.get("name", f"job-{job_id[-8:]}"), + payload.get("type", "train"), + payload.get("command"), + payload.get("gpu_count", 1), + payload.get("priority", 0), + payload.get("timeout_seconds"), + now, + ), + ) + return self.compute_job(job_id) + + def compute_job(self, job_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM compute_jobs WHERE id=?", (job_id,)).fetchone() + if not row: + raise KeyError(job_id) + return dict(row) + + def compute_jobs_by_task(self, task_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM compute_jobs WHERE task_id=? ORDER BY create_time DESC", + (task_id,), + ).fetchall() + return [dict(row) for row in rows] + + def stop_compute_job(self, job_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM compute_jobs WHERE id=?", (job_id,)).fetchone() + if not row: + raise KeyError(job_id) + conn.execute( + "UPDATE compute_jobs SET status='stopped', completed_at=? WHERE id=?", + (utcnow(), job_id), + ) + return self.compute_job(job_id) + + def compute_job_logs(self, job_id: str) -> dict[str, Any]: + job = self.compute_job(job_id) + task = self.task(job["task_id"]) + content = self.generate_training_log(task) + return {"job_id": job_id, "content": content, "lines": len(content.splitlines())} + + +_store: PlatformStore | None = None + + +def get_platform_store() -> PlatformStore: + global _store + if _store is None: + _store = PlatformStore() + return _store + diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..befe080 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import os +from collections.abc import Generator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft") + +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + future=True, +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@contextmanager +def session_scope() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + diff --git a/backend/app/db/sql/001_platform_runtime.sql b/backend/app/db/sql/001_platform_runtime.sql new file mode 100644 index 0000000..2133661 --- /dev/null +++ b/backend/app/db/sql/001_platform_runtime.sql @@ -0,0 +1,214 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + permissions TEXT NOT NULL, + create_time TEXT NOT NULL, + last_login TEXT, + protected INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + purpose TEXT NOT NULL, + model_source TEXT NOT NULL, + description TEXT, + path TEXT, + api_url TEXT, + api_key TEXT, + online_model_name TEXT, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS trained_models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + train_methods TEXT NOT NULL, + base_model_path TEXT, + create_time TEXT NOT NULL, + merged INTEGER NOT NULL DEFAULT 0, + merging INTEGER NOT NULL DEFAULT 0, + merged_path TEXT +); + +CREATE TABLE IF NOT EXISTS datasets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + storage_type TEXT NOT NULL, + source TEXT NOT NULL, + task_id TEXT, + size TEXT, + count INTEGER NOT NULL DEFAULT 0, + description TEXT, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS dataset_files ( + id TEXT PRIMARY KEY, + dataset_id TEXT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + name TEXT NOT NULL, + size TEXT, + content TEXT NOT NULL, + active_version_id TEXT NOT NULL, + versions TEXT NOT NULL, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS compute_nodes ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + api_base_url TEXT NOT NULL, + file_gateway_url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + scheduler_status TEXT NOT NULL, + scheduler_weight INTEGER NOT NULL DEFAULT 100, + tags TEXT NOT NULL, + gpu_count INTEGER NOT NULL DEFAULT 0, + current_running_jobs INTEGER NOT NULL DEFAULT 0, + max_parallel_jobs INTEGER NOT NULL DEFAULT 2, + data_root TEXT NOT NULL, + model_root TEXT NOT NULL, + log_root TEXT NOT NULL, + last_health_check_at TEXT, + health_detail TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS gpus ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + gpu_index INTEGER NOT NULL, + uuid TEXT NOT NULL, + name TEXT NOT NULL, + memory_total_gb DOUBLE PRECISION NOT NULL, + power_limit_w DOUBLE PRECISION NOT NULL, + base_temperature INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS fine_tune_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + payload TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + process_id INTEGER, + create_time TEXT NOT NULL, + start_time TEXT, + completed_at TEXT, + compute_node_id TEXT REFERENCES compute_nodes(id) ON DELETE SET NULL, + gpus TEXT NOT NULL, + sync_job_id TEXT +); + +CREATE TABLE IF NOT EXISTS resource_replicas ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + local_path TEXT NOT NULL, + status TEXT NOT NULL, + sync_status TEXT NOT NULL, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS resource_sync_jobs ( + id TEXT PRIMARY KEY, + target_node_id TEXT NOT NULL, + resources TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + create_time TEXT NOT NULL, + completed_at TEXT +); + +-- ===================== Project / Tenant ===================== + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + code TEXT NOT NULL, + description TEXT, + quota TEXT, + status TEXT NOT NULL DEFAULT 'active', + create_time TEXT NOT NULL, + create_by TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + create_time TEXT NOT NULL, + PRIMARY KEY (project_id, user_id) +); + +-- ===================== Fine-tune Checkpoints ===================== + +CREATE TABLE IF NOT EXISTS fine_tune_checkpoints ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + path TEXT NOT NULL, + step INTEGER NOT NULL DEFAULT 0, + loss DOUBLE PRECISION, + is_best INTEGER NOT NULL DEFAULT 0, + size_bytes BIGINT DEFAULT 0, + create_time TEXT NOT NULL +); + +-- ===================== Compute Jobs (internal) ===================== + +CREATE TABLE IF NOT EXISTS compute_jobs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'train', + status TEXT NOT NULL DEFAULT 'pending', + command TEXT, + gpu_count INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 0, + timeout_seconds INTEGER, + progress REAL DEFAULT 0, + result TEXT, + error_message TEXT, + create_time TEXT NOT NULL, + start_time TEXT, + completed_at TEXT +); + +-- ===================== Indexes ===================== + +CREATE INDEX IF NOT EXISTS idx_fine_tune_status ON fine_tune_tasks(status); +CREATE INDEX IF NOT EXISTS idx_dataset_files_dataset ON dataset_files(dataset_id); +CREATE INDEX IF NOT EXISTS idx_gpus_node ON gpus(node_id); +CREATE INDEX IF NOT EXISTS idx_replicas_resource ON resource_replicas(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id); +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id); +CREATE INDEX IF NOT EXISTS idx_checkpoints_task ON fine_tune_checkpoints(task_id); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_task ON compute_jobs(task_id); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_node ON compute_jobs(node_id); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_status ON compute_jobs(status); + +-- ===================== Migrations: extend fine_tune_tasks ===================== + +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS project_id TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS dataset_name TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS model_name TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS trained_model_name TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS checkpoint_retention_policy TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS error_message TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS last_retry_at TEXT; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS resumed_from_checkpoint_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_fine_tune_project ON fine_tune_tasks(project_id); diff --git a/backend/app/db/sql/002_governance.sql b/backend/app/db/sql/002_governance.sql new file mode 100644 index 0000000..3c17207 --- /dev/null +++ b/backend/app/db/sql/002_governance.sql @@ -0,0 +1,127 @@ +-- A. 平台基础与企业治理:权限 / 角色 / 租户 / 审批 / 审计 / 留存 +-- 沿用 001 的约定:时间戳存 TEXT,布尔用 INTEGER,列表用 TEXT(JSON) + +CREATE TABLE IF NOT EXISTS permissions ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + group_name TEXT NOT NULL, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id TEXT NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (role_id, permission) +); + +CREATE TABLE IF NOT EXISTS user_permission_overrides ( + user_id TEXT NOT NULL, + permission TEXT NOT NULL, + granted INTEGER NOT NULL, + PRIMARY KEY (user_id, permission) +); + +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + owner_user_id TEXT, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tenant_users ( + tenant_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (tenant_id, user_id) +); + +CREATE TABLE IF NOT EXISTS resource_acl ( + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + principal_type TEXT NOT NULL, + principal_id TEXT NOT NULL, + permission TEXT NOT NULL, + granted INTEGER NOT NULL, + PRIMARY KEY (resource_type, resource_id, principal_type, principal_id, permission) +); + +CREATE TABLE IF NOT EXISTS approval_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + steps TEXT NOT NULL, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS approval_instances ( + id TEXT PRIMARY KEY, + template_id TEXT, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + applicant_id TEXT NOT NULL, + status TEXT NOT NULL, + current_step INTEGER NOT NULL DEFAULT 0, + create_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS approval_steps ( + instance_id TEXT NOT NULL, + step_index INTEGER NOT NULL, + approver_id TEXT, + status TEXT NOT NULL, + comment TEXT, + time TEXT, + PRIMARY KEY (instance_id, step_index) +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + project_id TEXT, + actor_id TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + detail TEXT, + client_ip TEXT, + time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS retention_policies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + resource_type TEXT NOT NULL, + retention_days INTEGER NOT NULL, + create_time TEXT NOT NULL +); + +-- ===================== 种子数据 ===================== +INSERT INTO permissions (code, name, group_name, create_time) VALUES + ('dashboard', '仪表盘', '概览', '2026-01-01T00:00:00Z'), + ('fine-tune', '模型微调', '模型', '2026-01-01T00:00:00Z'), + ('model-eval', '模型评估', '模型', '2026-01-01T00:00:00Z'), + ('model-inference', '模型推理', '模型', '2026-01-01T00:00:00Z'), + ('model-manage', '模型管理', '模型', '2026-01-01T00:00:00Z'), + ('dataset', '数据集', '数据', '2026-01-01T00:00:00Z'), + ('data-process', '数据处理', '数据', '2026-01-01T00:00:00Z'), + ('data-convert', '数据转换', '数据', '2026-01-01T00:00:00Z'), + ('compute', '算力管理', '算力', '2026-01-01T00:00:00Z'), + ('hardware', '硬件监控', '算力', '2026-01-01T00:00:00Z'), + ('logs', '日志查看', '运维', '2026-01-01T00:00:00Z'), + ('user-settings', '用户设置', '运维', '2026-01-01T00:00:00Z') +ON CONFLICT (code) DO NOTHING; + +INSERT INTO roles (id, name, display_name, permissions, create_time) VALUES + ('role_admin', 'admin', '管理员', '["dashboard","fine-tune","model-eval","model-inference","model-manage","dataset","data-process","data-convert","compute","hardware","logs","user-settings"]', '2026-01-01T00:00:00Z'), + ('role_operator','operator','操作员', '["dashboard","fine-tune","model-eval","model-inference","model-manage","dataset","data-process","data-convert","compute","hardware","logs"]', '2026-01-01T00:00:00Z'), + ('role_viewer', 'viewer', '访客', '["dashboard"]', '2026-01-01T00:00:00Z') +ON CONFLICT (name) DO NOTHING; diff --git a/backend/app/db/sql/003_tenant_quota.sql b/backend/app/db/sql/003_tenant_quota.sql new file mode 100644 index 0000000..e89bc85 --- /dev/null +++ b/backend/app/db/sql/003_tenant_quota.sql @@ -0,0 +1,17 @@ +-- 003: tenants 补充 quota / retention_policy 列(接口契约 13.1 需要) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tenants' AND column_name = 'quota' + ) THEN + ALTER TABLE tenants ADD COLUMN quota TEXT; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tenants' AND column_name = 'retention_policy_id' + ) THEN + ALTER TABLE tenants ADD COLUMN retention_policy_id TEXT; + END IF; +END +$$; diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..37955e7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,26 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import api_router +from app.core.config import get_settings +from app.core.logging import configure_logging, setup_request_logging + + +def create_app() -> FastAPI: + settings = get_settings() + configure_logging(settings) + + app = FastAPI(title=settings.app_name) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + setup_request_logging(app) + app.include_router(api_router, prefix=settings.route_prefix) + return app + + +app = create_app() diff --git a/backend/app/modules/README.md b/backend/app/modules/README.md new file mode 100644 index 0000000..880ff37 --- /dev/null +++ b/backend/app/modules/README.md @@ -0,0 +1,15 @@ +# Backend Module Convention + +每个业务模块建议保持一致结构: + +```text +module_name/ + __init__.py + router.py # FastAPI router + schemas.py # Pydantic request/response models + service.py # Business orchestration + repository.py # Database access + permissions.py # Optional resource permission checks +``` + +模块边界以 `docs/system-development-plan.md` 的页面模块开发工作包为准。 diff --git a/backend/app/modules/approval/__init__.py b/backend/app/modules/approval/__init__.py new file mode 100644 index 0000000..1c710f1 --- /dev/null +++ b/backend/app/modules/approval/__init__.py @@ -0,0 +1,5 @@ +"""Approval workflow module.""" + +from app.modules.approval.router import router + +__all__ = ["router"] diff --git a/backend/app/modules/approval/router.py b/backend/app/modules/approval/router.py new file mode 100644 index 0000000..dd9d8f5 --- /dev/null +++ b/backend/app/modules/approval/router.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/approvals", tags=["approval"]) + + +@router.get("/templates") +def list_templates() -> dict[str, Any]: + return ok(get_platform_store().approval_templates()) + + +@router.post("/templates") +def create_template(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + if not payload.get("name"): + raise fail(400, "name 必填") + return ok(get_platform_store().create_approval_template(payload)) + + +@router.get("") +def list_instances(status: str | None = None) -> dict[str, Any]: + return ok(get_platform_store().approval_instances(status=status)) + + +@router.post("") +def create_instance(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + for field in ("resource_type", "resource_id", "applicant_id"): + if not payload.get(field): + raise fail(400, f"{field} 必填") + try: + return ok(get_platform_store().create_approval_instance(payload)) + except KeyError: + raise fail(404, "template not found") + + +@router.get("/{instance_id}") +def get_instance(instance_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().approval_instance(instance_id)) + except KeyError: + raise fail(404, "instance not found") + + +@router.post("/{instance_id}/steps/{step_index}/decision") +def decide( + instance_id: str, + step_index: int, + payload: dict[str, Any] = Body(...), +) -> dict[str, Any]: + if not payload.get("approver_id"): + raise fail(400, "approver_id 必填") + try: + return ok( + get_platform_store().decide_approval_step( + instance_id, + step_index, + approver_id=payload["approver_id"], + approved=bool(payload.get("approved", False)), + comment=payload.get("comment"), + ) + ) + except (KeyError, ValueError) as e: + raise fail(400, str(e)) diff --git a/backend/app/modules/audit/__init__.py b/backend/app/modules/audit/__init__.py new file mode 100644 index 0000000..3202b1e --- /dev/null +++ b/backend/app/modules/audit/__init__.py @@ -0,0 +1 @@ +"""Audit log module.""" diff --git a/backend/app/modules/auth/__init__.py b/backend/app/modules/auth/__init__.py new file mode 100644 index 0000000..8b1e9de --- /dev/null +++ b/backend/app/modules/auth/__init__.py @@ -0,0 +1,3 @@ +from app.modules.auth.router import router + +__all__ = ["router"] diff --git a/backend/app/modules/auth/deps.py b/backend/app/modules/auth/deps.py new file mode 100644 index 0000000..962102a --- /dev/null +++ b/backend/app/modules/auth/deps.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from fastapi import Depends, Header, HTTPException, status + +from app.db.platform_store import get_platform_store +from app.modules.auth.service import decode_access_token + + +def get_current_user(authorization: str | None = Header(default=None)) -> dict: + """从 Bearer 令牌解析出当前登录用户,供受保护接口依赖使用。""" + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少认证令牌") + token = authorization.split(" ", 1)[1].strip() + user_id = decode_access_token(token) + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效或已过期") + user = get_platform_store().user_by_id(user_id) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") + return user diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..1c099cd --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from app.db.platform_store import get_platform_store +from app.modules.auth.deps import get_current_user +from app.modules.auth.service import create_access_token + + +router = APIRouter() + + +class LoginBody(BaseModel): + username: str + password: str + + +@router.post("/login") +def login(body: LoginBody) -> dict: + user = get_platform_store().login(body.username, body.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + token = create_access_token(user["id"]) + return {"code": 0, "message": "ok", "data": {"token": token, "user": user}} + + +@router.get("/me") +def me(current_user: dict = Depends(get_current_user)) -> dict: + return {"code": 0, "message": "ok", "data": current_user} diff --git a/backend/app/modules/auth/service.py b/backend/app/modules/auth/service.py new file mode 100644 index 0000000..c2c1780 --- /dev/null +++ b/backend/app/modules/auth/service.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import jwt +from datetime import datetime, timedelta, timezone + +from app.core.config import get_settings + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def create_access_token(user_id: str, expires_minutes: int | None = None) -> str: + """为指定用户签发 JWT 访问令牌。""" + settings = get_settings() + expire = _now() + timedelta(minutes=expires_minutes or settings.access_token_expire_minutes) + payload = { + "sub": user_id, + "iat": int(_now().timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str | None: + """校验并返回令牌中的用户 ID;无效/过期返回 None。""" + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + except jwt.PyJWTError: + return None + sub = payload.get("sub") + return sub if isinstance(sub, str) else None diff --git a/backend/app/modules/compute_gateway/__init__.py b/backend/app/modules/compute_gateway/__init__.py new file mode 100644 index 0000000..70436ed --- /dev/null +++ b/backend/app/modules/compute_gateway/__init__.py @@ -0,0 +1 @@ +"""Application-side compute platform gateway module.""" diff --git a/backend/app/modules/data_process/__init__.py b/backend/app/modules/data_process/__init__.py new file mode 100644 index 0000000..3da002b --- /dev/null +++ b/backend/app/modules/data_process/__init__.py @@ -0,0 +1 @@ +"""Data processing module.""" diff --git a/backend/app/modules/dataset/__init__.py b/backend/app/modules/dataset/__init__.py new file mode 100644 index 0000000..9fbd064 --- /dev/null +++ b/backend/app/modules/dataset/__init__.py @@ -0,0 +1 @@ +"""Dataset management module.""" diff --git a/backend/app/modules/engine_registry/__init__.py b/backend/app/modules/engine_registry/__init__.py new file mode 100644 index 0000000..e5237fe --- /dev/null +++ b/backend/app/modules/engine_registry/__init__.py @@ -0,0 +1 @@ +"""Training engine registry module.""" diff --git a/backend/app/modules/eval/__init__.py b/backend/app/modules/eval/__init__.py new file mode 100644 index 0000000..c7f7436 --- /dev/null +++ b/backend/app/modules/eval/__init__.py @@ -0,0 +1 @@ +"""Evaluation module.""" diff --git a/backend/app/modules/file_gateway/__init__.py b/backend/app/modules/file_gateway/__init__.py new file mode 100644 index 0000000..e5e295c --- /dev/null +++ b/backend/app/modules/file_gateway/__init__.py @@ -0,0 +1 @@ +"""Application-side file gateway module.""" diff --git a/backend/app/modules/fine_tune/__init__.py b/backend/app/modules/fine_tune/__init__.py new file mode 100644 index 0000000..917e45a --- /dev/null +++ b/backend/app/modules/fine_tune/__init__.py @@ -0,0 +1,7 @@ +"""模型训练模块(移植自模型服务 projects/backend)。 + +- service.py: 业务编排(preset 参数预设、train_type→stage 映射、启动/暂停/恢复/取消)。 +- runner.py: 真实训练执行器(基于 LLaMA-Factory 的 llamafactory-cli 子进程 + 实时 loss 监控)。 + +当前后端默认 COMPUTE_MODE=real 时由本模块真正驱动训练;本地无 GPU 用 simulator 时不触发,行为不变。 +""" diff --git a/backend/app/modules/fine_tune/runner.py b/backend/app/modules/fine_tune/runner.py new file mode 100644 index 0000000..9b56fcf --- /dev/null +++ b/backend/app/modules/fine_tune/runner.py @@ -0,0 +1,423 @@ +""" +真实训练执行器(基于项目内 train.py 调用 LLaMA-Factory 库) + +- 根据任务配置构建 `python train.py` 命令(非 llamafactory-cli 子命令) +- 实时捕获训练日志与 trainer_log.jsonl 的 loss,回写到 PlatformStore +- 支持 SIGSTOP / SIGCONT / SIGKILL 实现暂停 / 恢复 / 取消 +- 训练完成后自动绘制 loss 曲线(matplotlib 可选) +""" +from __future__ import annotations + +import json +import os +import queue +import signal +import subprocess +import sys +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any + +from app.db.platform_store import get_platform_store + +BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent.parent +DATA_DIR = BACKEND_ROOT / "data" +DATASET_INFO_PATH = DATA_DIR / "dataset_info.json" +DATASET_STORE_DIR = DATA_DIR / "fine_tune_datasets" +OUTPUT_ROOT = DATA_DIR / "fine_tune_outputs" +TRAIN_SCRIPT = BACKEND_ROOT / "train.py" + +_running_processes: dict[str, "subprocess.Popen[Any]"] = {} + +_stage_map = {"sft": "sft", "dpo": "dpo", "cpt": "pt", "cot": "sft"} + + +# ────────────────────────────────────────────── +# 工具函数 +# ────────────────────────────────────────────── +def _log(store: Any, task_id: str, msg: str, log_type: str = "info") -> None: + try: + task = store.task(task_id) + logs = list(task.get("logs") or []) + logs.append({"time": datetime.now().strftime("%H:%M:%S"), "msg": msg, "type": log_type}) + store.update_task_runtime(task_id, extra={"logs": logs}) + except Exception: + pass + + +def _resolve_model_path(store: Any, name_or_path: str) -> str: + """把模型短名解析为本地绝对路径;已是合法路径则原样返回。""" + if not name_or_path: + return name_or_path + p = Path(name_or_path) + if p.exists() and (p / "config.json").exists(): + return str(p.resolve()) + for m in store.models() or []: + if m.get("name") == name_or_path or m.get("path") == name_or_path: + resolved = Path(m.get("path", "")) + if resolved.exists(): + return str(resolved.resolve()) + return name_or_path + + +def _materialize_dataset(store: Any, dataset_id: str) -> str: + """把数据集管理系统的 UUID 数据集落盘并注册进 dataset_info.json,返回 --dataset key。""" + if not dataset_id or dataset_id == "identity": + return dataset_id + try: + existing = json.loads(DATASET_INFO_PATH.read_text(encoding="utf-8")) if DATASET_INFO_PATH.exists() else {} + except Exception: + existing = {} + if dataset_id in existing: + return dataset_id + try: + ds = store.dataset(dataset_id) + except Exception: + return dataset_id + files = ds.get("files") or [] + if not files: + return dataset_id + file_id = files[0].get("id") + ext = files[0].get("ext", ".json") + if ext not in (".json", ".jsonl"): + ext = ".json" + if not file_id: + return dataset_id + try: + row = store.dataset_file(file_id) + except Exception: + return dataset_id + content = row.get("content", "") + DATASET_STORE_DIR.mkdir(parents=True, exist_ok=True) + actual = DATASET_STORE_DIR / f"{dataset_id}{ext}" + actual.write_text(content, encoding="utf-8") + rel = actual.relative_to(DATA_DIR) + entry: dict[str, Any] = {"file_name": str(rel)} + try: + text = content.strip() + if text.startswith("["): + text = text[text.find("{") : text.find("}") + 1] + sample = json.loads(text) + if isinstance(sample, dict) and ("messages" in sample or "conversations" in sample): + key = "messages" if "messages" in sample else "conversations" + entry["formatting"] = "sharegpt" + entry["columns"] = {"messages": key} + except Exception: + pass + existing[dataset_id] = entry + DATASET_INFO_PATH.parent.mkdir(parents=True, exist_ok=True) + DATASET_INFO_PATH.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8") + return dataset_id + + +# ────────────────────────────────────────────── +# 训练主流程 +# ────────────────────────────────────────────── +def run_training(task_id: str) -> None: + store = get_platform_store() + try: + task = store.task(task_id) + except Exception: + return + + cfg = dict(task) + mode = (cfg.get("train_type") or "sft").lower() + stage = _stage_map.get(mode, "sft") + + base_model = _resolve_model_path(store, cfg.get("base_model", "")) + train_dataset = _materialize_dataset(store, cfg.get("train_dataset_id", "")) + eval_dataset = _materialize_dataset(store, cfg.get("eval_dataset_id") or cfg.get("eval_dataset", "")) + + finetuning_type = (cfg.get("train_method") or "lora").lower() + OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) + output_dir = str(OUTPUT_ROOT / task["name"]) + + env = os.environ.copy() + env.setdefault("HF_HUB_OFFLINE", "1") + env.setdefault("TRANSFORMERS_OFFLINE", "1") + env.setdefault("HF_ENDPOINT", "https://hf-mirror.com") + + gpus = cfg.get("gpus") or [0] + num_gpus = int(cfg.get("num_gpus", len(gpus)) or 1) or 1 + # 仅当显式指定非默认 GPU 时限制可见设备;多卡统一走 torchrun + if gpus and str(gpus[0]) not in ("0", "gpu-0"): + env["CUDA_VISIBLE_DEVICES"] = ",".join(str(g).replace("gpu-", "") for g in gpus) + num_gpus = len(gpus) + + if num_gpus > 1: + _log(store, task_id, f"[INFO] 启用分布式训练: {num_gpus} 个 GPU", "info") + cmd = [sys.executable, "-m", "torch.distributed.run", "--nproc_per_node", str(num_gpus), str(TRAIN_SCRIPT)] + else: + cmd = [sys.executable, str(TRAIN_SCRIPT)] + cmd += [ + "--stage", stage, + "--do_train", "True", + "--model_name_or_path", base_model, + "--dataset", train_dataset, + "--dataset_dir", str(DATA_DIR), + "--template", cfg.get("template", "default"), + "--finetuning_type", finetuning_type, + "--output_dir", output_dir, + "--trust_remote_code", "True", + "--overwrite_output_dir", "True", + "--report_to", "none", + "--learning_rate", str(cfg.get("learning_rate", "1e-5")), + "--num_train_epochs", str(cfg.get("n_epochs", 3)), + "--per_device_train_batch_size", str(cfg.get("batch_size", 4)), + "--cutoff_len", str(cfg.get("max_length", 1024)), + "--gradient_accumulation_steps", str(cfg.get("gradient_accumulation_steps", 4)), + "--max_samples", str(cfg.get("max_samples", 100000)), + "--lr_scheduler_type", cfg.get("lr_scheduler_type", "cosine"), + "--warmup_ratio", str(cfg.get("warmup_ratio", 0.03)), + "--max_grad_norm", str(cfg.get("max_grad_norm", "1.0")), + "--optim", cfg.get("optim", "adamw_torch"), + "--logging_steps", str(cfg.get("logging_steps", 10)), + "--save_steps", str(cfg.get("save_steps", 100)), + "--save_total_limit", str(cfg.get("save_total_limit", 5)), + "--flash_attn", cfg.get("flash_attn", "auto"), + ] + dtype = cfg.get("dtype", "bf16") + if dtype == "bf16": + cmd += ["--bf16", "True"] + elif dtype == "fp16": + cmd += ["--fp16", "True"] + if finetuning_type == "lora": + cmd += [ + "--lora_rank", str(cfg.get("lora_rank", 8)), + "--lora_alpha", str(cfg.get("lora_alpha", 16)), + "--lora_dropout", str(cfg.get("lora_dropout", "0.05")), + "--lora_target", cfg.get("lora_target", "all"), + ] + if cfg.get("do_eval", False): + cmd += ["--do_eval", "True", "--eval_strategy", "steps", + "--eval_steps", str(cfg.get("eval_steps", 50)), + "--per_device_eval_batch_size", "4"] + if eval_dataset: + cmd += ["--eval_dataset", eval_dataset] + else: + cmd += ["--val_size", str(cfg.get("val_size", "0.1"))] + if cfg.get("resume_from"): + cmd += ["--resume_from_checkpoint", str(cfg["resume_from"])] + + _log(store, task_id, f"[INFO] 训练任务启动: {mode.upper()} 微调") + _log(store, task_id, f"[INFO] 基座模型: {base_model}") + _log(store, task_id, f"[INFO] 数据集: {train_dataset}") + _log(store, task_id, f"[INFO] 输出目录: {output_dir}") + store.update_task_runtime(task_id, status="running", progress=10, process_id=None) + + try: + process = subprocess.Popen( + cmd, + cwd=str(BACKEND_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + start_new_session=True, + ) + _running_processes[task_id] = process + store.update_task_runtime(task_id, process_id=process.pid) + _log(store, task_id, f"[INFO] 训练进程已启动 (PID: {process.pid})", "info") + + log_file = Path(output_dir) / "trainer_log.jsonl" + last_pos = 0 + stop_flag = threading.Event() + finished = threading.Event() + + def watch_logs() -> None: + nonlocal last_pos + while not stop_flag.is_set(): + time.sleep(1) + if not log_file.exists(): + continue + try: + with open(log_file, "r", encoding="utf-8") as lf: + lf.seek(last_pos) + for line in lf: + try: + data = json.loads(line) + if "loss" not in data: + continue + loss = float(data["loss"]) + history = list(store.task(task_id).get("loss_history") or []) + history.append(loss) + extra: dict[str, Any] = {"current_loss": loss, "loss_history": history} + if data.get("lr") is not None: + extra["learning_rate"] = float(str(data["lr"]).replace("'", "")) + if data.get("epoch") is not None: + extra["current_epoch"] = float(data["epoch"]) + if data.get("percentage") is not None: + extra["progress"] = float(data["percentage"]) + if data.get("remaining_time"): + extra["eta"] = str(data["remaining_time"]) + store.update_task_runtime(task_id, extra=extra) + step = data.get("current_steps") + total = data.get("total_steps") + _log(store, task_id, f"Step {step}/{total} loss={loss:.4f}") + try: + if int(step) >= int(total): + finished.set() + except Exception: + pass + except Exception: + pass + last_pos = lf.tell() + except Exception: + pass + + watcher = threading.Thread(target=watch_logs, daemon=True) + watcher.start() + + q: "queue.Queue[str]" = queue.Queue() + + def reader() -> None: + try: + for line in process.stdout: + q.put(line.rstrip("\r\n")) + except Exception: + pass + finally: + q.put("") + + r = threading.Thread(target=reader, daemon=True) + r.start() + + while True: + try: + raw = q.get(timeout=1) + except queue.Empty: + if finished.is_set() or (process.poll() is not None and q.empty()): + try: + raw = q.get(timeout=1) + except queue.Empty: + break + else: + continue + if not raw: + break + low = raw.lower() + log_type = "error" if "error" in low else ("warn" if "warn" in low else "info") + _log(store, task_id, raw, log_type) + + stop_flag.set() + try: + process.stdout.close() + except Exception: + pass + watcher.join(timeout=3) + try: + process.wait(timeout=60) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + ret = process.returncode + if ret == 0: + store.update_task_runtime(task_id, status="completed", progress=100) + _log(store, task_id, "[INFO] 训练完成!", "info") + try: + store.ensure_trained_model_for_task(task_id, cfg, output_dir) + except Exception as exc: + _log(store, task_id, f"[WARN] 登记训练产物失败: {exc}", "warn") + try: + _plot_loss_curve(task_id, output_dir, bool(cfg.get("do_eval", False))) + except Exception as exc: + _log(store, task_id, f"[WARN] Loss 曲线异常: {exc}", "warn") + else: + store.update_task_runtime(task_id, status="failed", extra={"error_message": f"训练退出码: {ret}"}) + _log(store, task_id, f"[ERROR] 训练退出码: {ret}", "error") + except FileNotFoundError: + store.update_task_runtime(task_id, status="failed", extra={"error_message": "未找到 train.py 或 Python,请确认后端根目录存在 train.py 且 LLaMA-Factory 已安装"}) + _log(store, task_id, "[ERROR] 未找到 train.py / Python,无法启动真实训练", "error") + except Exception as exc: # noqa: BLE001 + store.update_task_runtime(task_id, status="failed", extra={"error_message": str(exc)}) + _log(store, task_id, f"[ERROR] 训练异常: {exc}", "error") + finally: + _running_processes.pop(task_id, None) + + +# ────────────────────────────────────────────── +# 进程信号控制 +# ────────────────────────────────────────────── +def _signal(task_id: str, sig: int) -> bool: + process = _running_processes.get(task_id) + if not process: + return False + try: + try: + pgid = os.getpgid(process.pid) + os.killpg(pgid, sig) + except (ProcessLookupError, PermissionError): + process.send_signal(sig) + return True + except Exception: + return False + + +def pause(task_id: str) -> bool: + ok = _signal(task_id, signal.SIGSTOP) + if ok: + get_platform_store().update_task_runtime(task_id, status="paused") + _log(get_platform_store(), task_id, "[INFO] 训练已暂停", "info") + return ok + + +def resume(task_id: str) -> bool: + ok = _signal(task_id, signal.SIGCONT) + if ok: + get_platform_store().update_task_runtime(task_id, status="running") + _log(get_platform_store(), task_id, "[INFO] 训练已继续", "info") + return ok + + +def cancel(task_id: str) -> bool: + ok = _signal(task_id, signal.SIGKILL) + if ok: + get_platform_store().update_task_runtime(task_id, status="failed", extra={"error_message": "用户中断训练"}) + _log(get_platform_store(), task_id, "[WARN] 用户中断了训练", "warn") + return ok + + +def _plot_loss_curve(task_id: str, output_dir: str, has_eval: bool = False) -> None: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + state_file = Path(output_dir) / "trainer_state.json" + if not state_file.exists(): + return + state = json.loads(state_file.read_text(encoding="utf-8")) + log_history = state.get("log_history", []) + if not log_history: + return + train_steps, train_losses, eval_steps, eval_losses = [], [], [], [] + for entry in log_history: + if "loss" in entry and "step" in entry: + train_steps.append(entry["step"]) + train_losses.append(entry["loss"]) + if has_eval and "eval_loss" in entry and "step" in entry: + eval_steps.append(entry["step"]) + eval_losses.append(entry["eval_loss"]) + if not train_losses: + return + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot(train_steps, train_losses, label="Training Loss", color="#409eff", linewidth=1.5) + ax.axhline(y=min(train_losses), color="#67c23a", linestyle="--", alpha=0.5, + label=f"Min: {min(train_losses):.4f}") + if eval_losses: + ax.plot(eval_steps, eval_losses, label="Validation Loss", color="#f56c6c", + linewidth=1.5, marker="o", markersize=3) + ax.set_xlabel("Step") + ax.set_ylabel("Loss") + ax.set_title("Training Loss Curve") + ax.legend() + ax.grid(True, alpha=0.3) + save_path = Path(output_dir) / "loss_curve.png" + fig.savefig(str(save_path), dpi=150, bbox_inches="tight") + plt.close(fig) + _log(get_platform_store(), task_id, f"Loss 曲线已保存: {save_path}") + except Exception: + pass diff --git a/backend/app/modules/fine_tune/service.py b/backend/app/modules/fine_tune/service.py new file mode 100644 index 0000000..61ce10c --- /dev/null +++ b/backend/app/modules/fine_tune/service.py @@ -0,0 +1,79 @@ +""" +模型训练业务编排(移植自模型服务 projects/backend 的 training_service) + +- preset 参数预设(quick / standard / high) +- train_type → stage 映射(sft/dpo/cpt/cot) +- 训练任务的启动 / 暂停 / 恢复 / 取消(委托 runner 真实执行) +""" +from __future__ import annotations + +import threading +from typing import Any + +from app.db.platform_store import get_platform_store +from app.modules.fine_tune import runner + +PRESETS: dict[str, dict[str, Any]] = { + "quick": {"learning_rate": "5e-5", "n_epochs": 1, "batch_size": 4, "lora_rank": 8}, + "standard": {"learning_rate": "2e-5", "n_epochs": 3, "batch_size": 8, "lora_rank": 16}, + "high": {"learning_rate": "1e-5", "n_epochs": 5, "batch_size": 4, "lora_rank": 32}, +} + + +def apply_presets(payload: dict[str, Any]) -> dict[str, Any]: + """根据 preset 字段补全缺失的超参;preset=custom 时不覆盖。""" + payload = dict(payload) + preset = payload.get("preset", "standard") + if preset in PRESETS and payload.get("preset") != "custom": + for key, value in PRESETS[preset].items(): + payload.setdefault(key, value) + return payload + + +def build_training_config(payload: dict[str, Any]) -> dict[str, Any]: + """把前端创建/启动载荷标准化为执行器可消费的 config。""" + payload = apply_presets(dict(payload)) + gpus = payload.get("gpus") or [0] + return { + "name": payload.get("name", ""), + "description": payload.get("description", ""), + "train_type": payload.get("train_type", "SFT"), + "train_method": payload.get("train_method", "lora"), + "template": payload.get("template", "qwen"), + "base_model": payload.get("base_model", "") or payload.get("base_model_id", ""), + "train_dataset_id": payload.get("train_dataset_id", ""), + "eval_dataset_id": payload.get("eval_dataset_id", ""), + "auto_merge": bool(payload.get("auto_merge", False)), + "output_model_name": payload.get("output_model_name", ""), + "gpus": gpus, + "num_gpus": payload.get("num_gpus", len(gpus)), + "batch_size": payload.get("batch_size", 2), + "learning_rate": payload.get("learning_rate", 0.0002), + "n_epochs": payload.get("n_epochs", 3), + "save_steps": payload.get("save_steps", 50), + "lr_scheduler_type": payload.get("lr_scheduler_type", "cosine"), + "max_length": payload.get("max_length", 2048), + "warmup_ratio": payload.get("warmup_ratio", 0.03), + "weight_decay": payload.get("weight_decay", 0.01), + "lora_rank": payload.get("lora_rank", 8), + "lora_alpha": payload.get("lora_alpha", 16), + "lora_dropout": payload.get("lora_dropout", 0.05), + "resume_from": payload.get("resume_from"), + } + + +def launch_training(task_id: str) -> None: + """在后台线程启动真实训练。""" + threading.Thread(target=runner.run_training, args=(task_id,), daemon=True).start() + + +def pause(task_id: str) -> bool: + return runner.pause(task_id) + + +def resume(task_id: str) -> bool: + return runner.resume(task_id) + + +def cancel(task_id: str) -> bool: + return runner.cancel(task_id) diff --git a/backend/app/modules/inference/__init__.py b/backend/app/modules/inference/__init__.py new file mode 100644 index 0000000..be36430 --- /dev/null +++ b/backend/app/modules/inference/__init__.py @@ -0,0 +1 @@ +"""Inference and compare module.""" diff --git a/backend/app/modules/model/__init__.py b/backend/app/modules/model/__init__.py new file mode 100644 index 0000000..bcace02 --- /dev/null +++ b/backend/app/modules/model/__init__.py @@ -0,0 +1 @@ +"""Model registry module.""" diff --git a/backend/app/modules/project/__init__.py b/backend/app/modules/project/__init__.py new file mode 100644 index 0000000..654a293 --- /dev/null +++ b/backend/app/modules/project/__init__.py @@ -0,0 +1 @@ +from app.modules.project.router import router diff --git a/backend/app/modules/project/router.py b/backend/app/modules/project/router.py new file mode 100644 index 0000000..916f59c --- /dev/null +++ b/backend/app/modules/project/router.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/projects", tags=["project"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +def _require_no_pending_approval(resource_type: str, resource_id: str) -> None: + """第 4 周:写操作审批拦截——存在待审批实例时拒绝执行。""" + store = get_platform_store() + pending = [ + i for i in store.approval_instances(status="pending") + if i["resource_type"] == resource_type and i["resource_id"] == resource_id + ] + if pending: + raise fail(409, "存在待审批的变更,请先完成审批") + + +@router.get("") +def list_projects( + tenant_id: str = "default", + status: str | None = None, + keyword: str | None = None, +) -> dict[str, Any]: + return ok( + get_platform_store().projects( + tenant_id=tenant_id, status=status, keyword=keyword + ) + ) + + +@router.post("") +def create_project(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + proj = store.create_project(payload) + store.record_audit( + action="project.create", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=proj["id"], + tenant_id=proj.get("tenant_id"), + detail=f"name={proj.get('name')}", + ) + return ok(proj) + + +@router.get("/{project_id}") +def get_project(project_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().project(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.put("/{project_id}") +def update_project(project_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + proj = store.update_project(project_id, payload) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.update", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + tenant_id=proj.get("tenant_id"), + detail=f"fields={','.join(payload.keys())}", + ) + return ok(proj) + + +@router.post("/{project_id}/archive") +def archive_project(project_id: str, request: Request = None) -> dict[str, Any]: + _require_no_pending_approval("project", project_id) + store = get_platform_store() + try: + proj = store.archive_project(project_id) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.archive", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + tenant_id=proj.get("tenant_id"), + ) + return ok(proj) + + +@router.delete("/{project_id}") +def delete_project(project_id: str, request: Request = None) -> dict[str, Any]: + _require_no_pending_approval("project", project_id) + store = get_platform_store() + store.delete_project(project_id) + store.record_audit( + action="project.delete", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + ) + return ok(None) + + +@router.get("/{project_id}/members") +def list_members(project_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().project_members(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.post("/{project_id}/members") +def add_member(project_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + member = store.add_project_member(project_id, payload) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.member.add", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={payload.get('user_id')},role={payload.get('role')}", + ) + return ok(member) + + +@router.put("/{project_id}/members/{user_id}") +def update_member( + project_id: str, user_id: str, payload: dict[str, Any] = Body(...), request: Request = None +) -> dict[str, Any]: + store = get_platform_store() + try: + member = store.update_project_member_role(project_id, user_id, payload) + except KeyError: + raise fail(404, "project or member not found") + store.record_audit( + action="project.member.update", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={user_id},role={payload.get('role')}", + ) + return ok(member) + + +@router.delete("/{project_id}/members/{user_id}") +def remove_member(project_id: str, user_id: str, request: Request = None) -> dict[str, Any]: + store = get_platform_store() + store.remove_project_member(project_id, user_id) + store.record_audit( + action="project.member.remove", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={user_id}", + ) + return ok(None) diff --git a/backend/app/modules/resource/__init__.py b/backend/app/modules/resource/__init__.py new file mode 100644 index 0000000..60d70a6 --- /dev/null +++ b/backend/app/modules/resource/__init__.py @@ -0,0 +1 @@ +from app.modules.resource.router import router diff --git a/backend/app/modules/resource/router.py b/backend/app/modules/resource/router.py new file mode 100644 index 0000000..8cb5b0f --- /dev/null +++ b/backend/app/modules/resource/router.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/resources", tags=["resource"]) + + +@router.get("/{resource_type}/{resource_id}/acl") +def get_acl(resource_type: str, resource_id: str) -> dict[str, Any]: + return ok(get_platform_store().get_acl(resource_type, resource_id)) + + +@router.put("/{resource_type}/{resource_id}/acl") +def set_acl( + resource_type: str, resource_id: str, payload: dict[str, Any] = Body(...) +) -> dict[str, Any]: + return ok( + get_platform_store().set_acl( + resource_type, resource_id, payload.get("entries", []) + ) + ) diff --git a/backend/app/modules/retention/__init__.py b/backend/app/modules/retention/__init__.py new file mode 100644 index 0000000..22eeffc --- /dev/null +++ b/backend/app/modules/retention/__init__.py @@ -0,0 +1 @@ +"""Retention policy and cleanup module.""" diff --git a/backend/app/modules/system/__init__.py b/backend/app/modules/system/__init__.py new file mode 100644 index 0000000..bcf1cb1 --- /dev/null +++ b/backend/app/modules/system/__init__.py @@ -0,0 +1,3 @@ +from app.modules.system.router import router + +__all__ = ["router"] diff --git a/backend/app/modules/system/router.py b/backend/app/modules/system/router.py new file mode 100644 index 0000000..a95d144 --- /dev/null +++ b/backend/app/modules/system/router.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from fastapi import APIRouter, Query +from fastapi.responses import StreamingResponse + +from app.db.platform_store import ALL_PERMISSIONS, get_platform_store + + +router = APIRouter(prefix="/system", tags=["system"]) + + +@router.get("/permissions/codes") +def permission_codes() -> dict: + """返回平台权限码清单(权限码接口)。""" + return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}} + + +@router.get("/permissions") +def permissions_overview() -> dict: + """返回权限码清单与角色定义。""" + store = get_platform_store() + return { + "code": 0, + "message": "ok", + "data": {"codes": ALL_PERMISSIONS, "roles": store.roles()}, + } + + +@router.get("/audit-logs") +def audit_logs( + tenant_id: str | None = Query(default=None, description="租户 ID"), + project_id: str | None = Query(default=None, description="项目 ID"), + actor_id: str | None = Query(default=None, description="操作人 ID"), + action: str | None = Query(default=None, description="动作类型"), + target_type: str | None = Query(default=None, description="目标类型"), + start_time: str | None = Query(default=None, description="ISO8601 起始时间"), + end_time: str | None = Query(default=None, description="ISO8601 结束时间"), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> dict: + """审计日志查询:按租户/项目/操作人/动作/目标类型/时间范围分页过滤。""" + store = get_platform_store() + result = store.audit_logs( + tenant_id=tenant_id, + project_id=project_id, + actor_id=actor_id, + action=action, + target_type=target_type, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + return {"code": 0, "message": "ok", "data": result} + + +@router.get("/audit-logs/export") +def audit_logs_export( + tenant_id: str | None = Query(default=None, description="租户 ID"), + project_id: str | None = Query(default=None, description="项目 ID"), + actor_id: str | None = Query(default=None, description="操作人 ID"), + action: str | None = Query(default=None, description="动作类型"), + target_type: str | None = Query(default=None, description="目标类型"), + start_time: str | None = Query(default=None, description="ISO8601 起始时间"), + end_time: str | None = Query(default=None, description="ISO8601 结束时间"), +) -> StreamingResponse: + """审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。""" + store = get_platform_store() + result = store.audit_logs( + tenant_id=tenant_id, + project_id=project_id, + actor_id=actor_id, + action=action, + target_type=target_type, + start_time=start_time, + end_time=end_time, + limit=10000, + offset=0, + ) + items = result["items"] + columns = ["time", "tenant_id", "project_id", "actor_id", "action", "target_type", "target_id", "detail", "client_ip"] + header = ",".join(columns) + "\n" + + def iter_rows(): + yield header + for row in items: + yield ",".join(f'"{str(row.get(c, "") or "")}"' for c in columns) + "\n" + + return StreamingResponse( + iter_rows(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=audit_logs.csv"}, + ) diff --git a/backend/app/modules/tenant/__init__.py b/backend/app/modules/tenant/__init__.py new file mode 100644 index 0000000..5efd6d5 --- /dev/null +++ b/backend/app/modules/tenant/__init__.py @@ -0,0 +1 @@ +from app.modules.tenant.router import router diff --git a/backend/app/modules/tenant/router.py b/backend/app/modules/tenant/router.py new file mode 100644 index 0000000..50d9df3 --- /dev/null +++ b/backend/app/modules/tenant/router.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/tenants", tags=["tenant"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +@router.get("") +def list_tenants() -> dict[str, Any]: + return ok(get_platform_store().tenants()) + + +@router.post("") +def create_tenant(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.create_tenant(payload) + except KeyError as e: + raise fail(400, f"missing field: {e}") + store.record_audit( + action="tenant.create", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant["id"], + tenant_id=tenant["id"], + detail=f"name={tenant.get('name')}", + ) + return ok(tenant) + + +@router.get("/{tenant_id}") +def get_tenant(tenant_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().tenant(tenant_id)) + except KeyError: + raise fail(404, "tenant not found") + + +@router.put("/{tenant_id}") +def update_tenant(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.update_tenant(tenant_id, payload) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.update", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + detail=f"fields={','.join(payload.keys())}", + ) + return ok(tenant) + + +@router.put("/{tenant_id}/quota") +def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.set_tenant_quota(tenant_id, payload.get("quota", {})) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.quota.set", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + ) + return ok(tenant) + + +@router.put("/{tenant_id}/retention-policy") +def set_retention(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.set_tenant_retention(tenant_id, payload.get("retention_policy_id")) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.retention.set", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + ) + return ok(tenant) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8bd66a3 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Shared schemas package.""" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..84e9714 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Cross-module services package.""" diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..d4cb435 --- /dev/null +++ b/backend/app/workers/__init__.py @@ -0,0 +1 @@ +"""Background workers package.""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..042a8ab --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "yg-ft-backend" +version = "0.1.0" +description = "Backend service for the model fine-tuning platform" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.111.0", + "uvicorn[standard]>=0.30.0", + "python-multipart>=0.0.9", + "pydantic>=2.7.0", + "sqlalchemy>=2.0.30", + "psycopg[binary]>=3.2.1", + "alembic>=1.13.1", + "redis>=5.0.4", + "httpx>=0.27.0", + "PyJWT>=2.8.0", + "passlib[bcrypt]>=1.7.4", + "python-dotenv>=1.0.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2.0", + "ruff>=0.5.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6ef0933 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +python-multipart>=0.0.9 +pydantic>=2.7.0 +sqlalchemy>=2.0.30 +psycopg[binary]>=3.2.1 +alembic>=1.13.1 +redis>=5.0.4 +httpx>=0.27.0 +PyJWT>=2.8.0 +passlib[bcrypt]>=1.7.4 +python-dotenv>=1.0.1 diff --git a/backend/tests/test_auth_service.py b/backend/tests/test_auth_service.py new file mode 100644 index 0000000..fc15af8 --- /dev/null +++ b/backend/tests/test_auth_service.py @@ -0,0 +1,22 @@ +"""A 模块(平台基础与企业治理)第 1 周:鉴权与权限码基础测试。 + +不需要数据库连接,可直接运行: + cd backend && python -m pytest tests/test_auth_service.py -q +""" +from app.db.platform_store import ALL_PERMISSIONS +from app.modules.auth.service import create_access_token, decode_access_token + + +def test_token_roundtrip(): + token = create_access_token("u_abc") + assert decode_access_token(token) == "u_abc" + + +def test_decode_invalid_token_returns_none(): + assert decode_access_token("not.a.valid.token") is None + + +def test_permission_codes_present(): + for code in ("dashboard", "fine-tune", "model-manage", "user-settings", "logs"): + assert code in ALL_PERMISSIONS + assert len(ALL_PERMISSIONS) >= 12 diff --git a/backend/train.py b/backend/train.py new file mode 100644 index 0000000..19023c4 --- /dev/null +++ b/backend/train.py @@ -0,0 +1,27 @@ +# Copyright 2025 the LlamaFactory team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from llamafactory.train.tuner import run_exp + +def main(): + run_exp() + + +def _mp_fn(index): + # For xla_spawn (TPUs) + run_exp() + + +if __name__ == "__main__": + main() diff --git a/compute/README.md b/compute/README.md new file mode 100644 index 0000000..ac094ea --- /dev/null +++ b/compute/README.md @@ -0,0 +1,29 @@ +# Compute Platform + +算力平台与应用平台分开部署,本目录用于后续实现单机多 GPU 调度、文件网关和训练引擎适配。 + +## 目录结构 + +```text +compute/ + api/ # 只允许应用平台访问的内部 Compute API + agent/ # 单机 Agent,负责 GPU、进程、工作区管理 + engines/ + llama_factory/ # LLaMA-Factory 训练引擎适配器 + file_gateway/ # 本地磁盘上传、下载、预览、离线导入 + tests/ +``` + +## 开发职责 + +- GPU 发现、状态上报、锁定和释放。 +- 本地磁盘工作区管理。 +- 创建、停止、查询训练/评测/推理/合并任务。 +- LLaMA-Factory 命令生成、日志解析、产物收集。 +- 分片上传、短时下载、离线导入。 +- 通过服务间 token 接受应用平台调用。 + +## 运行模式 + +- 默认 `COMPUTE_EXECUTION_MODE=real`,Compute API 只暴露健康检查和接口契约;真实训练执行器完成前,创建作业会返回未实现错误。 +- 仅隔离联调时可设置 `COMPUTE_EXECUTION_MODE=simulator`,启用内存状态机和合成 GPU/日志数据。该模式不得作为生产运行路径。 diff --git a/compute/__init__.py b/compute/__init__.py new file mode 100644 index 0000000..fe51931 --- /dev/null +++ b/compute/__init__.py @@ -0,0 +1 @@ +"""Compute platform package.""" diff --git a/compute/agent/__init__.py b/compute/agent/__init__.py new file mode 100644 index 0000000..3f8fd74 --- /dev/null +++ b/compute/agent/__init__.py @@ -0,0 +1 @@ +"""Compute agent package.""" diff --git a/compute/api/__init__.py b/compute/api/__init__.py new file mode 100644 index 0000000..4a6735a --- /dev/null +++ b/compute/api/__init__.py @@ -0,0 +1 @@ +"""Compute API package.""" diff --git a/compute/api/main.py b/compute/api/main.py new file mode 100644 index 0000000..b90201a --- /dev/null +++ b/compute/api/main.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import math +import time +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException + +from compute.engines.llama_factory.adapter import build_command, parse_log_line + + +def create_app() -> FastAPI: + app = FastAPI(title="YG Fine-Tune Compute API") + jobs: dict[str, dict[str, Any]] = {} + route_prefix = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF").rstrip("/") or "/modelTF" + + def now() -> float: + return time.time() + + def host_id() -> str: + return os.getenv("COMPUTE_HOST_ID", "gpu-node-01") + + def execution_mode() -> str: + return os.getenv("COMPUTE_EXECUTION_MODE", os.getenv("COMPUTE_MODE", "real")).lower() + + def job_status(job: dict[str, Any]) -> dict[str, Any]: + if execution_mode() != "simulator": + return job + elapsed = max(0, int(now() - job["created_at"])) + if job["status"] not in {"stopped", "failed", "completed"}: + if elapsed < 5: + job["status"] = "queued" + job["progress"] = 12 + elapsed * 3 + elif elapsed < 60: + job["status"] = "running" + job["progress"] = min(96, 25 + int((elapsed - 5) / 55 * 70)) + else: + job["status"] = "completed" + job["progress"] = 100 + job["logs"] = generate_logs(job) + return job + + def generate_logs(job: dict[str, Any]) -> str: + progress = int(job.get("progress", 0) or 0) + points = max(1, min(80, progress)) + lines = [ + f"[INFO] compute_host_id={host_id()} job_id={job['id']} engine=llama_factory", + f"[INFO] command={' '.join(job['command'])}", + ] + for step in range(1, points + 1): + if step % 4 != 0 and step != points: + continue + loss = max(0.11, 2.5 * math.exp(-step / 40)) + grad_norm = 0.4 + (step % 5) * 0.04 + lr = 0.0002 * max(0.05, 1 - step / 100) + epoch = round(step / points * 3, 4) + lines.append( + "{" + f"'loss': {loss:.4f}, 'grad_norm': {grad_norm:.4f}, " + f"'learning_rate': {lr:.8f}, 'epoch': {epoch:.4f}" + "}" + ) + if job.get("status") == "completed": + lines.extend( + [ + "***** train metrics *****", + "epoch = 3", + "train_loss = 0.1181", + "train_runtime = 1m 0s", + "***** train metrics end *****", + ] + ) + return "\n".join(lines) + + def gpu_resources() -> list[dict[str, Any]]: + if execution_mode() != "simulator": + return [] + active_jobs = [job_status(job) for job in jobs.values() if job["status"] in {"queued", "running"}] + gpus: list[dict[str, Any]] = [] + for idx in range(4): + task = next((job for job in active_jobs if idx in job.get("gpus", [])), None) + busy = task is not None and task["status"] == "running" + reserved = task is not None and task["status"] == "queued" + gpus.append( + { + "id": idx, + "uuid": f"GPU-{host_id().upper()}-{idx}", + "name": os.getenv("COMPUTE_GPU_NAME", "NVIDIA A800-SXM4-80GB"), + "status": "busy" if busy else "reserved" if reserved else "idle", + "gpu_percent": 88 if busy else 25 if reserved else 4, + "memory_used_gb": 58 if busy else 12 if reserved else 2, + "memory_total_gb": 80, + "temperature": 61 if busy else 45 if reserved else 36, + "power_w": 215 if busy else 80 if reserved else 25, + "power_limit_w": 300, + "processes": [ + { + "pid": task["pid"], + "name": "llamafactory-cli", + "task_name": task["name"], + "memory_used_gb": 58 if busy else 12, + } + ] + if task + else [], + } + ) + return gpus + + @app.get(f"{route_prefix}/health") + async def health_check() -> dict[str, str]: + return { + "status": "ok", + "compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"), + } + + @app.get(f"{route_prefix}/v1/compute/health") + async def compute_health_check() -> dict[str, str | bool]: + data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")) + llama_factory_home = Path(os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory")) + return { + "status": "ok", + "compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"), + "app_callback_enabled": os.getenv("ENABLE_APP_CALLBACK", "false").lower() == "true", + "data_root": str(data_root), + "data_root_exists": data_root.exists(), + "llama_factory_home": str(llama_factory_home), + "llama_factory_home_exists": llama_factory_home.exists(), + "execution_mode": execution_mode(), + } + + @app.get(f"{route_prefix}/v1/compute/jobs") + async def list_jobs_alias() -> dict[str, list[dict[str, Any]]]: + return {"items": [job_status(job) for job in jobs.values()]} + + @app.get(f"{route_prefix}/compute/resources/gpus") + async def list_gpus() -> dict[str, Any]: + return {"items": gpu_resources(), "compute_host_id": host_id()} + + @app.post(f"{route_prefix}/compute/jobs") + async def create_job(payload: dict[str, Any]) -> dict[str, Any]: + try: + command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if execution_mode() != "simulator": + raise HTTPException( + status_code=501, + detail="real compute executor is not implemented yet; set COMPUTE_EXECUTION_MODE=simulator only for isolated development", + ) + job_id = str(payload.get("id") or f"job_{int(now() * 1000)}") + job = { + "id": job_id, + "name": payload.get("name", job_id), + "status": "queued", + "progress": 10, + "pid": int(52000 + now() % 10000), + "gpus": payload.get("gpus") or [0], + "created_at": now(), + "command": command.command, + "work_dir": command.work_dir, + "artifacts": [], + "logs": "", + } + jobs[job_id] = job + return job_status(job) + + @app.get(f"{route_prefix}/compute/jobs") + async def list_jobs() -> dict[str, Any]: + return {"items": [job_status(job) for job in jobs.values()]} + + @app.get(f"{route_prefix}/compute/jobs/{{job_id}}") + async def get_job(job_id: str) -> dict[str, Any]: + job = jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="job not found") + return job_status(job) + + @app.post(f"{route_prefix}/compute/jobs/{{job_id}}/stop") + async def stop_job(job_id: str) -> dict[str, Any]: + job = jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="job not found") + job["status"] = "stopped" + job["progress"] = min(job.get("progress", 0), 99) + return job + + @app.get(f"{route_prefix}/compute/jobs/{{job_id}}/logs") + async def job_logs(job_id: str) -> dict[str, Any]: + job = jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="job not found") + job = job_status(job) + metrics = [parse_log_line(line) for line in job["logs"].splitlines()] + return {"job_id": job_id, "content": job["logs"], "metrics": [m for m in metrics if m]} + + @app.post(f"{route_prefix}/compute/files/upload") + async def upload_file(payload: dict[str, Any]) -> dict[str, Any]: + file_id = str(payload.get("id") or f"file_{int(now() * 1000)}") + return {"id": file_id, "status": "available", "local_path": f"/data/yg-ft/uploads/{file_id}"} + + @app.get(f"{route_prefix}/compute/files/{{file_id}}/download") + async def download_file(file_id: str) -> dict[str, Any]: + return {"id": file_id, "status": "ready", "download_url": f"{route_prefix}/compute/files/{file_id}/download"} + + return app + + +app = create_app() diff --git a/compute/engines/__init__.py b/compute/engines/__init__.py new file mode 100644 index 0000000..adb1576 --- /dev/null +++ b/compute/engines/__init__.py @@ -0,0 +1 @@ +"""Training engine adapters package.""" diff --git a/compute/engines/llama_factory/__init__.py b/compute/engines/llama_factory/__init__.py new file mode 100644 index 0000000..eac03ba --- /dev/null +++ b/compute/engines/llama_factory/__init__.py @@ -0,0 +1 @@ +"""LLaMA-Factory engine adapter package.""" diff --git a/compute/engines/llama_factory/adapter.py b/compute/engines/llama_factory/adapter.py new file mode 100644 index 0000000..7e5fc17 --- /dev/null +++ b/compute/engines/llama_factory/adapter.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class LlamaFactoryCommand: + command: list[str] + work_dir: str + env: dict[str, str] + + +def validate_config(config: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if not config.get("base_model") and not config.get("model_name_or_path"): + errors.append("base_model or model_name_or_path is required") + if not config.get("dataset") and not config.get("dataset_dir"): + errors.append("dataset or dataset_dir is required") + learning_rate = float(config.get("learning_rate", 0.0002)) + if learning_rate <= 0: + errors.append("learning_rate must be greater than zero") + epochs = int(config.get("n_epochs", config.get("num_train_epochs", 1))) + if epochs <= 0: + errors.append("n_epochs must be greater than zero") + return errors + + +def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-Factory") -> LlamaFactoryCommand: + errors = validate_config(config) + if errors: + raise ValueError("; ".join(errors)) + + model_path = config.get("base_model") or config.get("model_name_or_path") + dataset = config.get("dataset") or config.get("dataset_dir") + output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-job')}" + command = [ + "llamafactory-cli", + "train", + "--stage", + str(config.get("stage", "sft")).lower(), + "--do_train", + "true", + "--model_name_or_path", + str(model_path), + "--dataset", + str(dataset), + "--template", + str(config.get("template", "qwen")), + "--finetuning_type", + str(config.get("train_method", config.get("finetuning_type", "lora"))), + "--output_dir", + str(output_dir), + "--per_device_train_batch_size", + str(config.get("batch_size", 2)), + "--learning_rate", + str(config.get("learning_rate", 0.0002)), + "--num_train_epochs", + str(config.get("n_epochs", 3)), + "--save_steps", + str(config.get("save_steps", 50)), + ] + quantization_bit = int(config.get("quantization_bit", 0) or 0) + if quantization_bit in {4, 8}: + command.extend(["--quantization_bit", str(quantization_bit)]) + return LlamaFactoryCommand(command=command, work_dir=str(Path(llama_factory_home)), env={}) + + +def parse_log_line(line: str) -> dict[str, float] | None: + if "loss" not in line or "learning_rate" not in line: + return None + result: dict[str, float] = {} + for key in ["loss", "grad_norm", "learning_rate", "epoch"]: + match = re.search(rf"['\"]?{key}['\"]?\s*:\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)", line) + if match: + result[key] = float(match.group(1)) + return result or None + diff --git a/compute/file_gateway/__init__.py b/compute/file_gateway/__init__.py new file mode 100644 index 0000000..e781d5e --- /dev/null +++ b/compute/file_gateway/__init__.py @@ -0,0 +1 @@ +"""Local file gateway package.""" diff --git a/compute/requirements.txt b/compute/requirements.txt new file mode 100644 index 0000000..922f92e --- /dev/null +++ b/compute/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.7.0 +python-dotenv>=1.0.1 +httpx>=0.27.0 diff --git a/compute/tests/__init__.py b/compute/tests/__init__.py new file mode 100644 index 0000000..a95c0bf --- /dev/null +++ b/compute/tests/__init__.py @@ -0,0 +1 @@ +"""Compute platform tests package.""" diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..381eee6 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,429 @@ +# Training Log Detail Design QA + +## Evidence + +- Source visual truth: `docs/superpowers/specs/assets/training-log-detail-option-2.png` +- Implementation screenshot: `docs/superpowers/specs/assets/training-log-detail-final-expanded-1440.png` +- Collapsed implementation screenshot with global surface: `docs/superpowers/specs/assets/training-log-detail-global-surface-1440-v2.png` +- Normalized full-view comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png` +- Focused parameter comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png` +- White-canvas reference: `docs/superpowers/specs/assets/page-white-canvas-reference.png` +- White-canvas implementation: `docs/superpowers/specs/assets/model-edit-white-page-canvas-final-1440.png` +- White-canvas normalized comparison: `docs/superpowers/specs/assets/page-white-canvas-comparison.png` +- Training-log white-canvas screenshot: `docs/superpowers/specs/assets/training-log-detail-white-page-canvas-1440.png` +- Self-surface list screenshot: `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png` +- Default-canvas detail screenshot: `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png` +- Reference/detail comparison: `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png` +- Create-page duplicate-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png` +- Create-page single-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png` +- Route-transition flash reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png` +- Route-transition final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png` +- Viewport: 1440 × 1024; comparison content normalized to 1200 × 800 after removing the existing 240px sidebar and 60px header from the implementation capture. +- State: `finance-sft-001`, completed, mock data loaded, training parameters expanded. + +## Full-view comparison + +The implementation preserves the selected two-column hierarchy: task and dataset information occupy the wide left track, runtime facts use the narrow right rail, and training parameters continue as a full-width disclosure section. The selected mock omitted the product shell, so the comparison intentionally crops the existing sidebar and header rather than treating them as design drift. + +The global product mode now uses two intentional surface modes. Form and detail routes render inside one `#ffffff` page canvas with 16px radius and 24px content padding. List routes that already own a white table/card surface render that surface directly on the `#f3f5f8` application background, avoiding a redundant white layer. + +## Required fidelity surfaces + +- Fonts and typography: Existing system font stack is preserved. Heading, label, value, and muted-copy hierarchy match the selected direction; output model uses body-level contrast after iteration 1, and all “未配置” values use `#64748b` on white after iteration 2. +- Spacing and layout rhythm: 24px main gap, 16px section gap, 12px surface radius, and light row separators match the selected composition. The existing application shell reduces usable content width, but normalized proportions remain aligned. +- Colors and tokens: Indigo accent, Slate text, success status, `#f3f5f8` page background, and white content surfaces are consistent with the current product. +- Image and icon fidelity: The screen contains no raster imagery. Existing Font Awesome icons are retained to match the repository's icon system; no placeholder, emoji, CSS drawing, or handcrafted SVG was introduced. +- Copy and content: Task name, status, model, date, duration, dataset metadata, storage, SFT, LoRA, and missing-value copy match the selected design and actual mock data. + +## Interaction and responsive checks + +- Parameter disclosure changed from `aria-expanded="false"` to `true` after activation, and the expanded content became visible. +- At 1000px viewport width, the overview changed to one column and the document had no horizontal overflow. +- At 700px viewport width, dataset metrics and parameter rows changed to one column and the document had no horizontal overflow. +- Browser console: no errors. One existing Element Plus `el-link` underline deprecation warning was emitted by the login flow and is unrelated to this page. + +## Comparison history + +### Iteration 1 — blocked + +- [P2] The implementation added a visible “基础训练参数” heading that did not exist in the selected mock, creating extra vertical space. +- [P2] “暂未生成” was styled too faintly compared with the selected design. + +Fixes: + +- Removed the redundant visible base-parameter heading while retaining an accessible region label. +- Restored body-level contrast for “暂未生成”. + +Post-fix evidence: + +- `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png` +- `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png` + +### Iteration 2 — blocked + +- [P2] “未配置” values used `#94a3b8` on white, below WCAG AA contrast for 14px text. + +Fix: + +- Updated muted values to `#64748b`; the regression check now calculates the contrast ratio and requires at least 4.5:1. + +Post-fix evidence: + +- Browser computed color: `rgb(100, 116, 139)`. +- Browser console: no errors. + +### Iteration 3 — passed + +No actionable P0/P1/P2 differences remain. The retained P3 difference is that the generated mock does not include the real product sidebar/header; this is an intentional constraint because the existing shell is shared by every page. + +### Iteration 4 — clarified global page canvas, passed + +- [P1] The earlier interpretation left the route content directly on the gray layout background and only made individual cards white. The clarified reference requires a single white page canvas behind every route. + +Fixes: + +- Split the shell and page tokens into `--app-shell-bg: #f3f5f8` and `--app-page-bg: #ffffff`. +- Added one global `.page-canvas` around every route in `MainLayout.vue`. +- Added 16px outer gutter, 16px canvas radius, 24px canvas padding, and a subtle canvas shadow. +- Flattened a route-root `PageCard` to prevent a duplicate large card layer. + +Post-fix evidence: + +- `docs/superpowers/specs/assets/page-white-canvas-comparison.png` +- Browser computed canvas: white background, 16px radius, 24px padding; outer shell: `rgb(243, 245, 248)`. +- Both the model-edit page and training-log page render inside the same global white canvas without horizontal overflow. + +### Iteration 5 — corrected list-page surface ownership, passed + +- [P1] Applying the white page canvas to every route created a redundant layer on list pages because `DataTablePage`, model evaluation, and model management already provide their own white root card. + +Fixes: + +- Added explicit `pageSurface: 'self'` metadata to each self-surfaced list route: model tuning, model evaluation, model inference, model management, data processing, and dataset management. +- Added `.page-canvas.is-self-surface` to remove the outer canvas padding, radius, background, and shadow only for those routes. +- Preserved the default white canvas for training-log, create, edit, preview, chat, and result routes. + +Post-fix evidence: + +- `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png` +- `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png` +- `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png` +- Browser computed list state: transparent outer canvas, 0px padding/radius, no shadow; white 12px-radius list card on `rgb(243, 245, 248)` shell. +- Browser computed detail state: white outer canvas, 24px padding, 16px radius, subtle shadow. +- Both states have no horizontal overflow and no console errors at 1440 × 900. + +### Iteration 6 — flattened wrapped root PageCard, passed + +- [P1] The training-task creation route wraps its root `PageCard` in `.fine-tune-create`. The earlier selector only matched a `PageCard` directly under `.page-canvas`, so this page retained a second white background, 12px radius, and card shadow. + +Fixes: + +- Added an explicit `.page-card-host` marker to the training-task creation route root; the layout flattens only a directly rendered root `PageCard` or a `PageCard` inside that explicit host. +- Root `PageCard` now uses a transparent background, 0px radius, no shadow, and no bottom margin while preserving its header/body layout. +- Kept the selector excluded from `.is-self-surface`, so list cards retain their own white background, 12px radius, and shadow. +- Rejected a generic one-level descendant selector because it would also match the training-log parameter card. + +Post-fix evidence: + +- `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png` +- `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png` +- Browser computed create-page root card: transparent background, 0px radius, no shadow; outer canvas remains white with 24px padding. +- Browser computed list-page card remains white with 12px radius and subtle shadow on a transparent outer canvas. +- Browser computed training-log parameter card remains white with 12px radius and subtle shadow, confirming that internal business cards are not flattened. +- Both pages have no horizontal overflow; create-page console has no errors. + +### Iteration 7 — removed page-level opacity transition, passed + +- [P1] When navigating from a self-surface list to a default-canvas secondary page, `route.meta.pageSurface` changed immediately while the old list remained for the 150ms `out-in` leave animation. The result was a semi-transparent old list rendered inside the new white canvas. + +Fixes: + +- Removed the page-level Vue `transition` wrapper from `MainLayout.vue`. +- Removed the `.fade-enter-*` and `.fade-leave-*` opacity rules. +- Preserved local component animations such as dialogs, disclosures, and the selected-row batch bar. + +Post-fix evidence: + +- Source flash frame: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png`. +- Final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png`. +- Immediate state after list → create: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas. +- Immediate state after create → list: old create page absent, route-root opacity `1`, zero `.fade-*` transition elements, correct self-surface canvas. +- Immediate state after list → training log: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas. +- All three paths had no horizontal overflow; browser console had no errors. + +## Build evidence gap + +The `type-check` script now uses project-reference mode (`vue-tsc -b --noEmit`) so it no longer reports a false pass. `npm run type-check` and `npm run build` remain blocked by pre-existing TypeScript errors in `src/mock/adapter.ts`, `FineTuneCreateView.vue`, and `FineTuneListView.vue`; no remaining error points to `TrainingLogView.vue` or the page-surface files. `npx vite build` succeeds, proving the updated UI bundles for production. + +Design-QA final result: passed + +final result: passed + +--- + +# Service Dashboard Design QA + +## Evidence + +- Source visual truth: `docs/superpowers/specs/assets/service-dashboard-approved-1440.png` +- Browser-rendered implementation: `docs/superpowers/specs/assets/service-dashboard-implementation-1440.png` +- Viewport: 1440 × 1024 +- State: authenticated `admin` user on `/dashboard`; service dashboard navigation active; 7-day chart visible; four training tasks visible. + +## Full-view comparison + +The implementation preserves the approved composition: the product shell stays intact, the service dashboard is the active navigation item, the platform-health summary spans the top, the grouped training chart occupies the wide middle track, service health occupies the narrow track, and the training-task table spans the bottom. The three bar series, dual axes, dates, values, service counts, task names, statuses, progress, accuracy, and actions match the selected mock. + +A separate focused crop was not required because both source and implementation evidence are full-resolution desktop captures at a readable scale; the chart labels, axis units, service rows, and every task-table column are legible in the full-view comparison. + +## Required fidelity surfaces + +- Fonts and typography: the existing Inter/system/PingFang stack is retained. Heading, section title, metric, table header, and muted-copy weights and sizes match the selected direction. +- Spacing and layout rhythm: 24px page padding, 14px section gaps, 10px panel radii, light separators, and the wide-chart/narrow-status grid preserve the selected hierarchy. The implementation uses the repository's 240px sidebar and 60px header exactly. +- Colors and visual tokens: white page canvas, `#f3f5f8` shell, indigo `#4f46e5`, green `#10b981`, amber `#f59e0b`, red `#ef4444`, and slate text are aligned with the source and current product tokens. +- Image and icon fidelity: the page contains no decorative raster imagery. The supplied product logo is preserved and existing Font Awesome icons are used consistently; no emoji, handcrafted SVG, placeholder image, or CSS illustration was introduced. +- Copy and content: dashboard title, health summary, chart legend and units, service states, task names, task status, model names, progress, accuracy, timestamps, and action labels match the approved design. + +## Interaction and runtime checks + +- Login with the existing `admin` credentials navigated to `/dashboard`, confirming the requested default entry behavior. +- ECharts rendered one canvas; hovering 07/10 exposed the tooltip values: training count 18, GPU count 7, and average accuracy 91%. +- “查看全部任务” navigated to `/fine-tune` and browser back restored `/dashboard`. +- The first “查看详情” action navigated to `/training-log/103942` and browser back restored `/dashboard`. +- Browser console errors: none. +- `npm run test:default-dashboard`: passed. +- `npm run test:dashboard`: passed. +- `npx vite build`: passed. + +## Comparison history + +### Iteration 1 — passed + +No actionable P0/P1/P2 differences remain. The only intentional product constraint is that the sidebar active background follows the repository's current neutral active token instead of the slightly bluer tint produced by ImageGen; location, contrast, label, and active-state clarity remain equivalent. + +## Validation gap + +`npm run type-check` remains blocked by pre-existing TypeScript errors in the mock adapter, dataset mock typing, data-process list, evaluation tabs, and fine-tune views. No reported error points to `DashboardView.vue`, the ECharts registration, router defaults, login redirect, or dashboard regression scripts. The direct Vite production build succeeds. + +Design-QA final result: passed + +final result: passed + +## Compact dashboard revision + +- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-43f63327-063f-47da-9e96-31b53cebc49d.png` +- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-compact-1440.png` +- Viewport: 1440 × 1024 +- State: authenticated dashboard, compact layout, redundant title/action row removed. + +### Iteration 2 — passed + +The annotated header row containing the duplicate “服务看板” title, subtitle, and “查看告警” action was removed entirely. Section gaps, overview height, health icon, metric type, chart height, service rows, task heading, and task rows were reduced by roughly 10%–15%. The result preserves chart labels, dual-axis readability, service-state text, task progress, accuracy, and all task actions while bringing the primary content closer to the top of the page. + +- ECharts tooltip remains functional after the height reduction and reports all three 07/10 series values. +- The revised page contains no browser console errors. +- The full-resolution comparison makes the removed annotation target and the compact replacement legible; no focused crop is necessary. +- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build` pass. + +Design-QA final result: passed + +final result: passed + +## One-screen dashboard revision + +- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-3c166ffc-7243-4e57-94fc-48949599c4f1.png` +- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-one-screen-1440x768.png` +- Viewport: 1440 × 768 +- State: authenticated dashboard with the desktop low-height compact rules active. + +### Iteration 3 — passed + +The platform-status block was reduced again, including its container padding, inner gap, health icon, status copy, metric labels, and metric values. The chart, service rows, task rows, and page-canvas padding now use a dedicated `max-height: 900px` desktop mode. The dashboard page canvas is constrained to the available application viewport so the outer content area does not introduce a vertical scrollbar. + +Browser measurements at 1440 × 768: + +- document overflow: false +- layout-content overflow: false +- page-canvas overflow: false +- dashboard overflow: false +- task section bottom: 653px within the 768px viewport +- ECharts tooltip: passed with all three series present +- browser console errors: none +- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed + +Design-QA final result: passed + +final result: passed + +## Flexible middle-region revision + +- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-daf2bbae-baea-418d-9962-7d0e1a1c219b.png` +- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-flex-middle-1440x900.png` +- Viewport: 1440 × 900 +- State: authenticated dashboard with flexible middle-region growth. + +### Iteration 4 — passed + +The previous fixed-height middle row caused unused white space beneath the task table on taller screens. The dashboard now reserves compact intrinsic height for the platform summary and task table while allowing the chart/service row to consume all remaining viewport height. The ECharts canvas grows with that row, and the service-state rows distribute across the matching height. + +Browser measurements: + +- at 1440 × 768, chart height: 283px; no document, layout, canvas, or dashboard overflow +- at 1440 × 900, chart height: 415px; no document, layout, canvas, or dashboard overflow +- task table bottom at 1440 × 900: 868px within the 900px viewport +- ECharts tooltip: passed with all three series present +- browser console errors: none +- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed + +Design-QA final result: passed + +final result: passed + +## Narrow service-status revision + +- User request: make the right-hand service-status panel slightly narrower. +- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-narrow-service-1440x900.png` +- Viewports: 1440 × 900 and 1440 × 768 +- State: authenticated dashboard with the service-status column reduced to approximately 30% of the middle row. + +### Iteration 5 — passed + +The middle grid now allocates `1.9fr` to the training chart and `0.82fr` to service status, with a 300px minimum width for the service panel. This gives the chart more horizontal space while keeping all service names, status badges, and instance counts fully visible. + +Browser measurements: + +- at 1440 × 900, chart width: 799px; service width: 345px; service share: 30.1% +- at 1440 × 768, chart width: 799px; service width: 345px +- clipped service cells: none at both tested viewports +- horizontal and vertical document overflow: none at both tested viewports +- browser console errors: none +- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed + +Design-QA final result: passed + +final result: passed + +## Taller training-task revision + +- User request: increase the training-task region slightly and shorten the middle chart/service region. +- Source visual truth: `docs/superpowers/specs/assets/service-dashboard-narrow-service-1440x900.png` plus the current user annotation. +- Intended viewports: 1440 × 900 and 1440 × 768. +- State: authenticated dashboard with larger task heading and table rows. + +### Iteration 6 — blocked + +The task section now uses a taller heading and table rows in both standard and low-height desktop modes. Because the middle row is the only flexible region, the additional task height is taken directly from the chart/service row while preserving the one-screen layout contract in code. + +Verification evidence: + +- `npm run test:dashboard`: passed +- `npm run test:default-dashboard`: passed +- `npx vite build`: passed +- browser-rendered comparison: blocked because the in-app browser rejected the local preview URL under its URL security policy +- implementation screenshot: unavailable for this iteration + +Design-QA final result: blocked + +final result: blocked + +--- + +# Dataset Version Actions Design QA + +## Evidence + +- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5e2e-3bee-77f0-b285-89ef139db56c/exec-48a163b6-d4c9-4646-9199-135957c6e72e.png` +- Historical-version implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-historical-version-final.png` +- Delete-confirm implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-delete-confirm.png` +- Full-view comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-full.png` +- Focused version-control comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-version-controls-final.png` +- Viewport: 1316 × 768 browser window; source crop normalized for the focused comparison. +- State: authenticated dataset detail, V3 current, V2 selected as a read-only historical version. + +## Full-view comparison + +The existing product shell, dataset summary, version selector, read-only alert, file toolbar, and sample table remain unchanged. The former standalone primary action has been replaced by one compact rounded-square overflow button at the far right of the version-control row, matching the selected hierarchy. + +## Focused comparison and required fidelity surfaces + +- Fonts and typography: existing system/PingFang stack, 13px labels, 12px metadata, and Element Plus menu text are preserved. +- Spacing and layout rhythm: the 40px overflow trigger aligns with the version selector and leaves the central status copy flexible; the 168px menu provides 40px action rows. +- Colors and visual tokens: the existing indigo primary token is used for the activate icon; the delete item and confirmation action use the danger token. +- Image and icon fidelity: no new raster assets are needed. Existing Font Awesome ellipsis, check-circle, and trash icons match the repository's icon system. +- Copy and content: the menu contains exactly “设为当前版本” and “删除版本”, separated visually; the confirmation names V2 and explains that current V3 is unaffected. + +## Interaction checks + +- Created V2 and V3 through the real edit-and-save flow, then switched from current V3 to historical V2. +- Historical records became read-only and the overflow trigger appeared; current V3 showed no history-operation trigger. +- Opening the trigger exposed exactly two accessible menu items: “设为当前版本” and “删除版本”. +- Choosing “删除版本” opened the danger confirmation dialog; cancelling returned focus without deleting data. +- Actual deletion behavior, protected-version rejection, optimistic-lock handling, and non-reused version numbers are covered by `test:dataset-preview`. + +## Comparison history + +### Iteration 1 — blocked + +- [P2] The overflow trigger was circular while the selected mock used a small rounded square. +- [P2] The menu did not explicitly lock its target width or primary-action icon color. + +Fixes: + +- Replaced the circular trigger with a 40px square and 10px radius. +- Set the menu minimum width to 168px, action height to 40px, and the activate icon to the product primary color. + +### Iteration 2 — passed + +No actionable P0/P1/P2 differences remain. The desktop capture API does not retain the transient popup layer in screenshots, so the open-menu labels were additionally verified through the accessibility tree; exact popup shadow rendering remains a non-blocking P3 capture gap. + +final result: passed + +--- + +# Login Page Responsive Design QA + +## Evidence + +- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5f3f-f7e1-7e41-9605-ea307d9f09e6/exec-d376c603-0c47-45ad-86fd-3e99c1a95ec7.png` +- Implementation route: `http://localhost:6801/login` +- Implementation screenshot: unavailable because the in-app browser runtime could not initialize in this session. +- Intended desktop viewport: 1536 × 1024. +- Intended laptop viewports: 1366 × 768 and 1280 × 720. +- State: unauthenticated login page, default username and password populated. + +## Static and automated evidence + +- Added a 1440px width breakpoint that shifts the split from 58/42 to 55/45 and caps the form at 440px. +- Added a short-screen breakpoint for heights up to 820px that reduces title, form, input, footer, and panel spacing without hiding the left visual. +- Kept the single-column fallback at 900px and below. +- `regression-login-layout.mjs`: passed. +- `regression-default-dashboard.mjs`: passed. +- `vue-tsc -b --noEmit`: passed. +- Vite dev transform for `LoginView.vue`: HTTP 200. +- Production build: blocked by the pre-existing missing route component `UserPermissionView.vue`, outside the login-page change. + +## Required fidelity surfaces + +- Fonts and typography: code uses the existing product font stack with laptop-specific display-size reductions; visual comparison remains unavailable. +- Spacing and layout rhythm: dedicated width and height media queries are present; rendered measurements remain unavailable. +- Colors and visual tokens: existing indigo tokens and the selected dark-purple visual asset are preserved. +- Image quality and asset fidelity: the generated `login-hero-flow.png` is used directly; the official `logo.png` is reused for the brand lockup. +- Copy and content: platform title, supporting copy, form labels, actions, and footer match the selected design. + +## Findings + +- [P2] Browser-rendered laptop comparison unavailable + Location: login page at 1366 × 768 and 1280 × 720. + Evidence: the in-app browser runtime failed during initialization, so no implementation screenshot or side-by-side comparison could be captured. + Impact: static checks cannot prove that all visible spacing and crop details match the selected design at laptop sizes. + Fix: capture both laptop viewports in a working in-app browser session, compare them with the source, and resolve any remaining P0/P1/P2 differences. + +## Comparison history + +### Iteration 1 — blocked + +- User reported that the initial implementation was optimized for large displays and did not compose well on laptop screens. +- Added explicit laptop-width and short-screen layout rules and passed targeted regression/type checks. +- Post-fix visual evidence remains unavailable because browser capture is blocked. + +final result: blocked diff --git a/devserver.err b/devserver.err new file mode 100644 index 0000000..24c3cf0 --- /dev/null +++ b/devserver.err @@ -0,0 +1,47 @@ +Traceback (most recent call last): + File "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/__main__.py", line 4, in + uvicorn.main() + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/click/core.py", line 1569, in __call__ + return self.main(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/click/core.py", line 1490, in main + rv = self.invoke(ctx) + ^^^^^^^^^^^^^^^^ + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/click/core.py", line 1353, in invoke + return ctx.invoke(self.callback, **ctx.params) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/click/core.py", line 907, in invoke + return callback(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/main.py", line 440, in main + run( + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/main.py", line 609, in run + config.load_app() + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/config.py", line 427, in load_app + return import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/importer.py", line 22, in import_from_string + raise exc from None + File "/mnt/e/yg_ft/projects/backend/venv/lib/python3.12/site-packages/uvicorn/importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/lib/python3.12/importlib/__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1387, in _gcd_import + File "", line 1360, in _find_and_load + File "", line 1331, in _find_and_load_unlocked + File "", line 935, in _load_unlocked + File "", line 995, in exec_module + File "", line 488, in _call_with_frames_removed + File "/mnt/e/yg_ft/backend/app/main.py", line 4, in + from app.api.v1.router import api_router + File "/mnt/e/yg_ft/backend/app/api/v1/router.py", line 3, in + from app.api.v1.endpoints.platform import router as platform_router + File "/mnt/e/yg_ft/backend/app/api/v1/endpoints/platform.py", line 10, in + from app.db.platform_store import get_platform_store + File "/mnt/e/yg_ft/backend/app/db/platform_store.py", line 15, in + import psycopg +ModuleNotFoundError: No module named 'psycopg' diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..f41ba59 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,267 @@ +# Docker 部署说明 + +本目录按应用服务器和算力服务器拆分 Dockerfile 与 Docker Compose 文件。Compose 文件不包含 `build:`,不会在 `docker compose up` 时自动构建业务镜像。所有业务镜像需要先通过手动 `docker build` 构建,再由 Compose 启动。 + +## 基础镜像 + +| 镜像 | 用途 | +| --- | --- | +| `python:3.12-slim` | 应用后端基础镜像,后端运行环境要求 Python 3.12 及以上 | +| `nginx:1.27-alpine` | 前端静态资源与 `/modelTF` 反向代理运行镜像 | +| `hiyouga/llamafactory:latest` | 算力服务基础镜像,基于 LLaMA-Factory 官方镜像扩展 Compute API | +| `postgres:16-alpine` | 开发阶段内置 PostgreSQL | +| `redis:7-alpine` | 开发阶段内置 Redis | + +一键拉取基础镜像: + +```bash +docker pull python:3.12-slim && \ +docker pull nginx:1.27-alpine && \ +docker pull hiyouga/llamafactory:latest && \ +docker pull postgres:16-alpine && \ +docker pull redis:7-alpine +``` + +Windows PowerShell: + +```powershell +$images = @( + "python:3.12-slim", + "nginx:1.27-alpine", + "hiyouga/llamafactory:latest", + "postgres:16-alpine", + "redis:7-alpine" +) +$images | ForEach-Object { docker pull $_ } +``` + +如果部署环境不能访问外网,需要提前在可联网环境执行上述拉取命令,再用 `docker save` / `docker load` 导出导入。 + +## 业务镜像 + +| 镜像 | Dockerfile | 构建命令 | +| --- | --- | --- | +| `yg-ft-backend-api:latest` | `docker/app/Dockerfile.backend` | `docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .` | +| `yg-ft-frontend-runtime:latest` | `docker/app/Dockerfile.frontend` | `docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .` | +| `yg-ft-compute-api:latest` | `docker/compute/Dockerfile.compute` | `docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .` | + +## 对外端口 + +所有宿主机对外端口统一使用 5 位端口。容器内部端口保持镜像默认端口,便于容器内服务和健康检查稳定。 + +| 服务 | 宿主机对外端口 | 容器内部端口 | 说明 | +| --- | --- | --- | --- | +| 前端 Nginx | `16801` | `80` | 前端页面入口 | +| 后端 API | `17861` | `8000` | FastAPI 服务 | +| PostgreSQL | `15432` | `5432` | 开发阶段内置数据库 | +| Redis | `16379` | `6379` | 开发阶段内置缓存 | +| Compute API | `19100` | `9100` | 算力服务器 API | +| File Gateway | `19101` | 后续服务端口 | 当前预留,后续拆出文件网关服务时使用 | + +注意:`8000` 是后端容器内部端口,不作为宿主机对外访问端口。宿主机或浏览器应访问 `http://:17861/modelTF/health`;前端 Nginx 容器在 Docker 网络内部访问 `http://backend-api:8000/modelTF/...`。 + +对应配置文件: + +- `docker/app/.env.example` + - `FRONTEND_PORT=16801` + - `BACKEND_API_PORT=17861` + - `POSTGRES_PORT=15432` + - `REDIS_PORT=16379` +- `docker/compute/.env.example` + - `COMPUTE_API_PORT=19100` + - `FILE_GATEWAY_PORT=19101` + +## 运行模式 + +- 应用侧默认 `COMPUTE_MODE=real`,任务状态必须由真实算力同步逻辑更新。 +- 算力侧默认 `COMPUTE_EXECUTION_MODE=real`,真实执行器未完成前不会伪造训练作业。 +- 仅隔离联调时可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`,该模式不得用于测试环境、生产环境或生产升级基线。 + +## 应用服务器部署 + +应用服务器包含前端 Nginx、Backend API、PostgreSQL、Redis。 + +当前 Compose 内置 PostgreSQL 使用 `backend/app/db/sql/001_platform_runtime.sql` 初始化运行库。`docs/postgres-schema.sql` 是完整目标架构设计,不应直接挂载为当前运行库初始化脚本,否则会与当前后端代码的运行表结构不兼容。 + +首次部署: + +```bash +cd + +# 1. 使用当前 Windows/宿主机 npm 构建前端静态产物 +cd frontend +npm ci +npm run build +cd .. + +# 2. 手动构建业务镜像 +docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest . +docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest . + +# 3. 启动应用服务 +cd docker/app +cp .env.example .env +docker compose up -d +``` + +后端镜像构建过程中会执行依赖导入自检,确认 `fastapi`、`uvicorn`、`psycopg`、`sqlalchemy`、`redis` 等运行依赖已安装。构建后也可以手动检查: + +```bash +docker run --rm yg-ft-backend-api:latest python -c "import psycopg; print(psycopg.__version__)" +``` + +默认访问地址: + +```text +http://:16801 +``` + +应用侧代码和数据外挂: + +```text +../../backend -> /app +../../frontend/dist -> /usr/share/nginx/html +../../runtime/app/logs/backend -> /opt/yg-ft/logs/backend +../../runtime/app/data -> /data/yg-ft +``` + +前端容器启动前必须确保 `../../frontend/dist/index.html` 已存在。若前端 Nginx 日志出现 `directory index of "/usr/share/nginx/html/" is forbidden` 或 `rewrite or internal redirection cycle while internally redirecting to "/index.html"`,通常表示当前执行 `docker compose` 的项目目录下没有构建好的 `frontend/dist`,或挂载路径不是同一份代码目录。 + +```bash +# 在执行 docker compose 的同一份代码目录中检查 +cd /frontend +npm run build +test -f dist/index.html && ls -lh dist/index.html + +cd ../docker/app +docker compose up -d --force-recreate frontend +docker compose logs --tail=80 frontend +``` + +如果使用 Windows npm 构建、WSL 中运行 Docker Compose,需要确认 Windows 路径和 WSL 路径指向同一份仓库。例如在 `D:\...\YG_FT\frontend` 构建不会自动生成 `/mnt/d/wuyongtao/Code/YG_FT/frontend/dist` 下的产物,除非二者本就是同一个目录。 + +如果使用企业统一 PostgreSQL/Redis,修改 `docker/app/.env`: + +```env +DATABASE_URL=postgresql+psycopg://:@:15432/ +REDIS_URL=redis://:16379/0 +USE_BUILTIN_POSTGRES=false +USE_BUILTIN_REDIS=false +``` + +生产环境如完全使用外部基础设施,可以删除或注释 Compose 中的 `postgres`、`redis` 服务及 `backend-api.depends_on` 中对应依赖。 + +## 算力服务器部署 + +算力服务器包含 Compute API、后续 Compute Agent、File Gateway、GPU runtime、本地训练数据目录和 LLaMA-Factory。`Dockerfile.compute` 基于 LLaMA-Factory 官方镜像: + +```dockerfile +FROM hiyouga/llamafactory:latest +``` + +部署前需要安装: + +- NVIDIA Driver +- NVIDIA Container Toolkit +- Docker Engine 和 Docker Compose Plugin +- 本地训练数据目录,默认 `/data/yg-ft` + +首次部署: + +```bash +cd + +# 手动构建算力业务镜像 +docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest . + +# 启动算力服务 +cd docker/compute +cp .env.example .env +docker compose up -d +``` + +健康检查: + +```text +GET http://:19100/modelTF/health +GET http://:19100/modelTF/v1/compute/health +``` + +算力侧代码和数据外挂: + +```text +../../compute -> /app/compute +${YG_FT_DATA_ROOT_HOST} -> /data/yg-ft +../../runtime/compute/logs -> /opt/yg-ft/logs/compute +../../runtime/compute/training-logs -> /opt/yg-ft/logs/training +``` + +## 应用与算力分离部署 + +应用服务器只需要主动访问算力服务器,不要求算力服务器回调应用服务器。 + +在 `docker/app/.env` 中配置: + +```env +COMPUTE_API_BASE_URL=http://:19100 +FILE_GATEWAY_BASE_URL=http://:19101 +COMPUTE_SERVICE_TOKEN=change_me +COMPUTE_STATUS_SYNC_MODE=polling +COMPUTE_POLL_INTERVAL_SECONDS=10 +COMPUTE_POLL_BATCH_SIZE=100 +``` + +交互链路: + +```text +Frontend + -> Backend API + -> Compute API + -> Compute Agent / LLaMA-Factory + -> 本地数据目录 / 模型目录 / 训练产物 + <- Backend Worker 定时轮询 Compute API +``` + +## 多算力节点部署 + +多算力节点仍按“单机多 GPU 节点”部署。每台 GPU 服务器都独立部署一套 `docker/compute`: + +```text +gpu-node-01: docker/compute + /data/yg-ft + 19100/19101 +gpu-node-02: docker/compute + /data/yg-ft + 19100/19101 +gpu-node-03: docker/compute + /data/yg-ft + 19100/19101 +``` + +节点之间默认不互访。应用平台主动访问每个节点的 Compute API/File Gateway,并通过 `compute_nodes`、`resource_replicas`、`resource_sync_jobs` 统一调度和同步。 + +## 常用命令 + +重新构建应用镜像: + +```bash +docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest . +docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest . +``` + +重新构建算力镜像: + +```bash +docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest . +``` + +启动服务: + +```bash +cd docker/app +docker compose up -d + +cd ../compute +docker compose up -d +``` + +查看服务: + +```bash +docker compose ps +docker compose logs -f +``` diff --git a/docker/app/.env.example b/docker/app/.env.example new file mode 100644 index 0000000..c4c81a5 --- /dev/null +++ b/docker/app/.env.example @@ -0,0 +1,45 @@ +APP_ENV=prod +APP_NAME=YG Fine-Tune Platform API +MODELTF_ROUTE_PREFIX=/modelTF +CORS_ALLOW_ORIGINS=http://localhost:16801,http://127.0.0.1:16801 + +FRONTEND_IMAGE=yg-ft-frontend-runtime:latest +BACKEND_API_IMAGE=yg-ft-backend-api:latest + +# Five-digit host ports exposed outside the application server. +FRONTEND_PORT=16801 +BACKEND_API_PORT=17861 +POSTGRES_PORT=15432 +REDIS_PORT=16379 + +POSTGRES_DB=yg_ft +POSTGRES_USER=yg_ft +POSTGRES_PASSWORD=change_me +DATABASE_URL=postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft + +REDIS_URL=redis://redis:6379/0 + +# Development uses the built-in PostgreSQL/Redis services in docker-compose.yml. +# For enterprise infrastructure, replace DATABASE_URL/REDIS_URL and remove or disable those services. +USE_BUILTIN_POSTGRES=true +USE_BUILTIN_REDIS=true + +LOG_LEVEL=INFO +LOG_DIR=/opt/yg-ft/logs/backend +LOG_FILE_PREFIX=backend +LOG_ERROR_FILE_PREFIX=error +LOG_MAX_BYTES=20971520 +LOG_RETENTION_DAYS=10 + +BACKEND_PROXY_PASS=http://backend-api:8000 + +# Split deployment: set these to the compute server address, for example http://10.10.20.31:19100. +COMPUTE_API_BASE_URL=http://compute-api:9100 +COMPUTE_SERVICE_TOKEN=change_me +FILE_GATEWAY_BASE_URL=http://compute-api:9101 + +# The application side polls Compute API for job state to avoid opening reverse network access. +COMPUTE_MODE=real +COMPUTE_STATUS_SYNC_MODE=polling +COMPUTE_POLL_INTERVAL_SECONDS=10 +COMPUTE_POLL_BATCH_SIZE=100 diff --git a/docker/app/Dockerfile.backend b/docker/app/Dockerfile.backend new file mode 100644 index 0000000..1f03dca --- /dev/null +++ b/docker/app/Dockerfile.backend @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY backend/requirements.txt /tmp/requirements.txt +RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \ + && pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \ + && rm -f /tmp/requirements.txt + +RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, jwt, passlib, httpx, alembic; print('backend dependency check ok')" + +RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \ + && chmod -R 0775 /opt/yg-ft /data/yg-ft + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/app/Dockerfile.frontend b/docker/app/Dockerfile.frontend new file mode 100644 index 0000000..3f7dee3 --- /dev/null +++ b/docker/app/Dockerfile.frontend @@ -0,0 +1,9 @@ + +FROM nginx:1.27-alpine + +RUN mkdir -p /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD test -f /usr/share/nginx/html/index.html && wget -qO- http://127.0.0.1/index.html >/dev/null || exit 1 diff --git a/docker/app/docker-compose.yml b/docker/app/docker-compose.yml new file mode 100644 index 0000000..29d8b43 --- /dev/null +++ b/docker/app/docker-compose.yml @@ -0,0 +1,123 @@ +services: + frontend: + image: ${FRONTEND_IMAGE:-yg-ft-frontend-runtime:latest} + container_name: yg-ft-frontend + depends_on: + backend-api: + condition: service_started + ports: + - "${FRONTEND_PORT:-16801}:80" + environment: + BACKEND_PROXY_PASS: ${BACKEND_PROXY_PASS:-http://backend-api:8000} + volumes: + - ../../frontend/dist:/usr/share/nginx/html:ro + - ../nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + command: + - /bin/sh + - -c + - | + if [ ! -f /usr/share/nginx/html/index.html ]; then + echo "frontend dist is missing: build frontend first and ensure ../../frontend/dist is mounted"; + ls -la /usr/share/nginx/html; + exit 1; + fi; + nginx -g 'daemon off;' + networks: + - yg-ft-app + restart: unless-stopped + + backend-api: + image: ${BACKEND_API_IMAGE:-yg-ft-backend-api:latest} + container_name: yg-ft-backend-api + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + expose: + - "8000" + ports: + - "${BACKEND_API_PORT:-17861}:8000" + environment: + APP_ENV: ${APP_ENV:-prod} + APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API} + MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF} + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:16801,http://127.0.0.1:16801} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + USE_BUILTIN_POSTGRES: ${USE_BUILTIN_POSTGRES:-true} + USE_BUILTIN_REDIS: ${USE_BUILTIN_REDIS:-true} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/backend} + LOG_FILE_PREFIX: ${LOG_FILE_PREFIX:-backend} + LOG_ERROR_FILE_PREFIX: ${LOG_ERROR_FILE_PREFIX:-error} + LOG_MAX_BYTES: ${LOG_MAX_BYTES:-20971520} + LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-10} + COMPUTE_API_BASE_URL: ${COMPUTE_API_BASE_URL:-http://compute-api:9100} + COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me} + FILE_GATEWAY_BASE_URL: ${FILE_GATEWAY_BASE_URL:-http://compute-api:9101} + COMPUTE_MODE: ${COMPUTE_MODE:-real} + COMPUTE_STATUS_SYNC_MODE: ${COMPUTE_STATUS_SYNC_MODE:-polling} + COMPUTE_POLL_INTERVAL_SECONDS: ${COMPUTE_POLL_INTERVAL_SECONDS:-10} + COMPUTE_POLL_BATCH_SIZE: ${COMPUTE_POLL_BATCH_SIZE:-100} + PYTHONPATH: /app + volumes: + - ../../backend:/app:ro + - ../../runtime/app/logs/backend:/opt/yg-ft/logs/backend + - ../../runtime/app/data:/data/yg-ft + networks: + - yg-ft-app + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/modelTF/health', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: yg-ft-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-yg_ft} + POSTGRES_USER: ${POSTGRES_USER:-yg_ft} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + - ../../backend/app/db/sql/001_platform_runtime.sql:/docker-entrypoint-initdb.d/001-platform-runtime.sql:ro + ports: + - "${POSTGRES_PORT:-15432}:5432" + networks: + - yg-ft-app + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: yg-ft-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + ports: + - "${REDIS_PORT:-16379}:6379" + networks: + - yg-ft-app + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + +networks: + yg-ft-app: + name: yg-ft-app + +volumes: + postgres_data: + redis_data: diff --git a/docker/compute/.env.example b/docker/compute/.env.example new file mode 100644 index 0000000..3106017 --- /dev/null +++ b/docker/compute/.env.example @@ -0,0 +1,23 @@ +COMPUTE_ENV=prod +COMPUTE_HOST_ID=gpu-node-01 +COMPUTE_EXECUTION_MODE=real +MODELTF_ROUTE_PREFIX=/modelTF +# Five-digit host ports exposed outside the compute server. +COMPUTE_API_PORT=19100 +FILE_GATEWAY_PORT=19101 +COMPUTE_API_IMAGE=yg-ft-compute-api:latest + +# The application server actively polls Compute API; compute server does not need reverse access. +COMPUTE_SERVICE_TOKEN=change_me +ENABLE_APP_CALLBACK=false + +# LLaMA-Factory is provided by the official hiyouga/llamafactory base image. +LLAMA_FACTORY_HOME=/app/LLaMA-Factory + +YG_FT_DATA_ROOT=/data/yg-ft +YG_FT_DATA_ROOT_HOST=/data/yg-ft + +LOG_DIR=/opt/yg-ft/logs/compute +CUDA_VISIBLE_DEVICES=all +NVIDIA_VISIBLE_DEVICES=all +NVIDIA_DRIVER_CAPABILITIES=compute,utility diff --git a/docker/compute/Dockerfile.compute b/docker/compute/Dockerfile.compute new file mode 100644 index 0000000..60af155 --- /dev/null +++ b/docker/compute/Dockerfile.compute @@ -0,0 +1,27 @@ + +FROM hiyouga/llamafactory:latest + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +COPY compute/requirements.txt /tmp/requirements.txt +RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \ + && pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \ + && rm -f /tmp/requirements.txt + +RUN mkdir -p /opt/yg-ft/logs/compute /opt/yg-ft/logs/training /data/yg-ft /app/LLaMA-Factory \ + && chmod -R 0775 /opt/yg-ft /data/yg-ft /app/LLaMA-Factory + +ENTRYPOINT ["/usr/bin/tini", "--"] + +EXPOSE 9100 + +CMD ["uvicorn", "compute.api.main:app", "--host", "0.0.0.0", "--port", "9100"] diff --git a/docker/compute/docker-compose.yml b/docker/compute/docker-compose.yml new file mode 100644 index 0000000..70f859e --- /dev/null +++ b/docker/compute/docker-compose.yml @@ -0,0 +1,39 @@ +services: + compute-api: + image: ${COMPUTE_API_IMAGE:-yg-ft-compute-api:latest} + container_name: yg-ft-compute-api + gpus: all + ports: + - "${COMPUTE_API_PORT:-19100}:9100" + environment: + COMPUTE_ENV: ${COMPUTE_ENV:-prod} + COMPUTE_HOST_ID: ${COMPUTE_HOST_ID:-gpu-node-01} + COMPUTE_EXECUTION_MODE: ${COMPUTE_EXECUTION_MODE:-real} + MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF} + COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me} + ENABLE_APP_CALLBACK: ${ENABLE_APP_CALLBACK:-false} + LLAMA_FACTORY_HOME: ${LLAMA_FACTORY_HOME:-/app/LLaMA-Factory} + YG_FT_DATA_ROOT: ${YG_FT_DATA_ROOT:-/data/yg-ft} + LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/compute} + CUDA_VISIBLE_DEVICES: ${CUDA_VISIBLE_DEVICES:-all} + NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all} + NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility} + PYTHONPATH: /app + volumes: + - ../../compute:/app/compute:ro + - ${YG_FT_DATA_ROOT_HOST:-/data/yg-ft}:${YG_FT_DATA_ROOT:-/data/yg-ft} + - ../../runtime/compute/logs:/opt/yg-ft/logs/compute + - ../../runtime/compute/training-logs:/opt/yg-ft/logs/training + networks: + - yg-ft-compute + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:9100/modelTF/health', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + +networks: + yg-ft-compute: + name: yg-ft-compute diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template new file mode 100644 index 0000000..1137000 --- /dev/null +++ b/docker/nginx.conf.template @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 200m; + + location / { + try_files $uri $uri/ /index.html; + } + + location /modelTF { + proxy_pass ${BACKEND_PROXY_PASS}; + proxy_http_version 1.1; + 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_read_timeout 300s; + proxy_send_timeout 300s; + } + + location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { + try_files $uri =404; + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/docs/backend-api-design.md b/docs/backend-api-design.md new file mode 100644 index 0000000..80da56b --- /dev/null +++ b/docs/backend-api-design.md @@ -0,0 +1,1161 @@ +# 模型微调平台后端接口设计 + +> 后端建议使用 FastAPI,统一挂载 `/modelTF` 前缀。本文根据当前 Vue 前端路由、API 模块和页面交互契约梳理接口,并补充完整微调平台必须具备的用户中心、权限控制、审计、异步任务、文件版本与监控能力。前端 Mock 仅作为隔离开发辅助,不作为接口设计准则。 + +菜单、二级路由、规划菜单、接口和数据库的总览映射见 `docs/menu-functional-requirements.md`。后续新增接口时,必须同步标注对应页面/功能模块。 + +## 1. 通用约定 + +### 1.1 统一响应 + +```json +{ + "code": 0, + "message": "ok", + "data": {} +} +``` + +- `code=0` 成功;非 0 为业务错误。 +- HTTP 状态码仍用于认证失败、权限不足、参数错误、系统异常。 +- 前端当前 axios 已按 `{ code, data, message }` 解包。 + +### 1.2 认证与权限 + +- 登录成功返回 JWT access token,前端后续请求增加 `Authorization: Bearer `。 +- 当前前端权限码:`dashboard`、`fine-tune`、`model-eval`、`model-inference`、`model-manage`、`dataset`、`data-process`、`data-convert`、`hardware`、`logs`、`user-settings`。 +- 后端 RBAC 建议按 `permission.code + role_permission + user_permission_override` 实现。 +- 所有写操作记录审计日志。 + +### 1.3 分页、排序、筛选 + +当前前端大多直接取数组,后端建议同时支持分页,便于数据量增长: + +```text +page=1&page_size=20&keyword=xxx&sort=-created_at +``` + +分页响应: + +```json +{ + "items": [], + "total": 0, + "page": 1, + "page_size": 20 +} +``` + +### 1.4 异步任务 + +训练、评测、模型加载、数据处理、文件转换都应落为异步任务: + +- 创建任务:返回 `task_id`。 +- 查询详情:返回状态、进度、错误信息、运行统计。 +- 实时进度:优先 SSE,必要时 WebSocket。 + +通用状态建议:`pending`、`running`、`completed`、`failed`、`stopped`。 + +## 2. 用户中心与系统权限 + +### 2.1 登录 + +`POST /modelTF/login` + +请求: + +```json +{ + "username": "admin", + "password": "password" +} +``` + +响应: + +```json +{ + "token": "jwt-token", + "user": { + "id": "uuid", + "username": "admin", + "display_name": "系统管理员", + "role": "admin", + "status": "active", + "permissions": ["dashboard", "fine-tune"], + "create_time": "2026-07-16T10:00:00+08:00", + "last_login": "2026-07-16T10:00:00+08:00", + "protected": true + } +} +``` + +说明:前端当前登录接口已经要求返回 `user`,正式后端必须返回完整用户信息。 + +### 2.2 当前用户 + +`GET /modelTF/me` + +用于刷新页面后恢复用户信息和权限,避免完全依赖 localStorage。 + +### 2.3 用户管理 + +| 方法 | 路径 | 说明 | 权限 | +| --- | --- | --- | --- | +| GET | `/modelTF/users` | 用户列表 | `user-settings` | +| POST | `/modelTF/users` | 创建用户 | `user-settings` | +| PUT | `/modelTF/users/{id}` | 更新角色、状态、页面权限 | `user-settings` | +| DELETE | `/modelTF/users/{id}` | 删除用户 | `user-settings` | +| PUT | `/modelTF/users/{id}/password` | 重置密码 | `user-settings` | + +创建用户请求: + +```json +{ + "username": "zhangsan", + "display_name": "张三", + "password": "InitialPass123", + "role": "operator", + "status": "active", + "permissions": ["dashboard", "fine-tune", "dataset"] +} +``` + +更新权限请求: + +```json +{ + "role": "viewer", + "status": "active", + "permissions": ["dashboard", "logs"] +} +``` + +## 3. 服务看板与系统监控 + +### 3.1 首页看板 + +`GET /modelTF/dashboard/overview?period=7d` + +返回: + +```json +{ + "health": { + "state": "normal", + "online_services": 12, + "running_tasks": 5, + "pending_alerts": 2 + }, + "service_statuses": [ + { "name": "模型推理", "state": "normal", "instances_online": 6, "instances_total": 6 } + ], + "training_stats": [ + { "date": "2026-07-16", "train_count": 11, "gpu_count": 5, "avg_score": 89.0 } + ], + "recent_tasks": [], + "operation_distribution": [], + "login_duration_rank": [], + "recent_login_users": [] +} +``` + +说明:首页需要由后端提供正式聚合接口,避免前端拼多接口导致加载慢。 + +### 3.2 健康指标 + +`GET /modelTF/health` + +响应字段兼容前端 `HealthMetrics`: + +```json +{ + "cpu_percent": 32, + "memory_percent": 58, + "disk_percent": 45 +} +``` + +### 3.3 平台性能 + +`GET /modelTF/system-info` + +返回 CPU、内存、磁盘、GPU、网络、系统运行时间。GPU 进程字段建议包含 `pid`、`name`、`memory_used_gb`、`task_name`、`user`。 + +### 3.4 日志 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/log-files?date=2026-07-16` | 系统日志文件列表 | +| GET | `/modelTF/log-content?file=system.log` | 系统日志内容 | +| GET | `/modelTF/training-log-files` | 训练日志文件列表 | +| GET | `/modelTF/training-log-content?file=xxx.log` | 训练日志内容 | +| POST | `/modelTF/web-log` | 前端错误/行为日志 | + +日志内容应支持 `tail`、`offset`、`limit` 参数,避免一次返回超大文件。 + +## 4. 模型管理 + +### 4.1 模型登记 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/model-manage` | 模型列表 | +| GET | `/modelTF/model-manage/{id}` | 模型详情 | +| GET | `/modelTF/model-manage/name/{name}` | 按名称查询 | +| POST | `/modelTF/model-manage` | 创建模型 | +| PUT | `/modelTF/model-manage/{id}` | 编辑模型 | +| DELETE | `/modelTF/model-manage/{id}` | 删除模型 | +| PUT | `/modelTF/model-manage/{id}/purpose` | 修改用途 | +| GET | `/modelTF/model-manage/local-models` | 扫描本地模型目录 | + +创建/编辑请求: + +```json +{ + "name": "Qwen2.5-7B-Instruct", + "type": "LLM", + "purpose": "training", + "model_source": "local", + "description": "训练基座", + "path": "/data/models/qwen2.5-7b", + "api_url": null, + "api_key": null, + "online_model_name": null +} +``` + +字段说明: + +- `type`: `LLM`、`CV`、`NLP`、`Embedding`、`Other`。 +- `purpose`: `training`、`inference`、`evaluation`。 +- `model_source`: `local`、`api`。 +- `api_key` 后端加密存储,列表接口只返回脱敏值。 + +### 4.2 已训练模型与权重合并 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/model-manage/trained-models` | 已训练模型列表 | +| DELETE | `/modelTF/model-manage/trained-models/{id}?type=merged\|lora` | 删除训练产物 | +| POST | `/modelTF/model-manage/merge` | 合并 LoRA 权重 | +| GET | `/modelTF/model-manage/trained-models/{model_name}/export` | 导出模型文件 | + +合并请求: + +```json +{ + "model_name": "qwen-ft-finance-001", + "train_method": "lora", + "base_model_path": "/data/models/qwen2.5-7b" +} +``` + +响应: + +```json +{ + "merge_task_id": "uuid", + "status": "pending" +} +``` + +## 5. 数据集管理 + +### 5.1 数据集 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/dataset-manage` | 数据集列表 | +| GET | `/modelTF/dataset-manage/{id}` | 数据集详情 | +| POST | `/modelTF/dataset-manage` | 创建数据集 | +| PUT | `/modelTF/dataset-manage/{id}` | 更新数据集 | +| DELETE | `/modelTF/dataset-manage/{id}` | 删除数据集 | +| POST | `/modelTF/dataset-manage/upload/{dataset_id}` | 上传文件,字段名 `files` | +| GET | `/modelTF/dataset-manage/download/{dataset_id}` | 打包下载数据集 | +| GET | `/modelTF/dataset-manage/download/{dataset_id}/{file_id}` | 下载单文件 | + +创建数据集: + +```json +{ + "name": "金融问答-训练集", + "type": "train", + "storage_type": "local", + "source": "upload", + "description": "金融领域问答", + "task_id": null +} +``` + +数据集类型: + +- `type`: `train`、`test`、`eval`、`val`、`other`。 +- `storage_type`: `local`、`minio`、`cloud`。 +- `source`: `upload`、`task`。 + +### 5.2 文件预览与版本 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/dataset-manage/preview/{file_id}` | 当前版本内容预览 | +| GET | `/modelTF/dataset-manage/versions/{file_id}` | 文件版本列表 | +| GET | `/modelTF/dataset-manage/versions/{file_id}/{version_id}` | 读取历史版本 | +| POST | `/modelTF/dataset-manage/versions/{file_id}` | 保存为新版本 | +| PUT | `/modelTF/dataset-manage/versions/{file_id}/active` | 切换当前版本 | +| DELETE | `/modelTF/dataset-manage/versions/{file_id}/{version_id}` | 删除非当前、非初始版本 | + +创建新版本: + +```json +{ + "content": "{\"instruction\":\"...\"}\n", + "description": "在线编辑", + "base_version_id": "uuid", + "expected_current_version_id": "uuid" +} +``` + +说明:`expected_current_version_id` 用于乐观锁,防止多人编辑覆盖。 + +## 6. 数据处理 + +当前数据处理页面需要后端正式承接上传、切片、生成、编辑和发布流程,建议实现以下接口。 + +### 6.1 任务列表与详情 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/data-process` | 数据处理任务列表 | +| POST | `/modelTF/data-process` | 创建草稿任务 | +| GET | `/modelTF/data-process/{id}` | 任务详情 | +| PUT | `/modelTF/data-process/{id}` | 更新任务配置 | +| DELETE | `/modelTF/data-process/{id}` | 删除任务 | +| POST | `/modelTF/data-process/{id}/start` | 启动处理 | +| POST | `/modelTF/data-process/{id}/stop` | 停止处理 | +| GET | `/modelTF/data-process/{id}/progress` | 查询进度 | +| GET | `/modelTF/data-process/{id}/events` | SSE 实时进度 | + +创建任务: + +```json +{ + "name": "客服问答数据清洗", + "description": "清洗并生成 SFT 数据", + "process_type": "structured", + "config": { + "preprocess_options": ["clean_invalid", "detect_structure", "deduplicate"], + "dataset_split": { "train": 80, "validation": 10, "test": 10 }, + "generation_model_id": "uuid", + "generation_prompt": "请生成训练数据", + "temperature": 0.7, + "max_tokens": 1024, + "json_mode": false, + "quality_filter_enabled": true + } +} +``` + +### 6.2 源文件与外部数据源 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/data-process/{id}/source-files` | 上传源文件,字段名 `files` | +| DELETE | `/modelTF/data-process/{id}/source-files/{file_id}` | 移除源文件 | +| POST | `/modelTF/data-process/{id}/external/test` | 测试外部数据源连接 | +| POST | `/modelTF/data-process/{id}/external/pull` | 拉取外部数据并生成源文件 | + +外部数据源请求: + +```json +{ + "type": "mysql", + "url": "mysql://host:3306/db", + "auth_mode": "password", + "username": "user", + "password": "secret", + "token": null, + "limit": 1000 +} +``` + +安全要求:连接密码/token 不落明文,任务详情只回显脱敏配置。 + +### 6.3 预览切片 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/data-process/{id}/preview/build` | 根据源文件和配置生成预览切片 | +| GET | `/modelTF/data-process/{id}/preview` | 查询预览切片 | +| PUT | `/modelTF/data-process/{id}/preview/{preview_id}` | 编辑切片内容 | +| POST | `/modelTF/data-process/{id}/preview` | 手动新增切片 | +| DELETE | `/modelTF/data-process/{id}/preview/{preview_id}` | 删除切片 | + +预览切片字段: + +```json +{ + "id": "uuid", + "source_file_id": "uuid", + "original_content": "...", + "edited_content": "...", + "source_start": 0, + "source_end": 100, + "source_start_line": 1, + "source_end_line": 5, + "token_count": 50, + "status": "original" +} +``` + +### 6.4 生成结果与发布数据集 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/data-process/{id}/generate` | 启动 LLM 生成 | +| GET | `/modelTF/data-process/{id}/results` | 查询结果明细 | +| PUT | `/modelTF/data-process/{id}/results/{result_id}` | 编辑结果 | +| POST | `/modelTF/data-process/{id}/results/{result_id}/restore` | 恢复原始结果 | +| POST | `/modelTF/data-process/{id}/publish` | 发布为数据集 | + +结果字段: + +```json +{ + "instruction": "生成简洁客服回复", + "input": "用户反馈页面加载慢", + "output": "已收到反馈,我们正在排查。", + "status": "valid" +} +``` + +发布请求: + +```json +{ + "dataset_name": "客服问答清洗集", + "dataset_type": "train", + "storage_type": "local", + "split": { "train": 80, "validation": 10, "test": 10 }, + "format": "alpaca_jsonl" +} +``` + +## 7. 模型微调 + +### 7.1 训练任务 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/fine-tune` | 训练任务列表 | +| GET | `/modelTF/fine-tune/{id}` | 训练任务详情 | +| GET | `/modelTF/fine-tune/check-name?name=xxx` | 任务名查重 | +| POST | `/modelTF/fine-tune` | 创建训练任务记录 | +| POST | `/modelTF/fine-tune/start` | 启动训练 | +| PUT | `/modelTF/fine-tune/{id}` | 更新任务 | +| POST | `/modelTF/fine-tune/stop/{id}` | 停止任务 | +| DELETE | `/modelTF/fine-tune/{id}` | 删除任务 | +| GET | `/modelTF/fine-tune/progress/{id}` | 获取训练进度 | +| GET | `/modelTF/fine-tune/{id}/events` | SSE 训练日志/进度 | +| POST | `/modelTF/fine-tune/tensorboard/start` | 启动 TensorBoard | + +启动训练请求兼容前端 `FineTuneStartPayload`: + +```json +{ + "task_id": "uuid", + "name": "finance-sft-001", + "description": "金融 SFT", + "train_type": "SFT", + "train_method": "lora", + "template": "qwen", + "base_model": "uuid", + "train_dataset_id": "uuid", + "auto_merge": true, + "output_model_name": "qwen-finance-sft-v1", + "gpus": [0, 1], + "batch_size": 1, + "learning_rate": 0.0001, + "n_epochs": 1, + "save_steps": 100, + "lr_scheduler_type": "cosine", + "max_length": 512, + "warmup_ratio": 0.05, + "weight_decay": 0.01, + "lora_alpha": 16, + "lora_dropout": 0.1, + "lora_rank": 8, + "quantization_bit": 4, + "export_quantized": false, + "quant_method": "", + "quant_bits": 0, + "quant_group_size": 0, + "export_format": "" +} +``` + +### 7.2 训练日志详情页 + +训练日志页还会联合调用: + +- `GET /modelTF/fine-tune/{id}` 获取任务参数。 +- `GET /modelTF/dataset-manage/{id}` 获取训练集信息。 +- `GET /modelTF/system-info` 获取 GPU 状态。 +- `GET /modelTF/training-log-files`、`GET /modelTF/training-log-content` 获取日志。 + +建议新增: + +`GET /modelTF/fine-tune/{id}/overview` + +一次返回任务、数据集、GPU、日志摘要、指标曲线,减少页面聚合复杂度。 + +## 8. 模型评测 + +### 8.1 评测任务 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/model-eval` | 评测任务列表 | +| GET | `/modelTF/model-eval/{id}` | 评测详情 | +| POST | `/modelTF/model-eval/start` | 启动评测 | +| DELETE | `/modelTF/model-eval/{id}` | 删除评测 | +| GET | `/modelTF/model-eval/{id}/events` | SSE 评测进度 | + +启动评测: + +```json +{ + "eval_task_name": "金融模型评测-v1", + "eval_type": "custom", + "model_id": "uuid", + "gpu_id": 0, + "dataset_id": "uuid", + "dimension_id": "uuid", + "data_source": "dataset", + "leaderboard": true, + "basic_metrics": { + "bleu": { "enabled": true, "ngram": 4 }, + "rouge": { "enabled": true, "methods": ["rouge-1", "rouge-l"] }, + "cosine": { "enabled": false }, + "output_precision": 3 + } +} +``` + +详情响应应包含: + +- 综合分数、最大分、总体评价、改进建议。 +- 维度汇总。 +- 样本级输入、参考答案、模型输出、评分、原因、错误类型。 + +### 8.2 评测维度 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/dimension` | 维度列表 | +| GET | `/modelTF/dimension/{id}` | 维度详情 | +| POST | `/modelTF/dimension` | 创建维度 | +| PUT | `/modelTF/dimension/{id}` | 编辑维度 | +| DELETE | `/modelTF/dimension/{id}` | 删除维度 | + +维度请求: + +```json +{ + "name": "回答准确性", + "type": "classification", + "description": "评估回答是否准确", + "eval_model": "uuid", + "eval_method": "standard", + "eval_prompt": "你是专业评测专家...", + "is_active": true, + "bleu_n": null, + "output_precision": 3, + "score_min": 0, + "score_max": 100, + "pass_threshold": 70 +} +``` + +## 9. 模型推理与对比 + +前端将“推理”和“模型对比”共用 `model-compare` 接口。 + +### 9.1 推理/对比任务 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/model-compare` | 推理/对比任务列表 | +| GET | `/modelTF/model-compare/{id}` | 任务详情 | +| POST | `/modelTF/model-compare` | 创建任务 | +| DELETE | `/modelTF/model-compare/{id}` | 删除任务 | +| POST | `/modelTF/model-compare/{id}/load` | 加载任务内模型 | +| POST | `/modelTF/model-compare/{id}/unload` | 卸载任务内模型 | +| GET | `/modelTF/model-compare/{id}/load-status` | 查询加载状态 | +| POST | `/modelTF/model-compare/{id}/load-status` | 更新加载状态 | +| POST | `/modelTF/model-compare/{id}/start-model` | 启动单个模型服务 | +| POST | `/modelTF/model-compare/stop-by-pid` | 按 PID 停止模型 | +| POST | `/modelTF/model-compare/all/stop-all` | 停止全部旧模型服务 | + +创建任务: + +```json +{ + "name": "金融模型对比", + "description": "对比基座与微调模型", + "models": [ + { + "model_id": "uuid", + "model_name": "Qwen2.5-7B-Instruct", + "model_path": "/data/models/qwen2.5-7b", + "gpu_id": 0, + "source": "database" + } + ] +} +``` + +### 9.2 对话接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/model-compare/stream-chat` | 流式对话,建议 SSE/chunked | +| POST | `/modelTF/model-compare/chat-with-port` | 指定端口非流式对话 | +| POST | `/modelTF/model-chat/batch` | API 模型批量对话 | +| POST | `/modelTF/model-chat/local/chat` | 本地模型对话 | +| POST | `/modelTF/model-chat/local/preload` | 预加载本地模型 | +| POST | `/modelTF/model-chat/trained/preload` | 预加载已训练模型 | + +流式请求: + +```json +{ + "task_id": "uuid", + "model_id": "uuid", + "messages": [ + { "role": "user", "content": "解释什么是 ROE" } + ], + "temperature": 0.7, + "max_tokens": 1024, + "stream": true +} +``` + +建议落库会话与消息,便于对比结果页查看历史。 + +## 10. 数据转换与工具中心 + +### 10.1 数据转换 + +当前 JSON 转 JSONL 页面是 UI 原型,建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/data-convert/jobs` | 创建转换任务,multipart 上传源文件 | +| GET | `/modelTF/data-convert/jobs/{id}` | 转换任务详情 | +| GET | `/modelTF/data-convert/jobs/{id}/download` | 下载转换结果 | +| DELETE | `/modelTF/data-convert/jobs/{id}` | 删除转换任务 | + +请求字段: + +- `source_file`: `.json` 文件。 +- `output_name`: 输出文件名。 +- `encoding`: 默认 `UTF-8`。 +- `convert_type`: `json_to_jsonl`。 + +### 10.2 自定义工具 + +当前自定义工具仅 localStorage,若要多用户共享,建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/tools` | 工具列表 | +| POST | `/modelTF/tools` | 创建工具 | +| GET | `/modelTF/tools/{id}` | 工具详情 | +| PUT | `/modelTF/tools/{id}` | 编辑工具 | +| DELETE | `/modelTF/tools/{id}` | 删除工具 | + +字段:`name`、`description`、`url`、`icon`、`visibility`、`owner_id`。 + +## 11. 后端开发需要补齐的关键点 + +1. 前端路由已有 `user-settings`、`user-create`、`user-permission`、`permission-denied`,但当前仓库缺少对应 Vue 文件;后端仍应先实现用户中心和权限接口。 +2. 数据处理主流程需要后端正式实现上传、切片、LLM 生成、结果编辑、发布数据集。 +3. 训练、评测、数据处理、模型加载都不应同步阻塞 HTTP;建议接 Celery/RQ/Arq 或 FastAPI BackgroundTasks + 独立 worker。 +4. 文件内容不要全部入库;数据库保存元数据、版本、校验和、对象存储路径,内容放本地 NAS/MinIO。 +5. API Key、外部数据源密码必须加密存储,接口只回显脱敏。 +6. 日志和监控数据增长快,需要分区或保留策略。 +7. 建议实现 OpenAPI schema,并用 Pydantic enum 与数据库 enum 对齐。 + +## 12. 仍需确认的问题 + +1. 部署形态已确认:多算力节点仍按“单机多 GPU 节点”管理,不引入 K8s;每台 GPU 服务器独立部署 Compute API/Agent/File Gateway/LLaMA-Factory。 +2. 文件存储:使用本地磁盘、NAS、MinIO,还是对象存储?是否需要断点续传? +3. 训练框架:是否固定使用 LLaMA-Factory?是否还要支持 Transformers 原生、DeepSpeed、Accelerate? +4. 权限粒度:页面级权限是否足够,还是需要到数据集/模型/任务的所有者与项目空间级权限? +5. 多租户/项目空间:是否需要组织、项目、团队隔离? +6. 审批流程:模型发布、数据集删除、停止训练等危险操作是否需要审批? +7. 评测方式:只支持规则指标 + LLM Judge,还是需要人工标注/复核闭环? +8. 推理服务:是否需要长驻服务、自动端口管理、并发限流、会话历史长期保存? +9. 合规安全:数据脱敏、敏感词检测、审计留存周期、模型 API Key 管理是否有公司规范? +10. 数据库规模预期:数据集样本量、日志保留周期、监控采样频率,会影响分区和索引策略。 + +## 13. 企业治理与算力平台补充接口 + +根据 `system-development-plan.md`,以下能力已从待确认项升级为第一版设计范围:单机多 GPU、本地磁盘、应用平台/算力平台分离、多租户、项目级资源隔离、审批流、审计留存、LLaMA-Factory 引擎插件化。原有业务接口需要统一增加 `tenant_id`、`project_id` 上下文,列表接口默认只返回当前用户可访问项目内资源。 + +### 13.1 租户管理 + +| 方法 | 路径 | 说明 | 权限 | +| --- | --- | --- | --- | +| GET | `/modelTF/tenants` | 租户列表 | 平台管理员 | +| POST | `/modelTF/tenants` | 创建租户 | 平台管理员 | +| GET | `/modelTF/tenants/{id}` | 租户详情 | 租户管理员 | +| PUT | `/modelTF/tenants/{id}` | 更新租户 | 平台管理员 | +| PUT | `/modelTF/tenants/{id}/quota` | 设置租户配额 | 平台管理员 | +| PUT | `/modelTF/tenants/{id}/retention-policy` | 设置租户留存策略 | 平台管理员 | + +创建租户: + +```json +{ + "name": "研发一部", + "code": "rd-1", + "status": "active", + "quota": { + "gpu_concurrency": 4, + "storage_bytes": 10995116277760, + "max_projects": 20 + }, + "retention_policy": { + "audit_days": 180, + "training_log_days": 90, + "metric_days": 30, + "temp_file_days": 1 + } +} +``` + +### 13.2 项目空间 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/projects` | 当前用户可访问项目列表 | +| POST | `/modelTF/projects` | 创建项目 | +| GET | `/modelTF/projects/{id}` | 项目详情 | +| PUT | `/modelTF/projects/{id}` | 更新项目 | +| POST | `/modelTF/projects/{id}/archive` | 归档项目 | +| GET | `/modelTF/projects/{id}/members` | 项目成员 | +| POST | `/modelTF/projects/{id}/members` | 添加成员 | +| PUT | `/modelTF/projects/{id}/members/{user_id}` | 修改项目角色 | +| DELETE | `/modelTF/projects/{id}/members/{user_id}` | 移除成员 | + +创建项目: + +```json +{ + "tenant_id": "uuid", + "name": "金融模型微调", + "code": "finance-ft", + "description": "金融问答模型训练与评测", + "quota": { + "gpu_concurrency": 2, + "storage_bytes": 2199023255552, + "max_running_jobs": 3 + } +} +``` + +项目角色建议:`owner`、`maintainer`、`developer`、`reviewer`、`viewer`。 + +### 13.3 资源级授权 + +模型、数据集、训练任务、评测任务、推理任务、数据处理任务都必须支持资源级 ACL。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/resources/{resource_type}/{resource_id}/acl` | 查询资源授权 | +| PUT | `/modelTF/resources/{resource_type}/{resource_id}/acl` | 覆盖资源授权 | +| POST | `/modelTF/resources/{resource_type}/{resource_id}/share` | 快速分享给用户/项目角色 | + +授权请求: + +```json +{ + "entries": [ + { + "subject_type": "user", + "subject_id": "uuid", + "permissions": ["read", "execute", "download"] + }, + { + "subject_type": "project_role", + "subject_id": "developer", + "permissions": ["read", "write", "execute"] + } + ] +} +``` + +权限码:`read`、`write`、`execute`、`download`、`delete`、`manage_acl`。 + +### 13.4 审批中心 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/approvals` | 审批列表,支持 `type=pending/mine/done` | +| POST | `/modelTF/approvals` | 发起审批 | +| GET | `/modelTF/approvals/{id}` | 审批详情 | +| POST | `/modelTF/approvals/{id}/approve` | 通过 | +| POST | `/modelTF/approvals/{id}/reject` | 驳回 | +| POST | `/modelTF/approvals/{id}/cancel` | 撤回 | +| GET | `/modelTF/approval-templates` | 审批模板列表 | +| PUT | `/modelTF/approval-templates/{id}` | 更新审批模板 | + +发起审批: + +```json +{ + "action": "model_publish", + "resource_type": "trained_model", + "resource_id": "uuid", + "project_id": "uuid", + "reason": "发布金融问答模型测试服务", + "payload": { + "service_level": "production", + "gpu_id": 0, + "max_concurrency": 8 + } +} +``` + +第一版建议触发审批的动作:删除数据集、删除模型、生产服务发布、导出模型、下载敏感数据集、提高 GPU 配额、停止他人任务。 + +### 13.5 算力资源与队列 + +应用平台对前端暴露 `/modelTF/compute/*`,实际由 Compute Gateway 调用算力平台内部接口。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/compute/nodes` | 算力节点列表 | +| POST | `/modelTF/compute/nodes` | 新增算力节点 | +| GET | `/modelTF/compute/nodes/{id}` | 算力节点详情 | +| PUT | `/modelTF/compute/nodes/{id}` | 编辑节点地址、权重、标签、路径和启用状态 | +| POST | `/modelTF/compute/nodes/{id}/test-connection` | 测试 Compute API/File Gateway 连通性 | +| POST | `/modelTF/compute/nodes/{id}/enable` | 启用节点 | +| POST | `/modelTF/compute/nodes/{id}/disable` | 禁用节点,不接收新任务 | +| POST | `/modelTF/compute/nodes/{id}/drain` | 进入维护模式,已有任务跑完后下线 | +| POST | `/modelTF/compute/nodes/{id}/health-check` | 主动触发节点健康检查 | +| GET | `/modelTF/compute/nodes/{id}/engines` | 节点训练引擎和版本 | +| GET | `/modelTF/compute/nodes/{id}/replicas` | 节点本地资源副本 | +| GET | `/modelTF/compute/gpus` | GPU 状态 | +| GET | `/modelTF/compute/queue` | 任务队列 | +| GET | `/modelTF/compute/jobs/{id}` | 算力任务详情 | +| POST | `/modelTF/compute/jobs/{id}/retry` | 重试任务 | +| POST | `/modelTF/compute/jobs/{id}/priority` | 调整优先级 | +| POST | `/modelTF/internal/compute-sync/jobs/poll` | 应用平台主动轮询并同步算力任务状态 | +| POST | `/modelTF/internal/compute-sync/resources` | 调度前同步数据集/模型到目标节点 | + +算力节点设计说明: + +- 多算力节点仍按“单机多 GPU 节点”管理,每台 GPU 服务器是一条 `compute_nodes` 记录。 +- 每个可执行训练的节点都需要部署 `Compute API`、`Compute Agent`、`File Gateway` 和宿主机挂载的 LLaMA-Factory。 +- 节点之间默认不互相访问,应用平台主动访问所有节点的 Compute API/File Gateway。 +- 调度支持 `auto` 和 `manual`:普通用户默认自动调度,管理员或高级用户可手动指定节点。 + +算力节点响应字段: + +```json +{ + "id": "uuid", + "code": "gpu-node-01", + "name": "A800 Node 01", + "api_base_url": "http://10.10.20.31:19100", + "file_gateway_url": "http://10.10.20.31:19101", + "enabled": true, + "scheduler_status": "online", + "scheduler_weight": 100, + "tags": ["A800", "80GB", "llama_factory"], + "gpu_count": 8, + "current_running_jobs": 2, + "max_parallel_jobs": 8, + "data_root": "/data/yg-ft", + "model_root": "/data/yg-ft/models", + "log_root": "/opt/yg-ft/logs/compute", + "last_health_check_at": "2026-07-20T12:00:00+08:00", + "health_detail": { + "compute_api": "ok", + "file_gateway": "ok", + "llama_factory": "ok" + } +} +``` + +GPU 响应字段: + +```json +{ + "node_id": "uuid", + "gpu_index": 0, + "uuid": "GPU-xxx", + "name": "NVIDIA A800", + "status": "running", + "memory_total_mb": 81920, + "memory_used_mb": 40960, + "utilization_percent": 72, + "temperature": 61, + "current_job_id": "uuid", + "current_project_id": "uuid" +} +``` + +### 13.6 算力平台内部接口 + +以下接口只允许应用平台调用,不直接暴露给浏览器。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/compute/jobs` | 创建训练/评测/数据处理/推理任务 | +| GET | `/modelTF/compute/jobs/{id}` | 查询任务 | +| POST | `/modelTF/compute/jobs/{id}/stop` | 停止任务 | +| GET | `/modelTF/compute/jobs/{id}/logs` | 拉取日志 | +| GET | `/modelTF/compute/resources/gpus` | 查询 GPU | +| POST | `/modelTF/compute/files/upload` | 上传到算力本地磁盘 | +| GET | `/modelTF/compute/files/{id}/download` | 下载文件 | + +创建算力任务: + +```json +{ + "tenant_id": "uuid", + "project_id": "uuid", + "job_type": "fine_tune", + "engine": "llama_factory", + "priority": "normal", + "scheduler": { + "mode": "auto", + "requested_node_id": null, + "required_tags": ["A800"], + "preferred_tags": ["llama_factory"], + "min_gpu_memory_mb": 40960 + }, + "resource_request": { + "gpu_count": 1, + "gpu_ids": [0], + "memory_gb": 64 + }, + "workspace": { + "root": "/data/ft-platform/tenants/{tenant_id}/projects/{project_id}/jobs/{job_id}" + }, + "payload": { + "model_path": "/data/ft-platform/.../models/base/qwen", + "dataset_path": "/data/ft-platform/.../datasets/train.jsonl", + "training_args": {} + }, + "status_sync_mode": "polling", + "poll_interval_seconds": 10 +} +``` + +手动指定节点时: + +```json +{ + "scheduler": { + "mode": "manual", + "requested_node_id": "uuid", + "gpu_ids": [0, 1] + } +} +``` + +调度前资源副本检查: + +```json +{ + "target_compute_node_id": "uuid", + "resources": [ + { + "resource_type": "model", + "resource_id": "uuid", + "required": true + }, + { + "resource_type": "dataset", + "resource_id": "uuid", + "required": true + } + ], + "sync_if_missing": true +} +``` + +如果目标节点缺少数据集或模型副本,应用平台通过 File Gateway 创建 `resource_sync_jobs`,同步完成后再提交训练任务。 + +### 13.7 文件网关与离线导入 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/modelTF/files/upload-session` | 创建分片上传会话 | +| PUT | `/modelTF/files/upload-session/{id}/parts/{part_no}` | 上传分片 | +| POST | `/modelTF/files/upload-session/{id}/complete` | 完成上传 | +| GET | `/modelTF/files/{id}/preview` | 文件预览 | +| GET | `/modelTF/files/{id}/download-url` | 获取短时下载链接 | +| POST | `/modelTF/import/local-model` | 从算力节点本地路径导入模型 | +| POST | `/modelTF/import/local-dataset` | 从算力节点本地路径导入数据集 | + +离线导入模型: + +```json +{ + "tenant_id": "uuid", + "project_id": "uuid", + "compute_node_id": "uuid", + "path": "/data/models/qwen2.5-7b", + "name": "Qwen2.5-7B-Instruct", + "purpose": "training" +} +``` + +### 13.8 训练引擎管理 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/training-engines` | 训练引擎列表 | +| GET | `/modelTF/training-engines/{id}` | 引擎详情 | +| GET | `/modelTF/training-engines/{id}/schema` | 参数 schema | +| POST | `/modelTF/training-engines/{id}/health-check` | 健康检查 | + +LLaMA-Factory 引擎声明: + +```json +{ + "code": "llama_factory", + "name": "LLaMA-Factory", + "version": "0.9.x", + "supported_task_types": ["SFT", "DPO", "CPT"], + "supported_methods": ["lora", "qlora", "full"], + "supported_formats": ["alpaca", "sharegpt", "dpo_pair", "pretrain_text"], + "schema": {} +} +``` + +### 13.9 Checkpoint 与恢复训练 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/fine-tune/{id}/checkpoints` | checkpoint 列表 | +| POST | `/modelTF/fine-tune/{id}/retry` | 失败任务重试 | +| POST | `/modelTF/fine-tune/{id}/resume` | 从 checkpoint 恢复训练 | +| DELETE | `/modelTF/fine-tune/{id}/checkpoints/{checkpoint_id}` | 删除 checkpoint,可能触发审批 | +| PUT | `/modelTF/fine-tune/{id}/checkpoint-retention` | 设置 checkpoint 保留策略 | + +默认保留策略:最近 3 个、最优 2 个、已发布模型关联 checkpoint 不自动删除、失败任务保留 14 天。 + +### 13.10 审计、留存、配额和成本统计 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/modelTF/audit-logs` | 操作审计 | +| GET | `/modelTF/login-logs` | 登录审计 | +| GET | `/modelTF/download-logs` | 下载审计 | +| GET | `/modelTF/retention-policies` | 留存策略 | +| PUT | `/modelTF/retention-policies/{id}` | 更新留存策略 | +| GET | `/modelTF/quotas/usage` | 配额使用 | +| GET | `/modelTF/usage/summary` | GPU 小时、磁盘、推理调用统计 | + +第一版只做用量统计,不做账单计费;第二期可扩展成本核算。 + +## 14. 接口与页面功能模块映射 + +本节用于接口开发和前后端联调。后端开发人员可按页面模块确认接口覆盖范围;前端开发人员可按页面查找需要调用的 API。 + +### 14.1 认证、用户和权限 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 登录页 | `/login` | `POST /modelTF/login`、`GET /modelTF/me`、`POST /modelTF/logout` | 登录、恢复用户、退出 | +| 用户中心 | `/user-settings` | `GET /modelTF/users` | 用户列表、搜索、状态筛选 | +| 创建用户 | `/user-settings/create` | `POST /modelTF/users` | 创建本地用户 | +| 用户权限 | `/user-settings/:id/permission` | `PUT /modelTF/users/{id}`、`PUT /modelTF/users/{id}/password` | 用户角色、状态、页面权限、重置密码 | +| 无权限页 | `/permission-denied` | 无专属接口,可调用 `GET /modelTF/me` | 展示当前用户权限和返回入口 | + +### 14.2 租户、项目和资源授权 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 租户管理 | `/tenants` | `GET /modelTF/tenants`、`POST /modelTF/tenants` | 租户列表、创建租户 | +| 租户详情 | `/tenants/:id` | `GET /modelTF/tenants/{id}`、`PUT /modelTF/tenants/{id}`、`PUT /modelTF/tenants/{id}/quota`、`PUT /modelTF/tenants/{id}/retention-policy` | 租户配置、配额、留存 | +| 项目列表 | `/projects` | `GET /modelTF/projects`、`POST /modelTF/projects` | 项目列表、创建项目 | +| 项目详情 | `/projects/:id` | `GET /modelTF/projects/{id}`、`PUT /modelTF/projects/{id}`、`POST /modelTF/projects/{id}/archive` | 项目概览、归档 | +| 项目成员 | `/projects/:id/members` | `GET /modelTF/projects/{id}/members`、`POST /modelTF/projects/{id}/members`、`PUT /modelTF/projects/{id}/members/{user_id}`、`DELETE /modelTF/projects/{id}/members/{user_id}` | 成员和项目角色 | +| 资源授权 | 资源详情弹窗或 `/projects/:id/permissions` | `GET /modelTF/resources/{resource_type}/{resource_id}/acl`、`PUT /modelTF/resources/{resource_type}/{resource_id}/acl`、`POST /modelTF/resources/{resource_type}/{resource_id}/share` | 模型/数据集/任务级 ACL | + +### 14.3 看板、监控和日志 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 服务看板 | `/dashboard` | `GET /modelTF/dashboard/overview`、`GET /modelTF/health` | 首页聚合、轻量健康指标 | +| 平台性能 | `/hardware` | `GET /modelTF/system-info`、`GET /modelTF/compute/gpus` | CPU、内存、磁盘、GPU、任务占用 | +| 系统日志 | `/logs` | `GET /modelTF/log-files`、`GET /modelTF/log-content` | 系统日志列表和内容 | +| 训练日志页 | `/training-log/:id` | `GET /modelTF/fine-tune/{id}/overview`、`GET /modelTF/training-log-files`、`GET /modelTF/training-log-content` | 训练日志、指标、GPU 状态 | + +### 14.4 模型管理 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 模型列表 | `/model-manage` | `GET /modelTF/model-manage`、`DELETE /modelTF/model-manage/{id}`、`PUT /modelTF/model-manage/{id}/purpose` | 模型列表、删除审批入口、用途变更 | +| 模型创建/编辑 | `/model-manage/create`、`/model-manage/:id/edit` | `GET /modelTF/model-manage/{id}`、`POST /modelTF/model-manage`、`PUT /modelTF/model-manage/{id}`、`GET /modelTF/model-manage/local-models` | 本地/API 模型登记 | +| 离线导入模型 | 模型创建页或导入弹窗 | `POST /modelTF/import/local-model` | 从算力节点本地路径导入 | +| 已训练模型 | 模型列表/选择弹窗 | `GET /modelTF/model-manage/trained-models`、`DELETE /modelTF/model-manage/trained-models/{id}` | 训练产物列表和删除 | +| 权重合并 | `/model-manage/merge` | `POST /modelTF/model-manage/merge` | LoRA 合并任务 | +| 模型导出 | 模型列表/详情 | `GET /modelTF/model-manage/trained-models/{model_name}/export` | 导出下载,必要时触发审批 | + +### 14.5 数据集与数据处理 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 数据集列表 | `/dataset` | `GET /modelTF/dataset-manage`、`DELETE /modelTF/dataset-manage/{id}`、`GET /modelTF/dataset-manage/download/{id}` | 数据集列表、删除、打包下载 | +| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | `POST /modelTF/dataset-manage`、`PUT /modelTF/dataset-manage/{id}`、`POST /modelTF/dataset-manage/upload/{dataset_id}` | 数据集元数据和文件上传 | +| 离线导入数据集 | 数据集创建页或导入弹窗 | `POST /modelTF/import/local-dataset` | 从算力节点目录导入 | +| 数据集预览 | `/dataset/:id/preview` | `GET /modelTF/dataset-manage/preview/{file_id}`、`GET /modelTF/dataset-manage/versions/{file_id}`、`POST /modelTF/dataset-manage/versions/{file_id}`、`PUT /modelTF/dataset-manage/versions/{file_id}/active`、`DELETE /modelTF/dataset-manage/versions/{file_id}/{version_id}` | 文件预览、版本、在线编辑 | +| 数据处理列表 | `/data-process` | `GET /modelTF/data-process`、`DELETE /modelTF/data-process/{id}` | 处理任务列表 | +| 数据处理创建向导 | `/data-process/create` | `POST /modelTF/data-process`、`POST /modelTF/data-process/{id}/source-files`、`POST /modelTF/data-process/{id}/preview/build`、`POST /modelTF/data-process/{id}/generate`、`POST /modelTF/data-process/{id}/publish` | 创建、上传、预览、生成、发布 | +| 数据处理详情 | `/data-process/:id` | `GET /modelTF/data-process/{id}`、`GET /modelTF/data-process/{id}/results`、`GET /modelTF/data-process/{id}/progress`、`GET /modelTF/data-process/{id}/events` | 详情、结果、进度 | +| 数据转换 | `/data-convert` | `POST /modelTF/data-convert/jobs`、`GET /modelTF/data-convert/jobs/{id}`、`GET /modelTF/data-convert/jobs/{id}/download` | JSON/JSONL 转换 | + +### 14.6 微调训练 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 微调列表 | `/fine-tune` | `GET /modelTF/fine-tune`、`POST /modelTF/fine-tune/stop/{id}`、`DELETE /modelTF/fine-tune/{id}` | 训练任务列表、停止、删除 | +| 微调创建 | `/fine-tune/create` | `GET /modelTF/fine-tune/check-name`、`POST /modelTF/fine-tune`、`POST /modelTF/fine-tune/start`、`GET /modelTF/model-manage`、`GET /modelTF/dataset-manage`、`GET /modelTF/compute/gpus` | 参数配置、GPU 选择、启动训练 | +| 训练详情/日志 | `/training-log/:id` | `GET /modelTF/fine-tune/{id}`、`GET /modelTF/fine-tune/progress/{id}`、`GET /modelTF/fine-tune/{id}/events`、`GET /modelTF/fine-tune/{id}/checkpoints` | 日志、进度、checkpoint | +| 恢复/重试训练 | 训练详情页 | `POST /modelTF/fine-tune/{id}/retry`、`POST /modelTF/fine-tune/{id}/resume` | 从 checkpoint 重试或恢复 | +| Checkpoint 管理 | 训练详情页、存储管理页 | `DELETE /modelTF/fine-tune/{id}/checkpoints/{checkpoint_id}`、`PUT /modelTF/fine-tune/{id}/checkpoint-retention` | 清理策略和删除 | +| TensorBoard | 训练详情页 | `POST /modelTF/fine-tune/tensorboard/start` | 启动 TensorBoard | + +### 14.7 评测、推理、对比和服务发布 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 评测列表 | `/model-eval` | `GET /modelTF/model-eval`、`DELETE /modelTF/model-eval/{id}` | 评测任务列表 | +| 评测创建 | `/model-eval/create` | `POST /modelTF/model-eval/start`、`GET /modelTF/dimension`、`GET /modelTF/model-manage/trained-models`、`GET /modelTF/dataset-manage`、`GET /modelTF/compute/gpus` | 选择模型、数据集、维度、GPU | +| 评测详情 | `/model-eval/:id` | `GET /modelTF/model-eval/{id}`、`GET /modelTF/model-eval/{id}/events` | 综合结果、样本评分 | +| 评测维度 | `/model-eval/dimension/:id/edit` | `GET /modelTF/dimension/{id}`、`POST /modelTF/dimension`、`PUT /modelTF/dimension/{id}`、`DELETE /modelTF/dimension/{id}` | 维度和 Prompt 管理 | +| 推理列表 | `/model-inference` | `GET /modelTF/model-compare`、`POST /modelTF/model-compare/{id}/load`、`POST /modelTF/model-compare/{id}/unload` | 推理任务和加载状态 | +| 推理创建 | `/model-inference/create` | `POST /modelTF/model-compare`、`GET /modelTF/model-manage`、`GET /modelTF/model-manage/trained-models`、`GET /modelTF/compute/gpus` | 选择模型和 GPU | +| 推理对话 | `/model-inference/chat/:id` | `GET /modelTF/model-compare/{id}`、`POST /modelTF/model-compare/stream-chat`、`POST /modelTF/model-compare/chat-with-port` | 单模型对话 | +| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | `POST /modelTF/model-chat/batch`、`POST /modelTF/model-chat/local/chat`、`POST /modelTF/model-chat/local/preload`、`POST /modelTF/model-chat/trained/preload` | 多模型对比和预加载 | +| 模型服务治理 | `/model-services`、`/model-services/:id` | `POST /modelTF/approvals`、`GET /modelTF/compute/jobs/{id}`、`GET /modelTF/usage/summary` | 测试/生产服务发布、调用统计、下线审批 | + +### 14.8 审批、审计、算力和存储运维 + +| 页面模块 | 路由/入口 | 接口 | 说明 | +| --- | --- | --- | --- | +| 审批中心 | `/approvals`、`/approvals/pending`、`/approvals/mine`、`/approvals/:id` | `GET /modelTF/approvals`、`POST /modelTF/approvals`、`GET /modelTF/approvals/{id}`、`POST /modelTF/approvals/{id}/approve`、`POST /modelTF/approvals/{id}/reject`、`POST /modelTF/approvals/{id}/cancel` | 审批列表和审批动作 | +| 审批设置 | `/approval-settings` | `GET /modelTF/approval-templates`、`PUT /modelTF/approval-templates/{id}` | 审批模板 | +| 算力资源 | `/compute`、`/compute/gpus`、`/compute/queue`、`/compute/nodes` | `GET/POST/PUT /modelTF/compute/nodes`、`POST /modelTF/compute/nodes/{id}/test-connection`、`POST /modelTF/compute/nodes/{id}/enable`、`POST /modelTF/compute/nodes/{id}/disable`、`POST /modelTF/compute/nodes/{id}/drain`、`GET /modelTF/compute/gpus`、`GET /modelTF/compute/queue`、`POST /modelTF/compute/jobs/{id}/retry`、`POST /modelTF/compute/jobs/{id}/priority` | GPU、节点、队列、节点权重、标签、维护状态、资源副本 | +| 存储管理 | `/storage` | `GET /modelTF/quotas/usage`、`GET /modelTF/files/{id}/download-url`、`GET /modelTF/retention-policies`、`PUT /modelTF/retention-policies/{id}` | 磁盘占用、下载、留存 | +| 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | `GET /modelTF/audit-logs`、`GET /modelTF/login-logs`、`GET /modelTF/download-logs` | 操作、登录、下载审计 | +| 训练引擎管理 | `/training-engines` | `GET /modelTF/training-engines`、`GET /modelTF/training-engines/{id}`、`GET /modelTF/training-engines/{id}/schema`、`POST /modelTF/training-engines/{id}/health-check` | 引擎能力和健康 | diff --git a/docs/backend-logging.md b/docs/backend-logging.md new file mode 100644 index 0000000..809a64b --- /dev/null +++ b/docs/backend-logging.md @@ -0,0 +1,109 @@ +# 后端日志模块说明 + +本文档对应页面/功能模块:全平台通用能力、系统设置、审计中心、任务详情、训练任务日志、运维监控。 + +## 设计目标 + +- 后端服务统一使用 `backend/app/core/logging.py` 初始化日志。 +- 日志文件按日期命名,单个文件超过 20MB 自动滚动。 +- 日志只保留最近 10 天,过期文件自动清理。 +- 业务日志使用 JSON Lines 格式,便于 Filebeat、Vector、Logstash、ELK、OpenSearch 等日志平台采集。 +- `ERROR` 及以上日志独立写入错误日志文件,便于告警与问题定位。 +- 日志字段必须包含代码文件、行号、函数、日志内容、请求 ID、进程和线程信息。 + +## 文件命名 + +默认日志目录由 `LOG_DIR` 控制,本地默认是 `./logs`。 + +```text +logs/ + backend-2026-07-16.log # INFO/ERROR 等全部应用日志,JSON Lines + backend-2026-07-16.1.log # 当天主日志超过 20MB 后滚动产生 + error-2026-07-16.log # ERROR/CRITICAL 错误日志,JSON Lines + error-2026-07-16.1.log # 当天错误日志超过 20MB 后滚动产生 +``` + +## 环境变量 + +```env +LOG_LEVEL=INFO +LOG_DIR=./logs +LOG_FILE_PREFIX=backend +LOG_ERROR_FILE_PREFIX=error +LOG_MAX_BYTES=20971520 +LOG_RETENTION_DAYS=10 +``` + +## JSON 字段 + +每一行都是一个完整 JSON 对象。 + +```json +{ + "@timestamp": "2026-07-16T13:20:10.123", + "level": "INFO", + "logger": "app.access", + "message": "request completed method=GET path=/modelTF/health status_code=200 duration_ms=3.12 client=127.0.0.1", + "module": "logging", + "function": "request_logging_middleware", + "file": "D:\\AI\\codex-code\\YG_FT\\backend\\app\\core\\logging.py", + "line": 169, + "process": 1234, + "thread": 5678, + "thread_name": "MainThread", + "request_id": "6f9d1c3c-8be0-4c8d-a5b2-18f9d41f9a0c" +} +``` + +异常日志会额外包含: + +```json +{ + "exception": "Traceback ..." +} +``` + +## 使用方式 + +业务代码中不要直接 `print`,统一使用: + +```python +from app.core.logging import get_logger + +logger = get_logger(__name__) + +logger.info("dataset uploaded dataset_id=%s", dataset_id) +logger.warning("gpu queue is busy project_id=%s", project_id) +logger.exception("training job failed job_id=%s", job_id) +``` + +`logger.exception(...)` 只能在 `except` 代码块中使用,它会自动写入堆栈信息,并同时进入主日志和错误日志。 + +## FastAPI 接入 + +应用入口 `backend/app/main.py` 已完成接入: + +```python +settings = get_settings() +configure_logging(settings) +setup_request_logging(app) +``` + +请求日志会自动生成或透传 `X-Request-ID`,并在响应头中返回同一个请求 ID,方便前端、后端、算力服务、日志平台串联排障。 + +## ELK/日志平台采集建议 + +- 采集路径:`/app/logs/*.log` 或生产环境挂载后的日志目录。 +- 解析方式:按行读取,每行作为 JSON 文档解析。 +- 索引建议: + - 主日志:`yg-ft-backend-*` + - 错误日志:`yg-ft-backend-error-*` +- 推荐保留字段:`@timestamp`、`level`、`logger`、`message`、`file`、`line`、`function`、`request_id`、`tenant_id`、`project_id`、`job_id`。 +- 业务开发后续应在关键模块日志中补充 `tenant_id`、`project_id`、`job_id` 等上下文字段,便于企业审计和问题定位。 + +## 注意事项 + +- 当前日志落本地磁盘,生产环境建议把日志目录挂载到独立数据盘。 +- 日志文件保留 10 天是应用侧兜底策略,企业侧长期留存应由 ELK、对象存储或归档服务承担。 +- 敏感字段如 token、密码、密钥、原始用户数据内容不得写入日志。 +- 算力节点和应用节点分开部署时,建议两侧都采用 JSON Lines 格式,并使用统一 `request_id/job_id` 贯穿链路。 diff --git a/docs/deployment-plan.md b/docs/deployment-plan.md new file mode 100644 index 0000000..8a848b8 --- /dev/null +++ b/docs/deployment-plan.md @@ -0,0 +1,406 @@ +# 模型微调平台后期部署方案 + +本文档对应页面/功能模块:系统设置、算力资源、训练任务、任务详情、模型管理、数据集管理、审批中心、审计中心、运维监控。 + +## 1. 部署目标 + +平台需要支持单机多 GPU 训练、本地磁盘文件存储、LLaMA-Factory 训练框架,并预留未来接入其他训练平台的能力。部署设计需要把“应用平台”和“算力平台”边界明确拆开: + +- 应用平台:面向用户、权限、项目、模型、数据集、审批、审计、任务编排和 API。 +- 算力平台:面向 GPU、训练进程、训练框架、本地工作目录、训练日志和产物。 +- 训练框架:当前固定 LLaMA-Factory,后续通过 Engine Adapter 标准接入其他框架。 + +结论:算力平台和训练框架应该部署在 GPU 算力服务器上。原因是训练框架需要直接访问 GPU、CUDA、驱动、模型权重、本地数据集切片、训练工作目录和训练进程。应用平台可以与算力平台同机部署,也可以独立部署,但不建议在无 GPU 的应用服务器上直接运行 LLaMA-Factory。 + +## 2. 服务清单 + +| 服务 | 部署位置 | 职责 | +| --- | --- | --- | +| Nginx | 应用服务器或算力服务器 | 前端静态资源、反向代理、TLS 终止 | +| Frontend | Nginx 静态目录 | 平台控制台 | +| Backend API | 应用服务器 | FastAPI 接口、鉴权、元数据、审批、审计、任务编排 | +| Backend Worker | 应用服务器 | 异步任务、状态同步、通知、审计归档 | +| PostgreSQL | 应用服务器或独立数据库服务器 | 业务元数据、权限、审批、审计 | +| Redis | 应用服务器或独立缓存服务器 | 队列、锁、短期状态、幂等控制 | +| Compute API | GPU 算力服务器 | 只对应用平台开放的内部算力接口 | +| Compute Agent | GPU 算力服务器 | GPU 发现、资源锁定、训练进程管理 | +| File Gateway | GPU 算力服务器 | 本地文件上传、下载、离线导入、产物访问 | +| LLaMA-Factory | GPU 算力服务器 | 实际训练、评测、合并、导出 | +| 日志采集 Agent | 两侧服务器 | 采集应用日志、训练日志、系统日志 | + +## 3. 目录与存储规划 + +建议生产环境把文件、日志、数据库数据分盘挂载: + +```text +/opt/yg-ft/ + app/ # 应用服务代码 + compute/ # 算力服务代码 + config/ # 环境配置和服务配置 + logs/ + backend/ # 后端 JSON Lines 日志 + compute/ # 算力服务日志 + training/ # 训练过程日志 + data/ + datasets/ # 数据集文件 + models/ # 基座模型、微调模型、导出模型 + jobs/ # 训练任务工作目录 + artifacts/ # 评测报告、adapter、checkpoint、导出包 +``` + +本地文件存储建议按租户、项目、资源类型分区: + +```text +/data/yg-ft/ + tenants/{tenant_id}/ + projects/{project_id}/ + datasets/{dataset_id}/ + models/{model_id}/ + jobs/{job_id}/ +``` + +## 4. 方案一:所有服务部署在算力服务器 + +### 4.1 适用场景 + +- 开发联调、单机试运行、资源受限的早期上线环境。 +- 小团队共用一台单机多 GPU 服务器。 +- 网络隔离要求不高,部署资源有限。 + +### 4.2 拓扑 + +```mermaid +flowchart LR + U["用户浏览器"] --> N["Nginx/Frontend"] + N --> B["Backend API"] + B --> DB["PostgreSQL"] + B --> R["Redis"] + B --> C["Compute API"] + C --> A["Compute Agent"] + A --> L["LLaMA-Factory"] + A --> G["GPU/CUDA"] + A --> FS["本地磁盘文件存储"] +``` + +### 4.3 部署方式 + +同一台 GPU 服务器部署: + +- `frontend` 构建后由 Nginx 托管。 +- `backend-api` 使用 Uvicorn/Gunicorn 或容器运行。 +- `backend-worker` 独立进程运行。 +- `postgres` 和 `redis` 可使用 Docker Compose 或系统服务。 +- `compute-api`、`compute-agent`、`file-gateway` 与 LLaMA-Factory 在同机运行。 +- 训练产物、数据集、模型和日志都放在本地数据盘。 + +### 4.4 优点 + +- 部署简单,路径共享容易。 +- 上传数据、训练读取、产物归档都在本机完成,I/O 链路短。 +- 适合快速验证平台功能。 + +### 4.5 风险 + +- 应用服务、数据库、训练任务抢占同一台服务器资源。 +- GPU 训练高负载可能影响 API 响应。 +- 数据库与文件存储容灾能力弱。 +- 安全边界不清晰,企业生产不推荐长期使用。 + +### 4.6 端口建议 + +| 服务 | 端口 | 暴露范围 | +| --- | --- | --- | +| Nginx | 80/443 | 用户网段 | +| Backend API | 17861 | 仅 Nginx、本机 | +| Compute API | 19100 | 仅 Backend API、本机 | +| File Gateway | 19101 | 仅 Backend API、本机 | +| PostgreSQL | 15432 | 本机或内网 | +| Redis | 16379 | 本机或内网 | + +## 5. 方案二:应用服务与算力/训练服务独立部署 + +### 5.1 适用场景 + +- 企业生产环境。 +- 有独立应用服务器、数据库服务器和 GPU 算力服务器。 +- 需要清晰网络边界、权限边界和运维职责。 +- 未来可能扩展多台 GPU 服务器或多种训练框架。 + +### 5.2 拓扑 + +```mermaid +flowchart LR + U["用户浏览器"] --> N["应用区 Nginx/Frontend"] + N --> B["应用区 Backend API"] + B --> DB["PostgreSQL"] + B --> R["Redis"] + B -- "内部 HTTPS/mTLS + 服务 Token" --> C["算力区 Compute API"] + C --> A["Compute Agent"] + A --> L["LLaMA-Factory"] + A --> G["GPU/CUDA"] + A --> FS["算力服务器本地磁盘"] + B -- "定时轮询任务状态/指标/产物索引" --> C +``` + +### 5.3 部署边界 + +应用服务器部署: + +- Nginx。 +- Frontend。 +- Backend API。 +- Backend Worker。 +- PostgreSQL 或数据库连接。 +- Redis 或队列连接。 +- 审批、审计、系统配置、用户中心等应用能力。 + +GPU 算力服务器部署: + +- Compute API。 +- Compute Agent。 +- File Gateway。 +- LLaMA-Factory。 +- CUDA、NVIDIA Driver、NCCL、PyTorch、训练依赖。 +- 本地训练工作目录、模型目录、数据集缓存、产物目录。 + +### 5.4 互通方式 + +应用平台调用算力平台: + +- 协议:内部 HTTPS REST,后续可扩展 gRPC。 +- 鉴权:服务间 Token,生产建议 mTLS + IP 白名单。 +- 幂等:训练任务提交使用 `Idempotency-Key` 或 `job_id`。 +- 状态同步:默认由应用平台定时轮询 Compute API,拉取任务状态、指标摘要和产物索引。 +- 回调策略:第一阶段关闭算力侧回调,避免算力服务器访问应用服务器,减少双向网络策略开通。 + +文件互通: + +- 小文件:前端上传到 Backend API,再由 Backend API 转发或同步到 File Gateway。 +- 大文件:Backend API 创建上传会话,前端通过受控地址分片上传到 File Gateway。 +- 离线数据:管理员把数据放到算力服务器指定目录,应用平台登记离线导入任务。 +- 产物下载:应用平台校验权限后,向 File Gateway 申请短期下载地址。 + +状态互通: + +- Backend API 是业务状态的最终来源。 +- Compute Agent 是训练进程状态的事实来源。 +- Worker 定时对账,把 `queued/running/succeeded/failed/cancelled` 等状态同步回业务库。 + +### 5.5 优点 + +- 应用服务稳定性不受 GPU 训练高负载直接影响。 +- 数据库和审计能力更适合纳入企业基础设施。 +- 算力节点可以逐步扩展,不影响前端和应用后端。 +- 安全边界更清晰,便于设置防火墙、堡垒机、服务账号和审计策略。 + +### 5.6 风险 + +- 文件传输链路比单机部署复杂。 +- 需要处理跨服务器网络失败、轮询延迟、任务状态对账。 +- 需要明确模型、数据集、产物在应用侧和算力侧的索引关系。 + +### 5.7 多算力节点部署约定 + +多算力节点阶段仍然按“单机多 GPU 节点”部署,每台 GPU 服务器都是一个独立算力节点。每个参与调度的节点都必须部署: + +- Compute API。 +- Compute Agent。 +- File Gateway。 +- LLaMA-Factory 宿主机目录和训练依赖。 +- CUDA、NVIDIA Driver、NCCL、PyTorch。 +- 本地数据盘 `/data/yg-ft`。 +- 本地日志和训练产物目录。 + +网络策略保持单向: + +```text +应用服务器 -> 算力节点 A Compute API/File Gateway +应用服务器 -> 算力节点 B Compute API/File Gateway +应用服务器 -> 算力节点 C Compute API/File Gateway +``` + +默认不要求: + +```text +算力节点 -> 应用服务器 +算力节点 A -> 算力节点 B +``` + +多节点任务调度由应用平台统一完成。应用平台从 `compute_nodes` 读取节点地址、权重、标签、启用状态、维护状态和健康检查结果;从 `resource_replicas` 判断目标节点是否已有所需数据集/模型副本;缺失时创建 `resource_sync_jobs`,通过目标节点 File Gateway 同步资源。 + +调度策略: + +- 默认自动调度,按节点健康、标签、GPU 空闲、队列长度、节点权重和资源副本命中率排序。 +- 支持管理员/高级用户手动指定节点或 GPU。 +- `disabled` 节点不参与调度。 +- `draining` 节点不接收新任务,但允许已有任务跑完。 +- `maintenance/offline` 节点只允许查看和清理,不允许提交训练任务。 + +## 6. Compute API 接入标准 + +为预留其他训练平台,应用平台只依赖统一算力接口,不直接依赖 LLaMA-Factory 命令。 + +训练引擎适配器应提供: + +- `validate_config(config)`:校验训练参数和模板。 +- `build_command(job)`:生成训练命令或执行计划。 +- `start(job)`:启动训练进程。 +- `stop(job_id)`:停止训练进程。 +- `status(job_id)`:查询训练状态。 +- `collect_metrics(job_id)`:采集 loss、learning rate、epoch、step 等指标。 +- `collect_artifacts(job_id)`:登记 checkpoint、adapter、导出模型、评测报告。 +- `parse_log(line)`:解析训练日志。 + +第一版适配器: + +```text +compute/engines/llama_factory/ +``` + +后续其他框架: + +```text +compute/engines/xtuner/ +compute/engines/deepspeed_custom/ +compute/engines/openrlhf/ +``` + +## 7. 环境变量建议 + +应用平台: + +```env +APP_ENV=prod +MODELTF_ROUTE_PREFIX=/modelTF +DATABASE_URL=postgresql+psycopg://yg_ft:***@postgres:5432/yg_ft +REDIS_URL=redis://redis:6379/0 +LOG_DIR=/opt/yg-ft/logs/backend +COMPUTE_API_BASE_URL=https://compute.internal:19100 +COMPUTE_SERVICE_TOKEN=*** +FILE_GATEWAY_BASE_URL=https://compute.internal:19101 +COMPUTE_STATUS_SYNC_MODE=polling +COMPUTE_POLL_INTERVAL_SECONDS=10 +COMPUTE_POLL_BATCH_SIZE=100 +``` + +算力平台: + +```env +COMPUTE_ENV=prod +COMPUTE_HOST_ID=gpu-node-01 +COMPUTE_API_PORT=19100 +FILE_GATEWAY_PORT=19101 +COMPUTE_SERVICE_TOKEN=*** +ENABLE_APP_CALLBACK=false +LLAMA_FACTORY_HOME=/app/LLaMA-Factory +YG_FT_DATA_ROOT=/data/yg-ft +LOG_DIR=/opt/yg-ft/logs/compute +CUDA_VISIBLE_DEVICES=0,1,2,3 +``` + +## 8. 日志与监控 + +应用平台: + +- 采集 `backend-YYYY-MM-DD.log` 和 `error-YYYY-MM-DD.log`。 +- 按 `request_id`、`tenant_id`、`project_id`、`job_id` 检索。 +- ERROR 日志触发告警。 + +算力平台: + +- 采集 Compute API 日志、Agent 日志、训练原始日志。 +- 训练日志需要按 `job_id` 独立归档。 +- 关键指标包括 GPU 利用率、显存、磁盘容量、训练队列长度、失败率。 + +## 9. 安全要求 + +- Compute API 不对公网开放。 +- 应用平台和算力平台之间使用服务账号鉴权,生产建议 mTLS。 +- File Gateway 下载地址必须短期有效,并绑定租户、项目、资源权限。 +- 日志不得输出密码、Token、密钥、数据集原文敏感内容。 +- 审计日志留存周期按租户或企业配置执行,应用日志短期留存,长期归档交给日志平台。 + +## 10. 部署检查清单 + +- PostgreSQL 已初始化当前运行脚本 `backend/app/db/sql/001_platform_runtime.sql`;`docs/postgres-schema.sql` 作为目标架构设计,后续通过迁移体系逐步收敛。 +- Redis 可连通。 +- 后端 `GET /modelTF/health` 正常。 +- Compute API 健康检查正常。 +- Compute Agent 能识别 GPU、显存、CUDA 版本。 +- LLaMA-Factory 能在命令行完成最小训练作业。 +- 应用平台能提交训练任务到 Compute API。 +- 任务状态能从算力平台同步回应用平台。 +- 数据集上传、离线导入、产物下载路径权限正确。 +- 后端 JSON 日志可被日志平台解析。 +- ERROR 日志能触发告警。 +- 日志、数据集、模型、产物所在磁盘容量有监控和告警。 + +## 11. Docker Compose 文件规划 + +当前项目按应用服务器和算力服务器拆分了两套 Docker 部署文件,均采用代码外挂方式运行: + +```text +docker/ + app/ + Dockerfile.backend # Backend API 运行时镜像,代码通过 volume 挂载到 /app + Dockerfile.frontend # Nginx 前端运行时镜像,frontend/dist 通过 volume 挂载 + docker-compose.yml # 应用服务器:frontend、backend-api、postgres、redis + .env.example + compute/ + Dockerfile.compute # CUDA + Python + Compute API 运行时镜像 + docker-compose.yml # 算力服务器:compute-api,预留 agent/file gateway 拆分 + .env.example +``` + +项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,避免与拆分部署入口混淆。 + +应用服务器启动: + +```bash +cd docker/app +cp .env.example .env +docker compose up -d +``` + +算力服务器启动: + +```bash +cd docker/compute +cp .env.example .env +docker compose up -d +``` + +应用服务器与算力服务器独立部署时,需要在 `docker/app/.env` 中配置: + +```env +COMPUTE_API_BASE_URL=http://:19100 +FILE_GATEWAY_BASE_URL=http://:19101 +COMPUTE_SERVICE_TOKEN=change_me +``` + +这些地址在当前 Docker 阶段通过环境变量动态配置。后续多算力节点阶段建议升级为数据库配置,由应用平台从 `compute_nodes` 表读取节点地址、权重、标签、健康状态和启用状态,并在“算力节点管理”页面维护。 + +多节点后,每台算力服务器各自进入 `docker/compute` 启动一套算力服务,并在应用平台中登记为一条 `compute_nodes` 记录: + +```text +gpu-node-01 -> http://10.10.20.31:19100 / http://10.10.20.31:19101 +gpu-node-02 -> http://10.10.20.32:19100 / http://10.10.20.32:19101 +gpu-node-03 -> http://10.10.20.33:19100 / http://10.10.20.33:19101 +``` + +算力服务器需要在 `docker/compute/.env` 中配置: + +```env +ENABLE_APP_CALLBACK=false +COMPUTE_SERVICE_TOKEN=change_me +YG_FT_DATA_ROOT_HOST=/data/yg-ft +``` + +## 12. 仍需确认的问题 + +- 生产环境是否已有统一 ELK/OpenSearch、Filebeat/Vector 标准配置。 +- PostgreSQL/Redis 开发阶段采用项目自带部署;生产阶段是否切换企业统一基础设施,以及对应 SLA 仍需确认。 +- 是否需要 PostgreSQL 主备、备份恢复、审计日志长期归档的明确 SLA。 +- 大文件上传是否需要断点续传、限速、病毒扫描或 DLP 检测。 +- 应用服务器与算力服务器默认只开通应用侧主动访问算力侧;如后续需要实时回调,再单独评估双向网络策略。 +- 多算力节点已按单机多 GPU 节点扩展设计;仍需确认是否需要节点组、租户绑定节点、同步限速和资源副本清理审批。 diff --git a/docs/first-version-development-plan.md b/docs/first-version-development-plan.md new file mode 100644 index 0000000..db16039 --- /dev/null +++ b/docs/first-version-development-plan.md @@ -0,0 +1,145 @@ +# 当前系统主链路开发计划 + +> 说明:本计划描述当前正在开发的系统能力。代码、接口和 SQL 均按后续生产演进基线维护,不以一次性演示、静态 Mock 或样例数据作为开发准则。联调辅助能力必须显式配置启用,并不得成为默认运行路径。 + +## 1. 阶段目标 + +当前阶段需要完成模型微调平台的主链路工程基础: + +```text +登录 + -> 模型管理 + -> 数据集管理 + -> 创建微调任务 + -> 调度算力节点与 GPU + -> 检查并同步模型/数据集资源 + -> 启动训练任务 + -> 轮询任务状态、GPU 占用、训练日志、loss 曲线 + -> 训练完成后登记训练产物 +``` + +该阶段是正式系统的第一批可运行能力,不再初始化业务样例数据。系统只允许初始化内置管理员/运维账号,模型、数据集、算力节点、GPU、训练任务和资源副本必须通过页面、接口、算力 Agent 扫描或正式导入流程产生。 + +## 2. 运行模式 + +| 模式 | 说明 | 当前要求 | +| --- | --- | --- | +| `real` | 面向真实部署,等待 Compute API、Agent、File Gateway 和 LLaMA-Factory 执行器回写状态 | 默认模式 | +| `simulator` | 仅用于隔离联调,无真实 GPU 时临时推进任务状态、GPU 状态和训练日志 | 必须显式开启,不得用于生产基线 | + +后端默认 `COMPUTE_MODE=real`。在该模式下,任务状态不再按时间自动推进,必须由后续真实算力同步逻辑更新。算力服务默认 `COMPUTE_EXECUTION_MODE=real`,真实训练执行器未完成前,创建训练作业会返回明确的未实现错误,避免误认为已经完成生产训练能力。 + +训练相关能力必须沉淀在 `compute/engines/` 适配层,不允许在应用平台后端直接拼接或执行训练命令。 + +## 3. 当前开发范围 + +### 3.1 应用平台后端 + +对应目录: + +```text +backend/app/ + api/v1/endpoints/platform.py + core/ + db/ +``` + +已建立能力: +- 统一 API 响应结构 `{ code, message, data }`。 +- PostgreSQL 运行表初始化,当前执行脚本位于 `backend/app/db/sql/001_platform_runtime.sql`。 +- 内置管理员账号初始化,业务数据不再自动写入样例记录。 +- 登录、当前用户、用户列表与权限页面接口。 +- 模型管理、训练产物列表、权重合并任务入口。 +- 数据集管理、文件上传、预览、版本管理和下载。 +- 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。 +- 系统健康指标、系统信息、训练日志、系统日志接口。 +- 算力节点、GPU、队列、资源副本、资源同步任务接口。 + +待继续开发: +- 接入正式 ORM/Repository/Service 分层和 Alembic 迁移。 +- 将任务状态更新改为应用侧定时轮询 Compute API/File Gateway 后落库。 +- 完成项目/模型/数据集级权限隔离校验。 +- 完成审批流、审计留存、配额、资源申请和多租户上下文。 +- 增加正式异常码、接口鉴权、中间件、幂等控制和分页规范。 + +### 3.2 算力平台服务 + +对应目录: + +```text +compute/ + api/main.py + agent/ + engines/llama_factory/ + file_gateway/ +``` + +已建立能力: +- `/modelTF/health` 与 `/modelTF/v1/compute/health` 节点健康检查。 +- LLaMA-Factory 参数校验、命令生成和训练日志指标解析。 +- Compute API 作业、GPU、文件网关接口壳。 +- 显式 `simulator` 模式下的内存状态机,用于隔离联调。 + +待继续开发: +- 真实 GPU 发现:接入 `nvidia-smi` 或 NVML。 +- GPU 锁定与释放:一张 GPU 同一时间只分配给一个训练或推理任务。 +- LLaMA-Factory 真实执行器:生成 YAML/命令、启动进程、停止进程、采集 PID。 +- 训练日志采集:读取宿主机挂载日志文件,解析 loss、learning rate、epoch 等指标。 +- Checkpoint/adapter/merged model 扫描与产物登记。 +- File Gateway:本地磁盘文件上传、下载、校验、导入和跨节点资源同步。 + +### 3.3 前端页面 + +已接入页面: +- `/login`:登录接口。 +- `/model-manage`:模型列表、模型来源、训练产物。 +- `/dataset`、`/dataset/:id/preview`:数据集列表、预览、版本。 +- `/fine-tune`、`/fine-tune/create`:微调任务创建、启动、状态轮询。 +- `/training-log/:id`:训练日志和 loss 曲线。 +- `/hardware`:平台 GPU 与系统性能。 +- `/compute`:算力节点、GPU、队列、资源副本。 + +前端 Mock 默认关闭。仅在隔离前端开发时可设置 `VITE_ENABLE_MOCK=true`,真实联调和后续生产演进均以 `/modelTF` 后端接口为准。 + +待继续开发: +- 补齐多租户、项目管理、审批中心、审计中心、配额管理页面。 +- 完成算力节点管理表单,包括节点地址、权重、标签、启用状态和健康检查结果。 +- 完成模型/数据集导入页面,支持本地路径扫描和归属项目选择。 +- 推理服务页面需接入真实后端任务接口,移除页面内本地假对话路径。 + +### 3.4 数据库 + +当前运行 SQL: + +```text +backend/app/db/sql/001_platform_runtime.sql +``` + +架构目标 SQL: + +```text +docs/postgres-schema.sql +``` + +当前运行 SQL 用于支持已开发接口落库;架构目标 SQL 包含用户中心、多租户、项目隔离、审批、审计、配额、评测等完整模型。后续需要通过 Alembic 将二者收敛为统一迁移体系,生产升级只走迁移脚本,不依赖手工改表。 + +## 4. 验收标准 + +- 启动后端必须连接 PostgreSQL,不允许回退到 SQLite。 +- 后端启动只初始化系统内置账号,不初始化模型、数据集、算力节点、GPU、训练任务等业务样例数据。 +- 前端默认请求真实 `/modelTF` 接口,除非显式设置 `VITE_ENABLE_MOCK=true`。 +- 默认 `real` 模式下任务状态不自动伪造完成,必须等待真实算力同步。 +- 显式 `simulator` 模式只能用于隔离联调,部署文档必须标注不得用于生产。 +- 登录后可以进入主界面,并可通过页面/API 创建真实业务记录。 +- 代码、接口路由、配置项、数据库表名不得使用 `demo` 命名。 + +## 5. 后续开发计划 + +| 阶段 | 重点 | 说明 | +| --- | --- | --- | +| 阶段 1 | 数据库迁移体系 | 将当前运行 SQL 与架构 SQL 收敛到 Alembic 迁移 | +| 阶段 2 | 后端领域分层 | 拆分用户、模型、数据集、训练、算力、审计等模块 | +| 阶段 3 | 真实 Compute Agent | GPU 发现、资源锁定、进程管理、日志采集 | +| 阶段 4 | LLaMA-Factory 训练执行 | YAML/命令生成、进程启动/停止、checkpoint 和 adapter 扫描 | +| 阶段 5 | 企业治理 | 多租户、项目隔离、审批流、审计留存、配额和资源申请 | +| 阶段 6 | 多算力节点调度 | 基于 `compute_nodes`、标签、权重、资源副本和节点健康实现调度策略 | diff --git a/docs/menu-functional-requirements.md b/docs/menu-functional-requirements.md new file mode 100644 index 0000000..e73fdc3 --- /dev/null +++ b/docs/menu-functional-requirements.md @@ -0,0 +1,98 @@ +# 菜单与功能需求总览 + +> 本文根据当前前端侧边栏、路由、需求文档、接口文档、部署文档和 SQL 脚本整理。当前代码和 SQL 均按正式系统开发基线维护;Mock、Simulator 只能作为显式联调能力,不作为默认开发准则。 + +## 1. 菜单分层 + +### 1.1 当前侧边栏菜单 + +| 一级分组 | 菜单 | 路由 | 权限码 | 当前状态 | 主要功能 | +| --- | --- | --- | --- | --- | --- | +| 服务看板 | 服务看板 | `/dashboard` | `dashboard` | 已有页面,接口需继续完善 | 总览指标、服务状态、训练统计、最近任务、健康入口 | +| 模型服务 | 模型训练 | `/fine-tune` | `fine-tune` | 已接入主链路 | 训练任务列表、创建训练、启动/停止、进度、训练日志、checkpoint | +| 模型服务 | 模型评测 | `/model-eval` | `model-eval` | 前端页面已有,后端待完整实现 | 评测任务、评测维度、样本评分、综合结果 | +| 模型服务 | 模型推理 | `/model-inference` | `model-inference` | 前端页面已有,后端待完整实现 | 推理任务、模型加载、单模型对话、模型对比入口 | +| 模型服务 | 模型管理 | `/model-manage` | `model-manage` | 已接入主链路 | 基座模型登记、本地/API 模型、训练产物、权重合并、模型导出 | +| 数据治理 | 数据集管理 | `/dataset` | `dataset` | 已接入主链路 | 数据集列表、上传、预览、在线编辑、版本、下载、删除审批入口 | +| 数据治理 | 数据处理 | `/data-process` | `data-process` | 前端页面已有,后端待完整实现 | 文档上传、切片预览、LLM 生成、结果编辑、发布数据集 | +| 其他工具 | 数据类型转换 | `/data-convert` | `data-convert` | 前端页面已有,后端待实现 | JSON/JSONL/Markdown 等格式转换任务 | +| 算力资源 | 算力节点 | `/compute` | `compute` | 已接入节点管理接口 | 节点地址、权重、标签、启用状态、GPU、队列、资源副本 | +| 系统设置 | 用户设置 | `/user-settings` | `user-settings` | 已接入基础用户接口 | 用户列表、创建用户、启停、页面权限 | +| 系统设置 | 平台性能 | `/hardware` | `hardware` | 已有接口,需接真实采集 | CPU、内存、磁盘、GPU、进程、网络监控 | +| 系统设置 | 查看日志 | `/logs` | `logs` | 已有接口,需接真实日志文件 | 后端日志、error 日志、训练日志索引、日志内容查看 | + +### 1.2 当前二级和隐藏路由 + +| 页面 | 路由 | 归属菜单 | 说明 | +| --- | --- | --- | --- | +| 登录 | `/login` | 独立入口 | 登录后进入主界面 | +| 使用文档 | `/guide` | 独立入口 | 当前系统使用说明 | +| 创建训练任务 | `/fine-tune/create` | 模型训练 | 训练参数、模型/数据集/GPU 选择 | +| 训练日志 | `/training-log/:id` | 模型训练 | 日志、指标、checkpoint、任务概览 | +| 新建评测 | `/model-eval/create` | 模型评测 | 模型、数据集、维度、GPU 选择 | +| 评测详情 | `/model-eval/:id` | 模型评测 | 维度汇总、样本结果、人工复核预留 | +| 评测维度创建/编辑 | `/model-eval/dimension/create`、`/model-eval/dimension/:id/edit` | 模型评测 | 评测规则、Prompt、评分器配置 | +| 新建推理 | `/model-inference/create` | 模型推理 | 推理任务和模型加载配置 | +| 模型对话 | `/model-inference/chat/:id` | 模型推理 | 单模型对话 | +| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 模型推理 | 多模型对比和结果页 | +| 添加/编辑模型 | `/model-manage/create`、`/model-manage/:id/edit` | 模型管理 | 模型登记、用途、来源、路径/API 配置 | +| 合并权重 | `/model-manage/merge` | 模型管理 | LoRA/Adapter 合并任务 | +| 数据处理创建/详情 | `/data-process/create`、`/data-process/:id` | 数据处理 | 数据处理向导和任务详情 | +| 数据集创建/编辑/预览 | `/dataset/create`、`/dataset/:id/edit`、`/dataset/:id/preview` | 数据集管理 | 数据集元数据、文件、版本与内容 | +| 自定义工具 | `/tools`、`/tools/create`、`/tools/:id/edit` | 规划入口 | 路由存在,当前侧边栏未展示,后续可归入“其他工具” | +| 算力子页 | `/compute/gpus`、`/compute/queue`、`/compute/nodes` | 算力节点 | 当前可作为页签或深链 | +| 创建用户/权限设置 | `/user-settings/create`、`/user-settings/:id/permission` | 用户设置 | 用户创建和页面权限 | +| 无权限页 | `/permission-denied` | 系统页 | 路由守卫无权限跳转 | + +### 1.3 企业治理待补菜单 + +| 建议菜单分组 | 菜单 | 建议路由 | 优先级 | 必要性 | +| --- | --- | --- | --- | --- | +| 组织与项目 | 租户管理 | `/tenants`、`/tenants/:id` | P0 | 多租户隔离、配额、留存策略入口 | +| 组织与项目 | 项目空间 | `/projects`、`/projects/:id`、`/projects/:id/members` | P0 | 项目级模型/数据集/任务隔离 | +| 组织与项目 | 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | P0 | 模型/数据集/任务级 ACL | +| 治理中心 | 审批中心 | `/approvals`、`/approvals/:id` | P0 | 删除、发布、导出、停止他人任务等高风险动作 | +| 治理中心 | 审批设置 | `/approval-settings` | P1 | 审批模板、审批人规则、超时策略 | +| 治理中心 | 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | P1 | 操作审计、登录审计、下载审计、导出 | +| 运维中心 | 存储管理 | `/storage` | P1 | 本地磁盘占用、临时文件、checkpoint 清理、留存 | +| 运维中心 | 训练引擎管理 | `/training-engines` | P2 | LLaMA-Factory 和后续引擎能力 schema、健康检查 | +| 模型服务 | 模型服务治理 | `/model-services`、`/model-services/:id` | P1 | 测试/生产服务发布、调用统计、下线审批 | + +## 2. 菜单对应接口和数据库 + +| 菜单/模块 | 主要接口 | 当前运行 SQL | 目标 SQL | +| --- | --- | --- | --- | +| 登录、用户设置 | `/modelTF/login`、`/modelTF/me`、`/modelTF/users` | `users` | `users`、`login_sessions`、`permissions`、`role_permissions`、`user_permission_overrides` | +| 服务看板 | `/modelTF/dashboard/overview`、`/modelTF/health` | 复用模型/数据集/任务/算力表 | `system_metric_snapshots`、`web_logs`、各业务表聚合 | +| 模型管理 | `/modelTF/model-manage`、`/modelTF/model-manage/trained-models`、`/modelTF/model-manage/merge` | `models`、`trained_models` | `models`、`trained_models`、`storage_objects`、`local_import_jobs`、`resource_acl` | +| 数据集管理 | `/modelTF/dataset-manage`、`/modelTF/dataset-manage/upload/{id}`、`/preview`、`/versions` | `datasets`、`dataset_files` | `datasets`、`dataset_files`、`dataset_file_versions`、`dataset_records`、`storage_objects` | +| 模型训练 | `/modelTF/fine-tune`、`/start`、`/progress`、`/checkpoints` | `fine_tune_tasks`、`trained_models` | `fine_tune_tasks`、`fine_tune_metrics`、`fine_tune_checkpoints`、`compute_jobs`、`gpu_allocations` | +| 训练日志 | `/modelTF/training-log-files`、`/modelTF/training-log-content` | 由任务表生成索引 | 日志文件元数据、`fine_tune_metrics`、`audit_logs` | +| 算力节点 | `/modelTF/compute/nodes`、`/compute/gpus`、`/compute/queue`、`/compute/nodes/{id}/replicas` | `compute_nodes`、`gpus`、`resource_replicas`、`resource_sync_jobs` | `compute_nodes`、`gpu_devices`、`compute_node_engines`、`compute_jobs`、`resource_replicas`、`resource_sync_jobs` | +| 平台性能 | `/modelTF/system-info`、`/modelTF/compute/gpus` | `gpus`、任务表 | `system_metric_snapshots`、`gpu_devices`、`compute_jobs` | +| 查看日志 | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/web-log` | 文件日志 | `web_logs`、`audit_logs`,大日志进入日志平台 | +| 模型评测 | `/modelTF/model-eval`、`/modelTF/dimension` | 当前运行 SQL 未覆盖 | `eval_tasks`、`eval_dimensions`、`eval_sample_results`、`eval_dimension_summaries` | +| 模型推理/对比 | `/modelTF/model-compare`、`/modelTF/model-chat/*` | 当前运行 SQL 未覆盖 | `inference_tasks`、`inference_task_models`、`chat_sessions`、`chat_messages` | +| 数据处理 | `/modelTF/data-process/*` | 当前运行 SQL 未覆盖 | `data_process_tasks`、`data_process_source_files`、`data_process_preview_items`、`data_process_results` | +| 数据转换/自定义工具 | `/modelTF/data-convert/jobs`、`/modelTF/tools` | 当前运行 SQL 未覆盖 | `data_convert_jobs`、`custom_tools` | +| 租户/项目/资源授权 | `/modelTF/tenants`、`/modelTF/projects`、`/modelTF/resources/{type}/{id}/acl` | 当前运行 SQL 未覆盖 | `tenants`、`tenant_users`、`projects`、`project_members`、`resource_acl` | +| 审批/审计/留存/配额 | `/modelTF/approvals`、`/modelTF/audit-logs`、`/modelTF/retention-policies`、`/modelTF/quotas/usage` | 当前运行 SQL 未覆盖 | `approval_templates`、`approval_instances`、`approval_steps`、`audit_logs`、`retention_policies`、`quotas`、`quota_usage` | + +## 3. 文档和脚本检查结论 + +| 对象 | 当前结论 | 本次补充 | +| --- | --- | --- | +| 需求文档 | `docs/platform-architecture-requirements.md` 和 `docs/system-development-plan.md` 已覆盖多租户、项目隔离、审批、审计、多算力节点、应用/算力分离部署;缺少一份按当前菜单组织的总览 | 新增本文作为菜单和功能需求总览 | +| 接口文档 | `docs/backend-api-design.md` 已统一 `/modelTF`,并已有页面/接口映射;需要明确引用菜单总览,避免开发只看接口不看页面入口 | 在接口文档增加菜单总览引用 | +| 开发计划 | `docs/system-development-plan.md` 已按工作包列出页面、接口和 DB;需要把本文作为任务认领入口 | 在开发计划增加菜单总览引用 | +| 部署文档 | `docs/deployment-plan.md`、`docker/README.md` 已覆盖应用/算力分离、单机多 GPU、本地磁盘、真实模式默认、Docker 拆分 | 暂无新增部署配置要求 | +| 目标 SQL | `docs/postgres-schema.sql` 覆盖完整目标模型,包含用户、权限、多租户、项目、审批、审计、模型、数据集、训练、评测、推理、算力、存储、导入、服务治理 | 暂不需要新增目标表 | +| 当前运行 SQL | `backend/app/db/sql/001_platform_runtime.sql` 只覆盖已接入运行接口的最小表集 | 后续每实现一个 P0/P1 菜单模块,应同步补运行 SQL 或迁移脚本;不能再以样例数据补功能 | + +## 4. 后续补充原则 + +- 新增侧边栏菜单时,必须同步补齐:路由、权限码、接口文档、DB 表/迁移、审计动作、部署依赖。 +- 新增后端接口时,必须在 `docs/backend-api-design.md` 标注对应页面/功能模块。 +- 新增表结构时,目标模型写入 `docs/postgres-schema.sql`,当前可执行落库写入 `backend/app/db/sql/` 或 Alembic 迁移。 +- 与训练、评测、推理、数据处理相关的异步任务必须落库,不能依赖前端本地状态。 +- 与算力相关的功能默认走真实模式;Simulator 只能显式开启用于隔离联调。 diff --git a/docs/platform-architecture-requirements.md b/docs/platform-architecture-requirements.md new file mode 100644 index 0000000..ec756bc --- /dev/null +++ b/docs/platform-architecture-requirements.md @@ -0,0 +1,772 @@ +# 模型训练平台架构与功能需求设计 + +> 本文基于当前前端页面、已有接口/数据库设计,以及用户补充的 6 条约束进行补全。目标是把平台从“单前端原型 + 后端接口草案”扩展为企业可落地的模型训练平台方案。 + +## 1. 补充需求结论 + +### 1.1 已确认约束 + +1. 部署形态:单机多 GPU。 +2. 文件存储:本地磁盘。 +3. 训练框架:当前固定 LLaMA-Factory,但要预留其他训练平台接入标准。 +4. 权限粒度:需要到项目、模型、数据集级隔离。 +5. 企业治理:需要多租户、审批流、审计留存周期。 +6. 部署边界:算力平台与应用平台分开部署。 + +### 1.2 对整体设计的影响 + +- 不能只做页面级 RBAC,需要引入“租户 -> 项目 -> 资源 -> 成员/角色/ACL”的资源权限模型。 +- 单机多 GPU 不等于简单指定 GPU ID,需要有 GPU 资源池、锁定、排队、抢占策略和异常释放机制。 +- 本地磁盘存储与应用/算力分离存在天然冲突:文件不能只存应用服务器本地,也不能让应用直接读算力服务器目录。建议将训练文件、模型文件、日志文件统一落在算力节点本地磁盘,由算力平台提供文件网关 API;应用平台只保存元数据和访问路径。 +- LLaMA-Factory 应作为第一个训练引擎插件,而不是写死到业务流程里。后续接入其他平台时只需实现同一套训练引擎协议。 +- 审批流、审计和保留策略必须从第一版进入数据模型和接口,否则后期补会牵动大量资源表。 + +## 2. 总体架构设计 + +### 2.1 逻辑分层 + +```mermaid +flowchart LR + U["用户浏览器"] --> APP["应用平台 Web / FastAPI"] + APP --> DB["PostgreSQL"] + APP --> REDIS["Redis / 任务队列"] + APP --> CPAPI["算力平台 API"] + CPAPI --> AGENT["算力节点 Agent"] + AGENT --> GPU["单机多 GPU"] + AGENT --> DISK["本地磁盘工作区"] + AGENT --> LF["LLaMA-Factory 引擎"] +``` + +### 2.2 应用平台职责 + +应用平台负责业务编排和企业治理,不直接执行训练命令: + +- 用户、租户、项目、权限、审批、审计。 +- 模型、数据集、任务、评测、推理任务元数据。 +- 前端 API、OpenAPI、统一认证、统一响应。 +- 训练/评测/数据处理任务创建、状态查询、审批校验。 +- 与算力平台通信,下发任务、查询进度、拉取日志、下载产物。 + +建议部署组件: + +- Nginx:静态前端与反向代理。 +- FastAPI:业务 API。 +- PostgreSQL:业务元数据。 +- Redis:缓存、任务队列、SSE 状态缓存、分布式锁。 +- Worker:审批通知、审计归档、周期清理、异步导出。 + +### 2.3 算力平台职责 + +算力平台负责“实际占用 GPU 和磁盘”的事情: + +- GPU 发现、资源上报、锁定、释放。 +- 本地磁盘工作区管理。 +- 数据集文件接收、校验、解压、版本目录管理。 +- LLaMA-Factory 命令生成、执行、停止、日志采集。 +- 训练产物、LoRA adapter、merged model、checkpoint 管理。 +- 推理服务进程管理、端口分配、健康检查。 +- 系统/GPU 监控指标采集。 + +建议部署组件: + +- Compute API:只暴露给应用平台访问。 +- Compute Agent:本机服务,具备启动/停止进程权限。 +- 本地文件网关:上传、下载、预览、断点续传、文件校验。 +- Engine Adapter:LLaMA-Factory 适配器,后续扩展其他训练引擎。 + +### 2.4 应用与算力平台通信 + +建议统一使用内部 HTTP/gRPC API,并配置服务间认证: + +- 应用平台调用算力平台必须携带 `X-Service-Token` 或 mTLS 证书。 +- 算力平台不信任前端用户身份,只信任应用平台下发的租户、项目、任务和资源上下文。 +- 第一阶段不默认启用算力侧回调,应用平台通过定时轮询 Compute API 同步任务状态;如未来启用回调,必须带签名,避免伪造状态。 + +核心通信接口: + +| 方向 | 接口 | 说明 | +| --- | --- | --- | +| 应用 -> 算力 | `POST /modelTF/compute/jobs` | 创建训练/评测/数据处理/推理任务 | +| 应用 -> 算力 | `POST /modelTF/compute/jobs/{id}/stop` | 停止任务 | +| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}` | 查询任务状态 | +| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}/logs` | 拉取日志 | +| 应用 -> 算力 | `GET /modelTF/compute/resources/gpus` | 查询 GPU 状态 | +| 应用 -> 算力 | `POST /modelTF/compute/files/upload` | 上传文件到算力本地磁盘 | +| 应用 -> 算力 | `GET /modelTF/compute/files/{object_id}/download` | 下载文件 | +| 应用 -> 算力 | `GET /modelTF/compute/jobs?status=running` | 定时轮询任务状态、指标、产物索引 | + +## 3. 本地磁盘存储设计 + +### 3.1 存储根目录 + +由于算力和应用分离,建议文件主存储放在算力节点本地磁盘: + +```text +/data/ft-platform/ + tenants/{tenant_id}/ + projects/{project_id}/ + datasets/{dataset_id}/ + models/base/{model_id}/ + models/trained/{trained_model_id}/ + jobs/{job_id}/ + input/ + output/ + logs/ + checkpoints/ + tmp/ +``` + +应用平台数据库保存: + +- `storage_type=local`。 +- `storage_node_id`。 +- `relative_path`。 +- `checksum_sha256`。 +- `byte_size`。 +- `tenant_id/project_id/resource_id`。 + +### 3.2 文件访问原则 + +- 前端不直接访问磁盘路径。 +- 应用平台生成短时下载凭证,转发或重定向到算力文件网关。 +- 预览内容只读取前 N 行或指定区间,避免大文件撑爆 API。 +- 大文件上传必须支持分片上传、校验、断点续传。 +- 删除操作采用软删除 + 延迟清理,等待审计留存和审批结果。 + +### 3.3 磁盘配额 + +配额应分三层: + +- 租户配额:总容量、模型容量、数据容量、日志容量。 +- 项目配额:可用容量、最大文件大小、最大任务产物保留数。 +- 用户配额:可上传文件总量、并发任务产物占用。 + +超过配额时: + +- 禁止新建任务或上传文件。 +- 允许下载和清理。 +- 提示可清理的 checkpoint、过期日志、失败任务临时目录。 + +## 4. 单机多 GPU 资源调度 + +第一版可以先以单个算力节点跑通主链路,但数据模型、接口和页面需要按多算力节点预留。多节点阶段仍然采用“每台 GPU 服务器 = 一个单机多 GPU 节点”的模式,不引入 Kubernetes。 + +### 4.1 GPU 资源模型 + +每张 GPU 需要记录: + +- `gpu_index`、`uuid`、型号、显存、驱动版本。 +- 当前利用率、显存占用、温度、功耗。 +- 当前锁定任务、进程 PID、端口。 +- 状态:`idle`、`reserved`、`running`、`draining`、`offline`、`error`。 + +### 4.2 调度策略 + +第一版建议支持三种模式: + +- 手动指定 GPU:兼容当前前端选择 GPU 的模式。 +- 自动选择 GPU:按空闲显存、温度、任务队列选择。 +- 项目配额调度:项目最多可占用 N 张 GPU,避免单项目占满机器。 + +训练任务启动流程: + +1. 应用平台校验权限、配额、审批状态。 +2. 生成任务,状态为 `pending`。 +3. 算力平台尝试锁定 GPU。 +4. 锁定成功后创建工作区并启动 LLaMA-Factory。 +5. Agent 持续上报进度、日志、指标。 +6. 任务完成后释放 GPU,登记模型产物。 + +### 4.3 并发与排队 + +- 同一 GPU 同时只允许一个训练任务。 +- 推理服务可与训练互斥,默认不允许混跑;如后续允许,需要显存保留策略。 +- 数据处理如果只调用 API 模型,可以不占 GPU;如果调用本地模型生成,需要占用 GPU。 +- 支持任务队列优先级:`low`、`normal`、`high`、`urgent`。 +- 高优任务是否可抢占低优任务,需要审批或管理员权限。 + +### 4.4 多算力节点升级策略 + +多算力节点阶段的推荐决策: + +- 每个可执行训练任务的 GPU 节点都部署 `Compute API`、`Compute Agent`、`File Gateway`、LLaMA-Factory、CUDA/PyTorch 训练环境和本地数据盘。 +- 应用服务器可以主动访问所有算力节点的 `Compute API/File Gateway`。 +- 算力节点之间默认不互相访问,不做节点间点对点同步;所有调度、状态同步和资源分发由应用平台统一编排。 +- 长期坚持每台算力服务器本地磁盘,因此需要 `resource_replicas` 记录数据集、基座模型、checkpoint、adapter、导出模型在哪些节点已有本地副本。 +- 调度前必须检查目标节点是否已有模型和数据集副本;缺失时由应用平台通过目标节点 File Gateway 创建同步任务,完成后再启动训练。 +- 调度支持自动和手动两种模式:普通用户默认自动调度,管理员/高级用户可手动指定节点、GPU、标签或节点组。 + +多节点自动调度建议: + +1. 过滤 `enabled = true` 且 `scheduler_status = online` 的节点。 +2. 按训练引擎、GPU 型号、显存、节点标签、租户/项目配额过滤。 +3. 优先选择已存在所需模型/数据集副本的节点,减少跨节点复制。 +4. 同等条件下按空闲 GPU、队列长度、节点权重和最近健康检查排序。 +5. `draining` 节点不接收新任务,但允许已有任务完成。 + +## 5. 训练引擎接入标准 + +### 5.1 引擎抽象 + +LLaMA-Factory 是第一实现,但业务系统只依赖统一训练引擎接口: + +```text +TrainingEngine + validate_config(config) + prepare_workspace(job_context) + build_command(job_context) + start(job_context) + stop(job_id) + parse_progress(log_line) + collect_artifacts(job_id) + export_model(job_id, export_config) +``` + +### 5.2 引擎注册信息 + +每个训练引擎需要声明: + +- 引擎编码:`llama_factory`。 +- 支持任务:`SFT`、`DPO`、`CPT`。 +- 支持方法:`lora`、`qlora`、`full`。 +- 支持模型模板:`qwen`、`llama3` 等。 +- 支持数据格式:Alpaca、ShareGPT、DPO pair、pretrain text。 +- 支持量化和导出格式。 +- 参数 schema。 +- 命令模板或启动方式。 + +### 5.3 LLaMA-Factory 适配要求 + +LLaMA-Factory 适配器负责: + +- 将平台训练参数转换为 YAML/CLI 参数。 +- 根据项目工作区生成数据集配置。 +- 自动设置 `CUDA_VISIBLE_DEVICES`。 +- 解析训练日志中的 loss、epoch、learning_rate、ETA。 +- 收集 checkpoint、adapter、merged model、training_args、trainer_state。 +- 支持训练停止和失败恢复。 + +### 5.4 后续接入其他平台的标准 + +其他训练平台只要实现以下契约即可接入: + +- 输入:模型引用、数据集引用、训练配置、资源需求、输出目录。 +- 输出:任务状态、进度、日志、指标、产物清单、失败原因。 +- 生命周期:`prepare`、`start`、`running`、`stop`、`complete`、`cleanup`。 +- 安全:不能越权访问其他租户/项目目录。 +- 可观测:必须输出结构化事件和日志。 + +## 6. 多租户与项目级隔离 + +### 6.1 租户模型 + +需要新增租户管理: + +- 租户名称、编码、状态。 +- 租户管理员。 +- 租户资源配额:GPU 并发数、磁盘容量、最大项目数。 +- 租户审计策略、保留周期、审批策略。 + +### 6.2 项目空间 + +所有业务资源必须归属项目: + +- 数据集。 +- 模型。 +- 训练任务。 +- 评测任务。 +- 推理任务。 +- 数据处理任务。 +- 自定义工具。 + +项目字段: + +- 项目名称、描述、所属租户。 +- 项目管理员、成员。 +- 默认资源权限。 +- GPU/磁盘/任务并发配额。 +- 项目状态:启用、归档、禁用。 + +### 6.3 资源级权限 + +建议采用 RBAC + ACL 混合: + +- RBAC 决定用户是否能访问模块,例如能否进入模型管理。 +- 项目角色决定用户是否能管理项目内资源。 +- 资源 ACL 处理特殊授权,例如某个数据集只给指定成员可读。 + +项目角色建议: + +| 角色 | 权限 | +| --- | --- | +| Project Owner | 项目设置、成员、资源、审批策略全权限 | +| Project Maintainer | 创建/编辑模型、数据集、任务,可发起发布和删除 | +| Developer | 创建训练、评测、推理、数据处理任务 | +| Reviewer | 审批、复核、查看评测结果 | +| Viewer | 只读查看 | + +资源权限建议: + +- `read`:查看资源。 +- `write`:编辑元数据和内容。 +- `execute`:用于训练/评测/推理。 +- `download`:下载文件和模型。 +- `delete`:删除或申请删除。 +- `manage_acl`:管理资源授权。 + +### 6.4 数据隔离要求 + +- API 查询必须默认带 `tenant_id` 和用户可访问项目范围。 +- 数据库所有核心资源表增加 `tenant_id`、`project_id`。 +- 本地磁盘路径包含租户和项目 ID,防止路径混用。 +- 算力任务上下文必须携带租户/项目,Agent 只允许访问对应工作区。 +- 日志、审计、下载链接也必须按租户隔离。 + +## 7. 企业治理设计 + +### 7.1 审批流 + +建议第一版支持可配置审批模板: + +| 场景 | 是否建议审批 | 原因 | +| --- | --- | --- | +| 删除数据集 | 是 | 数据不可逆风险高 | +| 删除模型 | 是 | 影响训练/推理依赖 | +| 模型发布为可推理服务 | 是 | 影响生产资源 | +| 停止他人训练任务 | 是或管理员直通 | 影响计算成本和他人工作 | +| 导出模型 | 可配置 | 涉及资产外流 | +| 下载敏感数据集 | 可配置 | 涉及数据安全 | +| 提高 GPU 配额 | 是 | 涉及资源竞争 | + +审批流能力: + +- 发起审批。 +- 指定审批人/审批组。 +- 多级审批。 +- 通过、驳回、撤回、转交。 +- 审批超时提醒。 +- 审批结果回写原业务动作。 + +### 7.2 审计留存周期 + +建议支持租户级配置: + +- 操作审计:默认 180 天,可配置 90/180/365/永久。 +- 登录审计:默认 180 天。 +- 训练日志:默认 90 天。 +- 系统监控:原始采样默认 30 天,聚合指标保留 1 年。 +- 模型产物:默认长期保留,删除需审批。 +- 临时文件:默认 7 天清理。 +- 失败任务工作区:默认 14 天清理。 + +审计不可被普通管理员物理删除,只能由系统归档任务按策略处理。 + +### 7.3 安全策略 + +需要补充: + +- API Key 加密存储和脱敏展示。 +- 外部数据源密码加密存储或不落库。 +- 下载链接短时有效。 +- 敏感操作二次确认。 +- 审批通过后动作有效期,例如 24 小时内执行。 +- IP 白名单和服务间 token。 +- 操作审计记录 before/after 数据。 + +## 8. 需要补全的页面和功能 + +### 8.1 租户与项目页面 + +当前前端缺失,建议新增: + +1. 租户管理页 + - 租户列表、创建、禁用、配额设置、审计策略。 + - 仅平台管理员可见。 + +2. 项目空间页 + - 项目列表、创建项目、归档项目。 + - 展示项目资源概览:模型数、数据集数、任务数、磁盘占用、GPU 使用。 + +3. 项目成员页 + - 添加/移除成员。 + - 设置项目角色。 + - 查看成员最近操作。 + +4. 项目资源权限页 + - 模型/数据集/任务级授权。 + - 支持按用户、用户组、项目角色授权。 + +### 8.2 用户中心补全 + +前端路由已有但页面文件缺失或未完成: + +- `PermissionDeniedView.vue` +- `UserSettingsView.vue` +- `UserCreateView.vue` +- `UserPermissionView.vue` + +建议功能: + +- 用户列表、创建、禁用、重置密码。 +- 角色管理。 +- 页面权限管理。 +- 用户所属租户/项目。 +- 用户可用 GPU/磁盘配额查看。 +- 登录记录和操作审计入口。 + +### 8.3 审批中心 + +新增页面: + +- 我的申请。 +- 待我审批。 +- 已办审批。 +- 审批详情。 +- 审批模板配置。 + +审批详情需要展示: + +- 申请人、申请时间、动作类型、目标资源。 +- 变更前后信息。 +- 风险提示。 +- 审批记录。 +- 通过/驳回意见。 + +### 8.4 算力资源中心 + +当前只有硬件监控页,建议扩展为算力资源中心: + +- GPU 拓扑和状态。 +- GPU 当前任务占用。 +- GPU 锁定/释放记录。 +- 队列中的任务。 +- 资源配额:租户/项目/用户维度。 +- 算力节点 Agent 状态。 +- 算力节点新增/编辑、连接测试、启用/禁用、维护模式。 +- 节点权重、标签、训练引擎版本、LLaMA-Factory 健康状态。 +- 节点本地资源副本:数据集、模型、checkpoint、adapter 和导出模型缓存。 +- 训练引擎健康状态。 + +### 8.5 文件与存储管理 + +新增页面: + +- 存储总览。 +- 租户/项目磁盘占用。 +- 大文件列表。 +- 临时文件清理。 +- Checkpoint 管理。 +- 日志保留策略。 +- 文件下载审计。 + +### 8.6 训练引擎管理 + +新增页面: + +- 引擎列表。 +- LLaMA-Factory 版本和路径。 +- 引擎能力声明。 +- 参数 schema 管理。 +- 引擎健康检查。 +- 引擎接入文档。 + +### 8.7 任务队列与运行控制 + +新增页面: + +- 全局任务队列。 +- 项目任务队列。 +- 任务优先级调整。 +- 任务重试。 +- 任务停止审批。 +- 失败任务诊断。 + +### 8.8 模型发布与服务治理 + +当前推理/对比页面已有基础能力,但缺少企业化发布能力: + +- 模型发布申请。 +- 推理服务实例配置。 +- 端口、GPU、并发、超时、最大上下文限制。 +- 服务启停记录。 +- 调用统计。 +- 服务下线审批。 + +## 9. 后端模块补全 + +### 9.1 新增核心模块 + +| 模块 | 职责 | +| --- | --- | +| tenant | 租户管理、租户配额、租户策略 | +| project | 项目空间、成员、项目角色 | +| resource_acl | 模型/数据集/任务级资源授权 | +| approval | 审批模板、审批实例、审批动作 | +| quota | GPU、磁盘、任务并发配额 | +| compute_gateway | 应用平台与算力平台通信 | +| file_gateway | 本地磁盘文件上传、下载、预览 | +| engine_registry | 训练引擎注册与能力发现 | +| retention | 审计、日志、临时文件保留策略 | + +### 9.2 数据模型补充 + +在前一版 SQL 基础上,应新增或调整: + +- `tenants` +- `tenant_users` +- `projects` +- `project_members` +- `resource_acl` +- `approval_templates` +- `approval_instances` +- `approval_steps` +- `quotas` +- `quota_usage` +- `compute_nodes` +- `gpu_devices` +- `gpu_allocations` +- `compute_jobs` +- `training_engines` +- `retention_policies` + +核心资源表需要补充字段: + +- `tenant_id` +- `project_id` +- `visibility` +- `owner_id` +- `approval_status` +- `storage_node_id` + +需要调整的已有表: + +- `models` +- `trained_models` +- `datasets` +- `dataset_files` +- `data_process_tasks` +- `fine_tune_tasks` +- `eval_tasks` +- `inference_tasks` +- `custom_tools` +- `audit_logs` +- `storage_objects` + +### 9.3 接口补充 + +租户: + +- `GET /modelTF/tenants` +- `POST /modelTF/tenants` +- `GET /modelTF/tenants/{id}` +- `PUT /modelTF/tenants/{id}` +- `PUT /modelTF/tenants/{id}/quota` +- `PUT /modelTF/tenants/{id}/retention-policy` + +项目: + +- `GET /modelTF/projects` +- `POST /modelTF/projects` +- `GET /modelTF/projects/{id}` +- `PUT /modelTF/projects/{id}` +- `POST /modelTF/projects/{id}/archive` +- `GET /modelTF/projects/{id}/members` +- `POST /modelTF/projects/{id}/members` +- `PUT /modelTF/projects/{id}/members/{user_id}` +- `DELETE /modelTF/projects/{id}/members/{user_id}` + +资源授权: + +- `GET /modelTF/resources/{resource_type}/{resource_id}/acl` +- `PUT /modelTF/resources/{resource_type}/{resource_id}/acl` +- `POST /modelTF/resources/{resource_type}/{resource_id}/share` + +审批: + +- `GET /modelTF/approvals` +- `POST /modelTF/approvals` +- `GET /modelTF/approvals/{id}` +- `POST /modelTF/approvals/{id}/approve` +- `POST /modelTF/approvals/{id}/reject` +- `POST /modelTF/approvals/{id}/cancel` + +算力: + +- `GET /modelTF/compute/nodes` +- `GET /modelTF/compute/gpus` +- `GET /modelTF/compute/queue` +- `POST /modelTF/compute/jobs/{id}/retry` +- `POST /modelTF/compute/jobs/{id}/priority` + +训练引擎: + +- `GET /modelTF/training-engines` +- `GET /modelTF/training-engines/{id}` +- `POST /modelTF/training-engines/{id}/health-check` +- `GET /modelTF/training-engines/{id}/schema` + +## 10. 端到端业务流程 + +### 10.1 数据集上传 + +1. 用户进入项目空间。 +2. 用户创建数据集。 +3. 应用平台校验项目写权限和磁盘配额。 +4. 前端上传文件到应用平台。 +5. 应用平台转发到算力文件网关,保存到项目目录。 +6. 算力平台返回文件元数据和 checksum。 +7. 应用平台登记数据集文件版本。 +8. 审计记录上传行为。 + +### 10.2 微调训练 + +1. 用户选择项目、模型、数据集、训练参数和 GPU。 +2. 应用平台检查模型/数据集 `execute` 权限。 +3. 检查项目 GPU 并发配额。 +4. 如果策略要求审批,先创建审批单。 +5. 审批通过后创建 compute job。 +6. 算力平台锁定 GPU,生成 LLaMA-Factory 配置,启动训练。 +7. 训练日志和指标实时回传。 +8. 完成后登记 trained model。 +9. 如启用自动合并,进入合并任务。 +10. 审计记录任务全生命周期。 + +### 10.3 模型发布 + +1. 用户选择训练产物。 +2. 提交发布申请。 +3. 审批通过后算力平台启动推理服务。 +4. 分配端口和 GPU。 +5. 应用平台登记服务实例。 +6. 前端推理页面调用服务。 +7. 监控调用量、延迟、错误率、GPU 占用。 + +## 11. 当前功能完整性评估 + +补充 6 条需求后,平台设计已经覆盖完整模型训练平台的主链路: + +- 数据准备。 +- 数据处理。 +- 模型登记。 +- 微调训练。 +- 训练日志和指标。 +- 模型合并和导出。 +- 模型评测。 +- 推理服务。 +- 模型对比。 +- 用户、权限、租户、项目隔离。 +- 审批、审计、保留策略。 +- 算力资源调度。 + +但如果目标是企业级生产平台,还建议继续确认以下缺口。 + +## 12. 仍需确认的问题 + +1. 本地磁盘是否在算力服务器上,应用服务器是否完全不保存训练文件?如果应用服务器也要保存上传临时文件,需要确认临时文件保留周期和容量。 +2. 单机多 GPU 是否需要支持 MIG、GPU 分片或多进程共享,还是一张 GPU 同一时间只给一个任务? +3. 是否允许训练任务抢占?高优先级任务是否能停止低优先级任务? +4. 是否需要离线导入已有模型和已有数据集目录,还是所有文件都必须从平台上传? +5. 模型发布是否区分“测试服务”和“生产服务”?生产发布是否必须审批? +6. 是否需要数据集脱敏、敏感字段识别和数据质量评分作为内置流程? +7. 是否需要人工评测/人工复核结果沉淀为新数据集? +8. 是否需要训练任务失败后的断点续训? +9. 是否需要 checkpoint 自动清理策略,例如只保留最近 N 个或最好 N 个? +10. 是否需要对外提供标准 API 给其他系统调用训练、评测、推理能力? +11. 是否需要接入企业统一身份认证,例如 LDAP、OIDC、企业微信、钉钉? +12. 是否需要成本核算:按租户/项目统计 GPU 小时、磁盘占用、模型调用量? +13. 多算力节点是否需要节点组、租户绑定节点或项目绑定节点策略? +14. 跨节点资源同步是否需要限速、同步窗口和管理员审批? + +## 13. 推荐决策补充 + +`system-development-plan.md` 已对上述问题给出第一版建议,需求设计以以下决策为准。 + +1. 本地磁盘主存储放在算力服务器,应用服务器只保留上传临时文件。临时文件默认保留 24 小时,成功转发到算力文件网关后可立即进入清理队列。 +2. 第一版不支持 MIG、GPU 分片和多任务共享同一张 GPU。一张 GPU 同一时间只分配给一个训练任务或一个推理服务。GPU 数据模型预留 `partition_type`、`parent_gpu_uuid`、`memory_total_mb`,便于后续扩展 MIG。 +3. 第一版不做自动抢占。支持任务优先级和排队;停止他人任务需要审批或平台管理员权限。 +4. 多算力节点仍按“单机多 GPU 节点”管理,每个节点独立部署算力服务和 LLaMA-Factory;节点之间不互相访问,由应用平台统一调度和资源同步。 +5. 多节点调度默认自动选择节点,同时支持管理员/高级用户手动指定节点;调度优先考虑节点健康、标签、权重、空闲 GPU、队列长度和资源副本是否已存在。 +6. 第一版必须支持离线导入已有模型和数据集目录。导入由算力 Agent 扫描、校验、登记,并归属指定租户和项目。 +7. 模型发布区分测试服务和生产服务。测试服务项目内可启动并默认限流;生产服务必须审批。 +8. 数据脱敏和数据质量评分作为数据处理模块的一等能力进入第一期,先实现规则版脱敏、格式校验、重复率、完整性、长度分布等指标。 +9. 人工评测/复核作为第二期功能,但第一期需在数据库和页面入口预留人工复核状态与修订字段。 +10. 第一版支持从 checkpoint 手动恢复训练,不做自动失败续训。失败任务可选择 checkpoint 重试。 +11. 第一版必须支持 checkpoint 自动清理策略:默认保留最近 3 个、最优 2 个;已发布模型关联 checkpoint 不自动删除;失败任务 checkpoint 默认保留 14 天。 +12. 第一版提供内部 API,第二期再开放面向其他系统的标准 API、API Key、限流和 Webhook。 +13. 第一版使用本地账号,预留 OIDC/LDAP 字段和认证 provider 抽象;第二期接入企业统一身份认证。 +14. 第一版做 GPU 小时、磁盘占用、任务时长、推理调用量等用量统计;第二期再做成本单价和账单核算。 + +以上决策需要同步反映在接口文档、数据库 SQL、前端页面和部署方案中。第一版实现不再阻塞于这些问题的反复确认,除非实际部署环境与假设明显冲突。 + +## 14. 页面功能模块映射 + +本节用于帮助前端、后端、DB 和测试人员理解需求对应到哪些页面与功能模块。页面路径以当前 Vue 路由和新增规划路由为准。 + +### 14.1 平台入口与全局能力 + +| 页面模块 | 路由/入口 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 登录页 | `/login` | 用户认证、本地账号、后续预留 OIDC/LDAP | 登录、会话创建、权限加载 | +| 主布局 | `/` | 全局项目上下文、权限控制 | 菜单、顶部状态、项目切换器、用户信息 | +| 无权限页 | `/permission-denied` | 页面权限和资源权限兜底 | 展示无权限原因、返回可访问页面 | +| 服务看板 | `/dashboard` | 平台运行总览 | 服务健康、任务概览、训练统计、用户操作分布 | + +### 14.2 租户、项目与权限治理 + +| 页面模块 | 建议路由 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 租户管理 | `/tenants`、`/tenants/:id` | 多租户、租户配额、留存策略 | 租户列表、创建/禁用租户、配额、审计留存策略 | +| 项目空间 | `/projects`、`/projects/:id` | 项目级隔离、项目资源聚合 | 项目列表、项目概览、资源统计、项目归档 | +| 项目成员 | `/projects/:id/members` | 项目角色 | 添加成员、移除成员、设置 owner/maintainer/developer/reviewer/viewer | +| 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | 模型/数据集/任务级 ACL | 按用户、项目角色授权 read/write/execute/download/delete/manage_acl | +| 用户中心 | `/user-settings`、`/user-settings/create`、`/user-settings/:id/permission` | 用户、角色、页面权限 | 用户列表、创建用户、禁用、重置密码、分配页面权限和项目 | + +### 14.3 数据链路 + +| 页面模块 | 路由/入口 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 数据集列表 | `/dataset` | 数据集管理、项目隔离 | 列表、搜索、下载、删除审批入口 | +| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | 上传、本地磁盘、文件网关 | 创建数据集、上传文件、离线导入、元数据编辑 | +| 数据集预览 | `/dataset/:id/preview` | 文件版本、在线编辑、乐观锁 | 文件预览、版本切换、保存新版本、下载 | +| 数据处理列表 | `/data-process` | 数据处理任务管理 | 任务列表、状态、输出数据集跳转、删除审批 | +| 数据处理创建向导 | `/data-process/create` | 清洗、切片、生成、质量评分、脱敏 | 任务配置、模型选择、源文件/外部源、预览切片、生成、结果编辑、发布数据集 | +| 数据处理详情 | `/data-process/:id` | 处理统计和结果追踪 | 运行信息、处理统计、失败原因、结果明细 | +| 数据转换 | `/data-convert` | JSON/JSONL 转换 | 上传 JSON、转换任务、下载结果 | + +### 14.4 模型训练链路 + +| 页面模块 | 路由/入口 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 模型管理 | `/model-manage` | 模型登记、离线导入、资源 ACL | 基座模型/API 模型列表、用途变更、授权、删除审批 | +| 模型创建/编辑 | `/model-manage/create`、`/model-manage/:id/edit` | 本地模型/API 模型登记 | 选择本地路径、填写 API 模型信息、加密 API Key | +| 权重合并 | `/model-manage/merge` | LoRA 合并、产物管理 | 选择训练产物、合并权重、生成 merged model | +| 微调任务列表 | `/fine-tune` | 训练任务管理 | 任务列表、状态、进度、停止/删除审批 | +| 微调创建 | `/fine-tune/create` | LLaMA-Factory 训练配置、GPU 调度 | 选择模型/数据集/GPU、训练参数、量化导出、提交审批或启动 | +| 训练日志详情 | `/training-log/:id` | 日志、指标、checkpoint、恢复训练 | 日志 tail、loss 曲线、GPU 状态、checkpoint 列表、恢复/重试 | +| 训练引擎管理 | `/training-engines` | LLaMA-Factory 插件化和后续引擎接入 | 引擎列表、能力声明、schema、健康检查 | + +### 14.5 评测、推理和发布 + +| 页面模块 | 路由/入口 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 评测列表 | `/model-eval` | 模型评测 | 评测任务列表、状态、分数、删除 | +| 评测创建 | `/model-eval/create` | 自动评测、LLM Judge、基础指标 | 选择模型/数据集/维度/GPU、配置指标、启动评测 | +| 评测详情 | `/model-eval/:id` | 样本级结果、人工复核预留 | 综合评价、维度汇总、样本评分、错误类型 | +| 评测维度 | `/model-eval/dimension/:id/edit` | 维度和评测 Prompt 管理 | 创建/编辑维度、评分范围、Prompt、启用状态 | +| 推理列表 | `/model-inference` | 测试推理服务 | 推理任务列表、加载/卸载、服务状态 | +| 推理创建 | `/model-inference/create` | 模型服务资源申请 | 选择模型、GPU、端口策略、并发参数 | +| 推理对话 | `/model-inference/chat/:id` | 模型对话 | 单模型流式对话、会话记录 | +| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 多模型对比 | 多模型加载、并行对话、对比结果 | +| 模型发布治理 | `/model-services`、`/model-services/:id` | 测试/生产服务、发布审批 | 测试服务启动、生产发布申请、调用统计、下线审批 | + +### 14.6 算力、审批、审计和运维 + +| 页面模块 | 建议路由 | 对应需求 | 主要功能 | +| --- | --- | --- | --- | +| 算力资源中心 | `/compute`、`/compute/gpus` | 单机多 GPU、资源锁定、队列 | GPU 卡片、任务占用、节点状态、队列、优先级 | +| 存储管理 | `/storage` | 本地磁盘、配额、清理 | 租户/项目占用、大文件、临时文件、checkpoint 清理 | +| 审批中心 | `/approvals`、`/approvals/pending`、`/approvals/mine`、`/approvals/:id` | 审批流 | 我的申请、待我审批、审批详情、通过/驳回/撤回 | +| 审批模板 | `/approval-settings` | 审批策略配置 | 按动作配置审批人、超时、风险级别 | +| 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | 操作审计和留存 | 操作审计、登录审计、下载审计、筛选导出 | +| 平台性能 | `/hardware` | 系统监控 | CPU、内存、磁盘、GPU、进程 | +| 系统日志 | `/logs` | 日志查看 | 系统日志、训练日志、tail/offset 查询 | diff --git a/docs/postgres-schema.sql b/docs/postgres-schema.sql new file mode 100644 index 0000000..221a475 --- /dev/null +++ b/docs/postgres-schema.sql @@ -0,0 +1,1496 @@ +-- PostgreSQL schema for the model fine-tuning platform. +-- Recommended PostgreSQL version: 14+. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE SCHEMA IF NOT EXISTS ft_platform; +SET search_path TO ft_platform, public; + +-- ========================= +-- Common helpers +-- ========================= + +CREATE OR REPLACE FUNCTION ft_platform.set_updated_at() +RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION ft_platform.touch_updated_at(table_name regclass) +RETURNS void AS $$ +BEGIN + EXECUTE format('DROP TRIGGER IF EXISTS trg_set_updated_at ON %s', table_name); + EXECUTE format( + 'CREATE TRIGGER trg_set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE FUNCTION ft_platform.set_updated_at()', + table_name + ); +END; +$$ LANGUAGE plpgsql; + +-- ========================= +-- Enums +-- ========================= + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN + CREATE TYPE user_role AS ENUM ('admin', 'operator', 'viewer'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN + CREATE TYPE user_status AS ENUM ('active', 'disabled'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'task_status') THEN + CREATE TYPE task_status AS ENUM ('pending', 'running', 'completed', 'failed', 'stopped'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_type') THEN + CREATE TYPE model_type AS ENUM ('LLM', 'CV', 'NLP', 'Embedding', 'Other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_purpose') THEN + CREATE TYPE model_purpose AS ENUM ('training', 'inference', 'evaluation'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_source') THEN + CREATE TYPE model_source AS ENUM ('local', 'api'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_type') THEN + CREATE TYPE dataset_type AS ENUM ('train', 'test', 'eval', 'val', 'other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_storage') THEN + CREATE TYPE dataset_storage AS ENUM ('local', 'minio', 'cloud'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_source') THEN + CREATE TYPE dataset_source AS ENUM ('upload', 'task'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'train_type') THEN + CREATE TYPE train_type AS ENUM ('SFT', 'DPO', 'CPT'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'train_method') THEN + CREATE TYPE train_method AS ENUM ('lora', 'qlora', 'full', 'prefix', 'adapter', 'peft', 'adalora', 'longlora'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'process_type') THEN + CREATE TYPE process_type AS ENUM ('structured', 'unstructured', 'external'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eval_type') THEN + CREATE TYPE eval_type AS ENUM ('custom', 'baseline'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dimension_type') THEN + CREATE TYPE dimension_type AS ENUM ('classification', 'metric', 'text_similarity'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'result_status') THEN + CREATE TYPE result_status AS ENUM ('valid', 'modified', 'invalid'); + END IF; +END $$; + +-- ========================= +-- User center and RBAC +-- ========================= + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username citext NOT NULL UNIQUE, + display_name varchar(100) NOT NULL, + password_hash text NOT NULL, + role user_role NOT NULL DEFAULT 'viewer', + status user_status NOT NULL DEFAULT 'active', + protected boolean NOT NULL DEFAULT false, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('users'); + +CREATE TABLE IF NOT EXISTS permissions ( + code varchar(64) PRIMARY KEY, + name varchar(100) NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role user_role NOT NULL, + permission_code varchar(64) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE, + PRIMARY KEY (role, permission_code) +); + +CREATE TABLE IF NOT EXISTS user_permissions ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_code varchar(64) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE, + allowed boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, permission_code) +); + +CREATE TABLE IF NOT EXISTS login_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_jti uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), + ip inet, + user_agent text, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_login_sessions_user_created ON login_sessions(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_login_sessions_expires ON login_sessions(expires_at) WHERE revoked_at IS NULL; + +INSERT INTO permissions(code, name, description) VALUES + ('dashboard', '服务看板', '访问服务看板'), + ('fine-tune', '模型训练', '创建和管理微调任务'), + ('model-eval', '模型评测', '创建和管理评测任务'), + ('model-inference', '模型推理', '创建推理与模型对比任务'), + ('model-manage', '模型管理', '登记、编辑、删除模型'), + ('dataset', '数据集管理', '上传、编辑、下载数据集'), + ('data-process', '数据处理', '创建和管理数据处理任务'), + ('data-convert', '数据转换/工具', '使用数据转换和工具中心'), + ('hardware', '平台性能', '查看硬件和系统监控'), + ('logs', '查看日志', '查看系统与训练日志'), + ('user-settings', '用户设置', '管理用户和权限') +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description; + +INSERT INTO role_permissions(role, permission_code) +SELECT 'admin'::user_role, code FROM permissions +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions(role, permission_code) VALUES + ('operator', 'dashboard'), + ('operator', 'fine-tune'), + ('operator', 'model-eval'), + ('operator', 'model-inference'), + ('operator', 'model-manage'), + ('operator', 'dataset'), + ('operator', 'data-process'), + ('operator', 'data-convert'), + ('operator', 'hardware'), + ('operator', 'logs'), + ('viewer', 'dashboard'), + ('viewer', 'model-eval'), + ('viewer', 'model-inference'), + ('viewer', 'dataset'), + ('viewer', 'hardware'), + ('viewer', 'logs') +ON CONFLICT DO NOTHING; + +-- Replace this password hash during deployment. +INSERT INTO users(username, display_name, password_hash, role, status, protected) +VALUES ('admin', '系统管理员', '$argon2id$replace-with-real-hash', 'admin', 'active', true) +ON CONFLICT (username) DO NOTHING; + +-- ========================= +-- Audit and operation logs +-- ========================= + +CREATE TABLE IF NOT EXISTS audit_logs ( + id bigserial PRIMARY KEY, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + username citext, + action varchar(100) NOT NULL, + resource_type varchar(80) NOT NULL, + resource_id text, + request_method varchar(12), + request_path text, + ip inet, + user_agent text, + success boolean NOT NULL DEFAULT true, + error_message text, + before_data jsonb, + after_data jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC); + +CREATE TABLE IF NOT EXISTS web_logs ( + id bigserial PRIMARY KEY, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + level varchar(20) NOT NULL, + message text NOT NULL, + page text, + context jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_web_logs_created ON web_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_web_logs_level_created ON web_logs(level, created_at DESC); + +-- ========================= +-- Files and object storage metadata +-- ========================= + +CREATE TABLE IF NOT EXISTS storage_objects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + storage_type dataset_storage NOT NULL DEFAULT 'local', + bucket varchar(128), + object_key text NOT NULL, + original_name text, + mime_type varchar(200), + byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0), + checksum_sha256 char(64), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_object_location + ON storage_objects(storage_type, COALESCE(bucket, ''), object_key); +CREATE INDEX IF NOT EXISTS idx_storage_objects_checksum ON storage_objects(checksum_sha256); + +-- ========================= +-- Model registry +-- ========================= + +CREATE TABLE IF NOT EXISTS models ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + type model_type NOT NULL DEFAULT 'LLM', + purpose model_purpose NOT NULL, + model_source model_source NOT NULL DEFAULT 'local', + description text, + path text, + api_url text, + api_key_encrypted text, + online_model_name varchar(200), + config jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT ck_model_local_or_api CHECK ( + (model_source = 'local' AND path IS NOT NULL) + OR + (model_source = 'api' AND api_url IS NOT NULL AND online_model_name IS NOT NULL) + ) +); +SELECT touch_updated_at('models'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_models_name_alive ON models(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_models_purpose ON models(purpose) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_models_type_source ON models(type, model_source) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS trained_models ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + base_model_id uuid REFERENCES models(id) ON DELETE SET NULL, + fine_tune_task_id uuid, + train_method train_method, + adapter_path text, + merged boolean NOT NULL DEFAULT false, + merging boolean NOT NULL DEFAULT false, + merged_path text, + export_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + metrics jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('trained_models'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_trained_models_name_alive ON trained_models(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_trained_models_task ON trained_models(fine_tune_task_id); +CREATE INDEX IF NOT EXISTS idx_trained_models_base ON trained_models(base_model_id); + +-- ========================= +-- Datasets and versions +-- ========================= + +CREATE TABLE IF NOT EXISTS datasets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + type dataset_type NOT NULL, + storage_type dataset_storage NOT NULL DEFAULT 'local', + source dataset_source NOT NULL DEFAULT 'upload', + source_task_id uuid, + size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0), + description text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('datasets'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_datasets_name_alive ON datasets(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_datasets_type_created ON datasets(type, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_datasets_source_task ON datasets(source_task_id) WHERE source = 'task'; + +CREATE TABLE IF NOT EXISTS dataset_files ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id uuid NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + name text NOT NULL, + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + current_version_id uuid, + size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0), + file_format varchar(40), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('dataset_files'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_dataset_files_name_alive + ON dataset_files(dataset_id, name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_dataset_files_dataset ON dataset_files(dataset_id) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS dataset_file_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_file_id uuid NOT NULL REFERENCES dataset_files(id) ON DELETE CASCADE, + version_no integer NOT NULL CHECK (version_no > 0), + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + content_preview text, + description text, + base_version_id uuid REFERENCES dataset_file_versions(id) ON DELETE SET NULL, + size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0), + checksum_sha256 char(64), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_dataset_file_versions_no + ON dataset_file_versions(dataset_file_id, version_no); +CREATE INDEX IF NOT EXISTS idx_dataset_file_versions_file_created + ON dataset_file_versions(dataset_file_id, created_at DESC); + +ALTER TABLE dataset_files + DROP CONSTRAINT IF EXISTS fk_dataset_files_current_version; +ALTER TABLE dataset_files + ADD CONSTRAINT fk_dataset_files_current_version + FOREIGN KEY (current_version_id) REFERENCES dataset_file_versions(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS dataset_records ( + id bigserial PRIMARY KEY, + dataset_id uuid NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + dataset_file_id uuid REFERENCES dataset_files(id) ON DELETE CASCADE, + version_id uuid REFERENCES dataset_file_versions(id) ON DELETE CASCADE, + line_no integer, + split varchar(20), + instruction text, + input text, + output text, + raw jsonb NOT NULL DEFAULT '{}'::jsonb, + status result_status NOT NULL DEFAULT 'valid', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dataset_records_dataset_id ON dataset_records(dataset_id, id); +CREATE INDEX IF NOT EXISTS idx_dataset_records_file_version ON dataset_records(dataset_file_id, version_id, line_no); +CREATE INDEX IF NOT EXISTS idx_dataset_records_split ON dataset_records(dataset_id, split); +CREATE INDEX IF NOT EXISTS idx_dataset_records_raw_gin ON dataset_records USING gin(raw); + +-- ========================= +-- Data processing +-- ========================= + +CREATE TABLE IF NOT EXISTS data_process_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + description text, + status task_status NOT NULL DEFAULT 'pending', + process_type process_type NOT NULL, + source_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL, + output_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL, + config jsonb NOT NULL DEFAULT '{}'::jsonb, + progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), + input_count bigint NOT NULL DEFAULT 0, + output_count bigint NOT NULL DEFAULT 0, + filtered_count bigint NOT NULL DEFAULT 0, + duplicate_count bigint NOT NULL DEFAULT 0, + error_count bigint NOT NULL DEFAULT 0, + failure_reason text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('data_process_tasks'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_data_process_tasks_name_alive + ON data_process_tasks(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_data_process_tasks_status_created + ON data_process_tasks(status, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_data_process_tasks_creator_created + ON data_process_tasks(created_by, created_at DESC) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS data_process_source_files ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE, + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + name text NOT NULL, + size_bytes bigint NOT NULL DEFAULT 0, + record_count bigint NOT NULL DEFAULT 0, + content_preview text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_data_process_source_files_task ON data_process_source_files(task_id); + +CREATE TABLE IF NOT EXISTS data_process_preview_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE, + source_file_id uuid REFERENCES data_process_source_files(id) ON DELETE CASCADE, + original_content text NOT NULL DEFAULT '', + edited_content text NOT NULL DEFAULT '', + source_start integer, + source_end integer, + source_start_line integer, + source_end_line integer, + token_count integer NOT NULL DEFAULT 0, + status varchar(20) NOT NULL DEFAULT 'original', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('data_process_preview_items'); +CREATE INDEX IF NOT EXISTS idx_data_process_preview_task_file + ON data_process_preview_items(task_id, source_file_id, created_at); + +CREATE TABLE IF NOT EXISTS data_process_results ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE, + preview_item_id uuid REFERENCES data_process_preview_items(id) ON DELETE SET NULL, + instruction text NOT NULL, + input text NOT NULL DEFAULT '', + output text NOT NULL, + original_instruction text, + original_input text, + original_output text, + status result_status NOT NULL DEFAULT 'valid', + error text, + split varchar(20), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('data_process_results'); +CREATE INDEX IF NOT EXISTS idx_data_process_results_task_status + ON data_process_results(task_id, status, id); +CREATE INDEX IF NOT EXISTS idx_data_process_results_task_split + ON data_process_results(task_id, split); + +-- ========================= +-- Fine-tune tasks +-- ========================= + +CREATE TABLE IF NOT EXISTS fine_tune_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + description text, + status task_status NOT NULL DEFAULT 'pending', + train_type train_type NOT NULL, + train_method train_method NOT NULL DEFAULT 'lora', + template varchar(80) NOT NULL DEFAULT 'qwen', + base_model_id uuid NOT NULL REFERENCES models(id) ON DELETE RESTRICT, + train_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL, + auto_merge boolean NOT NULL DEFAULT false, + output_model_name varchar(150), + gpus integer[] NOT NULL DEFAULT '{}', + params jsonb NOT NULL DEFAULT '{}'::jsonb, + progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), + process_id integer, + command text, + output_dir text, + log_file text, + train_duration_seconds integer, + failure_reason text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('fine_tune_tasks'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_fine_tune_tasks_name_alive + ON fine_tune_tasks(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_status_created + ON fine_tune_tasks(status, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_dataset ON fine_tune_tasks(train_dataset_id); +CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_base_model ON fine_tune_tasks(base_model_id); + +ALTER TABLE trained_models + DROP CONSTRAINT IF EXISTS fk_trained_models_fine_tune_task; +ALTER TABLE trained_models + ADD CONSTRAINT fk_trained_models_fine_tune_task + FOREIGN KEY (fine_tune_task_id) REFERENCES fine_tune_tasks(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS fine_tune_metrics ( + id bigserial PRIMARY KEY, + task_id uuid NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE, + step integer, + epoch numeric(10,4), + loss numeric(18,8), + learning_rate numeric(18,12), + grad_norm numeric(18,8), + metrics jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_fine_tune_metrics_task_step ON fine_tune_metrics(task_id, step); +CREATE INDEX IF NOT EXISTS idx_fine_tune_metrics_task_created ON fine_tune_metrics(task_id, created_at); + +-- ========================= +-- Inference and compare +-- ========================= + +CREATE TABLE IF NOT EXISTS inference_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + description text, + status task_status NOT NULL DEFAULT 'pending', + load_status jsonb NOT NULL DEFAULT '{"loaded_models":[]}'::jsonb, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('inference_tasks'); +CREATE INDEX IF NOT EXISTS idx_inference_tasks_status_created + ON inference_tasks(status, created_at DESC) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS inference_task_models ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES inference_tasks(id) ON DELETE CASCADE, + model_id uuid REFERENCES models(id) ON DELETE SET NULL, + trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL, + model_name varchar(150) NOT NULL, + model_path text, + gpu_id integer, + source varchar(40), + port integer, + pid integer, + status varchar(40) NOT NULL DEFAULT 'pending', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('inference_task_models'); +CREATE INDEX IF NOT EXISTS idx_inference_task_models_task ON inference_task_models(task_id); +CREATE INDEX IF NOT EXISTS idx_inference_task_models_pid ON inference_task_models(pid) WHERE pid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_inference_task_models_port_alive + ON inference_task_models(port) WHERE port IS NOT NULL AND status IN ('loading', 'ready'); + +CREATE TABLE IF NOT EXISTS chat_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inference_task_id uuid REFERENCES inference_tasks(id) ON DELETE SET NULL, + title varchar(200), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('chat_sessions'); +CREATE INDEX IF NOT EXISTS idx_chat_sessions_task_created ON chat_sessions(inference_task_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id bigserial PRIMARY KEY, + session_id uuid NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + model_id uuid REFERENCES models(id) ON DELETE SET NULL, + trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL, + role varchar(20) NOT NULL, + content text NOT NULL, + latency_ms integer, + token_usage jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_chat_messages_session_id ON chat_messages(session_id, id); + +-- ========================= +-- Evaluation +-- ========================= + +CREATE TABLE IF NOT EXISTS eval_dimensions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + type dimension_type NOT NULL, + description text, + eval_model_id uuid REFERENCES models(id) ON DELETE SET NULL, + eval_method jsonb NOT NULL DEFAULT '[]'::jsonb, + eval_prompt text, + is_active boolean NOT NULL DEFAULT true, + is_default boolean NOT NULL DEFAULT false, + bleu_n integer, + output_precision integer NOT NULL DEFAULT 3, + score_min numeric(12,4), + score_max numeric(12,4), + pass_threshold numeric(12,4), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('eval_dimensions'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_dimensions_name_alive + ON eval_dimensions(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS eval_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + eval_task_name varchar(150) NOT NULL, + eval_type eval_type NOT NULL DEFAULT 'custom', + model_id uuid REFERENCES models(id) ON DELETE SET NULL, + trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL, + dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL, + dimension_id uuid REFERENCES eval_dimensions(id) ON DELETE SET NULL, + gpu_id integer, + data_source varchar(40) NOT NULL DEFAULT 'dataset', + leaderboard boolean NOT NULL DEFAULT false, + basic_metrics jsonb NOT NULL DEFAULT '{}'::jsonb, + status task_status NOT NULL DEFAULT 'pending', + metric varchar(80), + score numeric(12,4), + overall_score numeric(12,4), + overall_score_max numeric(12,4), + overall_evaluation text, + improvement_suggestions jsonb NOT NULL DEFAULT '[]'::jsonb, + sample_count integer NOT NULL DEFAULT 0, + completed_count integer NOT NULL DEFAULT 0, + passed_count integer NOT NULL DEFAULT 0, + failure_reason text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('eval_tasks'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_tasks_name_alive + ON eval_tasks(eval_task_name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_eval_tasks_status_created ON eval_tasks(status, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_eval_tasks_model_dataset ON eval_tasks(model_id, dataset_id); + +CREATE TABLE IF NOT EXISTS eval_dimension_summaries ( + id bigserial PRIMARY KEY, + eval_task_id uuid NOT NULL REFERENCES eval_tasks(id) ON DELETE CASCADE, + name varchar(150) NOT NULL, + score numeric(12,4), + max_score numeric(12,4), + pass_rate numeric(6,2), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_eval_dimension_summaries_task ON eval_dimension_summaries(eval_task_id); + +CREATE TABLE IF NOT EXISTS eval_sample_results ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + eval_task_id uuid NOT NULL REFERENCES eval_tasks(id) ON DELETE CASCADE, + sample_index integer NOT NULL, + input text NOT NULL, + reference_answer text, + model_output text NOT NULL DEFAULT '', + score numeric(12,4), + max_score numeric(12,4), + passed boolean, + status task_status NOT NULL DEFAULT 'pending', + judgement varchar(40), + evaluation_reason text, + error_type varchar(80), + dimension_scores jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('eval_sample_results'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_sample_results_task_index + ON eval_sample_results(eval_task_id, sample_index); +CREATE INDEX IF NOT EXISTS idx_eval_sample_results_task_status + ON eval_sample_results(eval_task_id, status); + +-- ========================= +-- Data convert and custom tools +-- ========================= + +CREATE TABLE IF NOT EXISTS data_convert_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + convert_type varchar(40) NOT NULL DEFAULT 'json_to_jsonl', + status task_status NOT NULL DEFAULT 'pending', + output_name varchar(200) NOT NULL, + encoding varchar(40) NOT NULL DEFAULT 'UTF-8', + source_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + result_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + error_message text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('data_convert_jobs'); +CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_user_created ON data_convert_jobs(created_by, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_status ON data_convert_jobs(status, created_at DESC); + +CREATE TABLE IF NOT EXISTS custom_tools ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(100) NOT NULL, + description text, + url text NOT NULL, + icon varchar(80) NOT NULL DEFAULT 'fa-cog', + visibility varchar(20) NOT NULL DEFAULT 'private', + owner_id uuid REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('custom_tools'); +CREATE INDEX IF NOT EXISTS idx_custom_tools_owner ON custom_tools(owner_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_custom_tools_visibility ON custom_tools(visibility) WHERE deleted_at IS NULL; + +-- ========================= +-- Metrics snapshots +-- ========================= + +CREATE TABLE IF NOT EXISTS system_metric_snapshots ( + id bigserial PRIMARY KEY, + cpu jsonb NOT NULL DEFAULT '{}'::jsonb, + memory jsonb NOT NULL DEFAULT '{}'::jsonb, + disk jsonb NOT NULL DEFAULT '{}'::jsonb, + gpu jsonb NOT NULL DEFAULT '[]'::jsonb, + network jsonb NOT NULL DEFAULT '{}'::jsonb, + system jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_system_metric_snapshots_created ON system_metric_snapshots(created_at DESC); + +CREATE TABLE IF NOT EXISTS service_status_snapshots ( + id bigserial PRIMARY KEY, + service_name varchar(100) NOT NULL, + state varchar(30) NOT NULL, + instances_online integer NOT NULL DEFAULT 0, + instances_total integer NOT NULL DEFAULT 0, + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_service_status_snapshots_name_created + ON service_status_snapshots(service_name, created_at DESC); + +-- ========================= +-- Useful views +-- ========================= + +CREATE OR REPLACE VIEW v_user_effective_permissions AS +SELECT + u.id AS user_id, + u.username, + p.code AS permission_code, + COALESCE(up.allowed, rp.permission_code IS NOT NULL, false) AS allowed +FROM users u +CROSS JOIN permissions p +LEFT JOIN role_permissions rp + ON rp.role = u.role AND rp.permission_code = p.code +LEFT JOIN user_permissions up + ON up.user_id = u.id AND up.permission_code = p.code +WHERE u.deleted_at IS NULL; + +CREATE OR REPLACE VIEW v_dataset_summary AS +SELECT + d.id, + d.name, + d.type, + d.storage_type, + d.source, + d.source_task_id, + d.size_bytes, + d.record_count, + d.description, + count(df.id) FILTER (WHERE df.deleted_at IS NULL) AS file_count, + d.created_at, + d.updated_at +FROM datasets d +LEFT JOIN dataset_files df ON df.dataset_id = d.id +WHERE d.deleted_at IS NULL +GROUP BY d.id; + +-- ========================= +-- Maintenance notes +-- ========================= + +-- 1. For very large installations, convert audit_logs, web_logs, +-- system_metric_snapshots, fine_tune_metrics and eval_sample_results to +-- monthly/range partitions. +-- 2. Keep large file bodies in storage_objects, not in relational rows. +-- 3. Encrypt api_key_encrypted and external source secrets at the application layer +-- with KMS or a deployment secret. +-- 4. Use soft delete for user-facing resources to preserve audit trails. + +-- ========================= +-- Enterprise governance and compute extension +-- ========================= + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'tenant_status') THEN + CREATE TYPE tenant_status AS ENUM ('active', 'disabled'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'project_status') THEN + CREATE TYPE project_status AS ENUM ('active', 'archived', 'disabled'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'project_role') THEN + CREATE TYPE project_role AS ENUM ('owner', 'maintainer', 'developer', 'reviewer', 'viewer'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_status') THEN + CREATE TYPE approval_status AS ENUM ('not_required', 'pending', 'approved', 'rejected', 'cancelled', 'expired'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'compute_job_type') THEN + CREATE TYPE compute_job_type AS ENUM ('fine_tune', 'eval', 'data_process', 'inference', 'merge', 'convert', 'import'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_priority') THEN + CREATE TYPE job_priority AS ENUM ('low', 'normal', 'high', 'urgent'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'gpu_status') THEN + CREATE TYPE gpu_status AS ENUM ('idle', 'reserved', 'running', 'draining', 'offline', 'error'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'service_level') THEN + CREATE TYPE service_level AS ENUM ('test', 'production'); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS tenants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code citext NOT NULL UNIQUE, + name varchar(150) NOT NULL, + status tenant_status NOT NULL DEFAULT 'active', + owner_id uuid REFERENCES users(id) ON DELETE SET NULL, + quota_config jsonb NOT NULL DEFAULT '{}'::jsonb, + retention_config jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('tenants'); +CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS tenant_users ( + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role user_role NOT NULL DEFAULT 'viewer', + is_tenant_admin boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_tenant_users_user ON tenant_users(user_id); + +CREATE TABLE IF NOT EXISTS projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + code citext NOT NULL, + name varchar(150) NOT NULL, + description text, + status project_status NOT NULL DEFAULT 'active', + owner_id uuid REFERENCES users(id) ON DELETE SET NULL, + quota_config jsonb NOT NULL DEFAULT '{}'::jsonb, + default_acl jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + archived_at timestamptz, + deleted_at timestamptz, + UNIQUE (tenant_id, code) +); +SELECT touch_updated_at('projects'); +CREATE INDEX IF NOT EXISTS idx_projects_tenant_status ON projects(tenant_id, status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'viewer', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, user_id) +); +SELECT touch_updated_at('project_members'); +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id); + +CREATE TABLE IF NOT EXISTS storage_nodes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code citext NOT NULL UNIQUE, + name varchar(150) NOT NULL, + node_type varchar(40) NOT NULL DEFAULT 'compute_local', + base_path text NOT NULL, + total_bytes bigint, + used_bytes bigint NOT NULL DEFAULT 0, + status varchar(40) NOT NULL DEFAULT 'online', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('storage_nodes'); + +CREATE TABLE IF NOT EXISTS training_engines ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code citext NOT NULL UNIQUE, + name varchar(150) NOT NULL, + version varchar(80), + engine_type varchar(60) NOT NULL DEFAULT 'llama_factory', + executable_path text, + python_env_path text, + supported_task_types jsonb NOT NULL DEFAULT '[]'::jsonb, + supported_methods jsonb NOT NULL DEFAULT '[]'::jsonb, + supported_formats jsonb NOT NULL DEFAULT '[]'::jsonb, + schema jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(40) NOT NULL DEFAULT 'enabled', + last_health_check_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('training_engines'); + +INSERT INTO training_engines( + code, name, engine_type, supported_task_types, supported_methods, supported_formats, status +) VALUES ( + 'llama_factory', + 'LLaMA-Factory', + 'llama_factory', + '["SFT", "DPO", "CPT"]'::jsonb, + '["lora", "qlora", "full"]'::jsonb, + '["alpaca", "sharegpt", "dpo_pair", "pretrain_text"]'::jsonb, + 'enabled' +) ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS quotas ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + subject_type varchar(40) NOT NULL, + subject_id uuid, + gpu_concurrency integer NOT NULL DEFAULT 0, + storage_bytes bigint NOT NULL DEFAULT 0, + max_running_jobs integer NOT NULL DEFAULT 0, + max_projects integer NOT NULL DEFAULT 0, + max_upload_file_bytes bigint NOT NULL DEFAULT 0, + config jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (subject_type, subject_id) +); +SELECT touch_updated_at('quotas'); +CREATE INDEX IF NOT EXISTS idx_quotas_tenant_project ON quotas(tenant_id, project_id); + +CREATE TABLE IF NOT EXISTS quota_usage ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + subject_type varchar(40) NOT NULL, + subject_id uuid, + gpu_running integer NOT NULL DEFAULT 0, + storage_used_bytes bigint NOT NULL DEFAULT 0, + running_jobs integer NOT NULL DEFAULT 0, + usage_detail jsonb NOT NULL DEFAULT '{}'::jsonb, + measured_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (subject_type, subject_id) +); +CREATE INDEX IF NOT EXISTS idx_quota_usage_tenant_project ON quota_usage(tenant_id, project_id); + +CREATE TABLE IF NOT EXISTS retention_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + audit_days integer NOT NULL DEFAULT 180, + login_log_days integer NOT NULL DEFAULT 180, + training_log_days integer NOT NULL DEFAULT 90, + metric_raw_days integer NOT NULL DEFAULT 30, + temp_file_days integer NOT NULL DEFAULT 1, + failed_job_workspace_days integer NOT NULL DEFAULT 14, + checkpoint_policy jsonb NOT NULL DEFAULT '{"keep_last":3,"keep_best":2,"failed_job_days":14}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, project_id) +); +SELECT touch_updated_at('retention_policies'); + +CREATE TABLE IF NOT EXISTS approval_templates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + action varchar(80) NOT NULL, + name varchar(150) NOT NULL, + enabled boolean NOT NULL DEFAULT true, + approver_rules jsonb NOT NULL DEFAULT '[]'::jsonb, + risk_level varchar(40) NOT NULL DEFAULT 'medium', + expire_hours integer NOT NULL DEFAULT 24, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('approval_templates'); +CREATE INDEX IF NOT EXISTS idx_approval_templates_scope_action + ON approval_templates(tenant_id, project_id, action); + +CREATE TABLE IF NOT EXISTS approval_instances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + template_id uuid REFERENCES approval_templates(id) ON DELETE SET NULL, + action varchar(80) NOT NULL, + resource_type varchar(80) NOT NULL, + resource_id text NOT NULL, + reason text, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + status approval_status NOT NULL DEFAULT 'pending', + requested_by uuid REFERENCES users(id) ON DELETE SET NULL, + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + decided_at timestamptz, + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('approval_instances'); +CREATE INDEX IF NOT EXISTS idx_approval_instances_status_created + ON approval_instances(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_approval_instances_scope + ON approval_instances(tenant_id, project_id, action, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_approval_instances_resource + ON approval_instances(resource_type, resource_id); + +CREATE TABLE IF NOT EXISTS approval_steps ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + approval_id uuid NOT NULL REFERENCES approval_instances(id) ON DELETE CASCADE, + step_no integer NOT NULL, + approver_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + approver_role varchar(80), + status approval_status NOT NULL DEFAULT 'pending', + comment text, + decided_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (approval_id, step_no) +); +CREATE INDEX IF NOT EXISTS idx_approval_steps_approver + ON approval_steps(approver_user_id, status); + +CREATE TABLE IF NOT EXISTS compute_nodes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code citext NOT NULL UNIQUE, + name varchar(150) NOT NULL, + host varchar(200) NOT NULL, + api_base_url text NOT NULL, + storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL, + status varchar(40) NOT NULL DEFAULT 'online', + agent_version varchar(80), + gpu_count integer NOT NULL DEFAULT 0, + last_heartbeat_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('compute_nodes'); +CREATE INDEX IF NOT EXISTS idx_compute_nodes_status ON compute_nodes(status); + +CREATE TABLE IF NOT EXISTS gpu_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + gpu_index integer NOT NULL, + uuid varchar(120) NOT NULL, + name varchar(150) NOT NULL, + status gpu_status NOT NULL DEFAULT 'idle', + memory_total_mb integer NOT NULL DEFAULT 0, + memory_used_mb integer NOT NULL DEFAULT 0, + utilization_percent numeric(5,2) NOT NULL DEFAULT 0, + temperature numeric(5,2), + power_w numeric(8,2), + driver_version varchar(80), + partition_type varchar(40) NOT NULL DEFAULT 'full', + parent_gpu_uuid varchar(120), + current_job_id uuid, + last_seen_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (compute_node_id, gpu_index), + UNIQUE (uuid) +); +SELECT touch_updated_at('gpu_devices'); +CREATE INDEX IF NOT EXISTS idx_gpu_devices_node_status ON gpu_devices(compute_node_id, status); + +CREATE TABLE IF NOT EXISTS compute_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + app_task_type varchar(80) NOT NULL, + app_task_id uuid, + tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL, + project_id uuid REFERENCES projects(id) ON DELETE SET NULL, + compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL, + engine_id uuid REFERENCES training_engines(id) ON DELETE SET NULL, + job_type compute_job_type NOT NULL, + status task_status NOT NULL DEFAULT 'pending', + priority job_priority NOT NULL DEFAULT 'normal', + resource_request jsonb NOT NULL DEFAULT '{}'::jsonb, + workspace_root text, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb NOT NULL DEFAULT '{}'::jsonb, + progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), + pid integer, + port integer, + failure_reason text, + requested_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('compute_jobs'); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_scope_status + ON compute_jobs(tenant_id, project_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_node_status + ON compute_jobs(compute_node_id, status, priority, created_at); +CREATE INDEX IF NOT EXISTS idx_compute_jobs_app_task + ON compute_jobs(app_task_type, app_task_id); + +CREATE TABLE IF NOT EXISTS gpu_allocations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + compute_job_id uuid NOT NULL REFERENCES compute_jobs(id) ON DELETE CASCADE, + gpu_device_id uuid NOT NULL REFERENCES gpu_devices(id) ON DELETE RESTRICT, + tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL, + project_id uuid REFERENCES projects(id) ON DELETE SET NULL, + status varchar(40) NOT NULL DEFAULT 'reserved', + allocated_at timestamptz NOT NULL DEFAULT now(), + released_at timestamptz +); +CREATE INDEX IF NOT EXISTS idx_gpu_allocations_job ON gpu_allocations(compute_job_id); +CREATE INDEX IF NOT EXISTS idx_gpu_allocations_scope ON gpu_allocations(tenant_id, project_id, allocated_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS uq_gpu_allocations_active_gpu + ON gpu_allocations(gpu_device_id) WHERE released_at IS NULL; + +-- Multi compute-node scheduling and local-cache metadata. +-- Each GPU server is modeled as one compute node. Nodes do not call each other; +-- the application platform schedules jobs and syncs resources through each node's File Gateway. +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS file_gateway_url text; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS scheduler_status varchar(40) NOT NULL DEFAULT 'online'; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS scheduler_weight integer NOT NULL DEFAULT 100 CHECK (scheduler_weight >= 0); +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}'; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS service_token_encrypted text; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS data_root text NOT NULL DEFAULT '/data/yg-ft'; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS model_root text NOT NULL DEFAULT '/data/yg-ft/models'; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS log_root text NOT NULL DEFAULT '/opt/yg-ft/logs/compute'; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS max_parallel_jobs integer NOT NULL DEFAULT 1 CHECK (max_parallel_jobs >= 0); +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS current_running_jobs integer NOT NULL DEFAULT 0 CHECK (current_running_jobs >= 0); +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS last_health_check_at timestamptz; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS health_detail jsonb NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS drain_reason text; +CREATE INDEX IF NOT EXISTS idx_compute_nodes_scheduler + ON compute_nodes(enabled, scheduler_status, scheduler_weight DESC, last_health_check_at DESC); +CREATE INDEX IF NOT EXISTS idx_compute_nodes_tags_gin + ON compute_nodes USING gin(tags); + +ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS scheduler_mode varchar(40) NOT NULL DEFAULT 'auto'; +ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS requested_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL; +ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS assigned_at timestamptz; +ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS scheduler_reason text; +CREATE INDEX IF NOT EXISTS idx_compute_jobs_requested_node + ON compute_jobs(requested_node_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS compute_node_engines ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + engine_id uuid REFERENCES training_engines(id) ON DELETE SET NULL, + engine_code varchar(80) NOT NULL, + engine_version varchar(80), + home_path text, + status varchar(40) NOT NULL DEFAULT 'available', + capability jsonb NOT NULL DEFAULT '{}'::jsonb, + last_health_check_at timestamptz, + health_detail jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (compute_node_id, engine_code) +); +SELECT touch_updated_at('compute_node_engines'); +CREATE INDEX IF NOT EXISTS idx_compute_node_engines_node_status + ON compute_node_engines(compute_node_id, status, engine_code); + +CREATE TABLE IF NOT EXISTS resource_replicas ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL, + project_id uuid REFERENCES projects(id) ON DELETE SET NULL, + resource_type varchar(80) NOT NULL, + resource_id uuid NOT NULL, + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + local_path text NOT NULL, + status varchar(40) NOT NULL DEFAULT 'available', + sync_status varchar(40) NOT NULL DEFAULT 'synced', + checksum_sha256 char(64), + byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0), + version varchar(120), + pinned boolean NOT NULL DEFAULT false, + last_verified_at timestamptz, + expires_at timestamptz, + failure_reason text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (resource_type, resource_id, compute_node_id) +); +SELECT touch_updated_at('resource_replicas'); +CREATE INDEX IF NOT EXISTS idx_resource_replicas_resource + ON resource_replicas(resource_type, resource_id, status); +CREATE INDEX IF NOT EXISTS idx_resource_replicas_node_status + ON resource_replicas(compute_node_id, status, sync_status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_resource_replicas_scope + ON resource_replicas(tenant_id, project_id, resource_type, updated_at DESC); + +CREATE TABLE IF NOT EXISTS resource_sync_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL, + project_id uuid REFERENCES projects(id) ON DELETE SET NULL, + target_compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE, + source_compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL, + resource_type varchar(80) NOT NULL, + resource_id uuid NOT NULL, + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + status task_status NOT NULL DEFAULT 'pending', + transfer_mode varchar(40) NOT NULL DEFAULT 'app_proxy', + source_uri text, + target_path text NOT NULL, + byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0), + checksum_sha256 char(64), + progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), + failure_reason text, + requested_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('resource_sync_jobs'); +CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_status + ON resource_sync_jobs(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_target + ON resource_sync_jobs(target_compute_node_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_resource + ON resource_sync_jobs(resource_type, resource_id, status); + +CREATE TABLE IF NOT EXISTS resource_acl ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + resource_type varchar(80) NOT NULL, + resource_id text NOT NULL, + subject_type varchar(40) NOT NULL, + subject_id text NOT NULL, + permissions text[] NOT NULL DEFAULT '{}', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (resource_type, resource_id, subject_type, subject_id) +); +SELECT touch_updated_at('resource_acl'); +CREATE INDEX IF NOT EXISTS idx_resource_acl_resource + ON resource_acl(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_resource_acl_subject + ON resource_acl(subject_type, subject_id); + +CREATE TABLE IF NOT EXISTS file_upload_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL, + original_name text NOT NULL, + byte_size bigint NOT NULL DEFAULT 0, + checksum_sha256 char(64), + part_size_bytes integer NOT NULL DEFAULT 8388608, + uploaded_parts jsonb NOT NULL DEFAULT '[]'::jsonb, + status varchar(40) NOT NULL DEFAULT 'uploading', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL DEFAULT now() + interval '1 day', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('file_upload_sessions'); +CREATE INDEX IF NOT EXISTS idx_file_upload_sessions_scope_status + ON file_upload_sessions(tenant_id, project_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS local_import_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL, + import_type varchar(40) NOT NULL, + source_path text NOT NULL, + target_resource_id uuid, + status task_status NOT NULL DEFAULT 'pending', + scan_result jsonb NOT NULL DEFAULT '{}'::jsonb, + failure_reason text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +SELECT touch_updated_at('local_import_jobs'); +CREATE INDEX IF NOT EXISTS idx_local_import_jobs_scope_status + ON local_import_jobs(tenant_id, project_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS fine_tune_checkpoints ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + fine_tune_task_id uuid NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE, + storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL, + checkpoint_name varchar(200) NOT NULL, + step integer, + metric_name varchar(80), + metric_value numeric(18,8), + is_best boolean NOT NULL DEFAULT false, + protected boolean NOT NULL DEFAULT false, + size_bytes bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_fine_tune_checkpoints_task_step + ON fine_tune_checkpoints(fine_tune_task_id, step DESC); +CREATE INDEX IF NOT EXISTS idx_fine_tune_checkpoints_best + ON fine_tune_checkpoints(fine_tune_task_id, is_best) WHERE is_best; + +CREATE TABLE IF NOT EXISTS model_services ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + model_id uuid REFERENCES models(id) ON DELETE SET NULL, + trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL, + compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL, + name varchar(150) NOT NULL, + service_level service_level NOT NULL DEFAULT 'test', + status task_status NOT NULL DEFAULT 'pending', + gpu_device_id uuid REFERENCES gpu_devices(id) ON DELETE SET NULL, + port integer, + max_concurrency integer NOT NULL DEFAULT 1, + timeout_seconds integer NOT NULL DEFAULT 120, + max_context_tokens integer, + approval_id uuid REFERENCES approval_instances(id) ON DELETE SET NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + started_at timestamptz, + stopped_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +SELECT touch_updated_at('model_services'); +CREATE INDEX IF NOT EXISTS idx_model_services_scope_status + ON model_services(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS cleanup_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE CASCADE, + cleanup_type varchar(80) NOT NULL, + status task_status NOT NULL DEFAULT 'pending', + target jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb NOT NULL DEFAULT '{}'::jsonb, + failure_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz +); +CREATE INDEX IF NOT EXISTS idx_cleanup_jobs_status_created ON cleanup_jobs(status, created_at DESC); + +-- User identity provider extension. First release uses local accounts; +-- OIDC/LDAP can be enabled later without changing resource ownership tables. +ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider varchar(40) NOT NULL DEFAULT 'local'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE users ADD COLUMN IF NOT EXISTS default_tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_users_external_provider ON users(auth_provider, external_id); +CREATE INDEX IF NOT EXISTS idx_users_default_tenant ON users(default_tenant_id); + +-- Add tenant/project/resource governance columns to existing resource tables. +ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL; +ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project'; +ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +CREATE INDEX IF NOT EXISTS idx_storage_objects_scope + ON storage_objects(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE models ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE models ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE models ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE models ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project'; +ALTER TABLE models ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +CREATE INDEX IF NOT EXISTS idx_models_scope_status + ON models(tenant_id, project_id, approval_status, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project'; +ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +CREATE INDEX IF NOT EXISTS idx_trained_models_scope + ON trained_models(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project'; +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +CREATE INDEX IF NOT EXISTS idx_datasets_scope_status + ON datasets(tenant_id, project_id, type, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE dataset_files ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE dataset_files ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_dataset_files_scope + ON dataset_files(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_data_process_tasks_scope_status + ON data_process_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL; +ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS resume_checkpoint_id uuid REFERENCES fine_tune_checkpoints(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_scope_status + ON fine_tune_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_inference_tasks_scope_status + ON inference_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required'; +ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_eval_tasks_scope_status + ON eval_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_scope_status + ON data_convert_jobs(tenant_id, project_id, status, created_at DESC); + +ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_custom_tools_scope + ON custom_tools(tenant_id, project_id, visibility, created_at DESC) WHERE deleted_at IS NULL; + +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS approval_id uuid REFERENCES approval_instances(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_audit_logs_scope_created + ON audit_logs(tenant_id, project_id, created_at DESC); + +ALTER TABLE web_logs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL; +ALTER TABLE web_logs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_web_logs_scope_created + ON web_logs(tenant_id, project_id, created_at DESC); + +CREATE OR REPLACE VIEW v_project_member_permissions AS +SELECT + p.tenant_id, + pm.project_id, + pm.user_id, + pm.role, + CASE pm.role + WHEN 'owner' THEN ARRAY['read','write','execute','download','delete','manage_acl'] + WHEN 'maintainer' THEN ARRAY['read','write','execute','download','delete'] + WHEN 'developer' THEN ARRAY['read','write','execute','download'] + WHEN 'reviewer' THEN ARRAY['read','download'] + ELSE ARRAY['read'] + END AS permissions +FROM project_members pm +JOIN projects p ON p.id = pm.project_id +WHERE p.deleted_at IS NULL; diff --git a/docs/superpowers/plans/2026-07-10-data-process-create-wizard.md b/docs/superpowers/plans/2026-07-10-data-process-create-wizard.md new file mode 100644 index 0000000..9a1f8e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-data-process-create-wizard.md @@ -0,0 +1,458 @@ +# Data Process Create Wizard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 `/data-process/create` 实现为固定四步向导,并在第二步提供“右侧选择切片、左侧自动定位并高亮原文”的可编辑对照体验。 + +**Architecture:** `DataProcessCreateView.vue` 只负责向导状态、步骤切换和跨步骤数据;每个步骤拆成独立 Vue 组件。源文定位和切片生成由纯 TypeScript 模块负责,第二步组件只消费偏移范围并同步滚动、高亮和编辑状态。现有 Vue 3、Element Plus、SCSS 和 Font Awesome 继续使用,不引入新依赖。 + +**Tech Stack:** Vue 3.5、TypeScript 5.7、Vite 6、Element Plus 2.9、SCSS、Node.js 回归脚本、`vue-tsc`。 + +## Global Constraints + +- 顶部固定四步:`创建任务`、`数据预览`、`开始生成`、`结果编辑与保存`。 +- 结构化和非结构化类型不得改变步骤数量。 +- 第二步桌面端左侧约 58% 为只读源文件,右侧约 42% 为切片或记录列表及编辑器。 +- 点击右侧条目时,左侧必须定位并高亮 `sourceStart` 到 `sourceEnd` 的原始范围。 +- 编辑切片不得改写源文件;来源映射始终指向初始原文。 +- 每一步只能有一个主操作,不得同时出现“下一步”和“开始生成”等竞争动作。 +- 页面继续使用现有全局白色页面画布,不新增整页嵌套白卡。 +- 不增加第三方依赖。 + +--- + +### Task 1: 建立四步向导回归测试 + +**Files:** +- Create: `frontend/scripts/regression-data-process-wizard.mjs` +- Modify: `frontend/package.json` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: Vue SFC 源码、`@vue/compiler-sfc`、`@vue/compiler-dom`。 +- Produces: `npm run test:data-process-wizard`,验证固定步骤、组件边界、对照定位标记和底部唯一主操作。 + +- [ ] **Step 1: 写入当前实现必然失败的结构回归检查** + +```js +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseSfc } from '@vue/compiler-sfc' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const viewSource = await readFile( + path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue'), + 'utf8', +) +const previewSource = await readFile( + path.resolve(scriptDir, '../src/views/data-process/create/PreviewCompareStep.vue'), + 'utf8', +) + +assert.match(viewSource, /const WIZARD_STEPS = \[/) +for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) { + assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`) +} +assert.doesNotMatch(viewSource, /all\.filter|steps\s*=\s*computed/) +assert.match(previewSource, /class="source-viewer"/) +assert.match(previewSource, /class="preview-workspace"/) +assert.match(previewSource, /scrollIntoView/) +assert.match(previewSource, /sourceStart/) +assert.match(previewSource, /sourceEnd/) + +const { descriptor } = parseSfc(viewSource) +assert.ok(descriptor.template?.content.includes('TaskSetupStep')) +assert.ok(descriptor.template?.content.includes('PreviewCompareStep')) +assert.ok(descriptor.template?.content.includes('GenerationStep')) +assert.ok(descriptor.template?.content.includes('ResultEditorStep')) + +console.log('数据处理四步向导回归检查通过') +``` + +- [ ] **Step 2: 在 `package.json` 注册命令** + +```json +{ + "scripts": { + "test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs" + } +} +``` + +- [ ] **Step 3: 运行测试并确认失败原因正确** + +Run: `cd frontend && npm run test:data-process-wizard` + +Expected: FAIL,首先因 `PreviewCompareStep.vue` 不存在或固定步骤断言不成立而失败。 + +### Task 2: 建立向导类型、草稿状态和来源映射模型 + +**Files:** +- Create: `frontend/src/views/data-process/create/types.ts` +- Create: `frontend/src/views/data-process/create/previewModel.ts` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: 上传文件解析出的字符串。 +- Produces: `ProcessType`、`StepId`、`PreviewItem`、`ResultItem`、`DataProcessDraft`;`buildPreviewItems(sourceText, processType)` 和 `sourceLines(sourceText)`。 + +- [ ] **Step 1: 在回归脚本增加模型文件和关键字段断言** + +```js +const typesSource = await readFile( + path.resolve(scriptDir, '../src/views/data-process/create/types.ts'), + 'utf8', +) +const modelSource = await readFile( + path.resolve(scriptDir, '../src/views/data-process/create/previewModel.ts'), + 'utf8', +) +for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) { + assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`) +} +assert.match(modelSource, /export function buildPreviewItems/) +assert.match(modelSource, /export function sourceLines/) +``` + +- [ ] **Step 2: 定义稳定类型** + +```ts +export type ProcessType = 'structured' | 'unstructured' +export type StepId = 'create' | 'preview' | 'generate' | 'results' + +export interface PreviewItem { + id: string + originalContent: string + editedContent: string + sourceStart: number | null + sourceEnd: number | null + sourceStartLine: number | null + sourceEndLine: number | null + tokenCount: number + status: 'original' | 'modified' | 'manual' | 'invalid' +} + +export interface ResultItem { + id: string + instruction: string + input: string + output: string + status: 'valid' | 'modified' | 'invalid' + error?: string +} +``` + +- [ ] **Step 3: 实现可重复的来源偏移生成** + +```ts +export function buildPreviewItems(sourceText: string, processType: ProcessType): PreviewItem[] { + const lines = sourceText.split('\n') + const groupSize = processType === 'structured' ? 1 : 3 + let cursor = 0 + const ranges = lines.map((line, index) => { + const start = cursor + cursor += line.length + (index < lines.length - 1 ? 1 : 0) + return { line, lineNumber: index + 1, start, end: start + line.length } + }) + + const items: PreviewItem[] = [] + for (let index = 0; index < ranges.length; index += groupSize) { + const group = ranges.slice(index, index + groupSize) + if (!group.length || group.every((item) => !item.line.trim())) continue + const content = group.map((item) => item.line).join('\n') + items.push({ + id: `preview-${items.length + 1}`, + originalContent: content, + editedContent: content, + sourceStart: group[0].start, + sourceEnd: group[group.length - 1].end, + sourceStartLine: group[0].lineNumber, + sourceEndLine: group[group.length - 1].lineNumber, + tokenCount: Math.max(1, Math.ceil(content.length / 2)), + status: 'original', + }) + } + return items +} +``` + +- [ ] **Step 4: 运行回归检查和类型检查** + +Run: `cd frontend && npm run test:data-process-wizard && npm run type-check` + +Expected: 回归测试继续因组件未完成而失败;`previewModel.ts` 和 `types.ts` 不产生 TypeScript 错误。 + +### Task 3: 实现向导壳层和第一步创建任务 + +**Files:** +- Create: `frontend/src/views/data-process/create/TaskSetupStep.vue` +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: `ProcessType`、Element Plus 表单与上传组件。 +- Produces: `TaskSetupStep` 的 `v-model:name`、`v-model:description`、`v-model:processType`、`file-change`、`remove-file` 事件;父页面提供固定 `WIZARD_STEPS` 和统一底部操作。 + +- [ ] **Step 1: 将父页面步骤定义改为不可变四步** + +```ts +const WIZARD_STEPS = [ + { id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' }, + { id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' }, + { id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' }, + { id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' }, +] as const +``` + +- [ ] **Step 2: 创建第一步组件,保留现有校验并改为视觉选择块** + +```ts +const props = defineProps<{ + name: string + description: string + processType: ProcessType + file: File | null + fileCount: number +}>() + +const emit = defineEmits<{ + 'update:name': [value: string] + 'update:description': [value: string] + 'update:processType': [value: ProcessType] + 'file-change': [file: UploadFile] + 'remove-file': [] +}>() +``` + +- [ ] **Step 3: 在父页面统一步骤导航和底部动作文案** + +```ts +const primaryActionLabel = computed(() => ({ + create: '继续:数据预览', + preview: '确认预览并继续', + generate: generation.progress === 100 ? '查看生成结果' : '开始生成', + results: '保存任务', +}[currentStepId.value])) +``` + +- [ ] **Step 4: 运行回归检查和类型检查** + +Run: `cd frontend && npm run test:data-process-wizard && npm run type-check` + +Expected: 回归测试因后续三个组件缺失而失败;第一步相关代码通过类型检查。 + +### Task 4: 实现左右源文件与切片同步预览 + +**Files:** +- Create: `frontend/src/views/data-process/create/PreviewCompareStep.vue` +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: `sourceText: string`、`items: PreviewItem[]`、`selectedId: string | null`、`processType: ProcessType`。 +- Produces: `update:selectedId`、`update:item-content`、`restore:item`;选中条目变化时调用 `scrollIntoView({ block: 'center' })`。 + +- [ ] **Step 1: 增加源文范围与选中态的结构断言** + +```js +for (const marker of [ + 'source-viewer', + 'source-line', + 'is-highlighted', + 'preview-item', + 'preview-editor', + 'scrollIntoView', +]) { + assert.ok(previewSource.includes(marker), `第二步缺少结构:${marker}`) +} +``` + +- [ ] **Step 2: 通过行偏移判断高亮范围** + +```ts +function isLineHighlighted(lineStart: number, lineEnd: number) { + if (!selectedItem.value || selectedItem.value.sourceStart == null || selectedItem.value.sourceEnd == null) { + return false + } + return lineEnd >= selectedItem.value.sourceStart + && lineStart <= selectedItem.value.sourceEnd +} +``` + +- [ ] **Step 3: 选中切片后定位首个高亮行** + +```ts +watch(selectedItem, async (item) => { + if (!item || item.sourceStart == null) return + await nextTick() + sourceViewerRef.value + ?.querySelector(`[data-offset="${item.sourceStart}"]`) + ?.scrollIntoView({ block: 'center', behavior: 'smooth' }) +}) +``` + +- [ ] **Step 4: 编辑时只更新 `editedContent` 和状态** + +```ts +function updateContent(item: PreviewItem, value: string) { + emit('update:item-content', item.id, value) +} +``` + +父组件处理事件时不得修改 `sourceText`、`sourceStart` 或 `sourceEnd`: + +```ts +function updatePreviewContent(id: string, value: string) { + const item = draft.previewItems.find((entry) => entry.id === id) + if (!item) return + item.editedContent = value + item.status = value === item.originalContent ? 'original' : 'modified' + draft.dirty = true +} +``` + +- [ ] **Step 5: 完成搜索、仅看已修改、上一片、下一片和恢复原文** + +Run: `cd frontend && npm run test:data-process-wizard && npm run type-check` + +Expected: 第二步结构断言通过,类型检查通过;回归测试只因第三、四步组件缺失而失败。 + +### Task 5: 实现生成与结果编辑两个独立步骤 + +**Files:** +- Create: `frontend/src/views/data-process/create/GenerationStep.vue` +- Create: `frontend/src/views/data-process/create/ResultEditorStep.vue` +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: 任务摘要、预览条目、生成状态和结果条目。 +- Produces: `start`、`stop`、`retry`、`update:result`、`restore:result`、`save` 事件。 + +- [ ] **Step 1: 生成步骤只保留摘要、进度与状态** + +```ts +const emit = defineEmits<{ + start: [] + stop: [] + retry: [] +}>() +``` + +生成完成前底部唯一主操作为 `开始生成`;生成进行中为禁用的 `正在生成`;完成后变为 `查看生成结果`。 + +- [ ] **Step 2: 清理并托管模拟生成计时器** + +```ts +let generationTimer: ReturnType | null = null + +function stopGenerationTimer() { + if (generationTimer) clearInterval(generationTimer) + generationTimer = null +} + +onBeforeUnmount(stopGenerationTimer) +``` + +- [ ] **Step 3: 将预览条目转换为结构化结果字段** + +```ts +function createResults(items: PreviewItem[]): ResultItem[] { + return items.slice(0, 12).map((item, index) => ({ + id: `result-${index + 1}`, + instruction: item.editedContent.split('\n')[0] || `数据条目 ${index + 1}`, + input: '', + output: item.editedContent.split('\n').slice(1).join('\n') || item.editedContent, + status: 'valid', + })) +} +``` + +- [ ] **Step 4: 使用左侧结果列表和右侧字段编辑器替代原始 JSON 文本框** + +```ts +function validateResult(item: ResultItem) { + item.error = item.instruction.trim() && item.output.trim() ? undefined : '指令和输出不能为空' + item.status = item.error ? 'invalid' : 'modified' +} +``` + +- [ ] **Step 5: 运行回归、页面表面和类型检查** + +Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check` + +Expected: 三项检查全部 PASS。 + +### Task 6: 完成视觉实现、响应式和浏览器验收 + +**Files:** +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` +- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue` +- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue` +- Modify: `frontend/src/views/data-process/create/GenerationStep.vue` +- Modify: `frontend/src/views/data-process/create/ResultEditorStep.vue` +- Test: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: 已完成的四步组件和现有全局页面画布。 +- Produces: 与确认修订稿一致的桌面布局,以及 900px 以下的上下布局。 + +- [ ] **Step 1: 落实单层页面、固定步骤和底部操作栏样式** + +```scss +.wizard-footer { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + background: rgba(255, 255, 255, 0.98); + border-top: 1px solid #eef0f5; +} +``` + +- [ ] **Step 2: 落实桌面左右对照和 900px 响应式** + +```scss +.preview-workspace { + display: grid; + grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr); +} + +@media (max-width: 900px) { + .preview-workspace { + grid-template-columns: minmax(0, 1fr); + } +} +``` + +- [ ] **Step 3: 启动页面并逐步验证四步交互** + +Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 16801` + +Browser checks at `http://localhost:16801/data-process/create`: + +1. 第一步上传文本并选择非结构化数据。 +2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。 +3. 修改一个切片并切换前后条目,确认修改状态和内容保留。 +4. 完成生成并进入第四步,修改结果字段并保存。 +5. 返回前一步,确认草稿和选中项未丢失。 +6. 以 1440×1024 和 900px 窄屏分别截图,确认无横向溢出和底部遮挡。 + +- [ ] **Step 4: 执行完整验证** + +Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check && npm run build` + +Expected: 所有回归脚本、类型检查和生产构建全部 PASS。 + +## Self-Review Result + +- 规格中的四步稳定语义由 Tasks 1、3、5 覆盖。 +- 左右对照、来源映射、滚动高亮、编辑不改源文件由 Tasks 2、4 覆盖。 +- 生成状态、计时器清理、结果字段校验由 Task 5 覆盖。 +- 单层白底、响应式、路由转场连续性和最终验收由 Task 6 覆盖。 +- 未引入新依赖;计划中所有类型和事件名在前置任务中已有定义。 + diff --git a/docs/superpowers/plans/2026-07-10-data-process-status-tabs-removal.md b/docs/superpowers/plans/2026-07-10-data-process-status-tabs-removal.md new file mode 100644 index 0000000..f8fed01 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-data-process-status-tabs-removal.md @@ -0,0 +1,117 @@ +# 数据处理任务状态切换移除 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 移除数据处理任务列表左上角的三个状态切换按钮,并让表格始终展示全部任务。 + +**Architecture:** 保持现有 `DataTablePage` 结构不变,仅删除 `DataProcessListView.vue` 内部的页签状态、派生筛选数据、标题插槽和专用样式。新增一个轻量源码回归脚本,锁定“无状态切换组件且表格直接使用完整数据源”的行为。 + +**Tech Stack:** Vue 3、TypeScript、Element Plus、Node.js `assert`、Vue SFC parser + +--- + +### Task 1: 增加状态切换移除回归检查 + +**Files:** +- Create: `frontend/scripts/regression-data-process-list.mjs` +- Modify: `frontend/package.json` +- Test: `frontend/scripts/regression-data-process-list.mjs` + +- [ ] **Step 1: 编写失败的回归检查** + +```js +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { parse as parseSfc } from '@vue/compiler-sfc' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessListView.vue') +const source = await readFile(viewPath, 'utf8') +const { descriptor, errors } = parseSfc(source, { filename: viewPath }) + +assert.equal(errors.length, 0, `数据处理任务列表模板无法解析:${errors[0]}`) +assert.ok(descriptor.template?.content.trim(), '数据处理任务列表缺少可渲染模板') +assert.match(source, /:data="dataList"/, '任务表格必须直接展示完整任务数据') +assert.doesNotMatch(source, /activeTab|filteredDataList/, '不应保留状态切换筛选逻辑') +assert.doesNotMatch(source, /全部任务|处理中|已完成/, '不应保留状态切换按钮文案') +assert.doesNotMatch(source, /capsule-tabs|capsule-tab-item/, '不应保留状态切换专用样式') + +console.log('数据处理任务列表状态切换移除回归检查通过') +``` + +在 `frontend/package.json` 的 `scripts` 中增加: + +```json +"test:data-process-list": "node scripts/regression-data-process-list.mjs" +``` + +- [ ] **Step 2: 运行检查并确认先失败** + +Run: `npm run test:data-process-list` + +Expected: FAIL,错误指出任务表格尚未直接使用 `dataList`,或仍存在状态切换逻辑。 + +- [ ] **Step 3: 提交回归检查** + +```bash +git add frontend/package.json frontend/scripts/regression-data-process-list.mjs +git commit -m "test: 覆盖数据处理任务列表布局" +``` + +### Task 2: 移除状态切换组件和筛选逻辑 + +**Files:** +- Modify: `frontend/src/views/data-process/DataProcessListView.vue` +- Test: `frontend/scripts/regression-data-process-list.mjs` + +- [ ] **Step 1: 实现最小改动** + +将脚本导入改为仅保留 `ref`: + +```ts +import { ref } from 'vue' +``` + +删除 `activeTab` 和 `filteredDataList`,并将表格数据源改为: + +```vue + +``` + +同时删除整个 `#title` 插槽以及 `.capsule-tabs`、`.capsule-tab-item` 样式,仅保留操作按钮样式。 + +- [ ] **Step 2: 运行目标回归检查** + +Run: `npm run test:data-process-list` + +Expected: PASS,输出 `数据处理任务列表状态切换移除回归检查通过`。 + +- [ ] **Step 3: 运行前端类型检查** + +Run: `npm run type-check` + +Expected: PASS,退出码为 `0`。 + +- [ ] **Step 4: 检查差异和格式** + +Run: `git diff --check && git diff -- frontend/src/views/data-process/DataProcessListView.vue frontend/package.json frontend/scripts/regression-data-process-list.mjs` + +Expected: `git diff --check` 无输出,差异仅包含状态切换移除及对应测试。 + +- [ ] **Step 5: 提交实现** + +```bash +git add frontend/src/views/data-process/DataProcessListView.vue +git commit -m "refactor: 移除数据处理状态切换" +``` diff --git a/docs/superpowers/plans/2026-07-10-dataset-task-mock-data.md b/docs/superpowers/plans/2026-07-10-dataset-task-mock-data.md new file mode 100644 index 0000000..d96b866 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-dataset-task-mock-data.md @@ -0,0 +1,182 @@ +# Dataset Task Mock Data Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在数据集管理页的“数据任务”页签展示 4 条任务产出 Mock 数据,并让“本地上传”与“数据任务”按来源稳定分流。 + +**Architecture:** 保持 `mockDatasets` 为唯一数据源,在 `DatasetItem` 上增加可选来源字段,并由列表页计算属性按来源过滤。使用一个无新增依赖的 Node 回归脚本锁定类型、Mock 数量、数据名称和页签过滤规则。 + +**Tech Stack:** Vue 3、TypeScript 5.7、Element Plus、Node.js 回归脚本、Vite 6 + +## Global Constraints + +- `source` 只允许 `upload` 或 `task`,并保持可选以兼容暂未返回该字段的接口数据。 +- 未携带 `source` 的数据归入“本地上传”。 +- 现有 6 条 Mock 数据标记为 `upload`,新增 4 条 Mock 数据标记为 `task`。 +- 不新增依赖,不修改后端接口,不实现真实的数据任务关联。 +- 搜索、分页、预览、下载和删除按钮保持现有行为。 + +--- + +## File Structure + +- `frontend/scripts/regression-dataset-task-tab.mjs`:静态回归检查,验证来源类型、Mock 数据和页签过滤规则。 +- `frontend/package.json`:注册 `test:dataset-task-tab` 命令。 +- `frontend/src/types/index.ts`:定义 `DatasetSource` 并扩展 `DatasetItem`。 +- `frontend/src/mock/data.ts`:标记 6 条上传数据并新增 4 条任务数据。 +- `frontend/src/views/dataset/DatasetListView.vue`:按 `source` 过滤两个页签。 + +### Task 1: 数据任务 Mock 数据与页签分流 + +**Files:** +- Create: `frontend/scripts/regression-dataset-task-tab.mjs` +- Modify: `frontend/package.json` +- Modify: `frontend/src/types/index.ts:58-78` +- Modify: `frontend/src/mock/data.ts:100-108` +- Modify: `frontend/src/views/dataset/DatasetListView.vue:16-23` + +**Interfaces:** +- Consumes: `getDatasetList(): Promise` 与现有 `DataTablePage` 的 `data` 属性。 +- Produces: `DatasetSource = 'upload' | 'task'`、`DatasetItem.source?: DatasetSource`,以及按来源过滤后的 `filteredDataList`。 + +- [ ] **Step 1: 写入会失败的回归检查并注册命令** + +创建 `frontend/scripts/regression-dataset-task-tab.mjs`: + +```js +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const typesPath = path.resolve(scriptDir, '../src/types/index.ts') +const dataPath = path.resolve(scriptDir, '../src/mock/data.ts') +const viewPath = path.resolve(scriptDir, '../src/views/dataset/DatasetListView.vue') + +const [typesSource, dataSource, viewSource] = await Promise.all([ + readFile(typesPath, 'utf8'), + readFile(dataPath, 'utf8'), + readFile(viewPath, 'utf8'), +]) + +assert.match(typesSource, /export type DatasetSource = 'upload' \| 'task'/) +assert.match(typesSource, /source\?: DatasetSource/) + +assert.equal((dataSource.match(/source: 'upload'/g) || []).length, 6) +assert.equal((dataSource.match(/source: 'task'/g) || []).length, 4) + +for (const name of [ + '客服对话清洗集', + '通用指令构造集', + '用户反馈脱敏集', + '多轮对话增强集', +]) { + assert.ok(dataSource.includes(`name: '${name}'`), `缺少数据任务 Mock:${name}`) +} + +assert.match(viewSource, /item\.source === 'task'/) +assert.match(viewSource, /item\.source !== 'task'/) +assert.doesNotMatch(viewSource, /数据任务产生的数据集[\s\S]*?return \[\]/) + +console.log('数据任务 Mock 数据与页签分流回归检查通过') +``` + +在 `frontend/package.json` 的 `scripts` 中加入: + +```json +"test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs" +``` + +- [ ] **Step 2: 运行回归检查并确认红灯** + +Run: `cd frontend && npm run test:dataset-task-tab` + +Expected: FAIL,首个断言提示缺少 `DatasetSource`。 + +- [ ] **Step 3: 增加来源类型** + +在 `frontend/src/types/index.ts` 的数据集类型区加入并使用: + +```ts +export type DatasetType = 'train' | 'test' | 'eval' | 'val' | 'other' +export type DatasetStorage = 'local' | 'cloud' | 'minio' +export type DatasetSource = 'upload' | 'task' + +export interface DatasetItem { + id: number | string + name: string + type: DatasetType | string + storage_type: DatasetStorage | string + source?: DatasetSource + size?: string | number + count?: number + description?: string + create_time?: string + files?: DatasetFile[] +} +``` + +- [ ] **Step 4: 标记现有数据并添加 4 条任务数据** + +将 `frontend/src/mock/data.ts` 的 `mockDatasets` 更新为: + +```ts +export const mockDatasets: DatasetItem[] = [ + { id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' }, + { id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' }, + { id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', source: 'upload', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' }, + { id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', source: 'upload', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' }, + { id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', source: 'upload', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' }, + { id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' }, + { id: 7, name: '客服对话清洗集', type: 'train', storage_type: 'minio', source: 'task', size: '96 MB', count: 18240, description: '由客服问答数据清洗任务生成', create_time: '2026-07-08T06:28:00Z' }, + { id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' }, + { id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' }, + { id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' }, +] +``` + +- [ ] **Step 5: 实现两个页签的来源过滤** + +将 `frontend/src/views/dataset/DatasetListView.vue` 的 `filteredDataList` 更新为: + +```ts +const filteredDataList = computed(() => { + if (activeTab.value === 'task') { + return dataList.value.filter((item) => item.source === 'task') + } + + return dataList.value.filter((item) => item.source !== 'task') +}) +``` + +- [ ] **Step 6: 运行针对性回归检查并确认绿灯** + +Run: `cd frontend && npm run test:dataset-task-tab` + +Expected: PASS,输出 `数据任务 Mock 数据与页签分流回归检查通过`。 + +- [ ] **Step 7: 运行类型检查和生产构建** + +Run: `cd frontend && npm run type-check` + +Expected: PASS;若仓库原有错误仍存在,保存完整输出并确认本任务修改文件不在错误列表中。 + +Run: `cd frontend && npx vite build` + +Expected: PASS,并生成 `dist` 产物。 + +- [ ] **Step 8: 页面烟雾验证** + +启动开发服务器后打开数据集管理页,验证“本地上传”总数为 6,切换“数据任务”后总数为 4,搜索“脱敏”只显示“用户反馈脱敏集”,且预览、下载、删除按钮可见。 + +- [ ] **Step 9: 提交实现** + +```bash +git add frontend/package.json \ + frontend/scripts/regression-dataset-task-tab.mjs \ + frontend/src/types/index.ts \ + frontend/src/mock/data.ts \ + frontend/src/views/dataset/DatasetListView.vue +git commit -m "feat: 添加数据任务 mock 数据" +``` diff --git a/docs/superpowers/plans/2026-07-10-multi-file-preview-selector.md b/docs/superpowers/plans/2026-07-10-multi-file-preview-selector.md new file mode 100644 index 0000000..019c4fa --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-multi-file-preview-selector.md @@ -0,0 +1,126 @@ +# 多文件预览下拉选择器 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在数据处理向导第二步中支持按文件切换原文和切片,同时保留现有双栏阅读空间。 + +**Architecture:** 为每个预览条目记录来源文件 ID;父页面按当前文件筛选原文与条目。预览组件只负责可搜索下拉选择器和当前文件双栏对照,不拼接不同文件的原文。 + +**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node 回归脚本。 + +## Global Constraints + +- 预览主区保持原文与切片的双栏比例,不新增常驻第三栏。 +- 下拉选择器必须支持 100 个文件的名称筛选。 +- 切换文件不得丢失其他文件已编辑的切片内容。 + +--- + +### Task 1: 锁定多文件来源映射 + +**Files:** +- Modify: `frontend/scripts/regression-data-process-wizard.mjs` +- Modify: `frontend/src/views/data-process/create/types.ts` +- Modify: `frontend/src/views/data-process/create/previewModel.ts` + +**Interfaces:** +- Produces: `PreviewItem.sourceFileId: string`。 +- Produces: `buildPreviewItems(sourceText, processType, sourceFileId)` 为同一文件生成带文件归属的唯一条目。 + +- [ ] **Step 1: 写入失败断言** + +```js +assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识') +assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识') +``` + +- [ ] **Step 2: 运行失败断言** + +Run: `npm run test:data-process-wizard` +Expected: FAIL,提示缺少 `sourceFileId`。 + +- [ ] **Step 3: 实现文件归属** + +```ts +export interface PreviewItem { + sourceFileId: string +} + +export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId: string) { + // 每个 item 写入 sourceFileId,并以它构造稳定 ID。 +} +``` + +- [ ] **Step 4: 再次运行回归脚本** + +Run: `npm run test:data-process-wizard` +Expected: 新断言通过;仅保留已知的布局失败(如存在)。 + +### Task 2: 按文件驱动双栏预览 + +**Files:** +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` + +**Interfaces:** +- Consumes: `PreviewItem.sourceFileId`。 +- Produces: `activePreviewFile`、`activePreviewItems` 与当前文件选择状态。 + +- [ ] **Step 1: 为多文件下拉接线添加失败断言** + +```js +assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态') +assert.match(viewSource, /buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/, '预览没有按文件分别生成') +``` + +- [ ] **Step 2: 运行失败断言** + +Run: `npm run test:data-process-wizard` +Expected: FAIL,提示缺少当前预览文件状态。 + +- [ ] **Step 3: 最小实现** + +```ts +const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value)) +const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value)) +``` + +- [ ] **Step 4: 运行回归脚本** + +Run: `npm run test:data-process-wizard` +Expected: 父页面多文件断言通过。 + +### Task 3: 加入可搜索文件下拉框与布局修复 + +**Files:** +- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue` +- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue` +- Modify: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: `files`、`selectedFileId`、`items`、`sourceText`。 +- Produces: `update:selectedFileId` 事件。 + +- [ ] **Step 1: 添加失败断言** + +```js +assert.match(previewSource, /filterable/, '文件选择器必须可搜索') +assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器') +``` + +- [ ] **Step 2: 运行失败断言** + +Run: `npm run test:data-process-wizard` +Expected: FAIL,提示缺少可搜索的文件选择器。 + +- [ ] **Step 3: 实现下拉框与响应式样式** + +```vue + + + +``` + +- [ ] **Step 4: 将 `.wizard-content` 设为 `min-height: 0` 并运行验证** + +Run: `npm run test:data-process-wizard && npm run type-check && npm run build` +Expected: 三个命令退出码均为 0。 diff --git a/docs/superpowers/plans/2026-07-10-page-surface-classification.md b/docs/superpowers/plans/2026-07-10-page-surface-classification.md new file mode 100644 index 0000000..e940582 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-page-surface-classification.md @@ -0,0 +1,94 @@ +# Page Surface Classification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 列表页直接使用自身白色卡片,表单和详情页继续使用主布局提供的白色圆角画布。 + +**Architecture:** 使用 Vue Router `meta.pageSurface` 做显式页面表面分类。主布局默认渲染白色画布,仅在 `pageSurface === 'self'` 时切换为透明、无内边距的承载容器。 + +**Tech Stack:** Vue 3、Vue Router 4、TypeScript、SCSS、Node.js 回归脚本 + +## Global Constraints + +- 只给六个自带白色列表卡片的路由声明 `pageSurface: 'self'`。 +- 其他路由默认继续使用白色页面画布。 +- 不新增依赖,不修改业务逻辑。 +- 先写失败测试,再实现最小修复。 + +--- + +### Task 1: 路由级页面表面分类 + +**Files:** +- Modify: `frontend/scripts/regression-page-surface.mjs` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/layouts/MainLayout.vue` +- Test: `frontend/scripts/regression-page-surface.mjs` + +**Interfaces:** +- Consumes: Vue Router 当前路由对象的 `route.meta.pageSurface`。 +- Produces: `pageSurface: 'self'` 路由元数据和 `.page-canvas.is-self-surface` 布局状态。 + +- [ ] **Step 1: 写入失败回归测试** + +在 `regression-page-surface.mjs` 中读取 `src/router/index.ts`,断言六个列表路由包含 +`pageSurface: 'self'`;断言 `MainLayout` 使用 `useRoute()` 和动态类;断言状态样式为: + +```scss +.page-canvas.is-self-surface { + padding: 0; + border-radius: 0; + background-color: transparent; + box-shadow: none; +} +``` + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: `npm run test:page-surface` + +Expected: FAIL,提示列表路由缺少 `pageSurface: 'self'` 或主布局缺少自表面状态。 + +- [ ] **Step 3: 写入最小实现** + +在六个列表路由中加入: + +```ts +meta: { title: '页面标题', pageSurface: 'self' }, +``` + +在 `MainLayout.vue` 中使用: + +```ts +const route = useRoute() +``` + +```vue +
+``` + +并加入透明承载容器样式。 + +- [ ] **Step 4: 运行专项测试并确认 GREEN** + +Run: `npm run test:page-surface` + +Expected: PASS,输出“全局页面背景与内容表面回归检查通过”。 + +- [ ] **Step 5: 运行相关回归与生产构建** + +Run: `npm run test:training-log-layout` + +Expected: PASS。 + +Run: `npx vite build` + +Expected: build exit code 0;允许保留项目既有字体解析和 chunk size 警告。 + +- [ ] **Step 6: 浏览器视觉验证** + +打开 `/fine-tune`,确认灰色背景上仅有列表自身白色卡片;打开 +`/training-log/1`,确认白色圆角页面画布仍存在。两个页面均不得水平溢出,控制台不得新增错误。 diff --git a/docs/superpowers/plans/2026-07-10-preview-slice-edit-mode.md b/docs/superpowers/plans/2026-07-10-preview-slice-edit-mode.md new file mode 100644 index 0000000..6d8f0ec --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-preview-slice-edit-mode.md @@ -0,0 +1,77 @@ +# 切片单面板编辑模式 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将数据预览右侧改为列表和编辑器互斥的单面板,并通过保存或取消控制切片内容写回。 + +**Architecture:** `PreviewCompareStep.vue` 保留切片筛选、分页和源文件定位,新增组件内编辑模式与临时草稿。父页面仅在收到保存事件时更新 `PreviewItem`,删除仍复用现有确认与删除事件。 + +**Tech Stack:** Vue 3 Composition API、TypeScript、Element Plus、Node 回归脚本。 + +## Global Constraints + +- 列表行只展示编号、来源行号及编辑、删除图标操作。 +- 编辑草稿未保存时不得更新 `PreviewItem.editedContent`。 +- 搜索、分页或切换文件时退出编辑模式并丢弃草稿。 +- 不新增依赖。 + +--- + +### Task 1: 切片列表与编辑模式切换 + +**Files:** +- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue` +- Modify: `frontend/scripts/regression-data-process-wizard.mjs` + +**Interfaces:** +- Consumes: `PreviewItem`、`update:selectedId`、`update:item-content`、`remove:item`。 +- Produces: `openEditor(item)`、`closeEditor()`、`saveEditor()` 与列表/编辑互斥渲染。 + +- [ ] **Step 1: Write the failing test** + +在 `regression-data-process-wizard.mjs` 断言组件存在 `editingItemId` 和 `editorDraft`,列表以图标按钮触发 `openEditor` 与 `remove:item`,编辑模式拥有 `保存修改`、`取消` 与 `返回列表`,并且列表不再含 `item-token`、`item-status`、`modifiedOnly`。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:data-process-wizard` + +Expected: FAIL,提示缺少单面板编辑模式结构。 + +- [ ] **Step 3: Write minimal implementation** + +在组件中增加以下状态和行为: + +```ts +const editingItemId = ref(null) +const editorDraft = ref('') + +function openEditor(item: PreviewItem) { + editingItemId.value = item.id + editorDraft.value = item.editedContent +} + +function closeEditor() { + editingItemId.value = null + editorDraft.value = '' +} + +function saveEditor() { + if (!editingItem.value) return + emit('update:item-content', editingItem.value.id, editorDraft.value) + closeEditor() +} +``` + +列表模式只渲染编号、来源和两个无文字图标按钮;编辑模式在同一位置渲染正文输入框以及返回、取消、保存操作。搜索、翻页、文件切换调用 `closeEditor()`。 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:data-process-wizard` + +Expected: `数据处理四步向导回归检查通过`。 + +- [ ] **Step 5: Build and visually verify** + +Run: `npx vite build` + +Expected: Vite completes successfully. Open the second wizard step, verify the list has only the two icon operations and that cancel does not change the selected slice content while save returns to the list. diff --git a/docs/superpowers/plans/2026-07-10-route-transition.md b/docs/superpowers/plans/2026-07-10-route-transition.md new file mode 100644 index 0000000..c4c8afb --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-route-transition.md @@ -0,0 +1,78 @@ +# Route Transition Removal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 移除主布局的页面级透明转场,消除列表与二级页面切换时的闪烁中间帧。 + +**Architecture:** `router-view` 直接渲染当前路由组件,不再包裹 Vue `transition`。页面表面仍由 `route.meta.pageSurface` 控制,因此内容和表面在同一轮渲染中同步更新。 + +**Tech Stack:** Vue 3、Vue Router 4、SCSS、Node.js 回归脚本 + +## Global Constraints + +- 删除页面级透明度转场。 +- 保留组件内部动画。 +- 不改变路由表面分类、业务逻辑或数据加载流程。 +- 先写失败测试,再实现最小修复。 + +--- + +### Task 1: 移除主布局页面级透明转场 + +**Files:** +- Modify: `frontend/scripts/regression-page-surface.mjs` +- Modify: `frontend/src/layouts/MainLayout.vue` +- Test: `frontend/scripts/regression-page-surface.mjs` + +**Interfaces:** +- Consumes: `router-view` 提供的当前路由组件。 +- Produces: 不带页面级透明度动画的同步路由内容渲染。 + +- [ ] **Step 1: 写入失败回归测试** + +在 `regression-page-surface.mjs` 中断言主布局模板不包含页面级 `transition`,并断言主布局样式不包含 `.fade-enter-*` 或 `.fade-leave-*`。 + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: `npm run test:page-surface` + +Expected: FAIL,提示主布局仍包含页面级透明转场。 + +- [ ] **Step 3: 写入最小实现** + +将: + +```vue + + + +``` + +改为: + +```vue + +``` + +并删除主布局中的 `.fade-enter-active`、`.fade-leave-active`、`.fade-enter-from` 和 `.fade-leave-to` 样式。 + +- [ ] **Step 4: 运行专项回归并确认 GREEN** + +Run: `npm run test:page-surface` + +Expected: PASS,输出“全局页面背景与内容表面回归检查通过”。 + +- [ ] **Step 5: 运行相关回归与构建** + +Run: `npm run test:training-log-layout` + +Expected: PASS。 + +Run: `npx vite build` + +Expected: exit code 0;允许项目既有字体解析和 chunk size 警告。 + +- [ ] **Step 6: 浏览器往返验证** + +验证 `/fine-tune` → `/fine-tune/create`、`/fine-tune` → `/training-log/1` 以及二级页返回列表;页面内容与表面同步切换,无半透明旧页面、无水平溢出、无新增控制台错误。 + diff --git a/docs/superpowers/plans/2026-07-10-source-upload-file-list.md b/docs/superpowers/plans/2026-07-10-source-upload-file-list.md new file mode 100644 index 0000000..d4ca105 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-source-upload-file-list.md @@ -0,0 +1,202 @@ +# 源数据上传紧凑文件列表 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将数据处理创建页的已上传文件从大卡片改为固定高度、可滚动且可逐项操作的紧凑列表。 + +**Architecture:** 只修改 `TaskSetupStep` 的模板和局部样式,继续消费现有的 +`uploadedFiles` 属性并派发既有 `remove-file` 事件。使用同一组件内的标题栏和 +滚动容器管理信息密度,不改变上传、格式限制或父组件数据流。 + +**Tech Stack:** Vue 3 ` + + + +
+ + diff --git a/frontend/dist/logo.png b/frontend/dist/logo.png new file mode 100644 index 0000000..1de89ad Binary files /dev/null and b/frontend/dist/logo.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2b6ea12 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + 远光软件微调平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..437047d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4242 @@ +{ + "name": "yg-ft-platform-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yg-ft-platform-frontend", + "version": "1.0.0", + "dependencies": { + "@vueuse/core": "^11.3.0", + "axios": "^1.7.9", + "chart.js": "^4.4.7", + "dompurify": "^3.2.3", + "echarts": "^6.1.0", + "element-plus": "^2.9.1", + "marked": "^15.0.5", + "md-editor-v3": "^5.1.4", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-echarts": "^8.0.1", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/node": "^26.1.1", + "@vitejs/plugin-vue": "^5.2.1", + "sass": "^1.83.0", + "typescript": "~5.7.2", + "unplugin-auto-import": "^0.19.0", + "unplugin-vue-components": "^0.28.0", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", + "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", + "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.0.tgz", + "integrity": "sha512-zZDdfKkXl1CBYet/nL2jCskQXC+8DpbhZubbTEGqbqpkaBC4w03F5Kt9ZLDyqbadqIvvZ7VrVhd6d6qgurzAew==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "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": "*" + } + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "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/@vavt/copy2clipboard": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz", + "integrity": "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==", + "license": "MIT" + }, + "node_modules/@vavt/util": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vavt/util/-/util-2.1.2.tgz", + "integrity": "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==", + "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": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "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": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "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" + }, + "node_modules/@volar/typescript": { + "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": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/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" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "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": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "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": "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.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.3.0.tgz", + "integrity": "sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.3.0", + "@vueuse/shared": "11.3.0", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.3.0.tgz", + "integrity": "sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "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.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "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" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "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.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/element-plus/node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/element-plus/node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/element-plus/node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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" + } + }, + "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" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "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" + } + }, + "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", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "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": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.453.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.453.0.tgz", + "integrity": "sha512-5zmv83vxAs9SVoe22veDBi8Dw0Fh2F+oTngWgKnKOkrZVbZjceXLQ3tescV2boB0zlaf9R2Sd9RuUP2766xvsQ==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "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" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-image-figures": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-image-figures/-/markdown-it-image-figures-2.1.1.tgz", + "integrity": "sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "markdown-it": "*" + } + }, + "node_modules/markdown-it-sub": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-sub/-/markdown-it-sub-2.0.0.tgz", + "integrity": "sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==", + "license": "MIT" + }, + "node_modules/markdown-it-sup": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz", + "integrity": "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "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" + } + }, + "node_modules/md-editor-v3": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/md-editor-v3/-/md-editor-v3-5.8.5.tgz", + "integrity": "sha512-NsqAmmAx/ykA1AcwxcHH4Hkn4VAPkqMX7Hd6Lv4FcwQoMQ70wWmJfs/mokyPGkqr4oYqqn8LRMBTqFNfoP0O0A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/commands": "^6.8.1", + "@codemirror/lang-markdown": "^6.3.0", + "@codemirror/language": "^6.11.0", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.8", + "@lezer/highlight": "^1.2.1", + "@types/markdown-it": "^14.0.1", + "@vavt/copy2clipboard": "^1.0.1", + "@vavt/util": "^2.1.0", + "codemirror": "^6.0.1", + "lru-cache": "^11.0.1", + "lucide-vue-next": "^0.453.0", + "markdown-it": "^14.0.0", + "markdown-it-image-figures": "^2.1.1", + "markdown-it-sub": "^2.0.0", + "markdown-it-sup": "^2.0.0", + "medium-zoom": "^1.1.0", + "xss": "^1.0.15" + }, + "peerDependencies": { + "vue": "^3.5.3" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/medium-zoom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/medium-zoom/-/medium-zoom-1.1.0.tgz", + "integrity": "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==", + "license": "MIT" + }, + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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" + } + }, + "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" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "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.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "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/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/unimport/node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.19.0.tgz", + "integrity": "sha512-W97gTDEWu/L1EcKCXY5Ni8bsMW1E9kv12wYQv3mYpd7zcFctXYlLKsqeva6sbCQbzS8t9AG/XdU5/WkEJKPlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.15", + "picomatch": "^4.0.2", + "unimport": "^3.14.5", + "unplugin": "^2.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.28.0.tgz", + "integrity": "sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.4", + "chokidar": "^3.6.0", + "debug": "^4.4.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.15", + "minimatch": "^9.0.5", + "mlly": "^1.7.3", + "unplugin": "^2.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/unplugin-vue-components/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz", + "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", + "license": "MIT" + }, + "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": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz", + "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", + "license": "MIT", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "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" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "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": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..dc2037e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "yg-ft-platform-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "type-check": "vue-tsc -b --noEmit", + "test": "node scripts/run-regressions.mjs", + "test:login-layout": "node scripts/regression-login-layout.mjs", + "test:default-dashboard": "node scripts/regression-default-dashboard.mjs", + "test:dashboard": "node scripts/regression-dashboard.mjs", + "test:data-process-list": "node scripts/regression-data-process-list.mjs", + "test:data-process-detail": "node scripts/regression-data-process-detail.mjs", + "test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs", + "test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs", + "test:dataset-preview": "node scripts/regression-dataset-preview.mjs", + "test:data-convert": "node scripts/regression-data-convert.mjs", + "test:eval-create": "node scripts/regression-eval-create-wizard.mjs", + "test:eval-detail": "node scripts/regression-eval-detail.mjs", + "test:model-manage": "node scripts/regression-model-manage.mjs", + "test:hardware": "node scripts/regression-hardware-dashboard.mjs", + "test:user-settings": "node scripts/regression-user-settings.mjs", + "test:training-log-layout": "node scripts/regression-training-log-layout.mjs", + "test:fine-tune-create": "node scripts/regression-fine-tune-create-ui.mjs", + "test:page-surface": "node scripts/regression-page-surface.mjs" + }, + "dependencies": { + "@vueuse/core": "^11.3.0", + "axios": "^1.7.9", + "chart.js": "^4.4.7", + "dompurify": "^3.2.3", + "echarts": "^6.1.0", + "element-plus": "^2.9.1", + "marked": "^15.0.5", + "md-editor-v3": "^5.1.4", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-echarts": "^8.0.1", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/node": "^26.1.1", + "@vitejs/plugin-vue": "^5.2.1", + "sass": "^1.83.0", + "typescript": "~5.7.2", + "unplugin-auto-import": "^0.19.0", + "unplugin-vue-components": "^0.28.0", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/public/favicon.ico @@ -0,0 +1 @@ + diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..1de89ad Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/scripts/regression-back-navigation.mjs b/frontend/scripts/regression-back-navigation.mjs new file mode 100644 index 0000000..fdac1eb --- /dev/null +++ b/frontend/scripts/regression-back-navigation.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))) + +function read(relativePath) { + return readFileSync(resolve(root, relativePath), 'utf8') +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message) + } +} + +const appHeader = read('src/components/AppHeader.vue') +const trainingLog = read('src/views/system/TrainingLogView.vue') + +assert( + appHeader.includes('showBackButton'), + 'AppHeader should gate the global back button behind showBackButton', +) + +assert( + appHeader.includes('v-if="showBackButton"'), + 'AppHeader should hide 返回上一页 when the current route is not a detail/sub page', +) + +assert( + !trainingLog.includes('返回列表'), + 'TrainingLogView should rely on the global 返回上一页 button instead of rendering 返回列表', +) + +console.log('back-navigation regression checks passed') diff --git a/frontend/scripts/regression-dashboard.mjs b/frontend/scripts/regression-dashboard.mjs new file mode 100644 index 0000000..aad453e --- /dev/null +++ b/frontend/scripts/regression-dashboard.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const [routerSource, dashboardSource, echartsSource, mainLayoutSource] = await Promise.all([ + readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'), + readFile(path.resolve(scriptDir, '../src/views/dashboard/DashboardView.vue'), 'utf8'), + readFile(path.resolve(scriptDir, '../src/plugins/echarts.ts'), 'utf8'), + readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'), +]) + +assert.match( + routerSource, + /path:\s*['"]dashboard['"][\s\S]*?DashboardView\.vue/, + '服务看板路由应使用独立 DashboardView', +) + +assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart') +assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表') +assert.match(echartsSource, /import\s*\{[^}]*PieChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 PieChart') +assert.match(echartsSource, /use\(\[[\s\S]*?PieChart[\s\S]*?\]\)/, 'PieChart 未加入 ECharts 按需注册列表') + +for (const copy of [ + '平台运行状态', + '近 7 天训练统计', + '训练次数(次)', + 'GPU 使用数(个)', + '平均准确率(%)', + '服务状态', + '训练任务', + '查看全部任务', +]) { + assert.ok(dashboardSource.includes(copy), `服务看板缺少关键内容:${copy}`) +} + +assert.match(dashboardSource, /yAxis:\s*\[[\s\S]*?次数 \/ GPU 数[\s\S]*?准确率/, '柱状图应使用双 Y 轴表达不同单位') +assert.match(dashboardSource, /name:\s*['"]平均准确率(%)['"][\s\S]*?yAxisIndex:\s*1/, '准确率柱应绑定右侧百分比坐标轴') +assert.match(dashboardSource, /router\.push\(['"]\/fine-tune['"]\)/, '查看全部任务应进入模型微调列表') +assert.match(dashboardSource, /router\.push\(`\/training-log\/\$\{task\.id\}`\)/, '训练任务详情应进入训练日志页') +assert.doesNotMatch(dashboardSource, /class=["']dashboard-heading["']/, '服务看板不应重复展示页面标题栏') +assert.doesNotMatch(dashboardSource, /查看告警/, '服务看板不应保留冗余的顶部告警按钮') +assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*16px;/, '服务看板区块间距应保持舒展') +assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?min-height:\s*100%;/, '服务看板应至少填满页面可用高度') +assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?min-height:\s*0;/, '中间区域不应因新增统计卡片被压缩') +assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*minmax\(300px,\s*0\.82fr\);/, '服务状态列应收窄,为训练图表释放更多宽度') +assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度') +assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸') +assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高') +assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*50px;[\s\S]*?th\s*\{[\s\S]*?height:\s*38px;/, '训练任务表格正文行应保持舒展') +assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*34px;/, '低高度桌面视口也应保留可读的训练任务行高') +assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片') +assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示') +assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数') +assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称') +assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内') +assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列') +assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列') +assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列') +assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*auto;/, '服务看板应允许在内容超出视口时纵向滚动') +assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 0 auto;[\s\S]*?overflow:\s*visible;/, '服务看板画布应允许新增卡片完整显示') + +console.log('服务看板回归检查通过') diff --git a/frontend/scripts/regression-data-convert.mjs b/frontend/scripts/regression-data-convert.mjs new file mode 100644 index 0000000..d9b62e0 --- /dev/null +++ b/frontend/scripts/regression-data-convert.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const sourceRoot = path.resolve(scriptDir, '../src') + +const [viewSource, routerSource, toolsSource] = await Promise.all([ + readFile(path.join(sourceRoot, 'views/data-convert/DataConvertView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'views/tools/ToolsView.vue'), 'utf8'), +]) + +assert.match(viewSource, /JSON 转 JSONL/, '转换页缺少明确的格式标题') +assert.match(viewSource, /class="upload-zone"/, '转换页缺少源文件上传区域') +assert.match(viewSource, /class="converter-form"/, '转换页缺少标准表单区域') +assert.match(viewSource, /class="form-row"/, '转换页缺少输出配置区域') +assert.match(viewSource, /开始转换/, '转换页缺少主操作按钮') +assert.match(viewSource, /当前为 UI 原型/, '转换页没有明确说明 UI 原型范围') +assert.match(viewSource, //, '未接入功能前主操作按钮必须禁用') +assert.match(viewSource, /var\(--primary-color\)/, '转换页没有使用项目主色变量') +assert.match(viewSource, /var\(--el-color-primary-light-9\)/, '转换页没有使用项目主色浅色变量') +assert.doesNotMatch(viewSource, /#1890ff/i, '转换页仍包含未对齐当前主题的旧蓝色') +assert.match(viewSource, /\.converter-panel\s*\{[\s\S]*?width:\s*100%;/, '转换区域没有铺满页面宽度') +assert.match(viewSource, /min-height:\s*calc\(100vh\s*-\s*220px\)/, '转换区域没有铺满页面可用高度') +assert.doesNotMatch(viewSource, /max-width:\s*860px/, '转换区域仍被限制为窄卡片') + +// 当前迭代只允许界面开发,防止误接入文件读取、解析、转换或下载逻辑。 +for (const forbiddenImplementation of [ + /FileReader/, + /\.text\(\)/, + /JSON\.parse/, + /new Blob/, + /createObjectURL/, +]) { + assert.doesNotMatch(viewSource, forbiddenImplementation, 'UI 原型中不应包含实际转换实现') +} + +assert.match( + routerSource, + /path:\s*['"]data-convert['"][\s\S]*?DataConvertView\.vue/, + '数据类型转换路由未接入新页面', +) +assert.doesNotMatch( + routerSource, + /path:\s*['"]data-convert['"][\s\S]{0,180}?PlaceholderView\.vue/, + '数据类型转换路由仍指向占位页', +) +assert.match( + toolsSource, + /id === ['"]json2jsonl['"][\s\S]{0,100}?router\.push\(['"]\/data-convert['"]\)/, + '其他工具中的 JSON 转 JSONL 卡片未接入转换页', +) + +console.log('JSON 转 JSONL UI 原型回归检查通过') diff --git a/frontend/scripts/regression-data-process-detail.mjs b/frontend/scripts/regression-data-process-detail.mjs new file mode 100644 index 0000000..cbd34d6 --- /dev/null +++ b/frontend/scripts/regression-data-process-detail.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { parse as parseSfc } from '@vue/compiler-sfc' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const sourceRoot = path.resolve(scriptDir, '../src') +const [detailSource, listSource, routerSource] = await Promise.all([ + readFile(path.join(sourceRoot, 'views/data-process/DataProcessDetailView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'views/data-process/DataProcessListView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'), +]) + +const { descriptor, errors } = parseSfc(detailSource, { filename: 'DataProcessDetailView.vue' }) +assert.equal(errors.length, 0, `数据处理详情页无法解析:${errors[0]}`) +assert.ok(descriptor.template?.content.trim(), '数据处理详情页缺少可渲染模板') + +assert.match(routerSource, /path:\s*['"]data-process\/:id['"]/, '缺少数据处理详情动态路由') +assert.match(routerSource, /name:\s*['"]data-process-detail['"]/, '数据处理详情路由缺少名称') +assert.match(routerSource, /DataProcessDetailView\.vue/, '数据处理详情路由未加载详情页面') +assert.match(routerSource, /title:\s*['"]数据处理详情['"]/, '数据处理详情路由标题不正确') + +assert.match(listSource, /name:\s*['"]data-process-detail['"]/, '列表详情按钮未使用详情命名路由') +assert.match(listSource, /params:\s*\{\s*id:\s*taskId\s*\}/, '列表详情按钮未传递任务 ID') +assert.doesNotMatch(listSource, /查看详情功能开发中/, '详情按钮仍保留开发中提示') + +for (const requiredCopy of [ + '处理耗时', + '开始时间', + '完成时间', + '输入数据', + '输出结果', + '处理统计', + '处理配置', + '结果明细', +]) { + assert.match(detailSource, new RegExp(requiredCopy), `详情页缺少必要信息:${requiredCopy}`) +} + +for (const status of ['completed', 'running', 'pending', 'failed']) { + assert.match(detailSource, new RegExp(`status:\\s*['"]${status}['"]`), `详情 Mock 缺少 ${status} 状态`) +} + +assert.match(detailSource, /const completedResults:\s*ResultRow\[\]/, '完成任务缺少结果明细 Mock') +assert.match(detailSource, /:data="paginatedResults"/, '结果表格未绑定分页后的处理结果') +assert.match(detailSource, /v-model="keyword"/, '结果明细缺少搜索能力') +assert.match(detailSource, /v-model="statusFilter"/, '结果明细缺少状态筛选') +assert.match(detailSource, /router\.push\(`\/dataset\/\$\{detail\.outputDatasetId\}\/preview`\)/, '输出数据集未接入预览入口') +assert.match(detailSource, /未找到数据处理任务/, '未知任务 ID 缺少明确空状态') +assert.match(detailSource, /width:\s*100%/, '详情页没有铺满内容区域') +assert.doesNotMatch(detailSource, /^\s*max-width:\s*\d+px/m, '详情页不应使用固定最大宽度') +assert.doesNotMatch(detailSource, /\b(?:password|secret|token)\b/i, '详情页不得展示敏感凭据字段') + +console.log('数据处理任务详情 UI 回归检查通过') diff --git a/frontend/scripts/regression-data-process-list.mjs b/frontend/scripts/regression-data-process-list.mjs new file mode 100644 index 0000000..917f2f7 --- /dev/null +++ b/frontend/scripts/regression-data-process-list.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { parse as parseSfc } from '@vue/compiler-sfc' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessListView.vue') +const source = await readFile(viewPath, 'utf8') +const { descriptor, errors } = parseSfc(source, { filename: viewPath }) + +assert.equal(errors.length, 0, `数据处理任务列表模板无法解析:${errors[0]}`) +assert.ok(descriptor.template?.content.trim(), '数据处理任务列表缺少可渲染模板') +assert.match(source, /:data="dataList"/, '任务表格必须直接展示完整任务数据') +assert.doesNotMatch(source, /activeTab|filteredDataList/, '不应保留状态切换筛选逻辑') +assert.doesNotMatch(source, /全部任务|处理中|已完成/, '不应保留状态切换按钮文案') +assert.doesNotMatch(source, /capsule-tabs|capsule-tab-item/, '不应保留状态切换专用样式') + +console.log('数据处理任务列表状态切换移除回归检查通过') diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs new file mode 100644 index 0000000..2c4c1b7 --- /dev/null +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -0,0 +1,886 @@ +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { parse as parseSfc } from '@vue/compiler-sfc' +import ts from 'typescript' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue') +const createDir = path.resolve(scriptDir, '../src/views/data-process/create') +const confirmDialogPath = path.resolve(scriptDir, '../src/components/AppConfirmDialog.vue') +const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue') +const viewSource = await readFile(viewPath, 'utf8') +const layoutSource = await readFile(layoutPath, 'utf8') +const [draftSource, stateSource, generationSource, viewStyleSource] = await Promise.all([ + readFile(path.join(createDir, 'useDataProcessDraft.ts'), 'utf8'), + readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'), + readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'), + readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'), +]) +const implementationSource = [viewSource, draftSource, stateSource, generationSource].join('\n') + +assert.ok(existsSync(confirmDialogPath), '缺少公共确认弹窗组件 AppConfirmDialog') +const confirmDialogSource = await readFile(confirmDialogPath, 'utf8') +for (const marker of ['', 'role="alertdialog"', ':aria-modal="true"', 'handleKeydown', 'Escape']) { + assert.ok(confirmDialogSource.includes(marker), `公共确认弹窗缺少可访问性能力:${marker}`) +} +assert.match(confirmDialogSource, /min-height:\s*44px/, '公共确认弹窗按钮触控区域不足 44px') +assert.match(confirmDialogSource, /focus\(\)/, '公共确认弹窗打开后没有管理键盘焦点') +assert.match(confirmDialogSource, /defineExpose\(\{ open \}\)/, '公共确认弹窗没有暴露 Promise 式 open API') +assert.match(confirmDialogSource, /width:\s*min\(480px,\s*100%\)/, '企业级确认弹窗宽度应保持紧凑的 480px') +assert.match(confirmDialogSource, /border-radius:\s*8px/, '企业级确认弹窗应使用克制的 8px 圆角') +assert.doesNotMatch(confirmDialogSource, /backdrop-filter/, '企业级确认弹窗不应使用装饰性背景模糊') +assert.ok(confirmDialogSource.includes('app-confirm-header'), '企业级确认弹窗缺少独立标题栏') +assert.match(confirmDialogSource, /\.app-confirm-button\s*\{[\s\S]*?height:\s*34px/, '桌面端操作按钮应使用紧凑的 34px 高度') +assert.match(confirmDialogSource, /@media \(max-width: 520px\)[\s\S]*?\.app-confirm-button\s*\{[\s\S]*?min-height:\s*44px/, '移动端操作按钮仍需保留 44px 触控高度') +assert.match(viewSource, /import AppConfirmDialog from '@\/components\/AppConfirmDialog\.vue'/, '创建页没有接入公共确认弹窗') +assert.match(viewSource, //, '路由离开确认没有改为异步公共弹窗流程') +assert.doesNotMatch(viewSource, /window\.confirm|ElMessageBox/, '创建页仍在使用系统或 Element Plus 确认框') + +assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量') +for (const title of ['创建任务', '大模型选择', '上传文件', '数据预览', '开始生成', '结果编辑与保存']) { + assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`) +} +assert.match( + viewSource, + /\{ id: 'create',[\s\S]*?\{ id: 'model',[\s\S]*?\{ id: 'upload',[\s\S]*?\{ id: 'preview',[\s\S]*?\{ id: 'generate',[\s\S]*?\{ id: 'results'/, + '六步向导顺序必须为创建任务、大模型选择、上传文件、数据预览、开始生成、结果编辑与保存', +) +assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减') +assert.match( + viewStyleSource, + /@media \(max-width: 1100px\)[\s\S]*?\.step-title\s*\{[\s\S]*?display:\s*none[\s\S]*?\.step-item\.is-active \.step-title\s*\{[\s\S]*?display:\s*block/, + '六步向导在中等宽度下没有收起非当前步骤标题', +) +assert.match(draftSource, /localStorage\.setItem\(DATA_PROCESS_DRAFT_STORAGE_KEY/, '草稿没有持久化') +assert.match(draftSource, /localStorage\.getItem\(DATA_PROCESS_DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取') +assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿') +assert.ok(viewSource.split('\n').length < 800, 'DataProcessCreateView 拆分后仍超过 800 行') + +const expectedComponents = [ + 'TaskSetupStep.vue', + 'ModelSelectionStep.vue', + 'SourceUploadStep.vue', + 'PreviewCompareStep.vue', + 'GenerationStep.vue', + 'ResultEditorStep.vue', +] +for (const component of expectedComponents) { + assert.ok(existsSync(path.join(createDir, component)), `缺少步骤组件:${component}`) + assert.ok(viewSource.includes(component.replace('.vue', '')), `父页面未使用:${component}`) +} + +const typesPath = path.join(createDir, 'types.ts') +const modelPath = path.join(createDir, 'previewModel.ts') +assert.ok(existsSync(typesPath), '缺少向导类型定义') +assert.ok(existsSync(modelPath), '缺少来源映射模型') + +const [typesSource, modelSource, previewSource] = await Promise.all([ + readFile(typesPath, 'utf8'), + readFile(modelPath, 'utf8'), + readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'), +]) + +for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) { + assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`) +} +assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识') +assert.match(typesSource, /export type StepId = 'create' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '步骤类型缺少独立大模型选择步骤') +assert.match(modelSource, /export function buildPreviewItems/, '缺少切片来源映射生成函数') +assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数') +assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识') +assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态') +assert.match( + viewSource, + /buildPreviewItems\([\s\S]*?file\.content,[\s\S]*?processType\.value,[\s\S]*?String\(file\.uid\),[\s\S]*?unstructuredOptions\.value/, + '预览没有按文件分别生成或未传入非结构化切分配置', +) + +for (const marker of [ + 'preview-workspace', + 'source-viewer', + 'source-line', + 'is-highlighted', + 'preview-item', + 'preview-editor', + 'scrollIntoView', +]) { + assert.ok(previewSource.includes(marker), `第四步缺少结构或行为:${marker}`) +} +assert.match(previewSource, /sourceStart/, '第四步未使用来源起始偏移') +assert.match(previewSource, /sourceEnd/, '第四步未使用来源结束偏移') +assert.match(previewSource, /filterable/, '文件选择器必须可搜索') +assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器') +assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示') +assert.match(previewSource, /const PREVIEW_PAGE_SIZE = 6/, '切片列表必须限制每页展示数量') +assert.match(previewSource, /const pagedItems = computed/, '切片列表缺少分页数据') +assert.match(previewSource, /v-for="item in pagedItems"/, '切片列表没有使用分页数据') +assert.match(previewSource, /\(null\)/, '缺少切片编辑模式状态') +assert.match(previewSource, /const editorDraft = ref\(''\)/, '缺少编辑临时草稿') +assert.match(previewSource, /function openEditor\(item: PreviewItem\)/, '列表缺少打开切片编辑器的动作') +assert.match(previewSource, /function closeEditor\(\)/, '编辑器缺少返回列表的动作') +assert.match(previewSource, /function saveEditor\(\)/, '编辑器缺少保存动作') +assert.match(previewSource, /