rewrite web

This commit is contained in:
wangyuzhan 2025-11-14 14:21:14 +08:00
parent 7c947e59c1
commit c0b39e2396
14 changed files with 1068 additions and 82 deletions

87
README_web.md Normal file
View File

@ -0,0 +1,87 @@
## 总览
- 目标:将当前单文件 `static/js/script.js` 的所有逻辑拆分为职责清晰的 ES Modules并在不改变既有后端接口的前提下优化交互与视觉表现使界面更专业、易用。
- 范围仅前端HTML/CSS/JS保持与后端 `train_web_ui.py` 的 REST 接口兼容;可选提供后端小幅辅助(如 SSE但不作为本次必须项。
## 现状拆解
- 导航与标签:`openTab`、页面切换与进程/日志刷新script.js:229, 2329
- 训练类型联动与默认值:`train_type` 变更、分区显隐与各类型默认填充script.js:32154
- 轮询与请求:带超时/重试的 `fetchWithTimeoutAndRetry`script.js:200248进程状态轮询 `startProcessPolling`/`checkProcessStatusChanges`script.js:182199, 251279
- 进程列表与项更新:`loadProcesses`/`updateProcessItem`/`addProcessItemToGroup`script.js:467667, 357465
- SwanLab链接检测/打开script.js:282355
- 日志查看进程日志自动刷新与容器滚动script.js:669788日志文件列表/查看/删除script.js:11261462
- 全局通知与确认:`showNotification`/`showConfirmDialog`script.js:790885
- 训练表单提交数据整形、模式分支、启动训练后切到“训练进程”script.js:10521124
## 拆分方案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.jsscript.js:229
- 类型联动与默认值 → train/form.jsscript.js:32154, 10521124
- `fetchWithTimeoutAndRetry` → services/apiClient.jsscript.js:200248
- 轮询相关 → processes/list.jsscript.js:182199, 251279
- 进程项增/改/组折叠 → processes/list.jsscript.js:357667, 467602
- SwanLab → processes/list.js + servicesscript.js:282355
- 进程日志 → processes/logs.jsscript.js:669788
- 通知/确认 → ui/notify.js, ui/dialog.jsscript.js:790885
- 进程停止/删除 → processes/list.js + servicesscript.js:8871050
- 日志文件列表/操作 → logfiles/list.jsscript.js:11261462
## 加载与初始化
- 在 `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` 引用完成快速回滚。

View File

@ -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"
echo "停止命令: kill $(cat "train_web_ui.pid") or bash trainer_web/stop_web_ui.sh"

View File

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

View File

@ -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();
});

View File

@ -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 = '<p>暂无日志文件</p>';
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 = `
<div class="process-info">
<div><strong>${logfile.filename}</strong></div>
<div>
<span class="process-status status-completed">已保存</span>
<span style="margin-left: 10px; color: #999; font-size: 0.9em;">${logfile.modified_time}</span>
</div>
</div>
<div>
<button class="btn-logs" data-view="${logfile.filename}">查看日志</button>
<button class="btn-delete" data-del="${logfile.filename}">删除</button>
</div>
<div id="log-content-${logfile.filename.replace(/\./g, '-') }" class="logs-container hidden"></div>
`;
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;
}
}

View File

@ -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 = '<p>暂无训练进程</p>';
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 ? `<button class="btn-swanlab" data-swan="${process.id}">SwanLab</button>` : '';
item.innerHTML = `
<div class="process-info">
<div><strong>${process.start_time}</strong></div>
<div><span class="process-status ${statusClass}">${process.status}</span></div>
</div>
<div>
<button class="btn-logs" data-show="${process.id}">查看日志</button>
<button class="btn-logs" data-refresh="${process.id}">刷新日志</button>
${swanBtn}
${process.running ? `<button class="btn-stop" data-stop="${process.id}">停止训练</button>` : ''}
${showDelete ? `<button class="btn-delete" data-del="${process.id}">删除</button>` : ''}
</div>
<div id="logs-${process.id}" class="logs-container hidden"></div>
`;
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 = '<p>暂无训练进程</p>';
}
}, 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 = '<p>暂无训练进程</p>';
}
}
}
}, 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://');
}
}

View File

@ -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}`;
});
}

View File

@ -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());
}

View File

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

View File

@ -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 = `
<div class="dialog-content">
<div class="dialog-message">${message}</div>
<div class="dialog-actions">
<button class="dialog-button dialog-cancel">取消</button>
<button class="dialog-button dialog-confirm">确认</button>
</div>
</div>
`;
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);
}

View File

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

View File

@ -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();
}

View File

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

View File

@ -195,58 +195,8 @@
</div>
<script>
// 检查是否支持GPU并显示对应的选择器
// 使用Flask模板变量传递GPU信息
var hasGpu = {{ has_gpu|default(false)|tojson|safe }};
var gpuCount = {{ gpu_count|default(0)|tojson|safe }};
// 根据是否有GPU设置默认训练方式
var trainingModeSelect = document.getElementById('training_mode');
var singleGpuSelection = document.getElementById('single-gpu-selection');
var multiGpuSelection = document.getElementById('multi-gpu-selection');
// 设置默认值并初始化UI
function initUI() {
if (!trainingModeSelect) return;
if (!hasGpu) {
// 没有GPU时默认选择CPU训练
trainingModeSelect.value = 'cpu';
if (singleGpuSelection) singleGpuSelection.style.display = 'none';
if (multiGpuSelection) multiGpuSelection.style.display = 'none';
} else {
// 设置GPU数量的默认值
var gpuNumInput = document.getElementById('gpu_num');
if (gpuNumInput && gpuCount > 0) {
gpuNumInput.value = gpuCount;
}
}
// 初始化显示状态
updateSelectionVisibility();
}
// 更新选择器可见性
function updateSelectionVisibility() {
if (!trainingModeSelect) return;
var selectedMode = trainingModeSelect.value;
if (singleGpuSelection) {
singleGpuSelection.style.display = selectedMode === 'single_gpu' ? 'block' : 'none';
}
if (multiGpuSelection) {
multiGpuSelection.style.display = selectedMode === 'multi_gpu' ? 'block' : 'none';
}
}
// 添加训练方式切换事件监听器
if (trainingModeSelect) {
trainingModeSelect.addEventListener('change', updateSelectionVisibility);
}
// 初始化UI
window.addEventListener('DOMContentLoaded', initUI);
window.hasGpu = {{ has_gpu|default(false)|tojson|safe }};
window.gpuCount = {{ gpu_count|default(0)|tojson|safe }};
</script>
<div class="submit-container">
@ -270,6 +220,6 @@
</div>
</div>
<script src="/static/js/script.js"></script>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
</html>