diff --git a/src/api/logs.py b/src/api/logs.py index 97e3a97..1646d13 100644 --- a/src/api/logs.py +++ b/src/api/logs.py @@ -4,9 +4,7 @@ import os import sys import logging -import yaml from datetime import datetime -from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler from flask import Blueprint, request, jsonify # 获取项目根目录 @@ -14,74 +12,40 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(_ 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) -# 日志目录 -LOG_BASE_DIR = os.path.join(PROJECT_ROOT, 'logs') +# 日志目录 - 使用与 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 setup_logs_logger(): - """配置日志系统,按日期分目录存储""" - today = datetime.now().strftime('%Y-%m-%d') - log_dir = os.path.join(LOG_BASE_DIR, today) - os.makedirs(log_dir, exist_ok=True) - - logger = logging.getLogger('logs_api') - logger.setLevel(logging.DEBUG) - logger.handlers.clear() - - # 全部日志处理器 - all_log_path = os.path.join(log_dir, 'all.log') - all_handler = TimedRotatingFileHandler(all_log_path, when='midnight', interval=1, backupCount=30, encoding='utf-8') - all_handler.setLevel(logging.DEBUG) - all_handler.setFormatter(logging.Formatter( - '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - )) - - # 控制台处理器 - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(logging.Formatter( - '[%(asctime)s] %(levelname)s: %(message)s', - datefmt='%H:%M:%S' - )) - - logger.addHandler(all_handler) - logger.addHandler(console_handler) - - return logger +def get_logs_logger(): + """从 main.py 获取日志记录器""" + return logging.getLogger('logs_api') -logs_logger = setup_logs_logger() - - -@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', datetime.now().isoformat()) - - log_message = f'[WEB-{page}] {message}' - - if level == 'error': - logs_logger.error(log_message) - elif level == 'warning': - logs_logger.warning(log_message) - elif level == 'debug': - logs_logger.debug(log_message) - else: - logs_logger.info(log_message) - - return jsonify({'code': 0, 'message': '日志接收成功'}) +def get_request_logger(): + """获取请求日志记录器""" + return logging.getLogger('request') def format_file_size(size_bytes): @@ -94,6 +58,30 @@ def format_file_size(size_bytes): 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(): """获取指定日期的日志文件列表""" @@ -113,7 +101,8 @@ def get_log_files(): return jsonify({'code': 0, 'data': []}) log_files = [] - file_names = ['all.log', 'error.log', 'request.log'] + # 定义日志文件的优先级顺序 + 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) @@ -178,15 +167,13 @@ TRAINING_LOGS_BASE_DIR = '/app/base/logs' # 本地开发时的备用路径(Windows) LOCAL_TRAINING_LOGS_BASE_DIR = os.path.join(PROJECT_ROOT, 'logs') -# 添加调试日志 -logs_logger.info(f"[DEBUG] TRAINING_LOGS_BASE_DIR: {TRAINING_LOGS_BASE_DIR}") -logs_logger.info(f"[DEBUG] LOCAL_TRAINING_LOGS_BASE_DIR: {LOCAL_TRAINING_LOGS_BASE_DIR}") - @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): @@ -280,7 +267,7 @@ def get_training_log_files(): return jsonify({'code': 0, 'data': log_files}) except Exception as e: - logs_logger.error(f"[DEBUG] 获取训练日志列表失败: {e}") + get_logs_logger().error(f"[DEBUG] 获取训练日志列表失败: {e}") return jsonify({'code': 1, 'message': f'获取训练日志列表失败: {str(e)}'}) @@ -291,6 +278,7 @@ def get_training_log_content(): 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}") diff --git a/src/api/model_chat.py b/src/api/model_chat.py index 5609e00..3a7fd24 100644 --- a/src/api/model_chat.py +++ b/src/api/model_chat.py @@ -8,8 +8,12 @@ import json import requests import concurrent.futures import subprocess +import logging from flask import Blueprint, request, jsonify +# 获取模块 logger(继承 main.py 的日志配置) +logger = logging.getLogger(__name__) + # 获取项目根目录 PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -218,8 +222,6 @@ def preload_trained_model(): import sys as sys_module import pymysql import yaml - import logging - logger = logging.getLogger(__name__) data = request.json model_name = data.get('model_name') # 模型名称 @@ -572,3 +574,502 @@ if __name__ == "__main__": return jsonify({'code': 1, 'message': '推理超时,请稍后重试'}) except Exception as e: return jsonify({'code': 1, 'message': f'推理异常: {str(e)}'}) + + +# ==================== Transformers 本地模型接口 ==================== + +@model_chat_bp.route('/local/preload', methods=['POST']) +def preload_local_model(): + """预加载本地模型(使用 transformers)""" + import yaml + import subprocess + import sys as sys_module + + data = request.json + model_path = data.get('model_path') # 模型路径 + model_name = data.get('model_name', '本地模型') # 模型名称(用于显示) + + if not model_path: + return jsonify({'code': 1, 'message': '缺少模型路径'}) + + logger.info(f"[PRELOAD_LOCAL] 开始预加载本地模型: {model_name}, 路径: {model_path}") + + # 先生成唯一ID,避免并发冲突(必须在f-string之前定义) + import uuid as uuid_module + temp_id = uuid_module.uuid4().hex[:8] + + # 获取项目根目录 + PROJECT_ROOT = os.path.dirname(os.dirname(os.path.dirname(os.path.abspath(__file__)))) + CONFIG_PATH = os.path.join(PROJECT_ROOT, 'config.yaml') + + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + CONFIG = yaml.safe_load(f) + except Exception as e: + return jsonify({'code': 1, 'message': f'读取配置失败: {str(e)}'}) + + # 构建 transformers 预加载脚本(不使用f-string避免import语句导致的模块shadow问题) + preload_script = '''# -*- coding: utf-8 -*- +import sys +import uuid +import logging +import os +logging.basicConfig(level=logging.WARNING, format='%(message)s') + +# 生成唯一的临时文件路径,避免并发冲突 +temp_id = "TEMP_ID" +log_file = "/app/base/logs/preload_local_{}.log".format(temp_id) + +# 设置环境变量 +os.environ["CUDA_VISIBLE_DEVICES"] = "0" +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +try: + from transformers import AutoModelForCausalLM, AutoTokenizer + import torch + + model_id = "MODEL_PATH" + with open(log_file, "w", encoding="utf-8") as f: + f.write("开始加载模型: {}\\n".format(model_id)) + + # 加载 tokenizer + tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) + with open(log_file, "a", encoding="utf-8") as f: + f.write("Tokenizer 加载完成\\n") + + # 加载模型 + model = AutoModelForCausalLM.from_pretrained( + model_id, + torch_dtype=torch.float16, + device_map="auto", + trust_remote_code=True + ) + with open(log_file, "a", encoding="utf-8") as f: + f.write("模型加载完成\\n") + + # 测试推理 + test_input = "你好" + inputs = tokenizer(test_input, return_tensors="pt").to(model.device) + outputs = model.generate(**inputs, max_new_tokens=10, do_sample=False) + response = tokenizer.decode(outputs[0], skip_special_tokens=True) + with open(log_file, "a", encoding="utf-8") as f: + f.write("测试推理成功: {}\\n".format(response)) + + with open(log_file, "a", encoding="utf-8") as f: + f.write("SUCCESS\\n") + +except Exception as e: + with open(log_file, "a", encoding="utf-8") as f: + f.write("ERROR: {}\\n".format(str(e))) + import traceback + with open(log_file, "a", encoding="utf-8") as f: + f.write(traceback.format_exc()) + sys.exit(1) +''' + + # 使用 replace 替换变量(避免 f-string 中包含 import 语句) + preload_script = preload_script.replace('TEMP_ID', temp_id).replace('MODEL_PATH', model_path) + + # 写入临时脚本 - 使用唯一文件名避免并发冲突 + work_dir = '/app/base' + script_path = os.path.join(work_dir, f'temp_preload_local_{temp_id}.py') + log_path = os.path.join('/app/base/logs', f'preload_local_{temp_id}.log') + + try: + # 确保logs目录存在 + os.makedirs(os.path.dirname(log_path), exist_ok=True) + + with open(script_path, 'w', encoding='utf-8') as f: + f.write(preload_script) + + # 查找系统 Python(不使用虚拟环境) + def get_system_python(): + # 尝试常见的系统 Python 路径 + common_pythons = [ + '/usr/bin/python3', + '/usr/bin/python', + '/usr/local/bin/python3', + '/usr/local/bin/python', + ] + for py in common_pythons: + if os.path.exists(py) and os.access(py, os.X_OK): + return py + # 如果都没找到,使用系统 PATH 中的 python3 + import shutil + py_path = shutil.which('python3') + if py_path: + return py_path + return sys_module.executable # 兜底使用当前 Python + + python_executable = get_system_python() + logger.info(f"[PRELOAD_LOCAL] 使用系统 Python: {python_executable}") + + # 继承环境变量,但清除虚拟环境相关的变量 + env = {**os.environ} + env['CUDA_VISIBLE_DEVICES'] = '0' + env['TOKENIZERS_PARALLELISM'] = 'false' + # 清除虚拟环境相关变量 + env.pop('VIRTUAL_ENV', None) + env.pop('PYTHONHOME', None) + + logger.info(f"[PRELOAD_LOCAL] 脚本路径: {script_path}") + logger.info(f"[PRELOAD_LOCAL] 日志路径: {log_path}") + logger.info(f"[PRELOAD_LOCAL] 工作目录: {work_dir}") + logger.info(f"[PRELOAD_LOCAL] Python: {python_executable}") + logger.info(f"[PRELOAD_LOCAL] 脚本是否存在: {os.path.exists(script_path)}") + + # 执行预加载脚本 + try: + result = subprocess.run( + [python_executable, script_path], + capture_output=True, + text=True, + timeout=600, # 10分钟超时 + cwd=work_dir, + env=env + ) + logger.info(f"[PRELOAD_LOCAL] 返回码: {result.returncode}") + logger.info(f"[PRELOAD_LOCAL] stdout: {result.stdout[:500] if result.stdout else 'empty'}") + logger.info(f"[PRELOAD_LOCAL] stderr: {result.stderr[:500] if result.stderr else 'empty'}") + except Exception as sub_err: + logger.error(f"[PRELOAD_LOCAL] subprocess执行异常: {sub_err}") + return jsonify({'code': 1, 'message': f'执行异常: {str(sub_err)}'}) + + # 读取日志文件获取实际输出 + try: + if os.path.exists(log_path): + with open(log_path, 'r', encoding='utf-8') as f: + full_output = f.read() + logger.info(f"[PRELOAD_LOCAL] 日志文件内容: {full_output[:500]}") + else: + logger.warning(f"[PRELOAD_LOCAL] 日志文件不存在: {log_path}") + full_output = result.stdout + result.stderr + except Exception as read_err: + logger.error(f"[PRELOAD_LOCAL] 读取日志失败: {read_err}") + full_output = result.stdout + result.stderr + + logger.info(f"[PRELOAD_LOCAL] 脚本输出: {full_output[:500] if len(full_output) > 500 else full_output}") + + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + + if result.returncode == 0 and 'SUCCESS' in full_output: + logger.info(f"[PRELOAD_LOCAL] 模型预加载成功: {model_name}") + return jsonify({ + 'code': 0, + 'message': '模型预加载成功', + 'data': {'model_name': model_name} + }) + else: + error_msg = '预加载失败' + for line in full_output.split('\n'): + if 'ERROR:' in line: + error_msg = line.split('ERROR:')[1].strip() + break + logger.error(f"[PRELOAD_LOCAL] 预加载失败: {error_msg}") + return jsonify({'code': 1, 'message': f'预加载失败: {error_msg}'}) + + except subprocess.TimeoutExpired: + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + logger.error("[PRELOAD_LOCAL] 预加载超时") + return jsonify({'code': 1, 'message': '预加载超时,请确保模型路径正确且有足够显存'}) + except Exception as e: + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + logger.error(f"[PRELOAD_LOCAL] 预加载异常: {str(e)}") + return jsonify({'code': 1, 'message': f'预加载异常: {str(e)}'}) + + +@model_chat_bp.route('/local/chat', methods=['POST']) +def chat_local_model(): + """使用本地模型进行对话推理(使用 transformers)""" + import yaml + import subprocess + import sys as sys_module + import json + + data = request.json + model_path = data.get('model_path') # 模型路径 + system_prompt = data.get('system_prompt', '') + user_question = data.get('user_question') + temperature = data.get('temperature', 0.7) + max_tokens = data.get('max_tokens', 2048) + + if not model_path: + return jsonify({'code': 1, 'message': '缺少模型路径'}) + if not user_question: + return jsonify({'code': 1, 'message': '缺少用户提问'}) + + logger.info(f"[CHAT_LOCAL] 开始对话推理, 模型路径: {model_path}") + + # 构建消息 + messages = [] + if system_prompt: + messages.append({'role': 'system', 'content': system_prompt}) + messages.append({'role': 'user', 'content': user_question}) + + # 先生成唯一ID,避免并发冲突(必须在f-string之前定义) + import uuid as uuid_module + temp_id = uuid_module.uuid4().hex[:8] + + # 构建 transformers 推理脚本(不使用f-string避免import语句导致的模块shadow问题) + # 使用唯一占位符避免与其他内容冲突 + import json as json_module + messages_json = json_module.dumps(messages, ensure_ascii=False) + + inference_script = '''# -*- coding: utf-8 -*- +import sys +import os +import uuid +import logging +import json +logging.basicConfig(level=logging.WARNING, format='%(message)s') + +# 生成唯一的临时文件路径 +temp_id = "__TEMP_ID__" +log_file = "/app/base/logs/chat_local_{}.log".format(temp_id) + +# 设置环境变量 +os.environ["CUDA_VISIBLE_DEVICES"] = "0" +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +try: + from transformers import AutoModelForCausalLM, AutoTokenizer + import torch + + model_id = "__MODEL_PATH__" + with open(log_file, "w", encoding="utf-8") as f: + f.write("正在加载模型: {}\\n".format(model_id)) + + # 加载 tokenizer + tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) + with open(log_file, "a", encoding="utf-8") as f: + f.write("Tokenizer 加载完成\\n") + + # 加载模型 + model = AutoModelForCausalLM.from_pretrained( + model_id, + torch_dtype=torch.float16, + device_map="auto", + trust_remote_code=True + ) + with open(log_file, "a", encoding="utf-8") as f: + f.write("模型加载完成\\n") + + # 构建消息格式 + messages = __MESSAGES_JSON__ + + # 应用 chat template + if tokenizer.chat_template: + text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) + else: + # 手动构建 + text = "" + for msg in messages: + text += "<|im_start|>{}<|im_end|>\\n".format(msg['role']) + text += "{}<|im_end|>\\n".format(msg['content']) + text += "<|im_start|>assistant\\n" + + with open(log_file, "a", encoding="utf-8") as f: + f.write("构建输入完成\\n") + + # 编码输入 + inputs = tokenizer(text, return_tensors="pt").to(model.device) + + # 生成回复 + outputs = model.generate( + **inputs, + max_new_tokens=__MAX_TOKENS__, + temperature=__TEMPERATURE__, + do_sample=True, + top_p=0.9, + pad_token_id=tokenizer.eos_token_id + ) + + # 解码输出 + response = tokenizer.decode(outputs[0], skip_special_tokens=True) + + # 提取 assistant 的回复 + if "assistant\\n" in response: + response = response.split("assistant\\n")[-1] + elif "<|im_start|>assistant" in response: + response = response.split("<|im_start|>assistant")[-1] + response = response.strip() + + with open(log_file, "a", encoding="utf-8") as f: + f.write("{}\\n".format(response)) + with open(log_file, "a", encoding="utf-8") as f: + f.write("SUCCESS\\n") + +except Exception as e: + with open(log_file, "a", encoding="utf-8") as f: + f.write("ERROR: {}\\n".format(str(e))) + import traceback + with open(log_file, "a", encoding="utf-8") as f: + f.write(traceback.format_exc()) + sys.exit(1) +''' + + # 使用 replace 替换变量(避免 f-string 中包含 import 语句) + inference_script = inference_script.replace('__TEMP_ID__', temp_id) + inference_script = inference_script.replace('__MODEL_PATH__', model_path) + inference_script = inference_script.replace('__MESSAGES_JSON__', messages_json) + inference_script = inference_script.replace('__MAX_TOKENS__', str(max_tokens)) + inference_script = inference_script.replace('__TEMPERATURE__', str(temperature)) + + # 写入临时脚本 - 使用唯一文件名避免并发冲突 + work_dir = '/app/base' + script_path = os.path.join(work_dir, f'temp_chat_local_{temp_id}.py') + log_path = os.path.join('/app/base/logs', f'chat_local_{temp_id}.log') + + try: + # 确保logs目录存在 + os.makedirs(os.path.dirname(log_path), exist_ok=True) + + with open(script_path, 'w', encoding='utf-8') as f: + f.write(inference_script) + + # 查找系统 Python(不使用虚拟环境) + def get_system_python(): + # 尝试常见的系统 Python 路径 + common_pythons = [ + '/usr/bin/python3', + '/usr/bin/python', + '/usr/local/bin/python3', + '/usr/local/bin/python', + ] + for py in common_pythons: + if os.path.exists(py) and os.access(py, os.X_OK): + return py + # 如果都没找到,使用系统 PATH 中的 python3 + import shutil + py_path = shutil.which('python3') + if py_path: + return py_path + return sys_module.executable # 兜底使用当前 Python + + python_executable = get_system_python() + logger.info(f"[CHAT_LOCAL] 使用系统 Python: {python_executable}") + + # 继承环境变量,但清除虚拟环境相关的变量 + env = {**os.environ} + env['CUDA_VISIBLE_DEVICES'] = '0' + env['TOKENIZERS_PARALLELISM'] = 'false' + # 清除虚拟环境相关变量 + env.pop('VIRTUAL_ENV', None) + env.pop('PYTHONHOME', None) + + # 执行推理脚本 + result = subprocess.run( + [python_executable, script_path], + capture_output=True, + text=True, + timeout=600, # 10分钟超时 + cwd=work_dir, + env=env + ) + + # 读取日志文件获取实际输出 + try: + if os.path.exists(log_path): + with open(log_path, 'r', encoding='utf-8') as f: + full_output = f.read() + else: + full_output = result.stdout + result.stderr + except Exception as read_err: + logger.error(f"[CHAT_LOCAL] 读取日志失败: {read_err}") + full_output = result.stdout + result.stderr + + logger.info(f"[CHAT_LOCAL] 脚本输出: {full_output[:500] if len(full_output) > 500 else full_output}") + + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + + if result.returncode == 0 and 'SUCCESS' in full_output: + # 提取实际回复(去掉最后的 SUCCESS) + lines = full_output.strip().split('\n') + response_lines = [line for line in lines if line.strip() and line.strip() != 'SUCCESS'] + assistant_content = '\n'.join(response_lines).strip() + + logger.info(f"[CHAT_LOCAL] 对话成功, 回复长度: {len(assistant_content)}") + return jsonify({ + 'code': 0, + 'data': { + 'model_path': model_path, + 'response': assistant_content + } + }) + else: + error_msg = '推理失败' + for line in full_output.split('\n'): + if 'ERROR:' in line: + error_msg = line.split('ERROR:')[1].strip() + break + logger.error(f"[CHAT_LOCAL] 推理失败: {error_msg}") + return jsonify({'code': 1, 'message': f'推理失败: {error_msg}'}) + + except subprocess.TimeoutExpired: + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + logger.error("[CHAT_LOCAL] 推理超时") + return jsonify({'code': 1, 'message': '推理超时,请减少生成token数量或调整参数'}) + except Exception as e: + # 清理临时文件 + try: + if os.path.exists(script_path): + os.remove(script_path) + except Exception: + pass + try: + if os.path.exists(log_path): + os.remove(log_path) + except Exception: + pass + logger.error(f"[CHAT_LOCAL] 推理异常: {str(e)}") + return jsonify({'code': 1, 'message': f'推理异常: {str(e)}'}) diff --git a/src/api/model_manage.py b/src/api/model_manage.py index 0c21264..419b463 100644 --- a/src/api/model_manage.py +++ b/src/api/model_manage.py @@ -4,8 +4,12 @@ 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__)))) @@ -47,8 +51,6 @@ def generic_get_all(table_name, order_by='create_time DESC'): def get_model_path_by_name(model_name): """根据模型名称查询模型路径(用于获取基座模型路径)""" - import logging - logger = logging.getLogger(__name__) logger.info(f"[DEBUG get_model_path_by_name] 查询模型: {model_name}") try: @@ -165,6 +167,23 @@ def get_model_manage_by_id(id): return jsonify({'code': 1, 'message': '模型不存在'}) +@model_manage_bp.route('/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(): """创建模型""" @@ -575,25 +594,57 @@ def merge_model(): @model_manage_bp.route('/trained-models/', methods=['DELETE']) def delete_trained_model(model_name): - """删除已训练模型(从local_trained_models目录)""" + """删除已训练模型 + 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: - # 删除 local_trained_models 目录下的模型 - model_path = os.path.join(PROJECT_ROOT, 'local_trained_models', model_name) + if delete_type == 'lora': + # 删除权重:删除 saves 目录下的权重 + saves_path = os.path.join(PROJECT_ROOT, 'saves') + train_methods = ['lora', 'full', 'qlora', 'dpo', 'cpt', 'prefix', 'adapter', 'peft'] - if not os.path.exists(model_path): - return jsonify({'code': 1, 'message': f'模型不存在: {model_name}'}) + 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 - # 删除目录 - shutil.rmtree(model_path) - logger.info(f"[DELETE] 已删除模型: {model_path}") + 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 - return jsonify({'code': 0, 'message': '删除成功'}) + 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)}") + logger.error(f"[DELETE] 删除失败: {str(e)}") return jsonify({'code': 1, 'message': f'删除失败: {str(e)}'}) diff --git a/src/main.py b/src/main.py index c4abbd9..0db6121 100644 --- a/src/main.py +++ b/src/main.py @@ -37,7 +37,25 @@ CONFIG = load_config() TRAINING_LOGS_DIR = CONFIG.get('training_logs_path', '/app/base/training_logs') # ============ 日志系统配置 ============ -LOG_BASE_DIR = os.path.join(PROJECT_ROOT, 'logs') +# 日志目录逻辑: +# 1. 优先使用环境变量 LOG_BASE_DIR +# 2. 如果是容器环境(存在 /app/base),使用 /app/base/logs +# 3. 否则使用本地项目路径 PROJECT_ROOT/logs +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() def setup_logger(name='app'): @@ -98,6 +116,15 @@ def setup_logger(name='app'): datefmt='%Y-%m-%d %H:%M:%S' )) + # 6. API日志处理器 - 专门记录API相关日志 + api_log_path = os.path.join(log_dir, 'api.log') + api_handler = RotatingFileHandler(api_log_path, maxBytes=10*1024*1024, backupCount=10, encoding='utf-8') + api_handler.setLevel(logging.DEBUG) + api_handler.setFormatter(logging.Formatter( + '[%(asctime)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + )) + # 添加处理器到 logger logger.addHandler(all_handler) logger.addHandler(error_handler) @@ -117,6 +144,13 @@ def setup_logger(name='app'): train_logger.addHandler(train_handler) train_logger.addHandler(console_handler) + # 为 logs_api 创建单独的 logger (供 src/api/logs.py 使用) + logs_api_logger = logging.getLogger('logs_api') + logs_api_logger.setLevel(logging.DEBUG) + logs_api_logger.handlers.clear() + logs_api_logger.addHandler(api_handler) + logs_api_logger.addHandler(console_handler) + return logger @@ -588,6 +622,8 @@ def system_info(): # ============ 通用 CRUD 操作 ============ +import json + def generic_get_all(table_name, order_by='create_time DESC'): """通用查询所有""" conn = get_db_connection() @@ -596,6 +632,19 @@ def generic_get_all(table_name, order_by='create_time DESC'): result = cursor.fetchall() cursor.close() conn.close() + # 自动解析 JSON 字段 + for row in result: + for key, value in row.items(): + if isinstance(value, str) and value.startswith('[') and value.endswith(']'): + try: + row[key] = json.loads(value) + except: + pass + elif isinstance(value, str) and value.startswith('{') and value.endswith('}'): + try: + row[key] = json.loads(value) + except: + pass return result @@ -607,6 +656,19 @@ def generic_get_by_id(table_name, id_val): result = cursor.fetchone() cursor.close() conn.close() + # 自动解析 JSON 字段 + if result: + for key, value in result.items(): + if isinstance(value, str) and value.startswith('[') and value.endswith(']'): + try: + result[key] = json.loads(value) + except: + pass + elif isinstance(value, str) and value.startswith('{') and value.endswith('}'): + try: + result[key] = json.loads(value) + except: + pass return result @@ -1053,36 +1115,19 @@ def delete_model_deploy(id): return jsonify({'code': 0, 'message': '删除成功'}) -# ============ 模型管理接口 ============ -@app.route('/api/model-manage', methods=['GET']) -def get_model_manage(): - return jsonify({'code': 0, 'data': generic_get_all('model_manage')}) - - -@app.route('/api/model-manage', methods=['POST']) -def create_model_manage(): - data = request.json - new_id = generic_create('model_manage', data) - return jsonify({'code': 0, 'message': '创建成功', 'id': new_id}) - - -@app.route('/api/model-manage/', methods=['PUT']) -def update_model_manage(id): - data = request.json - generic_update('model_manage', id, data) - return jsonify({'code': 0, 'message': '更新成功'}) - - -@app.route('/api/model-manage/', methods=['DELETE']) -def delete_model_manage(id): - generic_delete('model_manage', id) - return jsonify({'code': 0, 'message': '删除成功'}) - - # ============ 模型对比接口 ============ @app.route('/api/model-compare', methods=['GET']) def get_model_compare(): - return jsonify({'code': 0, 'data': generic_get_all('model_compare')}) + result = generic_get_all('model_compare') + # 确保 models 字段被正确解析为 JSON + for row in result: + if 'models' in row and isinstance(row['models'], str): + try: + row['models'] = json.loads(row['models']) + logger.debug(f"[model-compare] 解析 models 字段成功: {row['models']}") + except Exception as e: + logger.error(f"[model-compare] 解析 models 字段失败: {e}, 原始值: {row['models']}") + return jsonify({'code': 0, 'data': result}) @app.route('/api/model-compare/', methods=['GET']) @@ -1090,13 +1135,25 @@ def get_model_compare_by_id(id): """获取单个模型对比任务""" result = generic_get_by_id('model_compare', id) if result: + # 确保 models 字段被正确解析为 JSON + if 'models' in result and isinstance(result['models'], str): + try: + result['models'] = json.loads(result['models']) + except Exception as e: + logger.error(f"[model-compare] 解析 models 字段失败: {e}") return jsonify({'code': 0, 'data': result}) return jsonify({'code': 1, 'message': '任务不存在'}) @app.route('/api/model-compare', methods=['POST']) def create_model_compare(): - data = request.json + data = request.json.copy() + # 字段映射: name -> model_name + if 'name' in data: + data['model_name'] = data.pop('name') + # 设置默认加载状态 + if 'status' not in data: + data['status'] = 'pending' new_id = generic_create('model_compare', data) return jsonify({'code': 0, 'message': '创建成功', 'id': new_id}) @@ -1110,10 +1167,278 @@ def update_model_compare(id): @app.route('/api/model-compare/', methods=['DELETE']) def delete_model_compare(id): + # 先停止加载的模型服务 + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT models, load_status FROM model_compare WHERE id = %s", (id,)) + row = cursor.fetchone() + cursor.close() + conn.close() + + if row and row[1] == 'loaded': + # 停止模型服务 + from subprocess import Popen + import signal + load_status = json.loads(row[0]) if isinstance(row[0], str) else row[0] + if isinstance(load_status, dict) and 'processes' in load_status: + for proc in load_status['processes']: + if 'pid' in proc: + try: + os.kill(proc['pid'], signal.SIGTERM) + except: + pass + except Exception as e: + logger.error(f"[model-compare] 停止模型服务失败: {e}") + generic_delete('model_compare', id) return jsonify({'code': 0, 'message': '删除成功'}) +# ============ 模型加载接口 ============ +import subprocess +import signal + +# 存储加载状态 (生产环境应使用数据库或Redis) +model_compare_processes = {} + + +@app.route('/api/model-compare//load', methods=['POST']) +def load_model_compare(id): + """加载模型 - 启动模型服务""" + try: + # 获取对比任务 + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT models FROM model_compare WHERE id = %s", (id,)) + row = cursor.fetchone() + cursor.close() + conn.close() + + if not row: + return jsonify({'code': 1, 'message': '任务不存在'}) + + models = json.loads(row[0]) if isinstance(row[0], str) else row[0] + if not models or len(models) < 2: + return jsonify({'code': 1, 'message': '模型配置无效'}) + + # 更新状态为 loading + generic_update('model_compare', id, {'status': 'loading'}) + + # 启动模型服务 + processes = [] + loaded_models = [] + + for i, model in enumerate(models): + model_path = model.get('model_path', '') + gpu_id = model.get('gpu_id', 0) + port = model.get('port', 7862 + i * 10) + + if not model_path: + continue + + # 构建启动命令 + # 获取模型名称和模板 + model_name = model.get('model_name', '') + template = detect_template(model_path) + + cmd = [ + sys.executable, + '-m', 'llamafactory.api', + '--model_name_or_path', model_path, + '--template', template or 'default', + '--port', str(port), + '--gpu_ids', str(gpu_id) + ] + + logger.info(f"[model-compare] 启动模型服务: {' '.join(cmd)}") + + # 启动进程 + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=PROJECT_ROOT + ) + + processes.append({ + 'pid': proc.pid, + 'port': port, + 'model_name': model_name, + 'model_path': model_path + }) + + loaded_models.append({ + 'port': port, + 'model_name': model_name, + 'status': 'starting' + }) + + # 保存进程信息 + model_compare_processes[id] = { + 'processes': processes, + 'loaded_models': loaded_models, + 'start_time': datetime.now().timestamp() + } + + # 更新数据库中的加载状态 + load_status = { + 'processes': processes, + 'loaded_models': loaded_models, + 'started_at': datetime.now().isoformat() + } + generic_update('model_compare', id, { + 'status': 'loading', + 'load_status': json.dumps(load_status, ensure_ascii=False) + }) + + return jsonify({ + 'code': 0, + 'message': '正在加载模型...', + 'data': { + 'models': loaded_models, + 'check_url': f'/api/model-compare/{id}/load-status' + } + }) + + except Exception as e: + logger.error(f"[model-compare] 加载模型失败: {e}") + generic_update('model_compare', id, {'status': 'pending'}) + return jsonify({'code': 1, 'message': f'加载失败: {str(e)}'}) + + +def detect_template(model_path): + """根据模型路径检测模板类型""" + model_lower = model_path.lower() + if 'qwen' in model_lower: + return 'qwen' + elif 'llama' in model_lower or 'llama' in model_lower: + return 'llama' + elif 'chatglm' in model_lower: + return 'chatglm' + elif 'baichuan' in model_lower: + return 'baichuan' + elif 'mistral' in model_lower: + return 'mistral' + elif 'yi' in model_lower: + return 'yi' + elif 'deepseek' in model_lower: + return 'deepseek' + return 'default' + + +@app.route('/api/model-compare//load-status', methods=['GET']) +def get_load_status(id): + """获取模型加载状态""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT models, load_status FROM model_compare WHERE id = %s", (id,)) + row = cursor.fetchone() + cursor.close() + conn.close() + + if not row: + return jsonify({'code': 1, 'message': '任务不存在'}) + + models = json.loads(row[0]) if isinstance(row[0], str) else row[0] + load_status = json.loads(row[1]) if row[1] and isinstance(row[1], str) else {} + + loaded_models = load_status.get('loaded_models', []) + processes = load_status.get('processes', []) + + # 检查每个模型服务是否就绪 + all_ready = True + for i, model in enumerate(loaded_models): + port = model.get('port') + try: + import requests + resp = requests.get(f'http://localhost:{port}/health', timeout=2) + if resp.status_code == 200: + model['status'] = 'ready' + else: + model['status'] = 'starting' + all_ready = False + except: + # 检查进程是否还在运行 + if 'pid' in processes[i] if i < len(processes) else False: + model['status'] = 'starting' + all_ready = False + else: + model['status'] = 'failed' + all_ready = False + + # 更新状态 + if all_ready: + generic_update('model_compare', id, {'status': 'loaded'}) + else: + generic_update('model_compare', id, {'status': 'loading'}) + + # 更新加载状态 + load_status['loaded_models'] = loaded_models + load_status['all_ready'] = all_ready + generic_update('model_compare', id, { + 'load_status': json.dumps(load_status, ensure_ascii=False) + }) + + return jsonify({ + 'code': 0, + 'data': { + 'status': 'loaded' if all_ready else 'loading', + 'models': loaded_models, + 'all_ready': all_ready + } + }) + + except Exception as e: + logger.error(f"[model-compare] 获取加载状态失败: {e}") + return jsonify({'code': 1, 'message': str(e)}) + + +@app.route('/api/model-compare//unload', methods=['POST']) +def unload_model_compare(id): + """停止加载的模型服务""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT load_status FROM model_compare WHERE id = %s", (id,)) + row = cursor.fetchone() + cursor.close() + conn.close() + + if row and row[0]: + load_status = json.loads(row[0]) if isinstance(row[0], str) else row[0] + processes = load_status.get('processes', []) + + # 停止所有进程 + for proc in processes: + pid = proc.get('pid') + if pid: + try: + os.kill(pid, signal.SIGTERM) + logger.info(f"[model-compare] 已停止进程 {pid}") + except ProcessLookupError: + pass + except Exception as e: + logger.warning(f"[model-compare] 停止进程 {pid} 失败: {e}") + + # 清理内存 + if id in model_compare_processes: + del model_compare_processes[id] + + # 更新状态 + generic_update('model_compare', id, { + 'status': 'pending', + 'load_status': None + }) + + return jsonify({'code': 0, 'message': '已停止模型服务'}) + + except Exception as e: + logger.error(f"[model-compare] 停止模型服务失败: {e}") + return jsonify({'code': 1, 'message': str(e)}) + + # ============ 数据生成接口 ============ @app.route('/api/data-generate', methods=['GET']) def get_data_generate(): diff --git a/start_all.py b/start_all.py index cb1cfd6..0a5e9a9 100644 --- a/start_all.py +++ b/start_all.py @@ -120,15 +120,16 @@ def start_api(): 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=subprocess.PIPE, - stderr=subprocess.STDOUT, + stdout=log_file, + stderr=log_file, text=True, bufsize=1, env=env @@ -172,11 +173,12 @@ def start_web(): 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=subprocess.PIPE, - stderr=subprocess.STDOUT, + stdout=log_file, + stderr=log_file, text=True, bufsize=1, env=env diff --git a/start_all.sh b/start_all.sh index 9577177..05ae6a1 100644 --- a/start_all.sh +++ b/start_all.sh @@ -63,11 +63,13 @@ start_api() { return 1 fi - LOG_DIR="$SCRIPT_DIR/logs/$(date +%Y-%m-%d)" - mkdir -p "$LOG_DIR" - python src/main.py > "$LOG_DIR/api.log" 2>&1 & + # 设置日志目录环境变量 + 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)" + echo "✅ 后端服务已启动 (PID: $API_PID, 端口: $API_PORT, 日志目录: $LOG_BASE_DIR)" echo "$API_PID" > /tmp/ygft_api.pid } diff --git a/web/css/main.css b/web/css/main.css new file mode 100644 index 0000000..3e68c77 --- /dev/null +++ b/web/css/main.css @@ -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; +} diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..0cf6190 --- /dev/null +++ b/web/js/api.js @@ -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(); diff --git a/web/js/components/sidebar-loader.js b/web/js/components/sidebar-loader.js new file mode 100644 index 0000000..d84b9ba --- /dev/null +++ b/web/js/components/sidebar-loader.js @@ -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(); + } +})(); diff --git a/web/js/components/table.js b/web/js/components/table.js new file mode 100644 index 0000000..a67e5ac --- /dev/null +++ b/web/js/components/table.js @@ -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 = `批量删除 (${window.selectedItems.size})`; + } else { + batchDeleteBtn.innerHTML = `批量删除 (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 '' + displayText + ''; + }}, + { 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 '' + display.text + ''; + }}, + { title: '模型来源', key: 'model_source', render: (val) => { + const textMap = { 'local': '本地模型', 'api': '在线模型', 'online': '在线模型' }; + const displayText = textMap[val] || val || '-'; + return '' + displayText + ''; + }}, + { title: '描述', key: 'description', render: (val) => val || '-' }, + { title: '创建时间', key: 'create_time', render: (val) => val ? new Date(val).toLocaleString('zh-CN') : '-' } + ]; + createButton = ` + + `; + 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) => `${val || '-'}` }, + { 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' ? ` + + ` : (config.api === 'fine-tune' ? ` + + ` : (config.api === 'model-compare' ? ` + + ` : '')); + 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) ? ` +
+ + +
+ ` : ''; + + // 批量删除按钮(仅当有选中项时显示) + const batchDeleteButton = supportsMultiSelect && window.selectedItems.size > 0 ? ` + + ` : ''; + + const hasData = data && data.length > 0; + + // 多选列头 + const selectAllHeader = supportsMultiSelect ? ` + + + + ` : ''; + + return ` +
+
+

