lxylxy123321 před 2 týdny
rodič
revize
ee32c78ffa

+ 179 - 16
frontend/src/api/client.ts

@@ -1,19 +1,182 @@
-import axios from 'axios'
-
-const api = axios.create({
-  baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
-  timeout: 60000,
-  headers: { 'Content-Type': 'application/json' },
-})
-
-// 响应拦截器:统一错误处理
-api.interceptors.response.use(
-  (res) => res,
-  (err) => {
-    const message = err.response?.data?.detail || err.message
-    console.error(`[API 错误] ${err.config?.url}: ${message}`)
-    return Promise.reject(err)
+const api = {
+  // --- Health ---
+  health: () => fetch('/health').then(r => r.json()),
+
+  // --- Models ---
+  models: {
+    list: () => fetch('/api/v1/models/').then(r => r.json()) as Promise<ModelInfo[]>,
+    download: (modelId: string, useModelscope = false) =>
+      fetch('/api/v1/models/download', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ model_id: modelId, use_modelscope: useModelscope }),
+      }).then(r => r.json()) as Promise<ModelDownloadResponse>,
+    getInfo: (modelId: string) =>
+      fetch(`/api/v1/models/${encodeURIComponent(modelId)}`).then(r => r.json()) as Promise<ModelInfo>,
+  },
+
+  // --- Datasets ---
+  datasets: {
+    list: () => fetch('/api/v1/datasets/').then(r => r.json()) as Promise<DatasetInfo[]>,
+    upload: (file: File) => {
+      const form = new FormData()
+      form.append('file', file)
+      return fetch('/api/v1/datasets/upload', { method: 'POST', body: form }).then(r => r.json()) as Promise<DatasetInfo>
+    },
+    preview: (id: string, rows = 10) =>
+      fetch(`/api/v1/datasets/${id}/preview?rows=${rows}`).then(r => r.json()) as Promise<DatasetPreview>,
+    validate: (id: string) =>
+      fetch(`/api/v1/datasets/${id}/validate`, { method: 'POST' }).then(r => r.json()) as Promise<DatasetValidation>,
+    delete: (id: string) =>
+      fetch(`/api/v1/datasets/${id}`, { method: 'DELETE' }).then(r => r.json()),
   },
   },
-)
+
+  // --- Training ---
+  training: {
+    list: () => fetch('/api/v1/training/jobs').then(r => r.json()) as Promise<TrainingJob[]>,
+    create: (cfg: TrainingConfig) =>
+      fetch('/api/v1/training/jobs', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(cfg),
+      }).then(r => r.json()) as Promise<TrainingJob>,
+    get: (id: string) =>
+      fetch(`/api/v1/training/jobs/${id}`).then(r => r.json()) as Promise<TrainingJob>,
+    cancel: (id: string) =>
+      fetch(`/api/v1/training/jobs/${id}/cancel`, { method: 'POST' }).then(r => r.json()),
+  },
+
+  // --- Evaluation ---
+  evaluation: {
+    run: (cfg: EvalConfig) =>
+      fetch('/api/v1/evaluation/run', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(cfg),
+      }).then(r => r.json()) as Promise<EvalResult>,
+    results: (id: string) =>
+      fetch(`/api/v1/evaluation/${id}/results`).then(r => r.json()) as Promise<EvalResult>,
+  },
+
+  // --- Deployment ---
+  deployment: {
+    export: (cfg: DeployConfig) =>
+      fetch('/api/v1/deployment/export', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(cfg),
+      }).then(r => r.json()) as Promise<DeployResponse>,
+    status: (id: string) =>
+      fetch(`/api/v1/deployment/${id}/status`).then(r => r.json()) as Promise<DeployResponse>,
+  },
+}
 
 
 export default api
 export default api
