diff --git a/scripts/static/css/style.css b/scripts/static/css/style.css index 60b2ea4..9563084 100644 --- a/scripts/static/css/style.css +++ b/scripts/static/css/style.css @@ -8,13 +8,28 @@ body { background-color: #121212; min-height: 100vh; } + +/* 头部样式 */ +.header { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 30px; +} + +.logo { + height: 60px; + margin-right: 20px; + vertical-align: middle; +} + h1 { color: #ffffff; - text-align: center; - margin-bottom: 30px; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); font-size: 2.5em; font-weight: 700; + margin: 0; + vertical-align: middle; } .tabs { display: flex; @@ -33,15 +48,23 @@ h1 { font-weight: 500; transition: all 0.3s ease; position: relative; - color: #cccccc; + color: #888888; width: 30%; text-align: center; + opacity: 0.7; } .tab.active { - background: linear-gradient(135deg, #4a148c 0%, #8e24aa 100%); + background: linear-gradient(135deg, #9c27b0 0%, #e040fb 100%); color: white; transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + opacity: 1; + font-weight: 600; +} +.tab:hover { + color: #aaaaaa; + opacity: 0.9; + transform: translateY(-1px); } .form-container { background: rgba(30, 30, 30, 0.9); @@ -56,12 +79,13 @@ h1 { .parameter-card { background-color: #2d2d2d; border-radius: 8px; - padding: 20px; - margin-bottom: 20px; + padding: 24px; + margin-bottom: 16px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); transition: transform 0.2s ease, box-shadow 0.2s ease; padding-left: 5%; padding-right: 5%; + margin-top: 16px; } .parameter-card:hover { @@ -71,13 +95,13 @@ h1 { /* 卡片标题样式 */ .card-title { - color: #e0e0e0; - font-size: 1.4rem; - font-weight: bold; + color: #ffffff; + font-size: 18px; + font-weight: 700; margin-top: 0; margin-bottom: 20px; padding-bottom: 10px; - border-bottom: 1px solid #404040; + border-bottom: 1px solid #e040fb; width: 100%; } @@ -118,14 +142,15 @@ label { display: block; margin-bottom: 5px; color: #ffffff; - font-weight: 600; - font-size: 0.9rem; + font-weight: 500; + font-size: 13px; + opacity: 0.8; text-transform: uppercase; letter-spacing: 0.5px; } input[type="text"], input[type="number"], select { width: 100%; - padding: 12px 15px; + padding: 8px 12px; border: 2px solid #444; border-radius: 8px; font-size: 0.9rem; @@ -137,7 +162,7 @@ input[type="text"], input[type="number"], select { /* 确保textarea也适应两列布局 */ textarea { width: 100%; - padding: 12px 15px; + padding: 8px 12px; border: 2px solid #444; border-radius: 8px; font-size: 0.9rem; @@ -200,14 +225,69 @@ button:active { .logs-container:hover { box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); } +.process-type-group { + margin-bottom: 30px; + background-color: rgba(30, 30, 30, 0.9); + border-radius: 15px; + border: 1px solid #444; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +/* 标题容器样式 */ +.process-type-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + cursor: pointer; + user-select: none; +} + +.process-type-title { + margin: 0; + color: #e0e0e0; + font-size: 1.2em; + font-weight: 600; + border-bottom: 2px solid #8e24aa; + padding-bottom: 8px; + flex-grow: 1; +} + +/* 切换按钮样式 */ +.toggle-btn { + background: none; + border: none; + color: #e0e0e0; + font-size: 0.8em; + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + transition: background-color 0.3s, transform 0.2s; + margin-left: 15px; +} + +.toggle-btn:hover { + background-color: rgba(255, 255, 255, 0.1); + transform: scale(1.1); +} + +/* 内容容器样式 */ +.process-type-content { + max-height: none; + overflow: visible; + transition: max-height 0.3s ease-in-out; + padding: 0 20px 20px 20px; +} + .process-item { - background: rgba(30, 30, 30, 0.9); + background: rgba(40, 40, 40, 0.9); padding: 20px; margin-bottom: 15px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); transition: all 0.3s ease; - border: 1px solid #444; + border: 1px solid #555; } .process-item:hover { @@ -261,6 +341,24 @@ button:active { margin-right: 10px; border-radius: 6px; } +.btn-delete { + background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%); + padding: 8px 15px; + font-size: 14px; + margin-right: 10px; + border-radius: 6px; + color: white; +} +.btn-delete:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(244, 67, 54, 0.3); +} +.btn-delete:disabled { + background: linear-gradient(135deg, #cccccc 0%, #aaaaaa 100%); + cursor: not-allowed; + transform: none; + box-shadow: none; +} .btn-logs:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(79, 172, 254, 0.3); @@ -270,12 +368,13 @@ button:active { } .section-title { color: #ffffff; - font-size: 20px; + font-size: 18px; margin-bottom: 25px; text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); - font-weight: 600; + font-weight: 700; padding-bottom: 10px; - border-bottom: 3px solid rgba(142, 36, 170, 0.3); + border-bottom: 1px solid #e040fb; + margin-top: 16px; } /* 添加滚动条样式 */ @@ -309,6 +408,131 @@ button:active { } } +/* 自定义确认对话框样式 */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; +} + +.dialog-overlay.show { + opacity: 1; +} + +.custom-dialog { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + max-width: 400px; + width: 90%; + transform: translateY(-20px); + opacity: 0; + transition: transform 0.3s ease, opacity 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.custom-dialog.show { + transform: translateY(0); + opacity: 1; +} + +.dialog-content { + padding: 20px; +} + +.dialog-message { + color: #ffffff; + font-size: 16px; + margin-bottom: 20px; + text-align: center; + line-height: 1.5; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.dialog-button { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.dialog-cancel { + background: linear-gradient(135deg, #4a4a4a 0%, #333333 100%); + color: #ffffff; +} + +.dialog-cancel:hover { + background: linear-gradient(135deg, #5a5a5a 0%, #444444 100%); + transform: translateY(-1px); +} + +.dialog-confirm { + background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); + color: #ffffff; +} + +.dialog-confirm:hover { + background: linear-gradient(135deg, #7a21db 0%, #3585ff 100%); + transform: translateY(-1px); +} + +/* 消息弹窗样式 */ +.notification { + position: fixed; + top: 20px; + right: 20px; + padding: 15px 25px; + border-radius: 10px; + color: white; + font-weight: 600; + font-size: 16px; + z-index: 1000; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; + max-width: 400px; + word-wrap: break-word; +} + +/* 显示状态 */ +.notification.show { + opacity: 1; + transform: translateX(0); +} + +/* 成功通知样式 */ +.notification-success { + background: linear-gradient(135deg, #4caf50 0%, #81c784 100%); +} + +/* 错误通知样式 */ +.notification-error { + background: linear-gradient(135deg, #f44336 0%, #ef5350 100%); +} + +/* 信息通知样式 */ +.notification-info { + background: linear-gradient(135deg, #2196f3 0%, #64b5f6 100%); +} + .tab-content { animation: fadeIn 0.5s ease-out; } @@ -319,6 +543,21 @@ button:active { padding: 10px; } + .header { + flex-direction: column; + text-align: center; + } + + .logo { + height: 50px; + margin-right: 0; + margin-bottom: 10px; + } + + h1 { + font-size: 2em; + } + .tabs { flex-direction: column; } diff --git a/scripts/static/images/logo2.png b/scripts/static/images/logo2.png new file mode 100644 index 0000000..9a0b3e2 Binary files /dev/null and b/scripts/static/images/logo2.png differ diff --git a/scripts/static/js/script.js b/scripts/static/js/script.js index ea9bcd7..02a726c 100644 --- a/scripts/static/js/script.js +++ b/scripts/static/js/script.js @@ -100,6 +100,147 @@ document.getElementById('train_type').addEventListener('change', function() { // 初始触发一次change事件以设置默认值 document.getElementById('train_type').dispatchEvent(new Event('change')); +// 页面加载时启动进程状态轮询 +window.addEventListener('load', () => { + startProcessPolling(); + + // 加载初始进程列表 + loadProcesses(); +}); + +// 在进程标签页切换时也触发一次检查 +const tabLinks = document.querySelectorAll('.tab'); +tabLinks.forEach(tab => { + tab.addEventListener('click', () => { + // 给标签页切换一些时间完成 + setTimeout(() => { + const processesTab = document.querySelector('.tab.active'); + if (processesTab && processesTab.textContent.includes('进程')) { + checkProcessStatusChanges(); + } + }, 100); + }); +}); + +// 添加定时轮询机制,每5秒检查一次进程状态变化 +let processPollingTimer; + +// 开始进程状态轮询 +function startProcessPolling() { + // 清除可能存在的旧定时器 + if (processPollingTimer) { + clearInterval(processPollingTimer); + } + // 每5秒检查一次进程状态 + processPollingTimer = setInterval(() => { + // 只有在进程标签页打开时才检查 + const processesTab = document.querySelector('.tab.active'); + if (processesTab && processesTab.textContent.includes('进程')) { + checkProcessStatusChanges(); + } + }, 5000); +} + +// 检查进程状态变化(特别是错误状态) +function checkProcessStatusChanges() { + fetch('/processes') + .then(response => response.json()) + .then(data => { + // 遍历每个进程,检查状态变化 + data.forEach(process => { + const processItem = document.querySelector(`[data-process-id="${process.id}"]`); + if (processItem) { + const currentStatus = processItem.dataset.processStatus; + const newStatus = process.status; + + // 如果状态发生变化,更新进程项 + if (currentStatus !== newStatus) { + updateProcessItem(processItem, process); + + // 如果状态变为出错,显示通知 + if (newStatus === '出错') { + showNotification(`进程 ${process.train_type} 已出错`, 'error'); + } + } + } + }); + }) + .catch(error => { + console.error('检查进程状态时出错:', error); + }); +} + +// 更新单个进程项 +function updateProcessItem(processItem, process) { + // 更新数据属性 + processItem.dataset.processStatus = process.status; + + // 更新状态类和文本 + const statusElement = processItem.querySelector('.process-status'); + if (statusElement) { + // 移除所有状态类 + statusElement.classList.remove('status-running', 'status-manual-stop', 'status-error', 'status-completed'); + + // 添加新的状态类 + let statusClass = ''; + if (process.status === '运行中') { + statusClass = 'status-running'; + } else if (process.status === '手动停止') { + statusClass = 'status-manual-stop'; + } else if (process.status === '出错') { + statusClass = 'status-error'; + } else { + statusClass = 'status-completed'; + } + + statusElement.classList.add(statusClass); + statusElement.textContent = process.status; + } + + // 更新停止按钮 + const stopButton = processItem.querySelector('.btn-stop'); + if (stopButton) { + if (!process.running) { + stopButton.remove(); + } + } else if (process.running) { + // 如果按钮不存在但进程仍在运行,添加停止按钮 + const buttonContainer = processItem.querySelector('div:last-child'); + if (buttonContainer) { + const newStopButton = document.createElement('button'); + newStopButton.className = 'btn-stop'; + newStopButton.onclick = () => stopProcess(process.id); + newStopButton.textContent = '停止训练'; + buttonContainer.appendChild(newStopButton); + } + } + + // 处理删除按钮 + const deleteButton = processItem.querySelector('.btn-delete'); + if (!process.running) { + // 非运行中状态应该有删除按钮 + if (!deleteButton) { + const buttonContainer = processItem.querySelector('div:last-child'); + if (buttonContainer) { + const newDeleteButton = document.createElement('button'); + newDeleteButton.className = 'btn-delete'; + newDeleteButton.onclick = () => deleteProcess(process.id); + newDeleteButton.textContent = '删除'; + buttonContainer.appendChild(newDeleteButton); + } + } + } else if (deleteButton) { + // 运行中状态不应该有删除按钮 + deleteButton.remove(); + } + + // 如果进程不再运行中,清除日志定时器 + if (!process.running && logTimers[process.id]) { + clearInterval(logTimers[process.id]); + delete logTimers[process.id]; + } +} + // 加载进程列表 function loadProcesses() { fetch('/processes') @@ -113,48 +254,186 @@ function loadProcesses() { return; } + // 按启动时间倒序排序,最新启动的进程显示在前面 + data.sort((a, b) => new Date(b.start_time) - new Date(a.start_time)); + + // 按训练类型分组 + const groupedProcesses = {}; data.forEach(process => { - const processItem = document.createElement('div'); - processItem.className = 'process-item'; - - let statusClass = ''; - // 根据后端返回的status字段设置状态类 - if (process.status === '运行中') { - statusClass = 'status-running'; - } else if (process.status === '手动停止') { - statusClass = 'status-manual-stop'; - } else if (process.status === '出错') { - statusClass = 'status-error'; - } else { - statusClass = 'status-completed'; + if (!groupedProcesses[process.train_type]) { + groupedProcesses[process.train_type] = []; } + groupedProcesses[process.train_type].push(process); + }); + + // 定义训练类型的显示顺序(可以根据需要调整) + const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo']; + + // 创建类型分组并添加收起/展开功能的辅助函数 + function createTypeGroupWithToggle(trainType, processes) { + // 创建类型分组容器 + const typeGroup = document.createElement('div'); + typeGroup.className = 'process-type-group'; - // 设置进程数据属性,用于后续检查状态 - processItem.dataset.processId = process.id; - processItem.dataset.processStatus = process.status; + // 创建标题容器(包含标题文本和切换按钮) + const titleContainer = document.createElement('div'); + titleContainer.className = 'process-type-header'; + titleContainer.dataset.expanded = 'true'; // 默认展开状态 - processItem.innerHTML = ` -
-
- ${process.train_type} - ${process.start_time} -
-
- ${process.status} -
-
-
- - - ${process.running ? `` : ''} -
- - `; + // 添加类型标题 + const typeTitle = document.createElement('h3'); + typeTitle.className = 'process-type-title'; + typeTitle.textContent = getTrainTypeDisplayName(trainType); - processList.appendChild(processItem); + // 添加切换按钮 + const toggleButton = document.createElement('button'); + toggleButton.className = 'toggle-btn'; + toggleButton.innerHTML = '▼'; // 向下箭头表示展开 + toggleButton.onclick = function(e) { + e.stopPropagation(); // 防止触发标题容器的点击事件 + toggleGroup(titleContainer); + }; + + // 将标题和按钮添加到容器 + titleContainer.appendChild(typeTitle); + titleContainer.appendChild(toggleButton); + + // 添加点击标题也可以切换展开/收起 + titleContainer.onclick = function() { + toggleGroup(titleContainer); + }; + + // 创建内容容器,用于容纳进程项 + const contentContainer = document.createElement('div'); + contentContainer.className = 'process-type-content'; + + // 添加该类型的所有进程 + processes.forEach(process => { + addProcessItemToGroup(contentContainer, process); + }); + + // 将标题容器和内容容器添加到类型分组 + typeGroup.appendChild(titleContainer); + typeGroup.appendChild(contentContainer); + + return typeGroup; + } + + // 切换分组展开/收起状态的函数 + function toggleGroup(headerElement) { + const isExpanded = headerElement.dataset.expanded === 'true'; + const contentContainer = headerElement.nextElementSibling; + const toggleButton = headerElement.querySelector('.toggle-btn'); + + if (isExpanded) { + // 收起分组 + headerElement.dataset.expanded = 'false'; + contentContainer.style.maxHeight = '0'; + contentContainer.style.overflow = 'hidden'; + toggleButton.innerHTML = '▶'; // 向右箭头表示收起 + } else { + // 展开分组前先重置样式 + contentContainer.style.overflow = 'hidden'; // 确保计算高度准确 + contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度 + + // 获取真实高度 + const actualHeight = contentContainer.scrollHeight; + + // 然后先设置为0,准备动画 + contentContainer.style.maxHeight = '0'; + + // 强制重排 + contentContainer.offsetHeight; + + // 展开分组 + headerElement.dataset.expanded = 'true'; + contentContainer.style.maxHeight = actualHeight + 'px'; + + setTimeout(() => { + // 动画完成后,设置为真实高度并允许溢出 + contentContainer.style.maxHeight = 'none'; + contentContainer.style.overflow = 'visible'; + }, 300); // 动画完成后显示溢出内容 + + toggleButton.innerHTML = '▼'; // 向下箭头表示展开 + } + } + + // 首先显示有明确顺序的训练类型 + trainTypeOrder.forEach(trainType => { + if (groupedProcesses[trainType]) { + const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]); + processList.appendChild(typeGroup); + + // 从分组中移除已处理的类型 + delete groupedProcesses[trainType]; + } + }); + + // 显示剩余的训练类型(不在预定义顺序中的) + Object.keys(groupedProcesses).forEach(trainType => { + const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]); + processList.appendChild(typeGroup); }); }); } +// 获取训练类型的显示名称 +function getTrainTypeDisplayName(trainType) { + const typeNames = { + 'pretrain': '预训练 (Pretrain)', + 'sft': '全参数监督微调 (SFT - Full)', + 'lora': 'LoRA监督微调 (SFT - Lora)', + 'dpo': '直接偏好优化 (RL - DPO)' + }; + return typeNames[trainType] || trainType; +} + +// 添加进程项到分组 +function addProcessItemToGroup(parentElement, process) { + const processItem = document.createElement('div'); + processItem.className = 'process-item'; + + let statusClass = ''; + // 根据后端返回的status字段设置状态类 + if (process.status === '运行中') { + statusClass = 'status-running'; + } else if (process.status === '手动停止') { + statusClass = 'status-manual-stop'; + } else if (process.status === '出错') { + statusClass = 'status-error'; + } else { + statusClass = 'status-completed'; + } + + // 设置进程数据属性,用于后续检查状态 + processItem.dataset.processId = process.id; + processItem.dataset.processStatus = process.status; + + // 检查是否显示删除按钮(对于非运行中的进程) + const showDeleteButton = !process.running; + + processItem.innerHTML = ` +
+
+ ${process.start_time} +
+
+ ${process.status} +
+
+
+ + + ${process.running ? `` : ''} + ${showDeleteButton ? `` : ''} +
+ + `; + + parentElement.appendChild(processItem); +} + // 存储训练日志定时器的ID let logTimers = {}; @@ -250,16 +529,266 @@ function loadLogContent(processId, logsContainer) { }); } +// 显示右上角消息弹窗 +function showNotification(message, type = 'success') { + // 创建通知元素 + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.textContent = message; + + // 添加到页面 + document.body.appendChild(notification); + + // 显示动画 + setTimeout(() => { + notification.classList.add('show'); + }, 10); + + // 自动关闭 + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + document.body.removeChild(notification); + }, 300); + }, 3000); +} + +// 自定义确认对话框 +function showConfirmDialog(message, onConfirm, onCancel = null) { + // 检查是否已存在对话框,如果有则移除 + const existingDialog = document.querySelector('.custom-dialog'); + if (existingDialog) { + document.body.removeChild(existingDialog); + } + + // 创建对话框遮罩 + const dialogOverlay = document.createElement('div'); + dialogOverlay.className = 'dialog-overlay'; + + // 创建对话框容器 + const dialogContainer = document.createElement('div'); + dialogContainer.className = 'custom-dialog'; + + // 设置对话框内容 + dialogContainer.innerHTML = ` +
+
${message}
+
+ + +
+
+ `; + + // 添加到页面 + dialogOverlay.appendChild(dialogContainer); + document.body.appendChild(dialogOverlay); + + // 添加动画类 + setTimeout(() => { + dialogOverlay.classList.add('show'); + dialogContainer.classList.add('show'); + }, 10); + + // 绑定确认按钮事件 + const confirmBtn = dialogContainer.querySelector('.dialog-confirm'); + confirmBtn.addEventListener('click', () => { + if (onConfirm) onConfirm(); + closeDialog(dialogOverlay); + }); + + // 绑定取消按钮事件 + const cancelBtn = dialogContainer.querySelector('.dialog-cancel'); + cancelBtn.addEventListener('click', () => { + if (onCancel) onCancel(); + closeDialog(dialogOverlay); + }); + + // 点击遮罩层也可以取消 + dialogOverlay.addEventListener('click', (e) => { + if (e.target === dialogOverlay) { + if (onCancel) onCancel(); + closeDialog(dialogOverlay); + } + }); +} + +// 关闭对话框函数 +function closeDialog(dialogOverlay) { + dialogOverlay.classList.remove('show'); + const dialogContainer = dialogOverlay.querySelector('.custom-dialog'); + dialogContainer.classList.remove('show'); + + setTimeout(() => { + if (dialogOverlay.parentNode) { + document.body.removeChild(dialogOverlay); + } + }, 300); +} + +// 删除进程 +function deleteProcess(processId) { + showConfirmDialog( + '确定要删除这个训练进程吗?此操作不可恢复。', + () => { + // 确认删除 + fetch(`/delete/${processId}`, { + method: 'POST' + }) + .then(response => { + // 检查响应状态 + if (!response.ok) { + throw new Error('删除请求失败'); + } + return response.json().catch(() => ({})); // 即使没有JSON响应也继续 + }) + .then(() => { + // 从UI中移除进程项 + const processItem = document.querySelector(`[data-process-id="${processId}"]`); + if (processItem) { + // 添加移除动画 + processItem.style.transition = 'opacity 0.3s, transform 0.3s'; + processItem.style.opacity = '0'; + processItem.style.transform = 'translateX(-20px)'; + + // 动画完成后移除 + setTimeout(() => { + // 获取内容容器和类型分组 + const contentContainer = processItem.closest('.process-type-content'); + const typeGroup = contentContainer ? contentContainer.closest('.process-type-group') : null; + + if (processItem.parentNode) { + processItem.parentNode.removeChild(processItem); + } + + // 检查内容容器中是否还有进程项 + if (contentContainer) { + const remainingProcessesInGroup = contentContainer.querySelectorAll('.process-item'); + + // 如果内容容器中没有进程项了,移除整个分组 + if (remainingProcessesInGroup.length === 0 && typeGroup) { + setTimeout(() => { + // 添加分组移除动画 + typeGroup.style.transition = 'opacity 0.3s, transform 0.3s'; + typeGroup.style.opacity = '0'; + typeGroup.style.transform = 'translateY(-10px)'; + + setTimeout(() => { + if (typeGroup.parentNode) { + typeGroup.parentNode.removeChild(typeGroup); + } + + // 检查是否还有进程项 + const remainingProcesses = document.querySelectorAll('.process-item'); + if (remainingProcesses.length === 0) { + const processList = document.getElementById('process-list'); + processList.innerHTML = '

暂无训练进程

'; + } + }, 300); + }, 100); + } else { + // 重新计算内容容器的高度(如果分组是展开的) + const headerElement = contentContainer.previousElementSibling; + if (headerElement && headerElement.dataset.expanded === 'true') { + contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px'; + } + + // 检查是否还有进程项 + const remainingProcesses = document.querySelectorAll('.process-item'); + if (remainingProcesses.length === 0) { + const processList = document.getElementById('process-list'); + processList.innerHTML = '

暂无训练进程

'; + } + } + } else { + // 检查是否还有进程项 + const remainingProcesses = document.querySelectorAll('.process-item'); + if (remainingProcesses.length === 0) { + const processList = document.getElementById('process-list'); + processList.innerHTML = '

暂无训练进程

'; + } + } + }, 300); + } + + // 清除日志定时器 + if (logTimers[processId]) { + clearInterval(logTimers[processId]); + delete logTimers[processId]; + } + + showNotification('训练进程已删除', 'success'); + }) + .catch(error => { + console.error('删除进程时出错:', error); + showNotification('删除进程失败,请刷新页面重试', 'error'); + }); + } + ); +} + // 停止进程 function stopProcess(processId) { - if (confirm('确定要停止这个训练进程吗?')) { - fetch(`/stop/${processId}`, { - method: 'POST' - }) - .then(() => { - loadProcesses(); - }); - } + showConfirmDialog( + '确定要停止这个训练进程吗?', + () => { + // 确认停止 + fetch(`/stop/${processId}`, { + method: 'POST' + }) + .then(() => { + // 立即给用户反馈,设置为手动停止状态 + const processItem = document.querySelector(`[data-process-id="${processId}"]`); + if (processItem) { + // 先设置为手动停止状态(即时反馈) + processItem.dataset.processStatus = '手动停止'; + const statusElement = processItem.querySelector('.process-status'); + if (statusElement) { + statusElement.classList.remove('status-running', 'status-error', 'status-completed'); + statusElement.classList.add('status-manual-stop'); + statusElement.textContent = '手动停止'; + } + + // 移除停止按钮 + const stopButton = processItem.querySelector('.btn-stop'); + if (stopButton) { + stopButton.remove(); + } + + // 清除日志定时器 + if (logTimers[processId]) { + clearInterval(logTimers[processId]); + delete logTimers[processId]; + } + } + + // 显示通知 + showNotification('训练进程已停止', 'info'); + + // 为了确保状态准确,仍然获取完整列表并更新单个进程 + fetch('/processes') + .then(response => response.json()) + .then(data => { + // 从完整列表中找到特定进程 + const updatedProcess = data.find(p => p.id === processId); + if (updatedProcess && processItem) { + // 使用updateProcessItem函数更新进程(如果需要的话) + updateProcessItem(processItem, updatedProcess); + } + }) + .catch(error => { + console.error('获取进程列表更新单个进程失败:', error); + }); + }) + .catch(() => { + showNotification('停止进程失败', 'error'); + }); + }, + () => { + // 取消停止,可选的回调 + showNotification('已取消停止操作', 'info'); + } + ); } // 表单提交处理 @@ -268,22 +797,46 @@ document.getElementById('train-form').addEventListener('submit', function(e) { const formData = new FormData(this); const data = Object.fromEntries(formData.entries()); - fetch('/train', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }) - .then(response => response.json()) - .then(result => { - if (result.success) { - alert('训练已开始!'); - openTab(event, 'processes'); - } else { - alert('训练启动失败:' + result.error); - } - }); + // 立即显示加载通知 + showNotification('正在启动训练...', 'info'); + + // 延迟1秒后发送请求,确保两个通知之间有间隔 + setTimeout(() => { + fetch('/train', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + throw new Error('网络响应异常'); + } + return response.json(); + }) + .then(result => { + if (result.success) { + showNotification('训练已开始!', 'success'); + + // 延迟1秒后切换到进程标签页,确保进程信息已更新 + setTimeout(() => { + // 获取进程标签按钮并触发点击 + const processTab = document.querySelector('.tab[onclick*="processes"]'); + if (processTab) { + processTab.click(); + } + }, 1000); + } else { + // 显示错误通知 + showNotification('训练启动失败:' + result.error, 'error'); + } + }) + .catch(error => { + // 显示等待通知 + showNotification('启动训练中,请耐心等待...', 'info'); + }); + }, 1000); // 1秒延迟确保两个通知之间有间隔 }); // 加载日志文件列表 @@ -302,12 +855,11 @@ function loadLogFiles() { // 按日期倒序排序 data.sort((a, b) => new Date(b.modified_time) - new Date(a.modified_time)); + // 按训练类型分组 + const groupedLogFiles = {}; data.forEach(logfile => { - const fileItem = document.createElement('div'); - fileItem.className = 'process-item'; - // 从文件名提取训练类型 - let trainType = '未知'; + let trainType = '自定义训练'; if (logfile.filename.includes('train_pretrain_')) { trainType = 'pretrain'; } else if (logfile.filename.includes('train_sft_')) { @@ -323,43 +875,305 @@ function loadLogFiles() { } else if (logfile.filename.includes('train_spo_')) { trainType = 'spo'; } - - fileItem.innerHTML = ` -
-
- ${trainType} - ${logfile.filename} -
-
- 已保存 - ${logfile.modified_time} -
-
-
- -
- - `; + // 为日志文件添加训练类型信息 + logfile.train_type = trainType; - logfilesList.appendChild(fileItem); + if (!groupedLogFiles[trainType]) { + groupedLogFiles[trainType] = []; + } + groupedLogFiles[trainType].push(logfile); + }); + + // 定义训练类型的显示顺序(与进程显示顺序保持一致) + const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo', 'ppo', 'grpo', 'spo', '未知']; + + // 创建类型分组并添加收起/展开功能的辅助函数 + function createTypeGroupWithToggle(trainType, logfiles) { + // 创建类型分组容器 + const typeGroup = document.createElement('div'); + typeGroup.className = 'process-type-group'; + + // 创建标题容器(包含标题文本和切换按钮) + const titleContainer = document.createElement('div'); + titleContainer.className = 'process-type-header'; + titleContainer.dataset.expanded = 'true'; // 默认展开状态 + + // 添加类型标题 + const typeTitle = document.createElement('h3'); + typeTitle.className = 'process-type-title'; + typeTitle.textContent = getTrainTypeDisplayName(trainType); + + // 添加切换按钮 + const toggleButton = document.createElement('button'); + toggleButton.className = 'toggle-btn'; + toggleButton.innerHTML = '▼'; // 向下箭头表示展开 + toggleButton.onclick = function(e) { + e.stopPropagation(); // 防止触发标题容器的点击事件 + toggleGroup(titleContainer); + }; + + // 将标题和按钮添加到容器 + titleContainer.appendChild(typeTitle); + titleContainer.appendChild(toggleButton); + + // 添加点击标题也可以切换展开/收起 + titleContainer.onclick = function() { + toggleGroup(titleContainer); + }; + + // 创建内容容器,用于容纳日志文件项 + const contentContainer = document.createElement('div'); + contentContainer.className = 'process-type-content'; + + // 添加该类型的所有日志文件 + logfiles.forEach(logfile => { + addLogFileItemToGroup(contentContainer, logfile); + }); + + // 将标题容器和内容容器添加到类型分组 + typeGroup.appendChild(titleContainer); + typeGroup.appendChild(contentContainer); + + return typeGroup; + } + + // 切换分组展开/收起状态的函数 + function toggleGroup(headerElement) { + const isExpanded = headerElement.dataset.expanded === 'true'; + const contentContainer = headerElement.nextElementSibling; + const toggleButton = headerElement.querySelector('.toggle-btn'); + + if (isExpanded) { + // 收起分组 + headerElement.dataset.expanded = 'false'; + contentContainer.style.maxHeight = '0'; + contentContainer.style.overflow = 'hidden'; + toggleButton.innerHTML = '▶'; // 向右箭头表示收起 + } else { + // 展开分组前先重置样式 + contentContainer.style.overflow = 'hidden'; // 确保计算高度准确 + contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度 + + // 获取真实高度 + const actualHeight = contentContainer.scrollHeight; + + // 然后先设置为0,准备动画 + contentContainer.style.maxHeight = '0'; + + // 强制重排 + contentContainer.offsetHeight; + + // 展开分组 + headerElement.dataset.expanded = 'true'; + contentContainer.style.maxHeight = actualHeight + 'px'; + + setTimeout(() => { + // 动画完成后,设置为真实高度并允许溢出 + contentContainer.style.maxHeight = 'none'; + contentContainer.style.overflow = 'visible'; + }, 300); // 动画完成后显示溢出内容 + + toggleButton.innerHTML = '▼'; // 向下箭头表示展开 + } + } + + // 首先显示有明确顺序的训练类型 + trainTypeOrder.forEach(trainType => { + if (groupedLogFiles[trainType]) { + const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]); + logfilesList.appendChild(typeGroup); + + // 从分组中移除已处理的类型 + delete groupedLogFiles[trainType]; + } + }); + + // 显示剩余的训练类型(不在预定义顺序中的) + Object.keys(groupedLogFiles).forEach(trainType => { + const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]); + logfilesList.appendChild(typeGroup); }); }); } +// 添加日志文件项到分组 +function addLogFileItemToGroup(parentElement, logfile) { + const fileItem = document.createElement('div'); + fileItem.className = 'process-item'; + + fileItem.innerHTML = ` +
+
+ ${logfile.filename} +
+
+ 已保存 + ${logfile.modified_time} +
+
+
+ + +
+ + `; + + parentElement.appendChild(fileItem); +} + +// 删除日志文件 +function deleteLogFile(filename, button) { + // 显示确认对话框 + showConfirmDialog( + `确定要删除日志文件 "${filename}" 吗?此操作无法恢复。`, + function() { + // 用户确认删除 + const processItem = button.closest('.process-item'); + const contentContainer = processItem.closest('.process-type-content'); + const typeGroup = contentContainer.closest('.process-type-group'); + + // 显示加载状态 + const originalText = button.textContent; + button.textContent = '删除中...'; + button.disabled = true; + + // 发送删除请求到服务器 + fetch(`/delete-logfile/${encodeURIComponent(filename)}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('删除失败'); + } + return response.json(); + }) + .then(data => { + if (data.success) { + // 从UI中移除日志项 + processItem.remove(); + + // 检查是否需要移除整个分组 + if (contentContainer.children.length === 0) { + typeGroup.remove(); + } else { + // 更新内容容器的高度 + const headerElement = contentContainer.previousElementSibling; + if (headerElement && headerElement.dataset.expanded === 'true') { + // 临时设置为none以获取真实高度 + contentContainer.style.maxHeight = 'none'; + const actualHeight = contentContainer.scrollHeight; + contentContainer.style.maxHeight = actualHeight + 'px'; + } + } + + // 显示成功通知 + showNotification(`日志文件 "${filename}" 已成功删除`); + } else { + throw new Error(data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除日志失败:', error); + showNotification(`删除失败: ${error.message}`, 'error'); + // 恢复按钮状态 + button.textContent = originalText; + button.disabled = false; + }); + }, + function() { + // 用户取消删除,不执行任何操作 + } + ); +} + // 查看日志文件内容 function viewLogFile(filename, button) { - const safeFilename = filename.replace(/\./g, '-'); - const logContainer = button.closest('.process-item').querySelector(`#log-content-${safeFilename}`); - logContainer.classList.toggle('hidden'); + // 生成安全的文件名,用于元素ID + const safeFilename = filename.replace(/[^a-zA-Z0-9_.-]/g, '_'); + const idSafeFilename = safeFilename.replace(/\./g, '-'); - if (!logContainer.classList.contains('hidden') && logContainer.textContent === '') { - logContainer.textContent = '加载日志中...'; + // 查找对应的日志容器 + const processItem = button.closest('.process-item'); + const logContainer = processItem.querySelector(`#log-content-${idSafeFilename}`); + + // 获取内容容器和头部元素 + const contentContainer = processItem.closest('.process-type-content'); + const headerElement = contentContainer ? contentContainer.previousElementSibling : null; + + // 确保日志所在的分组是展开状态 + if (contentContainer && headerElement && headerElement.dataset.expanded !== 'true') { + // 如果分组是折叠的,先展开分组 + toggleGroup(headerElement); + } + + // 切换日志容器的显示状态 + if (logContainer.classList.contains('hidden')) { + logContainer.classList.remove('hidden'); - fetch(`/logfile-content/${encodeURIComponent(filename)}`) - .then(response => response.text()) - .then(logs => { - logContainer.textContent = logs; - logContainer.scrollTop = 0; - }); + // 如果是首次加载,获取日志内容 + if (logContainer.textContent.trim() === '') { + logContainer.textContent = '加载中...'; + + fetch(`/logfile-content/${encodeURIComponent(filename)}`) + .then(response => { + if (!response.ok) { + throw new Error('获取日志失败'); + } + return response.text(); + }) + .then(logs => { + // 保留空白格式,使用textContent而不是innerHTML + logContainer.textContent = logs; + logContainer.scrollTop = 0; + + // 更新内容容器的高度 + updateContentContainerHeight(); + }) + .catch(error => { + console.error('获取日志失败:', error); + logContainer.innerHTML = `

获取日志失败: ${error.message}

`; + + // 更新内容容器的高度 + updateContentContainerHeight(); + }); + } else { + // 如果不是首次加载,直接更新内容容器的高度 + updateContentContainerHeight(); + } + } else { + logContainer.classList.add('hidden'); + + // 更新内容容器的高度 + updateContentContainerHeight(); + } + + // 辅助函数:更新内容容器的高度 + function updateContentContainerHeight() { + if (contentContainer && headerElement && headerElement.dataset.expanded === 'true') { + // 保存当前的maxHeight设置 + const currentMaxHeight = contentContainer.style.maxHeight; + + // 暂时设置为none以获取真实高度 + contentContainer.style.maxHeight = 'none'; + + // 获取真实高度 + const actualHeight = contentContainer.scrollHeight; + + // 检查是否需要重新设置高度 + if (currentMaxHeight === 'none' || parseInt(currentMaxHeight) !== actualHeight) { + contentContainer.style.maxHeight = actualHeight + 'px'; + + // 短暂延迟后再设置为none,确保高度变化正确应用 + setTimeout(() => { + if (headerElement.dataset.expanded === 'true') { + contentContainer.style.maxHeight = 'none'; + } + }, 300); + } else { + // 如果高度没有变化,恢复之前的设置 + contentContainer.style.maxHeight = currentMaxHeight; + } + } } } \ No newline at end of file diff --git a/scripts/templates/index.html b/scripts/templates/index.html index 63cc1a8..7d5b342 100644 --- a/scripts/templates/index.html +++ b/scripts/templates/index.html @@ -7,7 +7,10 @@ -

MiniMind Training Lab

+
+ +

MiniMind Training Lab

+
@@ -26,10 +29,10 @@
diff --git a/scripts/train_web_ui.py b/scripts/train_web_ui.py index ee80e3f..aa5c3b7 100644 --- a/scripts/train_web_ui.py +++ b/scripts/train_web_ui.py @@ -238,6 +238,28 @@ def get_logfile_content(filename): return f'读取日志失败: {str(e)}' return '日志文件不存在' +@app.route('/delete-logfile/', methods=['DELETE']) +def delete_logfile(filename): + # 获取脚本所在目录的绝对路径 + script_dir = os.path.dirname(os.path.abspath(__file__)) + # 构建logfile目录的绝对路径 + log_dir = os.path.join(script_dir, '../logfile') + log_dir = os.path.abspath(log_dir) + + # 安全检查:防止路径遍历攻击 + if '..' in filename or '/' in filename or '\\' in filename: + return jsonify({'success': False, 'message': '非法的文件名'}) + + log_file = os.path.join(log_dir, filename) + if os.path.exists(log_file) and os.path.isfile(log_file): + try: + os.remove(log_file) + return jsonify({'success': True, 'message': '日志文件删除成功'}) + except Exception as e: + print(f"删除日志文件失败: {str(e)}") + return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}) + return jsonify({'success': False, 'message': '日志文件不存在'}) + @app.route('/stop/', methods=['POST']) def stop(process_id): @@ -260,5 +282,40 @@ def stop(process_id): return jsonify({'success': True}) return jsonify({'success': False}) +@app.route('/delete/', methods=['POST']) +def delete(process_id): + if process_id in training_processes: + # 确保进程已经停止 + if training_processes[process_id]['running']: + # 如果进程还在运行,先停止它 + try: + process = training_processes[process_id]['process'] + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + except Exception as e: + print(f"停止进程失败: {str(e)}") + + # 从进程字典中删除 + del training_processes[process_id] + + # 可选:删除对应的日志文件 + try: + script_dir = os.path.dirname(os.path.abspath(__file__)) + log_dir = os.path.join(script_dir, '../logfile') + log_dir = os.path.abspath(log_dir) + + if os.path.exists(log_dir): + for filename in os.listdir(log_dir): + if filename.endswith(f'{process_id}.log'): + os.remove(os.path.join(log_dir, filename)) + except Exception as e: + print(f"删除日志文件失败: {str(e)}") + + return jsonify({'success': True}) + return jsonify({'success': False}) + if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file