14 Commits

Author SHA1 Message Date
1468834116 Merge pull request 'ft_wyt' (#5) from ft_wyt into main
Reviewed-on: #5
2026-07-16 13:54:08 +08:00
wuyongtao
ba4059fe3b feat: 添加后端架构、计算模块及部署文档 2026-07-16 13:47:37 +08:00
wuyongtao
4050c120d5 feat: 添加 Docker 支持与项目文档
- 添加 Dockerfile, docker-compose.yml, .dockerignore, nginx 配置
- 添加后端 API 设计文档、平台架构需求文档、系统开发计划
- 添加 PostgreSQL schema 设计
- 更新 README.md 和 design-qa.md
2026-07-16 11:52:05 +08:00
156a952b47 Merge pull request 'dev' (#4) from dev into main
Reviewed-on: #4
2026-07-16 11:04:35 +08:00
caoxiaozhu
4173b53b1b refactor: 前端构建按需化与 Mock 懒加载
移除全量 Element Plus 与全局 VChart 注册,改为按需引入样式与组件内局部图表;Mock 适配器改为按 VITE_ENABLE_MOCK 环境变量懒加载,生产默认不拦截请求;ECharts 精简为看板所需图表,各视图与 stores 同步适配。
2026-07-16 11:03:54 +08:00
caoxiaozhu
ab9e87f948 refactor: 推理对比超时与打字机展示改进
抽取 withTimeout 替代 Promise.race 超时控制,对比结果新增打字机逐字渲染与清理,推理聊天参数与列表类型同步收敛。
2026-07-16 11:03:25 +08:00
caoxiaozhu
5a040366da refactor: usePolling 改为串行轮询
上一轮完成后才安排下一轮避免慢请求重叠,页面不可见时自动暂停、恢复后立即刷新,支持动态间隔与错误回调。
2026-07-16 11:03:15 +08:00
8789019db2 Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-07-16 10:18:12 +08:00
caoxiaozhu
3cb20a4a28 style: 登录时长图表标签回到柱条右侧
标签位置由 insideRight 改回 right、颜色改回灰色,避免短柱条内文字溢出。
2026-07-16 09:38:23 +08:00
caoxiaozhu
a6085a2612 chore: 页面表面回归与数据处理文案适配
页面表面回归选择器更新为 task-overview,任务概览补充表面背景变量,数据处理结果步骤标题文案精简。
2026-07-16 09:36:39 +08:00
caoxiaozhu
4899bc8779 style: Dashboard 看板表格行高与图表微调
训练任务表格行高加大提升可读性,登录时长图表留白与标签位置调整避免溢出,回归断言同步。
2026-07-16 09:36:39 +08:00
caoxiaozhu
e70538e64d feat: 顶部栏新增使用文档入口
AppHeader 增加使用文档按钮,新窗口打开 /guide 路由,补充响应式窄屏适配。
2026-07-16 09:36:39 +08:00
39a5390ecd Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-07-14 16:20:08 +08:00
cd354f52e6 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-07-14 11:26:14 +08:00
83 changed files with 5743 additions and 258 deletions

11
.dockerignore Normal file
View File

@@ -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

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/frontend/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

184
README.md
View File

@@ -1,133 +1,107 @@
# YG_FT
# YG_FT 模型微调平台
远光微调平台 - 面向大语言模型微调、评测、推理与对比一体化前端
YG_FT 是一个面向企业治理场景的完整模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。当前前端已存在基础页面,后端与算力平台已按多人协作开发方式建立工程骨架
## 技术栈
## 总体架构
| 类别 | 技术 | 版本 |
|------|------|------|
| 框架 | Vue 3 | ^3.5.13 |
| 语言 | TypeScript | ~5.7.2 |
| 构建工具 | Vite | ^6.0.7 |
| 路由 | Vue Router | ^4.5.0 |
| 状态管理 | Pinia | ^2.3.0 |
| UI 组件库 | Element Plus | ^2.9.1 |
| HTTP 客户端 | axios | ^1.7.9 |
| 图表 | ECharts / vue-echarts | ^6.1.0 / ^8.0.1 |
| Markdown | marked + DOMPurify | ^15.0.5 / ^3.2.3 |
| 编辑器 | md-editor-v3 | ^5.1.4 |
| 工具集 | @vueuse/core | ^11.3.0 |
| 样式 | Sass | ^1.83.0 |
```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/ # Nginx 等容器化配置
```
**项目版本**1.0.0
## 平台分层
## 环境要求
| 层级 | 职责 | 主要目录 |
| --- | --- | --- |
| 前端控制台 | 用户操作入口、任务看板、项目/模型/数据集/训练/审批/审计页面 | `frontend/` |
| 应用平台后端 | 用户中心、多租户、RBAC/ABAC、项目隔离、元数据、审批流、审计、API 编排 | `backend/` |
| 算力平台 | GPU 发现、资源锁定、训练进程管理、日志采集、产物归档、任务状态回传 | `compute/` |
| 训练引擎 | 当前固定接入 LLaMA-Factory预留其他训练平台适配标准 | `compute/engines/` |
| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` |
- **Node.js** >= 18推荐 20 LTS
- **npm** >= 9
- 后端服务运行于 `http://localhost:7861`(前端通过代理转发,见下文)
## 关键能力
## 快速开始
- 多租户:租户级数据隔离、租户配置、租户成员和角色。
- 权限控制:支持项目、模型、数据集级隔离,后续可扩展到字段级和操作级策略。
- 审批流:覆盖数据集发布、模型发布、训练资源申请、推理服务上线等企业流程。
- 审计留存:操作审计、安全审计、审批审计、任务审计,支持留存周期策略。
- 训练任务:训练参数管理、单机多 GPU 调度、任务状态同步、训练日志、产物管理。
- 引擎适配:默认 LLaMA-Factory预留统一 Engine Adapter 接口接入其他微调框架。
- 文件存储:当前使用本地磁盘,按租户/项目/数据集/任务分区。
- 日志采集:后端 JSON Lines 日志,主日志和错误日志拆分,便于 ELK/日志平台采集。
### 1. 安装依赖
## 后端启动
```bash
cd frontend
npm install
cd backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
### 2. 启动开发服务器
默认健康检查:
```bash
npm run dev
```text
GET /api/v1/health
```
开发服务器默认运行在 `http://localhost:6801`
## 日志
### 3. 构建生产包
后端日志模块位于 `backend/app/core/logging.py`,说明文档见:
```bash
npm run build # 类型检查 + 生产构建,产物输出到 dist/
npm run preview # 本地预览构建产物
- `docs/backend-logging.md`
默认输出:
```text
logs/backend-YYYY-MM-DD.log
logs/error-YYYY-MM-DD.log
```
### 4. 类型检查
日志格式为 JSON Lines单个文件不超过 20MB只保留最近 10 天。
```bash
npm run type-check
```
## 主要文档
## 测试
- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。
- `docs/backend-api-design.md`FastAPI 接口分组、参数定义、权限说明。
- `docs/postgres-schema.sql`PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。
- `docs/system-development-plan.md`多人协作开发计划按前端、后端、DB、部署拆分。
- `docs/backend-logging.md`:后端日志模块使用说明。
- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。
内置基于 Playwright 的 UI 回归脚本,首次运行前需安装浏览器:
## 部署模式
```bash
npx playwright install chromium
```
平台支持两种主要部署模式:
执行已注册的回归脚本:
1. 所有服务部署在算力服务器:适合 PoC、内网试点、小团队单机多 GPU 使用。
2. 应用服务和算力/训练服务独立部署:适合企业生产环境,应用平台部署在业务服务区,算力平台和 LLaMA-Factory 部署在 GPU 服务器。
```bash
npm run test:data-process-wizard # 数据处理向导
npm run test:model-manage # 模型管理
npm run test:training-log-layout # 训练日志布局
npm run test:page-surface # 页面表层级
```
生产环境建议采用第二种模式。算力平台与训练框架应部署在 GPU 算力服务器上,应用平台不直接控制 GPU 进程,而是通过内部 Compute API 调度训练任务。
其余脚本可直接运行:
详细方案见 `docs/deployment-plan.md`
```bash
node scripts/regression-back-navigation.mjs # 返回导航
node scripts/regression-fine-tune-create-ui.mjs # 调优创建 UI
```
## 后续开发原则
> 回归脚本默认连接 `http://localhost:6801`,需先启动开发服务器
## 目录结构
```
YG-FT/
├── frontend/ # 前端工程Vue 3 SPA
│ ├── src/
│ │ ├── api/ # axios 封装 + 各业务模块 API
│ │ ├── components/ # 公共组件
│ │ ├── composables/ # 组合式函数
│ │ ├── constants/ # 常量与映射表
│ │ ├── layouts/ # 主布局
│ │ ├── mock/ # Mock 数据与适配器
│ │ ├── plugins/ # 第三方插件注册
│ │ ├── router/ # 路由配置 + 登录守卫
│ │ ├── stores/ # Pinia 状态
│ │ ├── styles/ # 全局样式
│ │ ├── types/ # TypeScript 类型定义
│ │ └── views/ # 业务页面
│ ├── scripts/ # UI 回归测试脚本
│ ├── public/ # 静态资源
│ └── vite.config.ts # Vite 构建与代理配置
├── docs/ # 设计文档与视觉走查记录
└── design-qa.md # 视觉走查汇总
```
## 端口与代理
| 服务 | 地址 |
|------|------|
| 前端开发服务器 | `http://localhost:6801` |
| 后端 API | `http://localhost:7861` |
前端统一使用 `/api` 相对路径发请求,由 Vite 开发代理转发到后端 `http://localhost:7861`(配置见 `frontend/vite.config.ts`)。
## 业务模块
| 模块 | 说明 |
|------|------|
| 登录 | 用户登录鉴权 |
| 模型调优 | 微调任务创建与管理 |
| 模型评测 | 评测任务与评测维度配置 |
| 模型推理 | 在线推理对话 |
| 模型对比 | 多模型对话与结果对比 |
| 模型管理 | 模型 CRUD 与权重合并 |
| 数据集 | 数据集管理与预览 |
| 数据处理 | 数据处理任务向导 |
| 工具 | 辅助工具集 |
| 系统 | 硬件监控、日志、训练日志 |
- 接口实现优先遵循 `docs/backend-api-design.md`
- 数据库实现优先遵循 `docs/postgres-schema.sql`,后续通过 Alembic 迁移管理变更。
- 前端页面与后端接口、数据库表之间的映射以文档中的“对应页面/功能模块”为准。
- 训练引擎适配必须通过 `compute/engines/` 下的标准接口,不在应用平台后端直接拼接训练命令。
- 敏感信息不得写入日志,生产环境密钥通过环境变量或密钥管理系统注入。

9
backend/.env.example Normal file
View File

@@ -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

65
backend/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Backend Service
后端工程使用 FastAPI定位为模型微调平台的应用平台服务负责用户中心、多租户、权限隔离、项目、数据集、模型、训练任务、审批、审计和算力平台编排。
## 目录结构
```text
backend/
app/
main.py # FastAPI 应用入口
api/v1/ # 对前端暴露的 API 路由
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/ # 本地开发日志目录,生产环境建议挂载到独立日志盘
```
## 本地启动
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
健康检查:
```text
GET /api/v1/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/日志平台采集。

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Application package."""

View File

@@ -0,0 +1 @@
"""API package."""

View File

@@ -0,0 +1 @@
"""Versioned API package."""

View File

@@ -0,0 +1 @@
"""API endpoint modules."""

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.core.logging import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.get("/health")
async def health_check() -> dict[str, str]:
logger.info("health check requested")
return {"status": "ok"}

View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.v1.endpoints.health import router as health_router
api_router = APIRouter()
api_router.include_router(health_router, tags=["health"])

View File

@@ -0,0 +1 @@
"""Core infrastructure modules."""

View File

@@ -0,0 +1,28 @@
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)
@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")
api_prefix: str = os.getenv("API_PREFIX", "/api")
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)
@lru_cache
def get_settings() -> Settings:
return Settings()

253
backend/app/core/logging.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1 @@
"""Database infrastructure package."""

View File

@@ -0,0 +1,4 @@
"""Database session factory placeholder.
Implement SQLAlchemy/SQLModel session management here when database development starts.
"""

18
backend/app/main.py Normal file
View File

@@ -0,0 +1,18 @@
from fastapi import FastAPI
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)
setup_request_logging(app)
app.include_router(api_router, prefix=settings.api_prefix)
return app
app = create_app()

View File

@@ -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` 的页面模块开发工作包为准。

View File

@@ -0,0 +1 @@
"""Approval workflow module."""

View File

@@ -0,0 +1 @@
"""Audit log module."""

View File

@@ -0,0 +1 @@
"""Authentication and user session module."""

View File

@@ -0,0 +1 @@
"""Application-side compute platform gateway module."""

View File

@@ -0,0 +1 @@
"""Data processing module."""

View File

@@ -0,0 +1 @@
"""Dataset management module."""

View File

@@ -0,0 +1 @@
"""Training engine registry module."""

View File

@@ -0,0 +1 @@
"""Evaluation module."""

View File

@@ -0,0 +1 @@
"""Application-side file gateway module."""

View File

@@ -0,0 +1 @@
"""Fine-tuning task module."""

View File

@@ -0,0 +1 @@
"""Inference and compare module."""

View File

@@ -0,0 +1 @@
"""Model registry module."""

View File

@@ -0,0 +1 @@
"""Project workspace and member module."""

View File

@@ -0,0 +1 @@
"""Retention policy and cleanup module."""

View File

@@ -0,0 +1 @@
"""System health, metrics and logs module."""

View File

@@ -0,0 +1 @@
"""Tenant management module."""

View File

@@ -0,0 +1 @@
"""Shared schemas package."""

View File

@@ -0,0 +1 @@
"""Cross-module services package."""

View File

@@ -0,0 +1 @@
"""Background workers package."""

32
backend/pyproject.toml Normal file
View File

@@ -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.11"
dependencies = [
"fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0",
"python-multipart>=0.0.9",
"pydantic>=2.7.0",
"sqlalchemy>=2.0.30",
"asyncpg>=0.29.0",
"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 = "py311"
[tool.pytest.ini_options]
testpaths = ["tests"]

12
backend/requirements.txt Normal file
View File

@@ -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
asyncpg>=0.29.0
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

24
compute/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Compute Platform
算力平台与应用平台分开部署,本目录用于后续实现单机多 GPU 调度、文件网关和训练引擎适配。
## 目录结构
```text
compute/
api/ # 只允许应用平台访问的内部 Compute API
agent/ # 单机 Agent负责 GPU、进程、工作区管理
engines/
llama_factory/ # LLaMA-Factory 训练引擎适配器
file_gateway/ # 本地磁盘上传、下载、预览、离线导入
tests/
```
## 第一版职责
- GPU 发现、状态上报、锁定和释放。
- 本地磁盘工作区管理。
- 创建、停止、查询训练/评测/推理/合并任务。
- LLaMA-Factory 命令生成、日志解析、产物收集。
- 分片上传、短时下载、离线导入。
- 通过服务间 token 接受应用平台调用。

View File

@@ -0,0 +1 @@
"""Compute agent package."""

1
compute/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Compute API package."""

View File

@@ -0,0 +1 @@
"""Training engine adapters package."""

View File

@@ -0,0 +1 @@
"""LLaMA-Factory engine adapter package."""

View File

@@ -0,0 +1 @@
"""Local file gateway package."""

View File

@@ -0,0 +1 @@
"""Compute platform tests package."""

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
yg-ft-frontend:
build:
context: .
dockerfile: Dockerfile
image: yg-ft-frontend:latest
container_name: yg-ft-frontend
ports:
- "6801:80"
environment:
# Change this if the backend is deployed somewhere else.
API_PROXY_PASS: "http://host.docker.internal:7861"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

View File

@@ -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 /api {
proxy_pass ${API_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";
}
}

1070
docs/backend-api-design.md Normal file

File diff suppressed because it is too large Load Diff

109
docs/backend-logging.md Normal file
View File

@@ -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=/api/v1/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` 贯穿链路。

305
docs/deployment-plan.md Normal file
View File

@@ -0,0 +1,305 @@
# 模型微调平台后期部署方案
本文档对应页面/功能模块:系统设置、算力资源、训练任务、任务详情、模型管理、数据集管理、审批中心、审计中心、运维监控。
## 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 适用场景
- PoC、试点环境、演示环境。
- 小团队共用一台单机多 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 | 8000 | 仅 Nginx、本机 |
| Compute API | 9100 | 仅 Backend API、本机 |
| File Gateway | 9101 | 仅 Backend API、本机 |
| PostgreSQL | 5432 | 本机或内网 |
| Redis | 6379 | 本机或内网 |
## 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["算力服务器本地磁盘"]
A -- "状态回调/日志摘要" --> B
```
### 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 风险
- 文件传输链路比单机部署复杂。
- 需要处理跨服务器网络失败、回调失败、任务状态对账。
- 需要明确模型、数据集、产物在应用侧和算力侧的索引关系。
## 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
API_PREFIX=/api
DATABASE_URL=postgresql+asyncpg://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:9100
COMPUTE_SERVICE_TOKEN=***
FILE_GATEWAY_BASE_URL=https://compute.internal:9101
```
算力平台:
```env
COMPUTE_ENV=prod
COMPUTE_HOST_ID=gpu-node-01
COMPUTE_API_PORT=9100
FILE_GATEWAY_PORT=9101
APP_CALLBACK_BASE_URL=https://app.internal/api/v1/compute/callbacks
APP_SERVICE_TOKEN=***
LLAMA_FACTORY_HOME=/opt/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 已初始化 `docs/postgres-schema.sql`
- Redis 可连通。
- 后端 `GET /api/v1/health` 正常。
- Compute API 健康检查正常。
- Compute Agent 能识别 GPU、显存、CUDA 版本。
- LLaMA-Factory 能在命令行完成最小训练样例。
- 应用平台能提交训练任务到 Compute API。
- 任务状态能从算力平台同步回应用平台。
- 数据集上传、离线导入、产物下载路径权限正确。
- 后端 JSON 日志可被日志平台解析。
- ERROR 日志能触发告警。
- 日志、数据集、模型、产物所在磁盘容量有监控和告警。
## 11. 仍需确认的问题
- 生产环境是否已有统一 ELK/OpenSearch、Filebeat/Vector 标准配置。
- 数据库和 Redis 是否由企业基础设施统一提供,还是由项目自行部署。
- 是否需要 PostgreSQL 主备、备份恢复、审计日志长期归档的明确 SLA。
- 大文件上传是否需要断点续传、限速、病毒扫描或 DLP 检测。
- 应用服务器与算力服务器之间是否允许双向访问,还是只能应用侧主动访问算力侧。
- 是否需要未来支持多台 GPU 节点调度如果需要Compute API 需要提前设计节点注册和调度策略。

View File

@@ -0,0 +1,744 @@
# 模型训练平台架构与功能需求设计
> 本文基于当前前端页面、已有接口/数据库设计,以及用户补充的 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 AdapterLLaMA-Factory 适配器,后续扩展其他训练引擎。
### 2.4 应用与算力平台通信
建议统一使用内部 HTTP/gRPC API并配置服务间认证
- 应用平台调用算力平台必须携带 `X-Service-Token` 或 mTLS 证书。
- 算力平台不信任前端用户身份,只信任应用平台下发的租户、项目、任务和资源上下文。
- 所有任务回调必须带签名,避免伪造状态。
核心通信接口:
| 方向 | 接口 | 说明 |
| --- | --- | --- |
| 应用 -> 算力 | `POST /compute/jobs` | 创建训练/评测/数据处理/推理任务 |
| 应用 -> 算力 | `POST /compute/jobs/{id}/stop` | 停止任务 |
| 应用 -> 算力 | `GET /compute/jobs/{id}` | 查询任务状态 |
| 应用 -> 算力 | `GET /compute/jobs/{id}/logs` | 拉取日志 |
| 应用 -> 算力 | `GET /compute/resources/gpus` | 查询 GPU 状态 |
| 应用 -> 算力 | `POST /compute/files/upload` | 上传文件到算力本地磁盘 |
| 应用 -> 算力 | `GET /compute/files/{object_id}/download` | 下载文件 |
| 算力 -> 应用 | `POST /api/internal/compute-callbacks/jobs` | 回调任务状态、指标、产物 |
## 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 资源调度
### 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`
- 高优任务是否可抢占低优任务,需要审批或管理员权限。
## 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 状态。
- 训练引擎健康状态。
### 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 /api/tenants`
- `POST /api/tenants`
- `GET /api/tenants/{id}`
- `PUT /api/tenants/{id}`
- `PUT /api/tenants/{id}/quota`
- `PUT /api/tenants/{id}/retention-policy`
项目:
- `GET /api/projects`
- `POST /api/projects`
- `GET /api/projects/{id}`
- `PUT /api/projects/{id}`
- `POST /api/projects/{id}/archive`
- `GET /api/projects/{id}/members`
- `POST /api/projects/{id}/members`
- `PUT /api/projects/{id}/members/{user_id}`
- `DELETE /api/projects/{id}/members/{user_id}`
资源授权:
- `GET /api/resources/{resource_type}/{resource_id}/acl`
- `PUT /api/resources/{resource_type}/{resource_id}/acl`
- `POST /api/resources/{resource_type}/{resource_id}/share`
审批:
- `GET /api/approvals`
- `POST /api/approvals`
- `GET /api/approvals/{id}`
- `POST /api/approvals/{id}/approve`
- `POST /api/approvals/{id}/reject`
- `POST /api/approvals/{id}/cancel`
算力:
- `GET /api/compute/nodes`
- `GET /api/compute/gpus`
- `GET /api/compute/queue`
- `POST /api/compute/jobs/{id}/retry`
- `POST /api/compute/jobs/{id}/priority`
训练引擎:
- `GET /api/training-engines`
- `GET /api/training-engines/{id}`
- `POST /api/training-engines/{id}/health-check`
- `GET /api/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. 推荐决策补充
`system-development-plan.md` 已对上述问题给出第一版建议,需求设计以以下决策为准。
1. 本地磁盘主存储放在算力服务器,应用服务器只保留上传临时文件。临时文件默认保留 24 小时,成功转发到算力文件网关后可立即进入清理队列。
2. 第一版不支持 MIG、GPU 分片和多任务共享同一张 GPU。一张 GPU 同一时间只分配给一个训练任务或一个推理服务。GPU 数据模型预留 `partition_type``parent_gpu_uuid``memory_total_mb`,便于后续扩展 MIG。
3. 第一版不做自动抢占。支持任务优先级和排队;停止他人任务需要审批或平台管理员权限。
4. 第一版必须支持离线导入已有模型和数据集目录。导入由算力 Agent 扫描、校验、登记,并归属指定租户和项目。
5. 模型发布区分测试服务和生产服务。测试服务项目内可启动并默认限流;生产服务必须审批。
6. 数据脱敏和数据质量评分作为数据处理模块的一等能力进入第一期,先实现规则版脱敏、格式校验、重复率、完整性、长度分布等指标。
7. 人工评测/复核作为第二期功能,但第一期需在数据库和页面入口预留人工复核状态与修订字段。
8. 第一版支持从 checkpoint 手动恢复训练,不做自动失败续训。失败任务可选择 checkpoint 重试。
9. 第一版必须支持 checkpoint 自动清理策略:默认保留最近 3 个、最优 2 个;已发布模型关联 checkpoint 不自动删除;失败任务 checkpoint 默认保留 14 天。
10. 第一版提供内部 API第二期再开放面向其他系统的标准 API、API Key、限流和 Webhook。
11. 第一版使用本地账号,预留 OIDC/LDAP 字段和认证 provider 抽象;第二期接入企业统一身份认证。
12. 第一版做 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 查询 |

1386
docs/postgres-schema.sql Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,15 @@ npm run dev
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
开发环境默认启用前端 Mock。如需联调真实后端使用
```bash
VITE_ENABLE_MOCK=false npm run dev
```
生产构建默认不包含 Mock仅在演示构建中可显式设置
`VITE_ENABLE_MOCK=true`
## 构建
```bash

View File

@@ -48,8 +48,8 @@ assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*
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*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高')
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*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比')
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\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')

View File

@@ -181,7 +181,7 @@ assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根
const trainingLogStyle = [trainingLogSource, trainingOverviewSource]
.flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content))
.join('\n')
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.task-overview')
assert.match(
businessSurfaceBlock,
/background:\s*var\(--app-surface-bg\);/,

View File

@@ -117,7 +117,8 @@ assert.match(
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器')
assert.match(source, /usePolling/, '训练日志必须使用统一轮询机制')
assert.match(source, /stopPolling\(\)/, '训练结束后必须停止轮询')
const overview = findElements(
templateAst,

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
// 根组件,仅承载路由出口
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>

View File

@@ -17,10 +17,6 @@ const service: AxiosInstance = axios.create({
timeout: 30000,
})
// 安装 mock adapter拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
import { installMockAdapter } from '@/mock/adapter'
installMockAdapter(service)
// 请求拦截器
service.interceptors.request.use(
(config) => config,

View File

@@ -27,6 +27,11 @@ function updateTime() {
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
}
function openGuide() {
const guideUrl = router.resolve({ name: 'guide' }).href
window.open(guideUrl, '_blank', 'noopener,noreferrer')
}
const serverIp = ref(window.location.hostname)
onMounted(() => {
@@ -103,6 +108,19 @@ onUnmounted(() => {
<span>{{ currentTime }}</span>
</div>
</div>
<div class="divider document-divider"></div>
<el-tooltip content="使用文档" placement="bottom">
<button
type="button"
class="documentation-btn"
aria-label="打开使用文档"
@click="openGuide"
>
<i class="fa fa-book" aria-hidden="true" />
</button>
</el-tooltip>
</div>
</header>
</template>
@@ -261,4 +279,50 @@ onUnmounted(() => {
}
}
}
.documentation-btn {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: #fff;
color: #64748b;
cursor: pointer;
font-size: 17px;
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
&:hover {
border-color: rgba(79, 70, 229, 0.28);
background: #eef2ff;
color: var(--primary-color);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
}
&:focus-visible {
outline: 3px solid rgba(79, 70, 229, 0.2);
outline-offset: 2px;
}
}
@media (max-width: 1280px) {
.header-right {
gap: 12px;
}
.system-info .info-item:first-child {
display: none;
}
}
@media (max-width: 1040px) {
.system-info,
.header-right > .divider:not(.document-divider) {
display: none;
}
}
</style>

View File

@@ -1,20 +1,82 @@
import { useIntervalFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
interface PollingOptions {
/** 启动后是否立即执行一次,默认立即执行。 */
immediate?: boolean
/** 页面不可见时是否暂停,默认暂停。 */
pauseWhenHidden?: boolean
/** 轮询函数抛错时的可选回调。 */
onError?: (error: unknown) => void
}
/**
* 轮询 composable
* 封装 useIntervalFn自动在组件卸载时清理
* 串行轮询:上一轮完成后才安排下一轮,避免慢请求重叠和旧响应覆盖。
* 页面进入后台时自动暂停,恢复可见后立即刷新;组件卸载时自动清理。
*/
export function usePolling(fn: () => void | Promise<void>, interval = 5000, immediate = true) {
const { pause, resume } = useIntervalFn(fn, interval, { immediate })
export function usePolling(
fn: () => void | Promise<void>,
interval: number | (() => number) = 5000,
options: PollingOptions = {},
) {
const { immediate = true, pauseWhenHidden = true, onError } = options
const isActive = ref(false)
const isRunning = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (!timer) return
clearTimeout(timer)
timer = null
}
function schedule() {
clearTimer()
if (!isActive.value) return
if (pauseWhenHidden && document.hidden) return
const delay = typeof interval === 'function' ? interval() : interval
timer = setTimeout(() => void run(), Math.max(0, delay))
}
async function run() {
clearTimer()
if (!isActive.value || isRunning.value) return
if (pauseWhenHidden && document.hidden) return
isRunning.value = true
try {
await fn()
} catch (error) {
onError?.(error)
} finally {
isRunning.value = false
schedule()
}
}
function stop() {
pause()
isActive.value = false
clearTimer()
}
function start() {
if (immediate) fn()
resume()
if (isActive.value) return
isActive.value = true
if (immediate) void run()
else schedule()
}
return { start, stop, pause, resume }
function handleVisibilityChange() {
if (!pauseWhenHidden || !isActive.value) return
if (document.hidden) clearTimer()
else void run()
}
onMounted(() => document.addEventListener('visibilitychange', handleVisibilityChange))
onBeforeUnmount(() => {
stop()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return { start, stop, run, isActive, isRunning }
}

View File

@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 是否启用前端 Mock开发环境默认开启生产环境默认关闭。 */
readonly VITE_ENABLE_MOCK?: 'true' | 'false'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import '@/assets/font-awesome/css/font-awesome.min.css'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import { useSystemStore } from '@/stores/system'

View File

@@ -1,17 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
// Font Awesome 图标
import '@/assets/font-awesome/css/font-awesome.min.css'
// ECharts按需引入+ vue-echarts 组件
import '@/plugins/echarts'
import VChart from 'vue-echarts'
// 这三个能力通过函数或指令使用,无法由模板组件扫描自动补充样式。
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/loading/style/css'
import App from './App.vue'
import service from '@/api/request'
import router from './router'
import './styles/index.scss'
@@ -19,7 +15,19 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.component('VChart', VChart)
async function bootstrap() {
// 开发环境默认使用 Mock生产环境只有显式开启时才加载整套 Mock 数据。
// 这样真实部署不会被前端适配器截断请求,也不会把 Mock 数据打进首屏包。
const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true'
|| (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK !== 'false')
if (shouldEnableMock) {
const { installMockAdapter } = await import('@/mock/adapter')
installMockAdapter(service)
}
app.mount('#app')
}
void bootstrap()

View File

@@ -1,26 +1,20 @@
/**
* ECharts 按需引入
* 仅注册训练曲线所需的模块,避免引入全量包以减小体积
* 服务看板图表按需注册。
*/
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { BarChart, PieChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
} from 'echarts/components'
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
])

View File

@@ -9,6 +9,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', public: true },
},
{
path: '/guide',
name: 'guide',
component: () => import('@/views/guide/GuideView.vue'),
meta: { title: '使用文档', skipPermission: true },
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),

View File

@@ -10,15 +10,24 @@ import type { ModelItem } from '@/types'
export const useModelsStore = defineStore('models', () => {
const list = ref<ModelItem[]>([])
const loaded = ref(false)
let pendingLoad: Promise<void> | null = null
async function load(force = false) {
if (loaded.value && !force) return
if (pendingLoad && !force) return pendingLoad
pendingLoad = (async () => {
try {
list.value = (await getModelList()) || []
loaded.value = true
} catch {
list.value = []
} finally {
pendingLoad = null
}
})()
return pendingLoad
}
/** 根据 id 获取模型名 */

View File

@@ -10,19 +10,29 @@ import type { HealthMetrics } from '@/types'
export const useSystemStore = defineStore('system', () => {
const metrics = ref<HealthMetrics>({})
let timer: ReturnType<typeof setInterval> | null = null
let fetching = false
async function fetchMetrics() {
if (fetching || document.hidden) return
fetching = true
try {
metrics.value = await getHealth()
} catch {
// 静默失败,顶部栏非关键
} finally {
fetching = false
}
}
function handleVisibilityChange() {
if (!document.hidden) void fetchMetrics()
}
function start() {
if (timer) return
fetchMetrics()
void fetchMetrics()
timer = setInterval(fetchMetrics, 30000)
document.addEventListener('visibilitychange', handleVisibilityChange)
}
function stop() {
@@ -30,6 +40,7 @@ export const useSystemStore = defineStore('system', () => {
clearInterval(timer)
timer = null
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
return { metrics, fetchMetrics, start, stop }

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import { getCompare } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
@@ -11,7 +12,6 @@ const router = useRouter()
const taskId = route.params.id as string
const task = ref<CompareTask | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({
systemPrompt: '',
@@ -73,13 +73,11 @@ function handleSubmit() {
window.open(url, '_blank')
}
onMounted(() => {
loadTask()
pollTimer = setInterval(loadTask, 5000)
})
const { start: startPolling } = usePolling(loadTask, 5000, { immediate: false })
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
onMounted(async () => {
await loadTask()
startPolling()
})
</script>

View File

@@ -1,14 +1,9 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import MarkdownView from '@/components/MarkdownView.vue'
import {
getCompare,
chatWithPort,
batchChat,
} from '@/api/modules/compare'
import { getModelByName } from '@/api/modules/model'
import type { CompareTask, LoadedModel } from '@/types'
import { getCompare, chatWithPort } from '@/api/modules/compare'
import type { LoadedModel } from '@/types'
const route = useRoute()
const taskId = route.query.taskId as string
@@ -25,6 +20,7 @@ interface ModelResult {
name: string
content: string
displayContent: string
isTyping: boolean
status: 'loading' | 'done' | 'error'
stats?: { charsPerSec?: number; totalTime?: number }
}
@@ -33,6 +29,7 @@ const results = ref<ModelResult[]>([])
const started = ref(false)
const loadedModels = ref<LoadedModel[]>([])
const typewriterTimers = new Set<ReturnType<typeof setInterval>>()
async function init() {
if (started.value) return
@@ -50,6 +47,7 @@ async function init() {
name: m.model_name || '模型',
content: '',
displayContent: '',
isTyping: false,
status: 'loading',
}))
@@ -65,7 +63,7 @@ async function inferOne(model: LoadedModel, idx: number) {
const startTime = Date.now()
try {
// 尝试通过端口代理调用
const res: any = await Promise.race([
const res: any = await withTimeout(
chatWithPort({
port: model.port,
model_name: model.model_name,
@@ -78,8 +76,8 @@ async function inferOne(model: LoadedModel, idx: number) {
top_k: topK,
max_tokens: maxTokens,
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)),
])
300000,
)
const content = res?.response || res?.content || res?.data || JSON.stringify(res)
const totalTime = (Date.now() - startTime) / 1000
@@ -87,7 +85,7 @@ async function inferOne(model: LoadedModel, idx: number) {
results.value[idx].status = 'done'
results.value[idx].stats = {
totalTime,
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0,
charsPerSec: totalTime > 0 ? Number((content.length / totalTime).toFixed(1)) : 0,
}
// 模拟打字机效果
typewriterDisplay(idx, content)
@@ -97,22 +95,46 @@ async function inferOne(model: LoadedModel, idx: number) {
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('推理超时')), timeoutMs)
}),
])
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
}
/** 打字机效果逐字展示 */
function typewriterDisplay(idx: number, content: string) {
let pos = 0
results.value[idx].isTyping = true
// 将更新次数控制在约 30 次,避免长回答逐字触发 Markdown 全文解析。
const step = Math.max(2, Math.ceil(content.length / 30))
const interval = setInterval(() => {
pos += 2
pos += step
results.value[idx].displayContent = content.slice(0, pos)
if (pos >= content.length) {
clearInterval(interval)
typewriterTimers.delete(interval)
results.value[idx].displayContent = content
results.value[idx].isTyping = false
}
}, 20)
}, 50)
typewriterTimers.add(interval)
}
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
onMounted(init)
onBeforeUnmount(() => {
typewriterTimers.forEach(clearInterval)
typewriterTimers.clear()
})
</script>
<template>
@@ -144,6 +166,7 @@ onMounted(init)
</template>
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
<div v-else-if="r.isTyping" class="streaming-text">{{ r.displayContent }}</div>
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
<div v-else class="loading-text">
<i class="fa fa-spinner fa-spin" /> 正在生成回答...
@@ -212,6 +235,13 @@ onMounted(init)
color: #f56c6c;
}
.streaming-text {
min-height: 80px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.result-stats {
display: flex;
gap: 16px;

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import VChart from 'vue-echarts'
import '@/plugins/echarts'
import type { EChartsOption } from 'echarts'
type ServiceState = 'normal' | 'busy' | 'error'
@@ -253,7 +255,7 @@ const loginDurationStats: LoginDurationStat[] = [
const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
grid: { top: 8, right: 40, bottom: 6, left: 8, containLabel: true },
grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
@@ -261,7 +263,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
},
xAxis: {
type: 'value',
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) / 10) * 10,
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
splitNumber: 4,
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
axisLine: { show: false },
@@ -284,7 +286,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
barMaxWidth: 18,
barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', distance: 8, color: '#64748b', fontSize: 11, formatter: '{c} 小时' },
label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' },
},
],
}))
@@ -798,8 +800,8 @@ function viewTask(task: DashboardTask) {
th,
td {
height: 42px;
padding: 6px 10px;
height: 50px;
padding: 8px 10px;
border-top: 1px solid #eef2f7;
text-align: left;
vertical-align: middle;
@@ -807,7 +809,7 @@ function viewTask(task: DashboardTask) {
}
th {
height: 36px;
height: 38px;
background: #f8fafc;
color: #475569;
font-weight: 600;
@@ -925,12 +927,12 @@ function viewTask(task: DashboardTask) {
.tasks-table {
th,
td {
height: 36px;
padding: 4px 8px;
height: 42px;
padding: 6px 8px;
}
th {
height: 32px;
height: 34px;
}
}
}

View File

@@ -47,7 +47,7 @@ const WIZARD_STEPS = [
{ id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '编辑与保存', desc: '检查、修改并保存结果' },
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
const currentStep = ref(0)
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { computed, defineAsyncComponent, watch } from 'vue'
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
import { DIMENSION_TYPE_MAP } from '@/constants'
import type { DimensionType, ModelItem } from '@/types'
// 编辑器体积较大,仅在用户进入包含 Prompt 的指标配置时加载。
const MdEditor = defineAsyncComponent(async () => {
await import('md-editor-v3/lib/style.css')
const module = await import('md-editor-v3')
return module.MdEditor
})
export interface DimensionFormDraft {
type: DimensionType | ''
description: string

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { usePolling } from '@/composables/usePolling'
import { useModelsStore } from '@/stores/models'
import {
getFineTuneList,
@@ -44,14 +45,10 @@ const filteredList = computed(() => {
})
})
let progressTimer: ReturnType<typeof setInterval> | null = null
async function loadData() {
loading.value = true
try {
dataList.value = (await getFineTuneList()) || []
// 列表加载完成后,立即获取一次运行中任务的进度
refreshProgress()
} catch {
// 拦截器已提示
} finally {
@@ -101,14 +98,13 @@ function formatDateTime(value?: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
onMounted(() => {
modelsStore.load()
loadData()
progressTimer = setInterval(refreshProgress, 5000)
})
const { start: startProgressPolling } = usePolling(refreshProgress, 5000, { immediate: false })
onUnmounted(() => {
if (progressTimer) clearInterval(progressTimer)
onMounted(async () => {
void modelsStore.load()
await loadData()
await refreshProgress()
startProgressPolling()
})
</script>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue'
import { ref, reactive, nextTick, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MarkdownView from '@/components/MarkdownView.vue'
@@ -34,6 +34,7 @@ const temperature = ref(0.7)
const top_p = ref(0.95)
const maxTokens = ref(2048)
const contentRef = ref<HTMLElement>()
let activeAssistant: ChatMessage | null = null
/** 设置面板抽屉 */
const showSettings = ref(false)
@@ -98,8 +99,8 @@ async function handleSend() {
return
}
// 监听流式 message 变化,同步到 assistantMsg
const watchStop = watchMessage(assistantMsg)
// 流式状态变化时只同步当前回复,避免固定定时器空转。
activeAssistant = assistantMsg
await send({
port: target.port,
@@ -118,7 +119,7 @@ async function handleSend() {
assistantMsg.isThinking = false
assistantMsg.isStreaming = false
assistantMsg.done = true
watchStop()
activeAssistant = null
reset()
await nextTick()
scrollToBottom()
@@ -149,17 +150,24 @@ async function mockReply(assistantMsg: ChatMessage, question: string) {
scrollToBottom()
}
/** 轮询同步流式状态到展示消息 */
function watchMessage(assistantMsg: ChatMessage) {
const timer = setInterval(() => {
assistantMsg.content = message.value.displayContent
assistantMsg.think = message.value.thinkContent
assistantMsg.isThinking = message.value.isThinking
if (message.value.done) clearInterval(timer)
watch(
() => [
message.value.displayContent,
message.value.thinkContent,
message.value.isThinking,
message.value.done,
] as const,
async ([content, think, isThinking, done]) => {
if (!activeAssistant) return
activeAssistant.content = content
activeAssistant.think = think
activeAssistant.isThinking = isThinking
activeAssistant.isStreaming = !done
await nextTick()
scrollToBottom()
}, 80)
return () => clearInterval(timer)
}
},
{ flush: 'post' },
)
function scrollToBottom() {
if (contentRef.value) {
@@ -168,6 +176,7 @@ function scrollToBottom() {
}
function handleNewChat() {
activeAssistant = null
messages.value = []
reset()
}

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { usePolling } from '@/composables/usePolling'
import {
getCompareList,
deleteCompare,
@@ -17,7 +18,7 @@ const router = useRouter()
const loading = ref(false)
const dataList = ref<CompareTask[]>([])
let refreshTimer: ReturnType<typeof setInterval> | null = null
let delayedRefreshTimer: ReturnType<typeof setTimeout> | null = null
async function loadData(silent = false) {
if (!silent) {
@@ -80,7 +81,8 @@ function parseModelNames(row: any): string[] {
async function handleLoad(row: any) {
await loadCompare(row.id)
ElMessage.info('正在加载模型,请稍候...')
setTimeout(loadData, 1000)
if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
delayedRefreshTimer = setTimeout(loadData, 1000)
}
/** 卸载推理任务 */
@@ -112,13 +114,15 @@ function startChat(row: any) {
router.push(`/model-inference/chat/${row.id}`)
}
onMounted(() => {
loadData()
refreshTimer = setInterval(() => loadData(true), 3000)
const { start: startPolling } = usePolling(() => loadData(true), 3000, { immediate: false })
onMounted(async () => {
await loadData()
startPolling()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
})
</script>

View File

@@ -136,7 +136,7 @@ async function handleLogin() {
position: relative;
color: #fff;
background-color: #121127;
background-image: url('@/assets/login-hero-flow.png');
background-image: url('@/assets/login-hero-flow.jpg');
background-size: cover;
background-position: center;
overflow: hidden;

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import VChart from 'vue-echarts'
import PageCard from '@/components/PageCard.vue'
import '@/plugins/echarts-hardware'
import { getSystemInfo } from '@/api/modules/system'
import type { GpuInfo, SystemInfo } from '@/types'

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import PageCard from '@/components/PageCard.vue'
import { useCountdown } from '@/composables/useCountdown'
import { usePolling } from '@/composables/usePolling'
import {
getLogFiles,
getLogContent,
@@ -30,29 +31,26 @@ const fullContent = ref('')
// 自动刷新
const refreshInterval = ref(10)
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const filteredContent = computed(() => {
if (!keyword.value.trim()) return fullContent.value
const filteredLog = computed(() => {
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
const kw = keyword.value.toLowerCase().trim()
return fullContent.value
const lines = fullContent.value
.split('\n')
.filter((line) => line.toLowerCase().includes(kw))
.join('\n')
return { content: lines.join('\n'), count: lines.length }
})
const matchCount = computed(() => {
if (!keyword.value.trim()) return 0
const kw = keyword.value.toLowerCase().trim()
return fullContent.value.split('\n').filter((line) => line.toLowerCase().includes(kw)).length
})
const filteredContent = computed(() => filteredLog.value.content)
const matchCount = computed(() => filteredLog.value.count)
async function loadSysFiles() {
try {
sysFiles.value = (await getLogFiles(sysDate.value)) || []
if (sysFiles.value.length > 0) {
sysSelected.value = sysFiles.value[0].file
loadSysContent()
const firstFile = sysFiles.value[0].file
if (sysSelected.value === firstFile) void loadSysContent()
else sysSelected.value = firstFile
} else {
sysContent.value = '该日期暂无日志文件'
}
@@ -76,8 +74,9 @@ async function loadTrainFiles() {
try {
trainFiles.value = (await getTrainingLogFiles()) || []
if (trainFiles.value.length > 0) {
trainSelected.value = trainFiles.value[0].file
loadTrainContent()
const firstFile = trainFiles.value[0].file
if (trainSelected.value === firstFile) void loadTrainContent()
else trainSelected.value = firstFile
} else {
trainContent.value = '暂无训练日志'
}
@@ -97,29 +96,28 @@ async function loadTrainContent() {
}
}
function refresh() {
async function refresh() {
if (activeTab.value === 'system') {
loadSysContent()
await loadSysContent()
} else {
loadTrainContent()
await loadTrainContent()
}
}
const { start: startPolling, stop: stopPolling } = usePolling(
refresh,
() => refreshInterval.value * 1000,
{ immediate: false },
)
function startAutoRefresh() {
stopAutoRefresh()
stopPolling()
if (refreshInterval.value === 0) {
stopCountdown()
return
}
startCountdown()
refreshTimer = setInterval(refresh, refreshInterval.value * 1000)
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
startPolling()
}
watch(refreshInterval, startAutoRefresh)
@@ -135,8 +133,6 @@ onMounted(() => {
loadSysFiles()
startAutoRefresh()
})
onUnmounted(stopAutoRefresh)
</script>
<template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import VChart from 'vue-echarts'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
import { usePolling } from '@/composables/usePolling'
import '@/plugins/echarts-training-log'
import { useModelsStore } from '@/stores/models'
import { getFineTune } from '@/api/modules/fineTune'
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
@@ -55,7 +58,6 @@ const paramsExpanded = ref(false)
const GPU_PREVIEW_LIMIT = 4
const gpuExpanded = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let refreshInFlight = false
/** 三个曲线的 ECharts 配置(响应式,数据变化自动重绘) */
@@ -257,14 +259,16 @@ async function refreshAll() {
}
}
onMounted(() => {
modelsStore.load()
refreshAll()
timer = setInterval(refreshAll, 5000)
})
const isTerminalTask = () => ['completed', 'failed', 'stopped', 'cancelled'].includes(task.value?.status || '')
const { start: startPolling, stop: stopPolling } = usePolling(async () => {
await refreshAll()
if (isTerminalTask()) stopPolling()
}, 5000, { immediate: false })
onUnmounted(() => {
if (timer) clearInterval(timer)
onMounted(async () => {
void modelsStore.load()
await refreshAll()
if (!isTerminalTask()) startPolling()
})
</script>

View File

@@ -61,6 +61,7 @@ function formatDateTime(value?: string) {
margin-bottom: 0;
border: 1px solid #e4e7ed !important;
border-radius: 8px !important;
background: var(--app-surface-bg);
box-shadow: none !important;
}
.overview-layout { width: 100%; }

View File

@@ -10,10 +10,10 @@ export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver({ importStyle: false })],
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
],
resolve: {