|
@@ -1,57 +1,144 @@
|
|
|
-import { useState, useEffect, memo, useRef } from 'react'
|
|
|
|
|
|
|
+import { useState, useEffect, useRef } from 'react'
|
|
|
import api, { ModelInfo, ModelDownloadTaskResponse } from '../api/client'
|
|
import api, { ModelInfo, ModelDownloadTaskResponse } from '../api/client'
|
|
|
-import { Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react'
|
|
|
|
|
|
|
+import { Cpu, Eye, Layers, CheckCircle, XCircle, Loader2, Play, Trash2 } from 'lucide-react'
|
|
|
|
|
|
|
|
-const ModelRow = memo(function ModelRow({ m, onTest, onDelete }: {
|
|
|
|
|
|
|
+function modelIcon(type: string) {
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case 'vision': return <Eye size={20} strokeWidth={1.8} />
|
|
|
|
|
+ case 'multimodal': return <Layers size={20} strokeWidth={1.8} />
|
|
|
|
|
+ default: return <Cpu size={20} strokeWidth={1.8} />
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function modelTypeLabel(type: string) {
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case 'vision': return '视觉'
|
|
|
|
|
+ case 'multimodal': return '多模态'
|
|
|
|
|
+ default: return '文本'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function modelTypeColor(type: string) {
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case 'vision': return { bg: '#eff6ff', color: '#2563eb', border: '#bfdbfe' }
|
|
|
|
|
+ case 'multimodal': return { bg: '#faf5ff', color: '#7c3aed', border: '#ddd6fe' }
|
|
|
|
|
+ default: return { bg: '#f0fdfa', color: '#0d9488', border: '#99f6e4' }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function ModelCard({ m, onTest, onDelete }: {
|
|
|
m: ModelInfo
|
|
m: ModelInfo
|
|
|
onTest: (id: string) => void
|
|
onTest: (id: string) => void
|
|
|
onDelete: (id: string, name: string) => void
|
|
onDelete: (id: string, name: string) => void
|
|
|
}) {
|
|
}) {
|
|
|
|
|
+ const [hovered, setHovered] = useState(false)
|
|
|
|
|
+ const shortName = m.id.split('/').pop() || m.id
|
|
|
|
|
+ const tc = modelTypeColor(m.model_type)
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
- <tr style={{
|
|
|
|
|
- borderBottom: '1px solid #f1f5f9',
|
|
|
|
|
- transition: 'background 0.15s ease',
|
|
|
|
|
- }}
|
|
|
|
|
- onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
|
|
|
|
|
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ onMouseEnter={() => setHovered(true)}
|
|
|
|
|
+ onMouseLeave={() => setHovered(false)}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: '#fff',
|
|
|
|
|
+ borderRadius: 12,
|
|
|
|
|
+ padding: 20,
|
|
|
|
|
+ border: `1px solid ${hovered ? 'rgba(20,184,166,0.3)' : 'rgba(0,0,0,0.06)'}`,
|
|
|
|
|
+ boxShadow: hovered
|
|
|
|
|
+ ? '0 4px 12px rgba(20,184,166,0.12)'
|
|
|
|
|
+ : '0 1px 3px rgba(0,0,0,0.04)',
|
|
|
|
|
+ transform: hovered ? 'translateY(-2px)' : 'none',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ flexDirection: 'column' as const,
|
|
|
|
|
+ gap: 12,
|
|
|
|
|
+ }}
|
|
|
>
|
|
>
|
|
|
- <td style={{ padding: '12px 12px', fontFamily: 'monospace', fontSize: 12, color: '#64748b' }}>{m.id}</td>
|
|
|
|
|
- <td style={{ padding: '12px 12px', fontWeight: 500, fontSize: 13 }}>{m.name}</td>
|
|
|
|
|
- <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>{m.model_type}</td>
|
|
|
|
|
- <td style={{ padding: '12px 12px' }}>
|
|
|
|
|
|
|
+ {/* Header: icon + status */}
|
|
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: 36, height: 36, borderRadius: 10,
|
|
|
|
|
+ background: tc.bg, color: tc.color,
|
|
|
|
|
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {modelIcon(m.model_type)}
|
|
|
|
|
+ </div>
|
|
|
<span style={{
|
|
<span style={{
|
|
|
- display: 'inline-block', padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 500,
|
|
|
|
|
- background: m.is_downloaded ? '#ecfdf5' : '#fff1f2',
|
|
|
|
|
- color: m.is_downloaded ? '#059669' : '#f43f5e',
|
|
|
|
|
- border: `1px solid ${m.is_downloaded ? '#d1fae5' : '#fecdd3'}`,
|
|
|
|
|
|
|
+ display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
|
|
|
+ padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 500,
|
|
|
|
|
+ background: m.is_downloaded ? '#ecfdf5' : '#fff7ed',
|
|
|
|
|
+ color: m.is_downloaded ? '#059669' : '#d97706',
|
|
|
|
|
+ border: `1px solid ${m.is_downloaded ? '#d1fae5' : '#fed7aa'}`,
|
|
|
}}>
|
|
}}>
|
|
|
|
|
+ {m.is_downloaded && <CheckCircle size={12} />}
|
|
|
{m.is_downloaded ? '已缓存' : '未下载'}
|
|
{m.is_downloaded ? '已缓存' : '未下载'}
|
|
|
</span>
|
|
</span>
|
|
|
- </td>
|
|
|
|
|
- <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>{m.supported_peft_methods.join(', ') || '-'}</td>
|
|
|
|
|
- <td style={{ padding: '12px 12px' }}>
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Name */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, wordBreak: 'break-all' }}>
|
|
|
|
|
+ {shortName}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {shortName !== m.id && (
|
|
|
|
|
+ <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 2, fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
|
|
|
|
+ {m.id}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Tags */}
|
|
|
|
|
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' as const }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
|
|
|
|
|
+ background: tc.bg, color: tc.color, border: `1px solid ${tc.border}`,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {modelTypeLabel(m.model_type)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {m.supported_peft_methods.filter(Boolean).slice(0, 3).map(method => (
|
|
|
|
|
+ <span key={method} style={{
|
|
|
|
|
+ padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
|
|
|
|
|
+ background: '#f1f5f9', color: '#64748b', border: '1px solid #e2e8f0',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {method.toUpperCase()}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Actions */}
|
|
|
|
|
+ <div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 4 }}>
|
|
|
{m.is_downloaded && (
|
|
{m.is_downloaded && (
|
|
|
- <button onClick={() => onTest(m.id)} style={{
|
|
|
|
|
- marginRight: 8, padding: '4px 12px', color: '#0ea5e9',
|
|
|
|
|
- border: '1px solid #0ea5e9', borderRadius: 6, background: 'transparent',
|
|
|
|
|
- cursor: 'pointer', fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
|
|
|
|
|
- }}
|
|
|
|
|
- onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
|
|
|
|
|
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
|
|
|
|
|
- >测试</button>
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onTest(m.id)}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
|
|
|
|
+ color: '#0ea5e9', border: '1px solid #0ea5e9', background: 'transparent',
|
|
|
|
|
+ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
|
|
|
|
+ transition: 'all 0.15s ease',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
|
|
|
|
|
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Play size={14} /> 测试
|
|
|
|
|
+ </button>
|
|
|
)}
|
|
)}
|
|
|
- <button onClick={() => onDelete(m.id, m.name)} style={{
|
|
|
|
|
- padding: '4px 12px', color: '#f43f5e', border: '1px solid #f43f5e',
|
|
|
|
|
- borderRadius: 6, background: 'transparent', cursor: 'pointer',
|
|
|
|
|
- fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
|
|
|
|
|
- }}
|
|
|
|
|
- onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
|
|
|
|
|
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
|
|
|
|
|
- >删除</button>
|
|
|
|
|
- </td>
|
|
|
|
|
- </tr>
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onDelete(m.id, m.name)}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
|
|
|
|
+ color: '#f43f5e', border: '1px solid #f43f5e', background: 'transparent',
|
|
|
|
|
+ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
|
|
|
|
+ transition: 'all 0.15s ease',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
|
|
|
|
|
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 size={14} /> 删除
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
)
|
|
)
|
|
|
-})
|
|
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export function Models() {
|
|
export function Models() {
|
|
|
const [modelId, setModelId] = useState('')
|
|
const [modelId, setModelId] = useState('')
|
|
@@ -62,11 +149,9 @@ export function Models() {
|
|
|
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
|
|
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
|
|
|
const [statusContent, setStatusContent] = useState('')
|
|
const [statusContent, setStatusContent] = useState('')
|
|
|
|
|
|
|
|
- // Active downloads tracking
|
|
|
|
|
const [activeDownloads, setActiveDownloads] = useState<Map<string, ModelDownloadTaskResponse>>(new Map())
|
|
const [activeDownloads, setActiveDownloads] = useState<Map<string, ModelDownloadTaskResponse>>(new Map())
|
|
|
const pollIntervals = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
|
|
const pollIntervals = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
|
|
|
|
|
|
|
|
- // Test state
|
|
|
|
|
const [testModelId, setTestModelId] = useState('')
|
|
const [testModelId, setTestModelId] = useState('')
|
|
|
const [testPrompt, setTestPrompt] = useState('')
|
|
const [testPrompt, setTestPrompt] = useState('')
|
|
|
const [testResult, setTestResult] = useState('')
|
|
const [testResult, setTestResult] = useState('')
|
|
@@ -94,9 +179,7 @@ export function Models() {
|
|
|
const res = await api.models.download(modelId, useModelscope)
|
|
const res = await api.models.download(modelId, useModelscope)
|
|
|
setStatusType('success')
|
|
setStatusType('success')
|
|
|
setStatusContent(`下载任务已提交: ${res.model_id}`)
|
|
setStatusContent(`下载任务已提交: ${res.model_id}`)
|
|
|
- // Add to active downloads
|
|
|
|
|
setActiveDownloads(prev => new Map(prev).set(res.task_id, res))
|
|
setActiveDownloads(prev => new Map(prev).set(res.task_id, res))
|
|
|
- // Start polling
|
|
|
|
|
startModelDownloadPolling(res.task_id)
|
|
startModelDownloadPolling(res.task_id)
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
setStatusType('error')
|
|
setStatusType('error')
|
|
@@ -128,9 +211,7 @@ export function Models() {
|
|
|
setStatusContent(`${res.model_id} 下载失败: ${res.error}`)
|
|
setStatusContent(`${res.model_id} 下载失败: ${res.error}`)
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
- .catch(() => {
|
|
|
|
|
- // ignore polling errors
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ .catch(() => {})
|
|
|
}, 3000)
|
|
}, 3000)
|
|
|
pollIntervals.current.set(taskId, interval)
|
|
pollIntervals.current.set(taskId, interval)
|
|
|
}
|
|
}
|
|
@@ -149,7 +230,6 @@ export function Models() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Cleanup polling on unmount
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
return () => {
|
|
return () => {
|
|
|
pollIntervals.current.forEach(interval => clearInterval(interval))
|
|
pollIntervals.current.forEach(interval => clearInterval(interval))
|
|
@@ -323,26 +403,13 @@ export function Models() {
|
|
|
|
|
|
|
|
{!loading && models.length > 0 && (
|
|
{!loading && models.length > 0 && (
|
|
|
<div style={{
|
|
<div style={{
|
|
|
- background: '#fff', borderRadius: 10, overflow: 'hidden',
|
|
|
|
|
- boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
|
|
|
|
+ display: 'grid',
|
|
|
|
|
+ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
|
|
|
+ gap: 16,
|
|
|
}}>
|
|
}}>
|
|
|
- <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
|
|
|
|
- <thead>
|
|
|
|
|
- <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>ID</th>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>名称</th>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>类型</th>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>状态</th>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>PEFT 支持</th>
|
|
|
|
|
- <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>操作</th>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </thead>
|
|
|
|
|
- <tbody>
|
|
|
|
|
- {models.map(m => (
|
|
|
|
|
- <ModelRow key={m.id} m={m} onTest={handleTest} onDelete={handleDelete} />
|
|
|
|
|
- ))}
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
|
|
+ {models.map(m => (
|
|
|
|
|
+ <ModelCard key={m.id} m={m} onTest={handleTest} onDelete={handleDelete} />
|
|
|
|
|
+ ))}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
@@ -361,7 +428,6 @@ export function Models() {
|
|
|
}}>关闭</button>
|
|
}}>关闭</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Chat-like input */}
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
<input
|
|
<input
|
|
|
value={testPrompt}
|
|
value={testPrompt}
|
|
@@ -390,14 +456,12 @@ export function Models() {
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Error */}
|
|
|
|
|
{testError && (
|
|
{testError && (
|
|
|
<div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, color: '#e11d48', fontSize: 13, border: '1px solid #fecdd3' }}>
|
|
<div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, color: '#e11d48', fontSize: 13, border: '1px solid #fecdd3' }}>
|
|
|
{testError}
|
|
{testError}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* Result */}
|
|
|
|
|
{testResult && (
|
|
{testResult && (
|
|
|
<div style={{ marginTop: 16 }}>
|
|
<div style={{ marginTop: 16 }}>
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|