Ver Fonte

优化前端界面

lxylxy123321 há 1 semana atrás
pai
commit
2d49aee91a

+ 61 - 19
frontend/src/pages/Deployment.tsx

@@ -1,42 +1,73 @@
-import { useState } from 'react'
-import api, { DeployResponse } from '../api/client'
+import { useState, useEffect } from 'react'
+import api, { DeployResponse, TrainingJob } from '../api/client'
+
+const EXPORT_FORMATS = [
+  { value: 'safetensors', label: 'SafeTensors (推荐)' },
+  { value: 'pytorch', label: 'PyTorch (.bin)' },
+  { value: 'gguf', label: 'GGUF (llama.cpp)' },
+]
 
 export function Deployment() {
+  const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobId, setJobId] = useState('')
   const [mergeWithBase, setMergeWithBase] = useState(false)
   const [exportFormat, setExportFormat] = useState('safetensors')
   const [running, setRunning] = useState(false)
   const [result, setResult] = useState<DeployResponse | null>(null)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    api.training.list()
+      .then(data => setJobs(data.filter(j => j.status === 'completed')))
+      .catch(() => setJobs([]))
+  }, [])
 
   const handleExport = () => {
     if (!jobId.trim()) return
     setRunning(true)
+    setError('')
+    setResult(null)
     api.deployment.export({
       job_id: jobId,
       merge_with_base: mergeWithBase,
       export_format: exportFormat,
     })
       .then(setResult)
-      .catch(console.error)
+      .catch(err => setError(err instanceof Error ? err.message : '导出失败'))
       .finally(() => setRunning(false))
   }
 
   return (
     <div>
-      <h1>部署</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型部署</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>导出训练好的模型用于生产部署</p>
 
-      <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 }}>导出 Adapter</h2>
+      <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 }}>导出 Adapter</h2>
         <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, alignItems: 'end' }}>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务 ID</label>
