refactor: 推理对比超时与打字机展示改进
抽取 withTimeout 替代 Promise.race 超时控制,对比结果新增打字机逐字渲染与清理,推理聊天参数与列表类型同步收敛。
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user