Files
YG_FT/frontend/src/views/compare/CompareResultView.vue
caoxiaozhu ab9e87f948 refactor: 推理对比超时与打字机展示改进
抽取 withTimeout 替代 Promise.race 超时控制,对比结果新增打字机逐字渲染与清理,推理聊天参数与列表类型同步收敛。
2026-07-16 11:03:25 +08:00

256 lines
7.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import MarkdownView from '@/components/MarkdownView.vue'
import { getCompare, chatWithPort } from '@/api/modules/compare'
import type { 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
isTyping: boolean
status: 'loading' | 'done' | 'error'
stats?: { charsPerSec?: number; totalTime?: number }
}
const results = ref<ModelResult[]>([])
const started = ref(false)
const loadedModels = ref<LoadedModel[]>([])
const typewriterTimers = new Set<ReturnType<typeof setInterval>>()
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: '',
isTyping: false,
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 withTimeout(
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,
}),
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 ? Number((content.length / totalTime).toFixed(1)) : 0,
}
// 模拟打字机效果
typewriterDisplay(idx, content)
} catch (e: any) {
results.value[idx].content = '推理失败: ' + (e.message || '')
results.value[idx].status = 'error'
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('推理超时')), timeoutMs)
}),
])
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
}
/** 打字机效果逐字展示 */
function typewriterDisplay(idx: number, content: string) {
let pos = 0
results.value[idx].isTyping = true
// 将更新次数控制在约 30 次,避免长回答逐字触发 Markdown 全文解析。
const step = Math.max(2, Math.ceil(content.length / 30))
const interval = setInterval(() => {
pos += step
results.value[idx].displayContent = content.slice(0, pos)
if (pos >= content.length) {
clearInterval(interval)
typewriterTimers.delete(interval)
results.value[idx].displayContent = content
results.value[idx].isTyping = false
}
}, 50)
typewriterTimers.add(interval)
}
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
onMounted(init)
onBeforeUnmount(() => {
typewriterTimers.forEach(clearInterval)
typewriterTimers.clear()
})
</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>
<div v-else-if="r.isTyping" class="streaming-text">{{ r.displayContent }}</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;
}
.streaming-text {
min-height: 80px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.result-stats {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #ebeef5;
font-size: 12px;
color: #909399;
}
}
</style>