-            <input value={jobId} onChange={e => setJobId(e.target.value)} placeholder="任务 ID" 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
+              value={jobId}
+              onChange={e => setJobId(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              <option value="" disabled>选择已完成的训练任务</option>
+              {jobs.map(j => (
+                <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+              ))}
+            </select>
           </div>
           <div>
             <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>导出格式</label>
-            <select value={exportFormat} onChange={e => setExportFormat(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              <option value="safetensors">SafeTensors</option>
-              <option value="gguf">GGUF</option>
+            <select
+              value={exportFormat}
+              onChange={e => setExportFormat(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              {EXPORT_FORMATS.map(f => (
+                <option key={f.value} value={f.value}>{f.label}</option>
+              ))}
             </select>
           </div>
           <div>
@@ -46,22 +77,33 @@ export function Deployment() {
             </label>
           </div>
         </div>
+
+        {error && (
+          <div style={{ marginTop: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
+            {error}
+          </div>
+        )}
+
         <button
           onClick={handleExport}
-          disabled={running}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: running ? 0.6 : 1 }}
+          disabled={running || !jobId}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#e94560', color: '#fff', cursor: 'pointer',
+            opacity: (running || !jobId) ? 0.5 : 1, fontSize: 14, fontWeight: 600,
+          }}
         >
           {running ? '导出中...' : '开始导出'}
         </button>
       </div>
 
       {result && (
-        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <h3>导出状态</h3>
-          <p><strong>任务 ID:</strong> {result.job_id}</p>
-          <p><strong>状态:</strong> <span style={{ color: result.error ? '#e94560' : '#4caf50' }}>{result.status}</span></p>
-          {result.output_path && <p><strong>输出路径:</strong> {result.output_path}</p>}
-          {result.error && <p style={{ color: '#e94560' }}><strong>错误:</strong> {result.error}</p>}
+        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>导出状态</h3>
+          <p style={{ fontSize: 13 }}><strong>任务 ID:</strong> <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.job_id}</code></p>
+          <p style={{ fontSize: 13 }}><strong>状态:</strong> <span style={{ color: result.error ? '#e94560' : '#4caf50', fontWeight: 600 }}>{result.status}</span></p>
+          {result.output_path && <p style={{ fontSize: 13 }}><strong>输出路径:</strong> <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.output_path}</code></p>}
+          {result.error && <p style={{ color: '#e94560', fontSize: 13 }}><strong>错误:</strong> {result.error}</p>}
         </div>
       )}
     </div>

+ 67 - 22
frontend/src/pages/Evaluation.tsx

@@ -1,74 +1,119 @@
-import { useState } from 'react'
-import api, { EvalResult } from '../api/client'
+import { useState, useEffect } from 'react'
+import api, { EvalResult, TrainingJob } from '../api/client'
+
+const METRICS_PRESETS = [
+  { value: 'perplexity,loss', label: '困惑度 + Loss' },
+  { value: 'perplexity', label: '困惑度' },
+  { value: 'loss', label: 'Loss' },
+  { value: 'accuracy', label: '准确率' },
+]
 
 export function Evaluation() {
+  const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobId, setJobId] = useState('')
   const [metrics, setMetrics] = useState('perplexity,loss')
   const [running, setRunning] = useState(false)
   const [result, setResult] = useState<EvalResult | null>(null)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    api.training.list()
+      .then(data => setJobs(data.filter(j => j.status === 'completed')))
+      .catch(() => setJobs([]))
+  }, [])
 
   const handleRun = () => {
     if (!jobId.trim()) return
     setRunning(true)
+    setError('')
+    setResult(null)
     api.evaluation.run({
       job_id: jobId,
       metrics: metrics.split(',').map(s => s.trim()).filter(Boolean),
     })
-      .then(setResult)
-      .catch(console.error)
+      .then(res => setResult(res))
+      .catch(err => setError(err instanceof Error ? err.message : '评估失败'))
       .finally(() => setRunning(false))
   }
 
   return (
     <div>
-      <h1>评估</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型评估</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>对已完成的训练任务进行性能评估</p>
 
-      <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={{ 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={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务 ID</label>
-            <input value={jobId} onChange={e => setJobId(e.target.value)} placeholder="任务 ID" 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
+              value={jobId}
+              onChange={e => setJobId(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              <option value="" disabled>选择已完成的训练任务</option>
+              {jobs.map(j => (
+                <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+              ))}
+            </select>
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>评估指标 (逗号分隔)</label>
-            <input value={metrics} onChange={e => setMetrics(e.target.value)} placeholder="perplexity,loss" 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
+              value={metrics}
+              onChange={e => setMetrics(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              {METRICS_PRESETS.map(m => (
+                <option key={m.value} value={m.value}>{m.label}</option>
+              ))}
+            </select>
           </div>
         </div>
+
+        {error && (
+          <div style={{ marginTop: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
+            {error}
+          </div>
+        )}
+
         <button
           onClick={handleRun}
-          disabled={running}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: running ? 0.6 : 1 }}
+          disabled={running || !jobId}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#e94560', color: '#fff', cursor: 'pointer',
+            opacity: (running || !jobId) ? 0.5 : 1, fontSize: 14, fontWeight: 600,
+          }}
         >
           {running ? '评估中...' : '启动评估'}
         </button>
       </div>
 
       {result && (
-        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <h3>评估结果</h3>
-          <p><strong>评估 ID:</strong> {result.id}</p>
-          <p><strong>任务 ID:</strong> {result.job_id}</p>
+        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>评估结果</h3>
+          <p style={{ fontSize: 13, color: '#666' }}>评估 ID: <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.id}</code></p>
           {Object.keys(result.metrics).length > 0
             ? (
               <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, marginTop: 12 }}>
                 <thead>
                   <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
-                    <th style={{ padding: '8px 0' }}>指标</th>
-                    <th>值</th>
+                    <th style={{ padding: '8px 12px', fontSize: 12, color: '#666' }}>指标</th>
+                    <th style={{ padding: '8px 12px', fontSize: 12, color: '#666' }}>值</th>
                   </tr>
                 </thead>
                 <tbody>
                   {Object.entries(result.metrics).map(([k, v]) => (
                     <tr key={k} style={{ borderBottom: '1px solid #eee' }}>
-                      <td style={{ padding: '6px 0' }}>{k}</td>
-                      <td>{String(v)}</td>
+                      <td style={{ padding: '8px 12px' }}>{k}</td>
+                      <td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{String(v)}</td>
                     </tr>
                   ))}
                 </tbody>
               </table>
             )
-            : <p style={{ color: '#999', fontSize: 14 }}>评估结果为空(后端尚未返回数据)</p>
+            : <p style={{ color: '#999', fontSize: 13 }}>评估结果为空(后端尚未返回数据)</p>
           }
         </div>
       )}

+ 155 - 56
frontend/src/pages/Inference.tsx

@@ -1,6 +1,66 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, memo } from 'react'
 import api, { AdapterInfo } from '../api/client'
 
+// --- 预设值 ---
+const MAX_TOKEN_PRESETS = [
+  { value: 64, label: '64 (快速)' },
+  { value: 128, label: '128' },
+  { value: 256, label: '256 (推荐)' },
+  { value: 512, label: '512' },
+  { value: 1024, label: '1024' },
+  { value: 2048, label: '2048 (长输出)' },
+]
+
+const TEMP_PRESETS = [
+  { value: 0.1, label: '0.1 (确定性)' },
+  { value: 0.3, label: '0.3' },
+  { value: 0.5, label: '0.5' },
+  { value: 0.7, label: '0.7' },
+  { value: 0.8, label: '0.8 (推荐)' },
+  { value: 1.0, label: '1.0' },
+  { value: 1.5, label: '1.5 (高创意)' },
+]
+
+const TOP_P_PRESETS = [
+  { value: 0.5, label: '0.5 (严格)' },
+  { value: 0.8, label: '0.8' },
+  { value: 0.9, label: '0.9' },
+  { value: 0.95, label: '0.95 (推荐)' },
+  { value: 1.0, label: '1.0 (无限制)' },
+]
+
+const REP_PENALTY_PRESETS = [
+  { value: 1.0, label: '1.0 (无惩罚)' },
+  { value: 1.05, label: '1.05' },
+  { value: 1.1, label: '1.1 (推荐)' },
+  { value: 1.2, label: '1.2' },
+  { value: 1.5, label: '1.5 (强惩罚)' },
+]
+
+interface SelectProps {
+  options: { value: number | string; label: string }[]
+  value: number | string
+  onChange: (value: number | string) => void
+}
+
+function Select({ options, value, onChange }: 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',
+      }}
+    >
+      {options.map(o => (
+        <option key={o.value} value={o.value}>{o.label}</option>
+      ))}
+    </select>
+  )
+}
+
 export function Inference() {
   const [adapters, setAdapters] = useState<AdapterInfo[]>([])
   const [adapterId, setAdapterId] = useState('')
@@ -54,66 +114,77 @@ export function Inference() {
 
   return (
     <div>
-      <h1>模型推理</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型推理</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>使用训练好的 Adapter 进行文本生成</p>
 
       {/* Adapter selector */}
-      <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 }}>选择 Adapter</h2>
+      <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>选择 Adapter</h2>
         {adapters.length === 0 ? (
-          <p style={{ color: '#999', fontSize: 14 }}>暂无可用的 adapter,请先完成训练任务</p>
+          <div style={{ padding: 20, textAlign: 'center', color: '#999', fontSize: 13, background: '#fafafa', borderRadius: 6 }}>
+            暂无可用的 adapter,请先完成训练任务
+          </div>
         ) : (
           <select
             value={adapterId}
             onChange={e => setAdapterId(e.target.value)}
-            style={{ padding: '6px 12px', borderRadius: 4, border: '1px solid #ccc', width: '100%', maxWidth: 500 }}
+            style={{ padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', width: '100%', maxWidth: 500, fontSize: 13 }}
           >
             {adapters.map(a => (
-              <option key={a.id} value={a.id}>{a.id} (base: {a.base_model})</option>
+              <option key={a.id} value={a.id}>{a.id} — {a.base_model} ({a.peft_type})</option>
             ))}
           </select>
         )}
       </div>
 
       {/* Prompt input */}
-      <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={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>输入提示词</h2>
         <textarea
           value={prompt}
           onChange={e => setPrompt(e.target.value)}
           placeholder="输入你的问题或指令..."
           rows={4}
-          style={{ width: '100%', padding: 12, borderRadius: 4, border: '1px solid #ccc', fontSize: 14, boxSizing: 'border-box', resize: 'vertical' }}
+          style={{ width: '100%', padding: 12, borderRadius: 6, border: '1px solid #d0d0d0', fontSize: 14, boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.6 }}
         />
 
         {/* Generation params */}
-        <div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Max Tokens</label>
-            <input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min={1} max={4096} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Temperature</label>
-            <input type="number" value={temperature} onChange={e => setTemperature(Number(e.target.value))} min={0} max={2} step={0.1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+        <div style={{ marginTop: 16 }}>
+          <div style={{ fontSize: 13, fontWeight: 600, color: '#666', marginBottom: 8 }}>生成参数</div>
+          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>最大输出长度</label>
+              <Select options={MAX_TOKEN_PRESETS} value={maxTokens} onChange={setMaxTokens} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>Temperature</label>
+              <Select options={TEMP_PRESETS} value={temperature} onChange={setTemperature} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>Top P</label>
+              <Select options={TOP_P_PRESETS} value={topP} onChange={setTopP} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>重复惩罚</label>
+              <Select options={REP_PENALTY_PRESETS} value={repetitionPenalty} onChange={setRepetitionPenalty} />
+            </div>
           </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Top P</label>
-            <input type="number" value={topP} onChange={e => setTopP(Number(e.target.value))} min={0} max={1} step={0.05} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Repetition Penalty</label>
-            <input type="number" value={repetitionPenalty} onChange={e => setRepetitionPenalty(Number(e.target.value))} min={1} max={2} step={0.1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-        </div>
 
-        <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', marginTop: 12 }}>
-          <input type="checkbox" checked={doSample} onChange={e => setDoSample(e.target.checked)} />
-          启用采样 (关闭则为 greedy decoding)
-        </label>
+          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', marginTop: 12 }}>
+            <input type="checkbox" checked={doSample} onChange={e => setDoSample(e.target.checked)} />
+            启用随机采样 (关闭则为贪婪解码)
+          </label>
+        </div>
 
         <button
           onClick={handleGenerate}
-          disabled={generating || !adapterId}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: generating || !adapterId ? 0.6 : 1 }}
+          disabled={generating || !adapterId || !prompt.trim()}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#2196f3', color: '#fff', cursor: 'pointer',
+            opacity: (generating || !adapterId || !prompt.trim()) ? 0.5 : 1,
+            fontSize: 14, fontWeight: 600,
+          }}
         >
           {generating ? '生成中...' : '生成'}
         </button>
@@ -121,38 +192,66 @@ export function Inference() {
 
       {/* Error */}
       {error && (
-        <div style={{ marginTop: 16, padding: 16, background: '#ffebee', borderRadius: 8, color: '#c62828' }}>
-          <strong>错误:</strong> {error}
+        <div style={{ marginTop: 16, padding: 14, background: '#fff2f0', borderRadius: 6, color: '#cf1322', fontSize: 13, border: '1px solid #ffccc7' }}>
+          {error}
         </div>
       )}
 
       {/* Result */}
       {result && (
-        <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
-            <h2 style={{ margin: 0, fontSize: 16 }}>生成结果</h2>
-            <span style={{ fontSize: 12, color: '#999' }}>{result.tokens_generated} tokens</span>
+        <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
+            <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>生成结果</h2>
+            <span style={{ fontSize: 12, color: '#999', background: '#f5f5f5', padding: '2px 10px', borderRadius: 10 }}>
+              {result.tokens_generated} tokens
+            </span>
           </div>
 
-          {/* View mode toggle */}
-          <div style={{ marginBottom: 12 }}>
-            <button
-              onClick={() => setViewMode('full')}
-              style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid ${viewMode === 'full' ? '#e94560' : '#ccc'}`, background: viewMode === 'full' ? '#e94560' : '#fff', color: viewMode === 'full' ? '#fff' : '#333', cursor: 'pointer', marginRight: 8, fontSize: 13 }}
-            >
-              完整输出
-            </button>
-            <button
-              onClick={() => setViewMode('new')}
-              style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid ${viewMode === 'new' ? '#e94560' : '#ccc'}`, background: viewMode === 'new' ? '#e94560' : '#fff', color: viewMode === 'new' ? '#fff' : '#333', cursor: 'pointer', fontSize: 13 }}
-            >
-              仅新生成部分
-            </button>
+          {/* Prompt */}
+          <div style={{ marginBottom: 16 }}>
+            <div style={{ fontSize: 12, color: '#999', marginBottom: 6, fontWeight: 600 }}>Prompt</div>
+            <div style={{ padding: 12, background: '#f0f7ff', borderRadius: 6, fontSize: 14, lineHeight: 1.6 }}>
+              {prompt}
+            </div>
           </div>
 
-          <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: '#f5f5f5', padding: 16, borderRadius: 4, fontSize: 14, lineHeight: 1.6, maxHeight: 400, overflow: 'auto' }}>
-            {viewMode === 'full' ? result.generated_text : result.generated_text.replace(prompt, '').trim()}
-          </pre>
+          {/* Response */}
+          <div>
+            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
+              <span style={{ fontSize: 12, color: '#999', fontWeight: 600 }}>Response</span>
+              <div style={{ display: 'flex', gap: 4 }}>
+                <button
+                  onClick={() => setViewMode('full')}
+                  style={{
+                    padding: '3px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer',
+                    border: `1px solid ${viewMode === 'full' ? '#e94560' : '#d0d0d0'}`,
+                    background: viewMode === 'full' ? '#e94560' : '#fff',
+                    color: viewMode === 'full' ? '#fff' : '#666',
+                  }}
+                >
+                  完整输出
+                </button>
+                <button
+                  onClick={() => setViewMode('new')}
+                  style={{
+                    padding: '3px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer',
+                    border: `1px solid ${viewMode === 'new' ? '#e94560' : '#d0d0d0'}`,
+                    background: viewMode === 'new' ? '#e94560' : '#fff',
+                    color: viewMode === 'new' ? '#fff' : '#666',
+                  }}
+                >
+                  仅新生成
+                </button>
+              </div>
+            </div>
+            <pre style={{
+              whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: '#fafafa',
+              padding: 16, borderRadius: 6, fontSize: 14, lineHeight: 1.6,
+              maxHeight: 400, overflow: 'auto', margin: 0, border: '1px solid #f0f0f0',
+            }}>
+              {viewMode === 'full' ? result.generated_text : result.generated_text.slice(prompt.length).trim()}
+            </pre>
+          </div>
         </div>
       )}
     </div>

