Compare commits
41 Commits
v1_interfa
...
v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 24a05ab7ab | |||
| 513e96082c | |||
| c9575b2553 | |||
| 03b6071856 | |||
| 85710d865c | |||
| 0f98d67e41 | |||
| d0675aede3 | |||
| 49393fefa0 | |||
| e494c4ce50 | |||
| e9e0e21e47 | |||
| a560d24e2f | |||
| 8a638b6372 | |||
| b7cd8097ac | |||
| 18e88601c0 | |||
| ef2bfe1acd | |||
| bdeba313f5 | |||
| 40ca89fad5 | |||
| 730ac6f460 | |||
| 7f64362826 | |||
| 6af55b4ff9 | |||
| 8c89e9907f | |||
| 1a847996c8 | |||
| 7109bdc9aa | |||
| d7fa8583f7 | |||
| f126139bbd | |||
| 6c87af46ba | |||
| 7da8a042cb | |||
| 80b937f82b | |||
| 93c8f1fcb6 | |||
| fb136fc778 | |||
| 5be838a74e | |||
| 861a4f4833 | |||
| 542cde416f | |||
| c328a69b28 | |||
| b2792a1144 | |||
| 8b4b750b44 | |||
| 86e1fc4c18 | |||
| 41d53fc10f | |||
| fa9c976f28 | |||
| bfaeb24d9e | |||
| 88eaa33db0 |
@@ -33,7 +33,15 @@
|
||||
"Bash(./test_upload.sh:*)",
|
||||
"Bash(./test_all.sh)",
|
||||
"Bash(/data/code/FT_Platform/YG_FT_Platform/test_data_dir.sh:*)",
|
||||
"Bash(grep:*)"
|
||||
"Bash(grep:*)",
|
||||
"Bash(mysql:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(/root/miniconda3/bin/pip install psutil)",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(pip install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 数据库配置
|
||||
database:
|
||||
host: "10.10.10.189"
|
||||
host: "mysql"
|
||||
port: 3306
|
||||
username: "root"
|
||||
password: "88116142"
|
||||
@@ -10,8 +10,12 @@ database:
|
||||
# 应用配置
|
||||
app:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
port: 7861
|
||||
web_port: 7863
|
||||
debug: true
|
||||
|
||||
# 密钥配置
|
||||
secret_key: "yg-ft-platform-secret-key-2024"
|
||||
|
||||
# 训练日志路径
|
||||
training_logs_path: "/app/base/training_logs"
|
||||
|
||||
124
create_venv.sh
Normal file
124
create_venv.sh
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
# YG_FT_Base 虚拟环境创建脚本 (Linux/Mac/WSL)
|
||||
# 使用方法: bash create_venv.sh 或 ./create_venv.sh
|
||||
|
||||
# 自动修复脚本换行符(如果是从 Windows 传来的文件)
|
||||
if grep -q $'\r' "$0"; then
|
||||
echo "检测到 Windows 换行符,自动修复中..."
|
||||
sed -i 's/\r$//' "$0"
|
||||
echo "修复完成,重新执行脚本..."
|
||||
exec "$0"
|
||||
fi
|
||||
|
||||
echo "===================================="
|
||||
echo "YG_FT_Base 虚拟环境创建脚本"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# 设置虚拟环境名称
|
||||
VENV_NAME="B_venv"
|
||||
|
||||
# 检查 Python 版本
|
||||
echo "检查 Python 版本..."
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON_CMD="python3"
|
||||
python3 --version
|
||||
elif command -v python &> /dev/null; then
|
||||
PYTHON_CMD="python"
|
||||
python --version
|
||||
else
|
||||
echo "错误: 未找到 Python!请先安装 Python 3.10 或更高版本。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查并安装 python3-venv(仅限 Debian/Ubuntu 系统)
|
||||
echo ""
|
||||
echo "检查 python3-venv..."
|
||||
if [ -f /etc/debian_version ]; then
|
||||
echo "检测到 Debian/Ubuntu 系统"
|
||||
if ! dpkg -l | grep -q python3-venv; then
|
||||
echo "python3-venv 未安装,尝试安装..."
|
||||
if [ -x "$(command -v apt)" ]; then
|
||||
apt update && apt install -y python3-venv python3-pip
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "警告: 安装 python3-venv 失败,请手动运行:"
|
||||
echo " apt install python3-venv python3-pip"
|
||||
echo "然后重新运行此脚本。"
|
||||
exit 1
|
||||
fi
|
||||
echo "python3-venv 安装成功!"
|
||||
else
|
||||
echo "错误: 未找到 apt 包管理器,请手动安装 python3-venv。"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "python3-venv 已安装。"
|
||||
fi
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
echo "检测到 RedHat/CentOS/Fedora 系统"
|
||||
if ! rpm -qa | grep -q python3-virtualenv; then
|
||||
echo "python3-virtualenv 未安装,尝试安装..."
|
||||
if [ -x "$(command -v yum)" ]; then
|
||||
yum install -y python3-virtualenv python3-pip
|
||||
elif [ -x "$(command -v dnf)" ]; then
|
||||
dnf install -y python3-virtualenv python3-pip
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查是否已存在虚拟环境
|
||||
if [ -d "$VENV_NAME" ]; then
|
||||
echo "警告: 虚拟环境 '$VENV_NAME' 已存在!"
|
||||
read -p "是否删除并重新创建? (y/n): " choice
|
||||
if [[ "$choice" == "y" || "$choice" == "Y" ]]; then
|
||||
echo "删除旧虚拟环境..."
|
||||
rm -rf "$VENV_NAME"
|
||||
else
|
||||
echo "已取消操作。"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 创建虚拟环境
|
||||
echo ""
|
||||
echo "创建虚拟环境..."
|
||||
$PYTHON_CMD -m venv "$VENV_NAME"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "错误: 创建虚拟环境失败!"
|
||||
exit 1
|
||||
fi
|
||||
echo "虚拟环境创建成功!"
|
||||
|
||||
# 激活虚拟环境并升级 pip
|
||||
echo ""
|
||||
echo "激活虚拟环境并升级 pip..."
|
||||
source "$VENV_NAME/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
echo "pip 升级完成!"
|
||||
|
||||
# 安装依赖
|
||||
echo ""
|
||||
echo "安装项目依赖..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip install -r requirements.txt
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "依赖安装成功!"
|
||||
else
|
||||
echo "警告: 依赖安装过程中出现一些问题。"
|
||||
fi
|
||||
else
|
||||
echo "未找到 requirements.txt,跳过依赖安装。"
|
||||
fi
|
||||
|
||||
# 完成信息
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo "虚拟环境创建完成!"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
echo "激活虚拟环境:"
|
||||
echo " source $VENV_NAME/bin/activate"
|
||||
echo ""
|
||||
echo "运行项目:"
|
||||
echo " python src/main.py"
|
||||
echo ""
|
||||
15002
datasets/1768964128318_6_json
Normal file
15002
datasets/1768964128318_6_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1768964285638_7_json
Normal file
15002
datasets/1768964285638_7_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1768964422776_8_json
Normal file
15002
datasets/1768964422776_8_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1768964463797_9_json
Normal file
15002
datasets/1768964463797_9_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1768964692603_10_json
Normal file
15002
datasets/1768964692603_10_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1768964776700_12_json
Normal file
15002
datasets/1768964776700_12_json
Normal file
File diff suppressed because one or more lines are too long
15002
datasets/1769073525693_13_json
Normal file
15002
datasets/1769073525693_13_json
Normal file
File diff suppressed because one or more lines are too long
8
datasets/dataset_info.json
Normal file
8
datasets/dataset_info.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"123": {
|
||||
"file_name": "1769495241519_8_liangce_257.json"
|
||||
},
|
||||
"liangce": {
|
||||
"file_name": "1769605160299_1_liangce_257.json"
|
||||
}
|
||||
}
|
||||
55
delete_venv.sh
Normal file
55
delete_venv.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# 删除 YG_FT_Base 虚拟环境脚本
|
||||
# 使用方法: bash delete_venv.sh
|
||||
|
||||
# 自动修复脚本换行符
|
||||
if grep -q $'\r' "$0"; then
|
||||
echo "检测到 Windows 换行符,自动修复中..."
|
||||
sed -i 's/\r$//' "$0"
|
||||
echo "修复完成,重新执行脚本..."
|
||||
exec "$0"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_NAME="B_venv"
|
||||
VENV_PATH="$SCRIPT_DIR/$VENV_NAME"
|
||||
|
||||
echo "===================================="
|
||||
echo "YG_FT_Base 虚拟环境删除脚本"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# 检查虚拟环境是否存在
|
||||
if [ ! -d "$VENV_PATH" ]; then
|
||||
echo "虚拟环境 '$VENV_NAME' 不存在,无需删除。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 确认删除
|
||||
read -p "确定要删除虚拟环境 '$VENV_NAME' 吗?这将删除所有已安装的包。(y/n): " choice
|
||||
|
||||
if [[ "$choice" != "y" && "$choice" != "Y" ]]; then
|
||||
echo "已取消操作。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 删除虚拟环境
|
||||
echo ""
|
||||
echo "🗑️ 删除虚拟环境..."
|
||||
rm -rf "$VENV_PATH"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 虚拟环境 '$VENV_NAME' 已删除!"
|
||||
else
|
||||
echo "❌ 删除失败,请检查权限。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo "删除完成!"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
echo "如需重新创建虚拟环境,请运行:"
|
||||
echo " bash create_venv.sh"
|
||||
echo ""
|
||||
30
local_models/Qwen3-0.6B/config.json
Normal file
30
local_models/Qwen3-0.6B/config.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"architectures": [
|
||||
"Qwen3ForCausalLM"
|
||||
],
|
||||
"attention_bias": false,
|
||||
"attention_dropout": 0.0,
|
||||
"bos_token_id": 151643,
|
||||
"eos_token_id": 151645,
|
||||
"head_dim": 128,
|
||||
"hidden_act": "silu",
|
||||
"hidden_size": 1024,
|
||||
"initializer_range": 0.02,
|
||||
"intermediate_size": 3072,
|
||||
"max_position_embeddings": 40960,
|
||||
"max_window_layers": 28,
|
||||
"model_type": "qwen3",
|
||||
"num_attention_heads": 16,
|
||||
"num_hidden_layers": 28,
|
||||
"num_key_value_heads": 8,
|
||||
"rms_norm_eps": 1e-06,
|
||||
"rope_scaling": null,
|
||||
"rope_theta": 1000000,
|
||||
"sliding_window": null,
|
||||
"tie_word_embeddings": true,
|
||||
"torch_dtype": "bfloat16",
|
||||
"transformers_version": "4.51.0",
|
||||
"use_cache": true,
|
||||
"use_sliding_window": false,
|
||||
"vocab_size": 151936
|
||||
}
|
||||
1
local_models/Qwen3-0.6B/configuration.json
Normal file
1
local_models/Qwen3-0.6B/configuration.json
Normal file
@@ -0,0 +1 @@
|
||||
{"framework": "pytorch", "task": "text-generation", "allow_remote": true}
|
||||
13
local_models/Qwen3-0.6B/generation_config.json
Normal file
13
local_models/Qwen3-0.6B/generation_config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"bos_token_id": 151643,
|
||||
"do_sample": true,
|
||||
"eos_token_id": [
|
||||
151645,
|
||||
151643
|
||||
],
|
||||
"pad_token_id": 151643,
|
||||
"temperature": 0.6,
|
||||
"top_k": 20,
|
||||
"top_p": 0.95,
|
||||
"transformers_version": "4.51.0"
|
||||
}
|
||||
@@ -3,3 +3,8 @@ flask-cors==4.0.0
|
||||
pymysql==1.1.0
|
||||
pyyaml==6.0.1
|
||||
cryptography==41.0.7
|
||||
requests==2.31.0
|
||||
psutil==5.9.8
|
||||
werkzeug==3.0.1
|
||||
pynvml==11.5.0
|
||||
tensorboard>=2.13.0
|
||||
|
||||
19
src/api/__init__.py
Normal file
19
src/api/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
API 路由包
|
||||
"""
|
||||
from .datasets import datasets_bp
|
||||
from .model_manage import model_manage_bp
|
||||
from .model_chat import model_chat_bp
|
||||
from .dimension import dimension_bp
|
||||
from .logs import logs_bp
|
||||
from .fine_tune import fine_tune_bp
|
||||
|
||||
# 注册所有蓝图
|
||||
def register_blueprints(app):
|
||||
"""注册所有蓝图"""
|
||||
app.register_blueprint(datasets_bp)
|
||||
app.register_blueprint(model_manage_bp)
|
||||
app.register_blueprint(model_chat_bp)
|
||||
app.register_blueprint(dimension_bp)
|
||||
app.register_blueprint(logs_bp)
|
||||
app.register_blueprint(fine_tune_bp)
|
||||
523
src/api/datasets.py
Normal file
523
src/api/datasets.py
Normal file
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
数据集管理 API 路由
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import zipfile
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, send_from_directory, Response
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
# 获取项目根目录
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATASET_FOLDER = os.path.join(PROJECT_ROOT, 'datasets')
|
||||
ALLOWED_EXTENSIONS = {'jsonl', 'json', 'xls', 'xlsx'}
|
||||
|
||||
# 创建蓝图
|
||||
datasets_bp = Blueprint('datasets', __name__, url_prefix='/api/dataset-manage')
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""获取数据库连接"""
|
||||
import pymysql
|
||||
import yaml
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
db_config = CONFIG['database']
|
||||
return pymysql.connect(
|
||||
host=db_config['host'],
|
||||
port=db_config['port'],
|
||||
user=db_config['username'],
|
||||
password=db_config['password'],
|
||||
database=db_config['name'],
|
||||
charset=db_config.get('charset', 'utf8mb4'),
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
|
||||
def format_file_size(size_bytes):
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def update_dataset_info_json(dataset_name=None, actual_filename=None, remove_filename=None):
|
||||
"""更新 datasets/dataset_info.json 文件
|
||||
|
||||
Args:
|
||||
dataset_name: 数据集名称(用于作为 key)
|
||||
actual_filename: 实际保存的文件名(含时间戳前缀),用于 file_name
|
||||
remove_filename: 要移除的文件名,为None表示不移除
|
||||
"""
|
||||
info_path = os.path.join(DATASET_FOLDER, 'dataset_info.json')
|
||||
|
||||
# 读取现有配置
|
||||
dataset_info = {}
|
||||
if os.path.exists(info_path):
|
||||
try:
|
||||
with open(info_path, 'r', encoding='utf-8') as f:
|
||||
dataset_info = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"读取 dataset_info.json 失败: {e}")
|
||||
|
||||
# 移除旧条目(根据移除的文件名)
|
||||
if remove_filename:
|
||||
key = os.path.splitext(remove_filename)[0]
|
||||
if key in dataset_info:
|
||||
del dataset_info[key]
|
||||
print(f"从 dataset_info.json 移除: {key}")
|
||||
|
||||
# 添加新条目
|
||||
if dataset_name and actual_filename:
|
||||
dataset_info[dataset_name] = {"file_name": actual_filename}
|
||||
print(f"更新 dataset_info.json: {dataset_name} -> {actual_filename}")
|
||||
|
||||
# 写入文件
|
||||
try:
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(dataset_info, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"写入 dataset_info.json 失败: {e}")
|
||||
|
||||
|
||||
def generic_get_by_id(table_name, id_val):
|
||||
"""通用按ID查询"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT * FROM {table_name} WHERE id = %s", (id_val,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# ============ 数据集管理 CRUD ============
|
||||
|
||||
@datasets_bp.route('/<int:id>', methods=['GET'])
|
||||
def get_dataset(id):
|
||||
"""获取单个数据集详情"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM dataset_manage WHERE id = %s", (id,))
|
||||
dataset = cursor.fetchone()
|
||||
|
||||
if not dataset:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': '数据集不存在'})
|
||||
|
||||
# 获取关联的文件列表
|
||||
cursor.execute(
|
||||
"SELECT id, file_name, file_path, file_size, file_type, create_time FROM dataset_files WHERE dataset_id = %s ORDER BY create_time DESC",
|
||||
(id,)
|
||||
)
|
||||
files = cursor.fetchall()
|
||||
|
||||
# 格式化文件大小
|
||||
for f in files:
|
||||
f['file_size_formatted'] = format_file_size(f['file_size'])
|
||||
|
||||
dataset['files'] = files
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'code': 0, 'data': dataset})
|
||||
|
||||
|
||||
@datasets_bp.route('', methods=['GET'])
|
||||
def get_datasets():
|
||||
"""获取所有数据集"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM dataset_manage ORDER BY create_time DESC")
|
||||
result = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 0, 'data': result})
|
||||
|
||||
|
||||
@datasets_bp.route('', methods=['POST'])
|
||||
def create_dataset():
|
||||
"""创建数据集"""
|
||||
data = request.json
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
columns = ', '.join(data.keys())
|
||||
placeholders = ', '.join(['%s'] * len(data))
|
||||
sql = f"INSERT INTO dataset_manage ({columns}) VALUES ({placeholders})"
|
||||
cursor.execute(sql, list(data.values()))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 0, 'message': '创建成功', 'id': new_id})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 1, 'message': f'创建失败: {str(e)}'})
|
||||
|
||||
|
||||
@datasets_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update_dataset(id):
|
||||
"""更新数据集"""
|
||||
data = request.json
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
|
||||
sql = f"UPDATE dataset_manage SET {set_clause} WHERE id = %s"
|
||||
values = list(data.values()) + [id]
|
||||
cursor.execute(sql, values)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 0, 'message': '更新成功'})
|
||||
|
||||
|
||||
@datasets_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete_dataset(id):
|
||||
"""删除数据集"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取数据集名称(用于从 dataset_info.json 移除条目)
|
||||
cursor.execute("SELECT name FROM dataset_manage WHERE id = %s", (id,))
|
||||
dataset_result = cursor.fetchone()
|
||||
dataset_name = dataset_result['name'] if dataset_result else None
|
||||
|
||||
# 获取文件信息列表(包含原始文件名)
|
||||
cursor.execute("SELECT file_name, file_path FROM dataset_files WHERE dataset_id = %s", (id,))
|
||||
files = cursor.fetchall()
|
||||
|
||||
for f in files:
|
||||
file_path = f.get('file_path')
|
||||
# 尝试多个可能的路径
|
||||
paths_to_try = []
|
||||
if file_path:
|
||||
paths_to_try.append(file_path)
|
||||
# 尝试 PROJECT_ROOT 相对路径
|
||||
rel_path = file_path.replace('/app/base', PROJECT_ROOT, 1) if file_path.startswith('/app/base') else None
|
||||
if rel_path:
|
||||
paths_to_try.append(rel_path)
|
||||
|
||||
for path in paths_to_try:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
print(f"已删除文件: {path}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"删除文件失败: {path}, {e}")
|
||||
|
||||
# 使用数据集名称从 dataset_info.json 移除条目
|
||||
if dataset_name:
|
||||
update_dataset_info_json(remove_filename=dataset_name)
|
||||
|
||||
# 删除数据库记录
|
||||
cursor.execute("DELETE FROM dataset_files WHERE dataset_id = %s", (id,))
|
||||
cursor.execute("DELETE FROM dataset_manage WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 0, 'message': '删除成功'})
|
||||
|
||||
|
||||
# ============ 数据集文件上传接口 ============
|
||||
|
||||
def make_response_with_cors(data, status=200):
|
||||
"""创建带CORS头的响应"""
|
||||
response = jsonify(data)
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
return response
|
||||
|
||||
@datasets_bp.route('/upload/<int:dataset_id>', methods=['POST', 'OPTIONS'])
|
||||
def upload_dataset_file(dataset_id):
|
||||
"""上传数据集文件"""
|
||||
# 处理 OPTIONS 预检请求
|
||||
if request.method == 'OPTIONS':
|
||||
response = make_response_with_cors({'status': 'ok'})
|
||||
return response
|
||||
|
||||
# 检查数据集是否存在
|
||||
dataset = generic_get_by_id('dataset_manage', dataset_id)
|
||||
if not dataset:
|
||||
return make_response_with_cors({'code': 1, 'message': '数据集不存在'})
|
||||
|
||||
# 确保上传目录存在(datasets根目录)
|
||||
os.makedirs(DATASET_FOLDER, exist_ok=True)
|
||||
|
||||
uploaded_files = []
|
||||
errors = []
|
||||
|
||||
if 'files' not in request.files:
|
||||
return make_response_with_cors({'code': 1, 'message': '没有文件被上传'})
|
||||
|
||||
files = request.files.getlist('files')
|
||||
|
||||
for file in files:
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
# 添加时间戳和dataset_id防止文件名冲突,格式:timestamp_datasetId_filename
|
||||
timestamp = int(time.time() * 1000)
|
||||
new_filename = f"{timestamp}_{dataset_id}_{filename}"
|
||||
file_path = os.path.join(DATASET_FOLDER, new_filename)
|
||||
|
||||
# 保存文件
|
||||
file.save(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 获取数据集名称用于作为 dataset_info.json 的 key
|
||||
dataset_name = dataset.get('name') if dataset else None
|
||||
|
||||
# 更新 dataset_info.json,使用数据集名称作为 key,实际保存的文件名作为 file_name
|
||||
update_dataset_info_json(dataset_name=dataset_name, actual_filename=new_filename)
|
||||
|
||||
# 获取文件扩展名(安全处理无扩展名的情况)
|
||||
parts = filename.rsplit('.', 1)
|
||||
ext = parts[1].lower() if len(parts) > 1 else 'unknown'
|
||||
|
||||
# 保存文件信息到数据库
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO dataset_files (dataset_id, file_name, file_path, file_size, file_type) VALUES (%s, %s, %s, %s, %s)",
|
||||
(dataset_id, filename, file_path, file_size, ext)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
uploaded_files.append({
|
||||
'name': filename,
|
||||
'size': file_size,
|
||||
'size_formatted': format_file_size(file_size)
|
||||
})
|
||||
else:
|
||||
errors.append(f"{file.filename}: 文件类型不支持")
|
||||
|
||||
# 如果有成功上传的文件,才更新数据集的文件数量和大小
|
||||
if uploaded_files:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count, SUM(file_size) as total_size FROM dataset_files WHERE dataset_id = %s", (dataset_id,))
|
||||
result = cursor.fetchone()
|
||||
file_count = result['count'] or 0
|
||||
total_size = result['total_size'] or 0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE dataset_manage SET file_count = %s, size = %s WHERE id = %s",
|
||||
(file_count, format_file_size(total_size), dataset_id)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if errors:
|
||||
return make_response_with_cors({
|
||||
'code': 0,
|
||||
'message': f'部分文件上传成功,{len(errors)}个文件失败',
|
||||
'data': {
|
||||
'uploaded': uploaded_files,
|
||||
'errors': errors
|
||||
}
|
||||
})
|
||||
|
||||
return make_response_with_cors({
|
||||
'code': 0,
|
||||
'message': f'成功上传 {len(uploaded_files)} 个文件',
|
||||
'data': {
|
||||
'uploaded': uploaded_files,
|
||||
'file_count': file_count
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@datasets_bp.route('/<int:dataset_id>/files', methods=['GET'])
|
||||
def get_dataset_files(dataset_id):
|
||||
"""获取数据集文件列表"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, file_name, file_path, file_size, file_type, create_time FROM dataset_files WHERE dataset_id = %s ORDER BY create_time DESC",
|
||||
(dataset_id,)
|
||||
)
|
||||
files = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# 格式化文件大小
|
||||
for f in files:
|
||||
f['file_size_formatted'] = format_file_size(f['file_size'])
|
||||
|
||||
return jsonify({'code': 0, 'data': files})
|
||||
|
||||
|
||||
@datasets_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
def delete_dataset_file(file_id):
|
||||
"""删除数据集文件"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取文件信息(包含 dataset_id)
|
||||
cursor.execute("SELECT dataset_id, file_name, file_path FROM dataset_files WHERE id = %s", (file_id,))
|
||||
file_info = cursor.fetchone()
|
||||
|
||||
if not file_info:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': '文件不存在'})
|
||||
|
||||
dataset_id = file_info['dataset_id']
|
||||
|
||||
# 获取数据集名称(用于从 dataset_info.json 移除条目)
|
||||
cursor.execute("SELECT name FROM dataset_manage WHERE id = %s", (dataset_id,))
|
||||
dataset_result = cursor.fetchone()
|
||||
dataset_name = dataset_result['name'] if dataset_result else None
|
||||
|
||||
# 删除物理文件
|
||||
file_path = file_info['file_path']
|
||||
if file_path and os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
print(f"删除文件失败: {file_path}, {e}")
|
||||
|
||||
# 删除数据库记录
|
||||
cursor.execute("DELETE FROM dataset_files WHERE id = %s", (file_id,))
|
||||
|
||||
# 使用数据集名称从 dataset_info.json 移除条目
|
||||
if dataset_name:
|
||||
update_dataset_info_json(remove_filename=dataset_name)
|
||||
|
||||
# 更新数据集的文件数量和大小
|
||||
cursor.execute("SELECT COUNT(*) as count, SUM(file_size) as total_size FROM dataset_files WHERE dataset_id = %s", (dataset_id,))
|
||||
result = cursor.fetchone()
|
||||
file_count = result['count'] or 0
|
||||
total_size = result['total_size'] or 0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE dataset_manage SET file_count = %s, size = %s WHERE id = %s",
|
||||
(file_count, format_file_size(total_size), dataset_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'code': 0, 'message': '删除成功'})
|
||||
|
||||
|
||||
# ============ 文件下载接口 ============
|
||||
|
||||
@datasets_bp.route('/download/<int:dataset_id>/<filename>', methods=['GET'])
|
||||
def download_dataset_file(dataset_id, filename):
|
||||
"""下载数据集文件"""
|
||||
# 文件直接存储在 DATASET_FOLDER 根目录下
|
||||
return send_from_directory(DATASET_FOLDER, filename, as_attachment=True)
|
||||
|
||||
|
||||
@datasets_bp.route('/download/<int:dataset_id>', methods=['GET'])
|
||||
def download_dataset_all(dataset_id):
|
||||
"""下载数据集所有文件(ZIP打包)"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取数据集信息
|
||||
cursor.execute("SELECT name FROM dataset_manage WHERE id = %s", (dataset_id,))
|
||||
dataset = cursor.fetchone()
|
||||
|
||||
if not dataset:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': '数据集不存在'})
|
||||
|
||||
# 获取所有文件
|
||||
cursor.execute(
|
||||
"SELECT id, file_name, file_path FROM dataset_files WHERE dataset_id = %s ORDER BY create_time DESC",
|
||||
(dataset_id,)
|
||||
)
|
||||
files = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if not files:
|
||||
return jsonify({'code': 1, 'message': '数据集没有文件'})
|
||||
|
||||
# 创建ZIP文件
|
||||
memory_file = io.BytesIO()
|
||||
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in files:
|
||||
file_path = f.get('file_path')
|
||||
if file_path and os.path.exists(file_path):
|
||||
# 使用原始文件名
|
||||
zf.write(file_path, f['file_name'])
|
||||
|
||||
memory_file.seek(0)
|
||||
|
||||
# 发送ZIP文件
|
||||
zip_name = f"{dataset['name'] or 'dataset'}_{dataset_id}.zip"
|
||||
return Response(
|
||||
memory_file,
|
||||
mimetype='application/zip',
|
||||
headers={'Content-Disposition': f'attachment;filename={zip_name}'}
|
||||
)
|
||||
|
||||
|
||||
# ============ 文件预览接口 ============
|
||||
|
||||
@datasets_bp.route('/preview/<int:file_id>', methods=['GET'])
|
||||
def preview_dataset_file(file_id):
|
||||
"""预览数据集文件内容(限100KB)"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取文件信息
|
||||
cursor.execute("SELECT id, file_name, file_path, file_type FROM dataset_files WHERE id = %s", (file_id,))
|
||||
file_info = cursor.fetchone()
|
||||
|
||||
if not file_info:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': '文件不存在'})
|
||||
|
||||
file_path = file_info['file_path']
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': '文件不存在'})
|
||||
|
||||
# 读取文件内容(限100KB)
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read(102400) # 100KB
|
||||
except Exception as e:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'code': 1, 'message': f'读取文件失败: {str(e)}'})
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'file_name': file_info['file_name'],
|
||||
'content': content
|
||||
}
|
||||
})
|
||||
144
src/api/dimension.py
Normal file
144
src/api/dimension.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
评测维度管理 API 路由
|
||||
"""
|
||||
import os
|
||||
import pymysql
|
||||
import yaml
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
# 获取项目根目录
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 创建蓝图
|
||||
dimension_bp = Blueprint('dimension', __name__, url_prefix='/api/dimension')
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""获取数据库连接"""
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
db_config = CONFIG['database']
|
||||
return pymysql.connect(
|
||||
host=db_config['host'],
|
||||
port=db_config['port'],
|
||||
user=db_config['username'],
|
||||
password=db_config['password'],
|
||||
database=db_config['name'],
|
||||
charset=db_config.get('charset', 'utf8mb4'),
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
|
||||
def generic_get_all(table_name, order_by='create_time DESC'):
|
||||
"""通用查询所有"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT * FROM {table_name} ORDER BY {order_by}")
|
||||
result = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
def generic_create(table_name, data):
|
||||
"""通用创建"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
columns = ', '.join(data.keys())
|
||||
placeholders = ', '.join(['%s'] * len(data))
|
||||
sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
|
||||
cursor.execute(sql, list(data.values()))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def generic_update(table_name, id_val, data):
|
||||
"""通用更新"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
|
||||
sql = f"UPDATE {table_name} SET {set_clause} WHERE id = %s"
|
||||
values = list(data.values()) + [id_val]
|
||||
cursor.execute(sql, values)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def generic_delete(table_name, id_val):
|
||||
"""通用删除"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"DELETE FROM {table_name} WHERE id = %s", (id_val,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def generic_get_by_id(table_name, id_val):
|
||||
"""通用按ID查询"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT * FROM {table_name} WHERE id = %s", (id_val,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# ============ 评测维度 CRUD ============
|
||||
|
||||
@dimension_bp.route('', methods=['GET'])
|
||||
def get_dimensions():
|
||||
"""获取所有评测维度"""
|
||||
return jsonify({'code': 0, 'data': generic_get_all('model_dimension')})
|
||||
|
||||
|
||||
@dimension_bp.route('/<int:id>', methods=['GET'])
|
||||
def get_dimension_by_id(id):
|
||||
"""获取单个评测维度"""
|
||||
dimension = generic_get_by_id('model_dimension', id)
|
||||
if dimension:
|
||||
return jsonify({'code': 0, 'data': dimension})
|
||||
return jsonify({'code': 1, 'message': '维度不存在'})
|
||||
|
||||
|
||||
@dimension_bp.route('', methods=['POST'])
|
||||
def create_dimension():
|
||||
"""创建评测维度"""
|
||||
data = request.json
|
||||
insert_data = {
|
||||
'name': data.get('name'),
|
||||
'type': data.get('type'),
|
||||
'description': data.get('description', '')
|
||||
}
|
||||
new_id = generic_create('model_dimension', insert_data)
|
||||
return jsonify({'code': 0, 'message': '创建成功', 'id': new_id})
|
||||
|
||||
|
||||
@dimension_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update_dimension(id):
|
||||
"""更新评测维度"""
|
||||
data = request.json
|
||||
update_data = {}
|
||||
if 'name' in data:
|
||||
update_data['name'] = data['name']
|
||||
if 'type' in data:
|
||||
update_data['type'] = data['type']
|
||||
if 'description' in data:
|
||||
update_data['description'] = data['description']
|
||||
|
||||
if update_data:
|
||||
generic_update('model_dimension', id, update_data)
|
||||
return jsonify({'code': 0, 'message': '更新成功'})
|
||||
|
||||
|
||||
@dimension_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete_dimension(id):
|
||||
"""删除评测维度"""
|
||||
generic_delete('model_dimension', id)
|
||||
return jsonify({'code': 0, 'message': '删除成功'})
|
||||
924
src/api/fine_tune.py
Normal file
924
src/api/fine_tune.py
Normal file
@@ -0,0 +1,924 @@
|
||||
"""
|
||||
精调训练 API 路由
|
||||
调用 llamafactory-cli 执行训练任务
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import signal
|
||||
import yaml
|
||||
from flask import Blueprint, request, jsonify
|
||||
import logging
|
||||
|
||||
# 添加项目根目录到路径
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 加载配置
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
train_logger = logging.getLogger('train') # 专门的训练日志 logger,输出到 train.log
|
||||
|
||||
# 从配置获取训练日志路径
|
||||
TRAINING_LOGS_DIR = CONFIG.get('training_logs_path', '/app/base/training_logs')
|
||||
|
||||
# 创建蓝图
|
||||
fine_tune_bp = Blueprint('fine_tune', __name__, url_prefix='/api/fine-tune')
|
||||
|
||||
|
||||
# 训练类型映射
|
||||
TRAIN_TYPE_MAP = {
|
||||
'SFT': 'sft',
|
||||
'DPO': 'dpo',
|
||||
'CPT': 'cpt'
|
||||
}
|
||||
|
||||
# 训练方法映射
|
||||
FINETUNING_TYPE_MAP = {
|
||||
'lora': 'lora',
|
||||
'full': 'full'
|
||||
}
|
||||
|
||||
|
||||
@fine_tune_bp.route('/start', methods=['POST'])
|
||||
def start_training():
|
||||
"""启动 llamafactory 训练任务"""
|
||||
try:
|
||||
data = request.json
|
||||
train_logger.info(f"[TRAIN] ========== 开始训练任务 ==========")
|
||||
train_logger.info(f"[TRAIN] 收到启动训练请求: base_model={data.get('base_model')}, train_dataset_id={data.get('train_dataset_id')}")
|
||||
|
||||
# 必填参数验证
|
||||
required_fields = ['base_model', 'template', 'train_dataset_id']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return jsonify({'code': 1, 'message': f'缺少必要参数: {field}'})
|
||||
|
||||
# 获取模型信息
|
||||
model_path = data.get('base_model')
|
||||
# 尝试转换为整数
|
||||
try:
|
||||
model_id = int(model_path) if str(model_path).isdigit() else None
|
||||
except (ValueError, TypeError):
|
||||
model_id = None
|
||||
|
||||
if model_id:
|
||||
# 如果是 model_id,需要获取模型路径
|
||||
from .model_manage import get_db_connection
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, name, path FROM model_manage WHERE id = %s", (model_id,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
logger.info(f"模型查询结果: {result}")
|
||||
if result and result.get('path'):
|
||||
model_path = result['path']
|
||||
logger.info(f"从数据库获取的模型路径: {model_path}")
|
||||
else:
|
||||
return jsonify({'code': 1, 'message': '模型不存在或路径为空'})
|
||||
elif not model_path:
|
||||
return jsonify({'code': 1, 'message': f'模型路径为空'})
|
||||
|
||||
train_logger.info(f"[TRAIN] 模型路径: {model_path}")
|
||||
|
||||
# 设置工作目录和 llamafactory 目录
|
||||
work_dir = '/app/base'
|
||||
llamafactory_dir = '/app/base'
|
||||
|
||||
# 数据集目录直接使用 /app/base/datasets(不再复制)
|
||||
datasets_dir = '/app/base/datasets'
|
||||
|
||||
# 获取数据集名称(用于 --dataset 参数)
|
||||
dataset_key = None
|
||||
dataset_id = data.get('train_dataset_id')
|
||||
try:
|
||||
dataset_id_int = int(dataset_id) if str(dataset_id).isdigit() else None
|
||||
except (ValueError, TypeError):
|
||||
dataset_id_int = None
|
||||
|
||||
if dataset_id_int:
|
||||
from .datasets import get_db_connection as get_dataset_conn
|
||||
conn = get_dataset_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT dm.name FROM dataset_manage dm WHERE dm.id = %s", (dataset_id_int,))
|
||||
dataset_result = cursor.fetchone()
|
||||
conn.close()
|
||||
dataset_key = dataset_result['name'] if dataset_result else None
|
||||
train_logger.info(f"[TRAIN] 数据集名称: {dataset_key}")
|
||||
|
||||
# 获取选中的 GPU 索引
|
||||
gpus = data.get('gpus', [])
|
||||
if gpus:
|
||||
gpu_ids = [gpu.get('id', '').replace('gpu', '') for gpu in gpus]
|
||||
gpu_ids = [g for g in gpu_ids if g.isdigit()]
|
||||
cuda_devices = ','.join(gpu_ids)
|
||||
else:
|
||||
cuda_devices = '0'
|
||||
|
||||
# 设置环境变量
|
||||
env = os.environ.copy()
|
||||
env['CUDA_VISIBLE_DEVICES'] = cuda_devices
|
||||
env['TF_CPP_MIN_LOG_LEVEL'] = '2' # 减少 TensorFlow 日志
|
||||
env['LLAMAFACTORY_DIR'] = '/app/base' # 指定 llamafactory 根目录
|
||||
env['PYTHONUNBUFFERED'] = '1' # 强制 Python 不缓冲输出,实时写入日志
|
||||
env['TRANSFORMERS_VERBOSITY'] = 'INFO' # 设置 transformers 日志级别
|
||||
|
||||
# 构建 llamafactory-cli 命令(传入数据集名称用于 --dataset 参数)
|
||||
cmd = build_train_command(data, model_path, dataset_key)
|
||||
cmd_str = ' '.join(cmd)
|
||||
train_logger.info(f"[TRAIN] 执行训练命令: {cmd_str}")
|
||||
|
||||
# 在返回的命令中显示 GPU 配置
|
||||
cmd_str_with_gpu = f"CUDA_VISIBLE_DEVICES={cuda_devices} {cmd_str}"
|
||||
|
||||
# 生成训练日志文件路径(存储在 logs 目录下的日期子目录中)
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
now_str = datetime.now().strftime('%Y%m%d_%H%M%S') # 时间戳用于排序
|
||||
task_id = data.get('task_id', 'unknown')
|
||||
task_name = data.get('name', 'unknown')
|
||||
# 工作目录设为 /app/base(而非 llamafactory 目录)
|
||||
work_dir = '/app/base'
|
||||
# 使用 logs 目录下的日期子目录
|
||||
training_logs_dir = os.path.join('/app/base/logs', today)
|
||||
os.makedirs(training_logs_dir, exist_ok=True)
|
||||
|
||||
# 日志文件路径: logs/{日期}/{task_id}_{task_name}.log
|
||||
log_file = os.path.join(training_logs_dir, f'{task_id}_{task_name}.log')
|
||||
|
||||
train_logger.info(f"[TRAIN] 启动训练进程...")
|
||||
|
||||
# 用于存储实际进程 PID
|
||||
actual_pid = None
|
||||
final_log_path = log_file
|
||||
|
||||
# 使用线程在后台运行训练进程
|
||||
def run_training():
|
||||
nonlocal actual_pid, final_log_path
|
||||
|
||||
# 从 data 中获取 template 和 train_method(与 build_train_command 保持一致)
|
||||
template = data.get('template', 'default')
|
||||
train_method = data.get('train_method', 'lora')
|
||||
|
||||
# 创建输出目录(如果不存在)
|
||||
# 路径格式: /app/base/saves/{train_method}/{output_model_name}
|
||||
output_model_name = data.get('output_model_name', template)
|
||||
output_model_name = f"{train_method}/{output_model_name}"
|
||||
if not output_model_name.startswith('/'):
|
||||
output_model_name = f"/app/base/saves/{output_model_name}"
|
||||
output_dir = output_model_name
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
train_logger.info(f"[TRAIN] 输出目录: {output_dir}")
|
||||
train_logger.info(f"[TRAIN] 完整训练命令: {' '.join(cmd)}")
|
||||
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
# 设置 cwd 为 /app,但通过 LLAMAFACTORY_DIR 环境变量指定 llamafactory 位置
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=work_dir,
|
||||
stdout=f,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env
|
||||
)
|
||||
actual_pid = process.pid
|
||||
train_logger.info(f"[TRAIN] 训练进程 PID: {actual_pid}")
|
||||
train_logger.info(f"[TRAIN] 日志文件: {log_file}")
|
||||
|
||||
# 更新数据库中的 PID(立即更新,方便停止任务)
|
||||
update_fine_tune_status(task_id, 'running', actual_pid)
|
||||
|
||||
# 等待进程完成
|
||||
process.wait()
|
||||
train_logger.info(f"[TRAIN] 训练进程已结束,退出码: {process.returncode}")
|
||||
|
||||
# 更新任务状态
|
||||
final_status = 'completed' if process.returncode == 0 else 'failed'
|
||||
update_fine_tune_status(task_id, final_status, actual_pid)
|
||||
|
||||
# 启动后台线程
|
||||
training_thread = threading.Thread(target=run_training, daemon=True)
|
||||
training_thread.start()
|
||||
|
||||
# 等待 PID 并更新到数据库
|
||||
for i in range(10): # 最多等待1秒
|
||||
time.sleep(0.1)
|
||||
if actual_pid:
|
||||
break
|
||||
|
||||
# 立即返回,不等待进程完成
|
||||
train_logger.info(f"[TRAIN] 训练任务已在后台启动,PID: {actual_pid}")
|
||||
|
||||
train_logger.info(f"[TRAIN] 训练日志输出到: {log_file}")
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'message': f'训练任务已启动 (GPU: {cuda_devices})',
|
||||
'data': {
|
||||
'task_id': task_id,
|
||||
'pid': actual_pid,
|
||||
'gpu_ids': cuda_devices,
|
||||
'command': cmd_str_with_gpu,
|
||||
'log_file': log_file,
|
||||
'training_logs_dir': training_logs_dir
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
train_logger.error(f"[TRAIN] 启动训练任务失败: {e}")
|
||||
train_logger.error(f"[TRAIN] 详细错误: {traceback.format_exc()}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
def build_train_command(data, model_path, dataset_name=None):
|
||||
"""构建 llamafactory-cli train 命令"""
|
||||
# llamafactory-cli 路径(已在系统 PATH 中)
|
||||
cmd = ['llamafactory-cli', 'train']
|
||||
|
||||
# 训练阶段
|
||||
train_type = data.get('train_type', 'SFT')
|
||||
cmd.extend(['--stage', TRAIN_TYPE_MAP.get(train_type, 'sft')])
|
||||
cmd.append('--do_train')
|
||||
|
||||
# 模型路径
|
||||
cmd.extend(['--model_name_or_path', model_path])
|
||||
|
||||
# 数据集 - 使用数据集名称(dataset_manage.name),不是实际文件名
|
||||
if dataset_name:
|
||||
cmd.extend(['--dataset', dataset_name])
|
||||
train_logger.info(f"[TRAIN] 使用数据集名称: {dataset_name}")
|
||||
else:
|
||||
# 回退到原有逻辑
|
||||
dataset_id = data.get('train_dataset_id')
|
||||
try:
|
||||
dataset_id_int = int(dataset_id) if str(dataset_id).isdigit() else None
|
||||
except (ValueError, TypeError):
|
||||
dataset_id_int = None
|
||||
|
||||
if dataset_id_int:
|
||||
dataset_name = get_dataset_name(dataset_id_int)
|
||||
train_logger.info(f"[TRAIN] 从数据库获取的数据集名称: {dataset_name}")
|
||||
else:
|
||||
dataset_name = dataset_id
|
||||
cmd.extend(['--dataset', dataset_name])
|
||||
|
||||
# 数据集目录
|
||||
cmd.extend(['--dataset_dir', './datasets']) # llamafactory 工作目录下的 datasets 目录
|
||||
|
||||
# 模板
|
||||
template = data.get('template')
|
||||
cmd.extend(['--template', template])
|
||||
|
||||
# 训练方法
|
||||
train_method = data.get('train_method', 'lora')
|
||||
cmd.extend(['--finetuning_type', FINETUNING_TYPE_MAP.get(train_method, 'lora')])
|
||||
|
||||
# 输出目录(确保是绝对路径)
|
||||
# 路径格式: /app/base/saves/{train_method}/{output_model_name}
|
||||
output_model_name = data.get('output_model_name', template)
|
||||
output_model_name = f"{train_method}/{output_model_name}"
|
||||
if not output_model_name.startswith('/'):
|
||||
output_model_name = f"/app/base/saves/{output_model_name}"
|
||||
output_dir = output_model_name
|
||||
cmd.extend(['--output_dir', output_dir])
|
||||
|
||||
# 常用参数
|
||||
cmd.extend([
|
||||
'--overwrite_cache',
|
||||
'--overwrite_output_dir',
|
||||
'--cutoff_len', str(data.get('max_length', 512)),
|
||||
'--preprocessing_num_workers', '16',
|
||||
'--per_device_train_batch_size', str(data.get('batch_size', 1)),
|
||||
'--per_device_eval_batch_size', '1',
|
||||
'--gradient_accumulation_steps', str(data.get('gradient_accumulation_steps', 8)),
|
||||
'--lr_scheduler_type', data.get('lr_scheduler_type', 'cosine'),
|
||||
'--logging_steps', '5',
|
||||
'--warmup_steps', str(data.get('warmup_steps', 20)),
|
||||
'--save_steps', str(data.get('save_steps', 100)),
|
||||
'--log_level', 'info', # 设置日志级别为 info
|
||||
'--log_level_replica', 'info', # 设置副本日志级别
|
||||
])
|
||||
|
||||
# 学习率
|
||||
cmd.extend(['--learning_rate', str(data.get('learning_rate', 0.0001))])
|
||||
|
||||
# 训练轮数
|
||||
cmd.extend(['--num_train_epochs', str(data.get('n_epochs', 1.0))])
|
||||
|
||||
# 验证集比例
|
||||
val_ratio = data.get('valid_ratio', 0)
|
||||
if val_ratio > 0:
|
||||
cmd.extend(['--val_size', str(val_ratio / 100)])
|
||||
|
||||
# 最大样本数
|
||||
if data.get('max_samples'):
|
||||
cmd.extend(['--max_samples', str(data.get('max_samples'))])
|
||||
|
||||
# 启用 TensorBoard 日志(用于可视化训练曲线)
|
||||
cmd.append('--plot_loss')
|
||||
|
||||
# 其他选项
|
||||
|
||||
if data.get('fp16'):
|
||||
cmd.append('--fp16')
|
||||
|
||||
if data.get('load_best_model_at_end'):
|
||||
cmd.append('--load_best_model_at_end')
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def get_dataset_name(dataset_id):
|
||||
"""根据数据集 ID 获取数据集名称"""
|
||||
try:
|
||||
from .datasets import get_db_connection
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, name FROM dataset_manage WHERE id = %s", (dataset_id,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
logger.info(f"数据集查询结果: {result}")
|
||||
if result and result.get('name'):
|
||||
return result['name']
|
||||
logger.warning(f"未找到数据集 ID={dataset_id},使用默认值")
|
||||
return 'default'
|
||||
except Exception as e:
|
||||
logger.error(f"查询数据集失败: {e}")
|
||||
return 'default'
|
||||
|
||||
|
||||
def update_fine_tune_status(task_id, status, pid=None):
|
||||
"""更新训练任务状态"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if status == 'running' and pid:
|
||||
cursor.execute(
|
||||
"UPDATE fine_tune SET status = %s, process_id = %s WHERE id = %s",
|
||||
(status, pid, task_id)
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"UPDATE fine_tune SET status = %s WHERE id = %s",
|
||||
(status, task_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
|
||||
|
||||
@fine_tune_bp.route('/stop/<int:task_id>', methods=['POST'])
|
||||
def stop_training(task_id):
|
||||
"""停止训练任务"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
|
||||
# 获取进程 ID
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT process_id FROM fine_tune WHERE id = %s", (task_id,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result and result.get('process_id'):
|
||||
pid = result['process_id']
|
||||
try:
|
||||
# 尝试终止进程
|
||||
import signal
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
logger.info(f"已终止训练进程 PID: {pid}")
|
||||
except ProcessLookupError:
|
||||
logger.warning(f"进程 {pid} 不存在")
|
||||
except PermissionError:
|
||||
logger.error(f"没有权限终止进程 {pid}")
|
||||
|
||||
# 更新状态
|
||||
update_fine_tune_status(task_id, 'stopped')
|
||||
|
||||
return jsonify({'code': 0, 'message': '训练任务已停止'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"停止训练任务失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
@fine_tune_bp.route('/<int:task_id>', methods=['DELETE'])
|
||||
def delete_training_task(task_id):
|
||||
"""删除训练任务及对应的日志文件"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
|
||||
# 获取任务信息(用于删除日志文件)
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
# 尝试获取所有字段,如果tensorboard_log_dir不存在会报错
|
||||
try:
|
||||
cursor.execute("SELECT name, process_id, tensorboard_log_dir FROM fine_tune WHERE id = %s", (task_id,))
|
||||
except:
|
||||
# 如果列不存在,只获取基本字段
|
||||
cursor.execute("SELECT name, process_id FROM fine_tune WHERE id = %s", (task_id,))
|
||||
task_result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not task_result:
|
||||
return jsonify({'code': 1, 'message': '任务不存在'})
|
||||
|
||||
task_name = task_result.get('name', 'unknown')
|
||||
tensorboard_log_dir = task_result.get('tensorboard_log_dir', '') if 'tensorboard_log_dir' in task_result else ''
|
||||
|
||||
# 删除日志文件 (logs/{日期}/{task_id}_{task_name}.log)
|
||||
try:
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# 可能的日志文件路径
|
||||
log_paths = [
|
||||
f'/app/base/logs/{today}/{task_id}_{task_name}.log',
|
||||
f'/app/base/logs/{task_id}_{task_name}.log',
|
||||
]
|
||||
|
||||
for log_path in log_paths:
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
logger.info(f"已删除日志文件: {log_path}")
|
||||
except Exception as log_err:
|
||||
logger.warning(f"删除日志文件失败: {log_err}")
|
||||
|
||||
# 删除 TensorBoard 进程(如果存在)
|
||||
global tensorboard_process
|
||||
if tensorboard_process and tensorboard_process.poll() is None:
|
||||
try:
|
||||
import signal
|
||||
os.killpg(os.getpgid(tensorboard_process.pid), signal.SIGTERM)
|
||||
tensorboard_process = None
|
||||
logger.info(f"已停止 TensorBoard 进程")
|
||||
except Exception as tb_err:
|
||||
logger.warning(f"停止 TensorBoard 失败: {tb_err}")
|
||||
|
||||
# 删除数据库中的任务记录
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM fine_tune WHERE id = %s", (task_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"已删除训练任务 {task_id}: {task_name}")
|
||||
return jsonify({'code': 0, 'message': '删除成功'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除训练任务失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
@fine_tune_bp.route('/status/<int:task_id>', methods=['GET'])
|
||||
def get_training_status(task_id):
|
||||
"""获取训练任务状态"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, name, status, progress, process_id FROM fine_tune WHERE id = %s",
|
||||
(task_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result:
|
||||
# 检查 PID 是否仍在运行
|
||||
actual_status = result['status']
|
||||
pid = result.get('process_id')
|
||||
if pid and actual_status == 'running':
|
||||
try:
|
||||
# 检查进程是否存在
|
||||
os.kill(pid, 0)
|
||||
# 进程仍在运行
|
||||
actual_status = 'running'
|
||||
except (OSError, ProcessLookupError):
|
||||
# 进程已结束,尝试更新状态
|
||||
actual_status = 'completed' # 假设完成(实际可能失败)
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'task_id': result['id'],
|
||||
'name': result['name'],
|
||||
'status': actual_status,
|
||||
'progress': result['progress'],
|
||||
'pid': result.get('process_id')
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'code': 1, 'message': '任务不存在'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务状态失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
@fine_tune_bp.route('/check-pid/<int:pid>', methods=['GET'])
|
||||
def check_pid_status(pid):
|
||||
"""检查 PID 是否仍在运行"""
|
||||
try:
|
||||
if pid <= 0:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': False,
|
||||
'message': '无效的 PID'
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
# 发送信号 0 来检查进程是否存在(不会实际终止进程)
|
||||
os.kill(pid, 0)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': True,
|
||||
'message': '进程仍在运行'
|
||||
}
|
||||
})
|
||||
except (OSError, ProcessLookupError):
|
||||
# 进程不存在
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': False,
|
||||
'message': '进程已结束'
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"检查 PID 状态失败: {e}")
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': False,
|
||||
'message': f'检查失败: {str(e)}'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@fine_tune_bp.route('/log/<int:task_id>', methods=['GET'])
|
||||
def get_training_log(task_id):
|
||||
"""获取训练任务日志内容(支持实时读取)"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
|
||||
# 获取任务信息和进程ID
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name, process_id, status FROM fine_tune WHERE id = %s",
|
||||
(task_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not result:
|
||||
return jsonify({'code': 1, 'message': '任务不存在'})
|
||||
|
||||
process_id = result.get('process_id')
|
||||
task_name = result['name']
|
||||
status = result['status']
|
||||
|
||||
if not process_id:
|
||||
return jsonify({'code': 1, 'message': '任务尚未启动'})
|
||||
|
||||
# 构建日志文件路径 - 新格式: logs/{日期}/{task_id}_{task_name}.log
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
training_logs_dir = os.path.join('/app/base/logs', today)
|
||||
|
||||
# 查找日志文件 (新格式: {task_id}_{task_name}.log)
|
||||
log_file = os.path.join(training_logs_dir, f'{task_id}_{task_name}.log')
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
# 如果没找到,返回空日志
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'content': '',
|
||||
'status': status,
|
||||
'message': '日志文件尚未创建'
|
||||
}
|
||||
})
|
||||
|
||||
# 读取日志文件内容
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'content': content,
|
||||
'status': status,
|
||||
'log_file': log_file
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'content': '',
|
||||
'status': status,
|
||||
'message': f'读取日志失败: {str(e)}'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取训练日志失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
import re
|
||||
|
||||
|
||||
@fine_tune_bp.route('/progress/<int:task_id>', methods=['GET'])
|
||||
def get_training_progress(task_id):
|
||||
"""获取训练任务进度(从日志中解析 llamafactory 的进度信息)"""
|
||||
try:
|
||||
from .model_manage import get_db_connection
|
||||
|
||||
# 获取任务信息和进程ID
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name, process_id, status FROM fine_tune WHERE id = %s",
|
||||
(task_id,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not result:
|
||||
return jsonify({'code': 1, 'message': '任务不存在'})
|
||||
|
||||
process_id = result.get('process_id')
|
||||
task_name = result['name']
|
||||
status = result['status']
|
||||
|
||||
if not process_id:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'progress': 0,
|
||||
'step': '',
|
||||
'eta': '',
|
||||
'speed': '',
|
||||
'status': status,
|
||||
'message': '任务尚未启动'
|
||||
}
|
||||
})
|
||||
|
||||
# 构建日志文件路径 - 新格式: logs/{日期}/{task_id}_{task_name}.log
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
training_logs_dir = os.path.join('/app/base/logs', today)
|
||||
|
||||
# 查找日志文件 (新格式: {task_id}_{task_name}.log)
|
||||
log_file = os.path.join(training_logs_dir, f'{task_id}_{task_name}.log')
|
||||
|
||||
# TensorBoard 日志目录(使用默认值)
|
||||
tensorboard_log_dir = '/app/base/saves'
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'step': '',
|
||||
'elapsed': '',
|
||||
'eta': '',
|
||||
'speed': '',
|
||||
'status': status,
|
||||
'message': '日志文件尚未创建',
|
||||
'tensorboard_url': ''
|
||||
}
|
||||
})
|
||||
|
||||
# 读取日志文件最后部分,解析进度信息
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# 读取最后 10KB 内容
|
||||
f.seek(0, 2) # 跳到文件末尾
|
||||
file_size = f.tell()
|
||||
read_size = min(10240, file_size)
|
||||
f.seek(max(0, file_size - read_size))
|
||||
content = f.read()
|
||||
|
||||
# 匹配 llamafactory 进度格式: 52%|█████▏ | 17/33 [02:16<02:08, 8.04s/it]
|
||||
progress_pattern = r'\s*(\d+)%\|[█░▌▋▒█▏▎▏▐▀■□▪▫‣▶➜➡→]+\s*\|\s*(\d+)/(\d+)\s+\[(\d+):(\d+)<(\d+):(\d+),\s*([\d.]+)s/it\]'
|
||||
match = re.search(progress_pattern, content)
|
||||
|
||||
step_info = ''
|
||||
elapsed = ''
|
||||
eta = ''
|
||||
speed = ''
|
||||
message = '等待训练开始'
|
||||
|
||||
if match:
|
||||
current_step = int(match.group(2))
|
||||
total_steps = int(match.group(3))
|
||||
elapsed_min = int(match.group(4))
|
||||
elapsed_sec = int(match.group(5))
|
||||
eta_min = int(match.group(6))
|
||||
eta_sec = int(match.group(7))
|
||||
speed_val = float(match.group(8))
|
||||
|
||||
step_info = f'{current_step}/{total_steps}'
|
||||
elapsed = f'{elapsed_min:02d}:{elapsed_sec:02d}'
|
||||
eta = f'{eta_min:02d}:{eta_sec:02d}'
|
||||
speed = f'{speed_val}s/it'
|
||||
message = '训练进行中'
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'step': step_info,
|
||||
'elapsed': elapsed,
|
||||
'eta': eta,
|
||||
'speed': speed,
|
||||
'status': status,
|
||||
'message': message,
|
||||
'tensorboard_log_dir': tensorboard_log_dir,
|
||||
'tensorboard_url': ''
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'step': '',
|
||||
'elapsed': '',
|
||||
'eta': '',
|
||||
'speed': '',
|
||||
'status': status,
|
||||
'message': f'读取进度失败: {str(e)}',
|
||||
'tensorboard_log_dir': tensorboard_log_dir,
|
||||
'tensorboard_url': ''
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取训练进度失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""获取数据库连接"""
|
||||
import pymysql
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
|
||||
db_config = CONFIG['database']
|
||||
return pymysql.connect(
|
||||
host=db_config['host'],
|
||||
port=db_config['port'],
|
||||
user=db_config['username'],
|
||||
password=db_config['password'],
|
||||
database=db_config['name'],
|
||||
charset=db_config.get('charset', 'utf8mb4'),
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
|
||||
@fine_tune_bp.route('/check-name', methods=['GET'])
|
||||
def check_task_name():
|
||||
"""检查任务名称是否重复"""
|
||||
try:
|
||||
name = request.args.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'code': 1, 'message': '任务名称不能为空'})
|
||||
|
||||
# 验证任务名称格式:只能包含英文、数字、下划线
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
||||
return jsonify({'code': 1, 'message': '任务名称只能包含英文、数字和下划线'})
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM fine_tune WHERE name = %s", (name,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': True,
|
||||
'message': '任务名称已存在'
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'exists': False,
|
||||
'message': '任务名称可用'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查任务名称失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
# TensorBoard 服务进程
|
||||
tensorboard_process = None
|
||||
|
||||
|
||||
@fine_tune_bp.route('/tensorboard/start', methods=['POST'])
|
||||
def start_tensorboard():
|
||||
"""启动 TensorBoard 服务"""
|
||||
global tensorboard_process
|
||||
try:
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# 检查是否已有进程在运行
|
||||
if tensorboard_process and tensorboard_process.poll() is None:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'url': 'http://10.10.10.177:6006',
|
||||
'status': 'already_running',
|
||||
'message': 'TensorBoard 服务已运行'
|
||||
}
|
||||
})
|
||||
|
||||
# 获取日志目录
|
||||
log_dir = '/app/base/saves'
|
||||
|
||||
# 检查目录是否存在
|
||||
if not os.path.exists(log_dir):
|
||||
return jsonify({'code': 1, 'message': f'日志目录不存在: {log_dir}'})
|
||||
|
||||
# 启动 TensorBoard(后台运行)
|
||||
cmd = ['tensorboard', '--logdir', log_dir, '--port', '6006', '--bind_all']
|
||||
tensorboard_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid
|
||||
)
|
||||
|
||||
logger.info(f"TensorBoard 服务已启动: {cmd}")
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'url': 'http://10.10.10.177:6006',
|
||||
'status': 'started',
|
||||
'message': 'TensorBoard 服务已启动'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动 TensorBoard 失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
@fine_tune_bp.route('/tensorboard/stop', methods=['POST'])
|
||||
def stop_tensorboard():
|
||||
"""停止 TensorBoard 服务"""
|
||||
global tensorboard_process
|
||||
try:
|
||||
import subprocess
|
||||
import signal
|
||||
|
||||
if tensorboard_process and tensorboard_process.poll() is None:
|
||||
# 使用 os.killpg 终止进程组
|
||||
try:
|
||||
os.killpg(os.getpgid(tensorboard_process.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
tensorboard_process = None
|
||||
logger.info("TensorBoard 服务已停止")
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'status': 'stopped',
|
||||
'message': 'TensorBoard 服务已停止'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"停止 TensorBoard 失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
387
src/api/logs.py
Normal file
387
src/api/logs.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
日志管理 API 路由
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
# 获取项目根目录
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 加载配置
|
||||
import yaml
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
|
||||
# 日志目录 - 使用与 main.py 相同的配置
|
||||
def get_log_base_dir():
|
||||
"""获取日志基础目录"""
|
||||
# 1. 检查环境变量
|
||||
if 'LOG_BASE_DIR' in os.environ:
|
||||
return os.environ['LOG_BASE_DIR']
|
||||
|
||||
# 2. 检查是否在容器环境中
|
||||
mount_base = os.environ.get('MOUNT_BASE', '/app/base')
|
||||
if os.path.exists(mount_base):
|
||||
return os.path.join(mount_base, 'logs')
|
||||
|
||||
# 3. 使用本地项目路径
|
||||
return os.path.join(PROJECT_ROOT, 'logs')
|
||||
|
||||
LOG_BASE_DIR = get_log_base_dir()
|
||||
|
||||
# 创建蓝图
|
||||
logs_bp = Blueprint('logs', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
def get_logs_logger():
|
||||
"""从 main.py 获取日志记录器"""
|
||||
return logging.getLogger('logs_api')
|
||||
|
||||
|
||||
def get_request_logger():
|
||||
"""获取请求日志记录器"""
|
||||
return logging.getLogger('request')
|
||||
|
||||
|
||||
def format_file_size(size_bytes):
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 1024:
|
||||
return f'{size_bytes} B'
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f'{size_bytes / 1024:.1f} KB'
|
||||
else:
|
||||
return f'{size_bytes / (1024 * 1024):.1f} MB'
|
||||
|
||||
|
||||
@logs_bp.route('/web-log', methods=['POST'])
|
||||
def receive_web_log():
|
||||
"""接收前端页面发送的日志"""
|
||||
data = request.json
|
||||
level = data.get('level', 'info')
|
||||
message = data.get('message', '')
|
||||
page = data.get('page', 'unknown')
|
||||
timestamp = data.get('timestamp', '')
|
||||
|
||||
log_message = f'[WEB-{page}] {message}'
|
||||
|
||||
logger = get_logs_logger()
|
||||
if level == 'error':
|
||||
logger.error(log_message)
|
||||
elif level == 'warning':
|
||||
logger.warning(log_message)
|
||||
elif level == 'debug':
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
logger.info(log_message)
|
||||
|
||||
return jsonify({'code': 0, 'message': '日志接收成功'})
|
||||
|
||||
|
||||
@logs_bp.route('/log-files', methods=['GET'])
|
||||
def get_log_files():
|
||||
"""获取指定日期的日志文件列表"""
|
||||
date = request.args.get('date')
|
||||
if not date:
|
||||
return jsonify({'code': 1, 'message': '缺少日期参数'})
|
||||
|
||||
# 验证日期格式
|
||||
try:
|
||||
datetime.strptime(date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return jsonify({'code': 1, 'message': '日期格式错误,应为 YYYY-MM-DD'})
|
||||
|
||||
log_dir = os.path.join(LOG_BASE_DIR, date)
|
||||
|
||||
if not os.path.exists(log_dir):
|
||||
return jsonify({'code': 0, 'data': []})
|
||||
|
||||
log_files = []
|
||||
# 定义日志文件的优先级顺序
|
||||
file_names = ['all.log', 'api.log', 'error.log', 'request.log', 'train.log']
|
||||
|
||||
for file_name in file_names:
|
||||
file_path = os.path.join(log_dir, file_name)
|
||||
if os.path.exists(file_path):
|
||||
size = os.path.getsize(file_path)
|
||||
log_files.append({
|
||||
'name': file_name.replace('.log', ''),
|
||||
'file': f'{date}/{file_name}',
|
||||
'size': format_file_size(size)
|
||||
})
|
||||
|
||||
return jsonify({'code': 0, 'data': log_files})
|
||||
|
||||
|
||||
@logs_bp.route('/log-content', methods=['GET'])
|
||||
def get_log_content():
|
||||
"""获取日志文件内容"""
|
||||
file_path = request.args.get('file')
|
||||
if not file_path:
|
||||
return jsonify({'code': 1, 'message': '缺少文件参数'})
|
||||
|
||||
# 防止目录遍历攻击
|
||||
file_path = file_path.replace('..', '').replace('//', '/')
|
||||
full_path = os.path.join(LOG_BASE_DIR, file_path)
|
||||
|
||||
# 验证文件路径是否在日志目录下
|
||||
if not full_path.startswith(LOG_BASE_DIR):
|
||||
return jsonify({'code': 1, 'message': '无效的文件路径'})
|
||||
|
||||
if not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return jsonify({'code': 1, 'message': '日志文件不存在'})
|
||||
|
||||
try:
|
||||
size = os.path.getsize(full_path)
|
||||
# 限制读取大小,最大 5MB
|
||||
max_size = 5 * 1024 * 1024
|
||||
if size > max_size:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
f.seek(size - max_size)
|
||||
content = '... (日志文件较大,已显示最后 5MB 内容) ...\n\n' + f.read()
|
||||
else:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'file': os.path.basename(file_path),
|
||||
'path': file_path,
|
||||
'size': format_file_size(size),
|
||||
'content': content
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 1, 'message': f'读取日志文件失败: {str(e)}'})
|
||||
|
||||
|
||||
# ============ 训练日志相关 API ============
|
||||
|
||||
# 训练日志保存在 logs/{日期} 目录下
|
||||
TRAINING_LOGS_BASE_DIR = '/app/base/logs'
|
||||
# 本地开发时的备用路径(Windows)
|
||||
LOCAL_TRAINING_LOGS_BASE_DIR = os.path.join(PROJECT_ROOT, 'logs')
|
||||
|
||||
|
||||
@logs_bp.route('/training-log-files', methods=['GET'])
|
||||
def get_training_log_files():
|
||||
"""获取训练日志文件列表 - 从 logs/{日期} 目录下的 .log 文件"""
|
||||
try:
|
||||
logs_logger = get_logs_logger()
|
||||
|
||||
# 确定基础目录
|
||||
logs_base_dir = TRAINING_LOGS_BASE_DIR
|
||||
if not os.path.exists(logs_base_dir):
|
||||
logs_base_dir = LOCAL_TRAINING_LOGS_BASE_DIR
|
||||
|
||||
logs_logger.info(f"[DEBUG] logs_base_dir: {logs_base_dir}, exists: {os.path.exists(logs_base_dir)}")
|
||||
|
||||
if not os.path.exists(logs_base_dir):
|
||||
return jsonify({'code': 0, 'data': []})
|
||||
|
||||
# 遍历所有日期目录,收集训练日志文件
|
||||
log_files = []
|
||||
date_dirs = []
|
||||
|
||||
try:
|
||||
# 获取所有日期目录(格式: YYYY-MM-DD)
|
||||
for item in os.listdir(logs_base_dir):
|
||||
item_path = os.path.join(logs_base_dir, item)
|
||||
if os.path.isdir(item_path):
|
||||
# 验证是否为日期目录
|
||||
try:
|
||||
datetime.strptime(item, '%Y-%m-%d')
|
||||
date_dirs.append(item)
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception as list_err:
|
||||
logs_logger.error(f"[DEBUG] Failed to list base directory: {list_err}")
|
||||
return jsonify({'code': 0, 'data': []})
|
||||
|
||||
# 按日期排序(最新的在前面)
|
||||
date_dirs.sort(reverse=True)
|
||||
|
||||
logs_logger.info(f"[DEBUG] Date directories: {date_dirs}")
|
||||
|
||||
# 遍历每个日期目录,查找 .log 文件
|
||||
for date_dir in date_dirs:
|
||||
date_full_path = os.path.join(logs_base_dir, date_dir)
|
||||
try:
|
||||
files = os.listdir(date_full_path)
|
||||
except Exception as list_err:
|
||||
logs_logger.warning(f"[DEBUG] Failed to list {date_full_path}: {list_err}")
|
||||
continue
|
||||
|
||||
for file_name in files:
|
||||
if not file_name.endswith('.log'):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(date_full_path, file_name)
|
||||
try:
|
||||
size = os.path.getsize(file_path)
|
||||
except Exception as size_err:
|
||||
logs_logger.warning(f"[DEBUG] Failed to get size of {file_path}: {size_err}")
|
||||
continue
|
||||
|
||||
# 文件名格式: {task_id}_{task_name}.log
|
||||
# 例如: 889_testing.log
|
||||
parts = file_name.replace('.log', '').split('_', 1)
|
||||
if len(parts) >= 2:
|
||||
task_id = parts[0]
|
||||
task_name = parts[1]
|
||||
try:
|
||||
dt = datetime.strptime(date_dir, '%Y-%m-%d')
|
||||
# 使用日期目录的时间作为排序键
|
||||
sort_key = dt.timestamp()
|
||||
display_date = date_dir
|
||||
except:
|
||||
sort_key = 0
|
||||
display_date = date_dir
|
||||
else:
|
||||
task_id = 'unknown'
|
||||
task_name = file_name.replace('.log', '')
|
||||
sort_key = 0
|
||||
display_date = date_dir
|
||||
|
||||
# 构建相对路径 (日期/文件名)
|
||||
relative_path = f"{date_dir}/{file_name}"
|
||||
|
||||
log_files.append({
|
||||
'name': task_name,
|
||||
'file': relative_path,
|
||||
'task_id': task_id,
|
||||
'date': display_date,
|
||||
'size': format_file_size(size),
|
||||
'sort_key': sort_key
|
||||
})
|
||||
|
||||
# 按时间戳排序(最新的在前面)
|
||||
log_files.sort(key=lambda x: x['sort_key'] if x['sort_key'] else 0, reverse=True)
|
||||
|
||||
logs_logger.info(f"[DEBUG] Found {len(log_files)} training log files")
|
||||
|
||||
return jsonify({'code': 0, 'data': log_files})
|
||||
except Exception as e:
|
||||
get_logs_logger().error(f"[DEBUG] 获取训练日志列表失败: {e}")
|
||||
return jsonify({'code': 1, 'message': f'获取训练日志列表失败: {str(e)}'})
|
||||
|
||||
|
||||
@logs_bp.route('/training-log-content', methods=['GET'])
|
||||
def get_training_log_content():
|
||||
"""获取训练日志文件内容 - 从 logs/{日期}/ 目录"""
|
||||
file_name = request.args.get('file')
|
||||
if not file_name:
|
||||
return jsonify({'code': 1, 'message': '缺少文件参数'})
|
||||
|
||||
logs_logger = get_logs_logger()
|
||||
logs_logger.info(f"[DEBUG] ============ get_training_log_content ============")
|
||||
logs_logger.info(f"[DEBUG] file: {file_name}")
|
||||
|
||||
# 防止目录遍历攻击
|
||||
file_name = file_name.replace('..', '').replace('//', '/')
|
||||
|
||||
# file 格式: 日期/文件名,例如: 2026-01-28/889_testing.log
|
||||
# 解析日期和文件名
|
||||
parts = file_name.split('/')
|
||||
if len(parts) < 2:
|
||||
return jsonify({'code': 1, 'message': '无效的文件路径格式'})
|
||||
|
||||
date_dir = parts[0]
|
||||
log_file_name = '/'.join(parts[1:])
|
||||
|
||||
# 验证日期格式
|
||||
try:
|
||||
datetime.strptime(date_dir, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return jsonify({'code': 1, 'message': '无效的日期格式'})
|
||||
|
||||
# 确定基础目录
|
||||
container_base_dir = TRAINING_LOGS_BASE_DIR # /app/base/logs
|
||||
local_base_dir = LOCAL_TRAINING_LOGS_BASE_DIR # 项目目录下的 logs
|
||||
|
||||
container_full_path = os.path.join(container_base_dir, date_dir, log_file_name)
|
||||
local_full_path = os.path.join(local_base_dir, date_dir, log_file_name)
|
||||
|
||||
logs_logger.info(f"[DEBUG] container_base_dir: {container_base_dir}, exists: {os.path.exists(container_base_dir)}")
|
||||
logs_logger.info(f"[DEBUG] local_base_dir: {local_base_dir}, exists: {os.path.exists(local_base_dir)}")
|
||||
logs_logger.info(f"[DEBUG] container_full_path: {container_full_path}, exists: {os.path.exists(container_full_path)}")
|
||||
logs_logger.info(f"[DEBUG] local_full_path: {local_full_path}, exists: {os.path.exists(local_full_path)}")
|
||||
|
||||
# 选择最终路径
|
||||
full_path = None
|
||||
if os.path.exists(container_full_path):
|
||||
full_path = container_full_path
|
||||
logs_logger.info(f"[DEBUG] Using container path")
|
||||
elif os.path.exists(local_full_path):
|
||||
full_path = local_full_path
|
||||
logs_logger.info(f"[DEBUG] Using local path")
|
||||
else:
|
||||
logs_logger.error(f"[DEBUG] File not found: {file_name}")
|
||||
return jsonify({'code': 1, 'message': f'日志文件不存在: {file_name}'})
|
||||
|
||||
logs_logger.info(f"[DEBUG] Final full_path: {full_path}")
|
||||
|
||||
# 尝试直接读取文件
|
||||
try:
|
||||
max_size = 10 * 1024 * 1024
|
||||
content = ''
|
||||
read_success = False
|
||||
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(0)
|
||||
|
||||
if size > max_size:
|
||||
f.seek(size - max_size)
|
||||
content = '... (日志文件较大,已显示最后 10MB 内容) ...\n\n' + f.read().decode('utf-8', errors='ignore')
|
||||
else:
|
||||
content = f.read().decode('utf-8', errors='ignore')
|
||||
read_success = True
|
||||
except (PermissionError, OSError) as pe:
|
||||
logs_logger.warning(f"[DEBUG] 直接读取失败: {pe},尝试共享模式读取")
|
||||
import mmap
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
try:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
if size > max_size:
|
||||
content = '... (日志文件较大,已显示最后 10MB 内容) ...\n\n' + \
|
||||
mm[-max_size:].decode('utf-8', errors='ignore')
|
||||
else:
|
||||
content = mm[:].decode('utf-8', errors='ignore')
|
||||
read_success = True
|
||||
finally:
|
||||
mm.close()
|
||||
except Exception as e2:
|
||||
logs_logger.error(f"[DEBUG] 共享模式读取失败: {e2}")
|
||||
return jsonify({
|
||||
'code': 2,
|
||||
'message': f'日志文件正在被训练进程占用,训练结束后可查看完整内容',
|
||||
'data': {
|
||||
'file': log_file_name,
|
||||
'size': format_file_size(0),
|
||||
'content': ''
|
||||
}
|
||||
})
|
||||
|
||||
if read_success:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'file': log_file_name,
|
||||
'size': format_file_size(size),
|
||||
'content': content
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logs_logger.error(f"[DEBUG] 读取日志文件失败: {e}")
|
||||
return jsonify({'code': 1, 'message': f'读取日志文件失败: {str(e)}'})
|
||||
1075
src/api/model_chat.py
Normal file
1075
src/api/model_chat.py
Normal file
File diff suppressed because it is too large
Load Diff
722
src/api/model_manage.py
Normal file
722
src/api/model_manage.py
Normal file
@@ -0,0 +1,722 @@
|
||||
"""
|
||||
模型管理 API 路由
|
||||
"""
|
||||
import os
|
||||
import pymysql
|
||||
import yaml
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
# 获取模块 logger(继承 main.py 的日志配置)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 获取项目根目录 - 优先使用环境变量,否则从文件路径计算
|
||||
MOUNT_BASE = os.environ.get('MOUNT_BASE', '/app/base')
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# 如果 PROJECT_ROOT 是 /app 或 /app/src/llamafactory,则使用挂载路径
|
||||
if PROJECT_ROOT in ('/app', '/app/src/llamafactory'):
|
||||
PROJECT_ROOT = MOUNT_BASE
|
||||
|
||||
# 创建蓝图
|
||||
model_manage_bp = Blueprint('model_manage', __name__, url_prefix='/api/model-manage')
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""获取数据库连接"""
|
||||
CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml')
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
CONFIG = yaml.safe_load(f)
|
||||
db_config = CONFIG['database']
|
||||
return pymysql.connect(
|
||||
host=db_config['host'],
|
||||
port=db_config['port'],
|
||||
user=db_config['username'],
|
||||
password=db_config['password'],
|
||||
database=db_config['name'],
|
||||
charset=db_config.get('charset', 'utf8mb4'),
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
|
||||
def generic_get_all(table_name, order_by='create_time DESC'):
|
||||
"""通用查询所有"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT * FROM {table_name} ORDER BY {order_by}")
|
||||
result = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
def get_model_path_by_name(model_name):
|
||||
"""根据模型名称查询模型路径(用于获取基座模型路径)"""
|
||||
logger.info(f"[DEBUG get_model_path_by_name] 查询模型: {model_name}")
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 优先从训练任务表查询基座模型
|
||||
logger.info(f"[DEBUG get_model_path_by_name] 尝试从fine_tune表查询...")
|
||||
cursor.execute("""
|
||||
SELECT base_model, output_model_name FROM fine_tune
|
||||
WHERE output_model_name LIKE %s OR output_model_name LIKE %s
|
||||
LIMIT 1
|
||||
""", (f'%/{model_name}', f'%{model_name}%'))
|
||||
ft_result = cursor.fetchone()
|
||||
logger.info(f"[DEBUG get_model_path_by_name] fine_tune查询结果: {ft_result}")
|
||||
|
||||
if ft_result and ft_result.get('base_model'):
|
||||
base_model_val = ft_result['base_model']
|
||||
logger.info(f"[DEBUG get_model_path_by_name] base_model_val: {base_model_val}")
|
||||
# 如果是数字ID,查询模型管理表获取路径
|
||||
if str(base_model_val).isdigit():
|
||||
cursor.execute("SELECT path FROM model_manage WHERE id = %s LIMIT 1", (base_model_val,))
|
||||
model_result = cursor.fetchone()
|
||||
logger.info(f"[DEBUG get_model_path_by_name] model_manage查询结果(数字ID): {model_result}")
|
||||
if model_result:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return model_result.get('path')
|
||||
else:
|
||||
# 直接是路径
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return base_model_val
|
||||
|
||||
# 如果训练任务表没找到,尝试从模型管理表按名称查询
|
||||
logger.info(f"[DEBUG get_model_path_by_name] 尝试从model_manage表查询...")
|
||||
cursor.execute("SELECT path FROM model_manage WHERE name = %s LIMIT 1", (model_name,))
|
||||
result = cursor.fetchone()
|
||||
logger.info(f"[DEBUG get_model_path_by_name] model_manage查询结果: {result}")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
if result:
|
||||
return result.get('path')
|
||||
logger.info(f"[DEBUG get_model_path_by_name] 未找到任何匹配,返回None")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] 查询模型路径失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def generic_create(table_name, data):
|
||||
"""通用创建"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
columns = ', '.join(data.keys())
|
||||
placeholders = ', '.join(['%s'] * len(data))
|
||||
sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
|
||||
cursor.execute(sql, list(data.values()))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def generic_update(table_name, id_val, data):
|
||||
"""通用更新"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
set_clause = ', '.join([f"{k} = %s" for k in data.keys()])
|
||||
sql = f"UPDATE {table_name} SET {set_clause} WHERE id = %s"
|
||||
values = list(data.values()) + [id_val]
|
||||
cursor.execute(sql, values)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def generic_delete(table_name, id_val):
|
||||
"""通用删除"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"DELETE FROM {table_name} WHERE id = %s", (id_val,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def generic_get_by_id(table_name, id_val):
|
||||
"""通用按ID查询"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT * FROM {table_name} WHERE id = %s", (id_val,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# ============ 模型管理 CRUD ============
|
||||
|
||||
@model_manage_bp.route('', methods=['GET'])
|
||||
def get_model_manage():
|
||||
"""获取所有模型"""
|
||||
return jsonify({'code': 0, 'data': generic_get_all('model_manage')})
|
||||
|
||||
|
||||
@model_manage_bp.route('/<int:id>', methods=['GET'])
|
||||
def get_model_manage_by_id(id):
|
||||
"""获取单个模型"""
|
||||
model = generic_get_by_id('model_manage', id)
|
||||
if model:
|
||||
return jsonify({'code': 0, 'data': model})
|
||||
return jsonify({'code': 1, 'message': '模型不存在'})
|
||||
|
||||
|
||||
@model_manage_bp.route('/name/<model_name>', methods=['GET'])
|
||||
def get_model_manage_by_name(model_name):
|
||||
"""根据名称获取模型"""
|
||||
logger.info(f"[DEBUG] 按名称查询模型: {model_name}")
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM model_manage WHERE name = %s LIMIT 1", (model_name,))
|
||||
model = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if model:
|
||||
return jsonify({'code': 0, 'data': model})
|
||||
return jsonify({'code': 1, 'message': '模型不存在'})
|
||||
|
||||
|
||||
@model_manage_bp.route('', methods=['POST'])
|
||||
def create_model_manage():
|
||||
"""创建模型"""
|
||||
data = request.json
|
||||
# 构建插入数据
|
||||
insert_data = {
|
||||
'name': data.get('name'),
|
||||
'type': data.get('type'),
|
||||
'model_source': data.get('model_source', 'local'),
|
||||
'description': data.get('description'),
|
||||
'purpose': data.get('purpose', 'inference') # 默认推理用途
|
||||
}
|
||||
|
||||
if data.get('model_source') == 'local':
|
||||
insert_data['path'] = data.get('path', '')
|
||||
else:
|
||||
insert_data['api_url'] = data.get('api_url', '')
|
||||
insert_data['api_key'] = data.get('api_key', '')
|
||||
insert_data['model_name'] = data.get('model_name', '')
|
||||
|
||||
new_id = generic_create('model_manage', insert_data)
|
||||
return jsonify({'code': 0, 'message': '创建成功', 'id': new_id})
|
||||
|
||||
|
||||
@model_manage_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update_model_manage(id):
|
||||
"""更新模型"""
|
||||
data = request.json
|
||||
generic_update('model_manage', id, data)
|
||||
return jsonify({'code': 0, 'message': '更新成功'})
|
||||
|
||||
|
||||
@model_manage_bp.route('/<int:id>/purpose', methods=['PUT'])
|
||||
def update_model_purpose(id):
|
||||
"""更新模型用途"""
|
||||
data = request.json
|
||||
purpose = data.get('purpose')
|
||||
if purpose not in ['training', 'inference', 'evaluation']:
|
||||
return jsonify({'code': 1, 'message': '无效的用途类型'})
|
||||
generic_update('model_manage', id, {'purpose': purpose})
|
||||
return jsonify({'code': 0, 'message': '更新成功'})
|
||||
|
||||
|
||||
@model_manage_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete_model_manage(id):
|
||||
"""删除模型"""
|
||||
generic_delete('model_manage', id)
|
||||
return jsonify({'code': 0, 'message': '删除成功'})
|
||||
|
||||
|
||||
# ============ 本地模型列表接口 ============
|
||||
|
||||
@model_manage_bp.route('/local-models', methods=['GET'])
|
||||
def get_local_models():
|
||||
"""获取本地模型列表(从YG_FT_Base/local_models目录)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# 使用 YG_FT_Base/local_models 目录
|
||||
base_path = os.path.join(PROJECT_ROOT, 'local_models')
|
||||
|
||||
models = []
|
||||
if os.path.exists(base_path):
|
||||
for item in os.listdir(base_path):
|
||||
item_path = os.path.join(base_path, item)
|
||||
if os.path.isdir(item_path):
|
||||
models.append({
|
||||
'name': item,
|
||||
'path': item_path
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'models': models,
|
||||
'base_path': base_path
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取本地模型列表失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
# ============ 已训练模型列表接口 ============
|
||||
|
||||
@model_manage_bp.route('/trained-models', methods=['GET'])
|
||||
def get_trained_models():
|
||||
"""获取已训练模型列表(从/app/base/saves目录)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# 多个可能的路径
|
||||
potential_paths = [
|
||||
'/app/base/saves', # 容器内路径
|
||||
os.path.join(PROJECT_ROOT, 'saves'), # 本地开发路径
|
||||
os.path.join(os.path.dirname(os.path.dirname(PROJECT_ROOT)), 'YG_FT_Base', 'saves'), # 上级目录
|
||||
]
|
||||
|
||||
base_path = None
|
||||
for path in potential_paths:
|
||||
logger.info(f"[DEBUG] 检查路径: {path}, exists: {os.path.exists(path)}")
|
||||
if os.path.exists(path):
|
||||
base_path = path
|
||||
break
|
||||
|
||||
logger.info(f"[DEBUG] 最终使用的路径: {base_path}")
|
||||
|
||||
models = []
|
||||
if base_path and os.path.exists(base_path):
|
||||
logger.info(f"[DEBUG] 遍历目录: {base_path}")
|
||||
try:
|
||||
# 路径结构: /app/base/saves/{train_method}/{model_name}/
|
||||
# train_method: lora, full, qlora, dpo, cpt 等
|
||||
# 同时兼容老结构: /app/base/saves/{model_name}/
|
||||
|
||||
train_methods = ['lora', 'full', 'qlora', 'dpo', 'cpt', 'prefix', 'adapter', 'peft']
|
||||
|
||||
for item in os.listdir(base_path):
|
||||
item_path = os.path.join(base_path, item)
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
# 情况1: 新结构 {train_method}/{model_name}
|
||||
if item in train_methods:
|
||||
logger.info(f"[DEBUG] 检查训练方法目录: {item}")
|
||||
model_count = 0
|
||||
|
||||
for model_name in os.listdir(item_path):
|
||||
model_path = os.path.join(item_path, model_name)
|
||||
if not os.path.isdir(model_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
files = os.listdir(model_path)
|
||||
has_model = any(f.endswith('.bin') or f.endswith('.safetensors') for f in files)
|
||||
|
||||
if has_model:
|
||||
logger.info(f"[DEBUG] 找到模型: {item}/{model_name}")
|
||||
# 获取文件创建时间
|
||||
try:
|
||||
import time
|
||||
stat = os.stat(model_path)
|
||||
create_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
|
||||
except:
|
||||
create_time = None
|
||||
|
||||
# 查询基座模型路径
|
||||
base_model_path = get_model_path_by_name(model_name)
|
||||
|
||||
models.append({
|
||||
'name': model_name,
|
||||
'path': model_path,
|
||||
'base_model_path': base_model_path,
|
||||
'create_time': create_time,
|
||||
'train_methods': [{
|
||||
'name': item,
|
||||
'path': model_path
|
||||
}]
|
||||
})
|
||||
model_count += 1
|
||||
except Exception as file_err:
|
||||
logger.error(f"[DEBUG] 读取 {model_path} 失败: {file_err}")
|
||||
|
||||
logger.info(f"[DEBUG] {item} 找到 {model_count} 个模型")
|
||||
|
||||
# 情况2: 老结构 {model_name} 直接在 saves 下
|
||||
else:
|
||||
logger.info(f"[DEBUG] 检查老结构模型目录: {item}")
|
||||
try:
|
||||
files = os.listdir(item_path)
|
||||
has_model = any(f.endswith('.bin') or f.endswith('.safetensors') for f in files)
|
||||
|
||||
if has_model:
|
||||
logger.info(f"[DEBUG] 找到模型: {item}")
|
||||
|
||||
# 尝试从 adapter_config.json 推断 train_method
|
||||
inferred_method = 'lora' # 默认
|
||||
config_file = os.path.join(item_path, 'adapter_config.json')
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
import json
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
if 'peft_type' in config:
|
||||
peft_type = config['peft_type'].lower()
|
||||
if 'lora' in peft_type:
|
||||
inferred_method = 'lora'
|
||||
elif 'full' in peft_type or 'pt' in peft_type:
|
||||
inferred_method = 'full'
|
||||
except:
|
||||
pass
|
||||
|
||||
# 获取文件创建时间
|
||||
try:
|
||||
import time
|
||||
stat = os.stat(item_path)
|
||||
create_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
|
||||
except:
|
||||
create_time = None
|
||||
|
||||
# 查询基座模型路径
|
||||
base_model_path = get_model_path_by_name(item)
|
||||
|
||||
models.append({
|
||||
'name': item,
|
||||
'path': item_path,
|
||||
'base_model_path': base_model_path,
|
||||
'create_time': create_time,
|
||||
'train_methods': [{
|
||||
'name': inferred_method,
|
||||
'path': item_path
|
||||
}]
|
||||
})
|
||||
except Exception as file_err:
|
||||
logger.error(f"[DEBUG] 读取 {item_path} 失败: {file_err}")
|
||||
|
||||
except Exception as list_err:
|
||||
logger.error(f"[DEBUG] 遍历目录失败: {list_err}")
|
||||
|
||||
logger.info(f"[DEBUG] 找到 {len(models)} 个已训练模型")
|
||||
|
||||
# 检查每个模型是否已合并或正在合并
|
||||
local_trained_path = os.path.join(PROJECT_ROOT, 'local_trained_models')
|
||||
for model in models:
|
||||
model_name = model['name']
|
||||
merged_path = os.path.join(local_trained_path, model_name)
|
||||
lock_file = os.path.join(local_trained_path, f'.merging_{model_name}.lock')
|
||||
model['merged'] = os.path.exists(merged_path)
|
||||
model['merging'] = os.path.exists(lock_file)
|
||||
logger.info(f"[DEBUG] 模型 {model_name} 已合并: {model['merged']}, 正在合并: {model['merging']}")
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'models': models,
|
||||
'base_path': base_path or ''
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取已训练模型列表失败: {e}")
|
||||
return jsonify({'code': 1, 'message': str(e)})
|
||||
|
||||
|
||||
# ============ 合并权重接口 ============
|
||||
|
||||
@model_manage_bp.route('/merge', methods=['POST'])
|
||||
def merge_model():
|
||||
"""合并模型权重(将LoRA适配器合并到基座模型)"""
|
||||
import subprocess
|
||||
import sys
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
data = request.json
|
||||
model_name = data.get('model_name') # 模型名称
|
||||
train_method = data.get('train_method', 'lora') # 训练方法
|
||||
base_model_path = data.get('base_model_path') # 基座模型路径
|
||||
|
||||
if not model_name:
|
||||
return jsonify({'code': 1, 'message': '缺少模型名称'})
|
||||
|
||||
logger.info(f"[MERGE] 开始合并模型: {model_name}, 方法: {train_method}")
|
||||
|
||||
# 如果没有提供基座模型路径,从数据库查询
|
||||
if not base_model_path:
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 优先从训练任务表查询
|
||||
cursor.execute("""
|
||||
SELECT base_model FROM fine_tune
|
||||
WHERE output_model_name LIKE %s OR output_model_name LIKE %s
|
||||
LIMIT 1
|
||||
""", (f'%/{model_name}', f'%{model_name}%'))
|
||||
ft_result = cursor.fetchone()
|
||||
|
||||
if ft_result and ft_result.get('base_model'):
|
||||
base_model_val = ft_result['base_model']
|
||||
if str(base_model_val).isdigit():
|
||||
cursor.execute("SELECT path FROM model_manage WHERE id = %s LIMIT 1", (base_model_val,))
|
||||
model_result = cursor.fetchone()
|
||||
if model_result:
|
||||
base_model_path = model_result.get('path')
|
||||
else:
|
||||
base_model_path = base_model_val
|
||||
|
||||
# 如果没找到,尝试从模型管理表按名称查询
|
||||
if not base_model_path:
|
||||
cursor.execute("SELECT path FROM model_manage WHERE name = %s LIMIT 1", (model_name,))
|
||||
model_result = cursor.fetchone()
|
||||
if model_result:
|
||||
base_model_path = model_result.get('path')
|
||||
|
||||
conn.close()
|
||||
|
||||
if not base_model_path:
|
||||
return jsonify({'code': 1, 'message': f'未找到模型 {model_name} 的基座模型配置'})
|
||||
except Exception as e:
|
||||
logger.error(f"[MERGE] 查询模型配置失败: {e}")
|
||||
return jsonify({'code': 1, 'message': f'查询模型配置失败: {str(e)}'})
|
||||
|
||||
# 训练后的模型路径(LoRA适配器)
|
||||
adapter_path = f"/app/base/saves/{train_method}/{model_name}"
|
||||
|
||||
# 检查路径是否存在
|
||||
if not os.path.exists(adapter_path):
|
||||
return jsonify({'code': 1, 'message': f'训练模型不存在: {adapter_path}'})
|
||||
|
||||
# 合并后的输出路径
|
||||
output_path = f"/app/base/local_trained_models/{model_name}"
|
||||
|
||||
# 合并状态锁文件
|
||||
lock_file = f"/app/base/local_trained_models/.merging_{model_name}.lock"
|
||||
|
||||
# 创建输出目录
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
# 创建锁文件表示正在合并中
|
||||
try:
|
||||
with open(lock_file, 'w') as f:
|
||||
f.write('merging')
|
||||
|
||||
work_dir = '/app/base'
|
||||
|
||||
# 设置环境变量
|
||||
env = {**os.environ, 'CUDA_VISIBLE_DEVICES': '0'}
|
||||
|
||||
# 使用 llamafactory-cli export 命令(假设已在系统 PATH 中,与训练命令一致)
|
||||
cli_cmd = ['llamafactory-cli', 'export']
|
||||
|
||||
# 检查 llamafactory-cli 是否存在
|
||||
try:
|
||||
# 尝试使用 which 命令(Linux/Mac)
|
||||
subprocess.run(['which', 'llamafactory-cli'], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# Windows 上没有 which 命令,直接尝试执行
|
||||
logger.info("[MERGE] which 命令不可用,直接尝试执行 llamafactory-cli")
|
||||
|
||||
# 构建完整命令参数
|
||||
export_args = [
|
||||
'--model_name_or_path', base_model_path,
|
||||
'--adapter_name_or_path', adapter_path,
|
||||
'--export_dir', output_path
|
||||
]
|
||||
|
||||
logger.info(f"[MERGE] 执行合并命令: {' '.join(cli_cmd)} {' '.join(export_args)}")
|
||||
|
||||
# 直接执行 llamafactory-cli export 命令
|
||||
result = subprocess.run(
|
||||
cli_cmd + export_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
cwd=work_dir or '/app/base',
|
||||
env=env
|
||||
)
|
||||
|
||||
logger.info(f"[MERGE] 命令返回码: {result.returncode}")
|
||||
logger.info(f"[MERGE] stdout: {result.stdout[:500] if result.stdout else 'empty'}")
|
||||
logger.info(f"[MERGE] stderr: {result.stderr[:500] if result.stderr else 'empty'}")
|
||||
|
||||
# 等待输出目录完全创建
|
||||
import time
|
||||
max_wait = 5 # 最多等待5秒
|
||||
waited = 0
|
||||
while not os.path.exists(output_path) and waited < max_wait:
|
||||
time.sleep(0.5)
|
||||
waited += 0.5
|
||||
|
||||
# 无论成功失败,都删除锁文件
|
||||
if os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
|
||||
if result.returncode == 0:
|
||||
# 确保目录存在才返回成功
|
||||
if os.path.exists(output_path):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'message': f'模型权重已成功合并到 {output_path}',
|
||||
'data': {
|
||||
'model_name': model_name,
|
||||
'output_path': output_path
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'code': 1, 'message': '合并失败:输出目录未创建'})
|
||||
else:
|
||||
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip()
|
||||
if not error_msg:
|
||||
error_msg = f'命令执行失败,返回码: {result.returncode}'
|
||||
return jsonify({'code': 1, 'message': f'合并失败: {error_msg}'})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[MERGE] 合并超时")
|
||||
# 删除锁文件
|
||||
if os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
return jsonify({'code': 1, 'message': '合并超时,请稍后重试'})
|
||||
except Exception as e:
|
||||
logger.error(f"[MERGE] 合并异常: {str(e)}")
|
||||
return jsonify({'code': 1, 'message': f'合并异常: {str(e)}'})
|
||||
|
||||
|
||||
# ============ 删除已训练模型接口 ============
|
||||
|
||||
@model_manage_bp.route('/trained-models/<model_name>', methods=['DELETE'])
|
||||
def delete_trained_model(model_name):
|
||||
"""删除已训练模型
|
||||
type=merged: 删除合并模型(local_trained_models目录)
|
||||
type=lora: 删除权重(saves目录下的lora等权重文件)
|
||||
"""
|
||||
import shutil
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 获取删除类型参数
|
||||
delete_type = request.args.get('type', 'merged') # 默认删除合并模型
|
||||
|
||||
try:
|
||||
if delete_type == 'lora':
|
||||
# 删除权重:删除 saves 目录下的权重
|
||||
saves_path = os.path.join(PROJECT_ROOT, 'saves')
|
||||
train_methods = ['lora', 'full', 'qlora', 'dpo', 'cpt', 'prefix', 'adapter', 'peft']
|
||||
|
||||
deleted = False
|
||||
for method in train_methods:
|
||||
weight_path = os.path.join(saves_path, method, model_name)
|
||||
if os.path.exists(weight_path):
|
||||
shutil.rmtree(weight_path)
|
||||
logger.info(f"[DELETE] 已删除权重: {weight_path}")
|
||||
deleted = True
|
||||
|
||||
if not deleted:
|
||||
# 也可能是老结构,直接在 saves 下的 model_name 目录
|
||||
old_path = os.path.join(saves_path, model_name)
|
||||
if os.path.exists(old_path):
|
||||
shutil.rmtree(old_path)
|
||||
logger.info(f"[DELETE] 已删除老结构权重: {old_path}")
|
||||
deleted = True
|
||||
|
||||
if deleted:
|
||||
return jsonify({'code': 0, 'message': '权重已删除'})
|
||||
else:
|
||||
return jsonify({'code': 1, 'message': f'权重不存在: {model_name}'})
|
||||
else:
|
||||
# 默认删除合并模型(local_trained_models目录)
|
||||
model_path = os.path.join(PROJECT_ROOT, 'local_trained_models', model_name)
|
||||
|
||||
if not os.path.exists(model_path):
|
||||
return jsonify({'code': 1, 'message': f'合并模型不存在: {model_name}'})
|
||||
|
||||
# 删除目录
|
||||
shutil.rmtree(model_path)
|
||||
logger.info(f"[DELETE] 已删除合并模型: {model_path}")
|
||||
|
||||
return jsonify({'code': 0, 'message': '合并模型已删除'})
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] 删除失败: {str(e)}")
|
||||
return jsonify({'code': 1, 'message': f'删除失败: {str(e)}'})
|
||||
|
||||
|
||||
# ============ 导出已训练模型接口 ============
|
||||
|
||||
@model_manage_bp.route('/trained-models/<model_name>/export', methods=['GET'])
|
||||
def export_trained_model(model_name):
|
||||
"""导出已训练模型(打包成zip下载)"""
|
||||
import shutil
|
||||
import logging
|
||||
from flask import send_file
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# 优先从 local_trained_models 目录查找(合并后的模型)
|
||||
model_path = os.path.join(PROJECT_ROOT, 'local_trained_models', model_name)
|
||||
|
||||
# 如果本地模型目录不存在,尝试从 saves 目录查找(未合并的模型)
|
||||
if not os.path.exists(model_path):
|
||||
# 查找 saves 目录下的模型
|
||||
saves_path = os.path.join(PROJECT_ROOT, 'saves')
|
||||
train_methods = ['lora', 'full', 'qlora', 'dpo', 'cpt', 'prefix', 'adapter', 'peft']
|
||||
|
||||
for method in train_methods:
|
||||
potential_path = os.path.join(saves_path, method, model_name)
|
||||
if os.path.exists(potential_path):
|
||||
model_path = potential_path
|
||||
logger.info(f"[EXPORT] 从 saves/{method} 目录找到模型: {model_path}")
|
||||
break
|
||||
|
||||
# 如果还是找不到,返回错误
|
||||
if not os.path.exists(model_path):
|
||||
return jsonify({'code': 1, 'message': f'模型不存在: {model_name}'})
|
||||
|
||||
# 创建临时 zip 文件
|
||||
zip_path = os.path.join(PROJECT_ROOT, 'temp_exports')
|
||||
os.makedirs(zip_path, exist_ok=True)
|
||||
|
||||
zip_file = os.path.join(zip_path, f'{model_name}.zip')
|
||||
|
||||
# 如果已存在先删除
|
||||
if os.path.exists(zip_file):
|
||||
os.remove(zip_file)
|
||||
|
||||
# 打包成 zip
|
||||
shutil.make_archive(zip_file[:-4], 'zip', model_path)
|
||||
logger.info(f"[EXPORT] 已打包模型: {zip_file}")
|
||||
|
||||
# 发送文件给前端
|
||||
response = send_file(
|
||||
zip_file,
|
||||
as_attachment=True,
|
||||
download_name=f'{model_name}.zip',
|
||||
mimetype='application/zip'
|
||||
)
|
||||
|
||||
# 注册回调,删除临时文件
|
||||
def cleanup():
|
||||
try:
|
||||
if os.path.exists(zip_file):
|
||||
os.remove(zip_file)
|
||||
logger.info(f"[EXPORT] 已清理临时文件: {zip_file}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 使用 after_request 清理
|
||||
@response.call_on_close
|
||||
def cleanup_after_request():
|
||||
cleanup()
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[EXPORT] 导出模型失败: {str(e)}")
|
||||
return jsonify({'code': 1, 'message': f'导出失败: {str(e)}'})
|
||||
1355
src/main.py
1355
src/main.py
File diff suppressed because it is too large
Load Diff
@@ -11,4 +11,6 @@ fi
|
||||
|
||||
# 启动服务
|
||||
echo "启动后端服务..."
|
||||
python3 main.py
|
||||
nohup python3 main.py &
|
||||
# 日志会写入 logs/ 目录,同时输出到 stdout(Docker 日志可见)
|
||||
echo "后端服务已在后台启动"
|
||||
|
||||
306
start_all.py
Normal file
306
start_all.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
YG_FT_Base 统一启动脚本
|
||||
同时启动后端 API 服务和 Web 静态服务器
|
||||
|
||||
使用方法:
|
||||
python start_all.py start # 启动所有服务
|
||||
python start_all.py stop # 停止所有服务
|
||||
python start_all.py restart # 重启所有服务
|
||||
python start_all.py status # 查看服务状态
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
||||
CONFIG_FILE = SCRIPT_DIR / "config.yaml"
|
||||
WEB_DIR = SCRIPT_DIR / "web"
|
||||
|
||||
# PID 文件路径
|
||||
API_PID_FILE = SCRIPT_DIR / ".api.pid"
|
||||
WEB_PID_FILE = SCRIPT_DIR / ".web.pid"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"❌ 配置文件不存在: {CONFIG_FILE}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
app_config = config.get('app', {})
|
||||
return {
|
||||
'api_port': app_config.get('port', 7861),
|
||||
'web_port': app_config.get('web_port', 7862)
|
||||
}
|
||||
|
||||
|
||||
def is_port_in_use(port):
|
||||
"""检查端口是否被占用"""
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
return s.connect_ex(('localhost', port)) == 0
|
||||
|
||||
|
||||
def get_pid(pid_file):
|
||||
"""获取 PID"""
|
||||
if pid_file.exists():
|
||||
try:
|
||||
return int(pid_file.read_text().strip())
|
||||
except:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def is_process_running(pid):
|
||||
"""检查进程是否在运行"""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def kill_process(pid_file):
|
||||
"""杀死进程"""
|
||||
pid = get_pid(pid_file)
|
||||
if pid and is_process_running(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
time.sleep(0.5)
|
||||
# 如果还在运行,强制杀死
|
||||
if is_process_running(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def check_virtual_env():
|
||||
"""检查虚拟环境是否存在"""
|
||||
venv_path = SCRIPT_DIR / "B_venv"
|
||||
if not venv_path.exists():
|
||||
print("⚠️ 虚拟环境不存在,请先运行 create_venv.sh 创建")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def start_api():
|
||||
"""启动后端 API 服务"""
|
||||
config = load_config()
|
||||
api_port = config['api_port']
|
||||
|
||||
print("🚀 启动后端 API 服务...")
|
||||
|
||||
if not check_virtual_env():
|
||||
return False
|
||||
|
||||
if is_port_in_use(api_port):
|
||||
print(f"❌ 端口 {api_port} 已被占用,后端服务可能已在运行")
|
||||
return False
|
||||
|
||||
venv_python = SCRIPT_DIR / "B_venv" / "bin" / "python"
|
||||
main_py = SCRIPT_DIR / "src" / "main.py"
|
||||
|
||||
if not venv_python.exists():
|
||||
venv_python = SCRIPT_DIR / "B_venv" / "Scripts" / "python.exe" # Windows
|
||||
|
||||
if not main_py.exists():
|
||||
print(f"❌ 找不到主程序文件: {main_py}")
|
||||
return False
|
||||
|
||||
# 启动进程(不重定向输出,让 Python logging 模块自己处理日志文件)
|
||||
env = os.environ.copy()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
|
||||
log_file = open(os.devnull, 'w') # 忽略输出
|
||||
proc = subprocess.Popen(
|
||||
[str(venv_python), str(main_py)],
|
||||
cwd=str(SCRIPT_DIR),
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env
|
||||
)
|
||||
|
||||
# 写入 PID
|
||||
with open(API_PID_FILE, 'w') as f:
|
||||
f.write(str(proc.pid))
|
||||
|
||||
# 等待服务启动
|
||||
time.sleep(2)
|
||||
|
||||
if proc.poll() is not None:
|
||||
print("❌ 后端服务启动失败")
|
||||
API_PID_FILE.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
print(f"✅ 后端服务已启动 (PID: {proc.pid}, 端口: {api_port})")
|
||||
return True
|
||||
|
||||
|
||||
def start_web():
|
||||
"""启动 Web 静态服务器"""
|
||||
config = load_config()
|
||||
web_port = config['web_port']
|
||||
|
||||
print("🚀 启动 Web 静态服务器...")
|
||||
|
||||
# 从项目根目录启动,这样 /lib/... 路径可以正确映射到 $PROJECT/lib/...
|
||||
web_root = SCRIPT_DIR
|
||||
|
||||
if not web_root.exists():
|
||||
print(f"❌ Web 目录不存在: {web_root}")
|
||||
return False
|
||||
|
||||
if is_port_in_use(web_port):
|
||||
print(f"⚠️ 端口 {web_port} 已被占用,Web 服务可能已在运行")
|
||||
return False
|
||||
|
||||
# 使用 python 启动简单 HTTP 服务器(从项目根目录)
|
||||
env = os.environ.copy()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
|
||||
log_file = open(os.devnull, 'w') # 忽略输出
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, '-m', 'http.server', str(web_port)],
|
||||
cwd=str(web_root),
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env
|
||||
)
|
||||
|
||||
# 写入 PID
|
||||
with open(WEB_PID_FILE, 'w') as f:
|
||||
f.write(str(proc.pid))
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
if proc.poll() is not None:
|
||||
print("❌ Web 服务启动失败")
|
||||
WEB_PID_FILE.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
print(f"✅ Web 服务已启动 (PID: {proc.pid}, 端口: {web_port})")
|
||||
return True
|
||||
|
||||
|
||||
def stop_all():
|
||||
"""停止所有服务"""
|
||||
print("🛑 停止所有服务...")
|
||||
|
||||
# 停止后端服务
|
||||
if kill_process(API_PID_FILE):
|
||||
print("✅ 后端服务已停止")
|
||||
|
||||
# 停止 Web 服务
|
||||
if kill_process(WEB_PID_FILE):
|
||||
print("✅ Web 服务已停止")
|
||||
|
||||
# 清理残留进程
|
||||
subprocess.run(['pkill', '-f', 'src/main.py'], capture_output=True)
|
||||
config = load_config()
|
||||
subprocess.run(['pkill', '-f', f'http.server {config["web_port"]}'], capture_output=True)
|
||||
|
||||
|
||||
def show_status():
|
||||
"""显示服务状态"""
|
||||
config = load_config()
|
||||
api_port = config['api_port']
|
||||
web_port = config['web_port']
|
||||
|
||||
print("\n📊 服务状态:")
|
||||
print("-" * 40)
|
||||
|
||||
api_pid = get_pid(API_PID_FILE)
|
||||
if api_pid and is_process_running(api_pid):
|
||||
print(f"✅ 后端 API: 运行中 (PID: {api_pid}, 端口: {api_port})")
|
||||
else:
|
||||
print(f"❌ 后端 API: 未运行 (端口: {api_port})")
|
||||
|
||||
web_pid = get_pid(WEB_PID_FILE)
|
||||
if web_pid and is_process_running(web_pid):
|
||||
print(f"✅ Web 服务: 运行中 (PID: {web_pid}, 端口: {web_port})")
|
||||
else:
|
||||
print(f"❌ Web 服务: 未运行 (端口: {web_port})")
|
||||
|
||||
print("-" * 40)
|
||||
print("\n🌐 访问地址:")
|
||||
print(f" - 后端 API: http://localhost:{api_port}")
|
||||
print(f" - Web 页面: http://localhost:{web_port}/web/pages/main.html")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python start_all.py {start|stop|restart|status}")
|
||||
print()
|
||||
print("命令:")
|
||||
print(" start - 启动所有服务")
|
||||
print(" stop - 停止所有服务")
|
||||
print(" restart - 重启所有服务")
|
||||
print(" status - 查看服务状态")
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
print("====================================")
|
||||
print("YG_FT_Base 统一启动脚本 (Python版)")
|
||||
print("====================================")
|
||||
print()
|
||||
|
||||
config = load_config()
|
||||
print(f"📦 端口配置:")
|
||||
print(f" - 后端 API: {config['api_port']}")
|
||||
print(f" - Web 服务: {config['web_port']}")
|
||||
print()
|
||||
|
||||
if command == 'start':
|
||||
start_api()
|
||||
start_web()
|
||||
print()
|
||||
print("====================================")
|
||||
print("所有服务已启动!")
|
||||
print("====================================")
|
||||
show_status()
|
||||
|
||||
elif command == 'stop':
|
||||
stop_all()
|
||||
print("✅ 所有服务已停止")
|
||||
|
||||
elif command == 'restart':
|
||||
stop_all()
|
||||
time.sleep(1)
|
||||
start_api()
|
||||
start_web()
|
||||
print()
|
||||
print("====================================")
|
||||
print("所有服务已重启!")
|
||||
print("====================================")
|
||||
show_status()
|
||||
|
||||
elif command == 'status':
|
||||
show_status()
|
||||
|
||||
else:
|
||||
print(f"❌ 未知命令: {command}")
|
||||
print("用法: python start_all.py {start|stop|restart|status}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
225
start_all.sh
Normal file
225
start_all.sh
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# YG_FT_Base 统一启动脚本
|
||||
# 同时启动后端 API 服务、Web 静态服务器和 TensorBoard
|
||||
# 使用方法: bash start_all.sh
|
||||
|
||||
# 自动修复脚本换行符
|
||||
if grep -q $'\r' "$0"; then
|
||||
echo "检测到 Windows 换行符,自动修复中..."
|
||||
sed -i 's/\r$//' "$0"
|
||||
echo "修复完成,重新执行脚本..."
|
||||
exec "$0"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "===================================="
|
||||
echo "YG_FT_Base 统一启动脚本"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# 读取配置
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.yaml"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
API_PORT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG_FILE'))['app'].get('port', 7861))" 2>/dev/null)
|
||||
WEB_PORT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG_FILE'))['app'].get('web_port', 7862))" 2>/dev/null)
|
||||
[ -z "$API_PORT" ] && API_PORT=7861
|
||||
[ -z "$WEB_PORT" ] && WEB_PORT=7862
|
||||
else
|
||||
API_PORT=7861
|
||||
WEB_PORT=7862
|
||||
fi
|
||||
|
||||
echo "📦 端口配置:"
|
||||
echo " - 后端 API: $API_PORT"
|
||||
echo " - Web 服务器: $WEB_PORT"
|
||||
echo " - TensorBoard: 6006"
|
||||
echo ""
|
||||
|
||||
# 检查端口是否已被占用
|
||||
check_port() {
|
||||
if lsof -i:$1 &> /dev/null || netstat -tuln | grep -q ":$1 " 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 启动后端 API 服务
|
||||
start_api() {
|
||||
echo "🚀 启动后端 API 服务..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
if [ -d "B_venv" ]; then
|
||||
source B_venv/bin/activate
|
||||
else
|
||||
echo "⚠️ 虚拟环境不存在,请先运行 create_venv.sh 创建"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查端口
|
||||
if ! check_port $API_PORT; then
|
||||
echo "❌ 端口 $API_PORT 已被占用,后端服务可能已在运行"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 设置日志目录环境变量
|
||||
export LOG_BASE_DIR="$SCRIPT_DIR/logs"
|
||||
|
||||
# 不再重定向输出,让 Python logging 模块自己处理日志文件
|
||||
nohup python src/main.py > /dev/null 2>&1 &
|
||||
API_PID=$!
|
||||
echo "✅ 后端服务已启动 (PID: $API_PID, 端口: $API_PORT, 日志目录: $LOG_BASE_DIR)"
|
||||
echo "$API_PID" > /tmp/ygft_api.pid
|
||||
}
|
||||
|
||||
# 启动 TensorBoard 服务
|
||||
start_tensorboard() {
|
||||
echo ""
|
||||
echo "🚀 启动 TensorBoard 服务..."
|
||||
|
||||
# 检查端口
|
||||
if ! check_port 6006; then
|
||||
echo "⚠️ 端口 6006 已被占用,TensorBoard 可能已在运行"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 确保日志目录存在
|
||||
LOG_DIR="/app/base/saves"
|
||||
if [ ! -d "$LOG_DIR" ]; then
|
||||
LOG_DIR="$SCRIPT_DIR/saves"
|
||||
fi
|
||||
|
||||
if [ ! -d "$LOG_DIR" ]; then
|
||||
echo "⚠️ 日志目录不存在,跳过 TensorBoard 启动"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 启动 TensorBoard(后台运行)
|
||||
nohup tensorboard --logdir "$LOG_DIR" --port 6006 --bind_all > "$LOG_DIR/tensorboard.log" 2>&1 &
|
||||
TB_PID=$!
|
||||
echo "✅ TensorBoard 服务已启动 (PID: $TB_PID, 端口: 6006)"
|
||||
echo "$TB_PID" > /tmp/ygft_tensorboard.pid
|
||||
echo "📊 TensorBoard 访问地址: http://localhost:6006"
|
||||
}
|
||||
|
||||
# 启动 Web 静态服务器
|
||||
start_web() {
|
||||
echo ""
|
||||
echo "🚀 启动 Web 静态服务器..."
|
||||
cd "$SCRIPT_DIR/web"
|
||||
|
||||
# 检查端口
|
||||
if ! check_port $WEB_PORT; then
|
||||
echo "⚠️ 端口 $WEB_PORT 已被占用,Web 服务可能已在运行"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 -m http.server $WEB_PORT &
|
||||
WEB_PID=$!
|
||||
echo "✅ Web 服务已启动 (PID: $WEB_PID, 端口: $WEB_PORT)"
|
||||
echo "$WEB_PID" > /tmp/ygft_web.pid
|
||||
}
|
||||
|
||||
# 停止服务
|
||||
stop_all() {
|
||||
echo ""
|
||||
echo "🛑 停止所有服务..."
|
||||
|
||||
if [ -f /tmp/ygft_api.pid ]; then
|
||||
kill $(cat /tmp/ygft_api.pid) 2>/dev/null
|
||||
rm /tmp/ygft_api.pid
|
||||
echo "✅ 后端服务已停止"
|
||||
fi
|
||||
|
||||
if [ -f /tmp/ygft_web.pid ]; then
|
||||
kill $(cat /tmp/ygft_web.pid) 2>/dev/null
|
||||
rm /tmp/ygft_web.pid
|
||||
echo "✅ Web 服务已停止"
|
||||
fi
|
||||
|
||||
if [ -f /tmp/ygft_tensorboard.pid ]; then
|
||||
kill $(cat /tmp/ygft_tensorboard.pid) 2>/dev/null
|
||||
rm /tmp/ygft_tensorboard.pid
|
||||
echo "✅ TensorBoard 服务已停止"
|
||||
fi
|
||||
|
||||
# 清理可能残留的进程
|
||||
pkill -f "src/main.py" 2>/dev/null
|
||||
pkill -f "http.server $WEB_PORT" 2>/dev/null
|
||||
pkill -f "tensorboard.*6006" 2>/dev/null
|
||||
}
|
||||
|
||||
# 显示状态
|
||||
status() {
|
||||
echo ""
|
||||
echo "📊 服务状态:"
|
||||
echo ""
|
||||
|
||||
if [ -f /tmp/ygft_api.pid ] && kill -0 $(cat /tmp/ygft_api.pid) 2>/dev/null; then
|
||||
echo "✅ 后端 API: 运行中 (PID: $(cat /tmp/ygft_api.pid), 端口: $API_PORT)"
|
||||
else
|
||||
echo "❌ 后端 API: 未运行"
|
||||
fi
|
||||
|
||||
if [ -f /tmp/ygft_web.pid ] && kill -0 $(cat /tmp/ygft_web.pid) 2>/dev/null; then
|
||||
echo "✅ Web 服务: 运行中 (PID: $(cat /tmp/ygft_web.pid), 端口: $WEB_PORT)"
|
||||
else
|
||||
echo "❌ Web 服务: 未运行"
|
||||
fi
|
||||
|
||||
if [ -f /tmp/ygft_tensorboard.pid ] && kill -0 $(cat /tmp/ygft_tensorboard.pid) 2>/dev/null; then
|
||||
echo "✅ TensorBoard: 运行中 (PID: $(cat /tmp/ygft_tensorboard.pid), 端口: 6006)"
|
||||
else
|
||||
echo "❌ TensorBoard: 未运行"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🌐 访问地址:"
|
||||
echo " - 后端 API: http://localhost:$API_PORT"
|
||||
echo " - Web 页面: http://localhost:$WEB_PORT/pages/main.html"
|
||||
echo " - TensorBoard: http://localhost:6006"
|
||||
}
|
||||
|
||||
# 主菜单
|
||||
case "$1" in
|
||||
start)
|
||||
start_api
|
||||
start_tensorboard
|
||||
start_web
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo "所有服务已启动!"
|
||||
echo "===================================="
|
||||
status
|
||||
;;
|
||||
stop)
|
||||
stop_all
|
||||
echo "✅ 所有服务已停止"
|
||||
;;
|
||||
restart)
|
||||
stop_all
|
||||
sleep 1
|
||||
start_api
|
||||
start_tensorboard
|
||||
start_web
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo "所有服务已重启!"
|
||||
echo "===================================="
|
||||
status
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|restart|status}"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start - 启动所有服务"
|
||||
echo " stop - 停止所有服务"
|
||||
echo " restart - 重启所有服务"
|
||||
echo " status - 查看服务状态"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
157
web/css/main.css
Normal file
157
web/css/main.css
Normal file
@@ -0,0 +1,157 @@
|
||||
/* 主页面样式 - 从 main.html 分离 */
|
||||
.sidebar-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(191, 203, 217, 0.7);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0, 21, 41, 0.2);
|
||||
}
|
||||
.sidebar-item-active {
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
.table-row-hover:hover {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.table-header-bg {
|
||||
background-color: #fafafa !important;
|
||||
}
|
||||
.card-radio {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.card-radio.active {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
.card-radio:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, outline 0.2s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, outline 0.2s;
|
||||
appearance: none;
|
||||
background-color: white;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
.form-select:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
}
|
||||
.icon-option {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.icon-option:hover {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
.icon-option.selected {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
.tab-active {
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.radio-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.upload-area:hover,
|
||||
.upload-area.drag-over {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
.bg-primary {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
.border-primary {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.text-danger {
|
||||
color: #f5222d;
|
||||
}
|
||||
.hover\:bg-primary\/90:hover {
|
||||
background-color: rgba(24, 144, 255, 0.9);
|
||||
}
|
||||
:root {
|
||||
--primary: #1890ff;
|
||||
--danger: #f5222d;
|
||||
--success: #52c41a;
|
||||
}
|
||||
|
||||
/* 侧边栏滑块动画 */
|
||||
.sidebar-slider {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 0;
|
||||
background-color: var(--primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 菜单项相对定位 */
|
||||
.nav-item-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 选中项背景动画 */
|
||||
.nav-link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
1
web/favicon.ico
Normal file
1
web/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
25
web/index.html
Normal file
25
web/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>跳转中...</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>正在检查登录状态...</p>
|
||||
<script>
|
||||
// 会话超时检查(5分钟)
|
||||
const SESSION_TIMEOUT = 5 * 60 * 1000; // 5分钟
|
||||
const loginTime = localStorage.getItem('loginTime');
|
||||
if (!loginTime || (Date.now() - parseInt(loginTime)) > SESSION_TIMEOUT) {
|
||||
// 会话过期,跳转登录页
|
||||
localStorage.removeItem('loginTime');
|
||||
localStorage.removeItem('username');
|
||||
window.location.href = 'pages/login.html';
|
||||
} else {
|
||||
// 会话有效,跳转主页
|
||||
localStorage.setItem('loginTime', Date.now());
|
||||
window.location.href = 'pages/main.html';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
web/js/api.js
Normal file
21
web/js/api.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* API 配置模块
|
||||
* 提供 API 基础地址和常用配置
|
||||
*/
|
||||
|
||||
// API 基础地址配置
|
||||
(function() {
|
||||
if (typeof window.getApiBase !== 'function') {
|
||||
window.getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
}
|
||||
if (typeof window.API_BASE === 'undefined') {
|
||||
window.API_BASE = window.getApiBase();
|
||||
}
|
||||
})();
|
||||
|
||||
// 导出 API_BASE 供其他模块使用
|
||||
window.API_BASE = window.getApiBase();
|
||||
236
web/js/components/sidebar-loader.js
Normal file
236
web/js/components/sidebar-loader.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 共享侧边栏加载器
|
||||
* 动态加载侧边栏组件,支持高亮当前页面
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 侧边栏容器占位样式(防止加载时闪烁)
|
||||
const containerStyles = `
|
||||
#sidebar-container {
|
||||
width: 16rem;
|
||||
min-width: 16rem;
|
||||
height: 100vh;
|
||||
background-color: #001529;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#sidebar-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// 侧边栏样式
|
||||
const sidebarStyles = `
|
||||
.sidebar-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(191, 203, 217, 0.7);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0, 21, 41, 0.2);
|
||||
}
|
||||
.sidebar-item-active {
|
||||
background-color: rgba(24, 144, 255, 0.1) !important;
|
||||
color: #1890ff !important;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
.sidebar-slider {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 0;
|
||||
background-color: #1890ff;
|
||||
border-radius: 0 2px 2px 0;
|
||||
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-item-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.nav-link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
// 立即注入容器样式(防止闪烁)
|
||||
(function injectContainerStyles() {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'sidebar-container-styles';
|
||||
styleEl.textContent = containerStyles;
|
||||
document.head.appendChild(styleEl);
|
||||
})();
|
||||
|
||||
// 根据当前页面路径确定活动页面
|
||||
function getCurrentPage() {
|
||||
const path = window.location.pathname;
|
||||
const search = window.location.search;
|
||||
const fileName = path.split('/').pop().replace('.html', '');
|
||||
|
||||
// 检查 URL 参数
|
||||
const urlParams = new URLSearchParams(search);
|
||||
const pageParam = urlParams.get('page');
|
||||
if (pageParam) {
|
||||
return pageParam;
|
||||
}
|
||||
|
||||
// 根据文件名映射
|
||||
const pageMap = {
|
||||
'main': 'fine-tune',
|
||||
'hardware': 'hardware',
|
||||
'logs': 'logs',
|
||||
'tools': 'tools',
|
||||
'fine-tune-create': 'fine-tune',
|
||||
'training-log': 'fine-tune',
|
||||
'dataset-create': 'dataset-manage',
|
||||
'dataset-preview': 'dataset-manage',
|
||||
'model-manage': 'model-manage',
|
||||
'model-manage-create': 'model-manage',
|
||||
'model-eval': 'model-eval',
|
||||
'model-eval-create': 'model-eval',
|
||||
'model-compare-create': 'model-compare',
|
||||
'model-compare-chat': 'model-compare',
|
||||
'model-compare-result': 'model-compare',
|
||||
'model-dimension-create': 'model-eval',
|
||||
'model-inference': 'model-manage',
|
||||
'custom-tool-create': 'tools'
|
||||
};
|
||||
|
||||
return pageMap[fileName] || 'fine-tune';
|
||||
}
|
||||
|
||||
// 加载侧边栏
|
||||
async function loadSidebar() {
|
||||
const container = document.getElementById('sidebar-container');
|
||||
if (!container) {
|
||||
console.warn('未找到 sidebar-container 元素');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算组件路径
|
||||
const currentPath = window.location.pathname;
|
||||
let basePath = '';
|
||||
if (currentPath.includes('/pages/')) {
|
||||
basePath = 'components/';
|
||||
} else {
|
||||
basePath = 'pages/components/';
|
||||
}
|
||||
|
||||
const response = await fetch(basePath + 'sidebar.html');
|
||||
if (!response.ok) {
|
||||
throw new Error('加载侧边栏失败: ' + response.status);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
container.innerHTML = html;
|
||||
|
||||
// 注入样式
|
||||
if (!document.getElementById('sidebar-styles')) {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'sidebar-styles';
|
||||
styleEl.textContent = sidebarStyles;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
// 修正 logo 路径
|
||||
const logo = container.querySelector('#sidebar-logo');
|
||||
if (logo) {
|
||||
const depth = currentPath.split('/pages/').length > 1 ? '../' : '';
|
||||
logo.src = depth + 'assets/logo/logo.png';
|
||||
}
|
||||
|
||||
// 高亮当前页面
|
||||
const currentPage = window.sidebarCurrentPage || getCurrentPage();
|
||||
highlightCurrentPage(currentPage);
|
||||
|
||||
// 初始化滑块
|
||||
initSlider();
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载侧边栏出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮当前页面
|
||||
function highlightCurrentPage(currentPage) {
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
const page = link.dataset.page;
|
||||
if (page === currentPage) {
|
||||
link.classList.add('sidebar-item-active');
|
||||
link.classList.remove('hover:bg-[#001529]/20', 'transition-colors');
|
||||
} else {
|
||||
link.classList.remove('sidebar-item-active');
|
||||
link.classList.add('hover:bg-[#001529]/20', 'transition-colors');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化滑块
|
||||
function initSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
|
||||
// 找到当前活动项
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
updateSliderPosition(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定导航点击事件
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
const wrapper = this.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
updateSliderPosition(wrapper);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新滑块位置
|
||||
function updateSliderPosition(targetWrapper) {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider || !targetWrapper) return;
|
||||
|
||||
const nav = document.querySelector('nav');
|
||||
if (!nav) return;
|
||||
|
||||
const navRect = nav.getBoundingClientRect();
|
||||
const wrapperRect = targetWrapper.getBoundingClientRect();
|
||||
|
||||
const top = wrapperRect.top - navRect.top + nav.scrollTop;
|
||||
const height = wrapperRect.height;
|
||||
|
||||
slider.style.top = top + 'px';
|
||||
slider.style.height = height + 'px';
|
||||
slider.style.opacity = '1';
|
||||
}
|
||||
|
||||
// 导出到全局
|
||||
window.SidebarLoader = {
|
||||
load: loadSidebar,
|
||||
highlight: highlightCurrentPage,
|
||||
getCurrentPage: getCurrentPage
|
||||
};
|
||||
|
||||
// DOM 加载完成后自动加载侧边栏
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadSidebar);
|
||||
} else {
|
||||
loadSidebar();
|
||||
}
|
||||
})();
|
||||
753
web/js/components/table.js
Normal file
753
web/js/components/table.js
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* 表格组件
|
||||
* 处理表格渲染、数据操作等
|
||||
*/
|
||||
|
||||
// 当前页面状态
|
||||
window.currentPage = 'fine-tune';
|
||||
window.currentParentPage = null;
|
||||
window.selectedItems = new Set(); // 存储选中的项ID
|
||||
window.currentPageData = []; // 存储当前页面数据
|
||||
window.modelListCache = []; // 模型列表缓存
|
||||
window.currentModelTab = 'config'; // 模型管理页面当前tab: 'config'=配置模型, 'trained'=训练模型
|
||||
|
||||
// 获取 API 数据
|
||||
async function fetchData(url) {
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '获取数据失败');
|
||||
}
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
// 删除数据
|
||||
async function deleteItem(api, id) {
|
||||
// 如果是我的模型,提示删除合并模型
|
||||
const confirmMessage = api === 'model-manage/trained-models'
|
||||
? '确定要删除合并模型吗?权重文件不会删除。'
|
||||
: '确定要删除这条记录吗?';
|
||||
|
||||
window.showConfirm('确认删除', confirmMessage, async () => {
|
||||
try {
|
||||
// 如果是我的模型,调用删除合并模型的API
|
||||
if (api === 'model-manage/trained-models') {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage/trained-models/${id}?type=merged`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
window.showMessage('成功', '删除成功', 'success');
|
||||
// 清除合并状态缓存
|
||||
sessionStorage.removeItem('merge_status_' + id);
|
||||
// 刷新当前页面
|
||||
clearSelection();
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(`${window.API_BASE}/${api}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
// 刷新当前页面
|
||||
clearSelection(); // 清除选中状态
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
window.showMessage('错误', '删除失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新模型用途
|
||||
async function updateModelPurpose(id, purpose) {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage/${id}/purpose`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ purpose })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
// 刷新当前页面
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '更新失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
window.showMessage('错误', '更新失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个项的选中状态
|
||||
function toggleItemSelection(id, api) {
|
||||
if (window.selectedItems.has(id)) {
|
||||
window.selectedItems.delete(id);
|
||||
} else {
|
||||
window.selectedItems.add(id);
|
||||
}
|
||||
// 重新渲染当前页面以更新UI
|
||||
refreshCurrentPage();
|
||||
}
|
||||
|
||||
// 切换全选/取消全选
|
||||
function toggleSelectAll(checkbox, api) {
|
||||
// 使用保存的当前页面数据
|
||||
if (checkbox.checked) {
|
||||
// 全选当前页面的所有数据(支持 name 或 id)
|
||||
window.currentPageData.forEach(item => window.selectedItems.add(item.name || item.id));
|
||||
} else {
|
||||
// 取消全选,移除当前页面所有数据的选中状态
|
||||
window.currentPageData.forEach(item => window.selectedItems.delete(item.name || item.id));
|
||||
}
|
||||
refreshCurrentPage();
|
||||
}
|
||||
|
||||
// 清除所有选中项
|
||||
function clearSelection() {
|
||||
window.selectedItems.clear();
|
||||
refreshCurrentPage();
|
||||
}
|
||||
|
||||
// 批量删除选中的项
|
||||
function batchDeleteItems(api) {
|
||||
if (window.selectedItems.size === 0) {
|
||||
window.showMessage('提示', '请先选择要删除的项', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
window.showConfirm('批量删除', `确定要删除选中的 ${window.selectedItems.size} 条记录吗?`, async () => {
|
||||
const ids = Array.from(window.selectedItems);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/${api}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
|
||||
// 刷新当前页面
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
window.showMessage('成功', `成功删除 ${successCount} 条记录`, 'success');
|
||||
} else {
|
||||
window.showMessage('部分失败', `成功删除 ${successCount} 条,${failCount} 条删除失败`, 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新当前页面(重新渲染)
|
||||
function refreshCurrentPage() {
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink) {
|
||||
const pageName = activeLink.dataset.page;
|
||||
const config = window.tableConfigs[pageName];
|
||||
|
||||
if (config && (config.hasModelTabs || config.api === 'model-manage' || config.api === 'dataset-manage')) {
|
||||
const container = document.getElementById('page-content');
|
||||
if (container && typeof renderTablePage === 'function') {
|
||||
container.innerHTML = renderTablePage(config, window.currentPageData);
|
||||
// 恢复复选框状态
|
||||
updateCheckboxStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新复选框状态(保持选中状态)
|
||||
function updateCheckboxStates() {
|
||||
const checkboxes = document.querySelectorAll('tbody input[type="checkbox"]');
|
||||
checkboxes.forEach(cb => {
|
||||
const match = cb.getAttribute('onchange')?.match(/toggleItemSelection\((\d+)/) || cb.getAttribute('onchange')?.match(/toggleItemSelection\(([^,]+)/);
|
||||
const id = match ? parseInt(match[1]) || match[1] : null;
|
||||
if (id !== null && window.selectedItems.has(id)) {
|
||||
cb.checked = true;
|
||||
cb.closest('tr')?.classList.add('bg-blue-50');
|
||||
} else {
|
||||
cb.checked = false;
|
||||
cb.closest('tr')?.classList.remove('bg-blue-50');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新批量操作栏的显示状态
|
||||
const batchActions = document.getElementById('batchActions');
|
||||
if (batchActions) {
|
||||
if (window.selectedItems.size > 0) {
|
||||
batchActions.classList.remove('hidden');
|
||||
batchActions.querySelector('strong').textContent = window.selectedItems.size;
|
||||
} else {
|
||||
batchActions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新批量删除按钮
|
||||
const batchDeleteBtn = document.querySelector('#batchActions button[onclick^="batchDeleteItems"]');
|
||||
if (batchDeleteBtn) {
|
||||
if (window.selectedItems.size > 0) {
|
||||
batchDeleteBtn.innerHTML = `<i class="fa fa-trash mr-1"></i>批量删除 (${window.selectedItems.size})`;
|
||||
} else {
|
||||
batchDeleteBtn.innerHTML = `<i class="fa fa-trash mr-1"></i>批量删除 (0)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑数据集
|
||||
function editItem(api, id) {
|
||||
if (api === 'dataset-manage') {
|
||||
// 跳转到数据集创建页面进行编辑
|
||||
window.location.href = `dataset-create.html?id=${id}`;
|
||||
} else if (api === 'model-manage') {
|
||||
// 跳转到模型创建页面进行编辑
|
||||
window.location.href = `model-manage-create.html?id=${id}`;
|
||||
} else {
|
||||
window.showMessage('提示', '编辑功能开发中...', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 下载数据集(打包下载)
|
||||
function downloadDataset(datasetId) {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
window.open(`${protocol}//${hostname}:7861/api/dataset-manage/download/${datasetId}`, '_blank');
|
||||
}
|
||||
|
||||
// 开始模型对比
|
||||
async function startCompare(id) {
|
||||
// 跳转到模型对比聊天页面(通过主框架加载)
|
||||
window.location.href = `main.html?page=model-compare-chat&id=${id}`;
|
||||
}
|
||||
|
||||
// 筛选表格
|
||||
function filterTable() {
|
||||
const searchInput = document.getElementById('tableSearchInput');
|
||||
if (!searchInput) return;
|
||||
|
||||
const keyword = searchInput.value.toLowerCase().trim();
|
||||
const tbody = document.querySelector('#page-content table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
rows.forEach(row => {
|
||||
const text = row.querySelector('td')?.textContent?.toLowerCase() || '';
|
||||
if (keyword === '' || text.includes(keyword)) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新表格数据 - 重新加载当前页面
|
||||
window.loadTableData = function() {
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
};
|
||||
|
||||
// 合并模型权重(保留兼容)
|
||||
window.viewTrainedModel = function(name, method, path) {
|
||||
if (typeof window.startMerge === 'function') {
|
||||
window.startMerge(name, method, path);
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑模型
|
||||
window.editModel = function(modelId) {
|
||||
window.location.href = `model-manage-create.html?id=${modelId}`;
|
||||
};
|
||||
|
||||
// 预览数据集
|
||||
window.previewDataset = function(datasetId) {
|
||||
window.location.href = `dataset-preview.html?id=${datasetId}`;
|
||||
};
|
||||
|
||||
// 下载数据集
|
||||
window.downloadDataset = function(datasetId) {
|
||||
window.open(`${window.API_BASE}/dataset-manage/download/${datasetId}`, '_blank');
|
||||
};
|
||||
|
||||
// 导出模型权重
|
||||
function exportModel(modelName) {
|
||||
window.open(`${window.API_BASE}/model-manage/trained-models/${encodeURIComponent(modelName)}/export`, '_blank');
|
||||
}
|
||||
|
||||
// 删除已训练模型的权重
|
||||
async function deleteTrainedWeight(modelName) {
|
||||
window.showConfirm('确认删除', `确定要删除模型 "${modelName}" 的权重文件吗?合并模型不受影响。`, async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage/trained-models/${encodeURIComponent(modelName)}?type=lora`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
// 只显示成功消息,不刷新表格(因为后端可能因为没有权重文件而不返回这条记录)
|
||||
window.showMessage('成功', '权重已删除', 'success');
|
||||
// 清除该模型的合并状态缓存,让前端重新从后端获取状态
|
||||
sessionStorage.removeItem('merge_status_' + modelName);
|
||||
sessionStorage.removeItem('merge_status_' + modelName + '_time');
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除权重失败:', error);
|
||||
window.showMessage('错误', '删除失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 切换模型管理tab
|
||||
function switchModelTab(tab) {
|
||||
window.currentModelTab = tab;
|
||||
// 清除选中状态
|
||||
clearSelection();
|
||||
// 重新加载模型管理页面
|
||||
window.loadPage('model-manage');
|
||||
}
|
||||
|
||||
// ========== 渲染函数 ==========
|
||||
|
||||
// 渲染表格页面
|
||||
function renderTablePage(config, data) {
|
||||
// 如果是模型管理页面,根据tab动态决定列和配置
|
||||
let columns = config.columns || [];
|
||||
let createButton = '';
|
||||
let supportsMultiSelect = false;
|
||||
let currentApi = config.api;
|
||||
|
||||
if (config.hasModelTabs) {
|
||||
if (window.currentModelTab === 'config') {
|
||||
// 配置模型tab
|
||||
columns = [
|
||||
{ title: '模型名称', key: 'name' },
|
||||
{ title: '模型类型', key: 'type', render: (val) => {
|
||||
const textMap = { 'LLM': '大语言模型', 'CV': '计算机视觉', 'NLP': '自然语言处理', 'Embedding': '向量模型', 'Other': '其他' };
|
||||
const displayText = textMap[val] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-blue-100 text-blue-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '用途', key: 'purpose', render: (val) => {
|
||||
const purposeMap = { 'training': { text: '训练', class: 'bg-blue-100 text-blue-700' }, 'inference': { text: '推理', class: 'bg-green-100 text-green-700' }, 'evaluation': { text: '评测', class: 'bg-purple-100 text-purple-700' } };
|
||||
const display = purposeMap[val] || { text: val || '-', class: 'bg-gray-100 text-gray-700' };
|
||||
return '<span class="px-2 py-1 rounded text-xs ' + display.class + '">' + display.text + '</span>';
|
||||
}},
|
||||
{ title: '模型来源', key: 'model_source', render: (val) => {
|
||||
const textMap = { 'local': '本地模型', 'api': '在线模型', 'online': '在线模型' };
|
||||
const displayText = textMap[val] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '描述', key: 'description', render: (val) => val || '-' },
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
];
|
||||
createButton = `
|
||||
<button onclick="showCreateModal('model-manage')" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>新建模型
|
||||
</button>
|
||||
`;
|
||||
supportsMultiSelect = true;
|
||||
currentApi = 'model-manage';
|
||||
} else {
|
||||
// 训练模型tab
|
||||
columns = [
|
||||
{ title: '模型名称', key: 'name' },
|
||||
{ title: '训练方法', key: 'train_methods', render: (val) => val && val[0] ? val[0].name : '-' },
|
||||
{ title: '基座模型', key: 'base_model_path', render: (val) => `<span class="text-xs text-gray-500 truncate block" title="${val}">${val || '-'}</span>` },
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
];
|
||||
supportsMultiSelect = true;
|
||||
currentApi = 'model-manage/trained-models';
|
||||
}
|
||||
} else {
|
||||
// 非模型管理页面,使用原始配置
|
||||
columns = config.columns || columns;
|
||||
createButton = config.api === 'dataset-manage' ? `
|
||||
<button onclick="showCreateModal('${config.api}')" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>创建数据集
|
||||
</button>
|
||||
` : (config.api === 'fine-tune' ? `
|
||||
<button onclick="navigateToPage('fine-tune-create')" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>新建调优任务
|
||||
</button>
|
||||
` : (config.api === 'model-compare' ? `
|
||||
<button onclick="navigateToPage('model-compare-create')" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>新建对比
|
||||
</button>
|
||||
` : ''));
|
||||
supportsMultiSelect = config.api === 'model-manage' || config.api === 'model-manage/trained-models' || config.api === 'dataset-manage' || config.api === 'fine-tune';
|
||||
}
|
||||
|
||||
// 搜索框
|
||||
const searchBox = (config.api === 'model-manage' || config.api === 'model-manage/trained-models' || config.api === 'dataset-manage' || config.api === 'fine-tune' || config.hasModelTabs) ? `
|
||||
<div class="relative">
|
||||
<input type="text" id="tableSearchInput" placeholder="搜索${config.title}..."
|
||||
class="w-72 pl-9 pr-3 py-1.5 rounded border border-gray-300 text-sm focus:outline-none focus:border-primary focus:ring-1 focus:border-primary"
|
||||
oninput="filterTable()">
|
||||
<i class="fa fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
// 批量删除按钮(仅当有选中项时显示)
|
||||
const batchDeleteButton = supportsMultiSelect && window.selectedItems.size > 0 ? `
|
||||
<button onclick="batchDeleteItems('${currentApi}')" class="bg-red-500 text-white px-4 py-2 rounded text-sm hover:bg-red-600 transition-colors font-medium shadow-sm">
|
||||
<i class="fa fa-trash mr-1"></i>批量删除 (${window.selectedItems.size})
|
||||
</button>
|
||||
` : '';
|
||||
|
||||
const hasData = data && data.length > 0;
|
||||
|
||||
// 多选列头
|
||||
const selectAllHeader = supportsMultiSelect ? `
|
||||
<th class="px-4 py-3 text-center font-medium w-10">
|
||||
<input type="checkbox" class="w-4 h-4 text-primary rounded border-gray-300 cursor-pointer"
|
||||
onchange="toggleSelectAll(this, '${currentApi}')">
|
||||
</th>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">${config.title}</h2>
|
||||
<div class="flex items-center space-x-3">
|
||||
${searchBox}
|
||||
${createButton}
|
||||
</div>
|
||||
</div>
|
||||
${config.hasModelTabs ? `
|
||||
<div class="px-4 border-b border-gray-100">
|
||||
<div class="flex space-x-1" id="modelTabs">
|
||||
<button onclick="switchModelTab('config')" class="tab-btn px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${window.currentModelTab === 'config' ? 'bg-primary text-white' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}">
|
||||
<i class="fa fa-cog mr-1"></i>配置模型
|
||||
</button>
|
||||
<button onclick="switchModelTab('trained')" class="tab-btn px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${window.currentModelTab === 'trained' ? 'bg-primary text-white' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}">
|
||||
<i class="fa fa-rocket mr-1"></i>训练模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.tab-btn { position: relative; transition: all 0.2s; }
|
||||
</style>
|
||||
` : ''}
|
||||
${supportsMultiSelect ? `
|
||||
<div id="batchActions" class="px-4 py-2 bg-blue-50 border-b border-blue-100 flex items-center justify-between ${window.selectedItems.size > 0 ? '' : 'hidden'}">
|
||||
<div class="flex items-center text-sm text-blue-700">
|
||||
<span>已选择 <strong>${window.selectedItems.size}</strong> 项</span>
|
||||
<button onclick="clearSelection()" class="ml-3 text-blue-500 hover:text-blue-700 underline">取消选择</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
${batchDeleteButton}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="table-header-bg text-gray-500 text-sm">
|
||||
${selectAllHeader}
|
||||
${columns.map(col => `<th class="px-4 py-3 text-center font-medium">${col.title}</th>`).join('')}
|
||||
<th class="px-4 py-3 text-center font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${hasData ? data.map(item => `
|
||||
<tr class="border-b border-gray-100 table-row-hover ${window.selectedItems.has(item.name || item.id) ? 'bg-blue-50' : ''}">
|
||||
${supportsMultiSelect ? `
|
||||
<td class="px-4 py-4 text-sm text-center">
|
||||
<input type="checkbox" class="w-4 h-4 text-primary rounded border-gray-300 cursor-pointer"
|
||||
${window.selectedItems.has(item.name || item.id) ? 'checked' : ''}
|
||||
onchange="toggleItemSelection('${item.name || item.id}', '${currentApi}')">
|
||||
</td>
|
||||
` : ''}
|
||||
${columns.map(col => `
|
||||
<td class="px-4 py-4 text-sm text-center">
|
||||
${col.render ? col.render(item[col.key], item) : (item[col.key] || '-')}
|
||||
</td>
|
||||
`).join('')}
|
||||
<td class="px-4 py-4 text-sm text-center">
|
||||
<div class="flex justify-center space-x-2">
|
||||
${config.api === 'fine-tune' ? `
|
||||
<button onclick="viewFineTuneLogs('${item.id}', '${item.name}')" class="bg-blue-500 text-white px-3 py-1 rounded text-xs hover:bg-blue-600">查看日志</button>
|
||||
<button onclick="deleteItem('${config.api}', '${item.id}')" class="bg-red-500 text-white px-3 py-1 rounded text-xs hover:bg-red-600">删除任务</button>
|
||||
` : (currentApi === 'model-manage/trained-models' ? `
|
||||
${getMergeButtonHtml(item.name, item.train_methods?.[0]?.name || 'lora', item.base_model_path || '', item.merged, item.merging)}
|
||||
<button onclick="deleteTrainedWeight('${item.name}')" class="bg-orange-500 text-white px-3 py-1 rounded text-xs hover:bg-orange-600" title="删除权重文件">删除权重</button>
|
||||
${item.merged ? `
|
||||
<button onclick="deleteItem('${currentApi}', '${item.name || item.id}')" class="bg-red-500 text-white px-3 py-1 rounded text-xs hover:bg-red-600" title="删除合并模型">删除模型</button>
|
||||
` : ''}
|
||||
${(item.merged && !item.merging) ? `
|
||||
<button onclick="exportModel('${item.name}')" class="bg-green-500 text-white px-3 py-1 rounded text-xs hover:bg-green-600">导出</button>
|
||||
` : ''}
|
||||
` : (currentApi === 'model-manage' ? `
|
||||
<button onclick="editModel('${item.id}')" class="bg-blue-500 text-white px-3 py-1 rounded text-xs hover:bg-blue-600">编辑</button>
|
||||
<button onclick="deleteItem('${currentApi}', '${item.id}')" class="bg-red-500 text-white px-3 py-1 rounded text-xs hover:bg-red-600">删除</button>
|
||||
` : (config.api === 'dataset-manage' ? `
|
||||
<button onclick="previewDataset('${item.id}')" class="bg-blue-500 text-white px-3 py-1 rounded text-xs hover:bg-blue-600">预览</button>
|
||||
<button onclick="downloadDataset('${item.id}')" class="bg-green-500 text-white px-3 py-1 rounded text-xs hover:bg-green-600">下载</button>
|
||||
<button onclick="deleteItem('${config.api}', '${item.id}')" class="bg-red-500 text-white px-3 py-1 rounded text-xs hover:bg-red-600">删除</button>
|
||||
` : (config.api === 'model-compare' ? `
|
||||
${getCompareButtonHtml(item.id, item.status)}
|
||||
` : ''))))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${!hasData ? `
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<div class="py-12">
|
||||
<i class="fa fa-inbox text-5xl mb-4 text-gray-300"></i>
|
||||
<p class="text-gray-500">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 获取合并按钮HTML
|
||||
function getMergeButtonHtml(name, method, path, merged, merging) {
|
||||
const storageKey = 'merge_status_' + name;
|
||||
const tempStatus = sessionStorage.getItem(storageKey);
|
||||
const tempStatusTime = sessionStorage.getItem(storageKey + '_time');
|
||||
console.log('[DEBUG] getMergeButtonHtml:', name, 'tempStatus:', tempStatus, 'merged:', merged, 'merging:', merging);
|
||||
|
||||
// 检查临时状态是否过期(超过5分钟视为过期)
|
||||
const now = Date.now();
|
||||
const statusExpired = tempStatusTime && (now - parseInt(tempStatusTime)) > 5 * 60 * 1000;
|
||||
|
||||
// 如果状态过期或无效,清除并视为无状态
|
||||
if (statusExpired || (tempStatus && !tempStatusTime)) {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
// 继续检查后端状态
|
||||
} else if (tempStatus === 'merging') {
|
||||
// 如果后端已经完成合并但前端状态未更新,清除临时状态
|
||||
if (merged) {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
} else {
|
||||
return `<button class="bg-gray-300 text-gray-500 px-3 py-1 rounded text-xs cursor-not-allowed flex items-center" disabled>
|
||||
<i class="fa fa-spinner fa-spin mr-1"></i>合并中...
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
// 如果后端返回正在合并中(锁文件存在)
|
||||
if (merging) {
|
||||
return `<button class="bg-gray-300 text-gray-500 px-3 py-1 rounded text-xs cursor-not-allowed flex items-center" disabled>
|
||||
<i class="fa fa-spinner fa-spin mr-1"></i>合并中...
|
||||
</button>`;
|
||||
}
|
||||
if (tempStatus === 'success' && merged) {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
return `<button class="bg-gray-300 text-gray-500 px-3 py-1 rounded text-xs cursor-not-allowed" disabled>合并成功</button>`;
|
||||
}
|
||||
if (tempStatus === 'success' && !merged) {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
}
|
||||
if (merged) {
|
||||
return `<button class="bg-gray-300 text-gray-500 px-3 py-1 rounded text-xs cursor-not-allowed" disabled>合并成功</button>`;
|
||||
}
|
||||
return `<button onclick="startMerge('${name}', '${method}', '${path}')" class="bg-primary text-white px-3 py-1 rounded text-xs hover:bg-primary/90">合并权重</button>`;
|
||||
}
|
||||
|
||||
// 获取模型对比操作按钮HTML
|
||||
function getCompareButtonHtml(id, status) {
|
||||
// status: pending(未加载), loading(加载中), loaded(已加载)
|
||||
if (status === 'loading') {
|
||||
return `<button class="bg-yellow-500 text-white px-3 py-1 rounded text-xs cursor-not-allowed flex items-center" disabled>
|
||||
<i class="fa fa-spinner fa-spin mr-1"></i>加载中...
|
||||
</button>`;
|
||||
}
|
||||
if (status === 'loaded') {
|
||||
return `
|
||||
<button onclick="startCompare(${id})" class="bg-green-500 text-white px-3 py-1 rounded text-xs hover:bg-green-600 flex items-center">
|
||||
<i class="fa fa-play mr-1"></i>开始对比
|
||||
</button>
|
||||
<button onclick="unloadCompare(${id})" class="bg-gray-500 text-white px-3 py-1 rounded text-xs hover:bg-gray-600">
|
||||
停止
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
// pending - 显示"准备加载"按钮
|
||||
return `<button onclick="loadCompare(${id})" class="bg-primary text-white px-3 py-1 rounded text-xs hover:bg-primary/90">
|
||||
<i class="fa fa-cloud-download mr-1"></i>准备加载
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// 加载模型对比任务
|
||||
async function loadCompare(id) {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-compare/${id}/load`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
window.showMessage('提示', '正在加载模型,请稍候...', 'info');
|
||||
// 轮询加载状态
|
||||
pollLoadStatus(id);
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '加载失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型失败:', error);
|
||||
window.showMessage('错误', '加载失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询加载状态
|
||||
async function pollLoadStatus(id, maxAttempts = 60) {
|
||||
let attempts = 0;
|
||||
const checkInterval = 3000; // 3秒检查一次
|
||||
|
||||
const poll = async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-compare/${id}/load-status`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
const data = result.data;
|
||||
if (data.all_ready) {
|
||||
window.showMessage('成功', '模型加载完成!可以开始对比了。', 'success');
|
||||
window.loadTableData(); // 刷新表格显示"开始对比"按钮
|
||||
} else if (attempts < maxAttempts) {
|
||||
setTimeout(poll, checkInterval);
|
||||
} else {
|
||||
window.showMessage('警告', '模型加载超时,请重试', 'warning');
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
setTimeout(poll, checkInterval);
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '加载状态检查失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查加载状态失败:', error);
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, checkInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(poll, checkInterval);
|
||||
}
|
||||
|
||||
// 停止模型对比任务
|
||||
async function unloadCompare(id) {
|
||||
window.showConfirm('确认停止', '确定要停止模型服务吗?', async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-compare/${id}/unload`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
window.showMessage('成功', '已停止模型服务', 'success');
|
||||
window.loadTableData();
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '停止失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止模型服务失败:', error);
|
||||
window.showMessage('错误', '停止失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启动合并任务
|
||||
async function startMerge(name, method, path) {
|
||||
const storageKey = 'merge_status_' + name;
|
||||
sessionStorage.setItem(storageKey, 'merging');
|
||||
sessionStorage.setItem(storageKey + '_time', Date.now().toString());
|
||||
window.loadTableData();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_name: name,
|
||||
train_method: method || 'lora',
|
||||
base_model_path: path
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
sessionStorage.setItem(storageKey, 'success');
|
||||
setTimeout(() => window.loadTableData(), 1500);
|
||||
} else {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
window.showMessage('失败', result.message || '合并失败', 'error');
|
||||
window.loadTableData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] 合并失败:', error);
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(storageKey + '_time');
|
||||
window.showMessage('错误', '合并失败: ' + error.message, 'error');
|
||||
window.loadTableData();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出表格组件
|
||||
window.TableComponent = {
|
||||
fetchData,
|
||||
deleteItem,
|
||||
updateModelPurpose,
|
||||
toggleItemSelection,
|
||||
toggleSelectAll,
|
||||
clearSelection,
|
||||
batchDeleteItems,
|
||||
refreshCurrentPage,
|
||||
updateCheckboxStates,
|
||||
editItem,
|
||||
downloadDataset,
|
||||
startCompare,
|
||||
filterTable,
|
||||
exportModel,
|
||||
switchModelTab,
|
||||
renderTablePage,
|
||||
getMergeButtonHtml,
|
||||
startMerge,
|
||||
deleteTrainedWeight,
|
||||
getCompareButtonHtml,
|
||||
loadCompare,
|
||||
unloadCompare
|
||||
};
|
||||
66
web/js/config/constants.js
Normal file
66
web/js/config/constants.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 常量定义
|
||||
* 集中管理所有硬编码的配置值
|
||||
*/
|
||||
|
||||
// API 配置
|
||||
window.API_CONFIG = {
|
||||
PORT: 7861,
|
||||
TIMEOUT: 30000, // 请求超时时间
|
||||
|
||||
// 系统监控
|
||||
METRICS_INTERVAL: 30000, // 系统指标刷新间隔(ms)
|
||||
|
||||
// 训练进度
|
||||
TRAINING_REFRESH_INTERVAL: 5000, // 训练进度刷新间隔(ms)
|
||||
PROGRESS_BAR_WIDTH: 200, // 进度条宽度
|
||||
|
||||
// 合并操作
|
||||
MERGE_TIMEOUT: 5 * 60 * 1000, // 合并状态超时时间(ms)
|
||||
|
||||
// 分页
|
||||
PAGE_SIZE: 10,
|
||||
|
||||
// 消息提示
|
||||
TOAST_DURATION: 3000, // toast提示显示时间(ms)
|
||||
|
||||
// 模型类型
|
||||
MODEL_TYPES: {
|
||||
'LLM': '大语言模型',
|
||||
'CV': '计算机视觉',
|
||||
'NLP': '自然语言处理',
|
||||
'Embedding': '向量模型',
|
||||
'Other': '其他'
|
||||
},
|
||||
|
||||
// 用途映射
|
||||
PURPOSE_MAP: {
|
||||
'training': { text: '训练', class: 'bg-blue-100 text-blue-700' },
|
||||
'inference': { text: '推理', class: 'bg-green-100 text-green-700' },
|
||||
'evaluation': { text: '评测', class: 'bg-purple-100 text-purple-700' }
|
||||
},
|
||||
|
||||
// 模型来源
|
||||
SOURCE_MAP: {
|
||||
'local': '本地模型',
|
||||
'api': '在线模型',
|
||||
'online': '在线模型'
|
||||
},
|
||||
|
||||
// 训练方法显示名称
|
||||
TRAIN_METHOD_MAP: {
|
||||
'lora': 'LoRA',
|
||||
'qlora': 'QLoRA',
|
||||
'full': '全量微调',
|
||||
'prefix': 'Prefix Tuning',
|
||||
'adapter': 'Adapter',
|
||||
'peft': 'PEFT',
|
||||
'adalora': 'AdaLoRA',
|
||||
'longlora': 'LongLoRA',
|
||||
'dpo': 'DPO',
|
||||
'cpt': 'CPT'
|
||||
}
|
||||
};
|
||||
|
||||
// 导出为常量引用方便使用
|
||||
window.CONSTANTS = window.API_CONFIG;
|
||||
597
web/js/main.js
Normal file
597
web/js/main.js
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* 主入口模块
|
||||
* 页面初始化和导航控制
|
||||
*/
|
||||
|
||||
// 各功能模块的表格配置
|
||||
window.tableConfigs = {
|
||||
'fine-tune': {
|
||||
title: '模型调优',
|
||||
api: 'fine-tune',
|
||||
hasCreate: true,
|
||||
createText: '创建训练任务',
|
||||
columns: [
|
||||
{ title: '任务名称', key: 'name' },
|
||||
{ title: '任务状态', key: 'status', render: (val) => `<span class="px-2 py-1 rounded text-xs ${val === 'running' ? 'bg-green-100 text-green-700' : val === 'failed' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'}">${val}</span>` },
|
||||
{ title: '训练方式', key: 'train_type', render: (val) => val === 'SFT' ? 'SFT 微调训练' : (val === 'DPO' ? 'DPO 偏好训练' : (val === 'CPT' ? 'CPT 继续预训练' : '-')) },
|
||||
{ title: '训练模板', key: 'template', render: (val) => val || '-' },
|
||||
{ title: '基座模型', key: 'base_model', render: (val, row) => `<span class="model-name-cell" data-model-id="${val}">加载中...</span>` }
|
||||
],
|
||||
actions: ['stop', 'logs', 'delete']
|
||||
},
|
||||
'my-models': {
|
||||
title: '我的模型',
|
||||
api: 'model-manage/trained-models',
|
||||
dataPath: 'models',
|
||||
hasCreate: false,
|
||||
columns: [
|
||||
{ title: '模型名称', key: 'name' },
|
||||
{ title: '训练方法', key: 'train_methods', render: (val) => val && val[0] ? val[0].name : '-' },
|
||||
{ title: '基座模型', key: 'base_model_path', render: (val) => `<span class="text-xs text-gray-500 truncate block" title="${val}">${val || '-'}</span>` },
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
],
|
||||
actions: ['view', 'delete']
|
||||
},
|
||||
'model-eval': {
|
||||
title: '模型评测',
|
||||
isExternalPage: true,
|
||||
createConfig: {
|
||||
page: 'model-eval-create',
|
||||
hasCreate: true,
|
||||
createText: '新建评测'
|
||||
}
|
||||
},
|
||||
'model-compare': {
|
||||
title: '模型对比',
|
||||
api: 'model-compare',
|
||||
hasCreate: true,
|
||||
createText: '新建对比',
|
||||
columns: [
|
||||
{ title: '对比名称', key: 'model_name' },
|
||||
{ title: '描述', key: 'description', render: (val) => val || '-' },
|
||||
{ title: '相关模型', key: 'models', render: (val) => {
|
||||
if (!val) return '-';
|
||||
try {
|
||||
// 如果是字符串,尝试解析 JSON
|
||||
let models = val;
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
models = JSON.parse(val);
|
||||
} catch {
|
||||
models = val.split(',').map(id => ({ model_name: id.trim() }));
|
||||
}
|
||||
}
|
||||
// 如果是数组,提取模型名称
|
||||
if (Array.isArray(models) && models.length > 0) {
|
||||
return models.map(function(m) {
|
||||
if (typeof m === 'object' && m !== null) {
|
||||
return m.model_name || m.name || '未知模型';
|
||||
}
|
||||
return String(m);
|
||||
}).join(', ');
|
||||
}
|
||||
// 如果是单个对象
|
||||
if (typeof models === 'object' && models !== null) {
|
||||
return models.model_name || models.name || '未知模型';
|
||||
}
|
||||
return String(models);
|
||||
} catch (e) {
|
||||
return '解析错误';
|
||||
}
|
||||
}},
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
],
|
||||
actions: ['compare', 'delete']
|
||||
},
|
||||
'dataset-manage': {
|
||||
title: '数据集管理',
|
||||
api: 'dataset-manage',
|
||||
hasCreate: true,
|
||||
createText: '上传数据集',
|
||||
columns: [
|
||||
{ title: '数据集名称', key: 'name' },
|
||||
{ title: '数据类型', key: 'type', render: (val) => {
|
||||
const textMap = {
|
||||
'train': '训练数据',
|
||||
'test': '测试数据',
|
||||
'eval': '评测数据',
|
||||
'val': '验证数据',
|
||||
'other': '其他'
|
||||
};
|
||||
const displayText = textMap[val?.toLowerCase()] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-blue-100 text-blue-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '存储位置', key: 'storage_type', render: (val) => {
|
||||
const textMap = {
|
||||
'local': '本地存储',
|
||||
'minio': 'MinIO',
|
||||
'cloud': '云存储'
|
||||
};
|
||||
const displayText = textMap[val] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-green-100 text-green-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '大小', key: 'size', render: (val) => (val && val !== '0 B' && val !== '0') ? val : '-' },
|
||||
{ title: '数据条数', key: 'count', render: (val) => val || 0 },
|
||||
{ title: '描述', key: 'description', render: (val) => val || '-' },
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
],
|
||||
actions: ['preview', 'download', 'delete']
|
||||
},
|
||||
'data-generate': {
|
||||
title: '其他工具',
|
||||
isTools: true,
|
||||
defaultTools: [
|
||||
{ id: 'data-generate', name: '数据生成', icon: 'fa-database', description: '基于LLM生成微调数据集' },
|
||||
{ id: 'json2jsonl', name: 'JSON转JSONL', icon: 'fa-code', description: '将JSON文件转换为JSONL格式' },
|
||||
{ id: 'md-convert', name: '转换Markdown', icon: 'fa-file-text', description: '将Markdown文件转换为训练数据' }
|
||||
],
|
||||
customTools: []
|
||||
},
|
||||
'model-manage': {
|
||||
title: '模型管理',
|
||||
api: 'model-manage',
|
||||
hasCreate: true,
|
||||
hasModelTabs: true,
|
||||
createText: '添加模型',
|
||||
columns: [
|
||||
{ title: '模型名称', key: 'name' },
|
||||
{ title: '模型类型', key: 'type', render: (val) => {
|
||||
const textMap = {
|
||||
'LLM': '大语言模型',
|
||||
'CV': '计算机视觉',
|
||||
'NLP': '自然语言处理',
|
||||
'Embedding': '向量模型',
|
||||
'Other': '其他'
|
||||
};
|
||||
const displayText = textMap[val] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-blue-100 text-blue-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '用途', key: 'purpose', render: (val) => {
|
||||
const purposeMap = {
|
||||
'training': { text: '训练', class: 'bg-blue-100 text-blue-700' },
|
||||
'inference': { text: '推理', class: 'bg-green-100 text-green-700' },
|
||||
'evaluation': { text: '评测', class: 'bg-purple-100 text-purple-700' }
|
||||
};
|
||||
const display = purposeMap[val] || { text: val || '-', class: 'bg-gray-100 text-gray-700' };
|
||||
return '<span class="px-2 py-1 rounded text-xs ' + display.class + '">' + display.text + '</span>';
|
||||
}},
|
||||
{ title: '模型来源', key: 'model_source', render: (val) => {
|
||||
const textMap = {
|
||||
'local': '本地模型',
|
||||
'api': '在线模型',
|
||||
'online': '在线模型'
|
||||
};
|
||||
const displayText = textMap[val] || val || '-';
|
||||
return '<span class="px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">' + displayText + '</span>';
|
||||
}},
|
||||
{ title: '描述', key: 'description', render: (val) => val || '-' },
|
||||
{ title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' }
|
||||
],
|
||||
actions: ['edit', 'delete']
|
||||
},
|
||||
'config': {
|
||||
title: '平台性能',
|
||||
skipFetch: true,
|
||||
hasCreate: false,
|
||||
isHardwareMonitor: true
|
||||
},
|
||||
'logs': {
|
||||
title: '查看日志',
|
||||
skipFetch: true,
|
||||
hasCreate: false,
|
||||
isLogViewer: true
|
||||
},
|
||||
'model-compare-chat': {
|
||||
title: '模型对比',
|
||||
skipFetch: true,
|
||||
hasCreate: false,
|
||||
isExternalPage: true
|
||||
},
|
||||
'model-compare-result': {
|
||||
title: '对比结果',
|
||||
skipFetch: true,
|
||||
hasCreate: false,
|
||||
isExternalPage: true
|
||||
},
|
||||
'training-log': {
|
||||
title: '训练日志',
|
||||
skipFetch: true,
|
||||
hasCreate: false,
|
||||
isExternalPage: true
|
||||
}
|
||||
};
|
||||
|
||||
// 操作按钮映射
|
||||
window.actionLabels = {
|
||||
'stop': '停止',
|
||||
'logs': '查看日志',
|
||||
'delete': '删除',
|
||||
'deploy': '部署',
|
||||
'eval': '评测',
|
||||
'report': '查看报告',
|
||||
'scale': '扩容',
|
||||
'preview': '预览',
|
||||
'download': '下载',
|
||||
'detail': '详情',
|
||||
'edit': '编辑',
|
||||
'compare': '开始对话',
|
||||
'chat': '对话',
|
||||
'view': '合并权重'
|
||||
};
|
||||
|
||||
// 加载模型列表缓存
|
||||
async function loadModelListCache() {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
window.modelListCache = result.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载模型列表失败:', e);
|
||||
window.modelListCache = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 根据模型ID获取模型名称(同步版本)
|
||||
function getModelName(modelId) {
|
||||
if (!modelId) return '-';
|
||||
const model = window.modelListCache.find(m =>
|
||||
m.id == modelId ||
|
||||
m.id === String(modelId) ||
|
||||
m.id === Number(modelId)
|
||||
);
|
||||
if (model) {
|
||||
return model.name;
|
||||
}
|
||||
return `模型${modelId}`;
|
||||
}
|
||||
|
||||
// 异步获取模型名称并更新 DOM
|
||||
async function fetchAndUpdateModelName(modelId, cellElement) {
|
||||
if (!modelId) {
|
||||
cellElement.textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
let model = window.modelListCache.find(m =>
|
||||
m.id == modelId ||
|
||||
m.id === String(modelId) ||
|
||||
m.id === Number(modelId)
|
||||
);
|
||||
|
||||
if (!model) {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
window.modelListCache = result.data || [];
|
||||
model = window.modelListCache.find(m =>
|
||||
m.id == modelId ||
|
||||
m.id === String(modelId) ||
|
||||
m.id === Number(modelId)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模型列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (model) {
|
||||
cellElement.textContent = model.name;
|
||||
} else {
|
||||
cellElement.textContent = `模型${modelId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据模型ID列表获取模型名称列表
|
||||
function getModelNames(modelIds) {
|
||||
if (!modelIds || !Array.isArray(modelIds)) return '-';
|
||||
return modelIds.map(id => getModelName(id)).join(', ');
|
||||
}
|
||||
|
||||
// 显示创建表单页面
|
||||
window.showCreateModal = function(apiType) {
|
||||
if (apiType === 'fine-tune') {
|
||||
window.location.href = 'fine-tune-create.html';
|
||||
} else if (apiType === 'model-manage') {
|
||||
window.location.href = 'model-manage-create.html';
|
||||
} else if (apiType === 'model-eval') {
|
||||
window.location.href = 'model-eval-create.html';
|
||||
} else if (apiType === 'dataset-manage') {
|
||||
window.location.href = 'dataset-create.html';
|
||||
} else if (apiType === 'model-compare') {
|
||||
window.location.href = 'model-compare-create.html';
|
||||
} else {
|
||||
window.showMessage('提示', '该功能开发中...', 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// 返回列表页
|
||||
window.goBack = function() {
|
||||
if (window.currentParentPage) {
|
||||
window.currentPage = window.currentParentPage;
|
||||
window.currentParentPage = null;
|
||||
loadPage(window.currentPage);
|
||||
} else {
|
||||
loadPage('fine-tune');
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到页面
|
||||
window.navigateToPage = function(pageName) {
|
||||
if (pageName.endsWith('-create')) {
|
||||
window.location.href = `${pageName}.html`;
|
||||
} else {
|
||||
window.location.href = `main.html?page=${pageName}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 返回到列表页
|
||||
window.goBackToList = function() {
|
||||
navigateToPage('fine-tune');
|
||||
};
|
||||
|
||||
// 加载页面内容
|
||||
async function loadPage(pageName) {
|
||||
// 切换页面时清除选中状态
|
||||
TableComponent.clearSelection();
|
||||
|
||||
// 离开日志页面时停止自动刷新
|
||||
SystemService.stopLogAutoRefresh();
|
||||
|
||||
// 离开模型调优页面时停止进度刷新
|
||||
if (window.currentPage === 'fine-tune' && pageName !== 'fine-tune') {
|
||||
TrainingService.stopProgressRefresh();
|
||||
}
|
||||
|
||||
const container = document.getElementById('page-content');
|
||||
const config = window.tableConfigs[pageName];
|
||||
|
||||
if (!config) return;
|
||||
|
||||
// 更新当前页面
|
||||
window.currentPage = pageName;
|
||||
|
||||
// 显示加载中
|
||||
container.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6 p-8 text-center">
|
||||
<i class="fa fa-spinner fa-spin text-3xl text-primary"></i>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 显示/隐藏返回按钮
|
||||
const backBtn = document.getElementById('pageBackBtn');
|
||||
if (config.isExternalPage) {
|
||||
backBtn.classList.remove('hidden');
|
||||
} else {
|
||||
backBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.isExternalPage) {
|
||||
// 外部页面
|
||||
const response = await fetch(`${pageName}.html?t=${Date.now()}`);
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
const scriptRegex = /<script\b(?![^>]*\bsrc)[^>]*>([\s\S]*?)<\/script>/g;
|
||||
const scriptContents = [];
|
||||
let match;
|
||||
while ((match = scriptRegex.exec(html)) !== null) {
|
||||
scriptContents.push(match[1]);
|
||||
}
|
||||
const scriptContent = scriptContents.join('\n');
|
||||
const htmlWithoutScript = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '');
|
||||
|
||||
let headerHtml = '';
|
||||
if (config.createConfig && config.createConfig.hasCreate) {
|
||||
headerHtml = `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6 p-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-8">
|
||||
<button class="tab-btn active pb-3 text-sm font-medium flex items-center text-primary" data-tab="tasks" onclick="switchTab(this, 'tasks')">
|
||||
<i class="fa fa-tasks mr-2"></i>评测任务
|
||||
</button>
|
||||
<button class="tab-btn pb-3 text-sm font-medium flex items-center text-gray-500" data-tab="leaderboard" onclick="switchTab(this, 'leaderboard')">
|
||||
<i class="fa fa-trophy mr-2"></i>排行榜
|
||||
</button>
|
||||
<button class="tab-btn pb-3 text-sm font-medium flex items-center text-gray-500" data-tab="dimensions" onclick="switchTab(this, 'dimensions')">
|
||||
<i class="fa fa-sliders mr-2"></i>评测维度
|
||||
</button>
|
||||
</div>
|
||||
<div id="headerActionButtons" style="min-height: 36px;">
|
||||
<button onclick="navigateToPage('${config.createConfig.page}')" class="bg-primary text-white px-4 py-2 rounded-lg text-sm hover:bg-primary/90 transition-colors flex items-center">
|
||||
<i class="fa fa-plus mr-2"></i>${config.createConfig.createText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.tab-btn { position: relative; transition: all 0.2s; }
|
||||
.tab-btn.active { color: #1890ff; }
|
||||
.tab-btn.active::after { content: ''; position: absolute; bottom: -16px; left: 0; right: 0; height: 2px; background-color: #1890ff; }
|
||||
.tab-btn:hover:not(.active) { color: #1890ff; }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = headerHtml + htmlWithoutScript;
|
||||
|
||||
if (scriptContent && scriptContent.trim()) {
|
||||
try {
|
||||
const oldScript = document.getElementById('externalPageScript');
|
||||
if (oldScript) oldScript.remove();
|
||||
const scriptEl = document.createElement('script');
|
||||
scriptEl.id = 'externalPageScript';
|
||||
scriptEl.textContent = scriptContent;
|
||||
document.body.appendChild(scriptEl);
|
||||
} catch (e) {
|
||||
console.error('执行脚本失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('页面加载失败');
|
||||
}
|
||||
} else if (config.isHardwareMonitor) {
|
||||
container.innerHTML = PageRenderer.renderConfigPage(config, null);
|
||||
PageRenderer.initGPUList();
|
||||
PageRenderer.startRefreshTimer();
|
||||
} else if (config.isLogViewer) {
|
||||
container.innerHTML = PageRenderer.renderLogViewerPage(config);
|
||||
SystemService.initLogViewer();
|
||||
} else if (config.isForm) {
|
||||
const data = await TableComponent.fetchData(`${window.API_BASE}/${config.api}`);
|
||||
container.innerHTML = PageRenderer.renderConfigPage(config, data);
|
||||
} else if (config.isTools) {
|
||||
container.innerHTML = PageRenderer.renderToolsPage(config);
|
||||
} else {
|
||||
// 模型管理页面根据tab选择不同的API
|
||||
let apiUrl = `${window.API_BASE}/${config.api}`;
|
||||
if (config.hasModelTabs) {
|
||||
if (window.currentModelTab === 'trained') {
|
||||
apiUrl = `${window.API_BASE}/model-manage/trained-models`;
|
||||
}
|
||||
}
|
||||
let data = await TableComponent.fetchData(apiUrl);
|
||||
let dataPath = config.dataPath || null;
|
||||
if (config.hasModelTabs && window.currentModelTab === 'trained') {
|
||||
dataPath = 'models';
|
||||
}
|
||||
if (dataPath && typeof data === 'object' && data !== null) {
|
||||
data = data[dataPath] || [];
|
||||
}
|
||||
window.currentPageData = data;
|
||||
container.innerHTML = TableComponent.renderTablePage(config, data);
|
||||
|
||||
setTimeout(() => {
|
||||
const modelCells = container.querySelectorAll('.model-name-cell');
|
||||
modelCells.forEach(cell => {
|
||||
const modelId = cell.getAttribute('data-model-id');
|
||||
if (modelId) {
|
||||
fetchAndUpdateModelName(modelId, cell);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
container.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6 p-8 text-center">
|
||||
<i class="fa fa-exclamation-circle text-3xl text-danger"></i>
|
||||
<p class="mt-2 text-gray-500">加载数据失败,请检查后端服务是否启动</p>
|
||||
<p class="text-sm text-gray-400 mt-1">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加评测维度
|
||||
window.addDimension = function() {
|
||||
window.location.href = 'model-dimension-create.html';
|
||||
};
|
||||
|
||||
// 删除评测维度
|
||||
window.deleteDimension = async function(id) {
|
||||
window.showConfirm('确认删除', '确定要删除此评测维度吗?', async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/dimension/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
window.showMessage('成功', '删除成功', 'success', () => {
|
||||
switchTab(document.querySelector('[data-tab="dimensions"]'), 'dimensions');
|
||||
});
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除维度失败:', error);
|
||||
window.showMessage('错误', '删除失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 切换 Tab
|
||||
window.switchTab = function(btn, tabId) {
|
||||
const parent = btn.parentElement;
|
||||
parent.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.classList.remove('active', 'text-primary');
|
||||
b.classList.add('text-gray-500');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.classList.remove('text-gray-500');
|
||||
|
||||
if (typeof window.switchTabContent === 'function') {
|
||||
window.switchTabContent(tabId);
|
||||
}
|
||||
|
||||
const btnContainer = document.getElementById('headerActionButtons');
|
||||
const currentConfig = window.tableConfigs[window.currentPage];
|
||||
if (btnContainer) {
|
||||
if (tabId === 'tasks') {
|
||||
const page = currentConfig?.createConfig?.page || 'model-eval-create';
|
||||
const text = currentConfig?.createConfig?.createText || '新建评测';
|
||||
btnContainer.innerHTML = `
|
||||
<button onclick="navigateToPage('${page}')" class="bg-primary text-white px-4 py-2 rounded-lg text-sm hover:bg-primary/90 transition-colors flex items-center">
|
||||
<i class="fa fa-plus mr-2"></i>${text}
|
||||
</button>
|
||||
`;
|
||||
} else if (tabId === 'leaderboard') {
|
||||
btnContainer.innerHTML = '<span class="invisible px-4 py-2 rounded-lg">占位</span>';
|
||||
} else if (tabId === 'dimensions') {
|
||||
btnContainer.innerHTML = `
|
||||
<button onclick="addDimension()" class="bg-primary text-white px-4 py-2 rounded-lg text-sm hover:bg-primary/90 transition-colors flex items-center">
|
||||
<i class="fa fa-plus mr-2"></i>添加维度
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 初始化 ============
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 从 localStorage 加载自定义工具
|
||||
const savedCustomTools = localStorage.getItem('customTools');
|
||||
if (savedCustomTools) {
|
||||
window.tableConfigs['data-generate'].customTools = JSON.parse(savedCustomTools);
|
||||
}
|
||||
|
||||
// 加载模型列表缓存
|
||||
loadModelListCache();
|
||||
|
||||
// 检查URL参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const pageParam = urlParams.get('page');
|
||||
|
||||
let defaultPage = 'fine-tune';
|
||||
|
||||
if (pageParam) {
|
||||
defaultPage = pageParam;
|
||||
} else {
|
||||
const sessionPage = sessionStorage.getItem('lastPage');
|
||||
const localPage = localStorage.getItem('lastPage');
|
||||
const savedPage = sessionPage || localPage;
|
||||
|
||||
if (savedPage && window.tableConfigs[savedPage]) {
|
||||
defaultPage = savedPage;
|
||||
}
|
||||
}
|
||||
|
||||
sessionStorage.setItem('lastPage', defaultPage);
|
||||
|
||||
// 加载页面
|
||||
loadPage(defaultPage);
|
||||
|
||||
// 启动系统监控定时器
|
||||
SystemService.fetchSystemMetrics();
|
||||
setInterval(SystemService.fetchSystemMetrics, 30000);
|
||||
|
||||
// 启动训练进度自动刷新
|
||||
TrainingService.startProgressRefresh();
|
||||
|
||||
// 初始化日志
|
||||
const path = window.location.pathname;
|
||||
const pageName = path.split('/').pop().replace('.html', '') || 'main';
|
||||
webLogger.init(pageName);
|
||||
webLogger.info('页面加载完成');
|
||||
});
|
||||
723
web/js/pages/render.js
Normal file
723
web/js/pages/render.js
Normal file
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* 页面渲染模块
|
||||
* 包含各类型页面的渲染函数
|
||||
*/
|
||||
|
||||
// 渲染日志查看页面
|
||||
function renderLogViewerPage(config) {
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">${config.title}</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="SystemService.refreshLogs()" class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-refresh mr-1"></i>刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- 日志类型切换 -->
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex bg-gray-100 rounded-lg p-1">
|
||||
<button id="logTabSystem" onclick="SystemService.switchLogTab('system')" class="px-4 py-1.5 text-sm rounded-md transition-colors bg-white shadow-sm text-primary">
|
||||
系统日志
|
||||
</button>
|
||||
<button id="logTabTraining" onclick="SystemService.switchLogTab('training')" class="px-4 py-1.5 text-sm rounded-md transition-colors text-gray-600 hover:text-gray-800">
|
||||
训练日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统日志选项 -->
|
||||
<div id="systemLogOptions">
|
||||
<!-- 日期选择 -->
|
||||
<div class="flex items-center flex-wrap gap-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm text-gray-600 mr-3">选择日期:</label>
|
||||
<input type="date" id="logDatePicker" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none" onchange="SystemService.loadLogFiles()">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm text-gray-600 mr-3">自动刷新:</label>
|
||||
<select id="logRefreshInterval" onchange="SystemService.setRefreshInterval()" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="0">关闭</option>
|
||||
<option value="5">5秒</option>
|
||||
<option value="10" selected>10秒</option>
|
||||
<option value="30">30秒</option>
|
||||
<option value="60">60秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="logRefreshCountdown" class="text-sm text-gray-500 hidden">
|
||||
<i class="fa fa-clock-o mr-1"></i><span>下次刷新: <span id="countdownNumber">10</span>秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 日志类型选择 -->
|
||||
<div class="flex items-center mb-4">
|
||||
<label class="text-sm text-gray-600 mr-3">日志类型:</label>
|
||||
<select id="logTypeSelect" onchange="SystemService.loadSelectedLog()" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="">请选择日志文件</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 训练日志选项(初始隐藏) -->
|
||||
<div id="trainingLogOptions" class="hidden">
|
||||
<div class="flex items-center mb-4">
|
||||
<label class="text-sm text-gray-600 mr-3">训练日志:</label>
|
||||
<select id="trainingLogSelect" onchange="SystemService.loadSelectedTrainingLog()" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none flex-1">
|
||||
<option value="">请选择训练日志</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容显示 -->
|
||||
<div class="border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<span class="text-sm text-gray-600" id="logFileInfo">请选择日志文件</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input type="text" id="logSearchInput" placeholder="搜索日志..." oninput="SystemService.filterLogContent()" class="px-2 py-1 text-xs border border-gray-300 rounded focus:border-primary focus:outline-none">
|
||||
<span id="logMatchCount" class="text-xs text-gray-500"></span>
|
||||
<button onclick="SystemService.clearLogContent()" class="text-xs text-gray-500 hover:text-primary">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="logContent" class="p-4 text-xs font-mono bg-gray-900 text-gray-100 overflow-auto max-h-[600px]" style="white-space: pre-wrap; word-wrap: break-word;">日志内容将在这里显示...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 渲染工具卡片页面
|
||||
function renderToolsPage(config) {
|
||||
const renderToolCard = (tool, canDelete = false, isCustom = false) => `
|
||||
<div class="relative group border border-gray-200 rounded-lg p-6 cursor-pointer hover:border-primary hover:shadow-md transition-all" onclick="navigateToTool('${tool.id}', '${tool.url || ''}', ${isCustom})">
|
||||
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<i class="fa ${tool.icon} text-xl text-primary"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-800 mb-2">${tool.name}</h3>
|
||||
<p class="text-sm text-gray-500">${tool.description}</p>
|
||||
${canDelete ? `
|
||||
<button onclick="event.stopPropagation(); editCustomTool('${tool.id}')" class="absolute top-2 right-10 w-6 h-6 rounded-full bg-gray-100 hover:bg-blue-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" title="修改">
|
||||
<i class="fa fa-pencil text-gray-400 hover:text-blue-500 text-xs"></i>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); deleteCustomTool('${tool.id}')" class="absolute top-2 right-2 w-6 h-6 rounded-full bg-gray-100 hover:bg-red-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" title="删除">
|
||||
<i class="fa fa-times text-gray-400 hover:text-red-500 text-xs"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const defaultCards = config.defaultTools.map(t => renderToolCard(t, false, false)).join('');
|
||||
const customCards = config.customTools.map(t => renderToolCard(t, true, true)).join('');
|
||||
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">${config.title}</h2>
|
||||
<button onclick="showCreateToolModal()" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>添加自定义工具
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- 默认工具 -->
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">默认工具</h3>
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
${defaultCards || '<p class="text-gray-400 text-sm col-span-3">暂无默认工具</p>'}
|
||||
</div>
|
||||
|
||||
<!-- 自定义工具 -->
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">自定义工具</h3>
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
${customCards || '<p class="text-gray-400 text-sm col-span-3">暂无自定义工具,点击右上角添加</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 删除自定义工具
|
||||
function deleteCustomTool(toolId) {
|
||||
window.showConfirm('确认删除', '确定要删除这个自定义工具吗?', () => {
|
||||
const config = window.tableConfigs['data-generate'];
|
||||
config.customTools = config.customTools.filter(t => t.id !== toolId);
|
||||
localStorage.setItem('customTools', JSON.stringify(config.customTools));
|
||||
document.getElementById('page-content').innerHTML = renderToolsPage(config);
|
||||
});
|
||||
}
|
||||
|
||||
// 修改自定义工具
|
||||
function editCustomTool(toolId) {
|
||||
const config = window.tableConfigs['data-generate'];
|
||||
const tool = config.customTools.find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
localStorage.setItem('editTool', JSON.stringify(tool));
|
||||
window.location.href = 'custom-tool-create.html?edit=true';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示创建工具弹窗
|
||||
function showCreateToolModal() {
|
||||
window.location.href = 'custom-tool-create.html';
|
||||
}
|
||||
|
||||
// 跳转到工具页面
|
||||
function navigateToTool(toolId, url, isCustom = false) {
|
||||
if (isCustom && url) {
|
||||
if (url.startsWith('http')) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
} else if (toolId === 'data-generate') {
|
||||
window.location.href = 'data-generate.html';
|
||||
} else if (toolId === 'json2jsonl') {
|
||||
window.showMessage('提示', 'JSON转JSONL 工具开发中...', 'info');
|
||||
} else if (toolId === 'md-convert') {
|
||||
window.showMessage('提示', '转换Markdown 工具开发中...', 'info');
|
||||
} else {
|
||||
window.showMessage('提示', `${toolId} 功能开发中...`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染配置页面(硬件监控)
|
||||
function renderConfigPage(config, data) {
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">${config.title}</h2>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<i class="fa fa-refresh mr-2"></i>
|
||||
<span class="mr-2">刷新频率:</span>
|
||||
<select id="refreshInterval" onchange="changeRefreshRate()" class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="1000">1秒</option>
|
||||
<option value="3000">3秒</option>
|
||||
<option value="5000" selected>5秒</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- 第一行:CPU、内存、磁盘 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- CPU监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-microchip text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">CPU 使用率</h3>
|
||||
<p class="text-xs text-gray-500" id="cpuCores">4 核心</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="cpuPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="cpuBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-blue-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-4 gap-2" id="cpuCoresList">
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心1</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core1">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心2</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core2">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心3</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core3">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心4</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core4">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内存监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-database text-purple-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">内存使用</h3>
|
||||
<p class="text-xs text-gray-500" id="memoryTotal">总计: 16 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="memoryPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="memoryBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-yellow-400 to-orange-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">已用</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryUsed">0 GB</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">可用</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryAvailable">0 GB</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">缓存</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryCached">0 GB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-hdd-o text-green-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">磁盘使用</h3>
|
||||
<p class="text-xs text-gray-500" id="diskTotal">SSD 512 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="diskPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="diskBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-emerald-400 to-teal-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">已用空间</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskUsed">0 GB</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">可用空间</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskAvailable">0 GB</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">读写速度</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskSpeed">0 MB/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:GPU监控 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-microchip text-red-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">GPU监控</h3>
|
||||
<p class="text-xs text-gray-500" id="gpuCount">多GPU并行监控</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="gpuList" style="max-height: 400px; overflow-y: auto;">
|
||||
<!-- GPU卡片由initGPUList()动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:网络和系统 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 网络流量 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-cyan-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-globe text-cyan-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">网络流量</h3>
|
||||
<p class="text-xs text-gray-500">实时带宽使用</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 bg-blue-50 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fa fa-arrow-down text-blue-600 mr-2"></i>
|
||||
<span class="text-xs text-gray-600">下载速度</span>
|
||||
</div>
|
||||
<div class="text-lg font-medium text-gray-800" id="downloadSpeed">0 MB/s</div>
|
||||
</div>
|
||||
<div class="p-3 bg-green-50 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fa fa-arrow-up text-green-600 mr-2"></i>
|
||||
<span class="text-xs text-gray-600">上传速度</span>
|
||||
</div>
|
||||
<div class="text-lg font-medium text-gray-800" id="uploadSpeed">0 MB/s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between text-xs text-gray-500">
|
||||
<span>总流入: <span id="totalDownload">0 GB</span></span>
|
||||
<span>总流出: <span id="totalUpload">0 GB</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-indigo-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-info-circle text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">系统信息</h3>
|
||||
<p class="text-xs text-gray-500">服务器状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">操作系统</span>
|
||||
<span class="text-gray-800 font-medium" id="osInfo">Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">运行时间</span>
|
||||
<span class="text-gray-800 font-medium" id="uptime">0 天 0 时 0 分</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">进程数</span>
|
||||
<span class="text-gray-800 font-medium" id="processCount">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">负载均值</span>
|
||||
<span class="text-gray-800 font-medium" id="loadAvg">0.00, 0.00, 0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 刷新间隔定时器
|
||||
let refreshTimer = null;
|
||||
let currentRefreshInterval = 5000;
|
||||
|
||||
// 刷新硬件信息
|
||||
async function refreshHardwareInfo() {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/system-info`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
const data = result.data;
|
||||
|
||||
// 更新CPU
|
||||
const cpu = data.cpu || {};
|
||||
const cpuPercent = cpu.percent || 0;
|
||||
const cpuEl = document.getElementById('cpuPercent');
|
||||
if (cpuEl) {
|
||||
cpuEl.textContent = cpuPercent + '%';
|
||||
document.getElementById('cpuBar').style.width = cpuPercent + '%';
|
||||
document.getElementById('cpuCores').textContent = (cpu.cores || 0) + ' 核心';
|
||||
}
|
||||
|
||||
// 更新内存
|
||||
const mem = data.memory || {};
|
||||
const memUsed = mem.used_gb || 0;
|
||||
const memTotal = mem.total_gb || 0;
|
||||
const memPercent = mem.percent || 0;
|
||||
document.getElementById('memoryPercent').textContent = memPercent + '%';
|
||||
document.getElementById('memoryBar').style.width = memPercent + '%';
|
||||
document.getElementById('memoryUsed').textContent = memUsed + ' GB';
|
||||
document.getElementById('memoryAvailable').textContent = (mem.available_gb || 0) + ' GB';
|
||||
document.getElementById('memoryCached').textContent = (mem.cached_gb || 0) + ' GB';
|
||||
|
||||
// 更新磁盘
|
||||
const disk = data.disk || {};
|
||||
const diskUsed = disk.used_gb || 0;
|
||||
const diskTotal = disk.total_gb || 0;
|
||||
const diskPercent = disk.percent || 0;
|
||||
document.getElementById('diskPercent').textContent = diskPercent + '%';
|
||||
document.getElementById('diskBar').style.width = diskPercent + '%';
|
||||
document.getElementById('diskUsed').textContent = diskUsed + ' GB';
|
||||
document.getElementById('diskAvailable').textContent = (diskTotal - diskUsed) + ' GB';
|
||||
|
||||
// 更新网络
|
||||
const net = data.network || {};
|
||||
document.getElementById('totalDownload').textContent = (net.download_mb || 0) + ' GB';
|
||||
document.getElementById('totalUpload').textContent = (net.upload_mb || 0) + ' GB';
|
||||
|
||||
// 更新系统信息
|
||||
const sys = data.system || {};
|
||||
const uptime = sys.uptime_seconds || 0;
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const mins = Math.floor((uptime % 3600) / 60);
|
||||
document.getElementById('uptime').textContent = days + ' 天 ' + hours + ' 时 ' + mins + ' 分';
|
||||
document.getElementById('processCount').textContent = sys.process_count || 0;
|
||||
|
||||
// 更新GPU信息
|
||||
updateGPUInfo(data.gpu || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error);
|
||||
useMockData();
|
||||
}
|
||||
}
|
||||
|
||||
// 使用模拟数据
|
||||
function useMockData() {
|
||||
const cpuUsage = Math.floor(Math.random() * 30) + 20;
|
||||
document.getElementById('cpuPercent').textContent = cpuUsage + '%';
|
||||
document.getElementById('cpuBar').style.width = cpuUsage + '%';
|
||||
document.getElementById('core1').textContent = Math.floor(Math.random() * 40 + 20) + '%';
|
||||
document.getElementById('core2').textContent = Math.floor(Math.random() * 40 + 15) + '%';
|
||||
document.getElementById('core3').textContent = Math.floor(Math.random() * 40 + 25) + '%';
|
||||
document.getElementById('core4').textContent = Math.floor(Math.random() * 40 + 10) + '%';
|
||||
|
||||
const memUsed = (Math.random() * 4 + 6).toFixed(1);
|
||||
const memTotal = 16;
|
||||
const memPercent = Math.floor((memUsed / memTotal) * 100);
|
||||
document.getElementById('memoryPercent').textContent = memPercent + '%';
|
||||
document.getElementById('memoryBar').style.width = memPercent + '%';
|
||||
document.getElementById('memoryUsed').textContent = memUsed + ' GB';
|
||||
document.getElementById('memoryAvailable').textContent = (memTotal - memUsed).toFixed(1) + ' GB';
|
||||
document.getElementById('memoryCached').textContent = (Math.random() * 3 + 1).toFixed(1) + ' GB';
|
||||
|
||||
const diskUsed = Math.floor(Math.random() * 100 + 150);
|
||||
const diskTotal = 512;
|
||||
const diskPercent = Math.floor((diskUsed / diskTotal) * 100);
|
||||
document.getElementById('diskPercent').textContent = diskPercent + '%';
|
||||
document.getElementById('diskBar').style.width = diskPercent + '%';
|
||||
document.getElementById('diskUsed').textContent = diskUsed + ' GB';
|
||||
document.getElementById('diskAvailable').textContent = (diskTotal - diskUsed) + ' GB';
|
||||
document.getElementById('diskSpeed').textContent = (Math.random() * 500 + 100).toFixed(0) + ' MB/s';
|
||||
|
||||
updateGPUInfo();
|
||||
|
||||
document.getElementById('downloadSpeed').textContent = (Math.random() * 100 + 10).toFixed(1) + ' MB/s';
|
||||
document.getElementById('uploadSpeed').textContent = (Math.random() * 50 + 5).toFixed(1) + ' MB/s';
|
||||
document.getElementById('totalDownload').textContent = (Math.random() * 500 + 100).toFixed(1) + ' GB';
|
||||
document.getElementById('totalUpload').textContent = (Math.random() * 200 + 50).toFixed(1) + ' GB';
|
||||
|
||||
const days = Math.floor(Math.random() * 30);
|
||||
const hours = Math.floor(Math.random() * 24);
|
||||
const mins = Math.floor(Math.random() * 60);
|
||||
document.getElementById('uptime').textContent = days + ' 天 ' + hours + ' 时 ' + mins + ' 分';
|
||||
document.getElementById('processCount').textContent = Math.floor(Math.random() * 200 + 100);
|
||||
document.getElementById('loadAvg').textContent = (Math.random() * 2).toFixed(2) + ', ' + (Math.random() * 1.5).toFixed(2) + ', ' + (Math.random() * 1).toFixed(2);
|
||||
}
|
||||
|
||||
// GPU配置
|
||||
const GPU_COUNT = 4;
|
||||
const gpuConfigs = [
|
||||
{ name: 'NVIDIA RTX 3090', memory: 24 },
|
||||
{ name: 'NVIDIA RTX 4090', memory: 24 },
|
||||
{ name: 'NVIDIA A100', memory: 80 },
|
||||
{ name: 'NVIDIA V100', memory: 32 },
|
||||
{ name: 'NVIDIA T4', memory: 16 },
|
||||
{ name: 'NVIDIA L40S', memory: 48 },
|
||||
{ name: 'NVIDIA H100', memory: 80 },
|
||||
{ name: 'NVIDIA RTX 4080', memory: 16 }
|
||||
];
|
||||
|
||||
// 初始化GPU列表
|
||||
async function initGPUList() {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/system-info`);
|
||||
const result = await response.json();
|
||||
const gpuData = (result.data && result.data.gpu) || [];
|
||||
updateGPUInfo(gpuData);
|
||||
} catch (error) {
|
||||
console.error('初始化GPU列表失败:', error);
|
||||
useMockGPUData();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新GPU信息
|
||||
function updateGPUInfo(gpuData) {
|
||||
if (gpuData && gpuData.length > 0) {
|
||||
const gpuCount = gpuData.length;
|
||||
document.getElementById('gpuCount').textContent = `检测到 ${gpuCount} 块 GPU`;
|
||||
|
||||
let totalUsedMemory = 0;
|
||||
let totalMemory = 0;
|
||||
|
||||
const gpuList = document.getElementById('gpuList');
|
||||
if (gpuList) {
|
||||
let gpuCardsHTML = '';
|
||||
for (let i = 0; i < gpuCount; i++) {
|
||||
const gpu = gpuData[i];
|
||||
totalUsedMemory += gpu.memory_used_gb;
|
||||
totalMemory += gpu.memory_total_gb;
|
||||
|
||||
gpuCardsHTML += `
|
||||
<div class="border border-gray-200 rounded-lg p-2 bg-gradient-to-br from-gray-50 to-gray-100" id="gpuCard${i}">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="w-6 h-6 rounded bg-red-100 flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fa fa-microchip text-red-600 text-xs"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-800 truncate" id="gpuName${i}" title="${gpu.name}">${gpu.name}</div>
|
||||
<div class="text-[10px] text-gray-400">PCIe</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0 ml-2">
|
||||
<span class="text-sm font-bold text-gray-800" id="gpuPercent${i}">${gpu.gpu_percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-1.5 bg-gray-200 rounded-full overflow-hidden mb-2">
|
||||
<div id="gpuBar${i}" class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 via-yellow-400 to-red-400 transition-all duration-500" style="width: ${gpu.gpu_percent}%"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">显存</div>
|
||||
<div class="font-medium text-gray-700" id="gpuMem${i}">${gpu.memory_used_gb}/${gpu.memory_total_gb} GB</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">温度</div>
|
||||
<div class="font-medium ${gpu.temperature >= 80 ? 'text-red-600' : gpu.temperature >= 70 ? 'text-yellow-600' : 'text-gray-800'}" id="gpuTemp${i}">${gpu.temperature}°C</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">功耗</div>
|
||||
<div class="font-medium text-gray-700" id="gpuPower${i}">${gpu.power_w} W</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">Fan</div>
|
||||
<div class="font-medium text-gray-700" id="gpuFan${i}">${gpu.fan_speed || 0}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 grid grid-cols-2 gap-1 text-center text-[9px] text-gray-400">
|
||||
<div>Clock: <span id="gpuClock${i}">${gpu.clock_mhz || 0} MHz</span></div>
|
||||
<div>Driver: <span id="gpuDriver${i}">${gpu.driver_version || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// 如果GPU数量不足4个,补充显示总显存
|
||||
if (gpuCount < 4) {
|
||||
gpuCardsHTML += `
|
||||
<div class="border border-gray-200 rounded-lg p-2 bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 rounded bg-gray-200 flex items-center justify-center mr-2">
|
||||
<i class="fa fa-server text-gray-600 text-xs"></i>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-600">总显存使用</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-bold text-gray-800" id="gpuTotalMemory">${totalUsedMemory}/${totalMemory} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
gpuList.innerHTML = gpuCardsHTML;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
useMockGPUData();
|
||||
}
|
||||
|
||||
// 使用模拟GPU数据
|
||||
function useMockGPUData() {
|
||||
const gpuCount = Math.min(GPU_COUNT, 8);
|
||||
let totalUsedMemory = 0;
|
||||
let totalMemory = 0;
|
||||
|
||||
const gpuList = document.getElementById('gpuList');
|
||||
if (gpuList) {
|
||||
let gpuCardsHTML = '';
|
||||
for (let i = 0; i < gpuCount; i++) {
|
||||
const config = gpuConfigs[i % gpuConfigs.length];
|
||||
const gpuUsage = Math.floor(Math.random() * 60 + 20);
|
||||
const memUsed = (Math.random() * config.memory * 0.7 + config.memory * 0.1).toFixed(1);
|
||||
const temp = Math.floor(Math.random() * 30 + 40);
|
||||
const power = Math.floor(Math.random() * 150 + 100);
|
||||
const fan = Math.floor(gpuUsage + Math.random() * 10);
|
||||
|
||||
totalUsedMemory += parseFloat(memUsed);
|
||||
totalMemory += config.memory;
|
||||
|
||||
gpuCardsHTML += `
|
||||
<div class="border border-gray-200 rounded-lg p-2 bg-gradient-to-br from-gray-50 to-gray-100" id="gpuCard${i}">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="w-6 h-6 rounded bg-red-100 flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fa fa-microchip text-red-600 text-xs"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-800 truncate" id="gpuName${i}" title="${config.name}">${config.name}</div>
|
||||
<div class="text-[10px] text-gray-400">PCIe ${Math.floor(Math.random() * 4 + 1)}:00.0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0 ml-2">
|
||||
<span class="text-sm font-bold text-gray-800" id="gpuPercent${i}">${gpuUsage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-1.5 bg-gray-200 rounded-full overflow-hidden mb-2">
|
||||
<div id="gpuBar${i}" class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 via-yellow-400 to-red-400 transition-all duration-500" style="width: ${gpuUsage}%"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">显存</div>
|
||||
<div class="font-medium text-gray-700" id="gpuMem${i}">${parseFloat(memUsed).toFixed(1)}/${config.memory} GB</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">温度</div>
|
||||
<div class="font-medium ${temp >= 80 ? 'text-red-600' : temp >= 70 ? 'text-yellow-600' : 'text-gray-800'}" id="gpuTemp${i}">${temp}°C</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">功耗</div>
|
||||
<div class="font-medium text-gray-700" id="gpuPower${i}">${power} W</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">Fan</div>
|
||||
<div class="font-medium text-gray-700" id="gpuFan${i}">${fan}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
gpuList.innerHTML = gpuCardsHTML;
|
||||
document.getElementById('gpuCount').textContent = `检测到 ${gpuCount} 块 GPU`;
|
||||
}
|
||||
|
||||
const gpuTotalMem = document.getElementById('gpuTotalMemory');
|
||||
if (gpuTotalMem) {
|
||||
gpuTotalMem.textContent = `${totalUsedMemory.toFixed(1)}/${totalMemory} GB`;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动硬件监控自动刷新
|
||||
function startRefreshTimer() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
refreshTimer = setInterval(refreshHardwareInfo, currentRefreshInterval);
|
||||
}
|
||||
|
||||
// 改变刷新频率
|
||||
function changeRefreshRate() {
|
||||
const select = document.getElementById('refreshInterval');
|
||||
currentRefreshInterval = parseInt(select.value);
|
||||
startRefreshTimer();
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
function saveConfig() {
|
||||
window.showMessage('提示', '配置保存功能开发中...', 'info');
|
||||
}
|
||||
|
||||
// 导出页面渲染模块
|
||||
window.PageRenderer = {
|
||||
renderLogViewerPage,
|
||||
renderToolsPage,
|
||||
renderConfigPage,
|
||||
refreshHardwareInfo,
|
||||
useMockData,
|
||||
initGPUList,
|
||||
updateGPUInfo,
|
||||
useMockGPUData,
|
||||
startRefreshTimer,
|
||||
changeRefreshRate,
|
||||
saveConfig
|
||||
};
|
||||
392
web/js/services/system.js
Normal file
392
web/js/services/system.js
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 系统监控服务
|
||||
* 处理系统性能指标获取和展示
|
||||
*/
|
||||
|
||||
// 日志自动刷新相关变量
|
||||
let logRefreshTimer = null;
|
||||
let logCountdownTimer = null;
|
||||
let logCurrentInterval = 10;
|
||||
let logFullContent = '';
|
||||
|
||||
// 设置自动刷新间隔
|
||||
function setRefreshInterval() {
|
||||
const select = document.getElementById('logRefreshInterval');
|
||||
const countdownEl = document.getElementById('logRefreshCountdown');
|
||||
const secondsEl = document.getElementById('countdownNumber');
|
||||
|
||||
if (!select) return;
|
||||
|
||||
logCurrentInterval = parseInt(select.value) || 10;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (logRefreshTimer) {
|
||||
clearInterval(logRefreshTimer);
|
||||
logRefreshTimer = null;
|
||||
}
|
||||
if (logCountdownTimer) {
|
||||
clearInterval(logCountdownTimer);
|
||||
logCountdownTimer = null;
|
||||
}
|
||||
|
||||
// 如果选择关闭,不显示倒计时
|
||||
if (select.value === '0') {
|
||||
countdownEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示倒计时
|
||||
countdownEl.classList.remove('hidden');
|
||||
secondsEl.textContent = logCurrentInterval;
|
||||
|
||||
// 启动倒计时
|
||||
let countdown = logCurrentInterval;
|
||||
logCountdownTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
countdown = logCurrentInterval;
|
||||
}
|
||||
secondsEl.textContent = countdown;
|
||||
}, 1000);
|
||||
|
||||
// 启动自动刷新
|
||||
logRefreshTimer = setInterval(() => {
|
||||
if (typeof refreshLogs === 'function') {
|
||||
refreshLogs();
|
||||
}
|
||||
}, logCurrentInterval * 1000);
|
||||
}
|
||||
|
||||
// 获取系统性能监控数据
|
||||
async function fetchSystemMetrics() {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/health`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
const data = result.data;
|
||||
// 更新CPU使用率
|
||||
const cpuEl = document.getElementById('cpuUsage');
|
||||
if (cpuEl && data.cpu_percent !== undefined) {
|
||||
cpuEl.textContent = data.cpu_percent;
|
||||
cpuEl.className = data.cpu_percent > 80 ? 'text-red-500 font-medium' : '';
|
||||
}
|
||||
// 更新内存使用率
|
||||
const memEl = document.getElementById('memUsage');
|
||||
if (memEl && data.memory_percent !== undefined) {
|
||||
memEl.textContent = data.memory_percent;
|
||||
memEl.className = data.memory_percent > 80 ? 'text-red-500 font-medium' : '';
|
||||
}
|
||||
// 更新磁盘使用率
|
||||
const diskEl = document.getElementById('diskUsage');
|
||||
if (diskEl && data.disk_percent !== undefined) {
|
||||
diskEl.textContent = data.disk_percent;
|
||||
diskEl.className = data.disk_percent > 80 ? 'text-red-500 font-medium' : '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统监控数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止日志自动刷新(离开页面时调用)
|
||||
function stopLogAutoRefresh() {
|
||||
if (logRefreshTimer) {
|
||||
clearInterval(logRefreshTimer);
|
||||
logRefreshTimer = null;
|
||||
}
|
||||
if (logCountdownTimer) {
|
||||
clearInterval(logCountdownTimer);
|
||||
logCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新日志
|
||||
function refreshLogs() {
|
||||
if (typeof switchLogTab === 'function') {
|
||||
const currentLogTab = window.currentLogTab || 'system';
|
||||
if (currentLogTab === 'system') {
|
||||
if (typeof loadLogFiles === 'function' && document.getElementById('logTypeSelect')?.value) {
|
||||
loadSelectedLog();
|
||||
} else {
|
||||
loadLogFiles();
|
||||
}
|
||||
} else {
|
||||
loadTrainingLogFiles();
|
||||
}
|
||||
} else {
|
||||
loadLogFiles();
|
||||
}
|
||||
// 重置倒计时
|
||||
const select = document.getElementById('logRefreshInterval');
|
||||
const secondsEl = document.getElementById('countdownNumber');
|
||||
if (select && select.value !== '0' && secondsEl) {
|
||||
secondsEl.textContent = logCurrentInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到日志底部
|
||||
function scrollToLogBottom() {
|
||||
const logContent = document.getElementById('logContent');
|
||||
if (logContent) {
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// 当前日志类型:system 或 training
|
||||
window.currentLogTab = 'system';
|
||||
|
||||
// 切换日志类型标签
|
||||
function switchLogTab(tab) {
|
||||
window.currentLogTab = tab;
|
||||
const systemTab = document.getElementById('logTabSystem');
|
||||
const trainingTab = document.getElementById('logTabTraining');
|
||||
const systemOptions = document.getElementById('systemLogOptions');
|
||||
const trainingOptions = document.getElementById('trainingLogOptions');
|
||||
|
||||
if (tab === 'system') {
|
||||
systemTab.className = 'px-4 py-1.5 text-sm rounded-md transition-colors bg-white shadow-sm text-primary';
|
||||
trainingTab.className = 'px-4 py-1.5 text-sm rounded-md transition-colors text-gray-600 hover:text-gray-800';
|
||||
systemOptions.classList.remove('hidden');
|
||||
trainingOptions.classList.add('hidden');
|
||||
loadLogFiles();
|
||||
} else {
|
||||
trainingTab.className = 'px-4 py-1.5 text-sm rounded-md transition-colors bg-white shadow-sm text-primary';
|
||||
systemTab.className = 'px-4 py-1.5 text-sm rounded-md transition-colors text-gray-600 hover:text-gray-800';
|
||||
trainingOptions.classList.remove('hidden');
|
||||
systemOptions.classList.add('hidden');
|
||||
loadTrainingLogFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志查看器
|
||||
function initLogViewer() {
|
||||
const datePicker = document.getElementById('logDatePicker');
|
||||
if (datePicker) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
datePicker.value = today;
|
||||
}
|
||||
// 加载默认日志类型
|
||||
loadLogFiles();
|
||||
// 启动自动刷新
|
||||
setRefreshInterval();
|
||||
}
|
||||
|
||||
// 加载训练日志文件列表
|
||||
async function loadTrainingLogFiles() {
|
||||
const logSelect = document.getElementById('trainingLogSelect');
|
||||
if (!logSelect) return;
|
||||
|
||||
logSelect.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/training-log-files`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
logSelect.innerHTML = '<option value="">请选择训练日志</option>';
|
||||
result.data.forEach(log => {
|
||||
const option = document.createElement('option');
|
||||
option.value = log.file;
|
||||
option.textContent = `${log.name} (PID: ${log.pid}, ${log.date}, ${log.size})`;
|
||||
logSelect.appendChild(option);
|
||||
});
|
||||
// 如果有日志文件,自动加载第一个
|
||||
if (result.data.length > 0) {
|
||||
logSelect.value = result.data[0].file;
|
||||
loadSelectedTrainingLog();
|
||||
} else {
|
||||
document.getElementById('logContent').textContent = '暂无训练日志';
|
||||
document.getElementById('logFileInfo').textContent = '无训练日志';
|
||||
}
|
||||
} else {
|
||||
logSelect.innerHTML = '<option value="">暂无训练日志</option>';
|
||||
document.getElementById('logContent').textContent = '暂无训练日志';
|
||||
document.getElementById('logFileInfo').textContent = '无训练日志';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载训练日志列表失败:', error);
|
||||
logSelect.innerHTML = '<option value="">加载失败</option>';
|
||||
document.getElementById('logContent').textContent = '加载训练日志列表失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载选中的训练日志
|
||||
async function loadSelectedTrainingLog() {
|
||||
const logSelect = document.getElementById('trainingLogSelect');
|
||||
const logFile = logSelect.value;
|
||||
const logContent = document.getElementById('logContent');
|
||||
const logFileInfo = document.getElementById('logFileInfo');
|
||||
|
||||
if (!logFile) {
|
||||
logContent.textContent = '请选择训练日志';
|
||||
logFileInfo.textContent = '无训练日志';
|
||||
return;
|
||||
}
|
||||
|
||||
logContent.textContent = '加载中...';
|
||||
logFileInfo.textContent = '加载中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/training-log-content?file=${encodeURIComponent(logFile)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
logFullContent = result.data.content || '';
|
||||
logContent.textContent = logFullContent || '(空日志)';
|
||||
logFileInfo.textContent = result.data.file + ' (' + result.data.size + ')';
|
||||
// 清空搜索
|
||||
document.getElementById('logSearchInput').value = '';
|
||||
document.getElementById('logMatchCount').textContent = '';
|
||||
// 滚动到底部
|
||||
scrollToLogBottom();
|
||||
} else {
|
||||
logContent.textContent = '加载失败: ' + (result.message || '未知错误');
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载训练日志内容失败:', error);
|
||||
logContent.textContent = '加载失败: ' + error.message;
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志文件列表
|
||||
async function loadLogFiles() {
|
||||
const datePicker = document.getElementById('logDatePicker');
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
const selectedDate = datePicker ? datePicker.value : new Date().toISOString().split('T')[0];
|
||||
|
||||
if (!logTypeSelect) return;
|
||||
logTypeSelect.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/log-files?date=${selectedDate}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
logTypeSelect.innerHTML = '<option value="">请选择日志文件</option>';
|
||||
result.data.forEach(log => {
|
||||
const option = document.createElement('option');
|
||||
option.value = log.file;
|
||||
option.textContent = log.name + ' (' + log.size + ')';
|
||||
logTypeSelect.appendChild(option);
|
||||
});
|
||||
// 如果有日志文件,自动加载第一个
|
||||
if (result.data.length > 0) {
|
||||
logTypeSelect.value = result.data[0].file;
|
||||
loadSelectedLog();
|
||||
} else {
|
||||
logTypeSelect.innerHTML = '<option value="">暂无日志文件</option>';
|
||||
document.getElementById('logContent').textContent = '该日期暂无日志文件';
|
||||
document.getElementById('logFileInfo').textContent = '无日志文件';
|
||||
}
|
||||
} else {
|
||||
logTypeSelect.innerHTML = '<option value="">暂无日志文件</option>';
|
||||
document.getElementById('logContent').textContent = '该日期暂无日志文件';
|
||||
document.getElementById('logFileInfo').textContent = '无日志文件';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日志文件列表失败:', error);
|
||||
logTypeSelect.innerHTML = '<option value="">加载失败</option>';
|
||||
document.getElementById('logContent').textContent = '加载日志文件列表失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载选中的日志
|
||||
async function loadSelectedLog() {
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
const logFile = logTypeSelect.value;
|
||||
const logContent = document.getElementById('logContent');
|
||||
const logFileInfo = document.getElementById('logFileInfo');
|
||||
|
||||
if (!logFile) {
|
||||
logContent.textContent = '请选择日志文件';
|
||||
logFileInfo.textContent = '无日志文件';
|
||||
return;
|
||||
}
|
||||
|
||||
logContent.textContent = '加载中...';
|
||||
logFileInfo.textContent = '加载中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/log-content?file=${encodeURIComponent(logFile)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
logFullContent = result.data.content || '';
|
||||
logContent.textContent = logFullContent || '(空日志)';
|
||||
logFileInfo.textContent = result.data.file + ' (' + result.data.size + ')';
|
||||
// 清空搜索框和匹配计数
|
||||
document.getElementById('logSearchInput').value = '';
|
||||
document.getElementById('logMatchCount').textContent = '';
|
||||
// 滚动到最底部
|
||||
scrollToLogBottom();
|
||||
} else {
|
||||
logContent.textContent = '加载失败: ' + (result.message || '未知错误');
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日志内容失败:', error);
|
||||
logContent.textContent = '加载日志内容失败: ' + error.message;
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤日志内容
|
||||
function filterLogContent() {
|
||||
const searchInput = document.getElementById('logSearchInput');
|
||||
const matchCount = document.getElementById('logMatchCount');
|
||||
const logContent = document.getElementById('logContent');
|
||||
|
||||
if (!searchInput || !matchCount || !logContent) return;
|
||||
|
||||
const keyword = searchInput.value.trim();
|
||||
|
||||
if (!keyword) {
|
||||
logContent.textContent = logFullContent || '(空日志)';
|
||||
matchCount.textContent = '';
|
||||
scrollToLogBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = logFullContent.split('\n');
|
||||
const matchingLines = lines.filter(line => line.toLowerCase().includes(keyword.toLowerCase()));
|
||||
|
||||
if (matchingLines.length > 0) {
|
||||
logContent.textContent = matchingLines.join('\n');
|
||||
matchCount.textContent = `(${matchingLines.length}条匹配)`;
|
||||
// 滚动到最底部查看最新匹配
|
||||
scrollToLogBottom();
|
||||
} else {
|
||||
logContent.textContent = '未找到匹配的日志';
|
||||
matchCount.textContent = '(0条匹配)';
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志内容显示
|
||||
function clearLogContent() {
|
||||
document.getElementById('logContent').textContent = '日志内容将在这里显示...';
|
||||
document.getElementById('logFileInfo').textContent = '请选择日志文件';
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
if (logTypeSelect) logTypeSelect.value = '';
|
||||
document.getElementById('logSearchInput').value = '';
|
||||
document.getElementById('logMatchCount').textContent = '';
|
||||
logFullContent = '';
|
||||
}
|
||||
|
||||
// 导出服务函数
|
||||
window.SystemService = {
|
||||
fetchSystemMetrics,
|
||||
setRefreshInterval,
|
||||
stopLogAutoRefresh,
|
||||
refreshLogs,
|
||||
scrollToLogBottom,
|
||||
switchLogTab,
|
||||
initLogViewer,
|
||||
loadTrainingLogFiles,
|
||||
loadSelectedTrainingLog,
|
||||
loadLogFiles,
|
||||
loadSelectedLog,
|
||||
filterLogContent,
|
||||
clearLogContent
|
||||
};
|
||||
201
web/js/services/training.js
Normal file
201
web/js/services/training.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 训练服务模块
|
||||
* 处理训练任务相关的操作和进度跟踪
|
||||
*/
|
||||
|
||||
// 训练进度缓存
|
||||
window.trainingProgressCache = window.trainingProgressCache || {};
|
||||
// 使用 window 避免重复声明
|
||||
if (typeof window._progressRefreshTimer === 'undefined') {
|
||||
window._progressRefreshTimer = null;
|
||||
}
|
||||
|
||||
// 渲染训练进度
|
||||
function renderTrainingProgress(val, row) {
|
||||
const progressData = window.trainingProgressCache[row.id];
|
||||
if (progressData && progressData.status === 'running') {
|
||||
if (progressData.progress > 0) {
|
||||
return `
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-primary">${progressData.progress}%</span>
|
||||
<span class="text-xs text-gray-500">${progressData.step || ''} ${progressData.speed || ''}</span>
|
||||
<span class="text-xs text-gray-400">ETA: ${progressData.eta || '--:--'}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
return `${val || 0}%`;
|
||||
}
|
||||
|
||||
// 刷新训练进度
|
||||
async function refreshTrainingProgress() {
|
||||
if (typeof window.currentPage !== 'string' || window.currentPage !== 'fine-tune') return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/fine-tune`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
// 刷新运行中或已完成的任务(有进度信息)
|
||||
const activeTasks = result.data.filter(task =>
|
||||
task.status === 'running' || task.status === 'pending'
|
||||
);
|
||||
|
||||
for (const task of activeTasks) {
|
||||
try {
|
||||
// 并行获取进度和PID状态
|
||||
const [progressResponse, statusResponse] = await Promise.all([
|
||||
fetch(`${window.API_BASE}/fine-tune/progress/${task.id}`),
|
||||
fetch(`${window.API_BASE}/fine-tune/${task.id}`)
|
||||
]);
|
||||
const progressResult = await progressResponse.json();
|
||||
const statusResult = await statusResponse.json();
|
||||
|
||||
if (progressResult.code === 0 && progressResult.data) {
|
||||
window.trainingProgressCache[task.id] = progressResult.data;
|
||||
}
|
||||
|
||||
// 如果状态已改变(PID已结束),更新表格中的状态显示
|
||||
if (statusResult.code === 0 && statusResult.data) {
|
||||
const actualStatus = statusResult.data.status;
|
||||
if (task.status !== actualStatus) {
|
||||
// 找到对应的行并更新状态
|
||||
const row = document.querySelector(`tr[data-id="${task.id}"]`);
|
||||
if (row) {
|
||||
const statusCell = row.querySelector('td:nth-child(3)');
|
||||
if (statusCell) {
|
||||
statusCell.innerHTML = `<span class="px-2 py-1 rounded text-xs ${actualStatus === 'running' ? 'bg-green-100 text-green-700' : actualStatus === 'failed' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}">${actualStatus}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`获取任务 ${task.id} 信息失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('刷新训练进度失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并更新任务状态(用于 fine-tune 页面)
|
||||
async function checkAndUpdateTaskStatus() {
|
||||
if (typeof window.currentPage !== 'string' || window.currentPage !== 'fine-tune') return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/fine-tune`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
// 获取所有 running 状态的任务
|
||||
const runningTasks = result.data.filter(task => task.status === 'running');
|
||||
|
||||
for (const task of runningTasks) {
|
||||
try {
|
||||
// 调用 status API 获取实际状态(会检查 PID)
|
||||
const statusResponse = await fetch(`${window.API_BASE}/fine-tune/${task.id}`);
|
||||
const statusResult = await statusResponse.json();
|
||||
|
||||
if (statusResult.code === 0 && statusResult.data) {
|
||||
const actualStatus = statusResult.data.status;
|
||||
// 如果实际状态不是 running,更新表格显示
|
||||
if (actualStatus !== 'running') {
|
||||
const row = document.querySelector(`tr[data-id="${task.id}"]`);
|
||||
if (row) {
|
||||
const statusCell = row.querySelector('td:nth-child(3)');
|
||||
if (statusCell) {
|
||||
const statusClass = actualStatus === 'failed'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700';
|
||||
statusCell.innerHTML = `<span class="px-2 py-1 rounded text-xs ${statusClass}">${actualStatus}</span>`;
|
||||
console.log(`[Status] 任务 ${task.id} 状态已更新: running -> ${actualStatus}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`检查任务 ${task.id} 状态失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查任务状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动训练进度自动刷新
|
||||
function startProgressRefresh() {
|
||||
stopProgressRefresh();
|
||||
window._progressRefreshTimer = setInterval(() => {
|
||||
refreshTrainingProgress();
|
||||
checkAndUpdateTaskStatus();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 停止训练进度刷新
|
||||
function stopProgressRefresh() {
|
||||
if (window._progressRefreshTimer) {
|
||||
clearInterval(window._progressRefreshTimer);
|
||||
window._progressRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 停止训练任务
|
||||
async function stopItem(taskId) {
|
||||
window.showConfirm('确认停止', '确定要停止这个训练任务吗?进程将被终止。', async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE}/fine-tune/stop/${taskId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
window.showMessage('成功', '训练任务已停止', 'success');
|
||||
// 刷新当前页面
|
||||
const activeLink = document.querySelector('.nav-link.sidebar-item-active');
|
||||
if (activeLink && typeof window.loadPage === 'function') {
|
||||
window.loadPage(activeLink.dataset.page);
|
||||
}
|
||||
} else {
|
||||
window.showMessage('错误', result.message || '停止失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
window.showMessage('错误', '停止失败: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 查看训练日志 - 跳转到日志页面
|
||||
async function viewTrainingLog(taskId, taskName) {
|
||||
window.loadPage('logs');
|
||||
}
|
||||
|
||||
// 查看调优任务日志 - 跳转到training-log.html页面
|
||||
function viewFineTuneLogs(taskId, taskName) {
|
||||
// 保存 taskId 到 sessionStorage
|
||||
sessionStorage.setItem('trainingLogTaskId', taskId.toString());
|
||||
sessionStorage.setItem('trainingLogTaskName', taskName);
|
||||
// 跳转到日志页面
|
||||
window.navigateToPage('training-log');
|
||||
}
|
||||
|
||||
// 跳转到训练日志二级页面
|
||||
function navigateToTrainingLog(taskId) {
|
||||
// 传递 taskId 到 sessionStorage
|
||||
sessionStorage.setItem('trainingLogTaskId', taskId.toString());
|
||||
// 跳转到日志页面
|
||||
window.navigateToPage('training-log');
|
||||
}
|
||||
|
||||
// 导出训练服务
|
||||
window.TrainingService = {
|
||||
renderTrainingProgress,
|
||||
refreshTrainingProgress,
|
||||
checkAndUpdateTaskStatus,
|
||||
startProgressRefresh,
|
||||
stopProgressRefresh,
|
||||
stopItem,
|
||||
viewTrainingLog,
|
||||
viewFineTuneLogs,
|
||||
navigateToTrainingLog
|
||||
};
|
||||
181
web/js/utils.js
Normal file
181
web/js/utils.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 工具函数模块
|
||||
* 包含弹窗、消息提示等通用功能
|
||||
*/
|
||||
|
||||
// ============ 自定义消息弹窗 ============
|
||||
window.showMessage = function(title, message, type = 'info', onConfirm) {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
||||
const modalConfirmBtn2 = document.getElementById('modalConfirmBtn2');
|
||||
const modalBtnGroup = document.getElementById('modalBtnGroup');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
// 设置标题
|
||||
modalTitle.textContent = title;
|
||||
modalTitle.className = 'text-lg font-medium text-gray-800 mb-2';
|
||||
|
||||
// 设置消息
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
// 设置图标和按钮颜色
|
||||
if (type === 'success') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center"><i class="fa fa-check text-xl text-green-600"></i></div>';
|
||||
} else if (type === 'error') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center"><i class="fa fa-times text-xl text-red-600"></i></div>';
|
||||
} else if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-info text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
// 单按钮模式
|
||||
modalBtnGroup.classList.add('hidden');
|
||||
modalSingleBtnGroup.classList.remove('hidden');
|
||||
const confirmBtn = modalConfirmBtn2;
|
||||
if (type === 'error') {
|
||||
confirmBtn.className = 'px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors';
|
||||
} else {
|
||||
confirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
}
|
||||
|
||||
// 显示弹窗
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 保存回调到按钮属性
|
||||
confirmBtn._onConfirm = onConfirm;
|
||||
|
||||
// 使用 function 而不是箭头函数
|
||||
confirmBtn.onclick = function() {
|
||||
closeModal();
|
||||
const callback = this._onConfirm;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
this._onConfirm = null;
|
||||
};
|
||||
};
|
||||
|
||||
// 关闭消息弹窗
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('customModal');
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// 确认弹窗(两个按钮)
|
||||
window.showConfirm = function(title, message, onConfirm, onCancel, type = 'info') {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
const modalBtnGroup = document.getElementById('modalBtnGroup');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
if (!modalConfirmBtn) {
|
||||
console.error('modalConfirmBtn not found');
|
||||
return;
|
||||
}
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
// 根据类型设置图标
|
||||
if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-question text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
// 显示双按钮组,隐藏单按钮组
|
||||
modalBtnGroup.classList.remove('hidden');
|
||||
modalSingleBtnGroup.classList.add('hidden');
|
||||
modalConfirmBtn.textContent = '确定';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 保存回调到按钮属性
|
||||
modalConfirmBtn._onConfirm = onConfirm;
|
||||
modalConfirmBtn._onCancel = onCancel;
|
||||
|
||||
// 使用 function 而不是箭头函数,确保 this 指向正确
|
||||
modalConfirmBtn.onclick = function() {
|
||||
closeModal();
|
||||
const callback = this._onConfirm;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
this._onConfirm = null;
|
||||
this._onCancel = null;
|
||||
};
|
||||
|
||||
modalCancelBtn.onclick = function() {
|
||||
closeModal();
|
||||
const callback = this._onCancel || modalConfirmBtn._onCancel;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
modalConfirmBtn._onConfirm = null;
|
||||
modalConfirmBtn._onCancel = null;
|
||||
};
|
||||
};
|
||||
|
||||
// ============ Web日志系统 ============
|
||||
const webLogger = {
|
||||
_currentPage: 'main',
|
||||
|
||||
// 初始化当前页面名称
|
||||
init: function(pageName) {
|
||||
this._currentPage = pageName || 'unknown';
|
||||
},
|
||||
|
||||
// 发送日志到服务器
|
||||
_sendLog: async function(level, message) {
|
||||
try {
|
||||
await fetch(`${window.API_BASE}/web-log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
level: level,
|
||||
message: message,
|
||||
page: this._currentPage,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
// 发送失败时只记录到控制台
|
||||
console.warn('日志发送失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
info: function(message) {
|
||||
console.log(`[INFO] ${message}`);
|
||||
this._sendLog('info', message);
|
||||
},
|
||||
|
||||
error: function(message) {
|
||||
console.error(`[ERROR] ${message}`);
|
||||
this._sendLog('error', message);
|
||||
},
|
||||
|
||||
warning: function(message) {
|
||||
console.warn(`[WARNING] ${message}`);
|
||||
this._sendLog('warning', message);
|
||||
},
|
||||
|
||||
debug: function(message) {
|
||||
console.debug(`[DEBUG] ${message}`);
|
||||
this._sendLog('debug', message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出 webLogger 到全局
|
||||
window.webLogger = webLogger;
|
||||
83
web/pages/components/sidebar.html
Normal file
83
web/pages/components/sidebar.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!-- 共享侧边栏组件 -->
|
||||
<!-- 使用方法:在页面中添加 <div id="sidebar-container"></div> 并引入 sidebar-loader.js -->
|
||||
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<!-- 平台LOGO区域 -->
|
||||
<div class="pt-5 pb-3 border-b border-[#001529]/30 flex items-center justify-center pl-2">
|
||||
<img id="sidebar-logo" src="../assets/logo/logo.png" alt="Logo" class="w-8 h-8 object-contain mr-2">
|
||||
<span class="text-white font-medium text-base">远光软件微调平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航主区域 -->
|
||||
<nav class="flex-1 overflow-y-auto py-2 relative">
|
||||
<!-- 滑块指示器 -->
|
||||
<div class="sidebar-slider" id="sidebar-slider"></div>
|
||||
|
||||
<!-- 第一分区:模型服务 -->
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=fine-tune" data-page="fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cogs w-5 text-center"></i>
|
||||
<span class="ml-2">模型调优</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=model-eval" data-page="model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-line-chart w-5 text-center"></i>
|
||||
<span class="ml-2">模型评测</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=model-compare" data-page="model-compare" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-server w-5 text-center"></i>
|
||||
<span class="ml-2">模型对比</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 第二分区:资源管理 -->
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=model-manage" data-page="model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cube w-5 text-center"></i>
|
||||
<span class="ml-2">模型管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=dataset-manage" data-page="dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">数据集管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="tools.html" data-page="tools" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-wrench w-5 text-center"></i>
|
||||
<span class="ml-2">其他工具</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 第三分区:系统设置 -->
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="hardware.html" data-page="hardware" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-bar-chart w-5 text-center"></i>
|
||||
<span class="ml-2">平台性能</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="logs.html" data-page="logs" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">查看日志</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 底部信息区域 -->
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#bfcbd9]">版本 v1.0.0</span>
|
||||
<i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -5,19 +5,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>添加自定义工具 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'tools';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.sidebar-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(191, 203, 217, 0.7);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0, 21, 41, 0.2);
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -63,69 +69,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<!-- 平台LOGO区域 -->
|
||||
<div class="p-4 border-b border-[#001529]/30 flex items-center">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-6 h-6 object-contain mr-2">
|
||||
<span class="text-white font-medium">远光软件微调平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航主区域 -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<!-- 第一分区:模型服务 -->
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<a href="main.html" data-page="fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cogs w-5 text-center"></i>
|
||||
<span class="ml-2">模型调优</span>
|
||||
</a>
|
||||
<a href="main.html?page=my-models" data-page="my-models" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">我的模型</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-eval" data-page="model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-line-chart w-5 text-center"></i>
|
||||
<span class="ml-2">模型评测</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-deploy" data-page="model-deploy" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-server w-5 text-center"></i>
|
||||
<span class="ml-2">模型对比</span>
|
||||
</a>
|
||||
|
||||
<!-- 第二分区:资源管理 -->
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<a href="main.html?page=model-manage" data-page="model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cube w-5 text-center"></i>
|
||||
<span class="ml-2">模型管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=dataset-manage" data-page="dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">数据集管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=data-generate" data-page="data-generate" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">其他工具</span>
|
||||
</a>
|
||||
|
||||
<!-- 第三分区:系统设置 -->
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<a href="main.html?page=config" data-page="config" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-bar-chart w-5 text-center"></i>
|
||||
<span class="ml-2">平台性能</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 底部信息区域 -->
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#bfcbd9]">版本 v1.0.0</span>
|
||||
<i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
@@ -214,13 +159,15 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础地址
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:8080/api`;
|
||||
};
|
||||
const API_BASE = getApiBase();
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 当前是否为编辑模式
|
||||
let isEditMode = false;
|
||||
@@ -271,8 +218,32 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设置侧边栏当前页高亮
|
||||
const currentPage = 'data-generate';
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.dataset.page === currentPage) {
|
||||
link.classList.add('bg-[#1890ff]/10', 'text-[#1890ff]');
|
||||
link.classList.remove('hover:bg-[#001529]/20', 'transition-colors');
|
||||
}
|
||||
});
|
||||
updateSidebarSlider();
|
||||
});
|
||||
|
||||
// 更新侧边栏滑块位置
|
||||
function updateSidebarSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
const activeLink = document.querySelector('.nav-link.bg-\\[\\#1890ff\\]\\/10');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载工具数据进行编辑
|
||||
function loadToolForEdit() {
|
||||
const editToolStr = localStorage.getItem('editTool');
|
||||
@@ -431,6 +402,7 @@
|
||||
if (onConfirm) onConfirm();
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 自定义消息弹窗 -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
313
web/pages/dataset-preview.html
Normal file
313
web/pages/dataset-preview.html
Normal file
@@ -0,0 +1,313 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据管理-文件预览 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'dataset-manage';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.text-primary { color: #1890ff; }
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-danger { color: #f5222d; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="#" onclick="goBack()" class="text-gray-500 hover:text-gray-700 flex items-center px-3 py-1.5 rounded hover:bg-gray-100 transition-colors">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">返回</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-1 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px] z-50">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区域 - 文件预览 -->
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50">
|
||||
<div id="page-content">
|
||||
<!-- 文件预览卡片 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-primary cursor-pointer hover:underline" onclick="goBack()">数据集管理</span>
|
||||
<span class="mx-2 text-gray-300">/</span>
|
||||
<span class="text-gray-800 font-medium">文件预览</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-medium">文件预览</h2>
|
||||
<span id="fileNameDisplay" class="ml-3 text-sm text-gray-500"></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="downloadFile()" class="px-3 py-1.5 bg-primary text-white rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-download mr-1"></i>导出
|
||||
</button>
|
||||
<button onclick="deleteDataset()" class="px-3 py-1.5 border border-red-200 text-red-600 rounded text-sm hover:bg-red-50 transition-colors">
|
||||
<i class="fa fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div id="fileStatus" class="px-4 py-3 bg-gray-50 border-b border-gray-100 flex items-center gap-6 text-sm text-gray-600 flex-wrap">
|
||||
<!-- 动态生成 -->
|
||||
</div>
|
||||
|
||||
<!-- 左侧文件列表 + 右侧预览区域 -->
|
||||
<div class="flex" style="min-height: 500px;">
|
||||
<!-- 右侧预览区域 -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-3 border-b border-gray-100 bg-gray-50/30">
|
||||
<h3 class="text-sm font-medium text-gray-700">预览内容</h3>
|
||||
</div>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<pre id="codePreview" class="text-sm text-gray-700 whitespace-pre-wrap font-mono bg-gray-50 rounded p-4" style="min-height: 400px;">加载中...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 全局返回函数
|
||||
function goBack() {
|
||||
window.location.href = 'main.html?page=dataset-manage';
|
||||
}
|
||||
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 获取URL参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const datasetId = urlParams.get('id');
|
||||
const fileId = urlParams.get('fileId');
|
||||
|
||||
let currentDataset = null;
|
||||
let selectedFileId = null;
|
||||
|
||||
// 返回上一页面
|
||||
function goBack() {
|
||||
window.location.href = 'main.html?page=dataset-manage';
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 绑定导航点击事件
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const page = this.dataset.page;
|
||||
window.location.href = `main.html?page=${page}`;
|
||||
});
|
||||
});
|
||||
|
||||
// 设置侧边栏当前页高亮
|
||||
const currentPage = 'dataset-manage';
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.dataset.page === currentPage) {
|
||||
link.classList.add('bg-[#1890ff]/10', 'text-[#1890ff]');
|
||||
link.classList.remove('hover:bg-[#001529]/20', 'transition-colors');
|
||||
}
|
||||
});
|
||||
updateSidebarSlider();
|
||||
|
||||
// 加载数据集信息
|
||||
loadDataset();
|
||||
});
|
||||
|
||||
// 更新侧边栏滑块位置
|
||||
function updateSidebarSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
const activeLink = document.querySelector('.nav-link.bg-\\[\\#1890ff\\]\\/10');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据集信息
|
||||
async function loadDataset() {
|
||||
if (!datasetId) {
|
||||
document.getElementById('codePreview').textContent = '缺少数据集ID参数';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dataset-manage/${datasetId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code !== 0) {
|
||||
document.getElementById('codePreview').textContent = '获取数据集信息失败: ' + (result.message || '未知错误');
|
||||
return;
|
||||
}
|
||||
|
||||
currentDataset = result.data;
|
||||
updateUI();
|
||||
} catch (error) {
|
||||
document.getElementById('codePreview').textContent = '加载失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
function updateUI() {
|
||||
const dataset = currentDataset;
|
||||
|
||||
// 更新状态栏
|
||||
const statusTag = dataset.storage_type === 'local'
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">本地存储</span>'
|
||||
: '<span class="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">云存储</span>';
|
||||
|
||||
const count = dataset.count || 0;
|
||||
const type = dataset.type || 'JSON/JSONL';
|
||||
const createTime = dataset.create_time
|
||||
? new Date(dataset.create_time).toLocaleString('zh-CN')
|
||||
: '-';
|
||||
|
||||
document.getElementById('fileStatus').innerHTML = `
|
||||
<span class="flex items-center">${statusTag}</span>
|
||||
<span class="flex items-center">数据量:<span class="text-gray-800 ml-1">${count} 条</span></span>
|
||||
<span class="flex items-center">数据类型:<span class="text-gray-800 ml-1">${type}</span></span>
|
||||
<span class="flex items-center">创建时间:<span class="text-gray-800 ml-1">${createTime}</span></span>
|
||||
`;
|
||||
|
||||
|
||||
// 如果有文件ID,选择该文件;否则选择第一个文件
|
||||
const targetFileId = fileId || (dataset.files && dataset.files.length > 0 ? dataset.files[0].id : null);
|
||||
if (targetFileId) {
|
||||
selectFile(targetFileId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 选择文件
|
||||
async function selectFile(id) {
|
||||
selectedFileId = id;
|
||||
const previewEl = document.getElementById('codePreview');
|
||||
previewEl.textContent = '加载中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dataset-manage/preview/${id}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code !== 0) {
|
||||
previewEl.textContent = '获取文件预览失败: ' + (result.message || '未知错误');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = result.data;
|
||||
document.getElementById('fileNameDisplay').textContent = fileData.file_name || '';
|
||||
|
||||
// 处理内容,限制显示前100条记录
|
||||
let content = fileData.content || '文件内容为空';
|
||||
if (content && content.trim()) {
|
||||
try {
|
||||
// 尝试解析JSONL格式(每行一个JSON对象)
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
if (lines.length > 100) {
|
||||
// 限制显示前100条
|
||||
const limitedLines = lines.slice(0, 100);
|
||||
content = limitedLines.join('\n') + '\n\n... 共 ' + lines.length + ' 条记录,已显示前 100 条 ...';
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,直接显示原内容
|
||||
}
|
||||
}
|
||||
previewEl.textContent = content;
|
||||
|
||||
} catch (error) {
|
||||
previewEl.textContent = '加载失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function downloadFile() {
|
||||
if (!selectedFileId) {
|
||||
alert('请先选择一个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
window.open(`${protocol}//${hostname}:7861/api/dataset-manage/download/${datasetId}/${selectedFileId}`, '_blank');
|
||||
}
|
||||
|
||||
// 删除数据集
|
||||
function deleteDataset() {
|
||||
if (!datasetId) {
|
||||
alert('缺少数据集ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要删除这个数据集吗?此操作不可恢复。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${API_BASE}/dataset-manage/${datasetId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.code === 0) {
|
||||
alert('删除成功');
|
||||
window.location.href = 'main.html?page=dataset-manage';
|
||||
} else {
|
||||
alert('删除失败: ' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('删除失败: ' + error.message);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
399
web/pages/hardware.html
Normal file
399
web/pages/hardware.html
Normal file
@@ -0,0 +1,399 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>平台性能 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
.bg-primary { background-color: var(--primary); }
|
||||
.text-primary { color: var(--primary); }
|
||||
.border-primary { border-color: var(--primary); }
|
||||
.hover\:bg-primary\/90:hover { background-color: rgba(24, 144, 255, 0.9); }
|
||||
.sidebar-section-title { padding: 0.5rem 1rem; font-size: 0.75rem; color: rgba(191, 203, 217, 0.7); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sidebar-item-active { background-color: rgba(24, 144, 255, 0.1); color: #1890ff; border-left: 4px solid #1890ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<div class="pt-5 pb-3 border-b border-[#001529]/30 flex items-center justify-center pl-2">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-8 h-8 object-contain mr-2">
|
||||
<span class="text-white font-medium text-base">远光软件微调平台</span>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-2 relative">
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<div><a href="main.html?page=fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cogs w-5 text-center"></i><span class="ml-2">模型调优</span></a></div>
|
||||
<div><a href="main.html?page=model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-line-chart w-5 text-center"></i><span class="ml-2">模型评测</span></a></div>
|
||||
<div><a href="main.html?page=model-compare" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-server w-5 text-center"></i><span class="ml-2">模型对比</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<div><a href="main.html?page=model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cube w-5 text-center"></i><span class="ml-2">模型管理</span></a></div>
|
||||
<div><a href="main.html?page=dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">数据集管理</span></a></div>
|
||||
<div><a href="tools.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-wrench w-5 text-center"></i><span class="ml-2">其他工具</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<div><a href="hardware.html" class="nav-link sidebar-item-active flex items-center px-4 py-2.5"><i class="fa fa-bar-chart w-5 text-center"></i><span class="ml-2">平台性能</span></a></div>
|
||||
<div><a href="logs.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">查看日志</span></a></div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between"><span class="text-[#bfcbd9]">版本 v1.0.0</span><i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="main.html" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">返回首页</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-2 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50">
|
||||
<!-- 平台性能页面 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">平台性能</h2>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<i class="fa fa-refresh mr-2"></i>
|
||||
<span class="mr-2">刷新频率:</span>
|
||||
<select id="refreshInterval" onchange="changeRefreshRate()" class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="1000">1秒</option>
|
||||
<option value="3000">3秒</option>
|
||||
<option value="5000" selected>5秒</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- 第一行:CPU、内存、磁盘 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- CPU监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-microchip text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">CPU 使用率</h3>
|
||||
<p class="text-xs text-gray-500" id="cpuCores">4 核心</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="cpuPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="cpuBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-blue-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-4 gap-2" id="cpuCoresList">
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心1</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core1">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心2</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core2">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心3</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core3">0%</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">核心4</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="core4">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内存监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-database text-purple-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">内存使用</h3>
|
||||
<p class="text-xs text-gray-500" id="memoryTotal">总计: 16 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="memoryPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="memoryBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-yellow-400 to-orange-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">已用</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryUsed">0 GB</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">可用</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryAvailable">0 GB</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-gray-500">缓存</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="memoryCached">0 GB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-hdd-o text-green-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">磁盘使用</h3>
|
||||
<p class="text-xs text-gray-500" id="diskTotal">SSD 512 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-medium text-gray-800" id="diskPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div id="diskBar" class="absolute left-0 top-0 h-full bg-gradient-to-r from-emerald-400 to-teal-500 transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">已用空间</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskUsed">0 GB</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">可用空间</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskAvailable">0 GB</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 rounded">
|
||||
<div class="text-xs text-gray-500">读写速度</div>
|
||||
<div class="text-sm font-medium text-gray-800" id="diskSpeed">0 MB/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:GPU监控 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5 mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-microchip text-red-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">GPU监控</h3>
|
||||
<p class="text-xs text-gray-500" id="gpuCount">检测中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="gpuList" style="max-height: 400px; overflow-y: auto;">
|
||||
<!-- GPU卡片动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="border border-gray-200 rounded-lg p-5">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-indigo-100 flex items-center justify-center mr-3">
|
||||
<i class="fa fa-info-circle text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-800">系统信息</h3>
|
||||
<p class="text-xs text-gray-500">服务器状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">操作系统</span>
|
||||
<span class="text-gray-800 font-medium" id="osInfo">Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">运行时间</span>
|
||||
<span class="text-gray-800 font-medium" id="uptime">0 天 0 时 0 分</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-500">进程数</span>
|
||||
<span class="text-gray-800 font-medium" id="processCount">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">负载均值</span>
|
||||
<span class="text-gray-800 font-medium" id="loadAvg">0.00, 0.00, 0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 配置
|
||||
const API_BASE = (() => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
})();
|
||||
|
||||
let refreshTimer = null;
|
||||
let currentRefreshInterval = 5000;
|
||||
|
||||
// 获取系统信息
|
||||
async function fetchSystemInfo() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/system-info`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
updateUI(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新界面
|
||||
function updateUI(data) {
|
||||
// CPU
|
||||
const cpu = data.cpu || {};
|
||||
const cpuPercent = cpu.percent || 0;
|
||||
document.getElementById('cpuPercent').textContent = cpuPercent + '%';
|
||||
document.getElementById('cpuBar').style.width = cpuPercent + '%';
|
||||
document.getElementById('cpuCores').textContent = (cpu.cores || 0) + ' 核心';
|
||||
|
||||
// 内存
|
||||
const mem = data.memory || {};
|
||||
const memUsed = mem.used_gb || 0;
|
||||
const memTotal = mem.total_gb || 0;
|
||||
const memPercent = mem.percent || 0;
|
||||
document.getElementById('memoryPercent').textContent = memPercent + '%';
|
||||
document.getElementById('memoryBar').style.width = memPercent + '%';
|
||||
document.getElementById('memoryUsed').textContent = memUsed + ' GB';
|
||||
document.getElementById('memoryAvailable').textContent = (mem.available_gb || 0) + ' GB';
|
||||
document.getElementById('memoryCached').textContent = (mem.cached_gb || 0) + ' GB';
|
||||
|
||||
// 磁盘
|
||||
const disk = data.disk || {};
|
||||
const diskUsed = disk.used_gb || 0;
|
||||
const diskTotal = disk.total_gb || 0;
|
||||
const diskPercent = disk.percent || 0;
|
||||
document.getElementById('diskPercent').textContent = diskPercent + '%';
|
||||
document.getElementById('diskBar').style.width = diskPercent + '%';
|
||||
document.getElementById('diskUsed').textContent = diskUsed + ' GB';
|
||||
document.getElementById('diskAvailable').textContent = (diskTotal - diskUsed) + ' GB';
|
||||
|
||||
// 系统
|
||||
const sys = data.system || {};
|
||||
const uptime = sys.uptime_seconds || 0;
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const mins = Math.floor((uptime % 3600) / 60);
|
||||
document.getElementById('uptime').textContent = days + ' 天 ' + hours + ' 时 ' + mins + ' 分';
|
||||
document.getElementById('processCount').textContent = sys.process_count || 0;
|
||||
|
||||
// GPU
|
||||
updateGPUInfo(data.gpu || []);
|
||||
}
|
||||
|
||||
// 更新GPU信息
|
||||
function updateGPUInfo(gpuData) {
|
||||
const gpuList = document.getElementById('gpuList');
|
||||
if (!gpuList) return;
|
||||
|
||||
if (gpuData && gpuData.length > 0) {
|
||||
document.getElementById('gpuCount').textContent = `检测到 ${gpuData.length} 块 GPU`;
|
||||
let html = '';
|
||||
gpuData.forEach((gpu, i) => {
|
||||
html += `
|
||||
<div class="border border-gray-200 rounded-lg p-2 bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="w-6 h-6 rounded bg-red-100 flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fa fa-microchip text-red-600 text-xs"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-gray-800 truncate" title="${gpu.name}">${gpu.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-bold text-gray-800">${gpu.gpu_percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 via-yellow-400 to-red-400 transition-all duration-500" style="width: ${gpu.gpu_percent}%"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-1 text-center text-[10px] mt-1">
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">显存</div>
|
||||
<div class="font-medium text-gray-700">${gpu.memory_used_gb}/${gpu.memory_total_gb} GB</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">温度</div>
|
||||
<div class="font-medium ${gpu.temperature >= 80 ? 'text-red-600' : gpu.temperature >= 70 ? 'text-yellow-600' : 'text-gray-700'}">${gpu.temperature}°C</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">功耗</div>
|
||||
<div class="font-medium text-gray-700">${gpu.power_w} W</div>
|
||||
</div>
|
||||
<div class="bg-white/80 rounded py-0.5">
|
||||
<div class="text-gray-400">Fan</div>
|
||||
<div class="font-medium text-gray-700">${gpu.fan_speed || 0}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
gpuList.innerHTML = html;
|
||||
} else {
|
||||
document.getElementById('gpuCount').textContent = '未检测到 GPU';
|
||||
gpuList.innerHTML = '<p class="text-gray-400 text-sm p-4">未检测到 GPU 设备</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 改变刷新频率
|
||||
function changeRefreshRate() {
|
||||
const select = document.getElementById('refreshInterval');
|
||||
currentRefreshInterval = parseInt(select.value);
|
||||
startRefreshTimer();
|
||||
}
|
||||
|
||||
// 启动定时器
|
||||
function startRefreshTimer() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
refreshTimer = setInterval(fetchSystemInfo, currentRefreshInterval);
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetchSystemInfo();
|
||||
startRefreshTimer();
|
||||
});
|
||||
|
||||
// 页面离开时清理
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>远光软件微调平台 - 登录</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.login-card-shadow {
|
||||
@@ -115,13 +126,15 @@
|
||||
|
||||
<!-- 简单的交互脚本 -->
|
||||
<script>
|
||||
// 动态获取 API 基础地址(根据当前访问的 IP 自动调整)
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:8080/api`;
|
||||
};
|
||||
const API_BASE = getApiBase();
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 密码显示/隐藏切换
|
||||
const togglePassword = document.getElementById('togglePassword');
|
||||
@@ -159,6 +172,9 @@
|
||||
|
||||
if (result.code === 0) {
|
||||
errorMsg.classList.add('hidden');
|
||||
// 保存登录时间戳(5分钟超时)
|
||||
localStorage.setItem('loginTime', Date.now());
|
||||
localStorage.setItem('username', username);
|
||||
window.location.href = 'main.html';
|
||||
} else {
|
||||
errorMsg.textContent = result.message || '账号或密码错误';
|
||||
@@ -169,6 +185,7 @@
|
||||
errorMsg.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
354
web/pages/logs.html
Normal file
354
web/pages/logs.html
Normal file
@@ -0,0 +1,354 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>查看日志 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
.bg-primary { background-color: var(--primary); }
|
||||
.text-primary { color: var(--primary); }
|
||||
.border-primary { border-color: var(--primary); }
|
||||
.hover\:bg-primary\/90:hover { background-color: rgba(24, 144, 255, 0.9); }
|
||||
.sidebar-section-title { padding: 0.5rem 1rem; font-size: 0.75rem; color: rgba(191, 203, 217, 0.7); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sidebar-item-active { background-color: rgba(24, 144, 255, 0.1); color: #1890ff; border-left: 4px solid #1890ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<div class="pt-5 pb-3 border-b border-[#001529]/30 flex items-center justify-center pl-2">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-8 h-8 object-contain mr-2">
|
||||
<span class="text-white font-medium text-base">远光软件微调平台</span>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-2 relative">
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<div><a href="main.html?page=fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cogs w-5 text-center"></i><span class="ml-2">模型调优</span></a></div>
|
||||
<div><a href="main.html?page=model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-line-chart w-5 text-center"></i><span class="ml-2">模型评测</span></a></div>
|
||||
<div><a href="main.html?page=model-compare" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-server w-5 text-center"></i><span class="ml-2">模型对比</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<div><a href="main.html?page=model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cube w-5 text-center"></i><span class="ml-2">模型管理</span></a></div>
|
||||
<div><a href="main.html?page=dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">数据集管理</span></a></div>
|
||||
<div><a href="tools.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-wrench w-5 text-center"></i><span class="ml-2">其他工具</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<div><a href="hardware.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-bar-chart w-5 text-center"></i><span class="ml-2">平台性能</span></a></div>
|
||||
<div><a href="logs.html" class="nav-link sidebar-item-active flex items-center px-4 py-2.5"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">查看日志</span></a></div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between"><span class="text-[#bfcbd9]">版本 v1.0.0</span><i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="main.html" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">返回首页</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-2 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50">
|
||||
<!-- 日志查看器 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">系统日志</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="refreshLogs()" class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-refresh mr-1"></i>刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- 日志选项 -->
|
||||
<div class="flex items-center flex-wrap gap-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm text-gray-600 mr-3">选择日期:</label>
|
||||
<input type="date" id="logDatePicker" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm text-gray-600 mr-3">自动刷新:</label>
|
||||
<select id="logRefreshInterval" onchange="setRefreshInterval()" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="0">关闭</option>
|
||||
<option value="5">5秒</option>
|
||||
<option value="10" selected>10秒</option>
|
||||
<option value="30">30秒</option>
|
||||
<option value="60">60秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="logRefreshCountdown" class="text-sm text-gray-500 hidden">
|
||||
<i class="fa fa-clock-o mr-1"></i><span>下次刷新: <span id="countdownNumber">10</span>秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<label class="text-sm text-gray-600 mr-3">日志类型:</label>
|
||||
<select id="logTypeSelect" onchange="loadSelectedLog()" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-primary focus:outline-none">
|
||||
<option value="">请选择日志文件</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容显示 -->
|
||||
<div class="border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<span class="text-sm text-gray-600" id="logFileInfo">请选择日志文件</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input type="text" id="logSearchInput" placeholder="搜索日志..." oninput="filterLogContent()" class="px-2 py-1 text-xs border border-gray-300 rounded focus:border-primary focus:outline-none">
|
||||
<span id="logMatchCount" class="text-xs text-gray-500"></span>
|
||||
<button onclick="clearLogContent()" class="text-xs text-gray-500 hover:text-primary">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="logContent" class="p-4 text-xs font-mono bg-gray-900 text-gray-100 overflow-auto max-h-[600px]" style="white-space: pre-wrap; word-wrap: break-word;">日志内容将在这里显示...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 配置
|
||||
const API_BASE = (() => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
})();
|
||||
|
||||
// 日志自动刷新相关变量
|
||||
let logRefreshTimer = null;
|
||||
let logCountdownTimer = null;
|
||||
let logCurrentInterval = 10;
|
||||
let logFullContent = '';
|
||||
|
||||
// 设置自动刷新间隔
|
||||
function setRefreshInterval() {
|
||||
const select = document.getElementById('logRefreshInterval');
|
||||
const countdownEl = document.getElementById('logRefreshCountdown');
|
||||
const secondsEl = document.getElementById('countdownNumber');
|
||||
|
||||
if (!select) return;
|
||||
|
||||
logCurrentInterval = parseInt(select.value) || 10;
|
||||
|
||||
if (logRefreshTimer) {
|
||||
clearInterval(logRefreshTimer);
|
||||
logRefreshTimer = null;
|
||||
}
|
||||
if (logCountdownTimer) {
|
||||
clearInterval(logCountdownTimer);
|
||||
logCountdownTimer = null;
|
||||
}
|
||||
|
||||
if (select.value === '0') {
|
||||
countdownEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
countdownEl.classList.remove('hidden');
|
||||
secondsEl.textContent = logCurrentInterval;
|
||||
|
||||
let countdown = logCurrentInterval;
|
||||
logCountdownTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
countdown = logCurrentInterval;
|
||||
}
|
||||
secondsEl.textContent = countdown;
|
||||
}, 1000);
|
||||
|
||||
logRefreshTimer = setInterval(() => {
|
||||
refreshLogs();
|
||||
}, logCurrentInterval * 1000);
|
||||
}
|
||||
|
||||
// 刷新日志
|
||||
function refreshLogs() {
|
||||
if (document.getElementById('logTypeSelect')?.value) {
|
||||
loadSelectedLog();
|
||||
} else {
|
||||
loadLogFiles();
|
||||
}
|
||||
const secondsEl = document.getElementById('countdownNumber');
|
||||
if (secondsEl && logCurrentInterval) {
|
||||
secondsEl.textContent = logCurrentInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到日志底部
|
||||
function scrollToLogBottom() {
|
||||
const logContent = document.getElementById('logContent');
|
||||
if (logContent) {
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志查看器
|
||||
function initLogViewer() {
|
||||
const datePicker = document.getElementById('logDatePicker');
|
||||
if (datePicker) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
datePicker.value = today;
|
||||
}
|
||||
loadLogFiles();
|
||||
setRefreshInterval();
|
||||
}
|
||||
|
||||
// 加载日志文件列表
|
||||
async function loadLogFiles() {
|
||||
const datePicker = document.getElementById('logDatePicker');
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
const selectedDate = datePicker ? datePicker.value : new Date().toISOString().split('T')[0];
|
||||
|
||||
if (!logTypeSelect) return;
|
||||
logTypeSelect.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/log-files?date=${selectedDate}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
logTypeSelect.innerHTML = '<option value="">请选择日志文件</option>';
|
||||
result.data.forEach(log => {
|
||||
const option = document.createElement('option');
|
||||
option.value = log.file;
|
||||
option.textContent = log.name + ' (' + log.size + ')';
|
||||
logTypeSelect.appendChild(option);
|
||||
});
|
||||
if (result.data.length > 0) {
|
||||
logTypeSelect.value = result.data[0].file;
|
||||
loadSelectedLog();
|
||||
} else {
|
||||
logTypeSelect.innerHTML = '<option value="">暂无日志文件</option>';
|
||||
document.getElementById('logContent').textContent = '该日期暂无日志文件';
|
||||
document.getElementById('logFileInfo').textContent = '无日志文件';
|
||||
}
|
||||
} else {
|
||||
logTypeSelect.innerHTML = '<option value="">暂无日志文件</option>';
|
||||
document.getElementById('logContent').textContent = '该日期暂无日志文件';
|
||||
document.getElementById('logFileInfo').textContent = '无日志文件';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日志文件列表失败:', error);
|
||||
logTypeSelect.innerHTML = '<option value="">加载失败</option>';
|
||||
document.getElementById('logContent').textContent = '加载日志文件列表失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载选中的日志
|
||||
async function loadSelectedLog() {
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
const logFile = logTypeSelect.value;
|
||||
const logContent = document.getElementById('logContent');
|
||||
const logFileInfo = document.getElementById('logFileInfo');
|
||||
|
||||
if (!logFile) {
|
||||
logContent.textContent = '请选择日志文件';
|
||||
logFileInfo.textContent = '无日志文件';
|
||||
return;
|
||||
}
|
||||
|
||||
logContent.textContent = '加载中...';
|
||||
logFileInfo.textContent = '加载中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/log-content?file=${encodeURIComponent(logFile)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
logFullContent = result.data.content || '';
|
||||
logContent.textContent = logFullContent || '(空日志)';
|
||||
logFileInfo.textContent = result.data.file + ' (' + result.data.size + ')';
|
||||
document.getElementById('logSearchInput').value = '';
|
||||
document.getElementById('logMatchCount').textContent = '';
|
||||
scrollToLogBottom();
|
||||
} else {
|
||||
logContent.textContent = '加载失败: ' + (result.message || '未知错误');
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日志内容失败:', error);
|
||||
logContent.textContent = '加载日志内容失败: ' + error.message;
|
||||
logFileInfo.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤日志内容
|
||||
function filterLogContent() {
|
||||
const searchInput = document.getElementById('logSearchInput');
|
||||
const matchCount = document.getElementById('logMatchCount');
|
||||
const logContent = document.getElementById('logContent');
|
||||
|
||||
if (!searchInput || !matchCount || !logContent) return;
|
||||
|
||||
const keyword = searchInput.value.trim();
|
||||
|
||||
if (!keyword) {
|
||||
logContent.textContent = logFullContent || '(空日志)';
|
||||
matchCount.textContent = '';
|
||||
scrollToLogBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = logFullContent.split('\n');
|
||||
const matchingLines = lines.filter(line => line.toLowerCase().includes(keyword.toLowerCase()));
|
||||
|
||||
if (matchingLines.length > 0) {
|
||||
logContent.textContent = matchingLines.join('\n');
|
||||
matchCount.textContent = `(${matchingLines.length}条匹配)`;
|
||||
scrollToLogBottom();
|
||||
} else {
|
||||
logContent.textContent = '未找到匹配的日志';
|
||||
matchCount.textContent = '(0条匹配)';
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志内容显示
|
||||
function clearLogContent() {
|
||||
document.getElementById('logContent').textContent = '日志内容将在这里显示...';
|
||||
document.getElementById('logFileInfo').textContent = '请选择日志文件';
|
||||
const logTypeSelect = document.getElementById('logTypeSelect');
|
||||
if (logTypeSelect) logTypeSelect.value = '';
|
||||
document.getElementById('logSearchInput').value = '';
|
||||
document.getElementById('logMatchCount').textContent = '';
|
||||
logFullContent = '';
|
||||
}
|
||||
|
||||
// 停止日志自动刷新(离开页面时调用)
|
||||
function stopLogAutoRefresh() {
|
||||
if (logRefreshTimer) {
|
||||
clearInterval(logRefreshTimer);
|
||||
logRefreshTimer = null;
|
||||
}
|
||||
if (logCountdownTimer) {
|
||||
clearInterval(logCountdownTimer);
|
||||
logCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initLogViewer();
|
||||
});
|
||||
|
||||
// 页面离开时停止刷新
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopLogAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2782
web/pages/main.html
2782
web/pages/main.html
File diff suppressed because it is too large
Load Diff
531
web/pages/model-compare-chat.html
Normal file
531
web/pages/model-compare-chat.html
Normal file
@@ -0,0 +1,531 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模型对比 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-compare';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, outline 0.2s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; }
|
||||
|
||||
/* 滑块样式 */
|
||||
.slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #e5e7eb;
|
||||
outline: none;
|
||||
}
|
||||
.slider-thumb::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.3);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.slider-thumb::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 折叠动画 */
|
||||
.input-section {
|
||||
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.input-section.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
.typing-cursor::after {
|
||||
content: '|';
|
||||
animation: blink 1s infinite;
|
||||
color: var(--primary);
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 输出卡片动画 */
|
||||
.output-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.output-card.loading {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* 已选模型标签 */
|
||||
.model-tag {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.model-tag:hover {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="main.html?page=model-compare" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">上一步</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-2 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50">
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-primary cursor-pointer hover:underline" onclick="window.location.href='main.html?page=model-compare'">模型对比</span>
|
||||
<span class="mx-2 text-gray-300">/</span>
|
||||
<span class="text-gray-800 font-medium" id="breadcrumbTask">对话</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form id="chatForm">
|
||||
<!-- 已配置模型展示 -->
|
||||
<div class="mb-6">
|
||||
<label class="form-label flex items-center mb-3">
|
||||
<i class="fa fa-server text-primary mr-2"></i>
|
||||
已配置模型
|
||||
<span class="ml-2 text-xs text-gray-400 font-normal">(共 <span id="modelCount">0</span> 个)</span>
|
||||
</label>
|
||||
<div id="selectedModelsList" class="flex flex-wrap gap-2">
|
||||
<!-- 模型标签将通过JS渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统提示词 -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label flex items-center">
|
||||
<i class="fa fa-cog text-primary mr-2"></i>
|
||||
系统提示词
|
||||
<span class="ml-2 text-xs text-gray-400 font-normal">(可选,设置模型行为)</span>
|
||||
</label>
|
||||
<textarea id="systemPrompt" class="form-input" rows="3"
|
||||
placeholder="例如:你是一个专业的技术助手,善于回答各类问题。请用简洁清晰的语言回答。"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 用户提问 -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label flex items-center">
|
||||
<i class="fa fa-comment text-primary mr-2"></i>
|
||||
用户提问
|
||||
<span class="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<textarea id="userQuestion" class="form-input" rows="4"
|
||||
placeholder="请输入您想要对比的问题..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 推理参数设置 -->
|
||||
<div class="mb-6">
|
||||
<label class="form-label flex items-center mb-4">
|
||||
<i class="fa fa-sliders text-primary mr-2"></i>
|
||||
推理参数设置
|
||||
</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<!-- Temperature -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">Temperature</span>
|
||||
<span id="tempValue" class="text-sm text-primary font-medium">0.7</span>
|
||||
</div>
|
||||
<input type="range" id="temperature" class="slider-thumb" min="0" max="2" step="0.1" value="0.7">
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>保守</span>
|
||||
<span>随机</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-p -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">Top-p</span>
|
||||
<span id="topPValue" class="text-sm text-primary font-medium">0.9</span>
|
||||
</div>
|
||||
<input type="range" id="topP" class="slider-thumb" min="0" max="1" step="0.05" value="0.9">
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>集中</span>
|
||||
<span>广泛</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-k -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">Top-k</span>
|
||||
<span id="topKValue" class="text-sm text-primary font-medium">50</span>
|
||||
</div>
|
||||
<input type="range" id="topK" class="slider-thumb" min="1" max="100" step="1" value="50">
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>精确</span>
|
||||
<span>多样</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max Tokens -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">Max Tokens</span>
|
||||
<span id="maxTokensValue" class="text-sm text-primary font-medium">2048</span>
|
||||
</div>
|
||||
<input type="range" id="maxTokens" class="slider-thumb" min="256" max="4096" step="256" value="2048">
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>短</span>
|
||||
<span>长</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<div class="flex items-center justify-end pt-4 border-t border-gray-100">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button type="button" onclick="goBack()" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg text-sm hover:bg-gray-300 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" id="startBtn" class="px-6 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors flex items-center disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fa fa-play mr-2"></i>
|
||||
开始提问
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
console.log('>>> model-compare-chat.html 脚本开始加载');
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 模拟模型数据(用于演示)
|
||||
const mockModels = [
|
||||
{ id: 1, name: 'GPT-4', type: 'LLM', description: 'OpenAI GPT-4 大语言模型' },
|
||||
{ id: 2, name: 'Claude-3', type: 'LLM', description: 'Anthropic Claude-3 模型' },
|
||||
{ id: 3, name: '文心一言', type: 'LLM', description: '百度文心一言大模型' },
|
||||
{ id: 4, name: '通义千问', type: 'LLM', description: '阿里通义千问大模型' },
|
||||
{ id: 5, name: 'ChatGLM', type: 'LLM', description: '智谱ChatGLM对话模型' },
|
||||
{ id: 6, name: '星火认知', type: 'LLM', description: '讯飞星火认知大模型' }
|
||||
];
|
||||
|
||||
let allModels = [];
|
||||
let selectedModelIds = [];
|
||||
let compareTaskId = null;
|
||||
let compareTaskData = null;
|
||||
|
||||
// 模拟回复内容
|
||||
const mockResponses = [
|
||||
"这是一个基于深度学习的自然语言处理模型回答。我可以处理各种复杂的问题,包括代码编写、文本分析、知识问答等多种任务。我的训练数据涵盖了广泛的领域知识,能够提供准确和有用的信息。",
|
||||
"您好!我是一个人工智能语言模型,很高兴为您服务。我可以帮助您解答问题、提供建议、进行创意写作等。如果您有任何需要,请随时告诉我,我会尽力提供帮助。",
|
||||
"这是一个很有趣的问题!从技术角度来看,我们需要考虑多个因素:数据质量、模型架构、训练策略等。深度学习在近年来取得了巨大进展,但仍然存在一些挑战需要解决。",
|
||||
"根据我的理解,这个问题涉及到以下几个方面:首先,需要明确问题的具体背景;其次,要分析相关的技术方案;最后,需要评估实施的成本和收益。建议您先收集更多信息再做决定。"
|
||||
];
|
||||
|
||||
// 页面初始化函数
|
||||
async function initPage() {
|
||||
console.log('>>> chat initPage 开始执行');
|
||||
console.log('document.readyState:', document.readyState);
|
||||
|
||||
try {
|
||||
// 获取URL参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
compareTaskId = urlParams.get('id');
|
||||
|
||||
// 先加载对比任务数据(获取已选模型ID列表)
|
||||
await loadCompareTask();
|
||||
// 再加载模型列表
|
||||
await loadModels();
|
||||
|
||||
// 确保两个都加载完成后再渲染已选模型
|
||||
if (selectedModelIds.length > 0) {
|
||||
renderSelectedModels();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载数据失败:', e);
|
||||
}
|
||||
// 无论加载成功与否,都绑定事件
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
// 立即初始化(支持通过 fetch 加载的页面)
|
||||
// 延迟执行,确保 DOM 完全准备好
|
||||
setTimeout(() => {
|
||||
initPage().catch(err => console.error('initPage 执行失败:', err));
|
||||
}, 50);
|
||||
|
||||
// 加载对比任务数据
|
||||
async function loadCompareTask() {
|
||||
if (!compareTaskId) {
|
||||
updatePageTitle('请选择对比任务');
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
showToast('请从列表页进入对比任务', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-compare/${compareTaskId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
const taskData = Array.isArray(result.data)
|
||||
? result.data.find(item => item && item.id == compareTaskId)
|
||||
: result.data;
|
||||
|
||||
if (taskData) {
|
||||
compareTaskData = taskData;
|
||||
updatePageTitle(taskData.model_name || '模型对比');
|
||||
|
||||
const modelsField = taskData.models;
|
||||
if (modelsField) {
|
||||
try {
|
||||
if (typeof modelsField === 'string') {
|
||||
selectedModelIds = JSON.parse(modelsField);
|
||||
} else if (Array.isArray(modelsField)) {
|
||||
selectedModelIds = modelsField;
|
||||
}
|
||||
// 不在这里渲染,等 loadModels() 完成后再渲染
|
||||
} catch (e) {
|
||||
console.error('解析模型列表失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到对应任务数据');
|
||||
}
|
||||
} else {
|
||||
showToast('加载对比任务失败: ' + result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对比任务失败:', error);
|
||||
showToast('加载对比任务失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新页面标题
|
||||
function updatePageTitle(title) {
|
||||
const breadcrumb = document.getElementById('breadcrumbTask');
|
||||
if (breadcrumb) {
|
||||
breadcrumb.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
allModels = result.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型失败:', error);
|
||||
allModels = mockModels;
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染已选模型标签
|
||||
function renderSelectedModels() {
|
||||
const container = document.getElementById('selectedModelsList');
|
||||
const modelCount = document.getElementById('modelCount');
|
||||
const allAvailableModels = [...allModels, ...mockModels];
|
||||
|
||||
modelCount.textContent = selectedModelIds.length;
|
||||
|
||||
if (selectedModelIds.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-sm">暂无配置模型</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = selectedModelIds.map((modelId, index) => {
|
||||
// 支持 ID 数字或模型名称字符串匹配
|
||||
const model = allAvailableModels.find(m =>
|
||||
String(m.id) === String(modelId) || m.name === modelId
|
||||
);
|
||||
const colors = ['bg-blue-100 text-blue-700 border-blue-200', 'bg-green-100 text-green-700 border-green-200', 'bg-purple-100 text-purple-700 border-purple-200', 'bg-orange-100 text-orange-700 border-orange-200'];
|
||||
const colorClass = colors[index % colors.length];
|
||||
return `
|
||||
<span class="model-tag px-3 py-1.5 rounded-lg text-sm font-medium flex items-center ${colorClass}">
|
||||
${model?.name || modelId}
|
||||
</span>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
function bindEvents() {
|
||||
const container = document.getElementById('chatForm') || document.body;
|
||||
|
||||
const sliders = [
|
||||
{ id: 'temperature', valueId: 'tempValue' },
|
||||
{ id: 'topP', valueId: 'topPValue' },
|
||||
{ id: 'topK', valueId: 'topKValue' },
|
||||
{ id: 'maxTokens', valueId: 'maxTokensValue' }
|
||||
];
|
||||
|
||||
sliders.forEach(({ id, valueId }) => {
|
||||
const slider = container.querySelector('#' + id);
|
||||
const valueDisplay = container.querySelector('#' + valueId);
|
||||
if (slider && valueDisplay) {
|
||||
slider.addEventListener('input', function() {
|
||||
valueDisplay.textContent = this.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const startBtn = container.querySelector('#startBtn');
|
||||
if (startBtn) startBtn.addEventListener('click', startGeneration);
|
||||
}
|
||||
|
||||
// 开始生成回答 - 直接跳转到结果页面,由结果页面调用API
|
||||
function startGeneration() {
|
||||
const userQuestion = document.getElementById('userQuestion').value.trim();
|
||||
|
||||
if (!userQuestion) {
|
||||
showToast('请输入用户提问', 'warning');
|
||||
return;
|
||||
}
|
||||
if (selectedModelIds.length === 0) {
|
||||
showToast('该任务没有配置模型', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取推理参数
|
||||
const temperature = document.getElementById('temperature')?.value || 0.7;
|
||||
const topP = document.getElementById('topP')?.value || 0.9;
|
||||
const topK = document.getElementById('topK')?.value || 50;
|
||||
const maxTokens = document.getElementById('maxTokens')?.value || 2048;
|
||||
const systemPrompt = document.getElementById('systemPrompt')?.value || '';
|
||||
|
||||
// 直接跳转到结果页面,不在这里等待API结果
|
||||
// 结果页面会自动调用API并显示加载状态
|
||||
const taskName = compareTaskData?.model_name || '对比任务';
|
||||
const encodedQuestion = encodeURIComponent(userQuestion);
|
||||
const encodedSystemPrompt = encodeURIComponent(systemPrompt);
|
||||
|
||||
// 构建 URL,传递推理参数
|
||||
const params = new URLSearchParams({
|
||||
taskId: compareTaskId,
|
||||
taskName: taskName,
|
||||
question: userQuestion,
|
||||
real: '1',
|
||||
temperature: temperature,
|
||||
topP: topP,
|
||||
topK: topK,
|
||||
maxTokens: maxTokens,
|
||||
systemPrompt: systemPrompt
|
||||
});
|
||||
|
||||
window.location.href = `main.html?page=model-compare-result&${params.toString()}`;
|
||||
}
|
||||
|
||||
// 返回
|
||||
function goBack() {
|
||||
window.location.href = 'main.html?page=model-compare';
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 left-1/2 transform -translate-x-1/2 px-4 py-2 rounded-lg text-sm text-white z-50 transition-all duration-300 ${
|
||||
type === 'warning' ? 'bg-yellow-500' :
|
||||
type === 'error' ? 'bg-red-500' :
|
||||
type === 'success' ? 'bg-green-500' : 'bg-primary'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translate(-50%, -20px)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 延迟函数
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
769
web/pages/model-compare-create.html
Normal file
769
web/pages/model-compare-create.html
Normal file
@@ -0,0 +1,769 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>新建对比 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-compare';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.model-card {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.model-card:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.model-card.selected {
|
||||
border-color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
.model-card.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, outline 0.2s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
transition: border-color 0.2s, outline 0.2s;
|
||||
}
|
||||
.form-select:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="#" onclick="goBack()" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">上一步</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-2 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50">
|
||||
<!-- 页面标题 -->
|
||||
<div class="bg-white rounded-lg shadow-sm w-full p-4 border-b border-gray-100 mb-4">
|
||||
<div class="flex items-center text-sm">
|
||||
<a href="main.html?page=model-compare" class="text-primary hover:underline">模型对比</a>
|
||||
<span class="mx-2 text-gray-300">/</span>
|
||||
<span class="text-gray-800 font-medium">新建对比</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<div class="bg-white rounded-lg shadow-sm w-full">
|
||||
<div class="p-6">
|
||||
<form id="createForm">
|
||||
<!-- 基本信息 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">基本信息</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="form-label">对比名称 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="name" class="form-input" placeholder="请输入对比任务名称" maxlength="50">
|
||||
<p class="text-xs text-gray-400 mt-1"><span id="nameCount">0</span> / 50</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">描述</label>
|
||||
<textarea name="description" class="form-input" rows="3" placeholder="请输入对比任务描述(可选)" maxlength="200"></textarea>
|
||||
<p class="text-xs text-gray-400 mt-1"><span id="descCount">0</span> / 200</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择对比模型 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">选择对比模型 <span class="text-red-500">*</span></h3>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-sm text-gray-600">已选择 <span id="selectedCount" class="text-primary font-medium">0</span> / 2 个模型</span>
|
||||
<span class="text-xs text-gray-400">最多选择2个模型进行对比</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="text" id="modelSearchInput" placeholder="搜索模型..."
|
||||
class="pl-8 pr-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:border-primary focus:outline-none w-48">
|
||||
<i class="fa fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型列表容器(带滚动) -->
|
||||
<div id="modelList" class="border border-gray-200 rounded-lg max-h-80 overflow-y-auto">
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<i class="fa fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<p>加载模型列表中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选模型配置区域 -->
|
||||
<div id="selectedModelsConfig" class="mb-6" style="display: none;">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">模型配置</h3>
|
||||
<div id="configContainer" class="space-y-4">
|
||||
<!-- 动态生成模型配置卡片 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="flex items-center justify-between pt-6 border-t border-gray-100">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button type="button" onclick="submitForm()" class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors">
|
||||
开始对比
|
||||
</button>
|
||||
<button type="button" onclick="goBack()" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg text-sm hover:bg-gray-300 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 自定义消息弹窗 -->
|
||||
<div id="customModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick="if(event.target === this) closeModal();">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden transform transition-all">
|
||||
<div class="flex flex-col items-center justify-center min-h-[160px] py-6">
|
||||
<div id="modalIcon"></div>
|
||||
<h3 id="modalTitle" class="text-lg font-medium text-gray-800 mb-2"></h3>
|
||||
<p id="modalMessage" class="text-gray-600 text-sm"></p>
|
||||
</div>
|
||||
<div id="modalSingleBtnGroup" class="px-6 pb-6 flex justify-center">
|
||||
<button id="modalConfirmBtn2" class="px-6 py-2 w-full text-white rounded transition-colors text-sm max-w-[160px]">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
let allModels = [];
|
||||
let selectedModels = new Map(); // 使用 Map 存储已选模型及其配置
|
||||
let availableGPUs = []; // 可用GPU列表
|
||||
let usedPorts = new Set(); // 已使用的端口
|
||||
|
||||
// 返回列表页
|
||||
function goBack() {
|
||||
window.location.href = 'main.html?page=model-compare';
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
try {
|
||||
// 并行加载数据库模型、已训练模型和GPU信息
|
||||
const [dbResponse, trainedResponse, gpuResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/model-manage`),
|
||||
fetch(`${API_BASE}/model-manage/trained-models`),
|
||||
fetch(`${API_BASE}/system-info`)
|
||||
]);
|
||||
|
||||
// 处理GPU信息
|
||||
const gpuResult = await gpuResponse.json();
|
||||
if (gpuResult.code === 0 && gpuResult.data.gpu) {
|
||||
availableGPUs = gpuResult.data.gpu.map((gpu, index) => ({
|
||||
id: index,
|
||||
name: gpu.name || `GPU ${index}`,
|
||||
memory: `${gpu.memory_used_gb || 0}/${gpu.memory_total_gb || 0} GB`,
|
||||
utilization: gpu.gpu_percent || 0
|
||||
}));
|
||||
} else {
|
||||
availableGPUs = [{ id: 0, name: 'GPU 0', memory: 'N/A', utilization: 0 }];
|
||||
}
|
||||
|
||||
const dbResult = await dbResponse.json();
|
||||
const trainedResult = await trainedResponse.json();
|
||||
|
||||
let dbModels = [];
|
||||
let trainedModels = [];
|
||||
|
||||
// 数据库模型
|
||||
if (dbResult.code === 0) {
|
||||
dbModels = dbResult.data || [];
|
||||
}
|
||||
|
||||
// 已训练模型
|
||||
if (trainedResult.code === 0) {
|
||||
const trainedData = trainedResult.data?.models || [];
|
||||
trainedData.forEach(model => {
|
||||
if (model.train_methods && model.train_methods.length > 0) {
|
||||
model.train_methods.forEach(method => {
|
||||
trainedModels.push({
|
||||
id: `trained_${model.name}_${method.name}`.replace(/[^a-zA-Z0-9]/g, '_'),
|
||||
name: `${model.name} (${method.name})`,
|
||||
type: 'LLM',
|
||||
source: 'trained',
|
||||
model_path: model.path,
|
||||
train_method: method.name,
|
||||
merged: model.merged, // 是否已合并
|
||||
merging: model.merging, // 是否正在合并
|
||||
description: `已训练模型 (${method.name})`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 合并所有模型
|
||||
allModels = [...dbModels, ...trainedModels];
|
||||
console.log('[DEBUG] 数据库模型:', dbModels.length, '已训练模型:', trainedModels.length);
|
||||
renderModels();
|
||||
} catch (error) {
|
||||
console.error('加载模型失败:', error);
|
||||
document.getElementById('modelList').innerHTML = `
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<i class="fa fa-exclamation-circle text-2xl mb-2 text-red-400"></i>
|
||||
<p>加载模型失败,请检查后端服务</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染模型列表 - 使用分栏显示数据库模型和已训练模型
|
||||
function renderModels() {
|
||||
const container = document.getElementById('modelList');
|
||||
|
||||
// 分离数据库模型和已训练模型
|
||||
const dbModels = allModels.filter(m => m.source !== 'trained');
|
||||
const trainedModels = allModels.filter(m => m.source === 'trained');
|
||||
|
||||
if (allModels.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<i class="fa fa-inbox text-2xl mb-2"></i>
|
||||
<p>暂无模型,请先添加模型</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 数据库模型区域
|
||||
if (dbModels.length > 0) {
|
||||
html += `
|
||||
<div class="border-b border-gray-200">
|
||||
<div class="px-4 py-2 bg-gray-50 text-xs font-medium text-gray-600 flex items-center">
|
||||
<i class="fa fa-database mr-1"></i> 数据库模型 (${dbModels.length})
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
`;
|
||||
html += dbModels.map(model => renderModelCard(model)).join('');
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 已训练模型区域
|
||||
if (trainedModels.length > 0) {
|
||||
// 统计已合并和未合并的数量
|
||||
const mergedCount = trainedModels.filter(m => m.merged).length;
|
||||
html += `
|
||||
<div class="border-b border-gray-200">
|
||||
<div class="px-4 py-2 bg-green-50 text-xs font-medium text-green-700 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fa fa-check-circle mr-1"></i> 已训练模型 (${trainedModels.length})
|
||||
</div>
|
||||
<span class="text-orange-600">${mergedCount > 0 ? `${mergedCount}个已合并可选择` : '需合并后才可选择'}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
`;
|
||||
html += trainedModels.map(model => renderModelCard(model)).join('');
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
// 渲染单个模型卡片
|
||||
function renderModelCard(model) {
|
||||
const isSelected = selectedModels.has(model.id);
|
||||
// 未合并的已训练模型不可选择
|
||||
const isTrainedNotMerged = model.source === 'trained' && !model.merged;
|
||||
const isDisabled = !isSelected && (selectedModels.size >= 2 || isTrainedNotMerged);
|
||||
const typeText = {
|
||||
'LLM': '大语言模型',
|
||||
'CV': '计算机视觉',
|
||||
'NLP': '自然语言处理',
|
||||
'Embedding': '向量模型',
|
||||
'Other': '其他'
|
||||
}[model.type] || model.type || '其他';
|
||||
// 处理字符串类型的ID
|
||||
const modelId = typeof model.id === 'string' ? `'${model.id.replace(/'/g, "\\'")}'` : model.id;
|
||||
// 未合并提示文字
|
||||
const unmergedTip = isTrainedNotMerged ? '请先合并权重后再选择' : '';
|
||||
|
||||
return `
|
||||
<div class="model-card border-b border-gray-100 last:border-b-0 p-4 cursor-pointer ${isSelected ? 'selected bg-blue-50/50' : ''} ${isDisabled ? 'disabled opacity-50' : ''} hover:bg-gray-50 transition-colors"
|
||||
onclick="${isTrainedNotMerged ? `showMessage('提示', '该模型未合并权重,无法参与对比。请先在模型管理中合并权重。', 'warning')` : `toggleModel(${modelId})`}"
|
||||
title="${unmergedTip}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-1 min-w-0">
|
||||
<div class="w-5 h-5 rounded-full border-2 ${isSelected ? 'border-primary bg-primary' : isTrainedNotMerged ? 'border-gray-300 bg-gray-200' : 'border-gray-300'} flex items-center justify-center mr-3 flex-shrink-0">
|
||||
${isSelected ? '<i class="fa fa-check text-white text-xs"></i>' : ''}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center mb-1">
|
||||
<span class="font-medium text-gray-800 truncate">${model.name}</span>
|
||||
<span class="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded flex-shrink-0">${typeText}</span>
|
||||
${isTrainedNotMerged ? '<span class="ml-2 px-2 py-0.5 bg-orange-100 text-orange-700 text-xs rounded flex-shrink-0"><i class="fa fa-lock mr-1"></i>未合并</span>' : ''}
|
||||
${model.source === 'trained' && model.merged ? '<span class="ml-2 px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded flex-shrink-0"><i class="fa fa-check-circle mr-1"></i>已合并</span>' : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 truncate">${model.description || '暂无描述'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 text-xs text-gray-400 flex-shrink-0">
|
||||
${model.source === 'trained' ? '<i class="fa fa-cog mr-1"></i>已训练' : (model.model_source === 'local' || model.model_source === undefined ? '<i class="fa fa-desktop mr-1"></i>本地' : '<i class="fa fa-cloud mr-1"></i>在线')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 切换模型选择
|
||||
function toggleModel(id) {
|
||||
if (selectedModels.has(id)) {
|
||||
// 移除模型时,释放其占用的端口
|
||||
const config = selectedModels.get(id);
|
||||
if (config && config.port) {
|
||||
usedPorts.delete(parseInt(config.port));
|
||||
}
|
||||
selectedModels.delete(id);
|
||||
} else if (selectedModels.size < 2) {
|
||||
// 选择新模型时,分配默认端口 (7862, 7865, ...)
|
||||
const defaultPorts = [7862, 7865, 7870, 7875, 7880, 7885, 7890, 7895];
|
||||
let defaultPort = 7862;
|
||||
for (const port of defaultPorts) {
|
||||
if (!usedPorts.has(port)) {
|
||||
defaultPort = port;
|
||||
break;
|
||||
}
|
||||
}
|
||||
usedPorts.add(defaultPort);
|
||||
|
||||
selectedModels.set(id, {
|
||||
gpu_id: 0,
|
||||
port: defaultPort
|
||||
});
|
||||
} else {
|
||||
showMessage('提示', '最多只能选择2个模型进行对比', 'warning');
|
||||
return;
|
||||
}
|
||||
renderModels();
|
||||
renderSelectedModelsConfig();
|
||||
}
|
||||
|
||||
// 渲染已选模型的配置区域
|
||||
function renderSelectedModelsConfig() {
|
||||
const configContainer = document.getElementById('configContainer');
|
||||
const selectedConfigDiv = document.getElementById('selectedModelsConfig');
|
||||
|
||||
if (selectedModels.size === 0) {
|
||||
selectedConfigDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedConfigDiv.style.display = 'block';
|
||||
|
||||
let html = '';
|
||||
selectedModels.forEach((config, modelId) => {
|
||||
const model = allModels.find(m => m.id === modelId);
|
||||
if (!model) return;
|
||||
|
||||
// GPU选项 - 确保至少有默认选项
|
||||
const gpuOptions = availableGPUs.length > 0
|
||||
? availableGPUs.map(gpu =>
|
||||
`<option value="${gpu.id}" ${config.gpu_id == gpu.id ? 'selected' : ''}>${gpu.name} (${gpu.memory})</option>`
|
||||
).join('')
|
||||
: '<option value="0">GPU 0 (不可用)</option>';
|
||||
|
||||
// 端口输入框
|
||||
const portInputHtml = `
|
||||
<input type="number" class="form-input"
|
||||
value="${config.port}"
|
||||
min="1024" max="65535"
|
||||
onchange="updateModelConfig('${typeof modelId === 'string' ? modelId.replace(/'/g, "\\'") : modelId}', 'port', this.value)"
|
||||
placeholder="请输入端口号">
|
||||
<p class="text-xs text-gray-400 mt-1">建议范围: 7860-7999</p>
|
||||
`;
|
||||
|
||||
const modelSourceLabel = model.source === 'trained' ? '已训练模型' : '本地模型';
|
||||
|
||||
html += `
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200" data-model-id="${modelId}">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium text-gray-800">${model.name}</span>
|
||||
<span class="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">${modelSourceLabel}</span>
|
||||
</div>
|
||||
<button type="button" onclick="removeModel(${typeof modelId === 'string' ? `'${modelId.replace(/'/g, "\\'")}'` : modelId})"
|
||||
class="text-red-500 hover:text-red-700 text-sm">
|
||||
<i class="fa fa-trash-o mr-1"></i>移除
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">选择GPU <span class="text-red-500">*</span></label>
|
||||
<select class="form-select" onchange="updateModelConfig('${typeof modelId === 'string' ? modelId.replace(/'/g, "\\'") : modelId}', 'gpu_id', this.value)">
|
||||
${gpuOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">启动端口 <span class="text-red-500">*</span></label>
|
||||
${portInputHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
configContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新模型配置
|
||||
window.updateModelConfig = function(modelId, key, value) {
|
||||
const config = selectedModels.get(modelId);
|
||||
if (config) {
|
||||
// 如果是端口变更,需要更新已使用端口
|
||||
if (key === 'port') {
|
||||
usedPorts.delete(config.port);
|
||||
usedPorts.add(parseInt(value));
|
||||
}
|
||||
config[key] = key === 'port' ? parseInt(value) : value;
|
||||
selectedModels.set(modelId, config);
|
||||
// 重新渲染配置区域以更新端口选项
|
||||
renderSelectedModelsConfig();
|
||||
}
|
||||
};
|
||||
|
||||
// 移除模型
|
||||
window.removeModel = function(modelId) {
|
||||
const config = selectedModels.get(modelId);
|
||||
if (config && config.port) {
|
||||
usedPorts.delete(parseInt(config.port));
|
||||
}
|
||||
selectedModels.delete(modelId);
|
||||
renderModels();
|
||||
renderSelectedModelsConfig();
|
||||
};
|
||||
|
||||
// 暴露到全局作用域供 onclick 调用
|
||||
window.toggleModel = toggleModel;
|
||||
window.goBack = goBack;
|
||||
window.submitForm = submitForm;
|
||||
|
||||
// 筛选模型
|
||||
function filterModels(keyword) {
|
||||
const filtered = allModels.filter(model => {
|
||||
const name = model.name?.toLowerCase() || '';
|
||||
const desc = model.description?.toLowerCase() || '';
|
||||
const type = model.type?.toLowerCase() || '';
|
||||
const kw = keyword.toLowerCase();
|
||||
return name.includes(kw) || desc.includes(kw) || type.includes(kw);
|
||||
});
|
||||
renderFilteredModels(filtered);
|
||||
}
|
||||
|
||||
// 渲染筛选后的模型列表 - 使用分栏显示
|
||||
function renderFilteredModels(models) {
|
||||
const container = document.getElementById('modelList');
|
||||
if (models.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<i class="fa fa-search text-2xl mb-2"></i>
|
||||
<p>未找到匹配的模型</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 分离数据库模型和已训练模型
|
||||
const dbModels = models.filter(m => m.source !== 'trained');
|
||||
const trainedModels = models.filter(m => m.source === 'trained');
|
||||
|
||||
let html = '';
|
||||
|
||||
// 数据库模型区域
|
||||
if (dbModels.length > 0) {
|
||||
html += `
|
||||
<div class="border-b border-gray-200">
|
||||
<div class="px-4 py-2 bg-gray-50 text-xs font-medium text-gray-600 flex items-center">
|
||||
<i class="fa fa-database mr-1"></i> 数据库模型 (${dbModels.length})
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
`;
|
||||
html += dbModels.map(model => renderModelCard(model)).join('');
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 已训练模型区域
|
||||
if (trainedModels.length > 0) {
|
||||
const mergedCount = trainedModels.filter(m => m.merged).length;
|
||||
html += `
|
||||
<div class="border-b border-gray-200">
|
||||
<div class="px-4 py-2 bg-green-50 text-xs font-medium text-green-700 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fa fa-check-circle mr-1"></i> 已训练模型 (${trainedModels.length})
|
||||
</div>
|
||||
<span class="text-orange-600">${mergedCount > 0 ? `${mergedCount}个已合并可选择` : '需合并后才可选择'}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
`;
|
||||
html += trainedModels.map(model => renderModelCard(model)).join('');
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新已选数量
|
||||
function updateSelectedCount() {
|
||||
document.getElementById('selectedCount').textContent = selectedModels.size;
|
||||
}
|
||||
|
||||
// 显示消息弹窗
|
||||
function showMessage(title, message, type = 'info', onConfirm) {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn2');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
if (type === 'success') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center"><i class="fa fa-check text-xl text-green-600"></i></div>';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
} else if (type === 'error') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center"><i class="fa fa-times text-xl text-red-600"></i></div>';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-danger text-white rounded-lg hover:bg-danger/90 transition-colors';
|
||||
} else if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-info text-xl text-blue-600"></i></div>';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
modalConfirmBtn.onclick = () => {
|
||||
closeModal();
|
||||
if (onConfirm) onConfirm();
|
||||
};
|
||||
}
|
||||
|
||||
// 关闭消息弹窗
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('customModal');
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function submitForm() {
|
||||
const form = document.getElementById('createForm');
|
||||
const formData = new FormData(form);
|
||||
const name = formData.get('name').trim();
|
||||
const description = formData.get('description').trim();
|
||||
|
||||
if (!name) {
|
||||
showMessage('提示', '请输入对比名称', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedModels.size < 2) {
|
||||
showMessage('提示', '请选择2个模型进行对比', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建模型配置数据
|
||||
const modelsConfig = [];
|
||||
selectedModels.forEach((config, modelId) => {
|
||||
const model = allModels.find(m => m.id === modelId);
|
||||
if (model) {
|
||||
modelsConfig.push({
|
||||
model_id: modelId,
|
||||
model_name: model.name,
|
||||
model_path: model.model_path || model.path || '',
|
||||
gpu_id: parseInt(config.gpu_id) || 0,
|
||||
port: parseInt(config.port) || 7862,
|
||||
source: model.source || 'database'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
name: name,
|
||||
description: description,
|
||||
models: modelsConfig,
|
||||
status: 'pending',
|
||||
create_time: new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')
|
||||
};
|
||||
|
||||
console.log('[DEBUG] 提交数据:', JSON.stringify(data, null, 2));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-compare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
showMessage('成功', '对比任务创建成功!', 'success', () => {
|
||||
window.location.href = 'main.html?page=model-compare';
|
||||
});
|
||||
} else {
|
||||
showMessage('错误', result.message || '创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('错误', '创建失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 绑定侧边栏导航点击事件
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const page = this.dataset.page;
|
||||
window.location.href = `main.html?page=${page}`;
|
||||
});
|
||||
});
|
||||
|
||||
// 绑定输入事件
|
||||
const nameInput = document.querySelector('input[name="name"]');
|
||||
const descInput = document.querySelector('textarea[name="description"]');
|
||||
|
||||
if (nameInput) {
|
||||
nameInput.addEventListener('input', () => {
|
||||
document.getElementById('nameCount').textContent = nameInput.value.length;
|
||||
});
|
||||
}
|
||||
|
||||
if (descInput) {
|
||||
descInput.addEventListener('input', () => {
|
||||
document.getElementById('descCount').textContent = descInput.value.length;
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定模型搜索框事件
|
||||
const modelSearchInput = document.getElementById('modelSearchInput');
|
||||
if (modelSearchInput) {
|
||||
modelSearchInput.addEventListener('input', (e) => {
|
||||
const keyword = e.target.value.trim();
|
||||
if (keyword) {
|
||||
filterModels(keyword);
|
||||
} else {
|
||||
renderModels();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载模型列表和GPU信息
|
||||
loadModels();
|
||||
|
||||
// 设置侧边栏当前页高亮
|
||||
const currentPage = 'model-compare';
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.dataset.page === currentPage) {
|
||||
link.classList.add('sidebar-item-active');
|
||||
link.classList.remove('hover:bg-[#001529]/20');
|
||||
}
|
||||
});
|
||||
updateSidebarSlider();
|
||||
});
|
||||
|
||||
// 更新侧边栏滑块位置
|
||||
function updateSidebarSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
const activeLink = document.querySelector('.nav-link.bg-\\[\\#1890ff\\]\\/10');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
811
web/pages/model-compare-result.html
Normal file
811
web/pages/model-compare-result.html
Normal file
@@ -0,0 +1,811 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模型对比结果 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-compare';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
:root { --primary: #1890ff; }
|
||||
|
||||
/* 卡片内容区域滚动条样式 */
|
||||
.markdown-content {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db transparent;
|
||||
}
|
||||
.markdown-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.markdown-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.markdown-content::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Markdown 基础样式 */
|
||||
.markdown-content h1 { font-size: 1.5em; font-weight: bold; margin: 0.5em 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3em; }
|
||||
.markdown-content h2 { font-size: 1.25em; font-weight: bold; margin: 0.5em 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }
|
||||
.markdown-content h3 { font-size: 1.1em; font-weight: bold; margin: 0.4em 0; }
|
||||
.markdown-content p { margin: 0.5em 0; line-height: 1.6; }
|
||||
.markdown-content ul, .markdown-content ol { margin: 0.5em 0; padding-left: 2em; }
|
||||
.markdown-content li { margin: 0.25em 0; }
|
||||
.markdown-content code { background-color: #f3f4f6; padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; font-size: 0.9em; }
|
||||
.markdown-content pre { background-color: #1f2937; color: #f9fafb; padding: 1em; border-radius: 8px; overflow-x: auto; margin: 0.5em 0; }
|
||||
.markdown-content pre code { background: none; padding: 0; color: inherit; }
|
||||
.markdown-content blockquote { border-left: 4px solid #1890ff; padding-left: 1em; margin: 0.5em 0; color: #6b7280; }
|
||||
.markdown-content table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||
.markdown-content th, .markdown-content td { border: 1px solid #d1d5db; padding: 0.5em 1em; text-align: left; }
|
||||
.markdown-content th { background-color: #f9fafb; font-weight: bold; }
|
||||
.markdown-content tr:nth-child(even) { background-color: #f9fafb; }
|
||||
.markdown-content a { color: #1890ff; text-decoration: none; }
|
||||
.markdown-content a:hover { text-decoration: underline; }
|
||||
.markdown-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 1em 0; }
|
||||
.markdown-content strong { font-weight: bold; }
|
||||
.markdown-content em { font-style: italic; }
|
||||
.markdown-content del { text-decoration: line-through; color: #9ca3af; }
|
||||
|
||||
/* 流式输出光标 */
|
||||
.typing-cursor::after {
|
||||
content: '|';
|
||||
animation: blink 1s infinite;
|
||||
color: var(--primary);
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-6">
|
||||
<button class="md:hidden text-gray-500 hover:text-gray-700">
|
||||
<i class="fa fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 系统性能监控 -->
|
||||
<a href="?page=config" class="flex items-center space-x-4 text-xs text-gray-500 hover:text-primary transition-colors">
|
||||
<div class="flex items-center" title="CPU使用率">
|
||||
<i class="fa fa-microchip mr-1 text-blue-500"></i>
|
||||
<span id="cpuUsage">--</span>%
|
||||
</div>
|
||||
<div class="flex items-center" title="内存使用率">
|
||||
<i class="fa fa-database mr-1 text-green-500"></i>
|
||||
<span id="memUsage">--</span>%
|
||||
</div>
|
||||
<div class="flex items-center" title="磁盘使用率">
|
||||
<i class="fa fa-hdd-o mr-1 text-orange-500"></i>
|
||||
<span id="diskUsage">--</span>%
|
||||
</div>
|
||||
</a>
|
||||
<div class="h-6 w-px bg-gray-200"></div>
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full pt-2 hidden group-hover:block z-50">
|
||||
<div class="bg-white rounded shadow-lg py-1 border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-hidden flex flex-col">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-2 flex items-center justify-end flex-shrink-0">
|
||||
<button id="restartBtn" class="px-3 py-1 bg-primary text-white rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-refresh mr-1"></i>重新提问
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 用户提问显示区 -->
|
||||
<div class="px-6 py-4 flex-shrink-0">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 bg-green-500 rounded-full px-3 py-1 mr-3">
|
||||
<span class="text-white text-xs font-medium">用户问题</span>
|
||||
</div>
|
||||
<div class="bg-green-100 rounded-2xl rounded-tl-sm px-5 py-3 max-w-3xl shadow-sm">
|
||||
<span class="text-green-800 text-base leading-relaxed" id="questionText"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型输出网格(2x2) -->
|
||||
<div id="outputGrid" class="grid grid-cols-2 gap-4 flex-1 min-h-0">
|
||||
<!-- 输出卡片将通过JS动态生成 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
console.log('>>> model-compare-result.html 脚本开始加载');
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 模拟模型数据(用于演示)
|
||||
const mockModels = [
|
||||
{ id: 1, name: 'GPT-4', type: 'LLM', description: 'OpenAI GPT-4 大语言模型' },
|
||||
{ id: 2, name: 'Claude-3', type: 'LLM', description: 'Anthropic Claude-3 模型' },
|
||||
{ id: 3, name: '文心一言', type: 'LLM', description: '百度文心一言大模型' },
|
||||
{ id: 4, name: '通义千问', type: 'LLM', description: '阿里通义千问大模型' },
|
||||
{ id: 5, name: 'ChatGLM', type: 'LLM', description: '智谱ChatGLM对话模型' },
|
||||
{ id: 6, name: '星火认知', type: 'LLM', description: '讯飞星火认知大模型' }
|
||||
];
|
||||
|
||||
let allModels = [];
|
||||
let selectedModelIds = [];
|
||||
let currentStreamingIntervals = [];
|
||||
let compareTaskId = null;
|
||||
let taskName = '';
|
||||
let userQuestion = '';
|
||||
let systemPrompt = '';
|
||||
let temperature = 0.7;
|
||||
let topP = 0.9;
|
||||
let topK = 50;
|
||||
let maxTokens = 2048;
|
||||
let chatResults = [];
|
||||
let useLocalStorageResults = false; // 是否使用localStorage中的结果
|
||||
|
||||
// 模拟回复内容
|
||||
const mockResponses = [
|
||||
"这是一个基于深度学习的自然语言处理模型回答。我可以处理各种复杂的问题,包括代码编写、文本分析、知识问答等多种任务。我的训练数据涵盖了广泛的领域知识,能够提供准确和有用的信息。",
|
||||
"您好!我是一个人工智能语言模型,很高兴为您服务。我可以帮助您解答问题、提供建议、进行创意写作等。如果您有任何需要,请随时告诉我,我会尽力提供帮助。",
|
||||
"这是一个很有趣的问题!从技术角度来看,我们需要考虑多个因素:数据质量、模型架构、训练策略等。深度学习在近年来取得了巨大进展,但仍然存在一些挑战需要解决。",
|
||||
"根据我的理解,这个问题涉及到以下几个方面:首先,需要明确问题的具体背景;其次,要分析相关的技术方案;最后,需要评估实施的成本和收益。建议您先收集更多信息再做决定。"
|
||||
];
|
||||
|
||||
// 模型类型常量
|
||||
const MODEL_TYPE = {
|
||||
API: 'api', // API调用模型(如OpenAI、百度等)
|
||||
LOCAL: 'local', // 本地模型(vLLM)
|
||||
TRAINED: 'trained' // 训练后的模型(llamafactory)
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
async function initPage() {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
compareTaskId = urlParams.get('taskId');
|
||||
taskName = urlParams.get('taskName') || '对比任务';
|
||||
userQuestion = decodeURIComponent(urlParams.get('question') || '');
|
||||
systemPrompt = decodeURIComponent(urlParams.get('systemPrompt') || '');
|
||||
temperature = parseFloat(urlParams.get('temperature') || 0.7);
|
||||
topP = parseFloat(urlParams.get('topP') || 0.9);
|
||||
topK = parseInt(urlParams.get('topK') || 50);
|
||||
maxTokens = parseInt(urlParams.get('maxTokens') || 2048);
|
||||
const needRealData = urlParams.get('real') === '1';
|
||||
|
||||
console.log('[INIT] 推理参数:', { temperature, topP, topK, maxTokens, systemPrompt });
|
||||
|
||||
// 设置用户提问
|
||||
const questionTextEl = document.getElementById('questionText');
|
||||
if (questionTextEl) {
|
||||
questionTextEl.textContent = userQuestion;
|
||||
}
|
||||
|
||||
// 加载模型列表和任务数据
|
||||
await Promise.all([loadModels(), loadCompareTask()]);
|
||||
|
||||
// 初始化输出卡片(显示加载中状态)
|
||||
initializeOutputCards(true);
|
||||
|
||||
// 读取 localStorage 结果
|
||||
let shouldUseStoredResults = false;
|
||||
const storedResults = localStorage.getItem('chatResults');
|
||||
|
||||
if (storedResults && !needRealData) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedResults);
|
||||
const isRecent = (Date.now() - parsed.timestamp) < 5 * 60 * 1000;
|
||||
if (parsed.results && isRecent) {
|
||||
chatResults = parsed.results;
|
||||
shouldUseStoredResults = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析localStorage失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要真实数据或没有缓存结果,则调用API
|
||||
if (needRealData || !shouldUseStoredResults) {
|
||||
if (selectedModelIds.length > 0 && userQuestion) {
|
||||
await fetchChatResults();
|
||||
// 存储到 localStorage
|
||||
localStorage.setItem('chatResults', JSON.stringify({
|
||||
results: chatResults,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 重新初始化卡片(移除加载状态)
|
||||
initializeOutputCards(false);
|
||||
|
||||
// 开始流式输出
|
||||
simulateStreaming();
|
||||
} catch (err) {
|
||||
console.error('初始化失败:', err);
|
||||
const contentGrid = document.getElementById('outputGrid');
|
||||
if (contentGrid) {
|
||||
contentGrid.innerHTML = `<div class="text-red-500 p-8">初始化失败: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 transformers 本地模型进行对话
|
||||
async function chatWithLocalModel(modelPath, modelName) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-chat/local/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_path: modelPath,
|
||||
system_prompt: systemPrompt,
|
||||
user_question: userQuestion,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
return {
|
||||
model_id: modelName,
|
||||
model_name: modelName,
|
||||
success: true,
|
||||
response: result.data.response || ''
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
model_id: modelName,
|
||||
model_name: modelName,
|
||||
success: false,
|
||||
error: result.message || '推理失败'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
model_id: modelName,
|
||||
model_name: modelName,
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 调用批量对话 API 获取结果
|
||||
async function fetchChatResults() {
|
||||
try {
|
||||
// 1. 预加载本地和训练后的模型
|
||||
console.log('[FETCH] 开始预加载本地/训练模型...');
|
||||
const { localModelsNeedingPreload, trainedModelsNeedingPreload } = await preloadAllModels(selectedModelIds);
|
||||
|
||||
// 2. 获取所有模型详情,按类型分组
|
||||
const apiModels = []; // API 调用模型 (使用 batch API)
|
||||
const localModels = []; // 本地 transformers 模型
|
||||
const trainedModels = []; // 训练后的 llamafactory 模型
|
||||
|
||||
for (const modelId of selectedModelIds) {
|
||||
const model = await getModelDetails(modelId);
|
||||
if (model) {
|
||||
const modelType = getModelType(model);
|
||||
if (modelType === MODEL_TYPE.API) {
|
||||
apiModels.push(modelId);
|
||||
} else if (modelType === MODEL_TYPE.LOCAL) {
|
||||
localModels.push({ id: modelId, name: model.name || modelId, path: model.path });
|
||||
} else if (modelType === MODEL_TYPE.TRAINED) {
|
||||
trainedModels.push({ id: modelId, name: model.name || modelId, basePath: model.path || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 收集结果
|
||||
let results = [];
|
||||
|
||||
// API 模型使用批量接口
|
||||
if (apiModels.length > 0) {
|
||||
console.log(`[FETCH] 调用 API 模型批量接口: ${apiModels.length} 个模型`);
|
||||
const response = await fetch(`${API_BASE}/model-chat/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_ids: apiModels,
|
||||
system_prompt: systemPrompt,
|
||||
user_question: userQuestion,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
results = results.concat(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地 transformers 模型使用单独接口
|
||||
for (const lm of localModels) {
|
||||
console.log(`[FETCH] 调用本地模型: ${lm.name}, 路径: ${lm.path}`);
|
||||
const result = await chatWithLocalModel(lm.path, lm.name);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// 训练后的 llamafactory 模型使用单独接口(如果有的话)
|
||||
// 这里假设 batch API 也能处理训练后的模型,如果没有则需要单独实现
|
||||
|
||||
chatResults = results;
|
||||
console.log(`[FETCH] 共获取 ${results.length} 个模型的结果`);
|
||||
} catch (error) {
|
||||
console.error('调用API失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载对比任务数据
|
||||
async function loadCompareTask() {
|
||||
if (!compareTaskId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-compare/${compareTaskId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
const taskData = Array.isArray(result.data)
|
||||
? result.data.find(item => item && item.id == compareTaskId)
|
||||
: result.data;
|
||||
|
||||
if (taskData) {
|
||||
const modelsField = taskData.models;
|
||||
if (modelsField) {
|
||||
try {
|
||||
if (typeof modelsField === 'string') {
|
||||
selectedModelIds = JSON.parse(modelsField);
|
||||
} else if (Array.isArray(modelsField)) {
|
||||
selectedModelIds = modelsField;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析模型列表失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对比任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
allModels = result.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模型详情
|
||||
async function getModelDetails(modelId) {
|
||||
// 支持 ID 数字或模型名称字符串
|
||||
const model = allModels.find(m =>
|
||||
m.id == modelId || m.id === modelId || m.name === modelId
|
||||
);
|
||||
if (model) return model;
|
||||
|
||||
// 如果没找到,尝试从 API 获取
|
||||
try {
|
||||
// 判断是数字ID还是名称
|
||||
const isNumericId = /^\d+$/.test(modelId);
|
||||
let apiUrl;
|
||||
if (isNumericId) {
|
||||
apiUrl = `${API_BASE}/model-manage/${modelId}`;
|
||||
} else {
|
||||
// 名称使用 name/<model_name> 端点
|
||||
apiUrl = `${API_BASE}/model-manage/name/${encodeURIComponent(modelId)}`;
|
||||
}
|
||||
const response = await fetch(apiUrl);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
return result.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模型详情失败:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断模型类型
|
||||
function getModelType(model) {
|
||||
if (!model) return null;
|
||||
const source = model.model_source || '';
|
||||
// 训练后的模型
|
||||
if (source === 'trained' || model.is_trained === true) {
|
||||
return MODEL_TYPE.TRAINED;
|
||||
}
|
||||
// 本地模型 (path 以 :// 开头表示 vLLM API 地址,或者是 transformers 本地路径)
|
||||
if (source === 'local' || (model.path && model.path.includes('://'))) {
|
||||
return MODEL_TYPE.LOCAL;
|
||||
}
|
||||
// API 调用模型
|
||||
if (source === 'api') {
|
||||
return MODEL_TYPE.API;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断是否为本地模型或训练后的模型
|
||||
function isLocalOrTrainedModel(model) {
|
||||
const type = getModelType(model);
|
||||
return type === MODEL_TYPE.LOCAL || type === MODEL_TYPE.TRAINED;
|
||||
}
|
||||
|
||||
// 预加载训练后的模型 (使用 llamafactory)
|
||||
async function preloadTrainedModel(modelName, baseModelPath) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-chat/trained/preload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_name: modelName,
|
||||
train_method: 'lora',
|
||||
base_model_path: baseModelPath
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
console.log(`[PRELOAD] 训练模型 ${modelName} 预加载成功`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[PRELOAD] 训练模型 ${modelName} 预加载失败: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PRELOAD] 训练模型 ${modelName} 预加载异常:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载本地模型 (使用 transformers)
|
||||
async function preloadLocalModel(modelPath, modelName) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-chat/local/preload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_path: modelPath,
|
||||
model_name: modelName || '本地模型'
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
console.log(`[PRELOAD] 本地模型 ${modelName} 预加载成功`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[PRELOAD] 本地模型 ${modelName} 预加载失败: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PRELOAD] 本地模型 ${modelName} 预加载异常:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新卡片状态显示
|
||||
function updateCardStatus(modelId, status, message) {
|
||||
const statusEl = document.getElementById(`status-${modelId}`);
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `<i class="fa ${status === 'loading' ? 'fa-spinner fa-spin' : status === 'success' ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'} mr-1"></i> ${message}`;
|
||||
if (status === 'loading') {
|
||||
statusEl.classList.remove('text-gray-400', 'text-green-500', 'text-red-500');
|
||||
statusEl.classList.add('text-primary');
|
||||
} else if (status === 'success') {
|
||||
statusEl.classList.remove('text-primary', 'text-gray-400', 'text-red-500');
|
||||
statusEl.classList.add('text-green-500');
|
||||
} else if (status === 'error') {
|
||||
statusEl.classList.remove('text-primary', 'text-gray-400', 'text-green-500');
|
||||
statusEl.classList.add('text-red-500');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载所有本地和训练后的模型
|
||||
async function preloadAllModels(modelIds) {
|
||||
const localModelsNeedingPreload = [];
|
||||
const trainedModelsNeedingPreload = [];
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
const model = await getModelDetails(modelId);
|
||||
if (model) {
|
||||
const modelType = getModelType(model);
|
||||
|
||||
// 本地模型 (transformers)
|
||||
if (modelType === MODEL_TYPE.LOCAL) {
|
||||
localModelsNeedingPreload.push({
|
||||
id: modelId,
|
||||
name: model.name || modelId,
|
||||
path: model.path || ''
|
||||
});
|
||||
}
|
||||
// 训练后的模型 (llamafactory)
|
||||
if (modelType === MODEL_TYPE.TRAINED) {
|
||||
trainedModelsNeedingPreload.push({
|
||||
id: modelId,
|
||||
name: model.name || modelId,
|
||||
basePath: model.path || model.base_model_path || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载本地模型 (transformers)
|
||||
if (localModelsNeedingPreload.length > 0) {
|
||||
// 更新所有本地模型的状态为"加载中"
|
||||
for (const lm of localModelsNeedingPreload) {
|
||||
updateCardStatus(lm.id, 'loading', '正在加载模型...');
|
||||
}
|
||||
|
||||
// 预加载每个本地模型
|
||||
for (const lm of localModelsNeedingPreload) {
|
||||
console.log(`[PRELOAD] 开始预加载本地模型: ${lm.name}, 路径: ${lm.path}`);
|
||||
await preloadLocalModel(lm.path, lm.name);
|
||||
}
|
||||
|
||||
// 更新状态为"加载完成"
|
||||
for (const lm of localModelsNeedingPreload) {
|
||||
updateCardStatus(lm.id, 'success', '模型已加载');
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载训练后的模型 (llamafactory)
|
||||
if (trainedModelsNeedingPreload.length > 0) {
|
||||
// 更新所有训练模型的状态为"加载中"
|
||||
for (const tm of trainedModelsNeedingPreload) {
|
||||
updateCardStatus(tm.id, 'loading', '正在加载模型...');
|
||||
}
|
||||
|
||||
// 预加载每个训练模型
|
||||
for (const tm of trainedModelsNeedingPreload) {
|
||||
console.log(`[PRELOAD] 开始预加载训练模型: ${tm.name}`);
|
||||
await preloadTrainedModel(tm.name, tm.basePath);
|
||||
}
|
||||
|
||||
// 更新状态为"加载完成"
|
||||
for (const tm of trainedModelsNeedingPreload) {
|
||||
updateCardStatus(tm.id, 'success', '模型已加载');
|
||||
}
|
||||
}
|
||||
|
||||
return { localModelsNeedingPreload, trainedModelsNeedingPreload };
|
||||
}
|
||||
|
||||
// 初始化输出卡片
|
||||
function initializeOutputCards(showLoading = false) {
|
||||
const grid = document.getElementById('outputGrid');
|
||||
const allAvailableModels = [...allModels, ...mockModels];
|
||||
|
||||
// 如果没有选择模型,使用前4个演示模型
|
||||
const displayModelIds = selectedModelIds.length > 0
|
||||
? selectedModelIds.slice(0, 4)
|
||||
: [1, 2, 3, 4];
|
||||
|
||||
const statusText = showLoading ? '准备中...' : '等待中';
|
||||
const statusIcon = showLoading ? 'fa-spinner fa-spin' : 'fa-clock-o';
|
||||
const statusClass = showLoading ? 'text-primary' : 'text-gray-400';
|
||||
const contentText = showLoading ? '<span class="text-gray-400">正在加载模型并准备推理...</span>' : '<span class="text-gray-300">模型即将开始生成回答...</span>';
|
||||
|
||||
grid.innerHTML = displayModelIds.map((modelId, index) => {
|
||||
// 支持 ID 数字或模型名称字符串匹配
|
||||
const model = allAvailableModels.find(m =>
|
||||
m.id == modelId || m.id === modelId || m.name === modelId
|
||||
) || { name: modelId };
|
||||
const colors = ['bg-blue-100 text-blue-700', 'bg-green-100 text-green-700', 'bg-purple-100 text-purple-700', 'bg-orange-100 text-orange-700'];
|
||||
const colorClass = colors[index % colors.length];
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow-sm flex flex-col h-full" id="output-${modelId}">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 flex-shrink-0">
|
||||
<span class="px-3 py-1 rounded text-sm font-medium ${colorClass}">${model.name}</span>
|
||||
<span id="status-${modelId}" class="text-xs ${statusClass} flex items-center">
|
||||
<i class="fa ${statusIcon} mr-1"></i>
|
||||
${statusText}
|
||||
</span>
|
||||
</div>
|
||||
<div id="content-${modelId}" class="markdown-content flex-1 p-8 text-base text-gray-600 leading-relaxed">
|
||||
${contentText}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 模拟流式输出
|
||||
function simulateStreaming() {
|
||||
currentStreamingIntervals.forEach(interval => clearInterval(interval));
|
||||
currentStreamingIntervals = [];
|
||||
|
||||
// 禁用重新提问按钮
|
||||
const btn = document.getElementById('restartBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
|
||||
const displayModelIds = selectedModelIds.length > 0
|
||||
? selectedModelIds.slice(0, 4)
|
||||
: [1, 2, 3, 4];
|
||||
|
||||
displayModelIds.forEach((modelId, index) => {
|
||||
setTimeout(() => {
|
||||
streamModelResponse(modelId, index);
|
||||
}, index * 500);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型对应的真实响应
|
||||
function getRealResponse(modelId) {
|
||||
if (chatResults.length === 0) return null;
|
||||
const result = chatResults.find(r => r.model_id == modelId || r.modelId == modelId);
|
||||
if (result && result.response && result.response.trim() !== '') {
|
||||
return result.response;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取错误信息
|
||||
function getErrorMessage(modelId) {
|
||||
if (chatResults.length === 0) return null;
|
||||
const result = chatResults.find(r => r.model_id == modelId || r.modelId == modelId);
|
||||
// 如果有 error 字段且 response 为空,返回错误信息
|
||||
if (result && result.error && (!result.response || result.response.trim() === '')) {
|
||||
return result.error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 流式输出单个模型
|
||||
function streamModelResponse(modelId, responseIndex) {
|
||||
const contentEl = document.getElementById(`content-${modelId}`);
|
||||
const statusEl = document.getElementById(`status-${modelId}`);
|
||||
|
||||
if (!contentEl || !statusEl) return;
|
||||
|
||||
statusEl.innerHTML = '<i class="fa fa-spinner fa-spin mr-1"></i> 生成中...';
|
||||
statusEl.classList.remove('text-gray-400');
|
||||
statusEl.classList.add('text-primary');
|
||||
|
||||
contentEl.innerHTML = '';
|
||||
contentEl.classList.add('typing-cursor');
|
||||
|
||||
// 获取响应内容(真实数据或模拟数据)
|
||||
const realResponse = getRealResponse(modelId);
|
||||
const errorMessage = getErrorMessage(modelId);
|
||||
|
||||
let response;
|
||||
let isRealData = false;
|
||||
if (realResponse) {
|
||||
response = realResponse;
|
||||
isRealData = true;
|
||||
} else if (errorMessage) {
|
||||
response = `[错误] ${errorMessage}`;
|
||||
} else {
|
||||
response = mockResponses[responseIndex % mockResponses.length];
|
||||
}
|
||||
|
||||
// 对于错误信息直接显示,不进行 Markdown 渲染
|
||||
const isError = errorMessage && !realResponse;
|
||||
// 标记是否需要 Markdown 渲染(真实数据需要)
|
||||
const needMarkdown = isRealData && !isError;
|
||||
|
||||
// 统计信息
|
||||
const startTime = Date.now();
|
||||
let firstCharTime = null;
|
||||
let charIndex = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (charIndex < response.length) {
|
||||
// 记录第一个字符输出的时间
|
||||
if (charIndex === 0) {
|
||||
firstCharTime = Date.now();
|
||||
}
|
||||
// 流式输出期间直接显示 Markdown 格式
|
||||
const currentText = response.substring(0, charIndex + 1);
|
||||
if (needMarkdown) {
|
||||
contentEl.innerHTML = marked.parse(currentText);
|
||||
} else {
|
||||
contentEl.textContent = currentText;
|
||||
}
|
||||
charIndex++;
|
||||
contentEl.scrollTop = contentEl.scrollHeight;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
const idx = currentStreamingIntervals.indexOf(interval);
|
||||
if (idx > -1) currentStreamingIntervals.splice(idx, 1);
|
||||
contentEl.classList.remove('typing-cursor');
|
||||
|
||||
// 计算统计信息
|
||||
const totalTime = (Date.now() - startTime) / 1000;
|
||||
const firstCharLatency = firstCharTime ? ((firstCharTime - startTime) / 1000).toFixed(2) : 0;
|
||||
const charCount = response.length;
|
||||
const speed = totalTime > 0 ? (charCount / totalTime).toFixed(1) : 0;
|
||||
|
||||
// 根据状态更新UI
|
||||
if (isError) {
|
||||
statusEl.innerHTML = '<i class="fa fa-times-circle text-red-500 mr-1"></i> 失败';
|
||||
statusEl.classList.remove('text-primary');
|
||||
statusEl.classList.add('text-red-500');
|
||||
} else {
|
||||
statusEl.innerHTML = `<i class="fa fa-check-circle text-green-500 mr-1"></i> 完成 <span class="text-gray-400 ml-2">${speed} 字/秒 · ${totalTime.toFixed(1)}秒 · 首字 ${firstCharLatency}秒</span>`;
|
||||
statusEl.classList.remove('text-primary');
|
||||
statusEl.classList.add('text-green-500');
|
||||
}
|
||||
|
||||
// 所有模型完成时启用按钮
|
||||
if (currentStreamingIntervals.length === 0) {
|
||||
const btn = document.getElementById('restartBtn');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, needMarkdown ? 20 : 30 + Math.random() * 40);
|
||||
|
||||
currentStreamingIntervals.push(interval);
|
||||
}
|
||||
|
||||
// 重新提问
|
||||
function restartQuestion() {
|
||||
window.location.href = `main.html?page=model-compare-chat&id=${compareTaskId}`;
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
setTimeout(() => {
|
||||
initPage().catch(err => console.error('初始化失败:', err));
|
||||
}, 50);
|
||||
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
if (restartBtn) {
|
||||
restartBtn.onclick = restartQuestion;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1218
web/pages/model-dimension-create.html
Normal file
1218
web/pages/model-dimension-create.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>新建评测 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-eval';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.sidebar-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(191, 203, 217, 0.7);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0, 21, 41, 0.2);
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -74,69 +80,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<!-- 平台LOGO区域 -->
|
||||
<div class="p-4 border-b border-[#001529]/30 flex items-center">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-6 h-6 object-contain mr-2">
|
||||
<span class="text-white font-medium">远光软件微调平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航主区域 -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<!-- 第一分区:模型服务 -->
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<a href="main.html" data-page="fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cogs w-5 text-center"></i>
|
||||
<span class="ml-2">模型调优</span>
|
||||
</a>
|
||||
<a href="main.html?page=my-models" data-page="my-models" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">我的模型</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-eval" data-page="model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-line-chart w-5 text-center"></i>
|
||||
<span class="ml-2">模型评测</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-deploy" data-page="model-deploy" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-server w-5 text-center"></i>
|
||||
<span class="ml-2">模型部署</span>
|
||||
</a>
|
||||
|
||||
<!-- 第二分区:资源管理 -->
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<a href="main.html?page=model-manage" data-page="model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cube w-5 text-center"></i>
|
||||
<span class="ml-2">模型管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=dataset-manage" data-page="dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">数据集管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=data-generate" data-page="data-generate" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">其他工具</span>
|
||||
</a>
|
||||
|
||||
<!-- 第三分区:系统设置 -->
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<a href="main.html?page=config" data-page="config" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-bar-chart w-5 text-center"></i>
|
||||
<span class="ml-2">平台性能</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 底部信息区域 -->
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#bfcbd9]">版本 v1.0.0</span>
|
||||
<i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
@@ -227,18 +172,18 @@
|
||||
</label>
|
||||
<div class="flex items-center space-x-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="data_source" value="dataset" class="mr-2" onchange="toggleDataSource('dataset')">
|
||||
<input type="radio" name="data_source" value="dataset" checked class="mr-2" onchange="toggleDataSource('dataset')">
|
||||
<span class="text-sm text-gray-700">评测数据集</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="data_source" value="inference" checked class="mr-2" onchange="toggleDataSource('inference')">
|
||||
<input type="radio" name="data_source" value="inference" class="mr-2" onchange="toggleDataSource('inference')">
|
||||
<span class="text-sm text-gray-700">推理结果集</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推理结果集上传 -->
|
||||
<div id="inferenceUpload" class="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<div id="inferenceUpload" class="hidden border-2 border-dashed border-gray-200 rounded-lg p-8 text-center hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<i class="fa fa-cloud-upload text-gray-400 text-xl mb-2"></i>
|
||||
<p class="text-gray-700 text-sm">点击或拖拽上传推理结果集</p>
|
||||
<p class="text-xs text-gray-400 mt-1">支持 .xls .xlsx 格式,不超过2MB</p>
|
||||
@@ -248,13 +193,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 评测数据集选择 -->
|
||||
<div id="datasetSelect" class="hidden mb-6">
|
||||
<div id="datasetSelect" class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">评测数据集</h3>
|
||||
<div class="flex items-center">
|
||||
<select name="dataset_id" class="form-select flex-1 max-w-md">
|
||||
<select name="dataset_id" id="testDatasetSelect" class="form-select flex-1 max-w-md">
|
||||
<option value="">请选择评测数据集</option>
|
||||
</select>
|
||||
<button type="button" class="ml-2 text-primary text-sm flex items-center hover:text-primary/80">
|
||||
<button type="button" class="ml-2 text-primary text-sm flex items-center hover:text-primary/80" onclick="loadTestDatasets()">
|
||||
<i class="fa fa-refresh"></i>
|
||||
</button>
|
||||
<button type="button" class="ml-3 bg-white border border-primary text-primary rounded px-3 py-1.5 text-sm hover:bg-primary/5" onclick="window.location.href='dataset-create.html'">
|
||||
@@ -267,18 +212,13 @@
|
||||
<div class="mb-6" id="evalRulesSection">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">评测规则</h3>
|
||||
<div class="flex items-center">
|
||||
<select name="eval_dimension" class="form-select flex-1 max-w-md">
|
||||
<select name="eval_dimension" id="dimensionSelect" class="form-select flex-1 max-w-md">
|
||||
<option value="">请选择评测维度</option>
|
||||
<option value="accuracy">准确率</option>
|
||||
<option value="recall">召回率</option>
|
||||
<option value="f1">F1值</option>
|
||||
<option value="bleu">BLEU</option>
|
||||
<option value="rouge">ROUGE</option>
|
||||
</select>
|
||||
<button type="button" class="ml-2 text-primary text-sm flex items-center hover:text-primary/80">
|
||||
<button type="button" class="ml-2 text-primary text-sm flex items-center hover:text-primary/80" onclick="loadDimensions()">
|
||||
<i class="fa fa-refresh"></i>
|
||||
</button>
|
||||
<button type="button" class="ml-3 bg-white border border-primary text-primary rounded px-3 py-1.5 text-sm hover:bg-primary/5">
|
||||
<button type="button" class="ml-3 bg-white border border-primary text-primary rounded px-3 py-1.5 text-sm hover:bg-primary/5" onclick="window.location.href='model-dimension-create.html'">
|
||||
+ 创建评测维度
|
||||
</button>
|
||||
</div>
|
||||
@@ -318,13 +258,15 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础地址
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:8080/api`;
|
||||
};
|
||||
const API_BASE = getApiBase();
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -337,9 +279,30 @@
|
||||
// 初始化评测方式样式
|
||||
updateEvalTypeStyle('custom');
|
||||
|
||||
// 初始化数据来源显示
|
||||
toggleDataSource('dataset');
|
||||
|
||||
// 加载模型列表
|
||||
loadModels();
|
||||
|
||||
// 加载评测数据集
|
||||
loadTestDatasets();
|
||||
|
||||
// 加载评测维度
|
||||
loadDimensions();
|
||||
|
||||
// 设置侧边栏当前页高亮
|
||||
const currentPage = 'model-eval';
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.dataset.page === currentPage) {
|
||||
link.classList.add('bg-[#1890ff]/10', 'text-[#1890ff]');
|
||||
link.classList.remove('hover:bg-[#001529]/20', 'transition-colors');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新滑块位置
|
||||
updateSidebarSlider();
|
||||
|
||||
// 绑定导航点击事件
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
@@ -351,6 +314,21 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 更新侧边栏滑块位置
|
||||
function updateSidebarSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
|
||||
const activeLink = document.querySelector('.nav-link.bg-\\[\\#1890ff\\]\\/10');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换评测方式
|
||||
function toggleEvalType(type) {
|
||||
updateEvalTypeStyle(type);
|
||||
@@ -410,7 +388,7 @@
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/my-models`);
|
||||
const response = await fetch(`${API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
const select = document.getElementById('modelSelect');
|
||||
@@ -422,6 +400,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 加载评测数据集(type="eval")
|
||||
async function loadTestDatasets() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dataset-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
// 筛选类型为 eval 的数据集
|
||||
const evalDatasets = (result.data || []).filter(d => d.type === 'eval');
|
||||
const select = document.getElementById('testDatasetSelect');
|
||||
select.innerHTML = '<option value="">请选择评测数据集</option>' +
|
||||
evalDatasets.map(d => `<option value="${d.id}">${d.name}</option>`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载评测数据集失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载评测维度列表
|
||||
async function loadDimensions() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dimension`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
const select = document.getElementById('dimensionSelect');
|
||||
select.innerHTML = '<option value="">请选择评测维度</option>' +
|
||||
result.data.map(d => `<option value="${d.id}">${d.name} (${getDimensionTypeName(d.type)})</option>`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载评测维度失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取评测维度类型名称
|
||||
function getDimensionTypeName(type) {
|
||||
const typeMap = {
|
||||
'classification': '分类',
|
||||
'metric': '指标',
|
||||
'text_similarity': '文本相似度'
|
||||
};
|
||||
return typeMap[type] || type || '未知';
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function submitForm() {
|
||||
const form = document.getElementById('evalForm');
|
||||
@@ -430,16 +450,26 @@
|
||||
name: formData.get('name'),
|
||||
eval_type: formData.get('eval_type'),
|
||||
model_id: formData.get('model_id'),
|
||||
dataset_id: formData.get('dataset_id'),
|
||||
eval_dimension: formData.get('eval_dimension'),
|
||||
data_source: formData.get('data_source'),
|
||||
leaderboard: formData.get('leader') === 'on'
|
||||
};
|
||||
|
||||
if (!data.name) {
|
||||
alert('请输入任务名称');
|
||||
showMessage('提示', '请输入任务名称', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!data.model_id) {
|
||||
alert('请选择评测模型');
|
||||
showMessage('提示', '请选择评测模型', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!data.dataset_id) {
|
||||
showMessage('提示', '请选择评测数据集', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!data.eval_dimension) {
|
||||
showMessage('提示', '请选择评测维度', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,15 +481,89 @@
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
alert('创建成功!');
|
||||
window.location.href = 'main.html?page=model-eval';
|
||||
showMessage('成功', '创建成功!', 'success', () => {
|
||||
window.location.href = 'main.html?page=model-eval';
|
||||
});
|
||||
} else {
|
||||
alert(result.message || '创建失败');
|
||||
showMessage('错误', result.message || '创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('创建失败: ' + error.message);
|
||||
showMessage('错误', '创建失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 自定义消息弹窗 -->
|
||||
<div id="customModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick="if(event.target === this) closeModal();">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden transform transition-all">
|
||||
<div class="flex flex-col items-center justify-center min-h-[160px] py-6">
|
||||
<div id="modalIcon"></div>
|
||||
<h3 id="modalTitle" class="text-base font-medium text-gray-800 mb-2"></h3>
|
||||
<p id="modalMessage" class="text-gray-600 text-sm"></p>
|
||||
</div>
|
||||
<div id="modalBtnGroup" class="hidden px-6 pb-6 flex flex-col space-y-2 mx-4">
|
||||
<button id="modalConfirmBtn" class="px-4 py-2 w-full text-white rounded transition-colors text-sm">确定</button>
|
||||
<button id="modalCancelBtn" class="px-4 py-2 w-full border border-gray-300 text-gray-700 rounded hover:bg-gray-50 transition-colors text-sm">取消</button>
|
||||
</div>
|
||||
<div id="modalSingleBtnGroup" class="px-6 pb-6 flex justify-center">
|
||||
<button id="modalConfirmBtn2" class="px-6 py-2 w-full text-white rounded transition-colors text-sm max-w-[160px]">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 显示消息弹窗
|
||||
function showMessage(title, message, type = 'info', onConfirm) {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
||||
const modalConfirmBtn2 = document.getElementById('modalConfirmBtn2');
|
||||
const modalBtnGroup = document.getElementById('modalBtnGroup');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalTitle.className = 'text-lg font-medium text-gray-800 mb-2';
|
||||
|
||||
if (type === 'success') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center"><i class="fa fa-check text-xl text-green-600"></i></div>';
|
||||
} else if (type === 'error') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center"><i class="fa fa-times text-xl text-red-600"></i></div>';
|
||||
} else if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-info text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
modalBtnGroup.classList.add('hidden');
|
||||
modalSingleBtnGroup.classList.remove('hidden');
|
||||
const confirmBtn = modalConfirmBtn2;
|
||||
confirmBtn.className = 'px-6 py-2 w-full text-white rounded transition-colors text-sm max-w-[160px]';
|
||||
if (type === 'success') {
|
||||
confirmBtn.classList.add('bg-primary');
|
||||
} else if (type === 'error') {
|
||||
confirmBtn.classList.add('bg-red-500');
|
||||
} else if (type === 'warning') {
|
||||
confirmBtn.classList.add('bg-yellow-500');
|
||||
} else {
|
||||
confirmBtn.classList.add('bg-primary');
|
||||
}
|
||||
confirmBtn.onclick = () => {
|
||||
closeModal();
|
||||
if (onConfirm) onConfirm();
|
||||
};
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('customModal');
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
471
web/pages/model-eval.html
Normal file
471
web/pages/model-eval.html
Normal file
@@ -0,0 +1,471 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模型评测 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50">
|
||||
<!-- 表格容器 -->
|
||||
<div id="tableContainer" style="padding: 16px;">
|
||||
<!-- 评测任务表格 -->
|
||||
<div id="tasksTable" class="tab-content active">
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="text-lg font-medium text-gray-800">评测任务列表</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">数据集</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">指标</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">得分</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tasksBody" class="bg-white divide-y divide-gray-200">
|
||||
<!-- 动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排行榜表格 -->
|
||||
<div id="leaderboardTable" class="tab-content hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="text-lg font-medium text-gray-800">模型排行榜</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">排名</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">平均得分</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">评测次数</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">最后评测</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leaderboardBody" class="bg-white divide-y divide-gray-200">
|
||||
<!-- 动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评测维度表格 -->
|
||||
<div id="dimensionsTable" class="tab-content hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="text-lg font-medium text-gray-800">评测维度管理</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">维度名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">指标类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">描述</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dimensionsBody" class="bg-white divide-y divide-gray-200">
|
||||
<!-- 动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义确认弹窗 -->
|
||||
<div id="confirmModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div id="confirmIcon" class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||
<i class="fa fa-exclamation text-xl text-yellow-600"></i>
|
||||
</div>
|
||||
<h3 id="confirmTitle" class="text-lg font-medium text-gray-800 mb-2"></h3>
|
||||
<p id="confirmMessage" class="text-gray-600 text-sm"></p>
|
||||
</div>
|
||||
<div class="px-6 pb-6 flex justify-center space-x-4">
|
||||
<button id="confirmCancelBtn" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">取消</button>
|
||||
<button id="confirmOkBtn" class="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// Tab 内容切换函数(供 main.html 调用)
|
||||
window.switchTabContent = function(tabId) {
|
||||
// 隐藏所有 tab 内容
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
content.classList.remove('active');
|
||||
});
|
||||
// 显示选中的 tab 内容
|
||||
const activeContent = document.getElementById(tabId + 'Table');
|
||||
if (activeContent) {
|
||||
activeContent.classList.remove('hidden');
|
||||
activeContent.classList.add('active');
|
||||
}
|
||||
// 更新面包屑标题
|
||||
const titles = {
|
||||
'tasks': '评测任务列表',
|
||||
'leaderboard': '模型排行榜',
|
||||
'dimensions': '评测维度管理'
|
||||
};
|
||||
const breadcrumbTitle = document.getElementById('breadcrumbTitle');
|
||||
if (breadcrumbTitle) {
|
||||
breadcrumbTitle.textContent = titles[tabId] || tabId;
|
||||
}
|
||||
// 切换到评测维度tab时刷新数据
|
||||
if (tabId === 'dimensions') {
|
||||
loadDimensions();
|
||||
}
|
||||
};
|
||||
|
||||
// 加载评测任务数据
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-eval`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
const data = result.data || [];
|
||||
const tbody = document.getElementById('tasksBody');
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-400">
|
||||
<i class="fa fa-inbox text-4xl mb-3"></i>
|
||||
<p>暂无评测任务</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${item.model_name || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.dataset || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.metric || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.score ? item.score + '%' : '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full ${getStatusClass(item.status)}">
|
||||
${item.status || '未知'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${item.create_time ? new Date(item.create_time).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button onclick="viewReport(${item.id})" class="text-primary hover:text-primary/80 mr-3">
|
||||
<i class="fa fa-bar-chart"></i> 报告
|
||||
</button>
|
||||
<button onclick="deleteTask(${item.id})" class="text-red-500 hover:text-red-600">
|
||||
<i class="fa fa-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评测任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载排行榜数据(模拟数据)
|
||||
async function loadLeaderboard() {
|
||||
// 模拟排行榜数据
|
||||
const mockData = [
|
||||
{ rank: 1, model_name: 'GPT-4', avg_score: 95.2, eval_count: 128, last_eval: '2026-01-20' },
|
||||
{ rank: 2, model_name: 'Claude-3', avg_score: 93.8, eval_count: 96, last_eval: '2026-01-19' },
|
||||
{ rank: 3, model_name: '文心一言', avg_score: 89.5, eval_count: 64, last_eval: '2026-01-18' },
|
||||
{ rank: 4, model_name: '通义千问', avg_score: 87.3, eval_count: 52, last_eval: '2026-01-17' },
|
||||
{ rank: 5, model_name: 'ChatGLM', avg_score: 84.6, eval_count: 45, last_eval: '2026-01-16' }
|
||||
];
|
||||
|
||||
const tbody = document.getElementById('leaderboardBody');
|
||||
tbody.innerHTML = mockData.map(item => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-bold rounded-full ${getRankClass(item.rank)}">
|
||||
#${item.rank}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${item.model_name}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.avg_score}%</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.eval_count}次</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.last_eval}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 加载评测维度数据
|
||||
window.loadDimensions = async function() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dimension`);
|
||||
const result = await response.json();
|
||||
|
||||
const tbody = document.getElementById('dimensionsBody');
|
||||
|
||||
if (result.code !== 0 || !result.data || result.data.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-12 text-center text-gray-400">
|
||||
<i class="fa fa-inbox text-4xl mb-3"></i>
|
||||
<p>暂无评测维度</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = result.data.map(item => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${item.name || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.type || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.description || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700">
|
||||
启用
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button onclick="editDimension(${item.id})" class="text-primary hover:text-primary/80 mr-3">
|
||||
<i class="fa fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button onclick="deleteDimension(${item.id})" class="text-red-500 hover:text-red-600">
|
||||
<i class="fa fa-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('加载评测维度失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 状态样式
|
||||
function getStatusClass(status) {
|
||||
const map = {
|
||||
'completed': 'bg-green-100 text-green-700',
|
||||
'running': 'bg-blue-100 text-blue-700',
|
||||
'pending': 'bg-yellow-100 text-yellow-700',
|
||||
'failed': 'bg-red-100 text-red-700'
|
||||
};
|
||||
return map[status?.toLowerCase()] || 'bg-gray-100 text-gray-500';
|
||||
}
|
||||
|
||||
// 排名样式
|
||||
function getRankClass(rank) {
|
||||
if (rank === 1) return 'bg-yellow-100 text-yellow-700';
|
||||
if (rank === 2) return 'bg-gray-100 text-gray-600';
|
||||
if (rank === 3) return 'bg-orange-100 text-orange-700';
|
||||
return 'bg-gray-50 text-gray-500';
|
||||
}
|
||||
|
||||
// 自定义确认弹窗
|
||||
function showConfirm(title, message, onConfirm) {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
const modalTitle = document.getElementById('confirmTitle');
|
||||
const modalMessage = document.getElementById('confirmMessage');
|
||||
const cancelBtn = document.getElementById('confirmCancelBtn');
|
||||
const okBtn = document.getElementById('confirmOkBtn');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.textContent = message;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
cancelBtn.onclick = function() {
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
|
||||
okBtn.onclick = function() {
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义消息弹窗
|
||||
function showMessage(title, message, type = 'info', onConfirm) {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
const modalTitle = document.getElementById('confirmTitle');
|
||||
const modalMessage = document.getElementById('confirmMessage');
|
||||
const cancelBtn = document.getElementById('confirmCancelBtn');
|
||||
const okBtn = document.getElementById('confirmOkBtn');
|
||||
|
||||
// 隐藏取消按钮,只显示确定按钮
|
||||
cancelBtn.classList.add('hidden');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
// 根据类型设置图标和按钮颜色
|
||||
const iconContainer = document.getElementById('confirmIcon');
|
||||
if (type === 'success') {
|
||||
iconContainer.className = 'w-12 h-12 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center';
|
||||
iconContainer.innerHTML = '<i class="fa fa-check text-xl text-green-600"></i>';
|
||||
okBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
} else if (type === 'error') {
|
||||
iconContainer.className = 'w-12 h-12 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center';
|
||||
iconContainer.innerHTML = '<i class="fa fa-times text-xl text-red-600"></i>';
|
||||
okBtn.className = 'px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors';
|
||||
} else if (type === 'warning') {
|
||||
iconContainer.className = 'w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center';
|
||||
iconContainer.innerHTML = '<i class="fa fa-exclamation text-xl text-yellow-600"></i>';
|
||||
okBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
} else {
|
||||
iconContainer.className = 'w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center';
|
||||
iconContainer.innerHTML = '<i class="fa fa-info text-xl text-blue-600"></i>';
|
||||
okBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
okBtn.onclick = function() {
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
// 恢复取消按钮
|
||||
cancelBtn.classList.remove('hidden');
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 操作函数(挂载到 window 以便 onclick 调用)
|
||||
window.viewReport = function(id) {
|
||||
alert('查看报告功能开发中');
|
||||
};
|
||||
|
||||
window.deleteTask = function(id) {
|
||||
showConfirm('确认删除', '确定要删除此评测任务吗?', () => {
|
||||
executeDeleteTask(id);
|
||||
});
|
||||
};
|
||||
|
||||
async function executeDeleteTask(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-eval/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
showMessage('成功', '删除成功', 'success', () => {
|
||||
loadTasks();
|
||||
});
|
||||
} else {
|
||||
showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除评测任务失败:', error);
|
||||
showMessage('错误', '删除失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.addDimension = function() {
|
||||
window.location.href = 'model-dimension-create.html';
|
||||
};
|
||||
|
||||
window.editDimension = function(id) {
|
||||
window.location.href = `model-dimension-create.html?id=${id}`;
|
||||
};
|
||||
|
||||
window.deleteDimension = function(id) {
|
||||
showConfirm('确认删除', '确定要删除此评测维度吗?', () => {
|
||||
executeDelete(id);
|
||||
});
|
||||
};
|
||||
|
||||
async function executeDelete(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dimension/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 0) {
|
||||
// 切换到评测维度tab并刷新列表
|
||||
switchToDimensionsTab();
|
||||
showMessage('成功', '删除成功', 'success');
|
||||
} else {
|
||||
showMessage('错误', result.message || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除维度失败:', error);
|
||||
showMessage('错误', '删除失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到评测维度tab
|
||||
window.switchToDimensionsTab = function() {
|
||||
const dimBtn = document.querySelector('[data-tab="dimensions"]');
|
||||
if (dimBtn) {
|
||||
dimBtn.click();
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载时初始化
|
||||
async function initPage() {
|
||||
await loadTasks();
|
||||
loadLeaderboard();
|
||||
loadDimensions();
|
||||
}
|
||||
|
||||
// 支持通过 main.html fetch 加载
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
} else {
|
||||
initPage();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
304
web/pages/model-inference.html
Normal file
304
web/pages/model-inference.html
Normal file
@@ -0,0 +1,304 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>合并权重 - YG_FT</title>
|
||||
<link href="../../assets/libs/font-awesome-4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<script src="../../assets/libs/tailwindcss.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#1890ff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-manage';
|
||||
</script>
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 h-screen flex overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="sidebar-section-title mt-6">训练管理</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=fine-tune" data-page="fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-magic w-5 text-center"></i>
|
||||
<span class="ml-2">训练任务</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=data-manage" data-page="data-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">数据集管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="#" data-page="data-generate" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">其他工具</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 第三分区:系统设置 -->
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="#" data-page="config" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-bar-chart w-5 text-center"></i>
|
||||
<span class="ml-2">平台性能</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="main.html?page=logs" data-page="logs" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">查看日志</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#bfcbd9]">版本 v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="main.html?page=my-models" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">返回我的模型</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center text-sm text-gray-600">
|
||||
<i class="fa fa-cube text-primary mr-2"></i>
|
||||
<span id="modelTitle">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
||||
<!-- 模型信息栏 -->
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-gray-400 mr-2">训练方法:</span>
|
||||
<span id="trainMethod" class="px-2 py-1 bg-green-100 text-green-700 text-xs rounded">-</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-gray-400 mr-2">基座模型:</span>
|
||||
<span id="baseModel" class="text-sm text-gray-700">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合并权重区域 -->
|
||||
<div id="mergeContainer" class="flex-1 overflow-y-auto p-6">
|
||||
<!-- 合并状态 -->
|
||||
<div id="mergeState" class="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mb-4">
|
||||
<i class="fa fa-compress text-2xl text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-600 mb-2">合并模型权重</h3>
|
||||
<p class="text-sm text-gray-400 max-w-sm mb-4" id="mergeText">将LoRA适配器权重合并到基座模型</p>
|
||||
<button onclick="mergeWeights()" id="mergeBtn"
|
||||
class="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors flex items-center">
|
||||
<i class="fa fa-compress mr-2"></i>
|
||||
<span>开始合并</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 合并结果 -->
|
||||
<div id="mergeResult" class="hidden flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<div class="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<i class="fa fa-check-circle text-2xl text-green-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-600 mb-2">合并完成</h3>
|
||||
<p class="text-sm text-gray-400 max-w-sm mb-4" id="resultText"></p>
|
||||
<button onclick="location.reload()"
|
||||
class="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-refresh mr-2"></i>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础地址
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 全局状态
|
||||
let currentModel = {
|
||||
name: '',
|
||||
trainMethod: '',
|
||||
baseModel: ''
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// 解析URL参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const modelName = urlParams.get('model');
|
||||
const trainMethod = urlParams.get('method');
|
||||
|
||||
if (modelName) {
|
||||
currentModel.name = decodeURIComponent(modelName);
|
||||
document.getElementById('modelTitle').textContent = currentModel.name;
|
||||
}
|
||||
|
||||
if (trainMethod) {
|
||||
currentModel.trainMethod = decodeURIComponent(trainMethod);
|
||||
const methodDisplay = {
|
||||
'lora': 'LoRA',
|
||||
'full': '全参微调',
|
||||
'sft': 'SFT',
|
||||
'dpo': 'DPO',
|
||||
'cpt': 'CPT'
|
||||
};
|
||||
document.getElementById('trainMethod').textContent = methodDisplay[currentModel.trainMethod] || currentModel.trainMethod;
|
||||
}
|
||||
|
||||
// 加载模型信息
|
||||
if (currentModel.name) {
|
||||
await loadModelInfo();
|
||||
}
|
||||
|
||||
// 绑定侧边栏导航点击事件
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const page = this.dataset.page;
|
||||
if (page === 'logs') {
|
||||
window.location.href = `main.html?page=${page}`;
|
||||
} else if (page) {
|
||||
window.location.href = `main.html?page=${page}`;
|
||||
}
|
||||
});
|
||||
if (link.dataset.page === 'my-models') {
|
||||
link.classList.add('sidebar-item-active');
|
||||
link.classList.remove('hover:bg-[#001529]/20');
|
||||
}
|
||||
});
|
||||
updateSidebarSlider();
|
||||
});
|
||||
|
||||
// 侧边栏高亮
|
||||
function updateSidebarSlider() {
|
||||
const activeItem = document.querySelector('.sidebar-item-active');
|
||||
const slider = document.getElementById('sidebarSlider');
|
||||
const wrapper = document.querySelector('.nav-item-wrapper');
|
||||
if (activeItem && slider && wrapper) {
|
||||
slider.style.display = 'block';
|
||||
slider.style.width = wrapper.offsetWidth + 'px';
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型信息
|
||||
async function loadModelInfo() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/trained-models`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code !== 0) {
|
||||
console.error('[DEBUG] 获取模型列表失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = result.data?.models || [];
|
||||
const modelInfo = models.find(m => m.name === currentModel.name);
|
||||
console.log('[DEBUG] 模型查找:', currentModel.name, modelInfo);
|
||||
|
||||
if (modelInfo) {
|
||||
currentModel.baseModel = modelInfo.base_model_path || '';
|
||||
document.getElementById('baseModel').textContent = currentModel.baseModel || '未知';
|
||||
} else {
|
||||
document.getElementById('baseModel').textContent = '未知';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] 加载模型信息失败:', error);
|
||||
document.getElementById('baseModel').textContent = '未知';
|
||||
}
|
||||
}
|
||||
|
||||
// 合并权重
|
||||
async function mergeWeights() {
|
||||
if (!currentModel.name) {
|
||||
alert('模型参数无效');
|
||||
return;
|
||||
}
|
||||
|
||||
const mergeBtn = document.getElementById('mergeBtn');
|
||||
const mergeText = document.getElementById('mergeText');
|
||||
const originalBtnText = mergeBtn.innerHTML;
|
||||
|
||||
mergeBtn.disabled = true;
|
||||
mergeBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i>合并中...';
|
||||
mergeText.textContent = '正在合并模型权重,请稍候...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_name: currentModel.name,
|
||||
train_method: currentModel.trainMethod || 'lora',
|
||||
base_model_path: currentModel.baseModel
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[DEBUG] 合并响应:', result);
|
||||
|
||||
if (result.code === 0) {
|
||||
mergeText.textContent = '合并成功!';
|
||||
document.getElementById('mergeState').classList.add('hidden');
|
||||
document.getElementById('mergeResult').classList.remove('hidden');
|
||||
document.getElementById('resultText').textContent = result.message || '模型权重已成功合并';
|
||||
} else {
|
||||
mergeText.textContent = result.message || '合并失败';
|
||||
mergeBtn.disabled = false;
|
||||
mergeBtn.innerHTML = originalBtnText;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] 合并失败:', error);
|
||||
mergeText.textContent = '合并失败: ' + error.message;
|
||||
mergeBtn.disabled = false;
|
||||
mergeBtn.innerHTML = originalBtnText;
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露到全局
|
||||
window.mergeWeights = mergeWeights;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,19 +5,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>添加模型 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-manage';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.sidebar-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(191, 203, 217, 0.7);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0, 21, 41, 0.2);
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -59,71 +65,11 @@
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<!-- 平台LOGO区域 -->
|
||||
<div class="p-4 border-b border-[#001529]/30 flex items-center">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-6 h-6 object-contain mr-2">
|
||||
<span class="text-white font-medium">远光软件微调平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航主区域 -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<!-- 第一分区:模型服务 -->
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<a href="main.html" data-page="fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cogs w-5 text-center"></i>
|
||||
<span class="ml-2">模型调优</span>
|
||||
</a>
|
||||
<a href="main.html?page=my-models" data-page="my-models" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">我的模型</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-eval" data-page="model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-line-chart w-5 text-center"></i>
|
||||
<span class="ml-2">模型评测</span>
|
||||
</a>
|
||||
<a href="main.html?page=model-deploy" data-page="model-deploy" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-server w-5 text-center"></i>
|
||||
<span class="ml-2">模型部署</span>
|
||||
</a>
|
||||
|
||||
<!-- 第二分区:资源管理 -->
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<a href="main.html?page=model-manage" data-page="model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-cube w-5 text-center"></i>
|
||||
<span class="ml-2">模型管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=dataset-manage" data-page="dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-file-text w-5 text-center"></i>
|
||||
<span class="ml-2">数据集管理</span>
|
||||
</a>
|
||||
<a href="main.html?page=data-generate" data-page="data-generate" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-database w-5 text-center"></i>
|
||||
<span class="ml-2">其他工具</span>
|
||||
</a>
|
||||
|
||||
<!-- 第三分区:系统设置 -->
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<a href="main.html?page=config" data-page="config" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors">
|
||||
<i class="fa fa-bar-chart w-5 text-center"></i>
|
||||
<span class="ml-2">平台性能</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 底部信息区域 -->
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#bfcbd9]">版本 v1.0.0</span>
|
||||
<i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
@@ -154,7 +100,7 @@
|
||||
<div class="flex items-center text-sm">
|
||||
<span id="breadcrumbParent" class="text-primary cursor-pointer hover:underline" onclick="goBack()">模型管理</span>
|
||||
<span class="mx-2 text-gray-300">/</span>
|
||||
<span class="text-gray-800 font-medium">添加模型</span>
|
||||
<span id="pageTitle" class="text-gray-800 font-medium">添加模型</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +111,7 @@
|
||||
<!-- 基本信息 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">基本信息</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="max-w-xl">
|
||||
<div>
|
||||
<label class="form-label">
|
||||
<span class="text-red-500 mr-1">*</span>模型名称
|
||||
@@ -173,13 +119,12 @@
|
||||
<input type="text" name="name" class="form-input" placeholder="请输入模型名称" maxlength="100">
|
||||
<p class="text-xs text-gray-400 mt-1">支持中文、英文、数字、下划线,最多100个字符</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<label class="form-label">
|
||||
<span class="text-red-500 mr-1">*</span>模型类型
|
||||
</label>
|
||||
<select name="type" class="form-input">
|
||||
<option value="">请选择</option>
|
||||
<option value="LLM">大语言模型 (LLM)</option>
|
||||
<option value="LLM" selected>大语言模型 (LLM)</option>
|
||||
<option value="CV">计算机视觉 (CV)</option>
|
||||
<option value="NLP">自然语言处理 (NLP)</option>
|
||||
<option value="Embedding">向量模型 (Embedding)</option>
|
||||
@@ -187,34 +132,40 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">版本信息</h3>
|
||||
<div class="max-w-md">
|
||||
<label class="form-label">
|
||||
<span class="text-red-500 mr-1">*</span>模型版本
|
||||
<!-- 模型用途 -->
|
||||
<div class="mt-8 mb-6">
|
||||
<label class="form-label flex items-center mb-3">
|
||||
<span class="text-red-500 mr-1">*</span>模型用途
|
||||
</label>
|
||||
<input type="text" name="version" class="form-input" placeholder="如:v1.0.0" maxlength="50">
|
||||
<p class="text-xs text-gray-400 mt-1">建议使用语义化版本号,如:v1.0.0、v2.1.0</p>
|
||||
<div class="flex items-center space-x-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="purpose" value="training" class="mr-2">
|
||||
<span class="text-sm">训练基座模型</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="purpose" value="inference" class="mr-2" checked>
|
||||
<span class="text-sm">推理对比模型</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="purpose" value="evaluation" class="mr-2">
|
||||
<span class="text-sm">评测模型</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型路径 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">模型路径</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">模型来源</h3>
|
||||
|
||||
<!-- 模型来源选择 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-600 mb-3">模型来源</label>
|
||||
<div class="flex items-center space-x-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="model_source" value="local" checked class="mr-2" onchange="toggleModelSource('local')">
|
||||
<span class="text-sm">本地模型</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="radio" name="model_source" value="online" class="mr-2" onchange="toggleModelSource('online')">
|
||||
<input type="radio" name="model_source" value="api" class="mr-2" onchange="toggleModelSource('api')">
|
||||
<span class="text-sm">在线模型</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -225,8 +176,9 @@
|
||||
<label class="form-label">
|
||||
<span class="text-red-500 mr-1">*</span>模型加载路径
|
||||
</label>
|
||||
<input type="text" name="local_path" class="form-input" placeholder="如:/models/my-model 或 s3://bucket/models/my-model">
|
||||
<p class="text-xs text-gray-400 mt-1">支持本地路径或云存储路径(OSS、S3、HDFS等)</p>
|
||||
<select name="local_path" id="localModelSelect" class="form-select">
|
||||
<option value="">-- 选择本地模型 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 在线模型配置 -->
|
||||
@@ -280,29 +232,61 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础地址
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:8080/api`;
|
||||
};
|
||||
const API_BASE = getApiBase();
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
// 返回页面
|
||||
let backUrl = 'main.html?page=model-manage';
|
||||
|
||||
// 编辑模式
|
||||
let isEditMode = false;
|
||||
let editId = null;
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 根据URL参数设置返回页面
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const from = urlParams.get('from');
|
||||
const id = urlParams.get('id');
|
||||
const breadcrumbParent = document.getElementById('breadcrumbParent');
|
||||
|
||||
if (from === 'fine-tune') {
|
||||
backUrl = 'fine-tune-create.html';
|
||||
if (breadcrumbParent) {
|
||||
breadcrumbParent.textContent = '创建训练任务';
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式
|
||||
if (id) {
|
||||
isEditMode = true;
|
||||
editId = parseInt(id);
|
||||
// 修改标题
|
||||
if (breadcrumbParent) {
|
||||
breadcrumbParent.textContent = '模型管理';
|
||||
}
|
||||
// 修改页面标题
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
if (pageTitle) {
|
||||
pageTitle.textContent = '编辑模型';
|
||||
}
|
||||
document.title = '编辑模型 / 远光软件微调平台';
|
||||
// 修改按钮文字
|
||||
const saveBtn = document.querySelector('button[onclick="submitForm()"]');
|
||||
if (saveBtn) {
|
||||
saveBtn.innerHTML = '<i class="fa fa-check mr-2"></i>保存修改';
|
||||
}
|
||||
// 加载模型数据
|
||||
loadModelData(editId);
|
||||
}
|
||||
|
||||
// 描述字数统计
|
||||
const descInput = document.querySelector('textarea[name="description"]');
|
||||
if (descInput) {
|
||||
@@ -320,8 +304,101 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 如果默认选中本地模型,立即加载模型列表
|
||||
const localRadio = document.querySelector('input[name="model_source"][value="local"]');
|
||||
if (localRadio && localRadio.checked) {
|
||||
loadLocalModels();
|
||||
}
|
||||
|
||||
// 设置侧边栏当前页高亮
|
||||
const currentPage = 'model-manage';
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.dataset.page === currentPage) {
|
||||
link.classList.add('bg-[#1890ff]/10', 'text-[#1890ff]');
|
||||
link.classList.remove('hover:bg-[#001529]/20', 'transition-colors');
|
||||
}
|
||||
});
|
||||
updateSidebarSlider();
|
||||
});
|
||||
|
||||
// 更新侧边栏滑块位置
|
||||
function updateSidebarSlider() {
|
||||
const slider = document.getElementById('sidebar-slider');
|
||||
if (!slider) return;
|
||||
const activeLink = document.querySelector('.nav-link.bg-\\[\\#1890ff\\]\\/10');
|
||||
if (activeLink) {
|
||||
const wrapper = activeLink.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
slider.style.top = wrapper.offsetTop + 'px';
|
||||
slider.style.height = wrapper.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
async function loadModelData(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/${id}`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
const model = result.data;
|
||||
// 填充表单
|
||||
document.querySelector('input[name="name"]').value = model.name || '';
|
||||
document.querySelector('select[name="type"]').value = model.type || 'LLM';
|
||||
|
||||
// 设置模型来源(兼容旧的 'online' 值,转换为新的 'api' 值)
|
||||
let modelSource = model.model_source || 'local';
|
||||
if (modelSource === 'online') {
|
||||
modelSource = 'api'; // 兼容旧数据
|
||||
}
|
||||
document.querySelectorAll('input[name="model_source"]').forEach(radio => {
|
||||
radio.checked = radio.value === modelSource;
|
||||
});
|
||||
|
||||
// 根据来源显示对应面板
|
||||
toggleModelSource(modelSource);
|
||||
|
||||
// 填充来源相关字段
|
||||
if (modelSource === 'local') {
|
||||
const path = model.path || '';
|
||||
// 尝试在选择器中找到匹配的模型
|
||||
loadLocalModels().then(() => {
|
||||
const select = document.getElementById('localModelSelect');
|
||||
if (select && path) {
|
||||
// 尝试精确匹配或部分匹配
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === path ||
|
||||
select.options[i].textContent === path ||
|
||||
path.includes(select.options[i].textContent)) {
|
||||
select.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
document.querySelector('input[name="api_url"]').value = model.api_url || '';
|
||||
document.querySelector('input[name="api_key"]').value = model.api_key || '';
|
||||
document.querySelector('input[name="online_model_name"]').value = model.model_name || '';
|
||||
}
|
||||
|
||||
// 填充描述
|
||||
const descInput = document.querySelector('textarea[name="description"]');
|
||||
descInput.value = model.description || '';
|
||||
document.getElementById('descCount').textContent = descInput.value.length;
|
||||
|
||||
// 填充用途(兼容旧数据没有 purpose 字段的情况)
|
||||
const purpose = model.purpose || 'inference';
|
||||
document.querySelectorAll('input[name="purpose"]').forEach(radio => {
|
||||
radio.checked = radio.value === purpose;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模型来源
|
||||
function toggleModelSource(source) {
|
||||
const localPanel = document.getElementById('localModelPanel');
|
||||
@@ -329,24 +406,62 @@
|
||||
if (source === 'local') {
|
||||
localPanel.classList.remove('hidden');
|
||||
onlinePanel.classList.add('hidden');
|
||||
// 切换到本地模型时加载模型列表
|
||||
loadLocalModels();
|
||||
} else {
|
||||
localPanel.classList.add('hidden');
|
||||
onlinePanel.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 为了兼容性,保留 online 作为别名
|
||||
window.toggleModelSource = toggleModelSource;
|
||||
|
||||
// 加载本地模型列表
|
||||
async function loadLocalModels() {
|
||||
const select = document.getElementById('localModelSelect');
|
||||
if (!select) return;
|
||||
|
||||
// 检查是否已经加载过
|
||||
if (select.options.length > 1) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/local-models`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data && result.data.models) {
|
||||
result.data.models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.path;
|
||||
option.textContent = model.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载本地模型列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 模型选择变化
|
||||
function onModelSelectChange() {
|
||||
// 保留空函数以兼容可能的其他调用
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function submitForm() {
|
||||
console.log('submitForm called');
|
||||
const form = document.getElementById('modelForm');
|
||||
const formData = new FormData(form);
|
||||
const modelSource = formData.get('model_source');
|
||||
const purpose = formData.get('purpose');
|
||||
console.log('modelSource:', modelSource, 'purpose:', purpose);
|
||||
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
type: formData.get('type'),
|
||||
version: formData.get('version'),
|
||||
model_source: modelSource,
|
||||
description: formData.get('description')
|
||||
description: formData.get('description'),
|
||||
purpose: purpose
|
||||
};
|
||||
|
||||
// 根据模型来源设置不同的字段
|
||||
@@ -383,27 +498,37 @@
|
||||
showMessage('提示', '请选择模型类型', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!data.version) {
|
||||
showMessage('提示', '请输入模型版本', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage`, {
|
||||
method: 'POST',
|
||||
let url = `${API_BASE}/model-manage`;
|
||||
let method = 'POST';
|
||||
let successMsg = '模型添加成功!';
|
||||
|
||||
if (isEditMode) {
|
||||
url = `${API_BASE}/model-manage/${editId}`;
|
||||
method = 'PUT';
|
||||
successMsg = '模型修改成功!';
|
||||
}
|
||||
|
||||
console.log('Sending request to:', url);
|
||||
console.log('Request data:', data);
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
console.log('Response status:', response.status);
|
||||
const result = await response.json();
|
||||
console.log('Response data:', result);
|
||||
if (result.code === 0) {
|
||||
showMessage('成功', '模型添加成功!', 'success', () => {
|
||||
showMessage('成功', successMsg, 'success', () => {
|
||||
window.location.href = 'main.html?page=model-manage';
|
||||
});
|
||||
} else {
|
||||
showMessage('错误', result.message || '添加失败', 'error');
|
||||
showMessage('错误', result.message || '操作失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('错误', '添加失败: ' + error.message, 'error');
|
||||
showMessage('错误', '操作失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +539,6 @@
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn2');
|
||||
const modalBtnGroup = document.getElementById('modalBtnGroup');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
@@ -430,7 +554,6 @@
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-info text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
modalBtnGroup.classList.add('hidden');
|
||||
modalSingleBtnGroup.classList.remove('hidden');
|
||||
modalConfirmBtn.className = type === 'error' ? 'px-6 py-2 bg-danger text-white rounded-lg hover:bg-danger/90 transition-colors' : 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
|
||||
@@ -448,6 +571,8 @@
|
||||
function goBack() {
|
||||
window.location.href = backUrl;
|
||||
}
|
||||
window.goBack = goBack;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 自定义消息弹窗 -->
|
||||
|
||||
642
web/pages/model-manage.html
Normal file
642
web/pages/model-manage.html
Normal file
@@ -0,0 +1,642 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模型管理 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
// 禁用 Tailwind 开发模式警告
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// 设置当前页面,供侧边栏高亮使用
|
||||
window.sidebarCurrentPage = 'model-manage';
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- 侧边栏加载器 -->
|
||||
<script src="../js/components/sidebar-loader.js"></script>
|
||||
<style>
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; }
|
||||
.form-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: #1f2937;
|
||||
background-color: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color 0.15s ease-in-out;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 8px 20px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 6px !important;
|
||||
border: 1px solid transparent !important;
|
||||
cursor: pointer !important;
|
||||
background: transparent !important;
|
||||
color: #4b5563 !important;
|
||||
transition: all 0.2s !important;
|
||||
min-width: 100px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
background: white !important;
|
||||
border-color: #d1d5db !important;
|
||||
}
|
||||
.tab-btn.tab-active {
|
||||
background: white !important;
|
||||
color: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 1px 3px rgba(24,144,255,0.2) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边栏容器 -->
|
||||
<div id="sidebar-container"></div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-6">
|
||||
<button class="md:hidden text-gray-500 hover:text-gray-700">
|
||||
<i class="fa fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 系统性能监控 -->
|
||||
<a href="?page=config" class="flex items-center space-x-4 text-xs text-gray-500 hover:text-primary transition-colors">
|
||||
<div class="flex items-center" title="CPU使用率">
|
||||
<i class="fa fa-microchip mr-1 text-blue-500"></i>
|
||||
<span id="cpuUsage">--</span>%
|
||||
</div>
|
||||
<div class="flex items-center" title="内存使用率">
|
||||
<i class="fa fa-database mr-1 text-green-500"></i>
|
||||
<span id="memUsage">--</span>%
|
||||
</div>
|
||||
<div class="flex items-center" title="磁盘使用率">
|
||||
<i class="fa fa-hdd-o mr-1 text-orange-500"></i>
|
||||
<span id="diskUsage">--</span>%
|
||||
</div>
|
||||
</a>
|
||||
<div class="h-6 w-px bg-gray-200"></div>
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full pt-2 hidden group-hover:block z-50">
|
||||
<div class="bg-white rounded shadow-lg py-1 border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<!-- Tab 导航 -->
|
||||
<div style="background: #fff; border-bottom: 1px solid #e5e7eb; padding: 16px;">
|
||||
<h2 style="font-size: 18px; font-weight: 600; color: #1f2937; margin-bottom: 12px;">模型管理</h2>
|
||||
<div style="display: flex; gap: 8px; background: #f3f4f6; padding: 6px; border-radius: 8px;">
|
||||
<button onclick="switchTab('all')" id="tab-all" class="tab-btn tab-active" style="display: inline-flex; align-items: center; justify-content: center;">
|
||||
全部模型
|
||||
</button>
|
||||
<button onclick="switchTab('training')" id="tab-training" class="tab-btn" style="display: inline-flex; align-items: center; justify-content: center;">
|
||||
训练基座
|
||||
</button>
|
||||
<button onclick="switchTab('inference')" id="tab-inference" class="tab-btn" style="display: inline-flex; align-items: center; justify-content: center;">
|
||||
推理对比
|
||||
</button>
|
||||
<button onclick="switchTab('evaluation')" id="tab-evaluation" class="tab-btn" style="display: inline-flex; align-items: center; justify-content: center;">
|
||||
评测模型
|
||||
</button>
|
||||
<button onclick="switchTab('trained')" id="tab-trained" class="tab-btn" style="display: inline-flex; align-items: center; justify-content: center;">
|
||||
已训练模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格容器 -->
|
||||
<div style="padding: 16px;">
|
||||
<!-- 工具栏 -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
<input type="text" id="searchInput" placeholder="搜索模型名称..." style="width: 256px; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;" oninput="filterModels()">
|
||||
</div>
|
||||
<button onclick="window.location.href='model-manage-create.html'" style="padding: 8px 16px; background: #1890ff; color: white; border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; font-size: 14px;">
|
||||
<span style="margin-right: 8px;">+</span>添加模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 模型表格 -->
|
||||
<div id="modelsTableContainer" class="bg-white rounded-lg shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用途</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型来源</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">描述</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型路径</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modelsBody" class="bg-white divide-y divide-gray-200">
|
||||
<!-- 动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div id="emptyState" class="hidden px-6 py-12 text-center">
|
||||
<i class="fa fa-inbox text-4xl text-gray-300 mb-3"></i>
|
||||
<p class="text-gray-500">暂无模型数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已训练模型表格 -->
|
||||
<div id="trainedModelsContainer" class="hidden bg-white rounded-lg shadow-sm">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<p class="text-sm text-gray-500">已训练模型存储在 /app/base/saves 目录下</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">基座模型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">训练方法</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型路径</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="trainedModelsBody" class="bg-white divide-y divide-gray-200">
|
||||
<!-- 动态加载 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div id="trainedEmptyState" class="hidden px-6 py-12 text-center">
|
||||
<i class="fa fa-inbox text-4xl text-gray-300 mb-3"></i>
|
||||
<p class="text-gray-500">暂无已训练模型</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
console.log('[DEBUG] model-manage.html 脚本开始加载');
|
||||
// 使用 IIFE 避免全局变量污染
|
||||
(function() {
|
||||
console.log('[DEBUG] model-manage.html IIFE 开始执行');
|
||||
// API 基础地址 - 优先使用 main.html 中定义的全局变量
|
||||
const getApiBase = () => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
};
|
||||
const API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : getApiBase();
|
||||
|
||||
let allModels = [];
|
||||
let trainedModels = [];
|
||||
let currentTab = 'all';
|
||||
|
||||
// Tab 切换
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
// 更新按钮样式
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('tab-active');
|
||||
});
|
||||
const activeTab = document.getElementById(`tab-${tab}`);
|
||||
activeTab.classList.add('tab-active');
|
||||
|
||||
// 显示/隐藏搜索框和添加按钮
|
||||
const toolbar = document.querySelector('div[style*="justify-content: space-between"]');
|
||||
if (toolbar) {
|
||||
toolbar.style.display = tab === 'trained' ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
// 显示/隐藏表格容器
|
||||
const modelsTable = document.getElementById('modelsTableContainer');
|
||||
const trainedModelsContainer = document.getElementById('trainedModelsContainer');
|
||||
|
||||
if (tab === 'trained') {
|
||||
modelsTable.classList.add('hidden');
|
||||
trainedModelsContainer.classList.remove('hidden');
|
||||
loadTrainedModels();
|
||||
} else {
|
||||
modelsTable.classList.remove('hidden');
|
||||
trainedModelsContainer.classList.add('hidden');
|
||||
renderModels();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型数据
|
||||
async function loadModels() {
|
||||
try {
|
||||
// 并行加载数据库模型和已训练模型
|
||||
const [dbResponse, trainedResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/model-manage`),
|
||||
fetch(`${API_BASE}/model-manage/trained-models`)
|
||||
]);
|
||||
|
||||
const dbResult = await dbResponse.json();
|
||||
const trainedResult = await trainedResponse.json();
|
||||
|
||||
console.log('[DEBUG] 数据库模型数量:', dbResult.data?.length || 0);
|
||||
console.log('[DEBUG] 已训练模型API响应:', trainedResult);
|
||||
|
||||
// 数据库模型
|
||||
let dbModels = [];
|
||||
if (dbResult.code === 0) {
|
||||
dbModels = dbResult.data || [];
|
||||
}
|
||||
|
||||
// 已训练模型 - 转换为统一格式
|
||||
trainedModels = [];
|
||||
if (trainedResult.code === 0) {
|
||||
const trainedData = trainedResult.data?.models || [];
|
||||
console.log('[DEBUG] 已训练模型数据:', trainedData);
|
||||
trainedData.forEach(model => {
|
||||
// 每个训练方法作为一个模型条目
|
||||
if (model.train_methods && model.train_methods.length > 0) {
|
||||
model.train_methods.forEach(method => {
|
||||
trainedModels.push({
|
||||
id: `trained_${model.name}_${method.name}`.replace(/[^a-zA-Z0-9]/g, '_'),
|
||||
name: `${model.name} (${method.name})`,
|
||||
type: 'LLM',
|
||||
purpose: 'inference',
|
||||
model_source: 'local',
|
||||
path: method.path,
|
||||
description: `基于 ${model.name} 的${getMethodDisplayName(method.name)}训练模型`,
|
||||
create_time: new Date().toISOString(),
|
||||
isTrained: true,
|
||||
baseModel: model.name,
|
||||
trainMethod: method.name
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有训练方法的也添加为模型
|
||||
trainedModels.push({
|
||||
id: `trained_${model.name}`.replace(/[^a-zA-Z0-9]/g, '_'),
|
||||
name: model.name,
|
||||
type: 'LLM',
|
||||
purpose: 'inference',
|
||||
model_source: 'local',
|
||||
path: model.path,
|
||||
description: '已训练模型',
|
||||
create_time: new Date().toISOString(),
|
||||
isTrained: true,
|
||||
baseModel: model.name,
|
||||
trainMethod: ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 合并所有模型
|
||||
allModels = [...dbModels, ...trainedModels];
|
||||
console.log('[DEBUG] 数据库模型:', dbModels.length);
|
||||
console.log('[DEBUG] 已训练模型:', trainedModels.length);
|
||||
console.log('[DEBUG] 合并后的模型总数:', allModels.length);
|
||||
renderModels();
|
||||
} catch (error) {
|
||||
console.error('加载模型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取训练方法显示名称
|
||||
function getMethodDisplayName(method) {
|
||||
const methodMap = {
|
||||
'lora': 'LoRA',
|
||||
'qlora': 'QLoRA',
|
||||
'full': '全量微调',
|
||||
'prefix': 'Prefix Tuning',
|
||||
'adapter': 'Adapter',
|
||||
'lora_plus': 'LoRA+',
|
||||
'peft': 'PEFT',
|
||||
'adalora': 'AdaLoRA',
|
||||
'longlora': 'LongLoRA'
|
||||
};
|
||||
return methodMap[method] || method;
|
||||
}
|
||||
|
||||
// 加载已训练模型数据(仅用于Trained Tab)
|
||||
async function loadTrainedModels() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/trained-models`);
|
||||
const result = await response.json();
|
||||
|
||||
console.log('[DEBUG] 已训练模型:', result);
|
||||
|
||||
if (result.code === 0) {
|
||||
trainedModels = result.data?.models || [];
|
||||
renderTrainedModels();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载已训练模型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选模型
|
||||
function filterModels() {
|
||||
renderModels();
|
||||
}
|
||||
|
||||
// 渲染模型列表
|
||||
function renderModels() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchValue = searchInput ? searchInput.value.toLowerCase() : '';
|
||||
let filteredModels = allModels;
|
||||
|
||||
// 按 Tab 筛选
|
||||
if (currentTab !== 'all') {
|
||||
filteredModels = filteredModels.filter(m => m.purpose === currentTab);
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (searchValue) {
|
||||
filteredModels = filteredModels.filter(m =>
|
||||
m.name?.toLowerCase().includes(searchValue) ||
|
||||
m.description?.toLowerCase().includes(searchValue)
|
||||
);
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('modelsBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
if (filteredModels.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
if (emptyState) emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) emptyState.classList.add('hidden');
|
||||
|
||||
tbody.innerHTML = filteredModels.map(item => {
|
||||
// 模型类型
|
||||
const typeMap = {
|
||||
'LLM': '大语言模型',
|
||||
'CV': '计算机视觉',
|
||||
'NLP': '自然语言处理',
|
||||
'Embedding': '向量模型',
|
||||
'Other': '其他'
|
||||
};
|
||||
const typeDisplay = typeMap[item.type] || item.type || '-';
|
||||
|
||||
// 用途
|
||||
const purposeMap = {
|
||||
'training': { text: '训练', class: 'bg-blue-100 text-blue-700' },
|
||||
'inference': { text: '推理', class: 'bg-green-100 text-green-700' },
|
||||
'evaluation': { text: '评测', class: 'bg-purple-100 text-purple-700' }
|
||||
};
|
||||
const purposeDisplay = purposeMap[item.purpose] || purposeMap['inference'];
|
||||
|
||||
// 模型来源
|
||||
const sourceMap = {
|
||||
'local': '本地模型',
|
||||
'api': '在线模型',
|
||||
'online': '在线模型'
|
||||
};
|
||||
const sourceDisplay = sourceMap[item.model_source] || item.model_source || '-';
|
||||
|
||||
// 判断是否是已训练模型
|
||||
const isTrained = item.isTrained === true;
|
||||
const trainedBadge = isTrained ? '<span class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-green-100 text-green-700">已训练</span>' : '';
|
||||
|
||||
// 操作按钮
|
||||
let actionButtons = '';
|
||||
if (isTrained) {
|
||||
// 已训练模型:显示查看和复制路径按钮
|
||||
actionButtons = `
|
||||
<button onclick="viewTrainedModel('${(item.path || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')" class="text-primary hover:text-primary/80 mr-3">
|
||||
<i class="fa fa-folder-open"></i> 查看
|
||||
</button>
|
||||
<button onclick="copyModelPath('${(item.path || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fa fa-copy"></i> 复制路径
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
// 普通模型:编辑和删除
|
||||
actionButtons = `
|
||||
<button onclick="editModel(${item.id})" class="text-primary hover:text-primary/80 mr-3">
|
||||
<i class="fa fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button onclick="deleteModel(${item.id})" class="text-red-500 hover:text-red-600">
|
||||
<i class="fa fa-trash"></i> 删除
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50 ${isTrained ? 'bg-green-50/30' : ''}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">${item.name || '-'}${trainedBadge}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700">${typeDisplay}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded ${purposeDisplay.class}">${purposeDisplay.text}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-gray-100 text-gray-700">${sourceDisplay}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 max-w-xs truncate" title="${item.description || ''}">${item.description || '-'}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 max-w-xs truncate ${isTrained ? 'font-mono text-green-600' : ''}" title="${item.path || ''}">${item.path ? (isTrained ? item.path.split('/').pop() : item.path) : '-'}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${item.create_time ? new Date(item.create_time).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${actionButtons}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 渲染已训练模型列表
|
||||
function renderTrainedModels() {
|
||||
const tbody = document.getElementById('trainedModelsBody');
|
||||
const emptyState = document.getElementById('trainedEmptyState');
|
||||
|
||||
// 收集所有训练方法
|
||||
let allTrainMethods = [];
|
||||
trainedModels.forEach(model => {
|
||||
if (model.train_methods && model.train_methods.length > 0) {
|
||||
model.train_methods.forEach(method => {
|
||||
allTrainMethods.push({
|
||||
baseModel: model.name,
|
||||
trainMethod: method.name,
|
||||
path: method.path
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (allTrainMethods.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
tbody.innerHTML = allTrainMethods.map(item => {
|
||||
// 训练方法显示
|
||||
const methodMap = {
|
||||
'lora': 'LoRA',
|
||||
'qlora': 'QLoRA',
|
||||
'full': '全量微调',
|
||||
'prefix': 'Prefix Tuning',
|
||||
'adapter': 'Adapter'
|
||||
};
|
||||
const methodDisplay = methodMap[item.trainMethod] || item.trainMethod;
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">${item.baseModel}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-green-100 text-green-700">${methodDisplay}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 max-w-xs truncate" title="${item.path}">${item.path}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button onclick="viewTrainedModel('${item.path.replace(/\\/g, '\\\\')}')" class="text-primary hover:text-primary/80 mr-3">
|
||||
<i class="fa fa-folder-open"></i> 查看
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 查看已训练模型
|
||||
function viewTrainedModel(path) {
|
||||
// 显示模型路径详情
|
||||
const content = `
|
||||
<div class="text-left">
|
||||
<p class="mb-3 text-gray-600">模型路径:</p>
|
||||
<div class="bg-gray-100 p-3 rounded text-sm font-mono break-all mb-3">${path}</div>
|
||||
<p class="text-sm text-gray-500">您可以复制此路径用于推理或评测。</p>
|
||||
</div>
|
||||
`;
|
||||
// 使用简单的提示框
|
||||
showModal('模型详情', content);
|
||||
}
|
||||
|
||||
// 复制模型路径
|
||||
function copyModelPath(path) {
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
showToast('模型路径已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = path;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast('模型路径已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
// 显示弹窗
|
||||
function showModal(title, content) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
||||
<h3 class="text-lg font-medium mb-4">${title}</h3>
|
||||
<div class="mb-4">${content}</div>
|
||||
<div class="flex justify-end">
|
||||
<button onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 显示提示
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded shadow-lg z-50';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
// 编辑模型
|
||||
function editModel(id) {
|
||||
window.location.href = `model-manage-create.html?id=${id}`;
|
||||
}
|
||||
|
||||
// 删除模型
|
||||
async function deleteModel(id) {
|
||||
if (!confirm('确定要删除此模型吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/model-manage/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadModels();
|
||||
} else {
|
||||
alert('删除失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除模型失败:', error);
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
try {
|
||||
loadModels();
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
293
web/pages/tools.html
Normal file
293
web/pages/tools.html
Normal file
@@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>其他工具 - 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
.bg-primary { background-color: var(--primary); }
|
||||
.text-primary { color: var(--primary); }
|
||||
.border-primary { border-color: var(--primary); }
|
||||
.hover\:bg-primary\/90:hover { background-color: rgba(24, 144, 255, 0.9); }
|
||||
.sidebar-section-title { padding: 0.5rem 1rem; font-size: 0.75rem; color: rgba(191, 203, 217, 0.7); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sidebar-item-active { background-color: rgba(24, 144, 255, 0.1); color: #1890ff; border-left: 4px solid #1890ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased bg-gray-50 flex h-screen overflow-hidden">
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="w-64 text-[#bfcbd9] flex-shrink-0 hidden md:block flex flex-col h-full" style="background-color: #001529;">
|
||||
<div class="pt-5 pb-3 border-b border-[#001529]/30 flex items-center justify-center pl-2">
|
||||
<img src="../assets/logo/logo.png" alt="Logo" class="w-8 h-8 object-contain mr-2">
|
||||
<span class="text-white font-medium text-base">远光软件微调平台</span>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-2 relative">
|
||||
<div class="sidebar-section-title">模型服务</div>
|
||||
<div><a href="main.html?page=fine-tune" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cogs w-5 text-center"></i><span class="ml-2">模型调优</span></a></div>
|
||||
<div><a href="main.html?page=model-eval" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-line-chart w-5 text-center"></i><span class="ml-2">模型评测</span></a></div>
|
||||
<div><a href="main.html?page=model-compare" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-server w-5 text-center"></i><span class="ml-2">模型对比</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">资源管理</div>
|
||||
<div><a href="main.html?page=model-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-cube w-5 text-center"></i><span class="ml-2">模型管理</span></a></div>
|
||||
<div><a href="main.html?page=dataset-manage" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">数据集管理</span></a></div>
|
||||
<div><a href="tools.html" class="nav-link sidebar-item-active flex items-center px-4 py-2.5"><i class="fa fa-wrench w-5 text-center"></i><span class="ml-2">其他工具</span></a></div>
|
||||
<div class="sidebar-section-title mt-6">系统设置</div>
|
||||
<div><a href="hardware.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-bar-chart w-5 text-center"></i><span class="ml-2">平台性能</span></a></div>
|
||||
<div><a href="logs.html" class="nav-link flex items-center px-4 py-2.5 hover:bg-[#001529]/20 transition-colors"><i class="fa fa-file-text w-5 text-center"></i><span class="ml-2">查看日志</span></a></div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-[#001529]/30 text-xs mt-auto">
|
||||
<div class="mb-2 text-[#bfcbd9]/80">默认业务空间</div>
|
||||
<div class="flex items-center justify-between"><span class="text-[#bfcbd9]">版本 v1.0.0</span><i class="fa fa-question-circle-o text-[#bfcbd9]/70"></i></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 h-14">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="main.html" class="text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<i class="fa fa-arrow-left"></i>
|
||||
<span class="ml-1">返回首页</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative group">
|
||||
<img src="https://picsum.photos/id/1005/32/32" class="w-8 h-8 rounded-full cursor-pointer" alt="用户头像">
|
||||
<div class="absolute right-0 top-full mt-2 bg-white rounded shadow-lg py-1 hidden group-hover:block border border-gray-100 min-w-[140px]">
|
||||
<a href="login.html" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap">
|
||||
<i class="fa fa-sign-out mr-1"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6 bg-gray-50" id="page-content">
|
||||
<!-- 工具卡片内容由JS渲染 -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 消息弹窗 -->
|
||||
<div id="customModal" class="fixed inset-0 bg-gray-900/50 hidden flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div id="modalIcon"></div>
|
||||
<h3 id="modalTitle" class="text-lg font-medium text-gray-800 mb-2"></h3>
|
||||
<p id="modalMessage" class="text-gray-500 text-sm mb-6"></p>
|
||||
<div id="modalBtnGroup" class="flex justify-center space-x-3 hidden">
|
||||
<button id="modalCancelBtn" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
|
||||
<button id="modalConfirmBtn" class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">确定</button>
|
||||
</div>
|
||||
<div id="modalSingleBtnGroup" class="flex justify-center">
|
||||
<button id="modalConfirmBtn2" class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 配置
|
||||
const API_BASE = (() => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:7861/api`;
|
||||
})();
|
||||
|
||||
// 默认工具配置
|
||||
const defaultTools = [
|
||||
{ id: 'data-generate', name: '数据生成', icon: 'fa-database', description: '基于LLM生成微调数据集' },
|
||||
{ id: 'json2jsonl', name: 'JSON转JSONL', icon: 'fa-code', description: '将JSON文件转换为JSONL格式' },
|
||||
{ id: 'md-convert', name: '转换Markdown', icon: 'fa-file-text', description: '将Markdown文件转换为训练数据' }
|
||||
];
|
||||
|
||||
// 获取自定义工具
|
||||
function getCustomTools() {
|
||||
const saved = localStorage.getItem('customTools');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
}
|
||||
|
||||
// 保存自定义工具
|
||||
function saveCustomTools(tools) {
|
||||
localStorage.setItem('customTools', JSON.stringify(tools));
|
||||
}
|
||||
|
||||
// 渲染单个工具卡片
|
||||
function renderToolCard(tool, canDelete = false, isCustom = false) {
|
||||
return `
|
||||
<div class="relative group border border-gray-200 rounded-lg p-6 cursor-pointer hover:border-primary hover:shadow-md transition-all" onclick="navigateToTool('${tool.id}', '${tool.url || ''}', ${isCustom})">
|
||||
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
<i class="fa ${tool.icon} text-xl text-primary"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-800 mb-2">${tool.name}</h3>
|
||||
<p class="text-sm text-gray-500">${tool.description}</p>
|
||||
${canDelete ? `
|
||||
<button onclick="event.stopPropagation(); editCustomTool('${tool.id}')" class="absolute top-2 right-10 w-6 h-6 rounded-full bg-gray-100 hover:bg-blue-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" title="修改">
|
||||
<i class="fa fa-pencil text-gray-400 hover:text-blue-500 text-xs"></i>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); deleteCustomTool('${tool.id}')" class="absolute top-2 right-2 w-6 h-6 rounded-full bg-gray-100 hover:bg-red-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10" title="删除">
|
||||
<i class="fa fa-times text-gray-400 hover:text-red-500 text-xs"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 渲染页面
|
||||
function renderPage() {
|
||||
const customTools = getCustomTools();
|
||||
const defaultCards = defaultTools.map(t => renderToolCard(t, false, false)).join('');
|
||||
const customCards = customTools.map(t => renderToolCard(t, true, true)).join('');
|
||||
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-medium">其他工具</h2>
|
||||
<button onclick="showCreateToolModal()" class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fa fa-plus mr-1"></i>添加自定义工具
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">默认工具</h3>
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
${defaultCards || '<p class="text-gray-400 text-sm col-span-3">暂无默认工具</p>'}
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-100">自定义工具</h3>
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
${customCards || '<p class="text-gray-400 text-sm col-span-3">暂无自定义工具,点击右上角添加</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 删除自定义工具
|
||||
function deleteCustomTool(toolId) {
|
||||
showConfirm('确认删除', '确定要删除这个自定义工具吗?', () => {
|
||||
const tools = getCustomTools().filter(t => t.id !== toolId);
|
||||
saveCustomTools(tools);
|
||||
renderPage();
|
||||
});
|
||||
}
|
||||
|
||||
// 修改自定义工具
|
||||
function editCustomTool(toolId) {
|
||||
const tool = getCustomTools().find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
localStorage.setItem('editTool', JSON.stringify(tool));
|
||||
window.location.href = 'custom-tool-create.html?edit=true';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示创建工具弹窗
|
||||
function showCreateToolModal() {
|
||||
window.location.href = 'custom-tool-create.html';
|
||||
}
|
||||
|
||||
// 跳转到工具页面
|
||||
function navigateToTool(toolId, url, isCustom = false) {
|
||||
if (isCustom && url) {
|
||||
if (url.startsWith('http')) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
} else if (toolId === 'data-generate') {
|
||||
window.location.href = 'data-generate.html';
|
||||
} else if (toolId === 'json2jsonl') {
|
||||
showMessage('提示', 'JSON转JSONL 工具开发中...', 'info');
|
||||
} else if (toolId === 'md-convert') {
|
||||
showMessage('提示', '转换Markdown 工具开发中...', 'info');
|
||||
} else {
|
||||
showMessage('提示', `${toolId} 功能开发中...`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 消息弹窗 ============
|
||||
function showMessage(title, message, type = 'info', onConfirm) {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn2');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
if (type === 'success') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center"><i class="fa fa-check text-xl text-green-600"></i></div>';
|
||||
} else if (type === 'error') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center"><i class="fa fa-times text-xl text-red-600"></i></div>';
|
||||
} else if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-info text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
modalSingleBtnGroup.classList.remove('hidden');
|
||||
modalSingleBtnGroup.querySelector('#modalConfirmBtn2').className = type === 'error' ? 'px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors' : 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
modalSingleBtnGroup.onclick = function() {
|
||||
closeModal();
|
||||
if (typeof onConfirm === 'function') onConfirm();
|
||||
};
|
||||
}
|
||||
|
||||
function showConfirm(title, message, onConfirm, onCancel) {
|
||||
const modal = document.getElementById('customModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalMessage = document.getElementById('modalMessage');
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalConfirmBtn = document.getElementById('modalConfirmBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
const modalBtnGroup = document.getElementById('modalBtnGroup');
|
||||
const modalSingleBtnGroup = document.getElementById('modalSingleBtnGroup');
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.innerHTML = message;
|
||||
|
||||
if (type === 'warning') {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-yellow-100 flex items-center justify-center"><i class="fa fa-exclamation text-xl text-yellow-600"></i></div>';
|
||||
} else {
|
||||
modalIcon.innerHTML = '<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"><i class="fa fa-question text-xl text-blue-600"></i></div>';
|
||||
}
|
||||
|
||||
modalBtnGroup.classList.remove('hidden');
|
||||
modalSingleBtnGroup.classList.add('hidden');
|
||||
modalConfirmBtn.textContent = '确定';
|
||||
modalConfirmBtn.className = 'px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
modalConfirmBtn.onclick = function() {
|
||||
closeModal();
|
||||
if (typeof onConfirm === 'function') onConfirm();
|
||||
};
|
||||
|
||||
modalCancelBtn.onclick = function() {
|
||||
closeModal();
|
||||
if (typeof onCancel === 'function') onCancel();
|
||||
};
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('customModal');
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
renderPage();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
997
web/pages/training-log.html
Normal file
997
web/pages/training-log.html
Normal file
@@ -0,0 +1,997 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>训练日志 / 远光软件微调平台</title>
|
||||
<script src="../lib/tailwindcss/tailwind.js"></script>
|
||||
<script>
|
||||
// 禁用 Tailwind 开发模式警告
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && args[0].includes && args[0].includes('cdn.tailwindcss.com')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<link href="../lib/font-awesome/css/font-awesome.min.css" rel="stylesheet">
|
||||
<script src="../lib/chart.js/chart.min.js"></script>
|
||||
<script>
|
||||
// 确保 Chart.js 已加载
|
||||
if (typeof Chart === 'undefined') {
|
||||
// 备用:尝试动态加载
|
||||
const script = document.createElement('script');
|
||||
script.src = '../lib/chart.js/chart.umd.min.js';
|
||||
script.onerror = function() {
|
||||
console.error('Chart.js 加载失败');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.bg-primary { background-color: #1890ff; }
|
||||
.text-primary { color: #1890ff; }
|
||||
.border-primary { border-color: #1890ff; }
|
||||
:root { --primary: #1890ff; --danger: #f5222d; --success: #52c41a; }
|
||||
|
||||
/* 日志样式 */
|
||||
.log-content {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.log-content .error { color: #dc3545; }
|
||||
.log-content .warning { color: #d97706; }
|
||||
.log-content .info { color: #0891b2; }
|
||||
.log-content .success { color: #16a34a; }
|
||||
.log-content .progress { color: #7c3aed; font-weight: bold; }
|
||||
.log-line { padding: 1px 8px; }
|
||||
.log-line:hover { background-color: rgba(24, 144, 255, 0.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="bg-white rounded-lg shadow-sm w-full p-4 border-b border-gray-100 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-gray-800 font-medium">训练日志</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="toggleTB()" id="tbBtn" class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 transition-colors text-sm">
|
||||
<i class="fa fa-bar-chart mr-1"></i>TensorBoard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务信息 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-800" id="taskName">加载中...</h2>
|
||||
<span id="taskStatus" class="px-3 py-1 rounded-full text-sm bg-gray-100 text-gray-600">加载中</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">基础模型</div>
|
||||
<div id="baseModel" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">数据集</div>
|
||||
<div id="dataset" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">创建时间</div>
|
||||
<div id="createTime" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">进程ID</div>
|
||||
<div id="processId" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">GPU信息</div>
|
||||
<div id="taskGPU" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 text-xs">最后更新</div>
|
||||
<div id="lastUpdate" class="font-medium text-gray-800">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 训练曲线图表 -->
|
||||
<div id="chartsContainer" class="bg-white rounded-xl shadow-md p-6 mb-6 border border-gray-100">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 text-white mr-3">
|
||||
<i class="fa fa-line-chart"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-800">训练实时曲线</h3>
|
||||
<span id="chartUpdateStatus" class="ml-auto text-xs text-gray-400">自动更新中...</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gradient-to-br from-red-50 to-white rounded-lg p-4 border border-red-100">
|
||||
<canvas id="lossChart" class="w-full h-48"></canvas>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-blue-50 to-white rounded-lg p-4 border border-blue-100">
|
||||
<canvas id="gradNormChart" class="w-full h-48"></canvas>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-green-50 to-white rounded-lg p-4 border border-green-100">
|
||||
<canvas id="learningRateChart" class="w-full h-48"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 训练总结 -->
|
||||
<div id="trainSummaryContainer" class="bg-white rounded-xl shadow-md p-6 mb-6 border border-gray-100">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-green-500 to-teal-500 text-white mr-3">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-800">训练总结</h3>
|
||||
<span id="trainSummaryStatus" class="ml-auto text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-500">训练中</span>
|
||||
</div>
|
||||
<div id="trainSummaryContent" class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Epoch</div>
|
||||
<div id="summaryEpoch" class="text-lg font-semibold text-gray-800">-</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">训练损失</div>
|
||||
<div id="summaryTrainLoss" class="text-lg font-semibold text-gray-800">-</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">训练时长</div>
|
||||
<div id="summaryTrainRuntime" class="text-lg font-semibold text-gray-800">-</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">样本/秒</div>
|
||||
<div id="summarySamplesPerSec" class="text-lg font-semibold text-gray-800">-</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">步/秒</div>
|
||||
<div id="summaryStepsPerSec" class="text-lg font-semibold text-gray-800">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="trainSummaryFlos" class="mt-4 text-center text-xs text-gray-400">
|
||||
浮点运算量 (Total FLOPS): <span id="summaryTotalFlos">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 class="text-base font-medium text-gray-800">实时日志</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<input type="text" id="logSearchInput" placeholder="搜索日志..."
|
||||
class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:border-primary w-48"
|
||||
oninput="searchLog()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div id="logMatchCount" class="text-xs text-gray-500 mb-2"></div>
|
||||
<div id="logContent" class="log-content bg-gray-50 rounded p-4 max-h-[400px] overflow-y-auto text-xs">
|
||||
加载日志中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let taskId = null;
|
||||
let taskInfo = null;
|
||||
let trainingLogFullContent = '';
|
||||
|
||||
// 训练曲线数据
|
||||
const lossData = { labels: [], values: [] };
|
||||
const gradNormData = { labels: [], values: [] };
|
||||
const learningRateData = { labels: [], values: [] };
|
||||
|
||||
// 图表实例
|
||||
let lossChart, gradNormChart, learningRateChart;
|
||||
|
||||
// 初始化图表
|
||||
function initCharts() {
|
||||
if (typeof Chart === 'undefined') {
|
||||
document.getElementById('chartsContainer').innerHTML = '<div class="text-center p-4 text-red-500"><i class="fa fa-exclamation-triangle mr-2"></i>图表库加载失败,请刷新页面重试</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建渐变填充函数
|
||||
function createGradient(ctx, colorStart, colorEnd) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||
gradient.addColorStop(0, colorStart);
|
||||
gradient.addColorStop(1, colorEnd);
|
||||
return gradient;
|
||||
}
|
||||
|
||||
// 通用样式配置
|
||||
const basePointStyle = {
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.4
|
||||
};
|
||||
|
||||
const baseLineStyle = {
|
||||
borderWidth: 2.5,
|
||||
borderCapStyle: 'round',
|
||||
borderJoinStyle: 'round'
|
||||
};
|
||||
|
||||
// Loss 图表
|
||||
const lossCtx = document.getElementById('lossChart').getContext('2d');
|
||||
const lossGradient = createGradient(lossCtx, 'rgba(239, 68, 68, 0.4)', 'rgba(239, 68, 68, 0.02)');
|
||||
lossChart = new Chart(lossCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: lossData.labels,
|
||||
datasets: [{
|
||||
label: 'Loss',
|
||||
data: lossData.values,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: lossGradient,
|
||||
fill: true,
|
||||
...basePointStyle,
|
||||
...baseLineStyle
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 500, easing: 'easeOutQuart' },
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: {
|
||||
display: true,
|
||||
text: '📉 损失值 (Loss)',
|
||||
color: '#ef4444',
|
||||
font: { size: 15, weight: '600' },
|
||||
padding: { bottom: 15 }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
displayColors: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: '训练步数 (Step)', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: { color: '#9ca3af', font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: '损失值', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: { color: '#9ca3af', font: { size: 11 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Grad Norm 图表
|
||||
const gradNormCtx = document.getElementById('gradNormChart').getContext('2d');
|
||||
const gradNormGradient = createGradient(gradNormCtx, 'rgba(59, 130, 246, 0.4)', 'rgba(59, 130, 246, 0.02)');
|
||||
gradNormChart = new Chart(gradNormCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: gradNormData.labels,
|
||||
datasets: [{
|
||||
label: 'Grad Norm',
|
||||
data: gradNormData.values,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: gradNormGradient,
|
||||
fill: true,
|
||||
...basePointStyle,
|
||||
...baseLineStyle
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 500, easing: 'easeOutQuart' },
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: {
|
||||
display: true,
|
||||
text: '📊 梯度范数 (Grad Norm)',
|
||||
color: '#3b82f6',
|
||||
font: { size: 15, weight: '600' },
|
||||
padding: { bottom: 15 }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
displayColors: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: '训练步数 (Step)', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: { color: '#9ca3af', font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: '梯度范数', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: { color: '#9ca3af', font: { size: 11 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Learning Rate 图表
|
||||
const lrCtx = document.getElementById('learningRateChart').getContext('2d');
|
||||
const lrGradient = createGradient(lrCtx, 'rgba(34, 197, 94, 0.4)', 'rgba(34, 197, 94, 0.02)');
|
||||
learningRateChart = new Chart(lrCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: learningRateData.labels,
|
||||
datasets: [{
|
||||
label: 'Learning Rate',
|
||||
data: learningRateData.values,
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: lrGradient,
|
||||
fill: true,
|
||||
...basePointStyle,
|
||||
...baseLineStyle
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 500, easing: 'easeOutQuart' },
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: {
|
||||
display: true,
|
||||
text: '📈 学习率 (Learning Rate)',
|
||||
color: '#22c55e',
|
||||
font: { size: 15, weight: '600' },
|
||||
padding: { bottom: 15 }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return '学习率: ' + context.parsed.y.toExponential(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: '训练步数 (Step)', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: { color: '#9ca3af', font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
type: 'logarithmic',
|
||||
title: { display: true, text: '学习率 (对数坐标)', color: '#6b7280', font: { size: 12 } },
|
||||
grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false },
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
font: { size: 11 },
|
||||
callback: function(value) {
|
||||
return value.toExponential(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 解析日志中的指标数据
|
||||
function parseMetricsFromLog(logContent) {
|
||||
// 匹配 {'loss': x.xxxx, 'grad_norm': x.xxxx, 'learning_rate': x.xxxx, 'epoch': x.xx}
|
||||
const metricRegex = /\{'loss':\s*([\d.]+),\s*'grad_norm':\s*([\d.]+),\s*'learning_rate':\s*([\d.e+-]+),\s*'epoch':\s*([\d.]+)\}/g;
|
||||
let match;
|
||||
let step = 0;
|
||||
|
||||
while ((match = metricRegex.exec(logContent)) !== null) {
|
||||
const loss = parseFloat(match[1]);
|
||||
const gradNorm = parseFloat(match[2]);
|
||||
const learningRate = parseFloat(match[3]);
|
||||
const epoch = parseFloat(match[4]);
|
||||
|
||||
// 更新数据
|
||||
if (!lossData.values.includes(loss)) {
|
||||
step++;
|
||||
lossData.labels.push(step);
|
||||
lossData.values.push(loss);
|
||||
gradNormData.labels.push(step);
|
||||
gradNormData.values.push(gradNorm);
|
||||
learningRateData.labels.push(step);
|
||||
learningRateData.values.push(learningRate);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
if (lossChart) {
|
||||
lossChart.data.labels = lossData.labels;
|
||||
lossChart.data.datasets[0].data = lossData.values;
|
||||
lossChart.update('none');
|
||||
}
|
||||
if (gradNormChart) {
|
||||
gradNormChart.data.labels = gradNormData.labels;
|
||||
gradNormChart.data.datasets[0].data = gradNormData.values;
|
||||
gradNormChart.update('none');
|
||||
}
|
||||
if (learningRateChart) {
|
||||
learningRateChart.data.labels = learningRateData.labels;
|
||||
learningRateChart.data.datasets[0].data = learningRateData.values;
|
||||
learningRateChart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
// 解析训练总结指标
|
||||
function parseTrainSummary(logContent, taskStatus) {
|
||||
const summary = {
|
||||
epoch: '-',
|
||||
train_loss: '-',
|
||||
train_runtime: '-',
|
||||
samples_per_sec: '-',
|
||||
steps_per_sec: '-',
|
||||
total_flos: '-',
|
||||
completed: false
|
||||
};
|
||||
|
||||
// 检查任务是否已完成
|
||||
if (taskStatus && taskStatus.toLowerCase() === 'completed') {
|
||||
summary.completed = true;
|
||||
}
|
||||
|
||||
// 匹配训练总结格式
|
||||
const summaryRegex = /\*\*\*\*\* train metrics \*\*\*\*\*\s*[\s\S]*?epoch\s*=\s*([\d.]+)\s*[\s\S]*?total_flos\s*=\s*([\d.]+)([GMT]?)\s*[\s\S]*?train_loss\s*=\s*([\d.]+)\s*[\s\S]*?train_runtime\s*=\s*([\d:.]+)\s*[\s\S]*?train_samples_per_second\s*=\s*([\d.]+)\s*[\s\S]*?train_steps_per_second\s*=\s*([\d.]+)/;
|
||||
|
||||
const match = logContent.match(summaryRegex);
|
||||
|
||||
if (match) {
|
||||
summary.epoch = match[1];
|
||||
summary.total_flos = match[2] + match[3];
|
||||
summary.train_loss = match[4];
|
||||
summary.train_runtime = match[5];
|
||||
summary.samples_per_sec = match[6];
|
||||
summary.steps_per_sec = match[7];
|
||||
summary.completed = true;
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
const summaryEpoch = document.getElementById('summaryEpoch');
|
||||
const summaryTrainLoss = document.getElementById('summaryTrainLoss');
|
||||
const summaryTrainRuntime = document.getElementById('summaryTrainRuntime');
|
||||
const summarySamplesPerSec = document.getElementById('summarySamplesPerSec');
|
||||
const summaryStepsPerSec = document.getElementById('summaryStepsPerSec');
|
||||
const summaryTotalFlos = document.getElementById('summaryTotalFlos');
|
||||
if (summaryEpoch) summaryEpoch.textContent = summary.epoch;
|
||||
if (summaryTrainLoss) summaryTrainLoss.textContent = summary.train_loss;
|
||||
if (summaryTrainRuntime) summaryTrainRuntime.textContent = summary.train_runtime;
|
||||
if (summarySamplesPerSec) summarySamplesPerSec.textContent = summary.samples_per_sec;
|
||||
if (summaryStepsPerSec) summaryStepsPerSec.textContent = summary.steps_per_sec;
|
||||
if (summaryTotalFlos) summaryTotalFlos.textContent = summary.total_flos;
|
||||
|
||||
// 更新状态标签
|
||||
const statusElement = document.getElementById('trainSummaryStatus');
|
||||
if (statusElement) {
|
||||
if (summary.completed) {
|
||||
statusElement.textContent = '已完成';
|
||||
statusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-green-100 text-green-600';
|
||||
} else {
|
||||
statusElement.textContent = '训练中';
|
||||
statusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-600';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 带超时的 fetch
|
||||
async function fetchWithTimeout(url, options = {}, timeout = 10000) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(id);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(id);
|
||||
throw new Error(`请求超时或失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL参数
|
||||
function getQueryParam(name) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get(name);
|
||||
}
|
||||
|
||||
// 获取任务ID(优先从URL参数,其次从sessionStorage)
|
||||
function getTaskId() {
|
||||
let id = getQueryParam('id');
|
||||
if (!id) {
|
||||
try {
|
||||
id = sessionStorage.getItem('trainingLogTaskId');
|
||||
} catch (e) {}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// 返回模型调优列表
|
||||
function goBack() {
|
||||
if (window.parent && window.parent.navigateToPage) {
|
||||
window.parent.navigateToPage('fine-tune');
|
||||
} else {
|
||||
window.location.href = 'main.html?page=fine-tune';
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
async function init() {
|
||||
taskId = getTaskId();
|
||||
|
||||
if (!taskId) {
|
||||
const taskNameEl = document.getElementById('taskName');
|
||||
const logContentEl = document.getElementById('logContent');
|
||||
if (taskNameEl) taskNameEl.textContent = '未指定任务ID';
|
||||
if (logContentEl) logContentEl.innerHTML = '<span class="text-gray-400">请先从模型调优列表点击查看日志</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadTaskInfo();
|
||||
await loadLogContent();
|
||||
|
||||
// 自动刷新(每5秒)
|
||||
setInterval(async () => {
|
||||
await loadTaskInfo();
|
||||
await loadLogContent();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 加载任务信息
|
||||
async function loadTaskInfo() {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_BASE}/fine-tune/${taskId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
taskInfo = result.data;
|
||||
await updateTaskInfo();
|
||||
} else {
|
||||
const statusElement = document.getElementById('taskStatus');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = '获取失败';
|
||||
statusElement.className = 'px-3 py-1 rounded-full text-sm bg-red-100 text-red-700';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Task] 获取任务信息失败:', error);
|
||||
const statusElement = document.getElementById('taskStatus');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = '获取失败';
|
||||
statusElement.className = 'px-3 py-1 rounded-full text-sm bg-red-100 text-red-700';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务信息显示
|
||||
async function updateTaskInfo() {
|
||||
if (!taskInfo) return;
|
||||
|
||||
const taskNameElement = document.getElementById('taskName');
|
||||
if (taskNameElement) {
|
||||
taskNameElement.textContent = taskInfo.name || '未知任务';
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const statusElement = document.getElementById('taskStatus');
|
||||
if (!statusElement) return;
|
||||
|
||||
const actualStatus = taskInfo.status ? taskInfo.status.toLowerCase() : 'unknown';
|
||||
const statusMap = {
|
||||
'pending': { text: '等待中', class: 'bg-gray-100 text-gray-600' },
|
||||
'running': { text: '训练中', class: 'bg-blue-100 text-blue-600' },
|
||||
'completed': { text: '已完成', class: 'bg-green-100 text-green-600' },
|
||||
'failed': { text: '失败', class: 'bg-red-100 text-red-700' },
|
||||
'stopped': { text: '已停止', class: 'bg-orange-100 text-orange-600' }
|
||||
};
|
||||
|
||||
const statusConfig = statusMap[actualStatus] || { text: actualStatus, class: 'bg-gray-100 text-gray-600' };
|
||||
statusElement.textContent = statusConfig.text;
|
||||
statusElement.className = `px-3 py-1 rounded-full text-sm ${statusConfig.class}`;
|
||||
|
||||
// 更新训练总结状态
|
||||
const summaryStatusElement = document.getElementById('trainSummaryStatus');
|
||||
if (summaryStatusElement) {
|
||||
if (actualStatus === 'completed') {
|
||||
summaryStatusElement.textContent = '已完成';
|
||||
summaryStatusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-green-100 text-green-600';
|
||||
} else if (actualStatus === 'running') {
|
||||
summaryStatusElement.textContent = '训练中';
|
||||
summaryStatusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-600';
|
||||
} else if (actualStatus === 'failed' || actualStatus === 'stopped') {
|
||||
summaryStatusElement.textContent = '已停止';
|
||||
summaryStatusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-500';
|
||||
} else {
|
||||
summaryStatusElement.textContent = '等待中';
|
||||
summaryStatusElement.className = 'ml-auto text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表区域状态显示
|
||||
const chartStatusElement = document.getElementById('chartUpdateStatus');
|
||||
if (chartStatusElement) {
|
||||
if (actualStatus === 'completed') {
|
||||
chartStatusElement.textContent = '已完成';
|
||||
chartStatusElement.className = 'ml-auto text-xs text-green-500';
|
||||
} else if (actualStatus === 'running') {
|
||||
chartStatusElement.textContent = '自动更新中...';
|
||||
chartStatusElement.className = 'ml-auto text-xs text-gray-400';
|
||||
} else {
|
||||
chartStatusElement.textContent = '-';
|
||||
chartStatusElement.className = 'ml-auto text-xs text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const progressElement = document.getElementById('taskProgress');
|
||||
if (progressElement && taskInfo.progress !== undefined) {
|
||||
progressElement.textContent = `${taskInfo.progress}%`;
|
||||
}
|
||||
|
||||
// 获取并显示GPU信息(如果有)
|
||||
try {
|
||||
const gpuResponse = await fetchWithTimeout(`${API_BASE}/fine-tune/progress/${taskId}`);
|
||||
const gpuResult = await gpuResponse.json();
|
||||
if (gpuResult.code === 0 && gpuResult.data) {
|
||||
const gpuElement = document.getElementById('taskGPU');
|
||||
if (gpuElement && gpuResult.data.gpu_info) {
|
||||
gpuElement.textContent = gpuResult.data.gpu_info;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// GPU信息获取失败,静默处理
|
||||
}
|
||||
|
||||
// 更新数据集信息
|
||||
const datasetElement = document.getElementById('dataset');
|
||||
if (datasetElement && taskInfo.train_dataset_id) {
|
||||
try {
|
||||
const datasetResponse = await fetchWithTimeout(`${API_BASE}/dataset-manage/${taskInfo.train_dataset_id}`);
|
||||
const datasetResult = await datasetResponse.json();
|
||||
if (datasetResult.code === 0 && datasetResult.data) {
|
||||
datasetElement.textContent = datasetResult.data.name;
|
||||
} else {
|
||||
datasetElement.textContent = `数据集${taskInfo.train_dataset_id}`;
|
||||
}
|
||||
} catch (e) {
|
||||
datasetElement.textContent = `数据集${taskInfo.train_dataset_id}`;
|
||||
}
|
||||
} else if (datasetElement) {
|
||||
datasetElement.textContent = '-';
|
||||
}
|
||||
|
||||
// 更新最后更新时间
|
||||
const lastUpdateElement = document.getElementById('lastUpdate');
|
||||
if (lastUpdateElement && taskInfo.update_time) {
|
||||
try {
|
||||
const updateTime = new Date(taskInfo.update_time);
|
||||
lastUpdateElement.textContent = updateTime.toLocaleString('zh-CN');
|
||||
} catch (e) {
|
||||
lastUpdateElement.textContent = taskInfo.update_time || '-';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他信息
|
||||
const processIdElement = document.getElementById('processId');
|
||||
if (processIdElement) {
|
||||
processIdElement.textContent = taskInfo.process_id || '-';
|
||||
}
|
||||
const createTimeElement = document.getElementById('createTime');
|
||||
if (createTimeElement) {
|
||||
createTimeElement.textContent = taskInfo.create_time ?
|
||||
new Date(taskInfo.create_time).toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
// 获取模型名称
|
||||
if (taskInfo.base_model) {
|
||||
loadModelName(taskInfo.base_model);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型名称
|
||||
async function loadModelName(modelId) {
|
||||
const baseModelElement = document.getElementById('baseModel');
|
||||
if (!baseModelElement) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_BASE}/model-manage`);
|
||||
const result = await response.json();
|
||||
if (result.code === 0 && result.data) {
|
||||
const model = result.data.find(m => m.id == modelId);
|
||||
baseModelElement.textContent = model ? model.name : `模型${modelId}`;
|
||||
}
|
||||
} catch (e) {
|
||||
baseModelElement.textContent = `模型${modelId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志内容
|
||||
async function loadLogContent() {
|
||||
const logContentElement = document.getElementById('logContent');
|
||||
|
||||
// 检查 taskInfo 是否存在
|
||||
if (!taskInfo) {
|
||||
// 尝试重新加载任务信息
|
||||
await loadTaskInfo();
|
||||
if (!taskInfo) {
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-gray-400">无法获取任务信息</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 process_id 和 task_name
|
||||
const processId = taskInfo.process_id;
|
||||
const taskName = taskInfo.name || '';
|
||||
|
||||
if (!processId && !taskName) {
|
||||
const msg = '<span class="text-gray-400">暂无日志文件 (任务未开始或无进程ID)</span>';
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = msg;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_BASE}/training-log-files`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
// 优先使用进程ID匹配文件名
|
||||
let selectedFile = null;
|
||||
|
||||
if (processId) {
|
||||
const pidStr = processId.toString();
|
||||
for (const file of result.data) {
|
||||
if (file.file.startsWith(pidStr + '_') || file.file.includes(`_${pidStr}_`) || file.file.endsWith(`_${pidStr}.log`)) {
|
||||
selectedFile = file.file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,尝试使用任务名称匹配
|
||||
if (!selectedFile && taskName) {
|
||||
for (const file of result.data) {
|
||||
if (file.file.includes(taskName)) {
|
||||
selectedFile = file.file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有找到,使用第一个文件
|
||||
if (!selectedFile && result.data.length > 0) {
|
||||
selectedFile = result.data[0].file;
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
await loadLogFileContent(selectedFile);
|
||||
} else {
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-gray-400">未找到匹配的日志文件</span>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-gray-400">获取日志列表失败: ' + (result.message || '未知错误') + '</span>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log] 获取日志列表失败:', error);
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-red-500">加载日志失败: ' + error.message + '</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志文件内容
|
||||
async function loadLogFileContent(fileName) {
|
||||
const logContentElement = document.getElementById('logContent');
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_BASE}/training-log-content?file=${encodeURIComponent(fileName)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
trainingLogFullContent = result.data.content || '';
|
||||
renderLogContent();
|
||||
// 解析并更新图表
|
||||
parseMetricsFromLog(trainingLogFullContent);
|
||||
// 解析并更新训练总结
|
||||
const taskStatus = taskInfo ? taskInfo.status : 'running';
|
||||
parseTrainSummary(trainingLogFullContent, taskStatus);
|
||||
} else if (result.code === 2) {
|
||||
// 文件被锁定,正在训练中
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = `
|
||||
<div class="text-orange-500 p-4 text-center">
|
||||
<i class="fa fa-spinner fa-spin fa-2x mb-2"></i>
|
||||
<p class="text-lg">日志文件正在被训练进程占用</p>
|
||||
<p class="text-sm text-gray-500 mt-1">${result.message || '训练结束后可查看完整内容'}</p>
|
||||
<p class="text-xs text-gray-400 mt-2">页面将自动刷新...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-red-500">加载日志失败: ' + (result.message || '未知错误') + '</span>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log] 获取日志内容失败:', error);
|
||||
if (logContentElement) {
|
||||
logContentElement.innerHTML = '<span class="text-red-500">加载日志失败: ' + error.message + '</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染日志内容
|
||||
function renderLogContent() {
|
||||
const logContent = document.getElementById('logContent');
|
||||
if (!logContent) return;
|
||||
|
||||
const searchInput = document.getElementById('logSearchInput');
|
||||
const searchText = searchInput ? searchInput.value.toLowerCase() : '';
|
||||
|
||||
if (!trainingLogFullContent) {
|
||||
logContent.innerHTML = '<span class="text-gray-400">暂无日志内容</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = trainingLogFullContent.split('\n');
|
||||
let html = '';
|
||||
let matchCount = 0;
|
||||
|
||||
// 只显示最后500行以提高性能
|
||||
const displayLines = lines.slice(-500);
|
||||
|
||||
for (const line of displayLines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// 搜索过滤
|
||||
if (searchText && !line.toLowerCase().includes(searchText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 级别过滤(不再使用)
|
||||
let cssClass = '';
|
||||
if (line.includes('[ERROR') || line.includes('error:') || line.includes('Error:')) {
|
||||
cssClass = 'error';
|
||||
} else if (line.includes('[WARNING') || line.includes('warning:') || line.includes('Warning:')) {
|
||||
cssClass = 'warning';
|
||||
} else if (line.includes('[INFO') || line.includes('info:') || line.includes('Info:')) {
|
||||
cssClass = 'info';
|
||||
}
|
||||
|
||||
// 进度条格式高亮
|
||||
if (/\d+%/.test(line)) {
|
||||
cssClass = cssClass ? cssClass + ' progress' : 'progress';
|
||||
}
|
||||
|
||||
html += `<div class="log-line ${cssClass}">${escapeHtml(line)}</div>`;
|
||||
matchCount++;
|
||||
}
|
||||
|
||||
if (matchCount === 0) {
|
||||
html = '<div class="text-gray-400 p-4">没有匹配的日志</div>';
|
||||
}
|
||||
|
||||
logContent.innerHTML = html;
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
|
||||
// 更新匹配数量
|
||||
document.getElementById('logMatchCount').textContent =
|
||||
searchText ? `找到 ${matchCount} 条` : '';
|
||||
}
|
||||
|
||||
// 搜索日志
|
||||
function searchLog() {
|
||||
renderLogContent();
|
||||
}
|
||||
|
||||
// HTML转义
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
function startApp() {
|
||||
// 等待 Chart.js 加载完成(最多等待5秒)
|
||||
let waitCount = 0;
|
||||
const maxWait = 50; // 50 * 100ms = 5秒
|
||||
|
||||
function waitForChart() {
|
||||
if (typeof Chart !== 'undefined') {
|
||||
initCharts();
|
||||
init();
|
||||
} else if (waitCount < maxWait) {
|
||||
waitCount++;
|
||||
setTimeout(waitForChart, 100);
|
||||
} else {
|
||||
document.getElementById('chartsContainer').innerHTML = '<div class="text-center p-4 text-red-500"><i class="fa fa-exclamation-triangle mr-2"></i>图表库加载失败,请检查网络或刷新页面</div>';
|
||||
// 仍然初始化其他功能
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已加载,直接初始化;否则等待
|
||||
if (typeof Chart !== 'undefined') {
|
||||
initCharts();
|
||||
init();
|
||||
} else {
|
||||
setTimeout(waitForChart, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// TensorBoard 控制
|
||||
const TB_URL = 'http://10.10.10.177:6006';
|
||||
|
||||
function toggleTB() {
|
||||
const btn = document.getElementById('tbBtn');
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin mr-1"></i>启动中...';
|
||||
btn.className = 'bg-gray-500 text-white px-4 py-2 rounded transition-colors text-sm cursor-wait';
|
||||
|
||||
// 调用API启动TensorBoard服务
|
||||
fetch(`${API_BASE}/fine-tune/tensorboard/start`, { method: 'POST' })
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.code === 0) {
|
||||
// 跳转到TensorBoard页面
|
||||
window.open(TB_URL, '_blank');
|
||||
btn.innerHTML = '<i class="fa fa-bar-chart mr-1"></i>打开TensorBoard';
|
||||
btn.className = 'bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 transition-colors text-sm';
|
||||
} else {
|
||||
alert('提示: ' + (result.message || '启动失败'));
|
||||
btn.innerHTML = '<i class="fa fa-bar-chart mr-1"></i>TensorBoard';
|
||||
btn.className = 'bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 transition-colors text-sm';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('提示: 启动失败 - ' + err.message);
|
||||
btn.innerHTML = '<i class="fa fa-bar-chart mr-1"></i>TensorBoard';
|
||||
btn.className = 'bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600 transition-colors text-sm';
|
||||
});
|
||||
}
|
||||
|
||||
// 立即尝试初始化(处理 iframe 情况)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startApp);
|
||||
} else {
|
||||
startApp();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
web/start.sh
21
web/start.sh
@@ -13,6 +13,18 @@ echo ""
|
||||
# 获取本机IP地址
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# 从 config.yaml 读取端口配置
|
||||
CONFIG_FILE="$SCRIPT_DIR/../config.yaml"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# 使用 python 读取 yaml 文件
|
||||
WEB_PORT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG_FILE'))['app'].get('web_port', 7862))" 2>/dev/null)
|
||||
if [ -z "$WEB_PORT" ]; then
|
||||
WEB_PORT=7862
|
||||
fi
|
||||
else
|
||||
WEB_PORT=7862
|
||||
fi
|
||||
|
||||
echo "请选择启动方式:"
|
||||
echo "1) 直接打开文件(本地访问)"
|
||||
echo "2) 启动HTTP服务器(可通过IP访问)"
|
||||
@@ -63,15 +75,15 @@ case $choice in
|
||||
fi
|
||||
|
||||
echo "📱 访问地址:"
|
||||
echo " - 主页: http://$SERVER_IP:8000/pages/main.html"
|
||||
echo " - 登录: http://$SERVER_IP:8000/pages/login.html"
|
||||
echo " - 主页: http://$SERVER_IP:$WEB_PORT/pages/main.html"
|
||||
echo " - 登录: http://$SERVER_IP:$WEB_PORT/pages/login.html"
|
||||
echo ""
|
||||
echo "⚠️ 服务器将在端口 8000 启动"
|
||||
echo "⚠️ 服务器将在端口 $WEB_PORT 启动"
|
||||
echo "按 Ctrl+C 停止服务器"
|
||||
echo ""
|
||||
|
||||
# 启动HTTP服务器
|
||||
$PYTHON_CMD -m http.server 8000
|
||||
$PYTHON_CMD -m http.server $WEB_PORT
|
||||
;;
|
||||
|
||||
*)
|
||||
@@ -79,4 +91,3 @@ case $choice in
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
Reference in New Issue
Block a user