diff --git a/.gitignore b/.gitignore index aee52b1..8675a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ +__pycache__ model/__pycache__ out website/ -docs-minimind/ \ No newline at end of file +docs-minimind/ +logfile +dataset +checkpoints \ No newline at end of file diff --git a/minimind_sdk/__init__.py b/minimind_sdk/__init__.py new file mode 100644 index 0000000..4f9b7ed --- /dev/null +++ b/minimind_sdk/__init__.py @@ -0,0 +1 @@ +from .client import MinimindClient \ No newline at end of file diff --git a/minimind_sdk/client.py b/minimind_sdk/client.py new file mode 100644 index 0000000..d104e1c --- /dev/null +++ b/minimind_sdk/client.py @@ -0,0 +1,65 @@ +import json +import urllib.request +import urllib.error + +class MinimindClient: + def __init__(self, base_url, api_key=None, timeout=10): + self.base_url = base_url.rstrip('/') + self.api_key = api_key or '' + self.timeout = timeout + + def _request(self, method, path, body=None, expect_text=False): + url = f"{self.base_url}{path}" + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + if self.api_key: + headers['Authorization'] = f"Bearer {self.api_key}" + data = None + if body is not None: + data = json.dumps(body).encode('utf-8') + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + if expect_text: + return raw.decode('utf-8', errors='replace') + return json.loads(raw.decode('utf-8')) + except urllib.error.HTTPError as e: + msg = e.read().decode('utf-8', errors='replace') + raise RuntimeError(f"HTTP {e.code}: {msg}") + except urllib.error.URLError as e: + raise RuntimeError(str(e)) + + def register(self, name, email): + res = self._request('POST', '/api/register', {'name': name, 'email': email}) + self.api_key = res.get('api_key', self.api_key) + return res + + def start_training(self, train_type, **params): + payload = {'train_type': train_type} + payload.update(params or {}) + res = self._request('POST', '/train', payload) + return res + + def get_processes(self): + return self._request('GET', '/processes', None) + + def get_logs(self, process_id): + return self._request('GET', f"/logs/{process_id}", None, expect_text=True) + + def stop(self, process_id): + return self._request('POST', f"/stop/{process_id}", None) + + def delete(self, process_id): + return self._request('POST', f"/delete/{process_id}", None) + + def get_logfiles(self): + return self._request('GET', '/logfiles', None) + + def get_logfile_content(self, filename): + return self._request('GET', f"/logfile-content/{filename}", None, expect_text=True) + + def delete_logfile(self, filename): + return self._request('DELETE', f"/delete-logfile/{filename}", None) \ No newline at end of file diff --git a/trainer_web/dispatcher.py b/trainer_web/dispatcher.py new file mode 100644 index 0000000..092f7b5 --- /dev/null +++ b/trainer_web/dispatcher.py @@ -0,0 +1,81 @@ +import sys +import os + +def build_command(train_type, params, gpu_num, use_torchrun): + if train_type == 'pretrain': + script_path = '../trainer/train_pretrain.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'save_weight' in params: + cmd.extend(['--save_weight', params['save_weight']]) + elif train_type == 'sft': + script_path = '../trainer/train_full_sft.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'save_weight' in params: + cmd.extend(['--save_weight', params['save_weight']]) + elif train_type == 'lora': + script_path = '../trainer/train_lora.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'lora_name' in params: + cmd.extend(['--lora_name', params['lora_name']]) + elif train_type == 'dpo': + script_path = '../trainer/train_dpo.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'beta' in params and params['beta']: + cmd.extend(['--beta', params['beta']]) + if 'accumulation_steps' in params and params['accumulation_steps']: + cmd.extend(['--accumulation_steps', params['accumulation_steps']]) + if 'grad_clip' in params and params['grad_clip']: + cmd.extend(['--grad_clip', params['grad_clip']]) + elif train_type == 'ppo': + script_path = '../trainer/train_ppo.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'clip_epsilon' in params and params['clip_epsilon']: + cmd.extend(['--clip_epsilon', params['clip_epsilon']]) + if 'vf_coef' in params and params['vf_coef']: + cmd.extend(['--vf_coef', params['vf_coef']]) + if 'kl_coef' in params and params['kl_coef']: + cmd.extend(['--kl_coef', params['kl_coef']]) + if 'reasoning' in params and params['reasoning']: + cmd.extend(['--reasoning', params['reasoning']]) + if 'update_old_actor_freq' in params and params['update_old_actor_freq']: + cmd.extend(['--update_old_actor_freq', params['update_old_actor_freq']]) + if 'reward_model_path' in params and params['reward_model_path']: + cmd.extend(['--reward_model_path', params['reward_model_path']]) + elif train_type == 'grpo': + script_path = '../trainer/train_grpo.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'beta' in params and params['beta']: + cmd.extend(['--beta', params['beta']]) + if 'num_generations' in params and params['num_generations']: + cmd.extend(['--num_generations', params['num_generations']]) + if 'reasoning' in params and params['reasoning']: + cmd.extend(['--reasoning', params['reasoning']]) + if 'reward_model_path' in params and params['reward_model_path']: + cmd.extend(['--reward_model_path', params['reward_model_path']]) + elif train_type == 'spo': + script_path = '../trainer/train_spo.py' + cmd = ['torchrun', '--nproc_per_node', str(gpu_num), script_path] if use_torchrun else [sys.executable, script_path] + if 'beta' in params and params['beta']: + cmd.extend(['--beta', params['beta']]) + if 'reasoning' in params and params['reasoning']: + cmd.extend(['--reasoning', params['reasoning']]) + if 'reward_model_path' in params and params['reward_model_path']: + cmd.extend(['--reward_model_path', params['reward_model_path']]) + else: + return None + + for key, value in params.items(): + if key in ['train_type', 'save_weight', 'lora_name', 'train_monitor', 'beta', 'accumulation_steps', 'grad_clip', 'gpu_num', 'clip_epsilon', 'vf_coef', 'kl_coef', 'reasoning', 'update_old_actor_freq', 'reward_model_path', 'num_generations'] or ((train_type == 'ppo' or train_type == 'grpo' or train_type == 'spo') and key == 'from_weight'): + continue + elif key == 'from_resume': + cmd.extend([f'--{key}', str(value)]) + else: + cmd.extend([f'--{key}', str(value)]) + + if 'train_monitor' in params: + if params['train_monitor'] == 'wandb' or params['train_monitor'] == 'swanlab': + cmd.append('--use_wandb') + if params['train_monitor'] == 'wandb': + cmd.extend(['--wandb_project', 'minimind_training']) + + return cmd \ No newline at end of file diff --git a/trainer_web/start_web_ui.sh b/trainer_web/start_web_ui.sh new file mode 100755 index 0000000..ef0741e --- /dev/null +++ b/trainer_web/start_web_ui.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# 获取脚本所在目录(兼容 macOS) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# 检查是否已经有实例在运行 +if [ -f "train_web_ui.pid" ]; then + pid=$(cat "train_web_ui.pid") + if ps -p "$pid" > /dev/null 2>&1; then + echo "Web UI 服务已经在运行 (PID: $pid)" + exit 1 + else + echo "删除旧的PID文件" + rm "train_web_ui.pid" + fi +fi + +# 创建日志目录 +LOG_DIR="../logfile" +mkdir -p "$LOG_DIR" + +# 生成时间戳 +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +LOG_FILE="$LOG_DIR/web_ui_$TIMESTAMP.log" + +echo "启动 MiniMind Web UI 服务..." +echo "日志文件: $LOG_FILE" + +# 依赖预检 +python - <<'PY' +import sys +missing = [] +for m in ('flask', 'psutil'): + try: + __import__(m) + except Exception as e: + missing.append(f"{m}: {e.__class__.__name__} {e}") +if missing: + print("依赖缺失或不可用:\n" + "\n".join(missing)) + sys.exit(1) +PY +if [ $? -ne 0 ]; then + echo "启动失败:请先安装缺失依赖,例如 'pip install flask psutil'" + exit 1 +fi + +# 使用nohup启动服务 +nohup python -u train_web_ui.py > "$LOG_FILE" 2>&1 & + +# 保存PID +echo $! > "train_web_ui.pid" + +# 轮询日志以获取实际端口号(最多等待10秒) +PORT="" +for i in {1..20}; do + # 提取形如 http://0.0.0.0:12345 的地址,再截取端口 + PORT=$(grep -Eo 'http://0\.0\.0\.0:[0-9]+' "$LOG_FILE" | tail -n1 | awk -F: '{print $NF}') + if [ -n "$PORT" ]; then + break + fi + sleep 0.5 +done + +# 如果仍未获取到端口,回退为默认提示端口(与后端起始端口一致) +# 健康检查:验证端口响应(最多等待10秒) +if [ -n "$PORT" ]; then + for i in {1..20}; do + if curl -s "http://localhost:$PORT/healthz" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then + echo "服务已启动! PID: $(cat "train_web_ui.pid")" + echo "访问地址: http://localhost:$PORT" + echo "停止命令: kill $(cat "train_web_ui.pid") or bash trainer_web/stop_web_ui.sh" + exit 0 + fi + sleep 0.5 + done +fi + +# 启动失败处理:打印日志并退出非零 +echo "服务启动失败,请查看日志" +tail -n 50 "$LOG_FILE" || true + +if [ -f "train_web_ui.pid" ]; then + pid=$(cat "train_web_ui.pid") + if ps -p "$pid" > /dev/null 2>&1; then + kill "$pid" >/dev/null 2>&1 || true + fi + rm -f "train_web_ui.pid" +fi + +exit 1 diff --git a/trainer_web/static/css/style.css b/trainer_web/static/css/style.css new file mode 100644 index 0000000..7ec69bd --- /dev/null +++ b/trainer_web/static/css/style.css @@ -0,0 +1,1365 @@ +:root { + --bg: #0b0b0b; + --card-bg: #000000; + --panel-bg: rgba(0, 0, 0, 0.95); + --text: #e2e8f0; + --text-secondary: #94a3b8; + --accent: #8b5cf6; + --accent-grad-start: #7c3aed; + --accent-grad-end: #a855f7; + --danger-grad-start: #ef4444; + --danger-grad-end: #dc2626; + --info-grad-start: #3b82f6; + --info-grad-end: #2563eb; + --success-grad-start: #10b981; + --success-grad-end: #059669; + --warning-grad-start: #f59e0b; + --warning-grad-end: #d97706; + --border: #2d3748; + --border-light: #4a5568; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: var(--text); + max-width: 1400px; + margin: 0 auto; + padding: 0; + background: linear-gradient(135deg, var(--bg) 0%, #000000 100%); + min-height: 100vh; + background-attachment: fixed; + font-size: 14px; +} + +/* 头部样式 */ +.header { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 0; + margin-bottom: 2rem; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.logo { + height: 48px; + margin-right: 1rem; + vertical-align: middle; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); + transition: transform 0.3s ease; +} + +.logo:hover { + transform: scale(1.05); +} + +h1 { + color: var(--text); + font-size: 2.25rem; + font-weight: 700; + margin: 0; + vertical-align: middle; + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: none; +} +.tabs { + display: flex; + justify-content: center; + margin: 0 auto 2rem; + max-width: 800px; + background: var(--panel-bg); + border-radius: var(--radius-lg); + padding: 0.5rem; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border); + backdrop-filter: blur(10px); +} +.tab { + padding: 0.75rem 1.5rem; + cursor: pointer; + background: transparent; + border: none; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s ease; + position: relative; + color: var(--text-secondary); + text-align: center; + border-radius: var(--radius-md); + flex: 1; + margin: 0 0.25rem; + position: relative; + overflow: hidden; +} +.tab.active { + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + color: white; + font-weight: 600; + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.tab:hover:not(.active) { + color: var(--text); + background: rgba(139, 92, 246, 0.1); + transform: translateY(-1px); +} +.form-container { + background: var(--panel-bg); + padding: 2rem; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + margin: 0 auto 2rem; + max-width: 1200px; + border: 1px solid var(--border); + backdrop-filter: blur(10px); +} + +/* 参数卡片样式 */ +.parameter-card { + background: linear-gradient(135deg, var(--card-bg) 0%, rgba(26, 26, 36, 0.8) 100%); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: var(--shadow-md); + transition: all 0.3s ease; + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} + +.parameter-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.parameter-card:hover::before { + opacity: 1; +} + +.parameter-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--border-light); +} + +/* 卡片标题样式 */ +.card-title { + color: var(--text); + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--accent); + width: 100%; + letter-spacing: 0.025em; +} + +/* 提交按钮容器 */ +.submit-container { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #4d4d4d; +} + +/* 参数内容容器 - 使用flex布局替代float */ +.parameter-content { + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + align-items: start; +} + +.form-group { + margin-bottom: 0; + box-sizing: border-box; + position: relative; +} + +/* 确保复选框组占满整行 */ +.form-group.checkbox-group { + width: 100%; +} + +.form-group.pretrain-sft, .form-group.lora { + /* 保持默认宽度,遵循每行两个的布局 */ + width: calc(40% - 8px); +} +label { + display: block; + margin-bottom: 0.5rem; + color: var(--text); + font-weight: 500; + font-size: 0.8rem; + opacity: 0.9; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: color 0.3s ease; +} +input[type="text"], input[type="number"], select, textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: 0.9rem; + transition: all 0.3s ease; + background: rgba(45, 55, 72, 0.5); + color: var(--text); + font-family: inherit; + box-sizing: border-box; +} + +input[type="text"]:hover, input[type="number"]:hover, select:hover, textarea:hover { + border-color: var(--border-light); + background: rgba(45, 55, 72, 0.7); +} + +/* 确保textarea也适应两列布局 */ +textarea { + resize: vertical; + min-height: 80px; +} + +input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2); + background: rgba(45, 55, 72, 0.9); + transform: translateY(-1px); +} + +/* 文件夹选择器样式 */ +.input-with-picker { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.input-with-picker input { + flex: 1; +} + +.btn-picker { + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); + color: white; + border: none; + padding: 0.75rem; + border-radius: var(--radius-md); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: var(--shadow-sm); + min-width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-picker:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + filter: brightness(1.1); +} + +/* 进度条样式 */ +.progress-container { + margin: 0.5rem 0; + background: rgba(45, 55, 72, 0.3); + border-radius: var(--radius-lg); + padding: 0.5rem; + border: 1px solid var(--border); +} + +.progress-bar { + width: 100%; + height: 8px; + background: rgba(45, 55, 72, 0.5); + border-radius: var(--radius-sm); + overflow: hidden; + margin: 0.5rem 0; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + border-radius: var(--radius-sm); + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: progress-shine 2s infinite; +} + +@keyframes progress-shine { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.progress-metrics { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.metric-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgba(139, 92, 246, 0.1); + border-radius: var(--radius-sm); + border: 1px solid rgba(139, 92, 246, 0.2); +} + +.metric-label { + font-weight: 500; + color: var(--text-secondary); +} + +.metric-value { + font-weight: 600; + color: var(--accent); +} + +/* 文件浏览器模态框样式 */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: opacity 0.3s ease; +} + +.modal.hidden { + display: none; +} + +.modal-content { + background: var(--panel-bg); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-xl); + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(139, 92, 246, 0.1); +} + +.modal-header h3 { + margin: 0; + color: var(--text); + font-size: 1.1rem; +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all 0.3s ease; +} + +.modal-close:hover { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.modal-body { + flex: 1; + padding: 1rem; + overflow-y: auto; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border); + display: flex; + gap: 0.5rem; + align-items: center; + background: rgba(22, 22, 32, 0.8); +} + +.modal-footer input { + flex: 1; +} + +.btn-secondary { + background: linear-gradient(135deg, var(--border-light) 0%, var(--border) 100%); + color: var(--text); + border: none; + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.3s ease; + font-size: 0.8rem; + font-weight: 500; +} + +.btn-secondary:hover { + transform: translateY(-1px); + filter: brightness(1.1); +} + +/* 模态框底部样式 */ +.modal-footer { + display: flex; + gap: 0.75rem; + align-items: center; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.modal-footer input { + flex: 1; + margin-right: 0.5rem; +} + +/* 改进模态框关闭按钮 */ +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: var(--radius-sm); + transition: all 0.3s ease; +} + +.modal-close:hover { + color: var(--text); + background: rgba(239, 68, 68, 0.1); +} + +/* 文件浏览器导航 */ +.file-browser-nav { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.5rem; + background: rgba(45, 55, 72, 0.5); + border-radius: var(--radius-md); +} + +.nav-buttons { + display: flex; + gap: 0.5rem; +} + +.current-path { + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: var(--text-secondary); + flex: 1; + margin-right: 1rem; + padding: 0.25rem 0.5rem; + background: rgba(22, 22, 32, 0.5); + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.btn-navigate { + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); + color: white; + border: none; + padding: 0.5rem; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.3s ease; +} + +.btn-navigate:hover { + transform: translateY(-1px); + filter: brightness(1.1); +} + +/* 快捷路径 */ +.quick-paths { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.file-browser-help { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius-sm); + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.8rem; + color: var(--info-grad-start); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.quick-path-btn { + background: rgba(139, 92, 246, 0.1); + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.3); + padding: 0.4rem 0.8rem; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.3s ease; +} + +.quick-path-btn:hover { + background: rgba(139, 92, 246, 0.2); + transform: translateY(-1px); +} + +/* 文件列表 */ +.file-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(22, 22, 32, 0.3); + margin-bottom: 1rem; +} + +.file-item { + display: flex; + align-items: center; + padding: 0.75rem; + cursor: pointer; + transition: all 0.3s ease; + border-bottom: 1px solid rgba(45, 55, 72, 0.3); +} + +.file-item:last-child { + border-bottom: none; +} + +.file-item:hover { + background: rgba(139, 92, 246, 0.1); +} + +.file-item.selected { + background: rgba(139, 92, 246, 0.2); + border-left: 3px solid var(--accent); +} + +.file-item.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.file-item.disabled:hover { + background: none; +} + +.file-icon { + margin-right: 0.75rem; + font-size: 1.2rem; + width: 20px; + text-align: center; +} + +.file-name { + flex: 1; + font-size: 0.9rem; + color: var(--text); +} + +.file-info { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: right; +} + +.directory { + color: var(--info-grad-start); +} + +.file { + color: var(--text-secondary); +} +.checkbox-group { + display: flex; + align-items: center; +} +.checkbox-group input[type="checkbox"] { + width: auto; + margin-right: 10px; +} +button { + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + letter-spacing: 0.025em; + text-transform: uppercase; + font-size: 0.8rem; +} +button:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + filter: brightness(1.1); +} + +button:active { + transform: translateY(0); + filter: brightness(0.95); +} + +.section-title { + color: var(--text); + font-size: 1.25rem; + margin-bottom: 1.5rem; + font-weight: 600; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--accent); + letter-spacing: 0.025em; +} +.logs-container { + background: linear-gradient(135deg, #0f0f15 0%, #1a1a24 100%); + color: var(--text); + padding: 1.5rem; + border-radius: var(--radius-lg); + max-height: 400px; + overflow-y: auto; + margin-top: 1rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8rem; + line-height: 1.4; + box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.3s ease; + border: 1px solid var(--border); + position: relative; +} + +.logs-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%); +} + +.logs-container:hover { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); +} +.process-type-group { + margin-bottom: 30px; + background-color: var(--panel-bg); + border-radius: 15px; + border: 1px solid #444; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +/* 标题容器样式 */ +.process-type-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + cursor: pointer; + user-select: none; +} + +.process-type-title { + margin: 0; + color: var(--text); + font-size: 1.2em; + font-weight: 600; + border-bottom: 2px solid var(--accent); + padding-bottom: 8px; + flex-grow: 1; +} + +/* 切换按钮样式 */ +.toggle-btn { + background: none; + border: none; + color: #e0e0e0; + font-size: 0.8em; + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + transition: background-color 0.3s, transform 0.2s; + margin-left: 15px; +} + +.toggle-btn:hover { + background-color: rgba(255, 255, 255, 0.1); + transform: scale(1.1); +} + +/* 内容容器样式 */ +.process-type-content { + max-height: none; + overflow: visible; + transition: max-height 0.3s ease-in-out; + padding: 0 20px 20px 20px; +} + +.process-item { + background: linear-gradient(135deg, var(--card-bg) 0%, rgba(26, 26, 36, 0.8) 100%); + padding: 1.5rem; + margin-bottom: 1rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transition: all 0.3s ease; + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} + +.process-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.process-item:hover::before { + opacity: 1; +} + +.process-item:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--border-light); +} +.process-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} +.process-status { + padding: 0.375rem 0.75rem; + border-radius: var(--radius-lg); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + backdrop-filter: blur(10px); +} +.status-running { + background: linear-gradient(135deg, var(--success-grad-start) 0%, var(--success-grad-end) 100%); + color: white; +} +.status-completed { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; +} +.status-error { + background: linear-gradient(135deg, #ff416c 0%, #ff4b2b 100%); + color: white; +} +.status-manual-stop { + background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%); + color: white; +} +.btn-stop { + background: linear-gradient(135deg, var(--danger-grad-start) 0%, var(--danger-grad-end) 100%); + padding: 8px 15px; + font-size: 14px; + border-radius: 6px; +} +.btn-stop:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(255, 65, 108, 0.3); +} +.btn-logs { + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); + padding: 8px 15px; + font-size: 14px; + margin-right: 10px; + border-radius: 6px; +} + +.btn-swanlab { + background: linear-gradient(135deg, #007bff 0%, #00bfff 100%); + padding: 8px 15px; + font-size: 14px; + margin-right: 10px; + border-radius: 6px; + color: white; +} + +.btn-swanlab:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3); +} +.btn-delete { + background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%); + padding: 8px 15px; + font-size: 14px; + margin-right: 10px; + border-radius: 6px; + color: white; +} +.btn-delete:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(244, 67, 54, 0.3); +} +.btn-delete:disabled { + background: linear-gradient(135deg, #cccccc 0%, #aaaaaa 100%); + cursor: not-allowed; + transform: none; + box-shadow: none; +} +.btn-logs:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(79, 172, 254, 0.3); +} +.hidden { + display: none; +} +.section-title { + color: #ffffff; + font-size: 18px; + margin-bottom: 25px; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); + font-weight: 700; + padding-bottom: 10px; + border-bottom: 1px solid #e040fb; + margin-top: 16px; +} + +/* 添加滚动条样式 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 100%); +} + +/* 添加动画效果 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* 加载动画 */ +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +.loading-shimmer { + background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.1) 50%, transparent 100%); + background-size: 1000px 100%; + animation: shimmer 2s infinite; +} + +/* 状态指示器 */ +.status-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 0.5rem; + animation: pulse 2s infinite; +} + +.status-indicator.running { + background: linear-gradient(135deg, var(--success-grad-start) 0%, var(--success-grad-end) 100%); +} + +.status-indicator.stopped { + background: linear-gradient(135deg, var(--danger-grad-start) 0%, var(--danger-grad-end) 100%); +} + +.status-indicator.pending { + background: linear-gradient(135deg, var(--warning-grad-start) 0%, var(--warning-grad-end) 100%); +} + +/* 自定义确认对话框样式 */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; +} + +.dialog-overlay.show { + opacity: 1; +} + +.custom-dialog { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + max-width: 400px; + width: 90%; + transform: translateY(-20px); + opacity: 0; + transition: transform 0.3s ease, opacity 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.custom-dialog.show { + transform: translateY(0); + opacity: 1; +} + +.dialog-content { + padding: 20px; +} + +.dialog-message { + color: #ffffff; + font-size: 16px; + margin-bottom: 20px; + text-align: center; + line-height: 1.5; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.dialog-button { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.dialog-cancel { + background: linear-gradient(135deg, #4a4a4a 0%, #333333 100%); + color: #ffffff; +} + +.dialog-cancel:hover { + background: linear-gradient(135deg, #5a5a5a 0%, #444444 100%); + transform: translateY(-1px); +} + +.dialog-confirm { + background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); + color: #ffffff; +} + +.dialog-confirm:hover { + background: linear-gradient(135deg, #7a21db 0%, #3585ff 100%); + transform: translateY(-1px); +} + +/* 消息弹窗样式 */ +.notification { + position: fixed; + top: 20px; + right: 20px; + padding: 15px 25px; + border-radius: 10px; + color: white; + font-weight: 600; + font-size: 16px; + z-index: 1000; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; + max-width: 400px; + word-wrap: break-word; +} + +/* 显示状态 */ +.notification.show { + opacity: 1; + transform: translateX(0); +} + +/* 成功通知样式 */ +.notification-success { + background: linear-gradient(135deg, #4caf50 0%, #81c784 100%); +} + +/* 错误通知样式 */ +.notification-error { + background: linear-gradient(135deg, #f44336 0%, #ef5350 100%); +} + +/* 信息通知样式 */ +.notification-info { + background: linear-gradient(135deg, #2196f3 0%, #64b5f6 100%); +} + +.tab-content { + animation: fadeIn 0.5s ease-out; + padding: 0 1rem; +} + +/* 增强现有样式 */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding: 0 1rem; +} + +.section-actions { + display: flex; + gap: 0.5rem; +} + +.btn-primary, .btn-refresh { + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: var(--shadow-md); + letter-spacing: 0.025em; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary:hover, .btn-refresh:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + filter: brightness(1.1); +} + +.btn-icon { + font-size: 1rem; + line-height: 1; +} + +.process-type-group { + margin-bottom: 2rem; + background: var(--panel-bg); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); + overflow: hidden; + backdrop-filter: blur(10px); +} + +.process-type-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + cursor: pointer; + user-select: none; + background: rgba(0, 0, 0, 0.3); + transition: background-color 0.3s ease; +} + +.process-type-header:hover { + background: rgba(45, 55, 72, 0.5); +} + +.process-type-title { + margin: 0; + color: var(--text); + font-size: 1.1rem; + font-weight: 600; + border-bottom: 2px solid var(--accent); + padding-bottom: 0.5rem; + flex-grow: 1; +} + +.toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 0.8rem; + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius-sm); + transition: all 0.3s ease; + margin-left: 1rem; +} + +.toggle-btn:hover { + background: rgba(139, 92, 246, 0.1); + color: var(--text); + transform: scale(1.1); +} + +.process-type-content { + max-height: none; + overflow: visible; + transition: max-height 0.3s ease-in-out; + padding: 0 1.5rem 1.5rem; +} + +/* 按钮样式增强 */ +.btn-stop, .btn-logs, .btn-swanlab, .btn-delete { + padding: 0.5rem 1rem; + font-size: 0.75rem; + border-radius: var(--radius-md); + font-weight: 500; + letter-spacing: 0.025em; + transition: all 0.3s ease; + margin-right: 0.5rem; + margin-bottom: 0.25rem; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.btn-stop:hover, .btn-logs:hover, .btn-swanlab:hover, .btn-delete:hover { + transform: translateY(-1px); + filter: brightness(1.1); +} + +.btn-stop { + background: linear-gradient(135deg, var(--danger-grad-start) 0%, var(--danger-grad-end) 100%); +} + +.btn-logs { + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); +} + +.btn-swanlab { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); +} + +.btn-delete { + background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); +} + +.btn-delete:disabled { + background: linear-gradient(135deg, #4b5563 0%, #374151 100%); + cursor: not-allowed; + transform: none; + filter: none; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + body { + padding: 0; + font-size: 13px; + } + + .header { + flex-direction: column; + text-align: center; + padding: 1.5rem 1rem; + position: relative; + } + + .logo { + height: 40px; + margin-right: 0; + margin-bottom: 0.5rem; + } + + h1 { + font-size: 1.75rem; + } + + .tabs { + flex-direction: column; + margin: 0 1rem 1.5rem; + max-width: none; + } + + .tab { + margin: 0.25rem 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + } + + .form-container { + padding: 1.5rem; + margin: 0 1rem 1.5rem; + max-width: none; + } + + .parameter-content { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .process-item { + padding: 1rem; + } + + .process-info { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .logs-container { + padding: 1rem; + max-height: 300px; + } +} + +@media (max-width: 480px) { + .header { + padding: 1rem; + } + + h1 { + font-size: 1.5rem; + } + + .form-container { + padding: 1rem; + } + + .parameter-card { + padding: 1rem; + } + + .card-title { + font-size: 1rem; + } +} diff --git a/trainer_web/static/images/logo2.png b/trainer_web/static/images/logo2.png new file mode 100644 index 0000000..9a0b3e2 Binary files /dev/null and b/trainer_web/static/images/logo2.png differ diff --git a/trainer_web/static/js/app.js b/trainer_web/static/js/app.js new file mode 100644 index 0000000..c2dcdb9 --- /dev/null +++ b/trainer_web/static/js/app.js @@ -0,0 +1,362 @@ +import { openTab as _openTab } from './ui/tabs.js'; +import { initTrainForm } from './train/form.js'; +import { startProcessPolling, stopProcessPolling, loadProcesses } from './processes/list.js'; +import { loadLogFiles } from './logfiles/list.js'; +import { refreshLog } from './processes/logs.js'; + +const hooks = { + onEnterProcesses: () => { + // 当切换到进程标签页时,立即加载一次,然后开始轮询 + loadProcesses().then(() => { + startProcessPolling(); + }); + }, + onLeaveProcesses: () => { + stopProcessPolling(); + }, + onEnterLogfiles: () => { + loadLogFiles(); + }, +}; + +window.openTab = (evt, tabName) => _openTab(evt, tabName, hooks); + +// 文件夹选择器功能 - 直接显示服务器端文件浏览器 +window.selectFolder = (inputId) => { + // 直接使用远程文件浏览器,不尝试本地文件系统访问 + openRemoteFileBrowser(inputId); +}; + +// 远程文件浏览器 - 支持文件和文件夹选择 +let currentFileBrowserTarget = null; +let currentBrowsePath = './'; +let selectedFilePath = null; +let currentSelectionMode = 'auto'; // 'file', 'folder', or 'auto' + +function openRemoteFileBrowser(inputId) { + console.log('openRemoteFileBrowser called with:', inputId); + currentFileBrowserTarget = inputId; + + // 根据输入框ID确定选择模式 + if (inputId === 'data_path') { + currentSelectionMode = 'file'; // 数据路径需要文件选择 + console.log('Mode set to: FILE selection'); + } else if (inputId === 'save_dir' || inputId.includes('reward_model_path')) { + currentSelectionMode = 'folder'; // 保存目录和奖励模型路径需要文件夹选择 + console.log('Mode set to: FOLDER selection'); + } else { + currentSelectionMode = 'auto'; // 自动模式 + console.log('Mode set to: AUTO selection'); + } + + const modal = document.getElementById('file-browser-modal'); + if (modal) { + modal.classList.remove('hidden'); + console.log('Modal opened successfully'); + } else { + console.error('Modal element not found!'); + return; + } + + // 重置选择状态 + selectedFilePath = null; + const selectedPathInput = document.getElementById('selected-path'); + if (selectedPathInput) { + selectedPathInput.value = ''; + console.log('Selected path input cleared'); + } + + // 加载初始路径 + loadQuickPaths(); + browsePath('./'); +} + +function closeFileBrowser() { + document.getElementById('file-browser-modal').classList.add('hidden'); + currentFileBrowserTarget = null; + currentBrowsePath = './'; + selectedFilePath = null; + currentSelectionMode = 'auto'; +} + +function confirmFileSelection() { + console.log('confirmFileSelection called'); + console.log('selectedFilePath:', selectedFilePath); + console.log('currentFileBrowserTarget:', currentFileBrowserTarget); + + if (selectedFilePath && currentFileBrowserTarget) { + const targetElement = document.getElementById(currentFileBrowserTarget); + console.log('targetElement:', targetElement); + + if (targetElement) { + targetElement.value = selectedFilePath; + console.log('Value set successfully'); + closeFileBrowser(); + } else { + console.error('Target element not found:', currentFileBrowserTarget); + alert('错误:无法找到目标输入框'); + } + } else { + console.log('Missing selection or target'); + alert('请先选择文件或文件夹'); + } +} + +function navigateToParent() { + if (window.currentParentPath) { + // 使用后端提供的父目录路径(绝对路径) + browsePath(window.currentParentPath); + } else if (currentBrowsePath && currentBrowsePath !== './') { + // 回退到基于当前路径的计算 + const parentPath = currentBrowsePath.includes('/') ? + currentBrowsePath.substring(0, currentBrowsePath.lastIndexOf('/')) : './'; + browsePath(parentPath || './'); + } +} + +function selectCurrentDirectory() { + // 选择当前目录 + selectedFilePath = currentBrowsePath; + document.getElementById('selected-path').value = currentBrowsePath; + // 可以关闭模态框或让用户继续浏览 +} + +async function loadQuickPaths() { + try { + const response = await fetch('/api/quick-paths'); + const data = await response.json(); + + const quickPathsContainer = document.getElementById('quick-paths'); + quickPathsContainer.innerHTML = ''; + + if (data.paths && data.paths.length > 0) { + data.paths.forEach(path => { + const btn = document.createElement('button'); + btn.className = 'quick-path-btn'; + btn.textContent = path.name; + btn.onclick = () => browsePath(path.path); + btn.title = path.path; + quickPathsContainer.appendChild(btn); + }); + } + } catch (error) { + console.warn('加载快捷路径失败:', error); + } +} + +async function browsePath(path) { + console.log('browsePath called with:', path); + try { + currentBrowsePath = path; + selectedFilePath = null; // 重置选中的文件路径 + document.getElementById('selected-path').value = ''; // 清空显示 + + // 更新帮助文本 + updateHelpText(); + + const response = await fetch(`/api/browse?path=${encodeURIComponent(path)}`); + const data = await response.json(); + + if (data.error) { + alert(`浏览失败: ${data.error}`); + return; + } + + renderFileList(data); + console.log('File list rendered successfully'); + } catch (error) { + console.error('浏览路径失败:', error); + alert('浏览路径失败,请检查网络连接'); + } +} + +function renderFileList(data) { + const fileList = document.getElementById('file-list'); + fileList.innerHTML = ''; + + if (!data.items || data.items.length === 0) { + fileList.innerHTML = '
暂无日志文件
'; + return; + } + data.sort((a, b) => new Date(b.modified_time) - new Date(a.modified_time)); + const groups = {}; + data.forEach((f) => { + let type = '自定义训练'; + const n = f.filename; + if (n.includes('train_pretrain_')) type = 'pretrain'; + else if (n.includes('train_sft_')) type = 'sft'; + else if (n.includes('train_lora_')) type = 'lora'; + else if (n.includes('train_dpo_')) type = 'dpo'; + else if (n.includes('train_ppo_')) type = 'ppo'; + else if (n.includes('train_grpo_')) type = 'grpo'; + else if (n.includes('train_spo_')) type = 'spo'; + f.train_type = type; + if (!groups[type]) groups[type] = []; + groups[type].push(f); + }); + const order = ['pretrain', 'sft', 'lora', 'dpo', 'ppo', 'grpo', 'spo', '未知']; + [...order.filter((t) => groups[t]), ...Object.keys(groups).filter((t) => !order.includes(t))].forEach((t) => { + list.appendChild(createTypeGroupWithToggle(t, groups[t])); + }); + }); +} + +function createTypeGroupWithToggle(trainType, files) { + const group = el('div', { class: 'process-type-group' }); + const header = el('div', { class: 'process-type-header' }); + header.dataset.expanded = 'true'; + const title = el('h3', { class: 'process-type-title', text: getTrainTypeDisplayName(trainType) }); + const toggle = el('button', { class: 'toggle-btn' }); + toggle.innerHTML = '▼'; + toggle.onclick = (e) => { + e.stopPropagation(); + toggleGroup(header); + }; + header.appendChild(title); + header.appendChild(toggle); + header.onclick = () => toggleGroup(header); + const content = el('div', { class: 'process-type-content' }); + files.forEach((f) => addLogFileItemToGroup(content, f)); + group.appendChild(header); + group.appendChild(content); + return group; +} + +function toggleGroup(header) { + const expanded = header.dataset.expanded === 'true'; + const content = header.nextElementSibling; + const toggle = header.querySelector('.toggle-btn'); + if (expanded) { + header.dataset.expanded = 'false'; + content.style.maxHeight = '0'; + content.style.overflow = 'hidden'; + toggle.innerHTML = '▶'; + } else { + content.style.overflow = 'hidden'; + content.style.maxHeight = 'none'; + const h = content.scrollHeight; + content.style.maxHeight = '0'; + content.offsetHeight; + header.dataset.expanded = 'true'; + content.style.maxHeight = h + 'px'; + setTimeout(() => { + content.style.maxHeight = 'none'; + content.style.overflow = 'visible'; + }, 300); + toggle.innerHTML = '▼'; + } +} + +function getTrainTypeDisplayName(trainType) { + const names = { + pretrain: '预训练 (Pretrain)', + sft: '全参数监督微调 (SFT - Full)', + lora: 'LoRA监督微调 (SFT - Lora)', + dpo: '直接偏好优化 (RL - DPO)', + ppo: 'PPO', + grpo: 'GRPO', + spo: 'SPO', + }; + return names[trainType] || trainType; +} + +function addLogFileItemToGroup(parent, logfile) { + const item = el('div', { class: 'process-item' }); + item.innerHTML = ` +暂无训练进程
'; + return; + } + data.sort((a, b) => new Date(b.start_time) - new Date(a.start_time)); + const groups = {}; + data.forEach((p) => { + if (!groups[p.train_type]) groups[p.train_type] = []; + groups[p.train_type].push(p); + }); + const order = ['pretrain', 'sft', 'lora', 'dpo']; + const types = [...order.filter((t) => groups[t]), ...Object.keys(groups).filter((t) => !order.includes(t))]; + types.forEach((t) => { + const g = createTypeGroupWithToggle(t, groups[t]); + list.appendChild(g); + }); + }); +} + +function createTypeGroupWithToggle(trainType, processes) { + const group = el('div', { class: 'process-type-group' }); + const header = el('div', { class: 'process-type-header' }); + header.dataset.expanded = 'true'; + const title = el('h3', { class: 'process-type-title', text: getTrainTypeDisplayName(trainType) }); + const toggle = el('button', { class: 'toggle-btn' }); + toggle.innerHTML = '▼'; + toggle.onclick = (e) => { + e.stopPropagation(); + toggleGroup(header); + }; + header.appendChild(title); + header.appendChild(toggle); + header.onclick = () => toggleGroup(header); + const content = el('div', { class: 'process-type-content' }); + processes.forEach((p) => addProcessItemToGroup(content, p)); + group.appendChild(header); + group.appendChild(content); + return group; +} + +function toggleGroup(header) { + const expanded = header.dataset.expanded === 'true'; + const content = header.nextElementSibling; + const toggle = header.querySelector('.toggle-btn'); + if (expanded) { + header.dataset.expanded = 'false'; + content.style.maxHeight = '0'; + content.style.overflow = 'hidden'; + toggle.innerHTML = '▶'; + } else { + content.style.overflow = 'hidden'; + content.style.maxHeight = 'none'; + const h = content.scrollHeight; + content.style.maxHeight = '0'; + content.offsetHeight; + header.dataset.expanded = 'true'; + content.style.maxHeight = h + 'px'; + setTimeout(() => { + content.style.maxHeight = 'none'; + content.style.overflow = 'visible'; + }, 300); + toggle.innerHTML = '▼'; + } +} + +function getTrainTypeDisplayName(trainType) { + const names = { + pretrain: '预训练 (Pretrain)', + sft: '全参数监督微调 (SFT - Full)', + lora: 'LoRA监督微调 (SFT - Lora)', + dpo: '直接偏好优化 (RL - DPO)', + ppo: 'PPO', + grpo: 'GRPO', + spo: 'SPO', + }; + return names[trainType] || trainType; +} + +export function addProcessItemToGroup(parent, process) { + const item = el('div', { class: 'process-item' }); + let statusClass = 'status-completed'; + if (process.status === '运行中') statusClass = 'status-running'; + else if (process.status === '手动停止') statusClass = 'status-manual-stop'; + else if (process.status === '出错') statusClass = 'status-error'; + item.dataset.processId = process.id; + item.dataset.processStatus = process.status; + item.dataset.trainMonitor = process.train_monitor || 'none'; + item.dataset.swanlabUrl = process.swanlab_url || ''; + const showDelete = !process.running; + const showSwanlab = process.train_monitor !== 'none'; + const swanBtn = showSwanlab ? `` : ''; + + // 计算进度信息 + const progressInfo = calculateProgress(process); + const progressBar = process.running ? ` +暂无训练进程
'; + } + }, 300); + }, 100); + } else { + const header = content.previousElementSibling; + if (header && header.dataset.expanded === 'true') content.style.maxHeight = content.scrollHeight + 'px'; + const left = document.querySelectorAll('.process-item'); + if (left.length === 0) { + const list = document.getElementById('process-list'); + list.innerHTML = '暂无训练进程
'; + } + } + } + }, 300); + } + clearLogTimerFor(processId); + showNotification('训练进程已删除', 'success'); + }) + .catch(() => { + showNotification('删除进程失败,请刷新页面重试', 'error'); + }); + }); +} + +export function stopProcess(processId) { + showConfirmDialog('确定要停止这个训练进程吗?', () => { + apiStop(processId) + .then(() => { + const item = document.querySelector(`[data-process-id="${processId}"]`); + if (item) { + item.dataset.processStatus = '手动停止'; + const statusEl = item.querySelector('.process-status'); + if (statusEl) { + statusEl.classList.remove('status-running', 'status-error', 'status-completed'); + statusEl.classList.add('status-manual-stop'); + statusEl.textContent = '手动停止'; + } + const stopBtn = item.querySelector('.btn-stop'); + if (stopBtn) stopBtn.remove(); + clearLogTimerFor(processId); + } + showNotification('训练进程已停止', 'info'); + getProcesses() + .then((data) => { + const updated = data.find((p) => p.id === processId); + if (updated && item) updateProcessItem(item, updated); + }) + .catch(() => {}); + }) + .catch(() => { + showNotification('停止进程失败', 'error'); + }); + }, () => { + showNotification('已取消停止操作', 'info'); + }); +} + +export function checkAndOpenSwanlab(processId) { + const item = document.querySelector(`[data-process-id="${processId}"]`); + const monitor = item ? item.dataset.trainMonitor : 'none'; + if (monitor === 'none') { + showNotification('此训练未启用监控功能', 'info'); + return; + } + let url = item ? item.dataset.swanlabUrl : ''; + if (!url || url.trim() === '') { + getProcesses() + .then((data) => { + const p = data.find((x) => x.id === processId); + if (p && p.swanlab_url) { + url = p.swanlab_url; + if (item) item.dataset.swanlabUrl = url; + openSwanlab(url); + } else { + showNotification('SwanLab链接尚未生成,请稍后再试', 'info'); + } + }) + .catch(() => { + showNotification('获取SwanLab链接失败,请稍后再试', 'error'); + }); + } else openSwanlab(url); +} + +function openSwanlab(url) { + if (!isValidUrl(url)) { + showNotification('SwanLab链接无效或尚未生成', 'info'); + return; + } + const w = window.open(url, '_blank'); + if (w) showNotification('正在打开SwanLab页面', 'info'); + else showNotification('无法打开新窗口,请检查浏览器设置', 'error'); +} + +function isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + const u = String(url).toLowerCase(); + return u.startsWith('http://') || u.startsWith('https://'); + } +} + diff --git a/trainer_web/static/js/processes/logs.js b/trainer_web/static/js/processes/logs.js new file mode 100644 index 0000000..3c63c0c --- /dev/null +++ b/trainer_web/static/js/processes/logs.js @@ -0,0 +1,73 @@ +import { getLogs } from '../services/apiClient.js'; +import { setHidden } from '../utils/dom.js'; + +const logTimers = new Map(); + +export function showLogs(processId) { + const container = document.getElementById(`logs-${processId}`); + if (!container) return; + const wasHidden = container.classList.contains('hidden'); + setHidden(container, false); + if (wasHidden) { + loadLogContent(processId, container); + resetTimer(processId, container); + } else { + setHidden(container, true); + clearTimer(processId); + } +} + +export function refreshLog(processId) { + const container = document.getElementById(`logs-${processId}`); + if (!container || container.classList.contains('hidden')) return; + loadLogContent(processId, container); + resetTimer(processId, container); +} + +export function clearLogTimerFor(processId) { + clearTimer(processId); +} + +export function isLogTimerActive(processId) { + return logTimers.has(processId); +} + +function resetTimer(processId, container) { + clearTimer(processId); + const item = document.querySelector(`[data-process-id="${processId}"]`); + const running = item && item.dataset.processStatus === '运行中'; + if (!running) return; + const id = setInterval(() => { + if (container.classList.contains('hidden')) { + clearTimer(processId); + return; + } + const current = document.querySelector(`[data-process-id="${processId}"]`); + const stillRunning = current && current.dataset.processStatus === '运行中'; + if (stillRunning) loadLogContent(processId, container); + else clearTimer(processId); + }, 1000); + logTimers.set(processId, id); +} + +function clearTimer(processId) { + const id = logTimers.get(processId); + if (id) { + clearInterval(id); + logTimers.delete(processId); + } +} + +function loadLogContent(processId, container) { + const old = container.textContent; + const stickBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 10; + return getLogs(processId) + .then((logs) => { + container.textContent = logs; + if (stickBottom || old === container.textContent) container.scrollTop = container.scrollHeight; + }) + .catch((err) => { + if (!container.textContent.includes('加载失败')) container.textContent = `加载日志失败: ${err.message}`; + }); +} + diff --git a/trainer_web/static/js/services/apiClient.js b/trainer_web/static/js/services/apiClient.js new file mode 100644 index 0000000..9652661 --- /dev/null +++ b/trainer_web/static/js/services/apiClient.js @@ -0,0 +1,75 @@ +const defaultTimeout = 10000; + +export function fetchWithTimeoutAndRetry(url, options = {}, timeout = defaultTimeout, retries = 3) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + const fetchOptions = { + ...options, + headers: { + ...options.headers, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + signal: controller.signal, + }; + + return fetch(url, fetchOptions) + .then((response) => { + clearTimeout(timeoutId); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response; + }) + .catch((error) => { + clearTimeout(timeoutId); + if (error.name === 'AbortError') throw new Error('请求超时'); + if (retries > 0) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(fetchWithTimeoutAndRetry(url, options, timeout, retries - 1)); + }, timeout / 2); + }); + } + throw error; + }); +} + +export function getProcesses() { + return fetchWithTimeoutAndRetry('/processes').then((r) => r.json()); +} + +export function getLogs(processId) { + return fetchWithTimeoutAndRetry(`/logs/${processId}`).then((r) => r.text()); +} + +export function startTrain(payload) { + return fetchWithTimeoutAndRetry('/train', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + body: JSON.stringify(payload), + }).then((r) => r.json()); +} + +export function stopProcess(processId) { + return fetchWithTimeoutAndRetry(`/stop/${processId}`, { method: 'POST' }).then((r) => r.json().catch(() => ({}))); +} + +export function deleteProcess(processId) { + return fetchWithTimeoutAndRetry(`/delete/${processId}`, { method: 'POST' }).then((r) => r.json().catch(() => ({}))); +} + +export function getLogFiles() { + return fetchWithTimeoutAndRetry('/logfiles').then((r) => r.json()); +} + +export function getLogFileContent(filename) { + return fetchWithTimeoutAndRetry(`/logfile-content/${encodeURIComponent(filename)}`).then((r) => r.text()); +} + +export function deleteLogFile(filename) { + return fetchWithTimeoutAndRetry(`/delete-logfile/${encodeURIComponent(filename)}`, { + method: 'DELETE', + headers: { 'Cache-Control': 'no-cache' }, + }).then((r) => r.json()); +} + diff --git a/trainer_web/static/js/services/authClient.js b/trainer_web/static/js/services/authClient.js new file mode 100644 index 0000000..18470c5 --- /dev/null +++ b/trainer_web/static/js/services/authClient.js @@ -0,0 +1,29 @@ +const KEY = 'minimind_api_key'; + +export function getApiKey() { + try { + return localStorage.getItem(KEY) || ''; + } catch (_) { + return ''; + } +} + +export function setApiKey(k) { + try { + localStorage.setItem(KEY, k || ''); + } catch (_) {} +} + +export function registerClient(payload) { + return fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + body: JSON.stringify(payload || {}), + }).then((r) => { + if (!r.ok) throw new Error('register_failed'); + return r.json(); + }).then((res) => { + if (res && res.api_key) setApiKey(res.api_key); + return res; + }); +} \ No newline at end of file diff --git a/trainer_web/static/js/train/form.js b/trainer_web/static/js/train/form.js new file mode 100644 index 0000000..6e9f893 --- /dev/null +++ b/trainer_web/static/js/train/form.js @@ -0,0 +1,128 @@ +import { startTrain } from '../services/apiClient.js'; +import { showNotification } from '../ui/notify.js'; + +export function initTrainForm() { + const typeSel = document.getElementById('train_type'); + if (typeSel) { + typeSel.addEventListener('change', onTrainTypeChange); + typeSel.dispatchEvent(new Event('change')); + } + initGpuSelectors(); + const form = document.getElementById('train-form'); + if (form) form.addEventListener('submit', onSubmit); +} + +function onTrainTypeChange() { + const v = this.value; + const pretrainSft = document.querySelectorAll('.pretrain-sft'); + const fromWeightFields = document.querySelectorAll('.from-weight'); + const loraFields = document.querySelectorAll('.lora'); + const dpoFields = document.querySelectorAll('.dpo'); + const dpoCard = document.querySelector('.parameter-card.dpo'); + const ppoFields = document.querySelectorAll('.ppo'); + const ppoCard = document.querySelector('.parameter-card.ppo'); + const grpoFields = document.querySelectorAll('.grpo'); + const grpoCard = document.querySelector('.parameter-card.grpo'); + const spoFields = document.querySelectorAll('.spo'); + const spoCard = document.querySelector('.parameter-card.spo'); + pretrainSft.forEach((f) => (f.style.display = v === 'pretrain' || v === 'sft' || v === 'dpo' || v === 'ppo' || v === 'grpo' || v === 'spo' ? 'block' : 'none')); + fromWeightFields.forEach((f) => (f.style.display = v !== 'ppo' && v !== 'grpo' && v !== 'spo' ? 'block' : 'none')); + loraFields.forEach((f) => (f.style.display = v === 'lora' ? 'block' : 'none')); + dpoFields.forEach((f) => (f.style.display = v === 'dpo' ? 'block' : 'none')); + ppoFields.forEach((f) => (f.style.display = v === 'ppo' ? 'block' : 'none')); + if (dpoCard) dpoCard.style.display = v === 'dpo' ? 'block' : 'none'; + if (ppoCard) ppoCard.style.display = v === 'ppo' ? 'block' : 'none'; + grpoFields.forEach((f) => (f.style.display = v === 'grpo' ? 'block' : 'none')); + spoFields.forEach((f) => (f.style.display = v === 'spo' ? 'block' : 'none')); + if (grpoCard) grpoCard.style.display = v === 'grpo' ? 'block' : 'none'; + if (spoCard) spoCard.style.display = v === 'spo' ? 'block' : 'none'; + if (v === 'pretrain') setDefaults({ save_dir: '../out', save_weight: 'pretrain', epochs: '1', batch_size: '32', learning_rate: '5e-4', data_path: '../dataset/pretrain_hq.jsonl', from_weight: 'none', log_interval: '100', save_interval: '100', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '512', use_moe: '0' }); + else if (v === 'sft') setDefaults({ save_dir: '../out', save_weight: 'full_sft', epochs: '2', batch_size: '16', learning_rate: '5e-7', data_path: '../dataset/sft_mini_512.jsonl', from_weight: 'pretrain', log_interval: '100', save_interval: '100', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '512', use_moe: '0' }); + else if (v === 'lora') setDefaults({ save_dir: '../out/lora', lora_name: 'lora_identity', epochs: '50', batch_size: '32', learning_rate: '1e-4', data_path: '../dataset/lora_identity.jsonl', from_weight: 'full_sft', log_interval: '10', save_interval: '1', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '512', use_moe: '0' }); + else if (v === 'dpo') setDefaults({ save_dir: '../out', save_weight: 'dpo', epochs: '1', batch_size: '4', learning_rate: '4e-8', data_path: '../dataset/dpo.jsonl', from_weight: 'full_sft', log_interval: '100', save_interval: '100', beta: '0.1', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '1024', use_moe: '0' }); + else if (v === 'ppo') setDefaults({ save_dir: '../out', save_weight: 'ppo_actor', epochs: '1', batch_size: '2', learning_rate: '8e-8', data_path: '../dataset/rlaif-mini.jsonl', log_interval: '1', save_interval: '10', clip_epsilon: '0.1', vf_coef: '0.5', kl_coef: '0.02', reasoning: '1', update_old_actor_freq: '4', reward_model_path: '../../internlm2-1_8b-reward', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '66', use_moe: '0' }); + else if (v === 'grpo') setDefaults({ save_dir: '../out', save_weight: 'grpo', epochs: '1', batch_size: '2', learning_rate: '8e-8', data_path: '../dataset/rlaif-mini.jsonl', log_interval: '1', save_interval: '10', beta: '0.02', num_generations: '8', reasoning: '1', reward_model_path: '../../internlm2-1_8b-reward', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '66', use_moe: '0' }); + else if (v === 'spo') setDefaults({ save_dir: '../out', save_weight: 'spo', epochs: '1', batch_size: '2', learning_rate: '1e-7', data_path: '../dataset/rlaif-mini.jsonl', log_interval: '1', save_interval: '10', beta: '0.02', reasoning: '1', reward_model_path: '../../internlm2-1_8b-reward', hidden_size: '512', num_hidden_layers: '8', max_seq_len: '66', use_moe: '0' }); +} + +function setDefaults(map) { + Object.entries(map).forEach(([name, val]) => { + const nodes = document.querySelectorAll(`[name="${name}"]`); + nodes.forEach((node) => { + const card = node.closest('.parameter-card'); + const visible = !card || card.style.display !== 'none'; + if (visible) node.value = val; + }); + }); +} + +function initGpuSelectors() { + const hasGpu = window.hasGpu === true; + const gpuCount = Number(window.gpuCount || 0); + const modeSel = document.getElementById('training_mode'); + const single = document.getElementById('single-gpu-selection'); + const multi = document.getElementById('multi-gpu-selection'); + if (!modeSel) return; + function updateVisibility() { + const mode = modeSel.value; + if (single) single.style.display = mode === 'single_gpu' ? 'block' : 'none'; + if (multi) multi.style.display = mode === 'multi_gpu' ? 'block' : 'none'; + } + if (!hasGpu) { + modeSel.value = 'cpu'; + if (single) single.style.display = 'none'; + if (multi) multi.style.display = 'none'; + } else { + const gpuNumInput = document.getElementById('gpu_num'); + if (gpuNumInput && gpuCount > 0) gpuNumInput.value = gpuCount; + } + updateVisibility(); + modeSel.addEventListener('change', updateVisibility); +} + +function onSubmit(e) { + e.preventDefault(); + const form = e.currentTarget; + const data = {}; + const trainingModeSel = form.querySelector('#training_mode'); + const trainingMode = trainingModeSel ? trainingModeSel.value : 'cpu'; + const inputs = form.querySelectorAll('input, select, textarea'); + inputs.forEach((el) => { + const name = el.name; + if (!name || name === 'training_mode') return; + const card = el.closest('.parameter-card'); + const visible = !card || card.style.display !== 'none'; + if (!visible) return; + let value = el.value; + if (el.type === 'checkbox') { + if (!el.checked) return; + } + if (name === 'gpu_num') { + const multi = document.getElementById('multi-gpu-selection'); + if (!(multi && multi.style.display !== 'none')) return; + } + if (name === 'device') { + if (trainingMode === 'single_gpu') value = `cuda:${value}`; + else if (trainingMode === 'cpu') value = 'cpu'; + else return; + } + data[name] = value; + }); + showNotification('正在启动训练...', 'info'); + setTimeout(() => { + startTrain(data) + .then((result) => { + if (result.success) { + showNotification('训练已开始!', 'success'); + setTimeout(() => { + const processTab = document.querySelector('.tab[onclick*="processes"]'); + if (processTab) processTab.click(); + }, 1000); + } else showNotification('训练启动失败:' + result.error, 'error'); + }) + .catch(() => { + showNotification('启动训练中,请耐心等待...', 'info'); + }); + }, 1000); +} + diff --git a/trainer_web/static/js/ui/dialog.js b/trainer_web/static/js/ui/dialog.js new file mode 100644 index 0000000..f9ebca5 --- /dev/null +++ b/trainer_web/static/js/ui/dialog.js @@ -0,0 +1,51 @@ +export function showConfirmDialog(message, onConfirm, onCancel = null) { + const existing = document.querySelector('.custom-dialog'); + if (existing && existing.parentNode && existing.parentNode.classList.contains('dialog-overlay')) { + document.body.removeChild(existing.parentNode); + } + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + const container = document.createElement('div'); + container.className = 'custom-dialog'; + container.innerHTML = ` +
+