${config.title}

+
+ ${searchBox} + ${createButton} +
+
+ ${config.hasModelTabs ? ` +
+
+ + +
+
+ + ` : ''} + ${supportsMultiSelect ? ` +
+
+ 已选择 ${window.selectedItems.size} + +
+
+ ${batchDeleteButton} +
+
+ ` : ''} +
+ + + + ${selectAllHeader} + ${columns.map(col => ``).join('')} + + + + + ${hasData ? data.map(item => ` + + ${supportsMultiSelect ? ` + + ` : ''} + ${columns.map(col => ` + + `).join('')} + + + `).join('') : ''} + +
${col.title}操作
+ + + ${col.render ? col.render(item[col.key], item) : (item[col.key] || '-')} + +
+ ${config.api === 'fine-tune' ? ` + + + ` : (currentApi === 'model-manage/trained-models' ? ` + ${getMergeButtonHtml(item.name, item.train_methods?.[0]?.name || 'lora', item.base_model_path || '', item.merged, item.merging)} + + ${item.merged ? ` + + ` : ''} + ${(item.merged && !item.merging) ? ` + + ` : ''} + ` : (currentApi === 'model-manage' ? ` + + + ` : (config.api === 'dataset-manage' ? ` + + + + ` : (config.api === 'model-compare' ? ` + ${getCompareButtonHtml(item.id, item.status)} + ` : ''))))} +
+
+
+ ${!hasData ? ` +
+
+ +

暂无数据

+
+
+ ` : ''} +
+ `; +} + +// 获取合并按钮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 ``; + } + } + // 如果后端返回正在合并中(锁文件存在) + if (merging) { + return ``; + } + if (tempStatus === 'success' && merged) { + sessionStorage.removeItem(storageKey); + sessionStorage.removeItem(storageKey + '_time'); + return ``; + } + if (tempStatus === 'success' && !merged) { + sessionStorage.removeItem(storageKey); + sessionStorage.removeItem(storageKey + '_time'); + } + if (merged) { + return ``; + } + return ``; +} + +// 获取模型对比操作按钮HTML +function getCompareButtonHtml(id, status) { + // status: pending(未加载), loading(加载中), loaded(已加载) + if (status === 'loading') { + return ``; + } + if (status === 'loaded') { + return ` + + + `; + } + // pending - 显示"准备加载"按钮 + return ``; +} + +// 加载模型对比任务 +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 +}; diff --git a/web/js/config/constants.js b/web/js/config/constants.js new file mode 100644 index 0000000..5738f71 --- /dev/null +++ b/web/js/config/constants.js @@ -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; diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..ede2e52 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,597 @@ +/** + * 主入口模块 + * 页面初始化和导航控制 + */ + +// 各功能模块的表格配置 +window.tableConfigs = { + 'fine-tune': { + title: '模型调优', + api: 'fine-tune', + hasCreate: true, + createText: '创建训练任务', + columns: [ + { title: '任务名称', key: 'name' }, + { title: '任务状态', key: 'status', render: (val) => `${val}` }, + { 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) => `加载中...` } + ], + 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) => `${val || '-'}` }, + { 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 '' + displayText + ''; + }}, + { title: '存储位置', key: 'storage_type', render: (val) => { + const textMap = { + 'local': '本地存储', + 'minio': 'MinIO', + 'cloud': '云存储' + }; + const displayText = textMap[val] || val || '-'; + return '' + displayText + ''; + }}, + { 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 '' + displayText + ''; + }}, + { 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 '' + display.text + ''; + }}, + { title: '模型来源', key: 'model_source', render: (val) => { + const textMap = { + 'local': '本地模型', + 'api': '在线模型', + 'online': '在线模型' + }; + const displayText = textMap[val] || val || '-'; + return '' + displayText + ''; + }}, + { 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 = ` +
+ +