+
+// --- Types ---
+
+interface ModelInfo {
+  id: string
+  name: string
+  model_type: string
+  path?: string
+  is_downloaded: boolean
+  context_length?: number
+  supported_peft_methods: string[]
+}
+
+interface ModelDownloadResponse {
+  model_id: string
+  status: string
+  path?: string
+  error?: string
+}
+
+interface DatasetInfo {
+  id: string
+  name: string
+  format: string
+  record_count: number
+  file_path: string
+  created_at: string
+}
+
+interface DatasetPreview {
+  total_records: number
+  preview_rows: { row_index: number; data: Record<string, unknown> }[]
+  columns: string[]
+}
+
+interface DatasetValidation {
+  is_valid: boolean
+  errors?: string[]
+  warnings?: string[]
+}
+
+interface TrainingJob {
+  id: string
+  model_id: string
+  model_type: string
+  peft_method: string
+  status: string
+  progress: number
+  current_epoch: number
+  current_step: number
+  total_steps: number
+  loss?: number
+  created_at: string
+  started_at?: string
+  finished_at?: string
+  error_message?: string
+  adapter_path?: string
+}
+
+interface TrainingConfig {
+  model_id: string
+  model_type: ModelType | string
+  dataset_id: string
+  peft_method: PeftMethod | string
+  epochs?: number
+  batch_size?: number
+  gradient_accumulation?: number
+  learning_rate?: number
+  max_seq_length?: number
+  warmup_ratio?: number
+  save_strategy?: string
+  eval_strategy?: string
+  eval_steps?: number
+  lora_r?: number
+  lora_alpha?: number
+  lora_dropout?: number
+  lora_target_modules?: string
+  qlora_bits?: number
+}
+
+interface EvalConfig {
+  job_id: string
+  test_split_ratio?: number
+  batch_size?: number
+  metrics?: string[]
+}
+
+interface EvalResult {
+  id: string
+  job_id: string
+  metrics: Record<string, unknown>
+  created_at: string
+}
+
+interface DeployConfig {
+  job_id: string
+  merge_with_base?: boolean
+  export_format?: string
+}
+
+interface DeployResponse {
+  job_id: string
+  status: string
+  output_path?: string
+  error?: string
+}
+
+export type { ModelInfo, ModelDownloadResponse, DatasetInfo, DatasetPreview, DatasetValidation, TrainingJob, TrainingConfig, EvalConfig, EvalResult, DeployConfig, DeployResponse }

+ 43 - 22
frontend/src/api/websocket.ts

