diff --git a/README_web.md b/README_web.md new file mode 100644 index 0000000..4ca3bd5 --- /dev/null +++ b/README_web.md @@ -0,0 +1,87 @@ +## 总览 +- 目标:将当前单文件 `static/js/script.js` 的所有逻辑拆分为职责清晰的 ES Modules,并在不改变既有后端接口的前提下,优化交互与视觉表现,使界面更专业、易用。 +- 范围:仅前端(HTML/CSS/JS),保持与后端 `train_web_ui.py` 的 REST 接口兼容;可选提供后端小幅辅助(如 SSE)但不作为本次必须项。 + +## 现状拆解 +- 导航与标签:`openTab`、页面切换与进程/日志刷新(script.js:2–29, 23–29)。 +- 训练类型联动与默认值:`train_type` 变更、分区显隐与各类型默认填充(script.js:32–154)。 +- 轮询与请求:带超时/重试的 `fetchWithTimeoutAndRetry`(script.js:200–248),进程状态轮询 `startProcessPolling`/`checkProcessStatusChanges`(script.js:182–199, 251–279)。 +- 进程列表与项更新:`loadProcesses`/`updateProcessItem`/`addProcessItemToGroup`(script.js:467–667, 357–465)。 +- SwanLab:链接检测/打开(script.js:282–355)。 +- 日志查看:进程日志自动刷新与容器滚动(script.js:669–788),日志文件列表/查看/删除(script.js:1126–1462)。 +- 全局通知与确认:`showNotification`/`showConfirmDialog`(script.js:790–885)。 +- 训练表单提交:数据整形、模式分支、启动训练后切到“训练进程”(script.js:1052–1124)。 + +## 拆分方案(ES Modules) +- 采用原生 ES Modules,无需打包器;在 `index.html` 将单一脚本替换为入口 `app.js`(type="module"),其余模块按需 import。 +- 目录结构(`static/js/`): + - `app.js`:应用入口,初始化标签、轮询、首屏加载。 + - `services/apiClient.js`:`fetchWithTimeoutAndRetry` 与接口封装(`getProcesses`/`getLogs`/`startTrain`/`stop`/`deleteProcess`/`getLogFiles`/`getLogFileContent`/`deleteLogFile`)。 + - `ui/tabs.js`:`openTab` 与标签选中态管理、切换后触发对应加载。 + - `ui/notify.js`:`showNotification`(success/error/info)。 + - `ui/dialog.js`:`showConfirmDialog`/`closeDialog`。 + - `train/form.js`:训练类型显隐与默认值、`submit` 整形与发起训练;合并现有 `index.html` 内联 GPU 选择器联动。 + - `processes/list.js`:`loadProcesses`、分组折叠/展开、`updateProcessItem`、状态 chip 与操作按钮逻辑(含 SwanLab)。 + - `processes/logs.js`:进程日志区域展开/关闭、自动刷新定时器管理(`logTimers`)、`refreshLog`/`loadLogContent`。 + - `logfiles/list.js`:日志文件分组、查看全文、删除项与分组高度更新。 + - `utils/dom.js`:通用 dom/help 方法(`qs`/`qsa`/`el`/`setVisible`/`setText` 等)。 +- 现有函数到模块的映射: + - `openTab` → ui/tabs.js(script.js:2–29)。 + - 类型联动与默认值 → train/form.js(script.js:32–154, 1052–1124)。 + - `fetchWithTimeoutAndRetry` → services/apiClient.js(script.js:200–248)。 + - 轮询相关 → processes/list.js(script.js:182–199, 251–279)。 + - 进程项增/改/组折叠 → processes/list.js(script.js:357–667, 467–602)。 + - SwanLab → processes/list.js + services(script.js:282–355)。 + - 进程日志 → processes/logs.js(script.js:669–788)。 + - 通知/确认 → ui/notify.js, ui/dialog.js(script.js:790–885)。 + - 进程停止/删除 → processes/list.js + services(script.js:887–1050)。 + - 日志文件列表/操作 → logfiles/list.js(script.js:1126–1462)。 + +## 加载与初始化 +- 在 `app.js`: + - 绑定 tab 切换,首屏激活“开始训练”。 + - 初始化训练表单默认值与 GPU 选择器显隐。 + - 启动进程状态轮询,只在“训练进程”栏激活;离开时清理定时器与日志定时器。 + - 首次进入“训练进程”时触发 `loadProcesses`,进入“日志文件”触发 `loadLogFiles`。 + +## 交互优化 +- 表单 + - 必填项校验与错误提示;失焦即时校验;数值范围与格式(学习率、小数、正整数)。 + - 将“强化学习参数”块 Default 折叠,仅在选择 DPO/PPO 展开;提供字段级 tooltip(问号提示)。 + - 训练命令预览:在提交前显示将要执行的命令(只读文本),便于核对。 + - 记忆最近一次配置(localStorage),一键恢复。 +- 进程列表 + - 顶部筛选:按训练类型/状态过滤;关键字搜索(按时间/类型)。 + - 操作区对齐与悬停提示;SwanLab 按钮仅在链接可用时亮显,否则显示“生成中”。 + - 进程项采用更明显的层次:标题(时间)、副标题(类型),右侧状态 chip;按钮组固定顺序。 + - 自适应展开动画与内容高度回流优化,减少抖动。 +- 日志查看 + - 自动滚动可开关(“跟随最新”开/关);复制按钮;加载失败重试入口。 + - 大文件懒加载:初次只读尾部 N 行,支持“加载更多历史”。 + +## 视觉美化 +- 主题变量:在 `style.css` 顶部引入 CSS 变量(背景、主色、强调色、阴影、圆角),统一渐变与阴影。 +- 表单栅格:更稳定的两列到单列响应式断点;标签与控件对齐;hint 文案灰度提升。 +- 状态 chip:统一尺寸/色阶,添加图标(运行/停止/错误/完成)。 +- 按钮规范:主次区分、禁用态与加载态;滚动条与日志容器对比度优化。 +- Header 与 Tabs:粘性头部、Tab 选中态对比更明显;移动端竖排布局间距优化。 + +## 兼容性与无后端改动原则 +- 保持与现有接口一致:`/train`、`/processes`、`/logs/:id`、`/stop/:id`、`/delete/:id`、`/logfiles`、`/logfile-content/:filename`、`/delete-logfile/:filename`(见 train_web_ui.py)。 +- 轮询频率保持 5s,可配置;进程日志刷新 1s,仅在“运行中”且展开时。 + +## 可选增强(后端协作,非必须) +- SSE/WS 实时日志:新增 `/logs/stream/:id`,前端以 EventSource/WS 订阅;减少轮询负载与延迟。 +- 进程持久化与重启恢复优化:后端提供简易查询缓存 API,前端减少初始闪烁。 + +## 交付内容 +- 新的 JS 模块文件(上述结构),`index.html` 改为 `type="module"` 入口加载;移除旧 `script.js` 引用。 +- 更新后的 `style.css`(保留既有风格,加入变量与状态样式优化)。 +- 无后端接口变更;提供 README 片段说明模块职责与使用(如需要)。 + +## 验收与回滚 +- 功能对比清单: + - 单卡/多卡 Pretrain/SFT/LoRA/DPO/PPO 启动 + - 进程列表与状态变更、SwanLab 链接打开 + - 进程日志展开/自动刷新、日志文件列表/查看/删除 +- 若出现问题,可通过 `index.html` 切回旧 `script.js` 引用完成快速回滚。 \ No newline at end of file diff --git a/trainer_web/start_web_ui.sh b/trainer_web/start_web_ui.sh index 012f15c..1702227 100755 --- a/trainer_web/start_web_ui.sh +++ b/trainer_web/start_web_ui.sh @@ -1,7 +1,7 @@ #!/bin/bash -# 获取脚本所在目录 -SCRIPT_DIR=$(dirname "$(readlink -f "$0")") +# 获取脚本所在目录(兼容 macOS) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" # 检查是否已经有实例在运行 @@ -33,18 +33,22 @@ nohup python -u train_web_ui.py > "$LOG_FILE" 2>&1 & # 保存PID echo $! > "train_web_ui.pid" -# 等待服务启动并获取实际端口号 -sleep 3 +# 轮询日志以获取实际端口号(最多等待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 -# 从日志文件中提取实际使用的端口号 -# 查找包含"启动Flask服务器在 http://0.0.0.0:"的行并提取端口号 -PORT=$(grep -oP '启动Flask服务器在 http://0.0.0.0:\K[0-9]+' "$LOG_FILE" || echo "5000") - -# 如果没有找到端口号,尝试查找"Running on http://0.0.0.0:"格式的日志 -if [ "$PORT" = "5000" ]; then - PORT=$(grep -oP 'Running on http://0.0.0.0:\K[0-9]+' "$LOG_FILE" || echo "5000") +# 如果仍未获取到端口,回退为默认提示端口(与后端起始端口一致) +if [ -z "$PORT" ]; then + PORT=12581 fi 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" \ No newline at end of file +echo "停止命令: kill $(cat "train_web_ui.pid") or bash trainer_web/stop_web_ui.sh" diff --git a/trainer_web/static/css/style.css b/trainer_web/static/css/style.css index 8f8b864..c0e6a15 100644 --- a/trainer_web/static/css/style.css +++ b/trainer_web/static/css/style.css @@ -1,11 +1,28 @@ +:root { + --bg: #121212; + --card-bg: #2d2d2d; + --panel-bg: rgba(30, 30, 30, 0.9); + --text: #e0e0e0; + --accent: #8e24aa; + --accent-grad-start: #4a148c; + --accent-grad-end: #8e24aa; + --danger-grad-start: #ff416c; + --danger-grad-end: #ff4b2b; + --info-grad-start: #4facfe; + --info-grad-end: #00f2fe; + --success-grad-start: #11998e; + --success-grad-end: #38ef7d; + --border: #333; +} + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; - color: #e0e0e0; + color: var(--text); max-width: 1200px; margin: 0 auto; padding: 20px; - background-color: #121212; + background-color: var(--bg); min-height: 100vh; } @@ -67,7 +84,7 @@ h1 { transform: translateY(-1px); } .form-container { - background: rgba(30, 30, 30, 0.9); + background: var(--panel-bg); padding: 30px; border-radius: 15px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); @@ -77,7 +94,7 @@ h1 { /* 参数卡片样式 */ .parameter-card { - background-color: #2d2d2d; + background-color: var(--card-bg); border-radius: 8px; padding: 24px; margin-bottom: 16px; @@ -101,7 +118,7 @@ h1 { margin-top: 0; margin-bottom: 20px; padding-bottom: 10px; - border-bottom: 1px solid #e040fb; + border-bottom: 1px solid var(--accent); width: 100%; } @@ -174,7 +191,7 @@ textarea { input[type="text"]:focus, input[type="number"]:focus, select:focus { outline: none; - border-color: #8e24aa; + border-color: var(--accent); box-shadow: 0 0 0 3px rgba(142, 36, 170, 0.2); background-color: #333; } @@ -187,7 +204,7 @@ input[type="text"]:focus, input[type="number"]:focus, select:focus { margin-right: 10px; } button { - background: linear-gradient(135deg, #4a148c 0%, #8e24aa 100%); + background: linear-gradient(135deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); color: white; border: none; padding: 12px 25px; @@ -210,7 +227,7 @@ button:active { } .logs-container { background-color: #0d0d0d; - color: #e0e0e0; + color: var(--text); padding: 20px; border-radius: 10px; max-height: 300px; @@ -219,7 +236,7 @@ button:active { font-family: 'Courier New', monospace; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4); transition: all 0.3s ease; - border: 1px solid #333; + border: 1px solid var(--border); } .logs-container:hover { @@ -227,7 +244,7 @@ button:active { } .process-type-group { margin-bottom: 30px; - background-color: rgba(30, 30, 30, 0.9); + background-color: var(--panel-bg); border-radius: 15px; border: 1px solid #444; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); @@ -246,10 +263,10 @@ button:active { .process-type-title { margin: 0; - color: #e0e0e0; + color: var(--text); font-size: 1.2em; font-weight: 600; - border-bottom: 2px solid #8e24aa; + border-bottom: 2px solid var(--accent); padding-bottom: 8px; flex-grow: 1; } @@ -309,7 +326,7 @@ button:active { letter-spacing: 0.5px; } .status-running { - background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + background: linear-gradient(135deg, var(--success-grad-start) 0%, var(--success-grad-end) 100%); color: white; } .status-completed { @@ -325,7 +342,7 @@ button:active { color: white; } .btn-stop { - background: linear-gradient(135deg, #ff416c 0%, #ff4b2b 100%); + background: linear-gradient(135deg, var(--danger-grad-start) 0%, var(--danger-grad-end) 100%); padding: 8px 15px; font-size: 14px; border-radius: 6px; @@ -335,7 +352,7 @@ button:active { box-shadow: 0 4px 10px rgba(255, 65, 108, 0.3); } .btn-logs { - background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); padding: 8px 15px; font-size: 14px; margin-right: 10px; @@ -585,4 +602,4 @@ button:active { .form-container { padding: 20px; } -} \ No newline at end of file +} diff --git a/trainer_web/static/js/app.js b/trainer_web/static/js/app.js new file mode 100644 index 0000000..580127b --- /dev/null +++ b/trainer_web/static/js/app.js @@ -0,0 +1,25 @@ +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'; + +const hooks = { + onEnterProcesses: () => { + loadProcesses(); + }, + onLeaveProcesses: () => { + stopProcessPolling(); + }, + onEnterLogfiles: () => { + loadLogFiles(); + }, +}; + +window.openTab = (evt, tabName) => _openTab(evt, tabName, hooks); + +window.addEventListener('load', () => { + initTrainForm(); + startProcessPolling(); + loadProcesses(); +}); + diff --git a/trainer_web/static/js/logfiles/list.js b/trainer_web/static/js/logfiles/list.js new file mode 100644 index 0000000..a20d24b --- /dev/null +++ b/trainer_web/static/js/logfiles/list.js @@ -0,0 +1,194 @@ +import { getLogFiles, getLogFileContent, deleteLogFile as apiDeleteLogFile } from '../services/apiClient.js'; +import { el } from '../utils/dom.js'; +import { showNotification } from '../ui/notify.js'; +import { showConfirmDialog } from '../ui/dialog.js'; + +export function loadLogFiles() { + return getLogFiles().then((data) => { + const list = document.getElementById('logfiles-list'); + list.innerHTML = ''; + if (data.length === 0) { + list.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 = ` +
+
${logfile.filename}
+
+ 已保存 + ${logfile.modified_time} +
+
+
+ + +
+ + `; + parent.appendChild(item); + bindItemButtons(item, logfile); +} + +function bindItemButtons(item, logfile) { + const viewBtn = item.querySelector('[data-view]'); + if (viewBtn) viewBtn.addEventListener('click', () => viewLogFile(logfile.filename, viewBtn)); + const delBtn = item.querySelector('[data-del]'); + if (delBtn) delBtn.addEventListener('click', () => deleteLogFile(logfile.filename, delBtn)); +} + +function deleteLogFile(filename, button) { + showConfirmDialog(`确定要删除日志文件 "${filename}" 吗?此操作无法恢复。`, () => { + const item = button.closest('.process-item'); + const content = item.closest('.process-type-content'); + const group = content.closest('.process-type-group'); + const original = button.textContent; + button.textContent = '删除中...'; + button.disabled = true; + apiDeleteLogFile(filename) + .then((data) => { + if (data.success) { + item.remove(); + if (content.children.length === 0) group.remove(); + else { + const header = content.previousElementSibling; + if (header && header.dataset.expanded === 'true') { + content.style.maxHeight = 'none'; + const h = content.scrollHeight; + content.style.maxHeight = h + 'px'; + } + } + showNotification(`日志文件 "${filename}" 已成功删除`); + } else throw new Error(data.message || '删除失败'); + }) + .catch((e) => { + showNotification(`删除失败: ${e.message}`, 'error'); + button.textContent = original; + button.disabled = false; + }); + }); +} + +function viewLogFile(filename, button) { + const safe = filename.replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/\./g, '-'); + const item = button.closest('.process-item'); + const container = item.querySelector(`#log-content-${safe}`); + const content = item.closest('.process-type-content'); + const header = content ? content.previousElementSibling : null; + if (content && header && header.dataset.expanded !== 'true') toggleGroup(header); + if (container.classList.contains('hidden')) { + container.classList.remove('hidden'); + container.textContent = '加载中...'; + getLogFileContent(filename) + .then((logs) => { + container.textContent = logs; + container.scrollTop = 0; + updateContentHeight(content, header); + }) + .catch((e) => { + container.textContent = `获取日志失败: ${e.message}`; + updateContentHeight(content, header); + }); + } else { + container.classList.add('hidden'); + updateContentHeight(content, header); + } +} + +function updateContentHeight(content, header) { + if (content && header && header.dataset.expanded === 'true') { + const current = content.style.maxHeight; + content.style.maxHeight = 'none'; + const h = content.scrollHeight; + if (current === 'none' || parseInt(current) !== h) { + content.style.maxHeight = h + 'px'; + setTimeout(() => { + if (header.dataset.expanded === 'true') content.style.maxHeight = 'none'; + }, 300); + } else content.style.maxHeight = current; + } +} + diff --git a/trainer_web/static/js/processes/list.js b/trainer_web/static/js/processes/list.js new file mode 100644 index 0000000..1d96bc9 --- /dev/null +++ b/trainer_web/static/js/processes/list.js @@ -0,0 +1,343 @@ +import { getProcesses, stopProcess as apiStop, deleteProcess as apiDelete } from '../services/apiClient.js'; +import { showNotification } from '../ui/notify.js'; +import { showConfirmDialog } from '../ui/dialog.js'; +import { el, clearChildren } from '../utils/dom.js'; +import { showLogs, refreshLog, clearLogTimerFor } from './logs.js'; + +let processPollingTimer = null; + +export function startProcessPolling() { + if (processPollingTimer) clearInterval(processPollingTimer); + processPollingTimer = setInterval(() => { + const tab = document.querySelector('.tab.active'); + if (tab && tab.textContent.includes('进程')) checkProcessStatusChanges(); + }, 5000); +} + +export function stopProcessPolling() { + if (processPollingTimer) { + clearInterval(processPollingTimer); + processPollingTimer = null; + } +} + +export function checkProcessStatusChanges() { + return getProcesses() + .then((data) => { + data.forEach((p) => { + const item = document.querySelector(`[data-process-id="${p.id}"]`); + if (!item) return; + const cur = item.dataset.processStatus; + const next = p.status; + if (cur !== next) { + updateProcessItem(item, p); + if (next === '出错') showNotification(`进程 ${p.train_type} 已出错`, 'error'); + } + }); + }) + .catch(() => { + showNotification('连接服务器失败,请刷新页面重试', 'error'); + }); +} + +export function loadProcesses() { + return getProcesses().then((data) => { + const list = document.getElementById('process-list'); + clearChildren(list); + if (data.length === 0) { + list.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)', + }; + 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 ? `` : ''; + item.innerHTML = ` +
+
${process.start_time}
+
${process.status}
+
+
+ + + ${swanBtn} + ${process.running ? `` : ''} + ${showDelete ? `` : ''} +
+ + `; + parent.appendChild(item); + bindItemButtons(item, process); +} + +function bindItemButtons(item, process) { + const showBtn = item.querySelector('[data-show]'); + if (showBtn) showBtn.addEventListener('click', () => showLogs(process.id)); + const refreshBtn = item.querySelector('[data-refresh]'); + if (refreshBtn) refreshBtn.addEventListener('click', () => refreshLog(process.id)); + const swanBtn = item.querySelector('[data-swan]'); + if (swanBtn) swanBtn.addEventListener('click', () => checkAndOpenSwanlab(process.id)); + const stopBtn = item.querySelector('[data-stop]'); + if (stopBtn) stopBtn.addEventListener('click', () => stopProcess(process.id)); + const delBtn = item.querySelector('[data-del]'); + if (delBtn) delBtn.addEventListener('click', () => deleteProcess(process.id)); +} + +export function updateProcessItem(item, process) { + item.dataset.processStatus = process.status; + item.dataset.trainMonitor = process.train_monitor || 'none'; + if (process.swanlab_url) item.dataset.swanlabUrl = process.swanlab_url; + const statusEl = item.querySelector('.process-status'); + if (statusEl) { + statusEl.classList.remove('status-running', 'status-manual-stop', 'status-error', 'status-completed'); + let cls = 'status-completed'; + if (process.status === '运行中') cls = 'status-running'; + else if (process.status === '手动停止') cls = 'status-manual-stop'; + else if (process.status === '出错') cls = 'status-error'; + statusEl.classList.add(cls); + statusEl.textContent = process.status; + } + const btnContainer = item.querySelector('div:nth-child(2)'); + const existingSwan = item.querySelector('.btn-swanlab'); + const showSwan = process.train_monitor !== 'none'; + if (showSwan && !existingSwan && btnContainer) { + const b = el('button', { class: 'btn-swanlab' }); + b.textContent = 'SwanLab'; + b.onclick = () => checkAndOpenSwanlab(process.id); + const stop = btnContainer.querySelector('.btn-stop'); + if (stop) btnContainer.insertBefore(b, stop); + else btnContainer.appendChild(b); + } else if (!showSwan && existingSwan) existingSwan.remove(); + const stopBtn = item.querySelector('.btn-stop'); + if (stopBtn) { + if (!process.running) stopBtn.remove(); + } else if (process.running && btnContainer) { + const n = el('button', { class: 'btn-stop' }); + n.textContent = '停止训练'; + n.onclick = () => stopProcess(process.id); + btnContainer.appendChild(n); + } + const delBtn = item.querySelector('.btn-delete'); + if (!process.running) { + if (!delBtn) { + const c = item.querySelector('div:last-child'); + if (c) { + const d = el('button', { class: 'btn-delete' }); + d.textContent = '删除'; + d.onclick = () => deleteProcess(process.id); + c.appendChild(d); + } + } + } else if (delBtn) delBtn.remove(); + if (!process.running) clearLogTimerFor(process.id); +} + +export function deleteProcess(processId) { + showConfirmDialog('确定要删除这个训练进程吗?此操作不可恢复。', () => { + apiDelete(processId) + .then(() => { + const item = document.querySelector(`[data-process-id="${processId}"]`); + if (item && item.parentNode) { + item.style.transition = 'opacity 0.3s, transform 0.3s'; + item.style.opacity = '0'; + item.style.transform = 'translateX(-20px)'; + setTimeout(() => { + const content = item.closest('.process-type-content'); + const group = content ? content.closest('.process-type-group') : null; + item.parentNode.removeChild(item); + if (content) { + const remain = content.querySelectorAll('.process-item'); + if (remain.length === 0 && group) { + setTimeout(() => { + group.style.transition = 'opacity 0.3s, transform 0.3s'; + group.style.opacity = '0'; + group.style.transform = 'translateY(-10px)'; + setTimeout(() => { + if (group.parentNode) group.parentNode.removeChild(group); + const left = document.querySelectorAll('.process-item'); + if (left.length === 0) { + const list = document.getElementById('process-list'); + list.innerHTML = '

暂无训练进程

'; + } + }, 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..0da4516 --- /dev/null +++ b/trainer_web/static/js/services/apiClient.js @@ -0,0 +1,74 @@ +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/train/form.js b/trainer_web/static/js/train/form.js new file mode 100644 index 0000000..72e8c09 --- /dev/null +++ b/trainer_web/static/js/train/form.js @@ -0,0 +1,103 @@ +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'); + pretrainSft.forEach((f) => (f.style.display = v === 'pretrain' || v === 'sft' || v === 'dpo' || v === 'ppo' ? 'block' : 'none')); + fromWeightFields.forEach((f) => (f.style.display = v !== 'ppo' ? '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'; + 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' }); +} + +function setDefaults(map) { + Object.entries(map).forEach(([id, val]) => { + const node = document.getElementById(id); + if (node) 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 fd = new FormData(form); + const data = {}; + const trainingMode = fd.get('training_mode'); + fd.forEach((value, key) => { + if (key === 'gpu_num') { + const multi = document.getElementById('multi-gpu-selection'); + if (multi && multi.style.display !== 'none') data[key] = value; + } else if (key === 'device') { + if (trainingMode === 'single_gpu') data[key] = `cuda:${value}`; + else if (trainingMode === 'cpu') data[key] = 'cpu'; + } else if (key !== 'training_mode') { + data[key] = 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 = ` +
+
${message}
+
+ + +
+
+ `; + overlay.appendChild(container); + document.body.appendChild(overlay); + setTimeout(() => { + overlay.classList.add('show'); + container.classList.add('show'); + }, 10); + const confirmBtn = container.querySelector('.dialog-confirm'); + confirmBtn.addEventListener('click', () => { + if (onConfirm) onConfirm(); + closeDialog(overlay); + }); + const cancelBtn = container.querySelector('.dialog-cancel'); + cancelBtn.addEventListener('click', () => { + if (onCancel) onCancel(); + closeDialog(overlay); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + if (onCancel) onCancel(); + closeDialog(overlay); + } + }); +} + +export function closeDialog(overlay) { + overlay.classList.remove('show'); + const container = overlay.querySelector('.custom-dialog'); + if (container) container.classList.remove('show'); + setTimeout(() => { + if (overlay.parentNode) document.body.removeChild(overlay); + }, 300); +} + diff --git a/trainer_web/static/js/ui/notify.js b/trainer_web/static/js/ui/notify.js new file mode 100644 index 0000000..33ad846 --- /dev/null +++ b/trainer_web/static/js/ui/notify.js @@ -0,0 +1,16 @@ +export function showNotification(message, type = 'success') { + const n = document.createElement('div'); + n.className = `notification notification-${type}`; + n.textContent = message; + document.body.appendChild(n); + setTimeout(() => { + n.classList.add('show'); + }, 10); + setTimeout(() => { + n.classList.remove('show'); + setTimeout(() => { + if (n.parentNode) document.body.removeChild(n); + }, 300); + }, 3000); +} + diff --git a/trainer_web/static/js/ui/tabs.js b/trainer_web/static/js/ui/tabs.js new file mode 100644 index 0000000..b4746d2 --- /dev/null +++ b/trainer_web/static/js/ui/tabs.js @@ -0,0 +1,15 @@ +import { qsa } from '../utils/dom.js'; + +export function openTab(evt, tabName, hooks = {}) { + const contents = qsa('.tab-content'); + contents.forEach((c) => c.classList.add('hidden')); + const tabs = qsa('.tab'); + tabs.forEach((t) => t.classList.remove('active')); + const target = document.getElementById(tabName); + if (target) target.classList.remove('hidden'); + if (evt && evt.currentTarget) evt.currentTarget.classList.add('active'); + if (tabName !== 'processes' && hooks.onLeaveProcesses) hooks.onLeaveProcesses(); + if (tabName === 'processes' && hooks.onEnterProcesses) hooks.onEnterProcesses(); + if (tabName === 'logfiles' && hooks.onEnterLogfiles) hooks.onEnterLogfiles(); +} + diff --git a/trainer_web/static/js/utils/dom.js b/trainer_web/static/js/utils/dom.js new file mode 100644 index 0000000..f7e0380 --- /dev/null +++ b/trainer_web/static/js/utils/dom.js @@ -0,0 +1,34 @@ +export function qs(selector, scope = document) { + return scope.querySelector(selector); +} + +export function qsa(selector, scope = document) { + return Array.from(scope.querySelectorAll(selector)); +} + +export function el(tag, attrs = {}) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else node.setAttribute(k, v); + } + return node; +} + +export function setHidden(node, hidden) { + if (!node) return; + if (hidden) node.classList.add('hidden'); + else node.classList.remove('hidden'); +} + +export function setText(node, text) { + if (!node) return; + node.textContent = text; +} + +export function clearChildren(node) { + if (!node) return; + while (node.firstChild) node.removeChild(node.firstChild); +} + diff --git a/trainer_web/templates/index.html b/trainer_web/templates/index.html index 2da4064..8f9d75e 100644 --- a/trainer_web/templates/index.html +++ b/trainer_web/templates/index.html @@ -195,58 +195,8 @@
@@ -270,6 +220,6 @@
- + - \ No newline at end of file +