226 lines
6.0 KiB
Vue
226 lines
6.0 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|||
|
|
import { useRoute } from 'vue-router'
|
|||
|
|
import MarkdownView from '@/components/MarkdownView.vue'
|
|||
|
|
import {
|
|||
|
|
getCompare,
|
|||
|
|
chatWithPort,
|
|||
|
|
batchChat,
|
|||
|
|
} from '@/api/modules/compare'
|
|||
|
|
import { getModelByName } from '@/api/modules/model'
|
|||
|
|
import type { CompareTask, LoadedModel } from '@/types'
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const taskId = route.query.taskId as string
|
|||
|
|
const question = decodeURIComponent(route.query.question as string || '')
|
|||
|
|
const systemPrompt = decodeURIComponent(route.query.systemPrompt as string || '')
|
|||
|
|
const temperature = Number(route.query.temperature || 0.7)
|
|||
|
|
const topP = Number(route.query.topP || 0.9)
|
|||
|
|
const topK = Number(route.query.topK || 40)
|
|||
|
|
const maxTokens = Number(route.query.maxTokens || 2048)
|
|||
|
|
|
|||
|
|
const taskName = route.query.taskName as string
|
|||
|
|
|
|||
|
|
interface ModelResult {
|
|||
|
|
name: string
|
|||
|
|
content: string
|
|||
|
|
displayContent: string
|
|||
|
|
status: 'loading' | 'done' | 'error'
|
|||
|
|
stats?: { charsPerSec?: number; totalTime?: number }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const results = ref<ModelResult[]>([])
|
|||
|
|
const started = ref(false)
|
|||
|
|
|
|||
|
|
const loadedModels = ref<LoadedModel[]>([])
|
|||
|
|
|
|||
|
|
async function init() {
|
|||
|
|
if (started.value) return
|
|||
|
|
started.value = true
|
|||
|
|
try {
|
|||
|
|
const task: any = await getCompare(taskId)
|
|||
|
|
let models: LoadedModel[] = []
|
|||
|
|
if (task.load_status) {
|
|||
|
|
const ls = typeof task.load_status === 'string' ? JSON.parse(task.load_status) : task.load_status
|
|||
|
|
models = ls.loaded_models || []
|
|||
|
|
}
|
|||
|
|
loadedModels.value = models
|
|||
|
|
// 初始化结果占位
|
|||
|
|
results.value = models.map((m) => ({
|
|||
|
|
name: m.model_name || '模型',
|
|||
|
|
content: '',
|
|||
|
|
displayContent: '',
|
|||
|
|
status: 'loading',
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
// 并行推理
|
|||
|
|
await Promise.all(models.map((m, idx) => inferOne(m, idx)))
|
|||
|
|
} catch (e) {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 单模型推理(带超时) */
|
|||
|
|
async function inferOne(model: LoadedModel, idx: number) {
|
|||
|
|
const startTime = Date.now()
|
|||
|
|
try {
|
|||
|
|
// 尝试通过端口代理调用
|
|||
|
|
const res: any = await Promise.race([
|
|||
|
|
chatWithPort({
|
|||
|
|
port: model.port,
|
|||
|
|
model_name: model.model_name,
|
|||
|
|
messages: [
|
|||
|
|
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
|||
|
|
{ role: 'user', content: question },
|
|||
|
|
],
|
|||
|
|
temperature,
|
|||
|
|
top_p: topP,
|
|||
|
|
top_k: topK,
|
|||
|
|
max_tokens: maxTokens,
|
|||
|
|
}),
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
const content = res?.response || res?.content || res?.data || JSON.stringify(res)
|
|||
|
|
const totalTime = (Date.now() - startTime) / 1000
|
|||
|
|
results.value[idx].content = content
|
|||
|
|
results.value[idx].status = 'done'
|
|||
|
|
results.value[idx].stats = {
|
|||
|
|
totalTime,
|
|||
|
|
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0,
|
|||
|
|
}
|
|||
|
|
// 模拟打字机效果
|
|||
|
|
typewriterDisplay(idx, content)
|
|||
|
|
} catch (e: any) {
|
|||
|
|
results.value[idx].content = '推理失败: ' + (e.message || '')
|
|||
|
|
results.value[idx].status = 'error'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 打字机效果逐字展示 */
|
|||
|
|
function typewriterDisplay(idx: number, content: string) {
|
|||
|
|
let pos = 0
|
|||
|
|
const interval = setInterval(() => {
|
|||
|
|
pos += 2
|
|||
|
|
results.value[idx].displayContent = content.slice(0, pos)
|
|||
|
|
if (pos >= content.length) {
|
|||
|
|
clearInterval(interval)
|
|||
|
|
results.value[idx].displayContent = content
|
|||
|
|
}
|
|||
|
|
}, 20)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
|
|||
|
|
|
|||
|
|
onMounted(init)
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="compare-result">
|
|||
|
|
<div class="result-header">
|
|||
|
|
<h2>对比结果{{ taskName ? ` - ${taskName}` : '' }}</h2>
|
|||
|
|
<div class="header-actions">
|
|||
|
|
<el-button @click="$router.push('/model-inference')">返回列表</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 问题 -->
|
|||
|
|
<el-alert type="info" :closable="false" show-icon class="question-box">
|
|||
|
|
<template #title>
|
|||
|
|
<strong>问题:</strong>{{ question }}
|
|||
|
|
</template>
|
|||
|
|
</el-alert>
|
|||
|
|
|
|||
|
|
<!-- 模型结果网格 -->
|
|||
|
|
<div class="result-grid">
|
|||
|
|
<el-card v-for="(r, idx) in results" :key="idx" shadow="hover" class="result-card">
|
|||
|
|
<template #header>
|
|||
|
|
<div class="card-header">
|
|||
|
|
<span class="model-name">{{ r.name }}</span>
|
|||
|
|
<el-tag v-if="r.status === 'loading'" type="warning" size="small">生成中...</el-tag>
|
|||
|
|
<el-tag v-else-if="r.status === 'done'" type="success" size="small">完成</el-tag>
|
|||
|
|
<el-tag v-else type="danger" size="small">失败</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
|
|||
|
|
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
|
|||
|
|
<div v-else class="loading-text">
|
|||
|
|
<i class="fa fa-spinner fa-spin" /> 正在生成回答...
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="r.stats" class="result-stats">
|
|||
|
|
<span>耗时 {{ r.stats.totalTime?.toFixed(1) }}s</span>
|
|||
|
|
<span>速度 {{ r.stats.charsPerSec }} 字/秒</span>
|
|||
|
|
</div>
|
|||
|
|
</el-card>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
.compare-result {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
|
|||
|
|
h2 {
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.question-box {
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-card {
|
|||
|
|
.card-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
|
|||
|
|
.model-name {
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #303133;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-text,
|
|||
|
|
.error-text {
|
|||
|
|
color: #909399;
|
|||
|
|
min-height: 80px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-text {
|
|||
|
|
color: #f56c6c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stats {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 16px;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
padding-top: 12px;
|
|||
|
|
border-top: 1px solid #ebeef5;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #909399;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|