@@ -1,37 +1,57 @@
-interface WebSocketMessage {
-  type: string
-  job_id: string
-  [key: string]: unknown
-}
-
-type MessageHandler = (msg: WebSocketMessage) => void
-
-class WebSocketManager {
+class WSManager {
   private ws: WebSocket | null = null
   private ws: WebSocket | null = null
-  private handlers: Map<string, Set<MessageHandler>> = new Map()
+  private handlers: Map<string, Set<(msg: Record<string, unknown>) => void>> = new Map()
   private reconnectTimer: ReturnType<typeof setTimeout> | null = null
   private reconnectTimer: ReturnType<typeof setTimeout> | null = null
+  private url: string | null = null
 
 
-  connect() {
+  connect(baseUrl?: string) {
     if (this.ws) return
     if (this.ws) return
-    const url = import.meta.env.VITE_WS_BASE_URL || 'ws://127.0.0.1:8000/ws'
-    this.ws = new WebSocket(url)
+    const url = baseUrl || (import.meta.env.VITE_WS_BASE_URL as string) || 'ws://127.0.0.1:8000/ws'
+    // If relative path, resolve to current origin
+    const wsUrl = url.startsWith('ws') ? url : `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${url}`
+    this.url = wsUrl
+
+    try {
+      this.ws = new WebSocket(wsUrl)
+    } catch {
+      this.scheduleReconnect()
+      return
+    }
+
+    this.ws.onopen = () => {
+      console.log('[WS] Connected')
+    }
 
 
     this.ws.onmessage = (event) => {
     this.ws.onmessage = (event) => {
-      const msg: WebSocketMessage = JSON.parse(event.data)
-      this.handlers.get(msg.job_id)?.forEach((h) => h(msg))
-      this.handlers.get('*')?.forEach((h) => h(msg))
+      try {
+        const msg = JSON.parse(event.data) as Record<string, unknown>
+        this.handlers.get(msg.job_id as string)?.forEach(h => h(msg))
+        this.handlers.get('*')?.forEach(h => h(msg))
+      } catch {
+        // ignore non-JSON messages
+      }
     }
     }
 
 
     this.ws.onclose = () => {
     this.ws.onclose = () => {
       this.ws = null
       this.ws = null
-      this.reconnectTimer = setTimeout(() => this.connect(), 3000)
+      this.scheduleReconnect()
     }
     }
-  }
 
 
-  subscribe(jobId: string, handler: MessageHandler): () => void {
-    if (!this.handlers.has(jobId)) {
-      this.handlers.set(jobId, new Set())
+    this.ws.onerror = () => {
+      this.ws?.close()
     }
     }
+  }
+
+  private scheduleReconnect() {
+    if (this.reconnectTimer) return
+    this.reconnectTimer = setTimeout(() => {
+      this.reconnectTimer = null
+      this.connect(this.url || undefined)
+    }, 3000)
+  }
+
+  subscribe(jobId: string, handler: (msg: Record<string, unknown>) => void): () => void {
+    if (!this.handlers.has(jobId)) this.handlers.set(jobId, new Set())
     this.handlers.get(jobId)!.add(handler)
     this.handlers.get(jobId)!.add(handler)
     return () => this.handlers.get(jobId)?.delete(handler)
     return () => this.handlers.get(jobId)?.delete(handler)
   }
   }
@@ -40,7 +60,8 @@ class WebSocketManager {
     this.ws?.close()
     this.ws?.close()
     this.ws = null
     this.ws = null
     if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
     if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
+    this.reconnectTimer = null
   }
   }
 }
 }
 
 
-export const wsManager = new WebSocketManager()
+export const wsManager = new WSManager()

+ 23 - 4
frontend/src/pages/Dashboard.tsx

@@ -1,18 +1,37 @@
+import { useState, useEffect } from 'react'
+import api from '../api/client'
+
 export function Dashboard() {
 export function Dashboard() {
+  const [models, setModels] = useState(0)
+  const [datasets, setDatasets] = useState(0)
+  const [jobs, setJobs] = useState(0)
+
+  useEffect(() => {
+    api.models.list()
+      .then(data => setModels(data.length))
+      .catch(() => setModels(0))
+    api.datasets.list()
+      .then(data => setDatasets(data.length))
+      .catch(() => setDatasets(0))
+    api.training.list()
+      .then(data => setJobs(data.length))
+      .catch(() => setJobs(0))
+  }, [])
+
   return (
   return (
     <div>
     <div>
       <h1>仪表盘</h1>
       <h1>仪表盘</h1>
       <p>PEFT Fine-Tuning Platform v0.1.0</p>
       <p>PEFT Fine-Tuning Platform v0.1.0</p>
       <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginTop: 24 }}>
       <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginTop: 24 }}>
-        <StatCard title="模型" value="0" desc="已缓存" />
-        <StatCard title="数据集" value="0" desc="已上传" />
-        <StatCard title="训练任务" value="0" desc="已完成" />
+        <StatCard title="模型" value={models} desc="已缓存" />
+        <StatCard title="数据集" value={datasets} desc="已上传" />
+        <StatCard title="训练任务" value={jobs} desc="总数" />
       </div>
       </div>
     </div>
     </div>
   )
   )
 }
 }
 
 