+ 265 - 209
frontend/src/pages/Training.tsx

@@ -10,7 +10,7 @@ const MODEL_TYPES = [
 
 const PEFT_METHODS = [
   { value: 'lora', label: 'LoRA' },
-  { value: 'qlora', label: 'QLoRA' },
+  { value: 'qlora', label: 'QLoRA (推荐)' },
   { value: 'ia3', label: 'IA3' },
   { value: 'adalora', label: 'AdaLoRA' },
   { value: 'prefix_tuning', label: 'Prefix Tuning' },
@@ -21,16 +21,91 @@ const TASK_TYPES = [
   { value: 'dpo', label: 'DPO (直接偏好优化)' },
   { value: 'orpo', label: 'ORPO (比值偏好优化)' },
   { value: 'kto', label: 'KTO (Kahneman-Tversky)' },
-  { value: 'rm', label: 'Reward Modeling' },
-  { value: 'ppo', label: 'PPO (强化学习)' },
 ]
 
 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 {
   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 inputRef = useRef<HTMLInputElement>(null)
 
-  // 点击外部关闭
   useEffect(() => {
     const handler = (e: MouseEvent) => {
       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)
   }, [open])
 
-  // 打开时自动聚焦输入框
   useEffect(() => {
     if (open) {
       setFilter('')
@@ -65,9 +138,7 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
     }
   }, [open])
 
-  // 当前选中的 label
   const selectedLabel = options.find(o => o.value === value)?.label ?? ''
-
   const filtered = options.filter(o =>
     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 (
     <div ref={wrapperRef} style={{ position: 'relative' }}>
-      {/* 显示框 */}
       <div
         onClick={toggleOpen}
         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' }}>
           {value ? selectedLabel : placeholder}
         </span>
-        <span style={{ color: '#999', fontSize: 12 }}>{open ? '▲' : '▼'}</span>
+        <span style={{ color: '#999', fontSize: 11 }}>{open ? '▲' : '▼'}</span>
       </div>
 
-      {/* 下拉列表 */}
       {open && (
         <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
             ref={inputRef}
             value={filter}
@@ -136,14 +188,10 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
             onKeyDown={handleKeyDown}
             placeholder="搜索..."
             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 }}>
             {loading && (
               <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}
                 onClick={() => handleSelect(opt.value)}
                 style={{
-                  padding: '8px 12px',
-                  cursor: 'pointer',
+                  padding: '8px 12px', cursor: 'pointer',
                   background: opt.value === value ? '#e94560' : 'transparent',
                   color: opt.value === value ? '#fff' : '#333',
                   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>
                 {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() {
   const [modelId, setModelId] = useState('')
   const [modelType, setModelType] = useState('text')
   const [datasetId, setDatasetId] = useState('')
-  const [peftMethod, setPeftMethod] = useState('lora')
+  const [peftMethod, setPeftMethod] = useState('qlora')
   const [taskType, setTaskType] = useState('sft')
   const [template, setTemplate] = useState('alpaca')
   const [epochs, setEpochs] = useState(3)
   const [batchSize, setBatchSize] = useState(4)
   const [lr, setLr] = useState('2e-4')
   const [loraR, setLoraR] = useState(16)
+  const [seqLen, setSeqLen] = useState(2048)
+  const [gradAcc, setGradAcc] = useState(4)
   const [deepspeed, setDeepspeed] = useState(false)
 
   const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [loading, setLoading] = useState(false)
   const [submitting, setSubmitting] = useState(false)
+  const [createError, setCreateError] = useState('')
 
-  // 模型和数据集列表
   const [models, setModels] = useState<ModelInfo[]>([])
   const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [loadingOptions, setLoadingOptions] = useState(true)
@@ -217,25 +322,19 @@ export function Training() {
     }).finally(() => setLoadingOptions(false))
   }, [])
 
-  // 页面加载时获取选项
-  useEffect(() => {
-    fetchOptions()
-  }, [fetchOptions])
+  useEffect(() => { fetchOptions() }, [fetchOptions])
 
-  // Connect WebSocket on mount
   useEffect(() => {
     wsManager.connect()
     return () => wsManager.disconnect()
   }, [])
 
-  // 将 jobs 存入 ref 用于比较,避免相同数据触发重渲染
   const jobsRef = useRef<TrainingJob[]>([])
 
   const fetchJobs = () => {
     setLoading(true)
     api.training.list()
       .then(newJobs => {
-        // 仅在数据真正变化时更新 state
         const prev = jobsRef.current
         if (JSON.stringify(prev) !== JSON.stringify(newJobs)) {
           setJobs(newJobs)
@@ -260,194 +359,145 @@ export function Training() {
   const handleCreate = () => {
     if (!modelId.trim() || !datasetId.trim()) return
     setSubmitting(true)
+    setCreateError('')
 
-    // 乐观更新:立即在列表中添加一个 pending 任务
     const tempId = 'temp-' + Date.now()
     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])
     setLoading(false)
 
     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(() => {
         setModelId('')
         setDatasetId('')
-        // 用真实数据替换占位
         setJobs(prev => prev.filter(j => j.id !== tempId))
         fetchJobs()
         fetchOptions()
       })
-      .catch(() => {
-        // 失败时移除占位任务
+      .catch(err => {
         setJobs(prev => prev.filter(j => j.id !== tempId))
+        setCreateError(err instanceof Error ? err.message : '创建失败')
       })
       .finally(() => setSubmitting(false))
   }
 
   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 => ({
-    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 => ({
-    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 (
     <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 */}
-      <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>
-            <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>
             <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>
-            <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>
-            <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>
             <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>
             <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>
-            <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>
-            <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>
-            <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>
-            <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>
-            <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 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
           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 ? '创建中...' : '启动训练'}
         </button>
@@ -456,36 +506,42 @@ const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel:
       {/* Job list */}
       <div style={{ marginTop: 24 }}>
         <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>
 
-        {loading && <p style={{ color: '#999' }}>加载中...</p>}
+        {loading && <p style={{ color: '#999', fontSize: 13 }}>加载中...</p>}
 
         {!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 && (
-          <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>