fix: 推理/评测结果同步、数据集统计与算力节点管理增强
后端: - 抽取 fetch_eval_result_content 复用函数,model_eval_detail 直接应用评测任务结果 - health 接口移除数据库依赖,返回静态指标 - 数据集: count_dataset_records JSON 感知计数; 文件统计改为从 dataset_files 聚合重算; 在线编辑记录 size/record_count/version_no; 上传同步批处理 - 算力节点: 调度支持 requested GPU 子集校验与容量计算; 新增 delete_compute_node(含活动任务保护)及 DELETE 接口; 连接池 connect_timeout - 评测任务落库 basic_metrics/score/completed_time, failed/stopped 记录 error 评测引擎: - _load_dataset 支持 JSON/JSONL 文件 - 新增 exact match 与文本相似度指标, 余弦相似度去掉 2 样本限制 前端: - 算力节点列表「维护」改为「删除」(带确认弹窗), compute.ts 新增 deleteComputeNode - 数据集上传超时调整为 120s; FineTuneTask 增加 compute_node_id; GpuInfo 状态增加 reserved
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
checkNodeReplicaDrift,
|
||||
createComputeNode,
|
||||
deleteComputeNode,
|
||||
disableComputeNode,
|
||||
drainComputeNode,
|
||||
enableComputeNode,
|
||||
getComputeGpus,
|
||||
getComputeNodes,
|
||||
@@ -129,11 +129,10 @@ async function changeTab(name: string | number) {
|
||||
await router.replace({ path: '/compute', query: { tab: String(name) } })
|
||||
}
|
||||
|
||||
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: ComputeNode) {
|
||||
async function handleNodeAction(action: 'enable' | 'disable' | 'test', node: ComputeNode) {
|
||||
const nodeId = String(node.id)
|
||||
if (action === 'enable') await enableComputeNode(nodeId)
|
||||
if (action === 'disable') await disableComputeNode(nodeId)
|
||||
if (action === 'drain') await drainComputeNode(nodeId)
|
||||
if (action === 'test') {
|
||||
const result = await testComputeNode(nodeId)
|
||||
if (result.success) {
|
||||
@@ -145,6 +144,27 @@ async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test',
|
||||
await load()
|
||||
}
|
||||
|
||||
async function handleDeleteNode(node: ComputeNode) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除算力节点「${node.name || node.code}」吗?节点删除后,其 GPU 设备和资源副本记录也会一并移除。`,
|
||||
'删除算力节点',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteComputeNode(String(node.id))
|
||||
ElMessage.success('算力节点已删除')
|
||||
if (selectedNodeId.value === node.id) selectedNodeId.value = ''
|
||||
await load({ showButtonLoading: true })
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.detail?.message || err?.response?.data?.message || '删除算力节点失败'
|
||||
ElMessage.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReplicaDriftCheck() {
|
||||
if (!selectedNodeId.value) return
|
||||
checkingReplicas.value = true
|
||||
@@ -355,7 +375,7 @@ onUnmounted(() => {
|
||||
<el-button size="small" @click="handleNodeAction('test', asComputeNode(row))">测试</el-button>
|
||||
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', asComputeNode(row))">停用</el-button>
|
||||
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', asComputeNode(row))">启用</el-button>
|
||||
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', asComputeNode(row))">维护</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDeleteNode(asComputeNode(row))">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -50,6 +50,20 @@ const rules: FormRules = {
|
||||
}
|
||||
|
||||
/** 处理文件选择(替换模式:新文件覆盖旧文件) */
|
||||
function parseDatasetRecordValues(text: string, fileName: string): unknown[] {
|
||||
const content = text.trim()
|
||||
if (!content) return []
|
||||
if (fileName.toLowerCase().endsWith('.json')) {
|
||||
const parsed = JSON.parse(content)
|
||||
return Array.isArray(parsed) ? parsed : [parsed]
|
||||
}
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
}
|
||||
|
||||
async function handleFileChange(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw
|
||||
if (!raw) return
|
||||
@@ -70,25 +84,19 @@ async function handleFileChange(uploadFile: UploadFile) {
|
||||
async function analyzeFile(file: File) {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const lines = text.trim().split('\n').filter(Boolean)
|
||||
fileCount.value = lines.length
|
||||
const records = parseDatasetRecordValues(text, file.name)
|
||||
fileCount.value = records.length
|
||||
|
||||
// Alpaca 格式校验:每行 JSON 须含 instruction 字段
|
||||
let validCount = 0
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
if (obj.instruction !== undefined) validCount++
|
||||
} catch {
|
||||
// 非 JSON 行(如纯 JSONL 多行结构)
|
||||
}
|
||||
}
|
||||
if (validCount > 0 && validCount === lines.length) {
|
||||
const validCount = records.filter(
|
||||
(obj) => obj && typeof obj === 'object' && 'instruction' in obj,
|
||||
).length
|
||||
if (validCount > 0 && validCount === records.length) {
|
||||
formatValid.value = true
|
||||
formatMessage.value = `符合 Alpaca 格式(含 instruction 字段)`
|
||||
} else if (validCount > 0) {
|
||||
formatValid.value = true
|
||||
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${lines.length})`
|
||||
formatMessage.value = `部分符合 Alpaca 格式(${validCount}/${records.length})`
|
||||
} else {
|
||||
formatValid.value = false
|
||||
formatMessage.value = '未检测到标准 Alpaca 格式(缺少 instruction 字段),仍可上传'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||
import { getEvalDetail } from '@/api/modules/eval'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -16,6 +17,7 @@ const keyword = ref('')
|
||||
const judgementFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
|
||||
|
||||
const filteredSamples = computed(() => {
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||
@@ -74,8 +76,8 @@ function resetPage() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
async function loadDetail(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
detail.value = await getEvalDetail(taskId)
|
||||
@@ -83,11 +85,29 @@ async function loadDetail() {
|
||||
detail.value = null
|
||||
loadError.value = '评测详情加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!options.silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(
|
||||
async () => {
|
||||
await loadDetail({ silent: true })
|
||||
if (!ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail()
|
||||
if (ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +133,7 @@ onMounted(loadDetail)
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true" />
|
||||
<h2>无法加载评测详情</h2>
|
||||
<p>{{ loadError }}</p>
|
||||
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
||||
<el-button type="primary" @click="() => loadDetail()">重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
|
||||
@@ -24,14 +24,16 @@ const leaderboard = ref([
|
||||
{ rank: 3, name: 'Qwen-Max', score: 85.3 },
|
||||
])
|
||||
|
||||
async function loadEvalList() {
|
||||
evalLoading.value = true
|
||||
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
|
||||
|
||||
async function loadEvalList(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) evalLoading.value = true
|
||||
try {
|
||||
evalList.value = (await getEvalList()) || []
|
||||
} catch {
|
||||
evalList.value = []
|
||||
} finally {
|
||||
evalLoading.value = false
|
||||
if (!options.silent) evalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +58,21 @@ function handleViewDetail(row: any) {
|
||||
}
|
||||
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(
|
||||
() => loadEvalList(),
|
||||
async () => {
|
||||
await loadEvalList({ silent: true })
|
||||
if (!evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEvalList()
|
||||
startPolling()
|
||||
if (evalList.value.some((item) => ACTIVE_STATUSES.has(String(item.status || '')))) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ const models = ref<ModelItem[]>([])
|
||||
const datasets = ref<DatasetItem[]>([])
|
||||
const gpus = ref<GpuInfo[]>([])
|
||||
const computeNodes = ref<Array<{ id: string; scheduler_status?: string }>>([])
|
||||
const selectedGpuId = ref<number | null>(null)
|
||||
const selectedGpuKeys = ref<string[]>([])
|
||||
|
||||
/** Only show GPUs from nodes that are online or draining */
|
||||
const availableGpus = computed(() => {
|
||||
@@ -74,7 +74,13 @@ const selectedModel = computed(() => models.value.find((model) => model.id === f
|
||||
const modelDialogTitle = computed(() => selectedModel.value?.name || '')
|
||||
|
||||
/** 训练命令与提交载荷共用同一份表单模型。 */
|
||||
const selectedGpuIds = computed(() => (selectedGpuId.value != null ? [selectedGpuId.value] : []))
|
||||
const selectedGpus = computed(() =>
|
||||
selectedGpuKeys.value
|
||||
.map((key) => availableGpus.value.find((gpu) => gpuKey(gpu) === key))
|
||||
.filter((gpu): gpu is GpuInfo => Boolean(gpu)),
|
||||
)
|
||||
const selectedComputeNodeId = computed(() => selectedGpus.value[0]?.node_id)
|
||||
const selectedGpuIds = computed(() => selectedGpus.value.map((gpu) => Number(gpu.id)))
|
||||
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpuIds.value))
|
||||
|
||||
const remoteCommandPreview = computed(() => {
|
||||
@@ -83,9 +89,32 @@ const remoteCommandPreview = computed(() => {
|
||||
return preflightResult.value?.preview?.command_text || ''
|
||||
})
|
||||
|
||||
/** GPU 单选切换(每次只选中一张 GPU) */
|
||||
function toggleGpu(gpuId: number) {
|
||||
selectedGpuId.value = selectedGpuId.value === gpuId ? null : gpuId
|
||||
function gpuKey(gpu: GpuInfo) {
|
||||
return `${gpu.node_id || 'local'}:${gpu.id ?? gpu.uuid ?? gpu.name}`
|
||||
}
|
||||
|
||||
function isGpuUnavailable(gpu: GpuInfo) {
|
||||
return gpu.status === 'busy' || gpu.status === 'reserved' || gpu.status === 'offline'
|
||||
}
|
||||
|
||||
function isGpuSelected(gpu: GpuInfo) {
|
||||
return selectedGpuKeys.value.includes(gpuKey(gpu))
|
||||
}
|
||||
|
||||
/** GPU 多选切换:单个任务只允许选择同一算力节点内的空闲卡。 */
|
||||
function toggleGpu(gpu: GpuInfo) {
|
||||
if (isGpuUnavailable(gpu) || gpu.id == null) return
|
||||
const key = gpuKey(gpu)
|
||||
if (isGpuSelected(gpu)) {
|
||||
selectedGpuKeys.value = selectedGpuKeys.value.filter((item) => item !== key)
|
||||
return
|
||||
}
|
||||
if (selectedComputeNodeId.value && gpu.node_id && selectedComputeNodeId.value !== gpu.node_id) {
|
||||
selectedGpuKeys.value = [key]
|
||||
ElMessage.info('已切换到新的算力节点,之前选择的 GPU 已清空')
|
||||
return
|
||||
}
|
||||
selectedGpuKeys.value = [...selectedGpuKeys.value, key]
|
||||
}
|
||||
|
||||
function gpuUsageWidth(percent: number) {
|
||||
@@ -178,8 +207,8 @@ async function loadGpus() {
|
||||
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
|
||||
gpus.value = sys?.gpu || []
|
||||
computeNodes.value = nodes || []
|
||||
// Default select first available GPU
|
||||
if (availableGpus.value.length > 0) selectedGpuId.value = availableGpus.value[0].id ?? null
|
||||
const firstIdle = availableGpus.value.find((gpu) => !isGpuUnavailable(gpu) && gpu.id != null)
|
||||
if (firstIdle) selectedGpuKeys.value = [gpuKey(firstIdle)]
|
||||
} catch {
|
||||
gpus.value = []
|
||||
}
|
||||
@@ -189,8 +218,8 @@ async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (selectedGpuId.value == null) {
|
||||
ElMessage.warning('请选择一个 GPU')
|
||||
if (!selectedGpuIds.value.length) {
|
||||
ElMessage.warning('请至少选择一张空闲 GPU')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
@@ -207,7 +236,7 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildFineTunePayload(form, selectedGpuIds.value)
|
||||
const payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)
|
||||
const preflight = await runPreflight(payload)
|
||||
if (!preflight?.valid) {
|
||||
ElMessage.error('训练预检未通过,请先处理预检问题')
|
||||
@@ -232,7 +261,7 @@ async function handleSubmit() {
|
||||
})
|
||||
}
|
||||
|
||||
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value)) {
|
||||
async function runPreflight(payload = buildFineTunePayload(form, selectedGpuIds.value, selectedComputeNodeId.value)) {
|
||||
preflightLoading.value = true
|
||||
try {
|
||||
const result = await preflightFineTune(payload)
|
||||
@@ -261,8 +290,8 @@ async function handlePreflightClick() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (selectedGpuId.value == null) {
|
||||
ElMessage.warning('请选择一个 GPU')
|
||||
if (!selectedGpuIds.value.length) {
|
||||
ElMessage.warning('请至少选择一张空闲 GPU')
|
||||
return
|
||||
}
|
||||
await runPreflight()
|
||||
@@ -297,12 +326,16 @@ onMounted(() => {
|
||||
<el-divider content-position="left">训练配置</el-divider>
|
||||
<el-form-item label="GPU 硬件">
|
||||
<div class="gpu-list">
|
||||
<div class="gpu-selection-summary">
|
||||
已选择 {{ selectedGpuIds.length }} 张 GPU
|
||||
<template v-if="selectedGpus[0]?.node_code"> · {{ selectedGpus[0].node_code }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="gpu in availableGpus"
|
||||
:key="gpu.id"
|
||||
:key="gpuKey(gpu)"
|
||||
class="gpu-card"
|
||||
:class="{ active: selectedGpuId === gpu.id, 'is-busy': gpu.gpu_percent > 80 }"
|
||||
@click="toggleGpu(gpu.id!)"
|
||||
:class="{ active: isGpuSelected(gpu), 'is-busy': isGpuUnavailable(gpu), 'is-disabled': isGpuUnavailable(gpu) }"
|
||||
@click="toggleGpu(gpu)"
|
||||
>
|
||||
<div class="gpu-card-top">
|
||||
<div class="gpu-title">
|
||||
@@ -312,7 +345,7 @@ onMounted(() => {
|
||||
</span>
|
||||
<span class="gpu-name">{{ gpu.name }}</span>
|
||||
</div>
|
||||
<span class="gpu-usage">{{ gpu.gpu_percent }}%</span>
|
||||
<span class="gpu-usage">{{ isGpuUnavailable(gpu) ? gpu.status : `${gpu.gpu_percent}%` }}</span>
|
||||
</div>
|
||||
<div class="gpu-usage-bar">
|
||||
<span :style="{ width: gpuUsageWidth(gpu.gpu_percent) }" />
|
||||
@@ -578,6 +611,13 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gpu-selection-summary {
|
||||
grid-column: 1 / -1;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.gpu-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
@@ -627,6 +667,11 @@ onMounted(() => {
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.gpu-card-top {
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createDefaultFineTuneForm(): FineTuneFormModel {
|
||||
export function buildFineTunePayload(
|
||||
form: FineTuneFormModel,
|
||||
gpus: number[],
|
||||
computeNodeId?: string,
|
||||
): Omit<FineTuneStartPayload, 'task_id'> {
|
||||
return {
|
||||
name: form.name,
|
||||
@@ -77,6 +78,7 @@ export function buildFineTunePayload(
|
||||
train_dataset_id: form.train_dataset_id,
|
||||
auto_merge: form.train_type === 'SFT' && form.auto_merge,
|
||||
output_model_name: form.name,
|
||||
compute_node_id: computeNodeId,
|
||||
batch_size: form.batch_size,
|
||||
learning_rate: form.learning_rate,
|
||||
n_epochs: form.n_epochs,
|
||||
|
||||
Reference in New Issue
Block a user