-function StatCard({ title, value, desc }: { title: string; value: string; desc: string }) {
+function StatCard({ title, value, desc }: { title: string; value: number; desc: string }) {
   return (
   return (
     <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
     <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
       <div style={{ fontSize: 13, color: '#666' }}>{title}</div>
       <div style={{ fontSize: 13, color: '#666' }}>{title}</div>

+ 140 - 8
frontend/src/pages/Datasets.tsx

@@ -1,16 +1,148 @@
+import { useState, useRef } from 'react'
+import api, { DatasetInfo } from '../api/client'
+
 export function Datasets() {
 export function Datasets() {
+  const [datasets, setDatasets] = useState<DatasetInfo[]>([])
+  const [uploading, setUploading] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [previewData, setPreviewData] = useState<{ columns: string[]; rows: { row_index: number; data: Record<string, unknown> }[] } | null>(null)
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  const fetchDatasets = () => {
+    setLoading(true)
+    api.datasets.list()
+      .then(setDatasets)
+      .catch(() => setDatasets([]))
+      .finally(() => setLoading(false))
+  }
+
+  const handleFileUpload = async (file: File) => {
+    setUploading(true)
+    try {
+      await api.datasets.upload(file)
+      fetchDatasets()
+    } catch (err) {
+      console.error('Upload failed:', err)
+    } finally {
+      setUploading(false)
+    }
+  }
+
+  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (file) handleFileUpload(file)
+  }
+
+  const handlePreview = (id: string) => {
+    api.datasets.preview(id, 10)
+      .then(res => setPreviewData({ columns: res.columns, rows: res.preview_rows }))
+      .catch(() => setPreviewData(null))
+  }
+
+  const handleDelete = async (id: string) => {
+    if (!confirm('确定删除此数据集?')) return
+    try {
+      await api.datasets.delete(id)
+      fetchDatasets()
+      setPreviewData(null)
+    } catch (err) {
+      console.error('Delete failed:', err)
+    }
+  }
+
   return (
   return (
     <div>
     <div>
       <h1>数据集管理</h1>
       <h1>数据集管理</h1>
-      <div style={{
-        marginTop: 16, border: '2px dashed #ccc', borderRadius: 8,
-        padding: 40, textAlign: 'center', color: '#999',
-      }}>
-        拖拽文件到此处或点击上传 (JSONL / CSV / Parquet / JSON)
+
+      {/* Upload area */}
+      <div
+        onClick={() => inputRef.current?.click()}
+        style={{
+          marginTop: 16, border: '2px dashed #ccc', borderRadius: 8,
+          padding: 40, textAlign: 'center', color: '#999', cursor: 'pointer',
+          opacity: uploading ? 0.6 : 1,
+        }}
+      >
+        {uploading ? '上传中...' : '拖拽文件到此处或点击上传 (JSONL / CSV / Parquet / JSON)'}
+        <input
+          ref={inputRef}
+          type="file"
+          accept=".jsonl,.csv,.parquet,.json"
+          style={{ display: 'none' }}
+          onChange={handleInputChange}
+        />
       </div>
       </div>
-      <p style={{ marginTop: 16, color: '#666', fontSize: 14 }}>
-        后端尚未实现文件上传,Phase 2 将添加完整功能。
-      </p>
+
+      {/* Dataset list */}
+      <div style={{ marginTop: 24 }}>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
+          <h2 style={{ margin: 0 }}>已上传数据集</h2>
+          <button onClick={fetchDatasets} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}>
+            刷新
+          </button>
+        </div>
+
+        {loading && <p style={{ color: '#999' }}>加载中...</p>}
+
+        {!loading && datasets.length === 0 && (
+          <p style={{ color: '#999', fontSize: 14 }}>暂无数据集</p>
+        )}
+
+        {!loading && datasets.length > 0 && (
+          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
+            <thead>
+              <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
+                <th style={{ padding: '8px 0' }}>名称</th>
+                <th>格式</th>
+                <th>记录数</th>
+                <th>上传时间</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {datasets.map(d => (
+                <tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
+                  <td style={{ padding: '8px 0' }}>{d.name}</td>
+                  <td>{d.format}</td>
+                  <td>{d.record_count}</td>
+                  <td>{d.created_at}</td>
+                  <td>
+                    <button onClick={() => handlePreview(d.id)} style={{ marginRight: 8, padding: '2px 8px', cursor: 'pointer' }}>预览</button>
+                    <button onClick={() => handleDelete(d.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>删除</button>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+      </div>
+
+      {/* Preview */}
+      {previewData && previewData.rows.length > 0 && (
+        <div style={{ marginTop: 24 }}>
+          <h3>数据预览</h3>
+          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+            <thead>
+              <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
+                {previewData.columns.map(col => (
+                  <th key={col} style={{ padding: '6px 8px' }}>{col}</th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {previewData.rows.slice(0, 10).map((row, i) => (
+                <tr key={i} style={{ borderBottom: '1px solid #eee' }}>
+                  {previewData.columns.map(col => (
+                    <td key={col} style={{ padding: '6px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+                      {String(row.data[col] ?? '')}
+                    </td>
+                  ))}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 62 - 3
frontend/src/pages/Deployment.tsx

@@ -1,10 +1,69 @@
+import { useState } from 'react'
+import api, { DeployResponse } from '../api/client'
+
 export function Deployment() {
 export function Deployment() {
+  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 handleExport = () => {
+    if (!jobId.trim()) return
+    setRunning(true)
+    api.deployment.export({
+      job_id: jobId,
+      merge_with_base: mergeWithBase,
+      export_format: exportFormat,
+    })
+      .then(setResult)
+      .catch(console.error)
+      .finally(() => setRunning(false))
+  }
+
   return (
   return (
     <div>
     <div>
       <h1>部署</h1>
       <h1>部署</h1>
-      <p style={{ marginTop: 16, color: '#666', fontSize: 14 }}>
-        Phase 5 将实现 Adapter 合并和 GGUF 导出。
-      </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={{ 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' }} />
+          </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>
+          </div>
+          <div>
+            <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
+              <input type="checkbox" checked={mergeWithBase} onChange={e => setMergeWithBase(e.target.checked)} />
+              合并基础模型
+            </label>
+          </div>
+        </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 }}
+        >
+          {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>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 70 - 3
frontend/src/pages/Evaluation.tsx

@@ -1,10 +1,77 @@
+import { useState } from 'react'
+import api, { EvalResult } from '../api/client'
+
 export function Evaluation() {
 export function Evaluation() {
+  const [jobId, setJobId] = useState('')
+  const [metrics, setMetrics] = useState('perplexity,loss')
+  const [running, setRunning] = useState(false)
+  const [result, setResult] = useState<EvalResult | null>(null)
+
+  const handleRun = () => {
+    if (!jobId.trim()) return
+    setRunning(true)
+    api.evaluation.run({
+      job_id: jobId,
+      metrics: metrics.split(',').map(s => s.trim()).filter(Boolean),
+    })
+      .then(setResult)
+      .catch(console.error)
+      .finally(() => setRunning(false))
+  }
+
   return (
   return (
     <div>
     <div>
       <h1>评估</h1>
       <h1>评估</h1>
-      <p style={{ marginTop: 16, color: '#666', fontSize: 14 }}>
-        Phase 5 将实现评估指标计算和可视化。
-      </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={{ 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' }} />
+          </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' }} />
+          </div>
+        </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 }}
+        >
+          {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>
+          {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>
+                  </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>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            )
+            : <p style={{ color: '#999', fontSize: 14 }}>评估结果为空(后端尚未返回数据)</p>
+          }
+        </div>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 83 - 7
frontend/src/pages/Models.tsx

@@ -1,26 +1,102 @@
 import { useState } from 'react'
 import { useState } from 'react'
+import api, { ModelInfo } from '../api/client'
 
 
 export function Models() {
 export function Models() {
   const [modelId, setModelId] = useState('')
   const [modelId, setModelId] = useState('')
+  const [useModelscope, setUseModelscope] = useState(false)
+  const [downloading, setDownloading] = useState(false)
+  const [models, setModels] = useState<ModelInfo[]>([])
+  const [loading, setLoading] = useState(false)
+  const [statusMsg, setStatusMsg] = useState('')
+
+  const fetchModels = () => {
+    setLoading(true)
+    api.models.list()
+      .then(setModels)
+      .catch(() => setModels([]))
+      .finally(() => setLoading(false))
+  }
+
+  const handleDownload = () => {
+    if (!modelId.trim()) return
+    setDownloading(true)
+    setStatusMsg('正在下载...')
+    api.models.download(modelId, useModelscope)
+      .then(res => setStatusMsg(`${res.model_id}: ${res.status}`))
+      .catch(err => setStatusMsg(`下载失败: ${err.message}`))
+      .finally(() => setDownloading(false))
+  }
 
 
   return (
   return (
     <div>
     <div>
       <h1>模型注册</h1>
       <h1>模型注册</h1>
-      <div style={{ marginTop: 16 }}>
+
+      {/* Download form */}
+      <div style={{ marginTop: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
         <input
         <input
           type="text"
           type="text"
           placeholder="输入模型 ID (如 meta-llama/Llama-3.1-8B)"
           placeholder="输入模型 ID (如 meta-llama/Llama-3.1-8B)"
           value={modelId}
           value={modelId}
-          onChange={(e) => setModelId(e.target.value)}
+          onChange={e => setModelId(e.target.value)}
           style={{ padding: '8px 12px', width: 400, borderRadius: 4, border: '1px solid #ccc' }}
           style={{ padding: '8px 12px', width: 400, borderRadius: 4, border: '1px solid #ccc' }}
         />
         />
-        <button style={{ marginLeft: 8, padding: '8px 16px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff' }}>
-          下载模型
+        <label style={{ fontSize: 13, color: '#666', whiteSpace: 'nowrap' }}>
+          <input type="checkbox" checked={useModelscope} onChange={e => setUseModelscope(e.target.checked)} />
+          {' '}ModelScope
+        </label>
+        <button
+          onClick={handleDownload}
+          disabled={downloading}
+          style={{ padding: '8px 16px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: downloading ? 0.6 : 1 }}
+        >
+          {downloading ? '下载中...' : '下载模型'}
         </button>
         </button>
       </div>
       </div>
-      <p style={{ marginTop: 16, color: '#666', fontSize: 14 }}>
-        后端尚未实现模型下载,Phase 2 将添加完整功能。
-      </p>
+
+      {statusMsg && <p style={{ marginTop: 8, fontSize: 13, color: '#e94560' }}>{statusMsg}</p>}
+
+      {/* Model list */}
+      <div style={{ marginTop: 24 }}>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
+          <h2 style={{ margin: 0 }}>已缓存模型</h2>
+          <button onClick={fetchModels} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}>
+            刷新
+          </button>
+        </div>
+
+        {loading && <p style={{ color: '#999' }}>加载中...</p>}
+
+        {!loading && models.length === 0 && (
+          <p style={{ color: '#999', fontSize: 14 }}>暂无已缓存模型</p>
+        )}
+
+        {!loading && models.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>类型</th>
+                <th>状态</th>
+                <th>PEFT 支持</th>
+              </tr>
+            </thead>
+            <tbody>
+              {models.map(m => (
+                <tr key={m.id} style={{ borderBottom: '1px solid #eee' }}>
+                  <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{m.id}</td>
+                  <td>{m.name}</td>
+                  <td>{m.model_type}</td>
+                  <td style={{ color: m.is_downloaded ? '#4caf50' : '#e94560' }}>
+                    {m.is_downloaded ? '已缓存' : '未下载'}
+                  </td>
+                  <td>{m.supported_peft_methods.join(', ') || '-'}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+      </div>
     </div>
     </div>
   )
   )
 }
 }

+ 187 - 3
frontend/src/pages/Training.tsx

@@ -1,10 +1,194 @@
+import { useState, useEffect } from 'react'
+import api, { TrainingJob } from '../api/client'
+import { wsManager } from '../api/websocket'
+
+const MODEL_TYPES = [
+  { value: 'text', label: '文本 (LLaMA/Qwen)' },
+  { value: 'vision', label: '视觉 (ViT/CLIP)' },
+  { value: 'multimodal', label: '多模态 (LLaVA/Qwen-VL)' },
+]
+
+const PEFT_METHODS = [
+  { value: 'lora', label: 'LoRA' },
+  { value: 'qlora', label: 'QLoRA' },
+  { value: 'ia3', label: 'IA3' },
+  { value: 'adalora', label: 'AdaLoRA' },
+  { value: 'prefix_tuning', label: 'Prefix Tuning' },
+]
+
 export function Training() {
 export function Training() {
+  // Form state
+  const [modelId, setModelId] = useState('')
+  const [modelType, setModelType] = useState('text')
+  const [datasetId, setDatasetId] = useState('')
+  const [peftMethod, setPeftMethod] = useState('lora')
+  const [epochs, setEpochs] = useState(3)
+  const [batchSize, setBatchSize] = useState(4)
+  const [lr, setLr] = useState('2e-4')
+  const [loraR, setLoraR] = useState(16)
+
+  // Job list
+  const [jobs, setJobs] = useState<TrainingJob[]>([])
+  const [loading, setLoading] = useState(false)
+  const [submitting, setSubmitting] = useState(false)
+
+  const fetchJobs = () => {
+    setLoading(true)
+    api.training.list()
+      .then(setJobs)
+      .catch(() => setJobs([]))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(() => {
+    fetchJobs()
+  }, [])
+
+  const handleCreate = () => {
+    if (!modelId.trim() || !datasetId.trim()) return
+    setSubmitting(true)
+    api.training.create({
+      model_id: modelId,
+      model_type: modelType,
+      dataset_id: datasetId,
+      peft_method: peftMethod,
+      epochs,
+      batch_size: batchSize,
+      learning_rate: parseFloat(lr),
+      lora_r: loraR,
+      lora_alpha: loraR * 2,
+    })
+      .then(() => {
+        setModelId('')
+        setDatasetId('')
+        fetchJobs()
+      })
+      .catch(console.error)
+      .finally(() => setSubmitting(false))
+  }
+
+  const handleCancel = (id: string) => {
+    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 'cancelled': return '#999'
+      default: return '#666'
+    }
+  }
+
   return (
   return (
     <div>
     <div>
       <h1>训练任务</h1>
       <h1>训练任务</h1>
-      <p style={{ marginTop: 16, color: '#666', fontSize: 14 }}>
-        Phase 3 将实现完整的训练任务创建、监控和 WebSocket 实时进度推送。
-      </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>
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型 ID</label>
+            <input value={modelId} onChange={e => setModelId(e.target.value)} placeholder="模型 ID" 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 }}>模型类型</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>
+          </div>
+          <div>
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据集 ID</label>
+            <input value={datasetId} onChange={e => setDatasetId(e.target.value)} placeholder="数据集 ID" 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 }}>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>
+          </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' }} />
+          </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' }} />
+          </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' }} />
+          </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' }} />
+          </div>
+        </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 }}
+        >
+          {submitting ? '创建中...' : '启动训练'}
+        </button>
+      </div>
+
+      {/* 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>
+        </div>
+
+        {loading && <p style={{ color: '#999' }}>加载中...</p>}
+
+        {!loading && jobs.length === 0 && (
+          <p style={{ color: '#999', fontSize: 14 }}>暂无训练任务</p>
+        )}
+
+        {!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>Loss</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {jobs.map(j => (
+                <tr key={j.id} 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={{ 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') && (
+                      <button onClick={() => handleCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>取消</button>
+                    )}
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+      </div>
     </div>
     </div>
   )
   )
 }
 }