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

722 lines
17 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, reactive, nextTick, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MarkdownView from '@/components/MarkdownView.vue'
import { useStreamChat } from '@/composables/useStreamChat'
import { getCompare } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types'
const route = useRoute()
const router = useRouter()
const taskId = route.params.id as string
/** 是否为 mock 直通模式(新建推理假数据进入,不走真实任务接口) */
const isMock = taskId === 'mock'
/** 当前对话使用的模型名 */
const modelName = ref(route.query.model as string || '')
const { message, loading, send, reset } = useStreamChat()
interface ChatMessage {
role: 'user' | 'assistant'
content: string
think?: string
isThinking?: boolean
isStreaming?: boolean
done: boolean
}
const task = ref<CompareTask | null>(null)
const messages = ref<ChatMessage[]>([])
const inputQuestion = ref('')
const systemPrompt = ref('')
const temperature = ref(0.7)
const top_p = ref(0.95)
const maxTokens = ref(2048)
const contentRef = ref<HTMLElement>()
let activeAssistant: ChatMessage | null = null
/** 设置面板抽屉 */
const showSettings = ref(false)
/** 获取任务信息定位已启动的模型mock 模式跳过) */
async function loadTask() {
if (isMock) return
try {
task.value = await getCompare(taskId)
const models = parseLoadedModels(task.value)
if (models[0]?.model_name) modelName.value = models[0].model_name
} catch {
// ignore
}
}
function parseLoadedModels(t: CompareTask | null): LoadedModel[] {
if (!t?.load_status) return []
try {
const ls = typeof t.load_status === 'string' ? JSON.parse(t.load_status) : t.load_status
return ls.loaded_models || []
} catch {
return []
}
}
async function handleSend() {
const question = inputQuestion.value.trim()
if (!question || loading.value) return
// 推入用户消息
messages.value.push({ role: 'user', content: question, done: true })
// 推入占位助手消息
const assistantMsg = reactive<ChatMessage>({
role: 'assistant',
content: '',
think: '',
isThinking: false,
isStreaming: true,
done: false,
})
messages.value.push(assistantMsg)
inputQuestion.value = ''
await nextTick()
resetInputHeight()
scrollToBottom()
// mock 模式:直接用假数据逐字填充
if (isMock) {
await mockReply(assistantMsg, question)
return
}
// 真实模式:获取已启动模型的端口/路径
const models = parseLoadedModels(task.value)
const target = models[0]
if (!target) {
ElMessage.error('未找到已启动的模型')
assistantMsg.content = '未找到已启动的模型,请先返回列表加载模型'
assistantMsg.done = true
assistantMsg.isStreaming = false
return
}
// 流式状态变化时只同步当前回复,避免固定定时器空转。
activeAssistant = assistantMsg
await send({
port: target.port,
model_name: target.model_name,
model_path: '',
system_prompt: systemPrompt.value,
user_question: question,
temperature: temperature.value,
top_p: top_p.value,
max_tokens: maxTokens.value,
})
// 完成后同步最终内容
assistantMsg.content = message.value.displayContent || message.value.error || '(无回复)'
assistantMsg.think = message.value.thinkContent
assistantMsg.isThinking = false
assistantMsg.isStreaming = false
assistantMsg.done = true
activeAssistant = null
reset()
await nextTick()
scrollToBottom()
}
/**
* Mock 模式:生成假回答并逐字填入消息(模拟流式效果)
*/
async function mockReply(assistantMsg: ChatMessage, question: string) {
const answer =
`你好!我是 **${modelName.value || '示例模型'}**mock 演示)。\n\n` +
`你刚才问的是:\n\n> ${question}\n\n` +
`这是一段模拟回复,用于演示对话界面。接入真实模型后,这里会展示模型的真实推理输出。\n\n` +
`## 说明\n- 当前为前端 mock 环境\n- 回复内容由本地生成\n- 流式打字效果为前端模拟`
// 逐字填充,模拟流式
for (const ch of answer) {
assistantMsg.content += ch
await nextTick()
scrollToBottom()
// 每 3 个字符暂停一下,控制速度
if (assistantMsg.content.length % 3 === 0) {
await new Promise((r) => setTimeout(r, 16))
}
}
assistantMsg.isStreaming = false
assistantMsg.done = true
await nextTick()
scrollToBottom()
}
watch(
() => [
message.value.displayContent,
message.value.thinkContent,
message.value.isThinking,
message.value.done,
] 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()
},
{ flush: 'post' },
)
function scrollToBottom() {
if (contentRef.value) {
contentRef.value.scrollTop = contentRef.value.scrollHeight
}
}
function handleNewChat() {
activeAssistant = null
messages.value = []
reset()
}
/** 输入框自适应高度 */
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
/** 重置输入框高度 */
function resetInputHeight() {
const el = document.querySelector('.input-box') as HTMLTextAreaElement
if (el) el.style.height = 'auto'
}
onMounted(loadTask)
</script>
<template>
<div class="chat-wrap">
<!-- 顶部栏 -->
<header class="chat-header">
<div class="header-left">
<div class="header-title">
<span class="title-text">{{ modelName || '模型对话' }}</span>
<span v-if="isMock" class="mock-badge">mock</span>
</div>
</div>
<button class="header-btn" title="设置" @click="showSettings = true">
<i class="fa fa-sliders" />
</button>
</header>
<!-- 消息列表 -->
<div ref="contentRef" class="chat-body">
<div class="chat-body-inner">
<div v-if="messages.length === 0" class="empty-hint">
<div class="empty-logo">
<i class="fa fa-cube" />
</div>
<h2>有什么我可以帮你的吗</h2>
<p>开始与 {{ modelName || '模型' }} 对话</p>
</div>
<div
v-for="(msg, idx) in messages"
:key="idx"
class="msg-row"
:class="msg.role"
>
<!-- 头像 -->
<div v-if="msg.role === 'assistant'" class="avatar assistant">
<i class="fa fa-robot" />
</div>
<!-- 消息内容 -->
<div class="bubble-wrap">
<!-- 思考过程可折叠 -->
<el-collapse v-if="msg.think" class="think-collapse">
<el-collapse-item title="思考过程" name="think">
<div class="think-content">{{ msg.think }}</div>
</el-collapse-item>
</el-collapse>
<div v-if="msg.role === 'user'" class="bubble user-bubble">
{{ msg.content }}
</div>
<div v-else-if="msg.done" class="bubble ai-bubble markdown">
<MarkdownView :content="msg.content" />
</div>
<div v-else class="bubble ai-bubble streaming">
<span>{{ msg.content || (msg.isThinking ? '思考中...' : '生成中') }}</span>
<span class="typing-cursor" />
</div>
</div>
</div>
</div>
</div>
<!-- 输入栏 -->
<footer class="chat-input-container">
<div class="chat-input-inner">
<button class="clear-btn" title="清空对话" @click="handleNewChat">
<i class="fa fa-eraser" />
</button>
<div class="input-wrapper">
<textarea
v-model="inputQuestion"
class="input-box"
rows="1"
:disabled="loading"
placeholder="给模型发送消息..."
@keydown.enter.exact.prevent="handleSend"
@input="autoResize"
/>
<button
class="send-btn"
:class="{ active: inputQuestion.trim() && !loading }"
:disabled="!inputQuestion.trim() || loading"
@click="handleSend"
>
<i class="fa fa-arrow-up" />
</button>
</div>
</div>
<div class="footer-hint">内容由 AI 生成请仔细甄别</div>
</footer>
<!-- 设置抽屉系统提示词等 -->
<el-drawer v-model="showSettings" title="对话设置" size="360px">
<el-form label-position="top">
<el-form-item label="系统提示词">
<el-input
v-model="systemPrompt"
type="textarea"
:rows="6"
placeholder="设置模型角色/约束(可选)"
resize="none"
/>
</el-form-item>
<el-form-item label="Temperature (温度)">
<el-slider v-model="temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="Top P (核心采样)">
<el-slider v-model="top_p" :min="0" :max="1" :step="0.05" show-input />
</el-form-item>
<el-form-item label="Max Tokens (最大生成长度)">
<el-slider v-model="maxTokens" :min="128" :max="8192" :step="128" show-input />
</el-form-item>
<el-form-item>
<el-button type="danger" plain @click="handleNewChat" style="width: 100%; margin-top: 20px;">
<i class="fa fa-trash-o" style="margin-right: 4px" />清空当前对话
</el-button>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
.chat-wrap {
height: calc(100vh - 80px);
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
overflow: hidden;
border: 1px solid #f3f4f6;
}
/* ============ 顶部栏 ============ */
.chat-header {
flex-shrink: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #ffffff;
border-bottom: 1px solid #f3f4f6;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #111827;
.title-text {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mock-badge {
padding: 2px 6px;
font-size: 11px;
font-weight: 600;
color: #b45309;
background: #fef3c7;
border-radius: 4px;
text-transform: uppercase;
}
}
.header-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 18px;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #f3f4f6;
color: #111827;
}
}
}
/* ============ 消息列表 ============ */
.chat-body {
flex: 1;
overflow-y: auto;
padding: 24px 0;
scroll-behavior: smooth;
/* 隐藏滚动条但保留功能 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.chat-body-inner {
max-width: 800px;
margin: 0 auto;
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 32px;
}
.empty-hint {
margin-top: 10vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #6b7280;
.empty-logo {
width: 64px;
height: 64px;
border-radius: 16px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
i {
font-size: 32px;
color: #9ca3af;
}
}
h2 {
font-size: 24px;
font-weight: 600;
color: #111827;
margin: 0 0 8px 0;
}
p {
margin: 0;
font-size: 15px;
}
}
.msg-row {
display: flex;
gap: 16px;
align-items: flex-start;
width: 100%;
/* 用户消息:靠右排列 */
&.user {
flex-direction: row-reverse;
}
/* AI 消息:靠左排列 */
&.assistant {
flex-direction: row;
}
}
/* 头像 */
.avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
&.assistant {
background: #111827;
}
}
/* 气泡容器 */
.bubble-wrap {
max-width: 85%;
min-width: 0;
}
/* 气泡 */
.bubble {
font-size: 15px;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
}
.user-bubble {
background: #f3f4f6;
color: #111827;
padding: 12px 20px;
border-radius: 20px;
border-top-right-radius: 4px;
}
.ai-bubble {
color: #111827;
padding: 4px 0;
&.markdown {
white-space: normal;
:deep(.markdown-view p:last-child) {
margin-bottom: 0;
}
:deep(pre) {
border-radius: 8px;
background: #f9fafb !important;
border: 1px solid #e5e7eb;
margin: 12px 0;
}
:deep(code) {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
}
}
/* 思考过程折叠 */
.think-collapse {
margin-bottom: 12px;
max-width: 100%;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
overflow: hidden;
:deep(.el-collapse-item__header) {
padding: 0 16px;
font-size: 13px;
font-weight: 500;
color: #6b7280;
height: 36px;
background: transparent;
border-bottom: none;
}
:deep(.el-collapse-item__wrap) {
border-bottom: none;
background: transparent;
}
.think-content {
padding: 0 16px 12px;
font-size: 13px;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
border-top: 1px dashed #e5e7eb;
margin-top: 4px;
padding-top: 12px;
}
}
/* 流式打字光标 */
.streaming .typing-cursor {
display: inline-block;
width: 4px;
height: 16px;
background: #111827;
border-radius: 2px;
margin-left: 4px;
vertical-align: middle;
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ============ 输入栏 ============ */
.chat-input-container {
flex-shrink: 0;
padding: 16px 20px 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 20%);
display: flex;
flex-direction: column;
align-items: center;
}
.chat-input-inner {
width: 100%;
max-width: 800px;
display: flex;
align-items: flex-end;
gap: 12px;
}
.clear-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
margin-bottom: 4px;
border: 1px solid #e5e7eb;
background: #ffffff;
border-radius: 50%;
font-size: 16px;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
background: #f9fafb;
color: #ef4444;
border-color: #fca5a5;
}
}
.input-wrapper {
flex: 1;
position: relative;
background: #f4f4f5;
border-radius: 24px;
padding: 8px 16px;
display: flex;
align-items: flex-end;
transition: all 0.2s;
border: 1px solid transparent;
&:focus-within {
background: #ffffff;
border-color: #d1d5db;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
}
.input-box {
flex: 1;
max-height: 200px;
padding: 4px 44px 4px 0;
border: none;
background: transparent;
font-size: 15px;
line-height: 1.5;
color: #111827;
resize: none;
outline: none;
font-family: inherit;
&::placeholder {
color: #9ca3af;
}
}
.send-btn {
position: absolute;
right: 12px;
bottom: 8px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #e5e7eb;
color: #ffffff;
font-size: 14px;
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.active {
background: #111827;
cursor: pointer;
&:hover {
background: #374151;
transform: translateY(-1px);
}
&:active {
transform: translateY(1px);
}
}
}
.footer-hint {
margin-top: 12px;
font-size: 12px;
color: #9ca3af;
}
</style>