refactor: 推理对比超时与打字机展示改进

抽取 withTimeout 替代 Promise.race 超时控制,对比结果新增打字机逐字渲染与清理,推理聊天参数与列表类型同步收敛。
This commit is contained in:
caoxiaozhu
2026-07-16 11:03:25 +08:00
parent 5a040366da
commit ab9e87f948
4 changed files with 83 additions and 42 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import { getCompare } from '@/api/modules/compare' import { getCompare } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types' import type { CompareTask, LoadedModel } from '@/types'
@@ -11,7 +12,6 @@ const router = useRouter()
const taskId = route.params.id as string const taskId = route.params.id as string
const task = ref<CompareTask | null>(null) const task = ref<CompareTask | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({ const form = reactive({
systemPrompt: '', systemPrompt: '',
@@ -73,13 +73,11 @@ function handleSubmit() {
window.open(url, '_blank') window.open(url, '_blank')
} }
onMounted(() => { const { start: startPolling } = usePolling(loadTask, 5000, { immediate: false })
loadTask()
pollTimer = setInterval(loadTask, 5000)
})
onUnmounted(() => { onMounted(async () => {
if (pollTimer) clearInterval(pollTimer) await loadTask()
startPolling()
}) })
</script> </script>

View File

@@ -1,14 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import MarkdownView from '@/components/MarkdownView.vue' import MarkdownView from '@/components/MarkdownView.vue'
import { import { getCompare, chatWithPort } from '@/api/modules/compare'
getCompare, import type { LoadedModel } from '@/types'
chatWithPort,
batchChat,
} from '@/api/modules/compare'
import { getModelByName } from '@/api/modules/model'
import type { CompareTask, LoadedModel } from '@/types'
const route = useRoute() const route = useRoute()
const taskId = route.query.taskId as string const taskId = route.query.taskId as string
@@ -25,6 +20,7 @@ interface ModelResult {
name: string name: string
content: string content: string
displayContent: string displayContent: string
isTyping: boolean
status: 'loading' | 'done' | 'error' status: 'loading' | 'done' | 'error'
stats?: { charsPerSec?: number; totalTime?: number } stats?: { charsPerSec?: number; totalTime?: number }
} }
@@ -33,6 +29,7 @@ const results = ref<ModelResult[]>([])
const started = ref(false) const started = ref(false)
const loadedModels = ref<LoadedModel[]>([]) const loadedModels = ref<LoadedModel[]>([])
const typewriterTimers = new Set<ReturnType<typeof setInterval>>()
async function init() { async function init() {
if (started.value) return if (started.value) return
@@ -50,6 +47,7 @@ async function init() {
name: m.model_name || '模型', name: m.model_name || '模型',
content: '', content: '',
displayContent: '', displayContent: '',
isTyping: false,
status: 'loading', status: 'loading',
})) }))
@@ -65,7 +63,7 @@ async function inferOne(model: LoadedModel, idx: number) {
const startTime = Date.now() const startTime = Date.now()
try { try {
// 尝试通过端口代理调用 // 尝试通过端口代理调用
const res: any = await Promise.race([ const res: any = await withTimeout(
chatWithPort({ chatWithPort({
port: model.port, port: model.port,
model_name: model.model_name, model_name: model.model_name,
@@ -78,8 +76,8 @@ async function inferOne(model: LoadedModel, idx: number) {
top_k: topK, top_k: topK,
max_tokens: maxTokens, max_tokens: maxTokens,
}), }),
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)), 300000,
]) )
const content = res?.response || res?.content || res?.data || JSON.stringify(res) const content = res?.response || res?.content || res?.data || JSON.stringify(res)
const totalTime = (Date.now() - startTime) / 1000 const totalTime = (Date.now() - startTime) / 1000
@@ -87,7 +85,7 @@ async function inferOne(model: LoadedModel, idx: number) {
results.value[idx].status = 'done' results.value[idx].status = 'done'
results.value[idx].stats = { results.value[idx].stats = {
totalTime, totalTime,
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0, charsPerSec: totalTime > 0 ? Number((content.length / totalTime).toFixed(1)) : 0,
} }
// 模拟打字机效果 // 模拟打字机效果
typewriterDisplay(idx, content) typewriterDisplay(idx, content)
@@ -97,22 +95,46 @@ async function inferOne(model: LoadedModel, idx: number) {
} }
} }
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) { function typewriterDisplay(idx: number, content: string) {
let pos = 0 let pos = 0
results.value[idx].isTyping = true
// 将更新次数控制在约 30 次,避免长回答逐字触发 Markdown 全文解析。
const step = Math.max(2, Math.ceil(content.length / 30))
const interval = setInterval(() => { const interval = setInterval(() => {
pos += 2 pos += step
results.value[idx].displayContent = content.slice(0, pos) results.value[idx].displayContent = content.slice(0, pos)
if (pos >= content.length) { if (pos >= content.length) {
clearInterval(interval) clearInterval(interval)
typewriterTimers.delete(interval)
results.value[idx].displayContent = content results.value[idx].displayContent = content
results.value[idx].isTyping = false
} }
}, 20) }, 50)
typewriterTimers.add(interval)
} }
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error')) const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
onMounted(init) onMounted(init)
onBeforeUnmount(() => {
typewriterTimers.forEach(clearInterval)
typewriterTimers.clear()
})
</script> </script>
<template> <template>
@@ -144,6 +166,7 @@ onMounted(init)
</template> </template>
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div> <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" /> <MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
<div v-else class="loading-text"> <div v-else class="loading-text">
<i class="fa fa-spinner fa-spin" /> 正在生成回答... <i class="fa fa-spinner fa-spin" /> 正在生成回答...
@@ -212,6 +235,13 @@ onMounted(init)
color: #f56c6c; color: #f56c6c;
} }
.streaming-text {
min-height: 80px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.result-stats { .result-stats {
display: flex; display: flex;
gap: 16px; gap: 16px;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue' import { ref, reactive, nextTick, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import MarkdownView from '@/components/MarkdownView.vue' import MarkdownView from '@/components/MarkdownView.vue'
@@ -34,6 +34,7 @@ const temperature = ref(0.7)
const top_p = ref(0.95) const top_p = ref(0.95)
const maxTokens = ref(2048) const maxTokens = ref(2048)
const contentRef = ref<HTMLElement>() const contentRef = ref<HTMLElement>()
let activeAssistant: ChatMessage | null = null
/** 设置面板抽屉 */ /** 设置面板抽屉 */
const showSettings = ref(false) const showSettings = ref(false)
@@ -98,8 +99,8 @@ async function handleSend() {
return return
} }
// 监听流式 message 变化,同步到 assistantMsg // 流式状态变化时只同步当前回复,避免固定定时器空转。
const watchStop = watchMessage(assistantMsg) activeAssistant = assistantMsg
await send({ await send({
port: target.port, port: target.port,
@@ -118,7 +119,7 @@ async function handleSend() {
assistantMsg.isThinking = false assistantMsg.isThinking = false
assistantMsg.isStreaming = false assistantMsg.isStreaming = false
assistantMsg.done = true assistantMsg.done = true
watchStop() activeAssistant = null
reset() reset()
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -149,17 +150,24 @@ async function mockReply(assistantMsg: ChatMessage, question: string) {
scrollToBottom() scrollToBottom()
} }
/** 轮询同步流式状态到展示消息 */ watch(
function watchMessage(assistantMsg: ChatMessage) { () => [
const timer = setInterval(() => { message.value.displayContent,
assistantMsg.content = message.value.displayContent message.value.thinkContent,
assistantMsg.think = message.value.thinkContent message.value.isThinking,
assistantMsg.isThinking = message.value.isThinking message.value.done,
if (message.value.done) clearInterval(timer) ] as const,
async ([content, think, isThinking, done]) => {
if (!activeAssistant) return
activeAssistant.content = content
activeAssistant.think = think
activeAssistant.isThinking = isThinking
activeAssistant.isStreaming = !done
await nextTick()
scrollToBottom() scrollToBottom()
}, 80) },
return () => clearInterval(timer) { flush: 'post' },
} )
function scrollToBottom() { function scrollToBottom() {
if (contentRef.value) { if (contentRef.value) {
@@ -168,6 +176,7 @@ function scrollToBottom() {
} }
function handleNewChat() { function handleNewChat() {
activeAssistant = null
messages.value = [] messages.value = []
reset() reset()
} }

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue' import DataTablePage from '@/components/DataTablePage.vue'
import { usePolling } from '@/composables/usePolling'
import { import {
getCompareList, getCompareList,
deleteCompare, deleteCompare,
@@ -17,7 +18,7 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const dataList = ref<CompareTask[]>([]) const dataList = ref<CompareTask[]>([])
let refreshTimer: ReturnType<typeof setInterval> | null = null let delayedRefreshTimer: ReturnType<typeof setTimeout> | null = null
async function loadData(silent = false) { async function loadData(silent = false) {
if (!silent) { if (!silent) {
@@ -80,7 +81,8 @@ function parseModelNames(row: any): string[] {
async function handleLoad(row: any) { async function handleLoad(row: any) {
await loadCompare(row.id) await loadCompare(row.id)
ElMessage.info('正在加载模型,请稍候...') ElMessage.info('正在加载模型,请稍候...')
setTimeout(loadData, 1000) if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
delayedRefreshTimer = setTimeout(loadData, 1000)
} }
/** 卸载推理任务 */ /** 卸载推理任务 */
@@ -112,13 +114,15 @@ function startChat(row: any) {
router.push(`/model-inference/chat/${row.id}`) router.push(`/model-inference/chat/${row.id}`)
} }
onMounted(() => { const { start: startPolling } = usePolling(() => loadData(true), 3000, { immediate: false })
loadData()
refreshTimer = setInterval(() => loadData(true), 3000) onMounted(async () => {
await loadData()
startPolling()
}) })
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer) if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
}) })
</script> </script>