加载中...

+
+ `; + + // 显示/隐藏返回按钮 + 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 = /]*\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(/]*>[\s\S]*?<\/script>/g, ''); + + let headerHtml = ''; + if (config.createConfig && config.createConfig.hasCreate) { + headerHtml = ` +
+
+ + + +
+
+ +
+
+ + `; + } + + 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 = ` +
+ +

加载数据失败,请检查后端服务是否启动

+

${error.message}

+
+ `; + } +} + +// 添加评测维度 +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 = ` + + `; + } else if (tabId === 'leaderboard') { + btnContainer.innerHTML = ''; + } else if (tabId === 'dimensions') { + btnContainer.innerHTML = ` + + `; + } + } +}; + +// ============ 初始化 ============ +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('页面加载完成'); +}); diff --git a/web/js/pages/render.js b/web/js/pages/render.js new file mode 100644 index 0000000..5ffaea8 --- /dev/null +++ b/web/js/pages/render.js @@ -0,0 +1,723 @@ +/** + * 页面渲染模块 + * 包含各类型页面的渲染函数 + */ + +// 渲染日志查看页面 +function renderLogViewerPage(config) { + return ` +
+
+

${config.title}

+
+ +
+
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ + + + + +
+
+ 请选择日志文件 +
+ + + +
+
+
日志内容将在这里显示...
+
+
+
+ `; +} + +// 渲染工具卡片页面 +function renderToolsPage(config) { + const renderToolCard = (tool, canDelete = false, isCustom = false) => ` +
+
+ +
+

