refactor: 训练日志组件化与 Mock 数据增强

拆分 TrainingTaskOverview 组件与 trainingLogModel 状态模型,TrainingLogView 大幅瘦身;Mock 新增按文件路由的训练日志内容与更真实的 GPU 进程占用数据,adapter 类型收敛为 AxiosAdapter,配套新增 mock 内容回归脚本。
This commit is contained in:
caoxiaozhu
2026-07-13 15:29:49 +08:00
parent e580ec4791
commit e212de1693
7 changed files with 1473 additions and 914 deletions

View File

@@ -3,7 +3,7 @@
* 拦截所有 API 请求并返回 mock 数据
* 通过 URL + method 路由到对应的 mock 响应
*/
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import type { AxiosAdapter, AxiosInstance, AxiosRequestConfig } from 'axios'
import {
mockLoginOk,
mockHealth,
@@ -21,6 +21,7 @@ import {
mockLogFiles,
mockTrainingLogFiles,
mockLogContent,
mockTrainingLogContents,
} from './data'
import {
activateDatasetVersion,
@@ -164,7 +165,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/dataset-manage\/([^/]+)$/)
if (m && method === 'get') {
const found = mockDatasets.find((x) => String(x.id) === m[1])
const datasetId = m[1]
const found = mockDatasets.find((x) => String(x.id) === datasetId)
return found ? ok(found) : fail('数据集不存在', 404)
}
if (m && (method === 'put' || method === 'delete')) {
@@ -261,7 +263,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
if (m && method === 'get') {
const task = mockFineTuneList.find((t) => String(t.id) === m[1])
const taskId = m[1]
const task = mockFineTuneList.find((t) => String(t.id) === taskId)
if (!task) return fail('任务不存在', 404)
if (task.status === 'running') {
return ok({
@@ -276,7 +279,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/fine-tune\/([^/]+)$/)
if (m && method === 'get') {
const found = mockFineTuneList.find((x) => String(x.id) === m[1])
const taskId = m[1]
const found = mockFineTuneList.find((x) => String(x.id) === taskId)
return found ? ok(found) : fail('任务不存在', 404)
}
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
@@ -296,7 +300,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/model-compare\/([^/]+)$/)
if (m && method === 'get') {
const found = mockCompareList.find((x) => String(x.id) === m[1])
const compareId = m[1]
const found = mockCompareList.find((x) => String(x.id) === compareId)
return found ? ok(found) : fail('任务不存在', 404)
}
if (m && method === 'delete') return ok({ deleted: m[1] })
@@ -363,7 +368,14 @@ async function handleMock(config: AxiosRequestConfig) {
if (url === '/log-files' && method === 'get') return ok(mockLogFiles)
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
if (url === '/training-log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-content' && method === 'get') {
const file = String(params.file || '')
return ok(mockTrainingLogContents[file] || {
file,
size: '0 KB',
content: `[Mock] 未找到训练日志内容:${file}`,
})
}
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
@@ -380,12 +392,13 @@ function safeJSON(str: string) {
/** 给 axios instance 安装 mock adapter */
export function installMockAdapter(instance: AxiosInstance) {
instance.defaults.adapter = async (config: AxiosRequestConfig) => {
const adapter = async (config: AxiosRequestConfig) => {
try {
const response = await handleMock(config)
return response
} catch (e: any) {
return fail(e.message || 'Mock 错误', 500, config)
} catch (error: unknown) {
return fail(error instanceof Error ? error.message : 'Mock 错误', 500, config)
}
}
instance.defaults.adapter = adapter as AxiosAdapter
}

View File

@@ -58,37 +58,40 @@ export const mockSystemInfo: SystemInfo = {
id: 0,
uuid: 'GPU-MOCK-A800-00',
name: 'NVIDIA A800',
status: 'idle',
gpu_percent: 0,
memory_used_gb: 0,
status: 'busy',
gpu_percent: 74,
memory_used_gb: 41.8,
memory_total_gb: 80,
memory_percent: 0,
temperature: 32,
power_w: 38,
memory_percent: 52.3,
temperature: 63,
power_w: 286,
power_limit_w: 400,
fan_speed: 0,
fan_speed: 51,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [],
processes: [
{ pid: 28741, name: 'python', task_name: 'medical-cpt-001 / rank 0', user: 'trainer', memory_used_gb: 39.6 },
{ pid: 28768, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
id: 1,
uuid: 'GPU-MOCK-A800-01',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 28,
memory_used_gb: 22.5,
gpu_percent: 71,
memory_used_gb: 42.1,
memory_total_gb: 80,
memory_percent: 28.1,
temperature: 52,
power_w: 165,
memory_percent: 52.6,
temperature: 62,
power_w: 279,
power_limit_w: 400,
fan_speed: 32,
fan_speed: 49,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 18421, name: 'python', task_name: '指令微调任务', user: 'trainer', memory_used_gb: 18.6 },
{ pid: 18503, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 3.9 },
{ pid: 28742, name: 'python', task_name: 'medical-cpt-001 / rank 1', user: 'trainer', memory_used_gb: 39.9 },
{ pid: 28769, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
@@ -249,7 +252,7 @@ export const mockLocalModels = {
}
// ============ 数据集 ============
export const mockDatasets: DatasetItem[] = [
export const mockDatasets: DatasetItem[] = ([
{
id: 1,
name: '金融问答-训练集',
@@ -273,7 +276,7 @@ export const mockDatasets: DatasetItem[] = [
{ id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', task_id: 492015, size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' },
{ id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', task_id: 731948, size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' },
{ id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', task_id: 582012, size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' },
].map((dataset) => ({
] satisfies DatasetItem[]).map((dataset) => ({
...dataset,
files: dataset.files?.length
? dataset.files
@@ -339,12 +342,181 @@ export const mockDatasetPreviews: Record<string, string> = {
// ============ 训练任务 ============
export const mockFineTuneList: FineTuneTask[] = [
{ id: 103942, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' },
{ id: 349102, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' },
{ id: 849301, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' },
{ id: 593021, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' },
{ id: 201948, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' },
{ id: 940212, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' },
{
id: 103942,
name: 'finance-sft-001',
description: '金融领域 SFT 训练',
status: 'completed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v1',
auto_merge: true,
gpus: [0],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12345,
progress: 100,
train_duration: '2小时18分钟',
create_time: '2026-01-15T08:00:00Z',
},
{
id: 349102,
name: 'legal-sft-002',
description: '法律文书 SFT',
status: 'completed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 2,
output_model_name: 'qwen2.5-7b-legal-sft-v2',
auto_merge: true,
gpus: [1],
batch_size: 4,
learning_rate: 0.000015,
n_epochs: 4,
save_steps: 120,
lr_scheduler_type: 'linear',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 32,
lora_alpha: 64,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12350,
progress: 100,
train_duration: '1小时46分钟',
create_time: '2026-01-18T10:00:00Z',
},
{
id: 849301,
name: 'medical-cpt-001',
description: '医疗领域继续预训练',
status: 'running',
train_type: 'CPT',
train_method: 'lora',
template: 'qwen2_5',
base_model: 2,
train_dataset_id: 6,
output_model_name: 'qwen2.5-14b-medical-cpt-v1',
auto_merge: true,
gpus: [0, 1],
batch_size: 2,
learning_rate: 0.0001,
n_epochs: 3,
save_steps: 200,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 28741,
progress: 64,
train_duration: '36分钟',
create_time: '2026-07-13T06:40:00Z',
},
{
id: 593021,
name: 'service-dpo-001',
description: '客服对话偏好训练',
status: 'pending',
train_type: 'DPO',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 3,
output_model_name: 'qwen2.5-7b-service-dpo-v1',
auto_merge: true,
gpus: [2],
batch_size: 4,
learning_rate: 0.000005,
n_epochs: 2,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.1,
weight_decay: 0,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.1,
quantization_bit: 0,
progress: 0,
train_duration: '等待调度',
create_time: '2026-02-08T14:00:00Z',
},
{
id: 201948,
name: 'finance-sft-002',
description: '金融领域二轮微调',
status: 'failed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v2',
auto_merge: false,
gpus: [3],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 27654,
progress: 32,
train_duration: '18分钟',
create_time: '2026-02-10T11:00:00Z',
},
{
id: 940212,
name: 'general-sft-001',
description: '通用能力全参数微调',
status: 'completed',
train_type: 'SFT',
train_method: 'full',
template: 'llama3',
base_model: 3,
train_dataset_id: 3,
output_model_name: 'llama3-8b-general-sft-v1',
auto_merge: false,
gpus: [0, 2],
batch_size: 2,
learning_rate: 0.00001,
n_epochs: 2,
save_steps: 250,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.1,
process_id: 26318,
progress: 100,
train_duration: '3小时05分钟',
create_time: '2026-02-12T13:00:00Z',
},
]
// ============ 模型推理/对比 ============
@@ -549,11 +721,48 @@ export const mockLogFiles: LogFile[] = [
]
export const mockTrainingLogFiles: TrainingLogFile[] = [
{ file: 'medical-cpt-001_pid28741.log', name: 'medical-cpt-001', size: '6.8 MB', pid: 28741, date: '2026-07-13' },
{ file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' },
{ file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' },
{ file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' },
]
const medicalTrainingLogLines = [
'[2026-07-13 14:40:01] INFO: Launching distributed training with torchrun --nproc_per_node=2',
'[2026-07-13 14:40:02] INFO: Process rank: 0, world size: 2, device: cuda:0, distributed training: True',
'[2026-07-13 14:40:04] INFO: Loading tokenizer from /data/models/qwen2.5-14b-instruct',
'[2026-07-13 14:40:16] INFO: Loading checkpoint shards: 100% | 8/8 | 00:12',
'[2026-07-13 14:40:18] INFO: Loading dataset 医疗问答-训练集 (9,800 samples)',
'[2026-07-13 14:40:27] INFO: Tokenizing dataset: 100% | 9,800/9,800 | 00:09',
'[2026-07-13 14:40:28] INFO: LoRA config: rank=16, alpha=32, dropout=0.05, target_modules=q_proj,k_proj,v_proj,o_proj',
'[2026-07-13 14:40:29] INFO: Trainable params: 83,886,080 / 14,787,584,000 (0.5673%)',
'[2026-07-13 14:40:30] INFO: ***** Running training *****',
'[2026-07-13 14:40:30] INFO: Num examples = 9,800',
'[2026-07-13 14:40:30] INFO: Num Epochs = 3',
'[2026-07-13 14:40:30] INFO: Instantaneous batch size per device = 2',
'[2026-07-13 14:40:30] INFO: Total train batch size = 32',
'[2026-07-13 14:40:30] INFO: Gradient Accumulation steps = 8',
'[2026-07-13 14:40:30] INFO: Total optimization steps = 921',
'[2026-07-13 14:42:18] INFO: step=40 {\'loss\': 2.684, \'grad_norm\': 1.184, \'learning_rate\': 9.82e-05, \'epoch\': 0.13}',
'[2026-07-13 14:44:26] INFO: step=80 {\'loss\': 2.312, \'grad_norm\': 1.092, \'learning_rate\': 9.68e-05, \'epoch\': 0.26}',
'[2026-07-13 14:46:34] INFO: step=120 {\'loss\': 2.084, \'grad_norm\': 1.037, \'learning_rate\': 9.43e-05, \'epoch\': 0.39}',
'[2026-07-13 14:48:42] INFO: step=160 {\'loss\': 1.932, \'grad_norm\': 0.986, \'learning_rate\': 9.08e-05, \'epoch\': 0.52}',
'[2026-07-13 14:50:49] INFO: step=200 {\'loss\': 1.801, \'grad_norm\': 0.944, \'learning_rate\': 8.64e-05, \'epoch\': 0.65}',
'[2026-07-13 14:50:54] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-200',
'[2026-07-13 14:52:58] INFO: step=240 {\'loss\': 1.696, \'grad_norm\': 0.913, \'learning_rate\': 8.15e-05, \'epoch\': 0.78}',
'[2026-07-13 14:55:06] INFO: step=280 {\'loss\': 1.611, \'grad_norm\': 0.887, \'learning_rate\': 7.61e-05, \'epoch\': 0.91}',
'[2026-07-13 14:57:13] INFO: step=320 {\'loss\': 1.532, \'grad_norm\': 0.852, \'learning_rate\': 7.06e-05, \'epoch\': 1.04}',
'[2026-07-13 14:59:21] INFO: step=360 {\'loss\': 1.461, \'grad_norm\': 0.829, \'learning_rate\': 6.49e-05, \'epoch\': 1.17}',
'[2026-07-13 15:01:29] INFO: step=400 {\'loss\': 1.396, \'grad_norm\': 0.811, \'learning_rate\': 5.91e-05, \'epoch\': 1.30}',
'[2026-07-13 15:01:34] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-400',
'[2026-07-13 15:03:37] INFO: step=440 {\'loss\': 1.337, \'grad_norm\': 0.795, \'learning_rate\': 5.35e-05, \'epoch\': 1.43}',
'[2026-07-13 15:05:45] INFO: step=480 {\'loss\': 1.286, \'grad_norm\': 0.776, \'learning_rate\': 4.80e-05, \'epoch\': 1.56}',
'[2026-07-13 15:07:53] INFO: step=520 {\'loss\': 1.232, \'grad_norm\': 0.758, \'learning_rate\': 4.28e-05, \'epoch\': 1.69}',
'[2026-07-13 15:11:59] INFO: step=560 {\'loss\': 1.184, \'grad_norm\': 0.741, \'learning_rate\': 3.79e-05, \'epoch\': 1.82}',
'[2026-07-13 15:16:05] INFO: step=590 {\'loss\': 1.146, \'grad_norm\': 0.728, \'learning_rate\': 3.44e-05, \'epoch\': 1.92}',
'[2026-07-13 15:16:06] INFO: Training is running normally, estimated remaining time: 00:20:15',
]
const fakeLogLines = [
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
"[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)",
@@ -591,3 +800,16 @@ export const mockLogContent: LogContent = {
size: '2.3 MB',
content: fakeLogLines.join('\n'),
}
export const mockTrainingLogContents: Record<string, LogContent> = {
'medical-cpt-001_pid28741.log': {
file: 'medical-cpt-001_pid28741.log',
size: '6.8 MB',
content: medicalTrainingLogLines.join('\n'),
},
'qwen-ft-finance-001_pid12345.log': {
file: 'qwen-ft-finance-001_pid12345.log',
size: '4.5 MB',
content: fakeLogLines.join('\n'),
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import PageCard from '@/components/PageCard.vue'
import { DATASET_TYPE_MAP, STORAGE_MAP, TRAIN_METHOD_MAP, TRAIN_TYPE_MAP } from '@/constants'
import type { DatasetItem, FineTuneTask } from '@/types'
defineProps<{
task: FineTuneTask | null
dataset: DatasetItem | null
baseModelName: string
}>()
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<PageCard class="task-overview" title="任务信息" subtitle="模型、数据集与运行配置">
<div class="overview-layout" aria-label="训练任务信息">
<el-descriptions class="task-descriptions task-profile" :column="2" border>
<el-descriptions-item label="任务 ID">{{ task?.id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.create_time) }}</el-descriptions-item>
<el-descriptions-item label="基座模型">{{ baseModelName }}</el-descriptions-item>
<el-descriptions-item label="输出模型">{{ task?.output_model_name || '暂未生成' }}</el-descriptions-item>
<el-descriptions-item label="训练方式">
{{ task?.train_type ? (TRAIN_TYPE_MAP[task.train_type] || task.train_type) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练方法">
{{ task?.train_method ? (TRAIN_METHOD_MAP[task.train_method] || task.train_method) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练数据集" class-name="dataset-profile">
{{ dataset?.name || (task?.train_dataset_id ? '正在加载' : '未配置') }}
</el-descriptions-item>
<el-descriptions-item label="数据类型">
{{ dataset ? (DATASET_TYPE_MAP[dataset.type] || dataset.type || '未配置') : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="数据条数">
{{ dataset?.count?.toLocaleString('zh-CN') ?? '未配置' }}{{ dataset?.count != null ? ' 条' : '' }}
</el-descriptions-item>
<el-descriptions-item label="数据大小">{{ dataset?.size || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="训练开始时间" class-name="runtime-panel">
{{ formatDateTime(task?.create_time) }}
</el-descriptions-item>
<el-descriptions-item label="训练时长">{{ task?.train_duration || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="存储位置">
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type || '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="使用 GPU">
{{ task?.gpus?.length ? task.gpus.join('、') : '未配置' }}
</el-descriptions-item>
</el-descriptions>
</div>
</PageCard>
</template>
<style scoped lang="scss">
.task-overview {
margin-bottom: 0;
border: 1px solid #e4e7ed !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.overview-layout { width: 100%; }
.task-descriptions :deep(.el-descriptions__label) {
width: 132px;
color: #606266;
font-weight: 500;
background: #f7f8fa !important;
}
.task-descriptions :deep(.el-descriptions__content) {
color: #303133;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.task-descriptions :deep(.el-descriptions__cell) { padding: 12px 16px !important; }
@media (max-width: 700px) {
.task-descriptions :deep(.el-descriptions__body),
.task-descriptions :deep(.el-descriptions__table),
.task-descriptions :deep(.el-descriptions__tbody),
.task-descriptions :deep(.el-descriptions__row),
.task-descriptions :deep(.el-descriptions__cell) { display: block; width: 100%; box-sizing: border-box; }
.task-descriptions :deep(.el-descriptions__label) { width: 100%; border-bottom: 0 !important; }
}
</style>

View File

@@ -0,0 +1,165 @@
import type { EChartsOption } from 'echarts'
import type { FineTuneTask, TrainingLogFile } from '@/types'
export interface TrainingMetricData {
loss: number[]
gradNorm: number[]
lr: number[]
epoch: number[]
}
export interface TrainingSummary {
epoch: string
trainLoss: string
runtime: string
}
export interface ParsedTrainingLog {
metrics: TrainingMetricData
summary: TrainingSummary
}
const NUMBER_SOURCE = '[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?'
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extractNumber(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*:\\s*(${NUMBER_SOURCE})`, 'i'))
return match ? Number(match[1]) : undefined
}
function extractSummaryValue(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*(?:=|:)\\s*(${NUMBER_SOURCE})`, 'i'))
return match?.[1] || ''
}
/** 根据任务精确选择日志PID 优先,任务名仅作为明确兜底。 */
export function resolveTrainingLogFile(
files: TrainingLogFile[],
task: Pick<FineTuneTask, 'process_id' | 'name'>,
) {
const processId = task.process_id
if (processId != null) {
const pidMatch = files.find((file) => file.pid === processId)
if (pidMatch) return pidMatch
const pidPattern = new RegExp(`(?:^|[^0-9])(?:pid)?${processId}(?:[^0-9]|$)`, 'i')
const filenameMatch = files.find((file) => pidPattern.test(file.file))
if (filenameMatch) return filenameMatch
}
const taskName = task.name.trim()
if (!taskName) return undefined
return files.find((file) => file.name.includes(taskName) || file.file.includes(taskName))
}
/** 解析日志中的逐步指标。字段顺序和常见数值格式均不受限制。 */
export function parseTrainingMetrics(text: string): TrainingMetricData {
const metrics: TrainingMetricData = { loss: [], gradNorm: [], lr: [], epoch: [] }
const blocks = text.match(/\{[^{}\r\n]*\}/g) || []
for (const block of blocks) {
const loss = extractNumber(block, 'loss')
const gradNorm = extractNumber(block, 'grad_norm')
const learningRate = extractNumber(block, 'learning_rate')
const epoch = extractNumber(block, 'epoch')
if (loss == null || gradNorm == null || learningRate == null) continue
metrics.loss.push(loss)
metrics.gradNorm.push(gradNorm)
metrics.lr.push(learningRate)
if (epoch != null) metrics.epoch.push(epoch)
}
return metrics
}
/** 每次都返回新对象,日志截断或切换时不会残留上一轮汇总。 */
export function parseTrainingSummary(text: string): TrainingSummary {
const emptySummary: TrainingSummary = { epoch: '', trainLoss: '', runtime: '' }
const startMatch = /\*{5}\s*train metrics\s*\*{5}/i.exec(text)
if (!startMatch) return emptySummary
const tail = text.slice(startMatch.index + startMatch[0].length)
const endMatch = /\*{5}\s*train metrics end\s*\*{5}/i.exec(tail)
const body = endMatch ? tail.slice(0, endMatch.index) : tail
return {
epoch: extractSummaryValue(body, 'epoch'),
trainLoss: extractSummaryValue(body, 'train_loss'),
runtime: extractSummaryValue(body, 'train_runtime'),
}
}
export function parseTrainingLog(text: string): ParsedTrainingLog {
return {
metrics: parseTrainingMetrics(text),
summary: parseTrainingSummary(text),
}
}
/** 构建单条训练指标曲线。 */
export function buildMetricChartOption(
label: string,
data: number[],
color: string,
logScale = false,
): EChartsOption {
return {
grid: { top: 24, right: 20, bottom: 56, left: 56 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderWidth: 0,
textStyle: { color: '#fff', fontSize: 12 },
},
xAxis: {
type: 'category',
boundaryGap: false,
name: 'Step',
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
type: logScale ? 'log' : 'value',
name: label,
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
dataZoom: data.length > 30
? [
{ type: 'inside', start: 0, end: 100 },
{ type: 'slider', height: 16, bottom: 8, borderColor: 'transparent', fillerColor: 'rgba(79,70,229,0.08)', handleStyle: { color: '#4f46e5' } },
]
: [],
series: [
{
name: label,
type: 'line',
data,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}55` },
{ offset: 1, color: `${color}05` },
],
},
},
},
],
}
}