mirror of
https://github.com/jingyaogong/minimind.git
synced 2026-01-13 19:57:20 +08:00
rewrite web
This commit is contained in:
parent
7c947e59c1
commit
c0b39e2396
87
README_web.md
Normal file
87
README_web.md
Normal file
@ -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` 引用完成快速回滚。
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
trainer_web/static/js/app.js
Normal file
25
trainer_web/static/js/app.js
Normal 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();
|
||||
});
|
||||
|
||||
194
trainer_web/static/js/logfiles/list.js
Normal file
194
trainer_web/static/js/logfiles/list.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
343
trainer_web/static/js/processes/list.js
Normal file
343
trainer_web/static/js/processes/list.js
Normal 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://');
|
||||
}
|
||||
}
|
||||
|
||||
73
trainer_web/static/js/processes/logs.js
Normal file
73
trainer_web/static/js/processes/logs.js
Normal 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}`;
|
||||
});
|
||||
}
|
||||
|
||||
74
trainer_web/static/js/services/apiClient.js
Normal file
74
trainer_web/static/js/services/apiClient.js
Normal 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());
|
||||
}
|
||||
|
||||
103
trainer_web/static/js/train/form.js
Normal file
103
trainer_web/static/js/train/form.js
Normal 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);
|
||||
}
|
||||
|
||||
51
trainer_web/static/js/ui/dialog.js
Normal file
51
trainer_web/static/js/ui/dialog.js
Normal 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);
|
||||
}
|
||||
|
||||
16
trainer_web/static/js/ui/notify.js
Normal file
16
trainer_web/static/js/ui/notify.js
Normal 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);
|
||||
}
|
||||
|
||||
15
trainer_web/static/js/ui/tabs.js
Normal file
15
trainer_web/static/js/ui/tabs.js
Normal 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();
|
||||
}
|
||||
|
||||
34
trainer_web/static/js/utils/dom.js
Normal file
34
trainer_web/static/js/utils/dom.js
Normal 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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user