|
@@ -10,7 +10,7 @@ const MODEL_TYPES = [
|
|
|
|
|
|
|
|
const PEFT_METHODS = [
|
|
const PEFT_METHODS = [
|
|
|
{ value: 'lora', label: 'LoRA' },
|
|
{ value: 'lora', label: 'LoRA' },
|
|
|
- { value: 'qlora', label: 'QLoRA' },
|
|
|
|
|
|
|
+ { value: 'qlora', label: 'QLoRA (推荐)' },
|
|
|
{ value: 'ia3', label: 'IA3' },
|
|
{ value: 'ia3', label: 'IA3' },
|
|
|
{ value: 'adalora', label: 'AdaLoRA' },
|
|
{ value: 'adalora', label: 'AdaLoRA' },
|
|
|
{ value: 'prefix_tuning', label: 'Prefix Tuning' },
|
|
{ value: 'prefix_tuning', label: 'Prefix Tuning' },
|
|
@@ -21,16 +21,91 @@ const TASK_TYPES = [
|
|
|
{ value: 'dpo', label: 'DPO (直接偏好优化)' },
|
|
{ value: 'dpo', label: 'DPO (直接偏好优化)' },
|
|
|
{ value: 'orpo', label: 'ORPO (比值偏好优化)' },
|
|
{ value: 'orpo', label: 'ORPO (比值偏好优化)' },
|
|
|
{ value: 'kto', label: 'KTO (Kahneman-Tversky)' },
|
|
{ value: 'kto', label: 'KTO (Kahneman-Tversky)' },
|
|
|
- { value: 'rm', label: 'Reward Modeling' },
|
|
|
|
|
- { value: 'ppo', label: 'PPO (强化学习)' },
|
|
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
const DATASET_TEMPLATES = [
|
|
const DATASET_TEMPLATES = [
|
|
|
- { value: 'alpaca', label: 'Alpaca' },
|
|
|
|
|
- { value: 'sharegpt', label: 'ShareGPT' },
|
|
|
|
|
- { value: 'raw', label: 'Raw (直接字段)' },
|
|
|
|
|
|
|
+ { value: 'alpaca', label: 'Alpaca (instruction/input/output)' },
|
|
|
|
|
+ { value: 'sharegpt', label: 'ShareGPT (conversations)' },
|
|
|
|
|
+ { value: 'raw', label: 'Raw (text 字段)' },
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
|
|
+// --- 预设值常量 ---
|
|
|
|
|
+const EPOCH_PRESETS = [
|
|
|
|
|
+ { value: 1, label: '1 (快速验证)' },
|
|
|
|
|
+ { value: 2, label: '2' },
|
|
|
|
|
+ { value: 3, label: '3 (推荐)' },
|
|
|
|
|
+ { value: 5, label: '5' },
|
|
|
|
|
+ { value: 10, label: '10 (充分训练)' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const BATCH_SIZE_PRESETS = [
|
|
|
|
|
+ { value: 1, label: '1 (显存受限)' },
|
|
|
|
|
+ { value: 2, label: '2' },
|
|
|
|
|
+ { value: 4, label: '4 (推荐)' },
|
|
|
|
|
+ { value: 8, label: '8' },
|
|
|
|
|
+ { value: 16, label: '16' },
|
|
|
|
|
+ { value: 32, label: '32' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const LR_PRESETS = [
|
|
|
|
|
+ { value: '1e-3', label: '1e-3 (较大)' },
|
|
|
|
|
+ { value: '5e-4', label: '5e-4' },
|
|
|
|
|
+ { value: '2e-4', label: '2e-4 (推荐)' },
|
|
|
|
|
+ { value: '1e-4', label: '1e-4' },
|
|
|
|
|
+ { value: '5e-5', label: '5e-5 (较小)' },
|
|
|
|
|
+ { value: '1e-5', label: '1e-5' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const LORA_R_PRESETS = [
|
|
|
|
|
+ { value: 4, label: '4 (轻量)' },
|
|
|
|
|
+ { value: 8, label: '8' },
|
|
|
|
|
+ { value: 16, label: '16 (推荐)' },
|
|
|
|
|
+ { value: 32, label: '32' },
|
|
|
|
|
+ { value: 64, label: '64 (高精度)' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const SEQ_LEN_PRESETS = [
|
|
|
|
|
+ { value: 512, label: '512 (短文本)' },
|
|
|
|
|
+ { value: 1024, label: '1024' },
|
|
|
|
|
+ { value: 2048, label: '2048 (推荐)' },
|
|
|
|
|
+ { value: 4096, label: '4096 (长文本)' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const GRAD_ACC_PRESETS = [
|
|
|
|
|
+ { value: 1, label: '1 (无累积)' },
|
|
|
|
|
+ { value: 2, label: '2' },
|
|
|
|
|
+ { value: 4, label: '4 (推荐)' },
|
|
|
|
|
+ { value: 8, label: '8' },
|
|
|
|
|
+ { value: 16, label: '16' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// --- 通用 Select 组件 ---
|
|
|
|
|
+interface SelectProps {
|
|
|
|
|
+ options: { value: string | number; label: string }[]
|
|
|
|
|
+ value: string | number
|
|
|
|
|
+ onChange: (value: string | number) => void
|
|
|
|
|
+ placeholder?: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function Select({ options, value, onChange, placeholder }: SelectProps) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={value}
|
|
|
|
|
+ onChange={e => onChange(e.target.value)}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: '100%', padding: '6px 8px', borderRadius: 4,
|
|
|
|
|
+ border: '1px solid #d0d0d0', boxSizing: 'border-box',
|
|
|
|
|
+ fontSize: 13, background: '#fff', cursor: 'pointer',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {placeholder && <option value="" disabled>{placeholder}</option>}
|
|
|
|
|
+ {options.map(o => (
|
|
|
|
|
+ <option key={o.value} value={o.value}>{o.label}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// --- 可搜索下拉框组件 ---
|
|
// --- 可搜索下拉框组件 ---
|
|
|
interface SearchableSelectProps {
|
|
interface SearchableSelectProps {
|
|
|
options: { value: string; label: string; subtitle?: string }[]
|
|
options: { value: string; label: string; subtitle?: string }[]
|
|
@@ -46,7 +121,6 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
|
- // 点击外部关闭
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const handler = (e: MouseEvent) => {
|
|
const handler = (e: MouseEvent) => {
|
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
@@ -57,7 +131,6 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
|
}, [open])
|
|
}, [open])
|
|
|
|
|
|
|
|
- // 打开时自动聚焦输入框
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (open) {
|
|
if (open) {
|
|
|
setFilter('')
|
|
setFilter('')
|
|
@@ -65,9 +138,7 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
}
|
|
}
|
|
|
}, [open])
|
|
}, [open])
|
|
|
|
|
|
|
|
- // 当前选中的 label
|
|
|
|
|
const selectedLabel = options.find(o => o.value === value)?.label ?? ''
|
|
const selectedLabel = options.find(o => o.value === value)?.label ?? ''
|
|
|
-
|
|
|
|
|
const filtered = options.filter(o =>
|
|
const filtered = options.filter(o =>
|
|
|
o.label.toLowerCase().includes(filter.toLowerCase()) || o.value.toLowerCase().includes(filter.toLowerCase())
|
|
o.label.toLowerCase().includes(filter.toLowerCase()) || o.value.toLowerCase().includes(filter.toLowerCase())
|
|
|
)
|
|
)
|
|
@@ -89,46 +160,27 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div ref={wrapperRef} style={{ position: 'relative' }}>
|
|
<div ref={wrapperRef} style={{ position: 'relative' }}>
|
|
|
- {/* 显示框 */}
|
|
|
|
|
<div
|
|
<div
|
|
|
onClick={toggleOpen}
|
|
onClick={toggleOpen}
|
|
|
style={{
|
|
style={{
|
|
|
- padding: '6px 8px',
|
|
|
|
|
- borderRadius: 4,
|
|
|
|
|
- border: '1px solid #ccc',
|
|
|
|
|
- cursor: 'pointer',
|
|
|
|
|
- background: '#fff',
|
|
|
|
|
- minHeight: 32,
|
|
|
|
|
- display: 'flex',
|
|
|
|
|
- alignItems: 'center',
|
|
|
|
|
- justifyContent: 'space-between',
|
|
|
|
|
- fontSize: 14,
|
|
|
|
|
|
|
+ padding: '6px 8px', borderRadius: 4,
|
|
|
|
|
+ border: '1px solid #d0d0d0', cursor: 'pointer', background: '#fff',
|
|
|
|
|
+ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
|
|
|
+ fontSize: 13, transition: 'border-color 0.2s',
|
|
|
}}
|
|
}}
|
|
|
>
|
|
>
|
|
|
<span style={{ color: value ? '#333' : '#999' }}>
|
|
<span style={{ color: value ? '#333' : '#999' }}>
|
|
|
{value ? selectedLabel : placeholder}
|
|
{value ? selectedLabel : placeholder}
|
|
|
</span>
|
|
</span>
|
|
|
- <span style={{ color: '#999', fontSize: 12 }}>{open ? '▲' : '▼'}</span>
|
|
|
|
|
|
|
+ <span style={{ color: '#999', fontSize: 11 }}>{open ? '▲' : '▼'}</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* 下拉列表 */}
|
|
|
|
|
{open && (
|
|
{open && (
|
|
|
<div style={{
|
|
<div style={{
|
|
|
- position: 'absolute',
|
|
|
|
|
- top: '100%',
|
|
|
|
|
- left: 0,
|
|
|
|
|
- right: 0,
|
|
|
|
|
- background: '#fff',
|
|
|
|
|
- border: '1px solid #ccc',
|
|
|
|
|
- borderRadius: 4,
|
|
|
|
|
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
|
|
|
- zIndex: 1000,
|
|
|
|
|
- marginTop: 2,
|
|
|
|
|
- maxHeight: 240,
|
|
|
|
|
- display: 'flex',
|
|
|
|
|
- flexDirection: 'column',
|
|
|
|
|
|
|
+ position: 'absolute', top: '100%', left: 0, right: 0, background: '#fff',
|
|
|
|
|
+ border: '1px solid #d0d0d0', borderRadius: 4, boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
|
|
|
|
|
+ zIndex: 1000, marginTop: 2, maxHeight: 240, display: 'flex', flexDirection: 'column',
|
|
|
}}>
|
|
}}>
|
|
|
- {/* 搜索输入 */}
|
|
|
|
|
<input
|
|
<input
|
|
|
ref={inputRef}
|
|
ref={inputRef}
|
|
|
value={filter}
|
|
value={filter}
|
|
@@ -136,14 +188,10 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
onKeyDown={handleKeyDown}
|
|
onKeyDown={handleKeyDown}
|
|
|
placeholder="搜索..."
|
|
placeholder="搜索..."
|
|
|
style={{
|
|
style={{
|
|
|
- padding: '6px 8px',
|
|
|
|
|
- border: 'none',
|
|
|
|
|
- borderBottom: '1px solid #eee',
|
|
|
|
|
- outline: 'none',
|
|
|
|
|
- fontSize: 13,
|
|
|
|
|
|
|
+ padding: '6px 8px', border: 'none', borderBottom: '1px solid #eee',
|
|
|
|
|
+ outline: 'none', fontSize: 13,
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
- {/* 选项列表 */}
|
|
|
|
|
<div style={{ overflowY: 'auto', flex: 1 }}>
|
|
<div style={{ overflowY: 'auto', flex: 1 }}>
|
|
|
{loading && (
|
|
{loading && (
|
|
|
<div style={{ padding: '8px 12px', color: '#999', fontSize: 13 }}>加载中...</div>
|
|
<div style={{ padding: '8px 12px', color: '#999', fontSize: 13 }}>加载中...</div>
|
|
@@ -156,18 +204,13 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
key={opt.value}
|
|
key={opt.value}
|
|
|
onClick={() => handleSelect(opt.value)}
|
|
onClick={() => handleSelect(opt.value)}
|
|
|
style={{
|
|
style={{
|
|
|
- padding: '8px 12px',
|
|
|
|
|
- cursor: 'pointer',
|
|
|
|
|
|
|
+ padding: '8px 12px', cursor: 'pointer',
|
|
|
background: opt.value === value ? '#e94560' : 'transparent',
|
|
background: opt.value === value ? '#e94560' : 'transparent',
|
|
|
color: opt.value === value ? '#fff' : '#333',
|
|
color: opt.value === value ? '#fff' : '#333',
|
|
|
fontSize: 13,
|
|
fontSize: 13,
|
|
|
}}
|
|
}}
|
|
|
- onMouseEnter={e => {
|
|
|
|
|
- if (opt.value !== value) (e.currentTarget.style.background = '#f5f5f5')
|
|
|
|
|
- }}
|
|
|
|
|
- onMouseLeave={e => {
|
|
|
|
|
- if (opt.value !== value) (e.currentTarget.style.background = 'transparent')
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ onMouseEnter={e => { if (opt.value !== value) e.currentTarget.style.background = '#f5f5f5' }}
|
|
|
|
|
+ onMouseLeave={e => { if (opt.value !== value) e.currentTarget.style.background = 'transparent' }}
|
|
|
>
|
|
>
|
|
|
<div>{opt.label}</div>
|
|
<div>{opt.label}</div>
|
|
|
{opt.subtitle && (
|
|
{opt.subtitle && (
|
|
@@ -184,24 +227,86 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// --- 任务状态颜色 ---
|
|
|
|
|
+const statusColor = (status: string) => {
|
|
|
|
|
+ switch (status) {
|
|
|
|
|
+ case 'completed': return '#4caf50'
|
|
|
|
|
+ case 'failed': return '#e94560'
|
|
|
|
|
+ case 'training': return '#2196f3'
|
|
|
|
|
+ case 'pending': case 'queued': return '#ff9800'
|
|
|
|
|
+ case 'preprocessing': return '#9c27b0'
|
|
|
|
|
+ case 'cancelled': return '#999'
|
|
|
|
|
+ default: return '#666'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const statusLabel = (status: string) => {
|
|
|
|
|
+ switch (status) {
|
|
|
|
|
+ case 'completed': return '已完成'
|
|
|
|
|
+ case 'failed': return '失败'
|
|
|
|
|
+ case 'training': return '训练中'
|
|
|
|
|
+ case 'pending': return '等待中'
|
|
|
|
|
+ case 'queued': return '排队中'
|
|
|
|
|
+ case 'preprocessing': return '预处理'
|
|
|
|
|
+ case 'cancelled': return '已取消'
|
|
|
|
|
+ default: return status
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- 任务行(memo) ---
|
|
|
|
|
+const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr style={{ borderBottom: '1px solid #eee' }}>
|
|
|
|
|
+ <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
|
|
|
|
|
+ <td style={{ fontSize: 13 }}>{j.model_id}</td>
|
|
|
|
|
+ <td style={{ fontSize: 13, textTransform: 'uppercase' }}>{j.peft_method}</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block', padding: '2px 8px', borderRadius: 4, fontSize: 12,
|
|
|
|
|
+ background: statusColor(j.status) + '18', color: statusColor(j.status), fontWeight: 600,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {statusLabel(j.status)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
+ <div style={{ width: 100, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
|
|
|
|
|
+ <div style={{ width: `${j.progress}%`, height: '100%', background: statusColor(j.status), transition: 'width 0.3s' }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span style={{ fontSize: 12, color: '#666', minWidth: 40 }}>{j.progress.toFixed(1)}%</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td style={{ fontSize: 13, fontFamily: 'monospace' }}>{j.loss?.toFixed(4) ?? '-'}</td>
|
|
|
|
|
+ <td style={{ fontSize: 12 }}>{j.current_epoch}/{Math.max(j.total_steps > 0 ? Math.ceil(j.total_steps / (j.total_steps / (j.current_epoch || 1)) || 1, 1))} 轮</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
|
|
|
|
|
+ <button onClick={() => onCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer', fontSize: 12 }}>取消</button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
export function Training() {
|
|
export function Training() {
|
|
|
const [modelId, setModelId] = useState('')
|
|
const [modelId, setModelId] = useState('')
|
|
|
const [modelType, setModelType] = useState('text')
|
|
const [modelType, setModelType] = useState('text')
|
|
|
const [datasetId, setDatasetId] = useState('')
|
|
const [datasetId, setDatasetId] = useState('')
|
|
|
- const [peftMethod, setPeftMethod] = useState('lora')
|
|
|
|
|
|
|
+ const [peftMethod, setPeftMethod] = useState('qlora')
|
|
|
const [taskType, setTaskType] = useState('sft')
|
|
const [taskType, setTaskType] = useState('sft')
|
|
|
const [template, setTemplate] = useState('alpaca')
|
|
const [template, setTemplate] = useState('alpaca')
|
|
|
const [epochs, setEpochs] = useState(3)
|
|
const [epochs, setEpochs] = useState(3)
|
|
|
const [batchSize, setBatchSize] = useState(4)
|
|
const [batchSize, setBatchSize] = useState(4)
|
|
|
const [lr, setLr] = useState('2e-4')
|
|
const [lr, setLr] = useState('2e-4')
|
|
|
const [loraR, setLoraR] = useState(16)
|
|
const [loraR, setLoraR] = useState(16)
|
|
|
|
|
+ const [seqLen, setSeqLen] = useState(2048)
|
|
|
|
|
+ const [gradAcc, setGradAcc] = useState(4)
|
|
|
const [deepspeed, setDeepspeed] = useState(false)
|
|
const [deepspeed, setDeepspeed] = useState(false)
|
|
|
|
|
|
|
|
const [jobs, setJobs] = useState<TrainingJob[]>([])
|
|
const [jobs, setJobs] = useState<TrainingJob[]>([])
|
|
|
const [loading, setLoading] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+ const [createError, setCreateError] = useState('')
|
|
|
|
|
|
|
|
- // 模型和数据集列表
|
|
|
|
|
const [models, setModels] = useState<ModelInfo[]>([])
|
|
const [models, setModels] = useState<ModelInfo[]>([])
|
|
|
const [datasets, setDatasets] = useState<DatasetInfo[]>([])
|
|
const [datasets, setDatasets] = useState<DatasetInfo[]>([])
|
|
|
const [loadingOptions, setLoadingOptions] = useState(true)
|
|
const [loadingOptions, setLoadingOptions] = useState(true)
|
|
@@ -217,25 +322,19 @@ export function Training() {
|
|
|
}).finally(() => setLoadingOptions(false))
|
|
}).finally(() => setLoadingOptions(false))
|
|
|
}, [])
|
|
}, [])
|
|
|
|
|
|
|
|
- // 页面加载时获取选项
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- fetchOptions()
|
|
|
|
|
- }, [fetchOptions])
|
|
|
|
|
|
|
+ useEffect(() => { fetchOptions() }, [fetchOptions])
|
|
|
|
|
|
|
|
- // Connect WebSocket on mount
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
wsManager.connect()
|
|
wsManager.connect()
|
|
|
return () => wsManager.disconnect()
|
|
return () => wsManager.disconnect()
|
|
|
}, [])
|
|
}, [])
|
|
|
|
|
|
|
|
- // 将 jobs 存入 ref 用于比较,避免相同数据触发重渲染
|
|
|
|
|
const jobsRef = useRef<TrainingJob[]>([])
|
|
const jobsRef = useRef<TrainingJob[]>([])
|
|
|
|
|
|
|
|
const fetchJobs = () => {
|
|
const fetchJobs = () => {
|
|
|
setLoading(true)
|
|
setLoading(true)
|
|
|
api.training.list()
|
|
api.training.list()
|
|
|
.then(newJobs => {
|
|
.then(newJobs => {
|
|
|
- // 仅在数据真正变化时更新 state
|
|
|
|
|
const prev = jobsRef.current
|
|
const prev = jobsRef.current
|
|
|
if (JSON.stringify(prev) !== JSON.stringify(newJobs)) {
|
|
if (JSON.stringify(prev) !== JSON.stringify(newJobs)) {
|
|
|
setJobs(newJobs)
|
|
setJobs(newJobs)
|
|
@@ -260,194 +359,145 @@ export function Training() {
|
|
|
const handleCreate = () => {
|
|
const handleCreate = () => {
|
|
|
if (!modelId.trim() || !datasetId.trim()) return
|
|
if (!modelId.trim() || !datasetId.trim()) return
|
|
|
setSubmitting(true)
|
|
setSubmitting(true)
|
|
|
|
|
+ setCreateError('')
|
|
|
|
|
|
|
|
- // 乐观更新:立即在列表中添加一个 pending 任务
|
|
|
|
|
const tempId = 'temp-' + Date.now()
|
|
const tempId = 'temp-' + Date.now()
|
|
|
const tempJob: TrainingJob = {
|
|
const tempJob: TrainingJob = {
|
|
|
- id: tempId,
|
|
|
|
|
- model_id: modelId,
|
|
|
|
|
- model_type: modelType,
|
|
|
|
|
- peft_method: peftMethod,
|
|
|
|
|
- status: 'pending',
|
|
|
|
|
- progress: 0,
|
|
|
|
|
- loss: undefined,
|
|
|
|
|
- created_at: new Date().toISOString(),
|
|
|
|
|
- started_at: undefined,
|
|
|
|
|
- finished_at: undefined,
|
|
|
|
|
- error_message: undefined,
|
|
|
|
|
- adapter_path: undefined,
|
|
|
|
|
- current_epoch: 0,
|
|
|
|
|
- current_step: 0,
|
|
|
|
|
- total_steps: 0,
|
|
|
|
|
|
|
+ id: tempId, model_id: modelId, model_type: modelType,
|
|
|
|
|
+ peft_method: peftMethod, status: 'pending', progress: 0,
|
|
|
|
|
+ loss: undefined, created_at: new Date().toISOString(),
|
|
|
|
|
+ started_at: undefined, finished_at: undefined,
|
|
|
|
|
+ error_message: undefined, adapter_path: undefined,
|
|
|
|
|
+ current_epoch: 0, current_step: 0, total_steps: 0,
|
|
|
}
|
|
}
|
|
|
setJobs(prev => [tempJob, ...prev])
|
|
setJobs(prev => [tempJob, ...prev])
|
|
|
setLoading(false)
|
|
setLoading(false)
|
|
|
|
|
|
|
|
api.training.create({
|
|
api.training.create({
|
|
|
- model_id: modelId,
|
|
|
|
|
- model_type: modelType,
|
|
|
|
|
- dataset_id: datasetId,
|
|
|
|
|
- peft_method: peftMethod,
|
|
|
|
|
- task_type: taskType,
|
|
|
|
|
- dataset_template: template,
|
|
|
|
|
- epochs,
|
|
|
|
|
- batch_size: batchSize,
|
|
|
|
|
- learning_rate: parseFloat(lr),
|
|
|
|
|
- lora_r: loraR,
|
|
|
|
|
- lora_alpha: loraR * 2,
|
|
|
|
|
- deepspeed: deepspeed,
|
|
|
|
|
|
|
+ model_id: modelId, model_type: modelType, dataset_id: datasetId,
|
|
|
|
|
+ peft_method: peftMethod, task_type: taskType, dataset_template: template,
|
|
|
|
|
+ epochs, batch_size: batchSize, gradient_accumulation: gradAcc,
|
|
|
|
|
+ max_seq_length: seqLen, learning_rate: parseFloat(lr),
|
|
|
|
|
+ lora_r: loraR, lora_alpha: loraR * 2, deepspeed,
|
|
|
})
|
|
})
|
|
|
.then(() => {
|
|
.then(() => {
|
|
|
setModelId('')
|
|
setModelId('')
|
|
|
setDatasetId('')
|
|
setDatasetId('')
|
|
|
- // 用真实数据替换占位
|
|
|
|
|
setJobs(prev => prev.filter(j => j.id !== tempId))
|
|
setJobs(prev => prev.filter(j => j.id !== tempId))
|
|
|
fetchJobs()
|
|
fetchJobs()
|
|
|
fetchOptions()
|
|
fetchOptions()
|
|
|
})
|
|
})
|
|
|
- .catch(() => {
|
|
|
|
|
- // 失败时移除占位任务
|
|
|
|
|
|
|
+ .catch(err => {
|
|
|
setJobs(prev => prev.filter(j => j.id !== tempId))
|
|
setJobs(prev => prev.filter(j => j.id !== tempId))
|
|
|
|
|
+ setCreateError(err instanceof Error ? err.message : '创建失败')
|
|
|
})
|
|
})
|
|
|
.finally(() => setSubmitting(false))
|
|
.finally(() => setSubmitting(false))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const handleCancel = (id: string) => {
|
|
const handleCancel = (id: string) => {
|
|
|
- api.training.cancel(id)
|
|
|
|
|
- .then(() => fetchJobs())
|
|
|
|
|
- .catch(console.error)
|
|
|
|
|
|
|
+ api.training.cancel(id).then(() => fetchJobs()).catch(console.error)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// --- 任务状态颜色 ---
|
|
|
|
|
-const statusColor = (status: string) => {
|
|
|
|
|
- switch (status) {
|
|
|
|
|
- case 'completed': return '#4caf50'
|
|
|
|
|
- case 'failed': return '#e94560'
|
|
|
|
|
- case 'training': return '#2196f3'
|
|
|
|
|
- case 'pending': case 'queued': return '#ff9800'
|
|
|
|
|
- case 'preprocessing': return '#9c27b0'
|
|
|
|
|
- case 'cancelled': return '#999'
|
|
|
|
|
- default: return '#666'
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// --- 任务行(memo 避免父组件渲染时重渲染) ---
|
|
|
|
|
-const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
|
|
|
|
|
- return (
|
|
|
|
|
- <tr style={{ borderBottom: '1px solid #eee' }}>
|
|
|
|
|
- <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
|
|
|
|
|
- <td>{j.model_id}</td>
|
|
|
|
|
- <td>{j.peft_method}</td>
|
|
|
|
|
- <td style={{ fontSize: 12 }}>{j.status === 'preprocessing' ? '预处理' : j.status === 'training' ? '训练中' : j.status}</td>
|
|
|
|
|
- <td style={{ color: statusColor(j.status), fontWeight: 600 }}>{j.status}</td>
|
|
|
|
|
- <td>
|
|
|
|
|
- <div style={{ width: 120, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
|
|
|
|
|
- <div style={{ width: `${j.progress}%`, height: '100%', background: j.status === 'failed' ? '#e94560' : '#4caf50', transition: 'width 0.3s' }} />
|
|
|
|
|
- </div>
|
|
|
|
|
- <span style={{ fontSize: 11, color: '#999' }}>{j.progress.toFixed(1)}%</span>
|
|
|
|
|
- </td>
|
|
|
|
|
- <td>{j.loss?.toFixed(4) ?? '-'}</td>
|
|
|
|
|
- <td>
|
|
|
|
|
- {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
|
|
|
|
|
- <button onClick={() => onCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>取消</button>
|
|
|
|
|
- )}
|
|
|
|
|
- </td>
|
|
|
|
|
- </tr>
|
|
|
|
|
- )
|
|
|
|
|
-})
|
|
|
|
|
-
|
|
|
|
|
- // 构建下拉选项
|
|
|
|
|
const modelOptions = models.map(m => ({
|
|
const modelOptions = models.map(m => ({
|
|
|
- value: m.id,
|
|
|
|
|
- label: m.id,
|
|
|
|
|
- subtitle: `${m.model_type}${m.is_downloaded ? ' ✓' : ''}`,
|
|
|
|
|
|
|
+ value: m.id, label: m.id, subtitle: `${m.model_type}${m.is_downloaded ? ' ✓ 已下载' : ''}`,
|
|
|
}))
|
|
}))
|
|
|
|
|
|
|
|
const datasetOptions = datasets.map(d => ({
|
|
const datasetOptions = datasets.map(d => ({
|
|
|
- value: d.id,
|
|
|
|
|
- label: d.name,
|
|
|
|
|
- subtitle: `${d.format} · ${d.record_count} 条`,
|
|
|
|
|
|
|
+ value: d.id, label: d.name, subtitle: `${d.format} · ${d.record_count} 条`,
|
|
|
}))
|
|
}))
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div>
|
|
<div>
|
|
|
- <h1>训练任务</h1>
|
|
|
|
|
|
|
+ <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>训练任务</h1>
|
|
|
|
|
+ <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>创建和管理模型微调任务</p>
|
|
|
|
|
|
|
|
{/* Create form */}
|
|
{/* Create form */}
|
|
|
- <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
|
|
|
|
- <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>创建训练任务</h2>
|
|
|
|
|
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
|
|
|
|
|
|
+ <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
|
|
|
+ <h2 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>创建训练任务</h2>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 核心配置 */}
|
|
|
|
|
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>核心配置</div>
|
|
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型</label>
|
|
|
|
|
- <SearchableSelect
|
|
|
|
|
- options={modelOptions}
|
|
|
|
|
- value={modelId}
|
|
|
|
|
- onChange={setModelId}
|
|
|
|
|
- placeholder="选择模型"
|
|
|
|
|
- loading={loadingOptions}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>基础模型</label>
|
|
|
|
|
+ <SearchableSelect options={modelOptions} value={modelId} onChange={setModelId} placeholder="选择已下载的模型" loading={loadingOptions} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型类型</label>
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型类型</label>
|
|
|
- <select value={modelType} onChange={e => setModelType(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
|
|
|
|
|
- {MODEL_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <Select options={MODEL_TYPES} value={modelType} onChange={setModelType} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据集</label>
|
|
|
|
|
- <SearchableSelect
|
|
|
|
|
- options={datasetOptions}
|
|
|
|
|
- value={datasetId}
|
|
|
|
|
- onChange={setDatasetId}
|
|
|
|
|
- placeholder="选择数据集"
|
|
|
|
|
- loading={loadingOptions}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练数据集</label>
|
|
|
|
|
+ <SearchableSelect options={datasetOptions} value={datasetId} onChange={setDatasetId} placeholder="选择数据集" loading={loadingOptions} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练类型</label>
|
|
|
|
|
- <select value={taskType} onChange={e => setTaskType(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
|
|
|
|
|
- {TASK_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练方法</label>
|
|
|
|
|
+ <Select options={TASK_TYPES} value={taskType} onChange={setTaskType} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据模板</label>
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据模板</label>
|
|
|
- <select value={template} onChange={e => setTemplate(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
|
|
|
|
|
- {DATASET_TEMPLATES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <Select options={DATASET_TEMPLATES} value={template} onChange={setTemplate} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>PEFT 方法</label>
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>PEFT 方法</label>
|
|
|
- <select value={peftMethod} onChange={e => setPeftMethod(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
|
|
|
|
|
- {PEFT_METHODS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <Select options={PEFT_METHODS} value={peftMethod} onChange={setPeftMethod} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 训练超参 */}
|
|
|
|
|
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>训练超参数</div>
|
|
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练轮数 (Epochs)</label>
|
|
|
|
|
+ <Select options={EPOCH_PRESETS} value={epochs} onChange={setEpochs} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Epochs</label>
|
|
|
|
|
- <input type="number" value={epochs} onChange={e => setEpochs(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>批次大小 (Batch Size)</label>
|
|
|
|
|
+ <Select options={BATCH_SIZE_PRESETS} value={batchSize} onChange={setBatchSize} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Batch Size</label>
|
|
|
|
|
- <input type="number" value={batchSize} onChange={e => setBatchSize(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>梯度累积</label>
|
|
|
|
|
+ <Select options={GRAD_ACC_PRESETS} value={gradAcc} onChange={setGradAcc} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Learning Rate</label>
|
|
|
|
|
- <input value={lr} onChange={e => setLr(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>学习率</label>
|
|
|
|
|
+ <Select options={LR_PRESETS} value={lr} onChange={setLr} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>LoRA R</label>
|
|
|
|
|
- <input type="number" value={loraR} onChange={e => setLoraR(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>最大序列长度</label>
|
|
|
|
|
+ <Select options={SEQ_LEN_PRESETS} value={seqLen} onChange={setSeqLen} />
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
|
|
|
|
|
- <input type="checkbox" checked={deepspeed} onChange={e => setDeepspeed(e.target.checked)} />
|
|
|
|
|
- DeepSpeed 多 GPU
|
|
|
|
|
- </label>
|
|
|
|
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>LoRA Rank (R)</label>
|
|
|
|
|
+ <Select options={LORA_R_PRESETS} value={loraR} onChange={setLoraR} />
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 高级选项 */}
|
|
|
|
|
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>高级选项</div>
|
|
|
|
|
+ <div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 16 }}>
|
|
|
|
|
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
|
|
|
|
|
+ <input type="checkbox" checked={deepspeed} onChange={e => setDeepspeed(e.target.checked)} />
|
|
|
|
|
+ DeepSpeed ZeRO-2 (多 GPU)
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 错误提示 */}
|
|
|
|
|
+ {createError && (
|
|
|
|
|
+ <div style={{ marginBottom: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
|
|
|
|
|
+ {createError}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
<button
|
|
<button
|
|
|
onClick={handleCreate}
|
|
onClick={handleCreate}
|
|
|
- disabled={submitting}
|
|
|
|
|
- style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}
|
|
|
|
|
|
|
+ disabled={submitting || !modelId || !datasetId}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ marginTop: 8, padding: '10px 32px', borderRadius: 6, border: 'none',
|
|
|
|
|
+ background: '#e94560', color: '#fff', cursor: 'pointer',
|
|
|
|
|
+ opacity: (submitting || !modelId || !datasetId) ? 0.5 : 1,
|
|
|
|
|
+ fontSize: 14, fontWeight: 600,
|
|
|
|
|
+ }}
|
|
|
>
|
|
>
|
|
|
{submitting ? '创建中...' : '启动训练'}
|
|
{submitting ? '创建中...' : '启动训练'}
|
|
|
</button>
|
|
</button>
|
|
@@ -456,36 +506,42 @@ const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel:
|
|
|
{/* Job list */}
|
|
{/* Job list */}
|
|
|
<div style={{ marginTop: 24 }}>
|
|
<div style={{ marginTop: 24 }}>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
|
- <h2 style={{ margin: 0 }}>任务列表</h2>
|
|
|
|
|
- <button onClick={fetchJobs} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}>刷新</button>
|
|
|
|
|
|
|
+ <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>任务列表</h2>
|
|
|
|
|
+ <button onClick={fetchJobs} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d0d0d0', background: '#fff', cursor: 'pointer', fontSize: 12 }}>
|
|
|
|
|
+ 刷新
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {loading && <p style={{ color: '#999' }}>加载中...</p>}
|
|
|
|
|
|
|
+ {loading && <p style={{ color: '#999', fontSize: 13 }}>加载中...</p>}
|
|
|
|
|
|
|
|
{!loading && jobs.length === 0 && (
|
|
{!loading && jobs.length === 0 && (
|
|
|
- <p style={{ color: '#999', fontSize: 14 }}>暂无训练任务</p>
|
|
|
|
|
|
|
+ <div style={{ padding: 40, textAlign: 'center', color: '#999', fontSize: 14, background: '#fff', borderRadius: 8 }}>
|
|
|
|
|
+ 暂无训练任务,请先创建训练任务
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{!loading && jobs.length > 0 && (
|
|
{!loading && jobs.length > 0 && (
|
|
|
- <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
|
|
|
|
- <thead>
|
|
|
|
|
- <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
|
|
|
|
|
- <th style={{ padding: '8px 0' }}>任务 ID</th>
|
|
|
|
|
- <th>模型</th>
|
|
|
|
|
- <th>PEFT</th>
|
|
|
|
|
- <th>类型</th>
|
|
|
|
|
- <th>状态</th>
|
|
|
|
|
- <th>进度</th>
|
|
|
|
|
- <th>Loss</th>
|
|
|
|
|
- <th>操作</th>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </thead>
|
|
|
|
|
- <tbody>
|
|
|
|
|
- {jobs.map(j => (
|
|
|
|
|
- <JobRow key={j.id} j={j} onCancel={handleCancel} />
|
|
|
|
|
- ))}
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
|
|
+ <div style={{ background: '#fff', borderRadius: 8, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
|
|
|
|
|
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr style={{ background: '#fafafa', borderBottom: '2px solid #eee', textAlign: 'left' }}>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>任务 ID</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>模型</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>PEFT</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>状态</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>进度</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>Loss</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>轮次</th>
|
|
|
|
|
+ <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>操作</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {jobs.map(j => (
|
|
|
|
|
+ <JobRow key={j.id} j={j} onCancel={handleCancel} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|