|
|
@@ -1,153 +1,736 @@
|
|
|
-import { useState, useEffect } from 'react'
|
|
|
-import api, { DeployResponse, TrainingJob } from '../api/client'
|
|
|
+import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
+import api, { DeployResponse, DeployedServiceInfo, TrainingJob, ApiKeyInfo, ApiKeyCreateResponse } from '../api/client'
|
|
|
|
|
|
-const EXPORT_FORMATS = [
|
|
|
- { value: 'safetensors', label: 'SafeTensors (推荐)' },
|
|
|
- { value: 'pytorch', label: 'PyTorch (.bin)' },
|
|
|
- { value: 'gguf', label: 'GGUF (llama.cpp)' },
|
|
|
-]
|
|
|
+type Tab = 'serve' | 'export'
|
|
|
|
|
|
export function Deployment() {
|
|
|
+ const [tab, setTab] = useState<Tab>('serve')
|
|
|
const [jobs, setJobs] = useState<TrainingJob[]>([])
|
|
|
- const [jobId, setJobId] = useState('')
|
|
|
- const [mergeWithBase, setMergeWithBase] = useState(false)
|
|
|
+ const [services, setServices] = useState<DeployedServiceInfo[]>([])
|
|
|
+ const [loadingServices, setLoadingServices] = useState(false)
|
|
|
+
|
|
|
+ // 导出状态
|
|
|
+ const [exportJobId, setExportJobId] = useState('')
|
|
|
+ const [exportMerge, setExportMerge] = useState(false)
|
|
|
const [exportFormat, setExportFormat] = useState('safetensors')
|
|
|
- const [running, setRunning] = useState(false)
|
|
|
- const [result, setResult] = useState<DeployResponse | null>(null)
|
|
|
- const [error, setError] = useState('')
|
|
|
+ const [exportRunning, setExportRunning] = useState(false)
|
|
|
+ const [exportResult, setExportResult] = useState<DeployResponse | null>(null)
|
|
|
+ const [exportError, setExportError] = useState('')
|
|
|
+
|
|
|
+ // 部署状态
|
|
|
+ const [serveJobId, setServeJobId] = useState('')
|
|
|
+ const [serveMerge, setServeMerge] = useState(true)
|
|
|
+ const [serveRunning, setServeRunning] = useState(false)
|
|
|
+ const [serveResult, setServeResult] = useState<DeployResponse | null>(null)
|
|
|
+ const [serveError, setServeError] = useState('')
|
|
|
+
|
|
|
+ // API Key 状态
|
|
|
+ const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([])
|
|
|
+ const [newKeyName, setNewKeyName] = useState('')
|
|
|
+ const [creatingKey, setCreatingKey] = useState(false)
|
|
|
+ const [justCreatedKey, setJustCreatedKey] = useState<ApiKeyCreateResponse | null>(null)
|
|
|
|
|
|
+ const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
+ const servicesPollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
+
|
|
|
+ // 加载 API Keys
|
|
|
+ const loadApiKeys = useCallback(() => {
|
|
|
+ api.apiKeys.list().then(setApiKeys).catch(() => setApiKeys([]))
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ // 加载已完成训练任务
|
|
|
useEffect(() => {
|
|
|
api.training.list()
|
|
|
.then(data => setJobs(data.filter(j => j.status === 'completed')))
|
|
|
.catch(() => setJobs([]))
|
|
|
}, [])
|
|
|
|
|
|
+ // 加载已部署服务列表
|
|
|
+ const loadServices = useCallback(() => {
|
|
|
+ setLoadingServices(true)
|
|
|
+ api.deployment.services()
|
|
|
+ .then(setServices)
|
|
|
+ .catch(() => setServices([]))
|
|
|
+ .finally(() => setLoadingServices(false))
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ loadServices()
|
|
|
+ loadApiKeys()
|
|
|
+ // 每 10 秒刷新服务列表
|
|
|
+ servicesPollingRef.current = setInterval(loadServices, 10000)
|
|
|
+ return () => {
|
|
|
+ if (servicesPollingRef.current) clearInterval(servicesPollingRef.current)
|
|
|
+ if (pollingRef.current) clearInterval(pollingRef.current)
|
|
|
+ }
|
|
|
+ }, [loadServices, loadApiKeys])
|
|
|
+
|
|
|
+ // 轮询部署任务状态
|
|
|
+ const startPolling = useCallback((taskId: string, mode: Tab) => {
|
|
|
+ if (pollingRef.current) clearInterval(pollingRef.current)
|
|
|
+ pollingRef.current = setInterval(async () => {
|
|
|
+ try {
|
|
|
+ const res = await api.deployment.status(taskId)
|
|
|
+ if (mode === 'serve') setServeResult(res)
|
|
|
+ else setExportResult(res)
|
|
|
+ if (res.status === 'completed' || res.status === 'running' || res.status === 'failed') {
|
|
|
+ if (pollingRef.current) clearInterval(pollingRef.current)
|
|
|
+ pollingRef.current = null
|
|
|
+ if (mode === 'serve') {
|
|
|
+ setServeRunning(false)
|
|
|
+ loadServices()
|
|
|
+ } else {
|
|
|
+ setExportRunning(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ }, 3000)
|
|
|
+ }, [loadServices])
|
|
|
+
|
|
|
const handleExport = () => {
|
|
|
- if (!jobId.trim()) return
|
|
|
- setRunning(true)
|
|
|
- setError('')
|
|
|
- setResult(null)
|
|
|
+ if (!exportJobId.trim()) return
|
|
|
+ setExportRunning(true)
|
|
|
+ setExportError('')
|
|
|
+ setExportResult(null)
|
|
|
api.deployment.export({
|
|
|
- job_id: jobId,
|
|
|
- merge_with_base: mergeWithBase,
|
|
|
+ job_id: exportJobId,
|
|
|
+ merge_with_base: exportMerge,
|
|
|
export_format: exportFormat,
|
|
|
})
|
|
|
- .then(setResult)
|
|
|
- .catch(err => setError(err instanceof Error ? err.message : '导出失败'))
|
|
|
- .finally(() => setRunning(false))
|
|
|
+ .then(res => {
|
|
|
+ setExportResult(res)
|
|
|
+ if (res.status === 'pending' && res.task_id) startPolling(res.task_id, 'export')
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ setExportError(err instanceof Error ? err.message : '导出失败')
|
|
|
+ setExportRunning(false)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleServe = () => {
|
|
|
+ if (!serveJobId.trim()) return
|
|
|
+ setServeRunning(true)
|
|
|
+ setServeError('')
|
|
|
+ setServeResult(null)
|
|
|
+ api.deployment.serve({
|
|
|
+ job_id: serveJobId,
|
|
|
+ merge_with_base: serveMerge,
|
|
|
+ })
|
|
|
+ .then(res => {
|
|
|
+ setServeResult(res)
|
|
|
+ if (res.status === 'pending' && res.task_id) startPolling(res.task_id, 'serve')
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ setServeError(err instanceof Error ? err.message : '部署失败')
|
|
|
+ setServeRunning(false)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleStop = (taskId: string) => {
|
|
|
+ api.deployment.stop(taskId)
|
|
|
+ .then(() => loadServices())
|
|
|
+ .catch(() => {})
|
|
|
+ }
|
|
|
+
|
|
|
+ const tabStyle = (active: boolean): React.CSSProperties => ({
|
|
|
+ padding: '8px 20px',
|
|
|
+ borderRadius: 8,
|
|
|
+ border: 'none',
|
|
|
+ background: active ? '#14b8a6' : 'transparent',
|
|
|
+ color: active ? '#fff' : '#64748b',
|
|
|
+ cursor: 'pointer',
|
|
|
+ fontSize: 14,
|
|
|
+ fontWeight: active ? 600 : 400,
|
|
|
+ transition: 'all 0.15s ease',
|
|
|
+ })
|
|
|
+
|
|
|
+ const selectStyle: React.CSSProperties = {
|
|
|
+ width: '100%', padding: '10px 12px', borderRadius: 8,
|
|
|
+ border: '1px solid #d0d0d0', boxSizing: 'border-box',
|
|
|
+ fontSize: 14, outline: 'none', background: '#fff',
|
|
|
+ transition: 'border-color 0.2s',
|
|
|
+ }
|
|
|
+
|
|
|
+ const btnPrimary: React.CSSProperties = {
|
|
|
+ padding: '10px 32px', borderRadius: 8, border: 'none',
|
|
|
+ background: '#14b8a6', color: '#fff', cursor: 'pointer',
|
|
|
+ fontSize: 14, fontWeight: 600, transition: 'all 0.2s ease',
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<div>
|
|
|
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型部署</h1>
|
|
|
- <p style={{ color: '#888', fontSize: 13, margin: '4px 0 16px' }}>导出训练好的模型用于生产部署</p>
|
|
|
+ <p style={{ color: '#888', fontSize: 13, margin: '4px 0 16px' }}>
|
|
|
+ 将训练好的模型部署为 OpenAI 兼容的推理服务,或导出模型文件
|
|
|
+ </p>
|
|
|
+
|
|
|
+ {/* Tab 切换 */}
|
|
|
+ <div style={{ display: 'flex', gap: 4, marginBottom: 20, background: '#f1f5f9', borderRadius: 10, padding: 4, width: 'fit-content' }}>
|
|
|
+ <button style={tabStyle(tab === 'serve')} onClick={() => setTab('serve')}>部署为服务</button>
|
|
|
+ <button style={tabStyle(tab === 'export')} onClick={() => setTab('export')}>导出文件</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 部署为服务 */}
|
|
|
+ {tab === 'serve' && (
|
|
|
+ <div style={{
|
|
|
+ background: '#fff', borderRadius: 12, padding: 24,
|
|
|
+ boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
+ }}>
|
|
|
+ <h2 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600 }}>部署为在线推理服务</h2>
|
|
|
+ <p style={{ margin: '0 0 16px', fontSize: 12, color: '#94a3b8' }}>
|
|
|
+ 启动 OpenAI 兼容 API 服务,可通过 base_url 在外部系统调用
|
|
|
+ </p>
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
|
|
+ <div>
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 6, fontWeight: 500 }}>训练任务</label>
|
|
|
+ <select
|
|
|
+ value={serveJobId}
|
|
|
+ onChange={e => setServeJobId(e.target.value)}
|
|
|
+ style={selectStyle}
|
|
|
+ onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
+ onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
|
|
|
+ >
|
|
|
+ <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 style={{ display: 'flex', alignItems: 'end' }}>
|
|
|
+ <label style={{
|
|
|
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer',
|
|
|
+ padding: '10px 14px', background: '#fafbfc', borderRadius: 8, border: '1px solid #f0f0f0',
|
|
|
+ }}>
|
|
|
+ <input type="checkbox" checked={serveMerge} onChange={e => setServeMerge(e.target.checked)} />
|
|
|
+ 合并基础模型(推荐)
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {serveError && (
|
|
|
+ <div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, fontSize: 13, color: '#e11d48', border: '1px solid #fecdd3' }}>
|
|
|
+ {serveError}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={handleServe}
|
|
|
+ disabled={serveRunning || !serveJobId}
|
|
|
+ style={{ ...btnPrimary, marginTop: 20, opacity: (serveRunning || !serveJobId) ? 0.5 : 1 }}
|
|
|
+ >
|
|
|
+ {serveRunning ? '部署中...' : '启动服务'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 导出文件 */}
|
|
|
+ {tab === 'export' && (
|
|
|
+ <div style={{
|
|
|
+ background: '#fff', borderRadius: 12, padding: 24,
|
|
|
+ boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
+ }}>
|
|
|
+ <h2 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600 }}>导出模型文件</h2>
|
|
|
+ <p style={{ margin: '0 0 16px', fontSize: 12, color: '#94a3b8' }}>
|
|
|
+ 导出合并后的模型文件,同时附带 server.py 和 start.sh 启动脚本
|
|
|
+ </p>
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, alignItems: 'end' }}>
|
|
|
+ <div>
|
|
|
+ <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 6, fontWeight: 500 }}>训练任务</label>
|
|
|
+ <select
|
|
|
+ value={exportJobId}
|
|
|
+ onChange={e => setExportJobId(e.target.value)}
|
|
|
+ style={selectStyle}
|
|
|
+ onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
+ onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
|
|
|
+ >
|
|
|
+ <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: 6, fontWeight: 500 }}>导出格式</label>
|
|
|
+ <select
|
|
|
+ value={exportFormat}
|
|
|
+ onChange={e => setExportFormat(e.target.value)}
|
|
|
+ style={selectStyle}
|
|
|
+ onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
+ onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
|
|
|
+ >
|
|
|
+ <option value="safetensors">SafeTensors (推荐)</option>
|
|
|
+ <option value="pytorch">PyTorch (.bin)</option>
|
|
|
+ <option value="gguf">GGUF (llama.cpp)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label style={{
|
|
|
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer',
|
|
|
+ padding: '10px 14px', background: '#fafbfc', borderRadius: 8, border: '1px solid #f0f0f0',
|
|
|
+ }}>
|
|
|
+ <input type="checkbox" checked={exportMerge} onChange={e => setExportMerge(e.target.checked)} />
|
|
|
+ 合并基础模型
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {exportError && (
|
|
|
+ <div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, fontSize: 13, color: '#e11d48', border: '1px solid #fecdd3' }}>
|
|
|
+ {exportError}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={handleExport}
|
|
|
+ disabled={exportRunning || !exportJobId}
|
|
|
+ style={{ ...btnPrimary, marginTop: 20, opacity: (exportRunning || !exportJobId) ? 0.5 : 1 }}
|
|
|
+ >
|
|
|
+ {exportRunning ? '导出中...' : '开始导出'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 当前任务状态 */}
|
|
|
+ {(serveResult || exportResult) && (
|
|
|
+ <div style={{
|
|
|
+ marginTop: 20, background: '#fff', borderRadius: 12, padding: 20,
|
|
|
+ boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
+ }}>
|
|
|
+ <h3 style={{ margin: '0 0 12px', fontSize: 14, fontWeight: 600 }}>任务状态</h3>
|
|
|
+ <TaskStatus result={serveResult || exportResult} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
+ {/* API Key 管理 */}
|
|
|
<div style={{
|
|
|
- background: '#fff', borderRadius: 12, padding: 24,
|
|
|
+ marginTop: 24, background: '#fff', borderRadius: 12, padding: 24,
|
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
}}>
|
|
|
- <h2 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>导出 Adapter</h2>
|
|
|
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, alignItems: 'end' }}>
|
|
|
- <div>
|
|
|
- <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 6, fontWeight: 500 }}>训练任务</label>
|
|
|
- <select
|
|
|
- value={jobId}
|
|
|
- onChange={e => setJobId(e.target.value)}
|
|
|
- style={{
|
|
|
- width: '100%', padding: '10px 12px', borderRadius: 8,
|
|
|
- border: '1px solid #d0d0d0', boxSizing: 'border-box',
|
|
|
- fontSize: 14, outline: 'none', background: '#fff',
|
|
|
- transition: 'border-color 0.2s',
|
|
|
- }}
|
|
|
- onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
- onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
|
|
|
- >
|
|
|
- <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: 6, fontWeight: 500 }}>导出格式</label>
|
|
|
- <select
|
|
|
- value={exportFormat}
|
|
|
- onChange={e => setExportFormat(e.target.value)}
|
|
|
- style={{
|
|
|
- width: '100%', padding: '10px 12px', borderRadius: 8,
|
|
|
- border: '1px solid #d0d0d0', boxSizing: 'border-box',
|
|
|
- fontSize: 14, outline: 'none', background: '#fff',
|
|
|
- transition: 'border-color 0.2s',
|
|
|
- }}
|
|
|
- onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
- onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
|
|
|
- >
|
|
|
- {EXPORT_FORMATS.map(f => (
|
|
|
- <option key={f.value} value={f.value}>{f.label}</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
+ <h2 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600 }}>API Key 管理</h2>
|
|
|
+ <p style={{ margin: '0 0 16px', fontSize: 12, color: '#94a3b8' }}>
|
|
|
+ 创建 API Key 用于外部系统调用已部署的推理服务
|
|
|
+ </p>
|
|
|
+
|
|
|
+ {/* 创建新 Key */}
|
|
|
+ <div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
|
|
+ <input
|
|
|
+ value={newKeyName}
|
|
|
+ onChange={e => setNewKeyName(e.target.value)}
|
|
|
+ placeholder="Key 名称(如:生产环境、测试)"
|
|
|
+ style={{
|
|
|
+ flex: 1, padding: '8px 12px', borderRadius: 8,
|
|
|
+ border: '1px solid #d0d0d0', fontSize: 13, outline: 'none',
|
|
|
+ }}
|
|
|
+ onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
|
|
|
+ onBlur={e => { e.currentTarget.style.borderColor = '#d0d0d0' }}
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setCreatingKey(true)
|
|
|
+ api.apiKeys.create(newKeyName || 'default')
|
|
|
+ .then(res => {
|
|
|
+ setJustCreatedKey(res)
|
|
|
+ setNewKeyName('')
|
|
|
+ loadApiKeys()
|
|
|
+ })
|
|
|
+ .catch(() => {})
|
|
|
+ .finally(() => setCreatingKey(false))
|
|
|
+ }}
|
|
|
+ disabled={creatingKey}
|
|
|
+ style={{
|
|
|
+ padding: '8px 20px', borderRadius: 8, border: 'none',
|
|
|
+ background: '#14b8a6', color: '#fff', cursor: 'pointer',
|
|
|
+ fontSize: 13, fontWeight: 500, opacity: creatingKey ? 0.5 : 1,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {creatingKey ? '创建中...' : '创建 Key'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 新创建的 Key 提示 */}
|
|
|
+ {justCreatedKey && (
|
|
|
+ <div style={{
|
|
|
+ padding: 14, background: '#f0fdfa', borderRadius: 8, border: '1px solid #ccfbf1',
|
|
|
+ marginBottom: 16,
|
|
|
+ }}>
|
|
|
+ <div style={{ fontSize: 12, color: '#0d9488', fontWeight: 600, marginBottom: 6 }}>
|
|
|
+ API Key 创建成功(仅显示一次,请立即保存)
|
|
|
+ </div>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
+ <code style={{
|
|
|
+ flex: 1, padding: '6px 10px', background: '#fff', borderRadius: 6,
|
|
|
+ border: '1px solid #e2e8f0', fontSize: 13, fontFamily: 'monospace',
|
|
|
+ wordBreak: 'break-all',
|
|
|
+ }}>
|
|
|
+ {justCreatedKey.key}
|
|
|
+ </code>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ navigator.clipboard.writeText(justCreatedKey.key)
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ padding: '6px 12px', borderRadius: 6, border: '1px solid #e2e8f0',
|
|
|
+ background: '#fff', cursor: 'pointer', fontSize: 12, whiteSpace: 'nowrap',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 复制
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => setJustCreatedKey(null)}
|
|
|
+ style={{
|
|
|
+ padding: '6px 12px', borderRadius: 6, border: '1px solid #e2e8f0',
|
|
|
+ background: '#fff', cursor: 'pointer', fontSize: 12, color: '#64748b',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 关闭
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div>
|
|
|
- <label style={{
|
|
|
- display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer',
|
|
|
- padding: '10px 14px', background: '#fafbfc', borderRadius: 8, border: '1px solid #f0f0f0',
|
|
|
- }}>
|
|
|
- <input type="checkbox" checked={mergeWithBase} onChange={e => setMergeWithBase(e.target.checked)} />
|
|
|
- 合并基础模型
|
|
|
- </label>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 已有 Key 列表 */}
|
|
|
+ {apiKeys.length === 0 ? (
|
|
|
+ <p style={{ color: '#94a3b8', fontSize: 13, margin: 0 }}>暂无 API Key</p>
|
|
|
+ ) : (
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
|
+ {apiKeys.map(k => (
|
|
|
+ <div key={k.id} style={{
|
|
|
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
|
+ padding: '10px 14px', borderRadius: 8,
|
|
|
+ background: k.status === 'active' ? '#fafbfc' : '#f8f8f8',
|
|
|
+ border: `1px solid ${k.status === 'active' ? '#e2e8f0' : '#eee'}`,
|
|
|
+ }}>
|
|
|
+ <div style={{ flex: 1 }}>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
+ <span style={{ fontSize: 13, fontWeight: 500 }}>{k.name}</span>
|
|
|
+ <span style={{
|
|
|
+ fontSize: 11, padding: '1px 6px', borderRadius: 10,
|
|
|
+ background: k.status === 'active' ? '#dcfce7' : '#f1f5f9',
|
|
|
+ color: k.status === 'active' ? '#16a34a' : '#94a3b8',
|
|
|
+ }}>
|
|
|
+ {k.status === 'active' ? '有效' : '已吊销'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div style={{ fontSize: 12, color: '#94a3b8', fontFamily: 'monospace', marginTop: 2 }}>
|
|
|
+ {k.key}
|
|
|
+ </div>
|
|
|
+ <div style={{ fontSize: 11, color: '#cbd5e1', marginTop: 2 }}>
|
|
|
+ 创建于 {k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}
|
|
|
+ {k.last_used_at && ` · 最后使用 ${new Date(k.last_used_at).toLocaleDateString()}`}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {k.status === 'active' && (
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ if (confirm('确定吊销此 API Key?吊销后不可恢复。')) {
|
|
|
+ api.apiKeys.revoke(k.id).then(() => loadApiKeys())
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ padding: '4px 10px', borderRadius: 6, border: '1px solid #fca5a5',
|
|
|
+ background: '#fff', color: '#dc2626', cursor: 'pointer', fontSize: 12,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 吊销
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 已部署服务列表 */}
|
|
|
+ <div style={{
|
|
|
+ marginTop: 24, background: '#fff', borderRadius: 12, padding: 24,
|
|
|
+ boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
+ }}>
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
|
+ <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>已部署服务</h2>
|
|
|
+ <button
|
|
|
+ onClick={loadServices}
|
|
|
+ style={{ padding: '6px 12px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#fff', cursor: 'pointer', fontSize: 12, color: '#64748b' }}
|
|
|
+ >
|
|
|
+ {loadingServices ? '刷新中...' : '刷新'}
|
|
|
+ </button>
|
|
|
</div>
|
|
|
|
|
|
- {error && (
|
|
|
- <div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, fontSize: 13, color: '#e11d48', border: '1px solid #fecdd3' }}>
|
|
|
- {error}
|
|
|
+ {services.length === 0 ? (
|
|
|
+ <p style={{ color: '#94a3b8', fontSize: 13, margin: 0 }}>暂无已部署的服务</p>
|
|
|
+ ) : (
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
|
+ {services.map(svc => (
|
|
|
+ <ServiceCard key={svc.task_id} service={svc} onStop={() => handleStop(svc.task_id)} />
|
|
|
+ ))}
|
|
|
</div>
|
|
|
)}
|
|
|
+ </div>
|
|
|
|
|
|
+ <style>{`
|
|
|
+ @keyframes spin {
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
+ }
|
|
|
+ @keyframes pulse {
|
|
|
+ 0%, 100% { opacity: 1; }
|
|
|
+ 50% { opacity: 0.4; }
|
|
|
+ }
|
|
|
+ `}</style>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function TaskStatus({ result }: { result: DeployResponse }) {
|
|
|
+ const isPending = result.status === 'pending' || result.status === 'running'
|
|
|
+ const isFailed = result.status === 'failed'
|
|
|
+ const isSuccess = result.status === 'completed' || result.status === 'running'
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
|
|
+ <InfoBox label="任务 ID" value={result.job_id} mono />
|
|
|
+ <InfoBox
|
|
|
+ label="状态"
|
|
|
+ value={result.status}
|
|
|
+ color={isFailed ? '#e11d48' : isSuccess ? '#059669' : '#d97706'}
|
|
|
+ bg={isFailed ? '#fff1f2' : isSuccess ? '#ecfdf5' : '#fffbeb'}
|
|
|
+ borderColor={isFailed ? '#fecdd3' : isSuccess ? '#d1fae5' : '#fde68a'}
|
|
|
+ />
|
|
|
+ {result.endpoint_url && (
|
|
|
+ <InfoBox label="Endpoint URL" value={result.endpoint_url} mono wide />
|
|
|
+ )}
|
|
|
+ {result.output_path && (
|
|
|
+ <InfoBox label="输出路径" value={result.output_path} mono wide />
|
|
|
+ )}
|
|
|
+ {result.error && (
|
|
|
+ <InfoBox label="错误" value={result.error} color="#e11d48" bg="#fff1f2" borderColor="#fecdd3" wide />
|
|
|
+ )}
|
|
|
+ {isPending && (
|
|
|
+ <div style={{ gridColumn: '1 / -1', padding: 12, background: '#f0fdfa', borderRadius: 8, border: '1px solid #ccfbf1', display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
+ <Spinner />
|
|
|
+ <span style={{ fontSize: 13, color: '#134e4a' }}>
|
|
|
+ {result.status === 'pending' ? '任务排队中...' : '任务执行中...'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function ServiceCard({ service, onStop }: { service: DeployedServiceInfo; onStop: () => void }) {
|
|
|
+ const [showUsage, setShowUsage] = useState(false)
|
|
|
+ const isRunning = service.status === 'running'
|
|
|
+ // endpoint_url 是相对路径(如 /api/v1/deployment/proxy/{task_id}/v1),拼接完整 URL
|
|
|
+ const relativeUrl = service.endpoint_url || service.base_url || ''
|
|
|
+ const baseUrl = relativeUrl ? `${window.location.origin}${relativeUrl}` : ''
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{
|
|
|
+ padding: 16, borderRadius: 10,
|
|
|
+ border: `1px solid ${isRunning ? '#ccfbf1' : '#f1f5f9'}`,
|
|
|
+ background: isRunning ? '#f0fdfa' : '#fafbfc',
|
|
|
+ }}>
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
|
+ <div style={{ flex: 1 }}>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
|
+ <StatusBadge status={service.status} />
|
|
|
+ <span style={{ fontSize: 13, color: '#64748b', fontFamily: 'monospace' }}>
|
|
|
+ Job: {service.job_id.slice(0, 8)}...
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ {baseUrl && isRunning && (
|
|
|
+ <div style={{ marginTop: 6 }}>
|
|
|
+ <span style={{ fontSize: 11, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.5px' }}>base_url</span>
|
|
|
+ <div style={{
|
|
|
+ fontFamily: 'monospace', fontSize: 14, fontWeight: 600, color: '#134e4a',
|
|
|
+ background: '#fff', padding: '6px 10px', borderRadius: 6, marginTop: 3,
|
|
|
+ border: '1px solid #e2e8f0', display: 'inline-block',
|
|
|
+ }}>
|
|
|
+ {baseUrl}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {service.error && (
|
|
|
+ <div style={{ marginTop: 8, fontSize: 12, color: '#e11d48' }}>{service.error}</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div style={{ display: 'flex', gap: 6 }}>
|
|
|
+ {isRunning && (
|
|
|
+ <>
|
|
|
+ <button
|
|
|
+ onClick={() => setShowUsage(!showUsage)}
|
|
|
+ style={{
|
|
|
+ padding: '6px 12px', borderRadius: 6,
|
|
|
+ border: '1px solid #14b8a6', background: showUsage ? '#14b8a6' : '#fff',
|
|
|
+ color: showUsage ? '#fff' : '#14b8a6',
|
|
|
+ cursor: 'pointer', fontSize: 12, fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {showUsage ? '收起示例' : '调用示例'}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={onStop}
|
|
|
+ style={{
|
|
|
+ padding: '6px 12px', borderRadius: 6,
|
|
|
+ border: '1px solid #fca5a5', background: '#fff', color: '#dc2626',
|
|
|
+ cursor: 'pointer', fontSize: 12, fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 停止
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {showUsage && baseUrl && (
|
|
|
+ <div style={{ marginTop: 14, borderTop: '1px solid #e2e8f0', paddingTop: 14 }}>
|
|
|
+ <UsageExamples baseUrl={baseUrl} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function UsageExamples({ baseUrl }: { baseUrl: string }) {
|
|
|
+ const curlExample = `curl ${baseUrl}/chat/completions \\
|
|
|
+ -H "Content-Type: application/json" \\
|
|
|
+ -H "Authorization: Bearer YOUR_API_KEY" \\
|
|
|
+ -d '{
|
|
|
+ "model": "local-model",
|
|
|
+ "messages": [{"role": "user", "content": "你好"}],
|
|
|
+ "max_tokens": 512,
|
|
|
+ "temperature": 0.7
|
|
|
+ }'`
|
|
|
+
|
|
|
+ const pythonExample = `from openai import OpenAI
|
|
|
+
|
|
|
+client = OpenAI(
|
|
|
+ base_url="${baseUrl}",
|
|
|
+ api_key="sk-xxx" # 替换为你的 API Key
|
|
|
+)
|
|
|
+
|
|
|
+response = client.chat.completions.create(
|
|
|
+ model="local-model",
|
|
|
+ messages=[{"role": "user", "content": "你好"}],
|
|
|
+ max_tokens=512,
|
|
|
+ temperature=0.7
|
|
|
+)
|
|
|
+print(response.choices[0].message.content)`
|
|
|
+
|
|
|
+ const jsExample = `import OpenAI from 'openai'
|
|
|
+
|
|
|
+const client = new OpenAI({
|
|
|
+ baseURL: '${baseUrl}',
|
|
|
+ apiKey: 'sk-xxx' // 替换为你的 API Key
|
|
|
+})
|
|
|
+
|
|
|
+const response = await client.chat.completions.create({
|
|
|
+ model: 'local-model',
|
|
|
+ messages: [{ role: 'user', content: '你好' }],
|
|
|
+ max_tokens: 512,
|
|
|
+ temperature: 0.7
|
|
|
+})
|
|
|
+console.log(response.choices[0].message.content)`
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <p style={{ margin: '0 0 10px', fontSize: 12, color: '#64748b' }}>
|
|
|
+ 使用 OpenAI 兼容接口调用,需要在请求头中携带 API Key(在上方「API Key 管理」中创建):
|
|
|
+ </p>
|
|
|
+ <CodeBlock title="curl" code={curlExample} />
|
|
|
+ <CodeBlock title="Python (openai SDK)" code={pythonExample} />
|
|
|
+ <CodeBlock title="JavaScript / TypeScript" code={jsExample} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function CodeBlock({ title, code }: { title: string; code: string }) {
|
|
|
+ const [copied, setCopied] = useState(false)
|
|
|
+
|
|
|
+ const handleCopy = () => {
|
|
|
+ navigator.clipboard.writeText(code).then(() => {
|
|
|
+ setCopied(true)
|
|
|
+ setTimeout(() => setCopied(false), 2000)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ marginBottom: 10 }}>
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
|
|
+ <span style={{ fontSize: 11, color: '#94a3b8', fontWeight: 500 }}>{title}</span>
|
|
|
<button
|
|
|
- onClick={handleExport}
|
|
|
- disabled={running || !jobId}
|
|
|
+ onClick={handleCopy}
|
|
|
style={{
|
|
|
- marginTop: 20, padding: '10px 32px', borderRadius: 8, border: 'none',
|
|
|
- background: '#14b8a6', color: '#fff', cursor: 'pointer',
|
|
|
- opacity: (running || !jobId) ? 0.5 : 1, fontSize: 14, fontWeight: 600,
|
|
|
- transition: 'all 0.2s ease',
|
|
|
+ padding: '2px 8px', borderRadius: 4, border: '1px solid #e2e8f0',
|
|
|
+ background: '#fff', cursor: 'pointer', fontSize: 11, color: '#64748b',
|
|
|
}}
|
|
|
>
|
|
|
- {running ? '导出中...' : '开始导出'}
|
|
|
+ {copied ? '已复制' : '复制'}
|
|
|
</button>
|
|
|
</div>
|
|
|
+ <pre style={{
|
|
|
+ background: '#1e293b', color: '#e2e8f0', padding: 12, borderRadius: 8,
|
|
|
+ fontSize: 12, margin: 0, overflow: 'auto', fontFamily: 'monospace',
|
|
|
+ lineHeight: 1.5,
|
|
|
+ }}>
|
|
|
+ {code}
|
|
|
+ </pre>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
|
|
|
- {result && (
|
|
|
- <div style={{
|
|
|
- marginTop: 24, background: '#fff', borderRadius: 12, padding: 24,
|
|
|
- boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
|
|
|
- }}>
|
|
|
- <h3 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>导出状态</h3>
|
|
|
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
|
|
- <div style={{ padding: '14px 16px', background: '#fafbfc', borderRadius: 8, border: '1px solid #f0f0f0' }}>
|
|
|
- <div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>任务 ID</div>
|
|
|
- <div style={{ fontSize: 14, fontFamily: 'monospace' }}>{result.job_id}</div>
|
|
|
- </div>
|
|
|
- <div style={{ padding: '14px 16px', background: result.error ? '#fff1f2' : '#ecfdf5', borderRadius: 8, border: `1px solid ${result.error ? '#fecdd3' : '#d1fae5'}` }}>
|
|
|
- <div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>状态</div>
|
|
|
- <div style={{ color: result.error ? '#e11d48' : '#059669', fontWeight: 600, fontSize: 14 }}>{result.status}</div>
|
|
|
- </div>
|
|
|
- {result.output_path && (
|
|
|
- <div style={{ padding: '14px 16px', background: '#fafbfc', borderRadius: 8, border: '1px solid #f0f0f0' }}>
|
|
|
- <div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>输出路径</div>
|
|
|
- <div style={{ fontSize: 13, fontFamily: 'monospace', wordBreak: 'break-all' }}>{result.output_path}</div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {result.error && (
|
|
|
- <div style={{ padding: '14px 16px', background: '#fff1f2', borderRadius: 8, border: '1px solid #fecdd3' }}>
|
|
|
- <div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>错误</div>
|
|
|
- <div style={{ color: '#e11d48', fontSize: 13 }}>{result.error}</div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+function StatusBadge({ status }: { status: string }) {
|
|
|
+ const map: Record<string, { label: string; bg: string; color: string }> = {
|
|
|
+ running: { label: '运行中', bg: '#dcfce7', color: '#16a34a' },
|
|
|
+ pending: { label: '排队中', bg: '#fef9c3', color: '#a16207' },
|
|
|
+ completed: { label: '已完成', bg: '#dcfce7', color: '#16a34a' },
|
|
|
+ stopped: { label: '已停止', bg: '#f1f5f9', color: '#64748b' },
|
|
|
+ failed: { label: '失败', bg: '#fee2e2', color: '#dc2626' },
|
|
|
+ }
|
|
|
+ const { label, bg, color } = map[status] || { label: status, bg: '#f1f5f9', color: '#64748b' }
|
|
|
+ return (
|
|
|
+ <span style={{
|
|
|
+ display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
|
+ padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 600,
|
|
|
+ background: bg, color,
|
|
|
+ }}>
|
|
|
+ {status === 'running' && <span style={{ width: 6, height: 6, borderRadius: '50%', background: color, animation: 'pulse 2s infinite' }} />}
|
|
|
+ {label}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function InfoBox({ label, value, mono, color, bg, borderColor, wide }: {
|
|
|
+ label: string; value: string; mono?: boolean
|
|
|
+ color?: string; bg?: string; borderColor?: string; wide?: boolean
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div style={{
|
|
|
+ gridColumn: wide ? '1 / -1' : undefined,
|
|
|
+ padding: '10px 14px', borderRadius: 8,
|
|
|
+ background: bg || '#fafbfc',
|
|
|
+ border: `1px solid ${borderColor || '#f0f0f0'}`,
|
|
|
+ }}>
|
|
|
+ <div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 3 }}>{label}</div>
|
|
|
+ <div style={{
|
|
|
+ fontSize: 13, fontWeight: 500,
|
|
|
+ fontFamily: mono ? 'monospace' : undefined,
|
|
|
+ color: color || '#1e293b',
|
|
|
+ wordBreak: 'break-all',
|
|
|
+ }}>{value}</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
+
|
|
|
+function Spinner() {
|
|
|
+ return (
|
|
|
+ <div style={{
|
|
|
+ width: 16, height: 16,
|
|
|
+ border: '2px solid #14b8a6',
|
|
|
+ borderTopColor: 'transparent',
|
|
|
+ borderRadius: '50%',
|
|
|
+ animation: 'spin 1s linear infinite',
|
|
|
+ }} />
|
|
|
+ )
|
|
|
+}
|