${tool.name}

+

${tool.description}

+ ${canDelete ? ` + + + ` : ''} +
+ `; + + const defaultCards = config.defaultTools.map(t => renderToolCard(t, false, false)).join(''); + const customCards = config.customTools.map(t => renderToolCard(t, true, true)).join(''); + + return ` +
+
+

${config.title}

+ +
+
+ +

默认工具

+
+ ${defaultCards || '

暂无默认工具

'} +
+ + +

自定义工具

+
+ ${customCards || '

暂无自定义工具,点击右上角添加

'} +
+
+
+ `; +} + +// 删除自定义工具 +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 ` +
+
+

${config.title}

+
+ + 刷新频率: + +
+
+
+ +
+ +
+
+
+
+ +
+
+

CPU 使用率

+

4 核心

+
+
+
+ 0% +
+
+
+
+
+
+
+
核心1
+
0%
+
+
+
核心2
+
0%
+
+
+
核心3
+
0%
+
+
+
核心4
+
0%
+
+
+
+ + +
+
+
+
+ +
+
+

内存使用

+

总计: 16 GB

+
+
+
+ 0% +
+
+
+
+
+
+
+
已用
+
0 GB
+
+
+
可用
+
0 GB
+
+
+
缓存
+
0 GB
+
+
+
+ + +
+
+
+
+ +
+
+

磁盘使用

+

SSD 512 GB

+
+
+
+ 0% +
+
+
+
+
+
+
+
已用空间
+
0 GB
+
+
+
可用空间
+
0 GB
+
+
+
读写速度
+
0 MB/s
+
+
+
+
+ + +
+
+
+ +
+
+

GPU监控

+

多GPU并行监控

+
+
+
+ +
+
+ + +
+ +
+
+
+ +
+
+

网络流量

+

实时带宽使用

+
+
+
+
+
+ + 下载速度 +
+
0 MB/s
+
+
+
+ + 上传速度 +
+
0 MB/s
+
+
+
+ 总流入: 0 GB + 总流出: 0 GB +
+
+ + +
+
+
+ +
+
+

系统信息

+

服务器状态

+
+
+
+
+ 操作系统 + Ubuntu 22.04 LTS +
+
+ 运行时间 + 0 天 0 时 0 分 +
+
+ 进程数 + 0 +
+
+ 负载均值 + 0.00, 0.00, 0.00 +
+
+
+
+
+
+ `; +} + +// 刷新间隔定时器 +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 += ` +
+
+
+
+ +
+
+
${gpu.name}
+
PCIe
+
+
+
+ ${gpu.gpu_percent}% +
+
+
+
+
+
+
+
显存
+
${gpu.memory_used_gb}/${gpu.memory_total_gb} GB
+
+
+
温度
+
${gpu.temperature}°C
+
+
+
功耗
+
${gpu.power_w} W
+
+
+
Fan
+
${gpu.fan_speed || 0}%
+
+
+
+
Clock: ${gpu.clock_mhz || 0} MHz
+
Driver: ${gpu.driver_version || '-'}
+
+
+ `; + } + // 如果GPU数量不足4个,补充显示总显存 + if (gpuCount < 4) { + gpuCardsHTML += ` +
+
+
+
+ +
+
总显存使用
+
+
+ ${totalUsedMemory}/${totalMemory} GB +
+
+
+ `; + } + 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 += ` +
+
+
+
+ +
+
+
${config.name}
+
PCIe ${Math.floor(Math.random() * 4 + 1)}:00.0
+
+
+
+ ${gpuUsage}% +
+
+
+
+
+
+
+
显存
+
${parseFloat(memUsed).toFixed(1)}/${config.memory} GB
+
+
+
温度
+
${temp}°C
+
+
+
功耗
+
${power} W
+
+
+
Fan
+
${fan}%
+
+
+
+ `; + } + 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 +}; diff --git a/web/js/services/system.js b/web/js/services/system.js new file mode 100644 index 0000000..3f24feb --- /dev/null +++ b/web/js/services/system.js @@ -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 = ''; + + try { + const response = await fetch(`${window.API_BASE}/training-log-files`); + const result = await response.json(); + + if (result.code === 0 && result.data) { + logSelect.innerHTML = ''; + 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 = ''; + document.getElementById('logContent').textContent = '暂无训练日志'; + document.getElementById('logFileInfo').textContent = '无训练日志'; + } + } catch (error) { + console.error('加载训练日志列表失败:', error); + logSelect.innerHTML = ''; + 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 = ''; + + 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 = ''; + 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 = ''; + document.getElementById('logContent').textContent = '该日期暂无日志文件'; + document.getElementById('logFileInfo').textContent = '无日志文件'; + } + } else { + logTypeSelect.innerHTML = ''; + document.getElementById('logContent').textContent = '该日期暂无日志文件'; + document.getElementById('logFileInfo').textContent = '无日志文件'; + } + } catch (error) { + console.error('加载日志文件列表失败:', error); + logTypeSelect.innerHTML = ''; + 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 +}; diff --git a/web/js/services/training.js b/web/js/services/training.js new file mode 100644 index 0000000..c099d52 --- /dev/null +++ b/web/js/services/training.js @@ -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 ` +
+ ${progressData.progress}% + ${progressData.step || ''} ${progressData.speed || ''} + ETA: ${progressData.eta || '--:--'} +
+ `; + } + } + 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 = `${actualStatus}`; + } + } + } + } + } 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 = `${actualStatus}`; + 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 +}; diff --git a/web/js/utils.js b/web/js/utils.js new file mode 100644 index 0000000..f998766 --- /dev/null +++ b/web/js/utils.js @@ -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 = '
'; + } else if (type === 'error') { + modalIcon.innerHTML = '
'; + } else if (type === 'warning') { + modalIcon.innerHTML = '
'; + } else { + modalIcon.innerHTML = '
'; + } + + // 单按钮模式 + 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 = '
'; + } else { + modalIcon.innerHTML = '
'; + } + + // 显示双按钮组,隐藏单按钮组 + 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; diff --git a/web/pages/components/sidebar.html b/web/pages/components/sidebar.html index f1a78bc..48d1ad7 100644 --- a/web/pages/components/sidebar.html +++ b/web/pages/components/sidebar.html @@ -1,11 +1,11 @@ - + - - - - diff --git a/web/pages/custom-tool-create.html b/web/pages/custom-tool-create.html index 1dc53a1..8a0fc0a 100644 --- a/web/pages/custom-tool-create.html +++ b/web/pages/custom-tool-create.html @@ -15,21 +15,15 @@ originalWarn.apply(console, args); }; } - })(); + + + + - - - - + +
diff --git a/web/pages/dataset-create.html b/web/pages/dataset-create.html index b84af93..80ecba6 100644 --- a/web/pages/dataset-create.html +++ b/web/pages/dataset-create.html @@ -16,38 +16,14 @@ }; } + + + - - - - + +
@@ -197,6 +87,18 @@
+ + +
+ + --% +
+
+ + --% +
+
+
用户头像