Files
yunzerwebsiteallinone/backend/src/views/apps/ai/components/chatWindow.vue
T
2026-11-13 20:26:16 +08:00

1391 lines
39 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.
<template>
<div class="chat-window">
<div class="chat-header">
<span class="chat-title">{{ sessionTitle || 'AI广场' }}</span>
<div class="header-selectors">
<el-tooltip :content="defaultPreset ? '当前预设:' + defaultPreset.name + ':' + defaultPreset.content : '未设置默认预设'" placement="bottom">
<el-button
size="small"
:icon="UserFilled"
class="preset-btn"
:class="{ 'has-preset': !!defaultPreset }"
@click="presetVisible = true"
>
{{ defaultPreset ? defaultPreset.name : '角色预设' }}
</el-button>
</el-tooltip>
<el-select
v-model="selectedProviderId"
placeholder="选择接入"
class="provider-select"
@change="handleProviderChange"
>
<el-option
v-for="p in providerList"
:key="p.id"
:label="p.name + ' (' + (p.provider_type === 'openai' ? 'OpenAI' : 'Anthropic') + ')'"
:value="p.id"
/>
</el-select>
<el-select
v-model="selectedModel"
placeholder="选择模型"
class="model-select"
:disabled="!selectedProviderId"
>
<el-option
v-for="m in currentModels"
:key="m"
:label="m"
:value="m"
/>
</el-select>
<el-popover placement="bottom-end" :width="300" trigger="click" @show="fetchMcpStatus">
<template #reference>
<span class="mcp-indicator" :class="{ 'has-mcp': mcpOn }">
<el-icon><Cpu /></el-icon>
<span class="mcp-ind-text">MCP</span>
<span v-if="mcpOn" class="mcp-ind-count">{{ selectedMcpServers.length }}</span>
</span>
</template>
<div class="mcp-panel">
<div class="mcp-panel-head">
<span class="mcp-panel-title">MCP 工具</span>
<el-switch v-model="mcpEnabled" size="small" @change="persistMcpSetting" />
</div>
<div class="mcp-panel-tip">{{ mcpTip }}</div>
<template v-if="mcpEnabled">
<div v-if="mcpServerList.length === 0" class="mcp-panel-empty">
暂无已启用的 MCP 服务,可在侧边栏 CPU 按钮中配置
</div>
<el-checkbox-group
v-else
v-model="selectedMcpServers"
class="mcp-check-list"
@change="persistMcpSetting"
>
<el-checkbox v-for="s in mcpServerList" :key="s.id" :value="s.id">
{{ s.name }}
</el-checkbox>
</el-checkbox-group>
</template>
</div>
</el-popover>
</div>
</div>
<div class="chat-messages" ref="messagesRef" v-loading="loading">
<div v-if="messages.length === 0 && !loading" class="empty-state">
<el-empty description="开始与AI对话吧" :image-size="100" />
<div class="quick-tips">
<el-tag v-for="tip in quickTips" :key="tip" class="tip-tag" @click="handleQuickTip(tip)">
{{ tip }}
</el-tag>
</div>
</div>
<div
v-for="(msg, idx) in messages"
:key="msg.id || idx"
class="message-item"
:class="msg.role === 'user' ? 'user-msg' : 'assistant-msg'"
>
<div class="message-avatar">
<el-icon v-if="msg.role === 'user'"><User /></el-icon>
<el-icon v-else><Cpu /></el-icon>
</div>
<div class="message-content">
<!-- 编辑模式 -->
<div v-if="editingId === msg.id" class="edit-area">
<el-input
v-model="editContent"
type="textarea"
:rows="3"
resize="none"
/>
<div class="edit-actions">
<el-button size="small" type="primary" :loading="editSaving" @click="saveEdit(msg)">保存</el-button>
<el-button size="small" @click="cancelEdit">取消</el-button>
</div>
</div>
<!-- 正常显示 -->
<template v-else>
<div
class="message-bubble"
:class="{
typing: msg.role === 'assistant' && !msg.content && sending,
'has-tools': msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length,
generating: msg.role === 'assistant' && sending,
error: msg.role === 'assistant' && msg.error
}"
>
<div v-if="msg.role === 'assistant' && sending" class="generating-indicator">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
<div v-if="msg.images && msg.images.length" class="message-images">
<img v-for="(img, i) in msg.images" :key="i" :src="img" class="message-image" alt="图片" />
</div>
<template v-if="msg.role === 'assistant' && !msg.content && sending">
<span class="thinking-text">思考中…</span>
</template>
<template v-else>
<div v-if="msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length" class="tool-call-list">
<div v-for="tc in msg.toolCalls" :key="tc.id" class="tool-call-card">
<div class="tool-call-head" @click="tc.expanded = !tc.expanded">
<el-icon class="tool-call-icon"><Cpu /></el-icon>
<span class="tool-call-name">
<span v-if="tc.serverName" class="tool-server">{{ tc.serverName }}</span>
{{ tc.tool }}
</span>
<el-icon v-if="tc.status === 'running'" class="is-loading tool-status-icon"><Loading /></el-icon>
<el-tag v-else-if="tc.status === 'ok'" type="success" size="small" effect="plain">完成</el-tag>
<el-tag v-else-if="tc.status === 'error'" type="danger" size="small" effect="plain">失败</el-tag>
<span class="tool-call-toggle">{{ tc.expanded ? '收起' : '详情' }}</span>
</div>
<div v-if="tc.expanded" class="tool-call-detail">
<div v-if="tc.args && Object.keys(tc.args).length" class="tool-call-block">
<div class="tool-call-label">参数</div>
<pre class="tool-call-json">{{ JSON.stringify(tc.args, null, 2) }}</pre>
</div>
<div v-if="tc.status !== 'running'" class="tool-call-block">
<div class="tool-call-label">结果</div>
<pre class="tool-call-json" :class="{ 'is-error': tc.status === 'error' }">{{ tc.status === 'error' ? tc.error : tc.result }}</pre>
</div>
</div>
</div>
</div>
<pre v-if="msg.content" class="message-text">{{ msg.content }}</pre>
</template>
</div>
<div
v-if="msg.role === 'assistant' && (msg.tokens || msg.durationMs || msg.firstTokenMs || msg.model || msg.providerName || msg.provider_name)"
class="message-meta"
>
<span v-if="msg.firstTokenMs" class="meta-item">首字 {{ (msg.firstTokenMs / 1000).toFixed(1) }}s</span>
<span v-if="msg.firstTokenMs" class="meta-dot">·</span>
<span v-if="msg.providerName || msg.provider_name || msg.model" class="meta-item">
{{ msg.providerName || msg.provider_name || '未知厂商' }}<template v-if="msg.model"> · {{ msg.model }}</template>
</span>
<span v-if="msg.tokens || msg.durationMs" class="meta-dot">·</span>
<span v-if="msg.durationMs" class="meta-item">用时 {{ formatDuration(msg.durationMs) }}</span>
<span v-if="msg.durationMs && msg.tokens" class="meta-dot">·</span>
<span v-if="msg.tokens" class="meta-item">{{ formatTokenRate(msg.tokens, msg.durationMs) }} token/s</span>
<span v-if="msg.tokens" class="meta-dot">·</span>
<span v-if="msg.tokens" class="meta-item">{{ msg.tokens }} tokens</span>
</div>
<div class="message-actions">
<el-tooltip content="复制" placement="top">
<el-button link type="primary" :icon="CopyDocument" class="action-btn" @click="copyMessage(msg)" />
</el-tooltip>
<el-tooltip v-if="msg.role === 'user'" content="编辑" placement="top">
<el-button link type="primary" :icon="Edit" class="action-btn" @click="startEdit(msg)" />
</el-tooltip>
<el-tooltip :content="msg.role === 'user' ? '重新发送' : '重新生成'" placement="top">
<el-button link type="primary" :icon="RefreshRight" class="action-btn" @click="resendMessage(msg, idx)" />
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="deleteMessage(msg, idx)" />
</el-tooltip>
</div>
</template>
</div>
</div>
</div>
<div class="chat-input-area">
<div v-if="lastAssistant && lastAssistant.firstTokenMs" class="input-status">
<span>首字 {{ (lastAssistant.firstTokenMs / 1000).toFixed(1) }}s</span>
<template v-if="lastAssistant.durationMs">
<span class="meta-dot">·</span>
<span>共 {{ (lastAssistant.durationMs / 1000).toFixed(1) }}s</span>
</template>
<template v-if="lastAssistant.tokens">
<span class="meta-dot">·</span>
<span>{{ lastAssistant.tokens }} tokens</span>
</template>
</div>
<div class="input-wrapper">
<div v-if="inputImages.length" class="input-images">
<div v-for="(img, i) in inputImages" :key="i" class="input-image-item">
<img :src="img" alt="待发送图片" />
<el-icon class="input-image-remove" @click="removeInputImage(i)"><Close /></el-icon>
</div>
</div>
<div class="input-row">
<el-input
v-model="inputText"
type="textarea"
:rows="3"
resize="vertical"
placeholder="输入消息,Enter发送,Shift+Enter换行;可粘贴图片"
@keydown.enter.exact.prevent="handleSend"
@paste="onInputPaste"
/>
<div class="input-actions">
<el-button
type="primary"
class="send-btn"
:icon="Promotion"
:loading="sending"
:disabled="(!inputText.trim() && inputImages.length === 0) || sending"
@click="handleSend"
>
发送
</el-button>
</div>
</div>
</div>
<div class="input-tip">AI生成内容仅供参考,请核实重要信息</div>
</div>
<PresetDialog v-model="presetVisible" @change="fetchDefaultPreset" />
</div>
</template>
<script setup>
import { ref, nextTick, watch, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Cpu, Promotion, Edit, CopyDocument, RefreshRight, Delete, UserFilled, Loading, Close } from '@element-plus/icons-vue'
import { getChatMessageList, sendChatMessage, getAiProviderList, deleteChatMessage, getAiPresetList, getMcpServerList } from '@/api/ai'
import PresetDialog from './presetDialog.vue'
const props = defineProps({
sessionId: { type: [Number, String], default: null },
sessionTitle: { type: String, default: '' }
})
const emit = defineEmits(['session-created'])
const loading = ref(false)
const sending = ref(false)
const inputText = ref('')
// 待发送的图片(base64 dataURL)
const inputImages = ref([])
const messages = ref([])
const messagesRef = ref()
// 流式回复进行中时 sessionId 变化的暂存值(等待本轮结束后再加载历史)
const pendingSessionId = ref(null)
// 标记「本次 sessionId 变化是本轮发送自己创建的会话」,避免拉取历史覆盖正在流式追加的消息
const selfSessionSwitch = ref(false)
// 消息编辑
const editingId = ref(-1)
const editContent = ref('')
const editSaving = ref(false)
// 接入配置和模型选择
const providerList = ref([])
const selectedProviderId = ref(null)
const selectedModel = ref('')
// 角色预设
const presetVisible = ref(false)
const defaultPreset = ref(null)
// MCP 接入状态
const activeMcpTools = ref([])
// 本轮对话是否启用 MCP 工具;默认开启,以 MCP 市场配置为准(市场已启用服务即可被调用)
const savedMcp = loadMcpSetting()
const mcpEnabled = ref(true)
// 参与本轮对话的 MCP 服务器(默认全部已启用的服务)
const selectedMcpServers = ref(savedMcp.serverIds)
const mcpServerList = ref([])
// 开关打开且至少勾选了一个服务才算真正启用
const mcpOn = computed(() => mcpEnabled.value && selectedMcpServers.value.length > 0)
// 最近一条助手消息(用于输入框上方状态栏展示首字耗时等)
const lastAssistant = computed(() => {
for (let i = messages.value.length - 1; i >= 0; i--) {
if (messages.value[i].role === 'assistant') return messages.value[i]
}
return null
})
const mcpTip = computed(() => {
if (!mcpEnabled.value) {
return 'MCP 工具已关闭,本轮对话不会调用任何 MCP 工具'
}
if (selectedMcpServers.value.length === 0) {
return '未勾选任何 MCP 服务,本轮对话不会调用工具'
}
const names = mcpServerList.value
.filter((s) => selectedMcpServers.value.includes(s.id))
.map((s) => s.name)
return `本轮将调用:${names.join('、')}`
})
function loadMcpSetting() {
const setting = { serverIds: [] }
try {
const s = localStorage.getItem('ai_mcp_servers')
if (s) setting.serverIds = JSON.parse(s) || []
} catch (e) {
// 忽略
}
return setting
}
function persistMcpSetting() {
try {
localStorage.setItem('ai_mcp_servers', JSON.stringify(selectedMcpServers.value))
} catch (e) {
// 忽略
}
}
// 用户选择的接入/模型偏好(持久化,避免每次重新选择)
function loadModelSetting() {
const s = { providerId: null, model: '' }
try {
const p = localStorage.getItem('ai_selected_provider')
const m = localStorage.getItem('ai_selected_model')
if (p) s.providerId = Number(p)
if (m) s.model = m
} catch (e) {
// 忽略
}
return s
}
function saveModelSetting(providerId, model) {
try {
localStorage.setItem('ai_selected_provider', String(providerId ?? ''))
localStorage.setItem('ai_selected_model', String(model ?? ''))
} catch (e) {
// 忽略
}
}
// 接入/模型变化时持久化用户选择
watch([selectedProviderId, selectedModel], () => {
if (selectedProviderId.value && selectedModel.value) {
saveModelSetting(selectedProviderId.value, selectedModel.value)
}
})
// 将毫秒格式化为可读耗时
function formatDuration(ms) {
if (!ms || ms <= 0) return '0ms'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
const currentModels = computed(() => {
const p = providerList.value.find(item => item.id === selectedProviderId.value)
return p && p.models ? p.models : []
})
const currentProvider = computed(() =>
providerList.value.find((item) => item.id === selectedProviderId.value) || null
)
function formatTokenRate(tokens, durationMs) {
const count = Number(tokens || 0)
const elapsed = Number(durationMs || 0)
if (!count || !elapsed) return '—'
return (count / (elapsed / 1000)).toFixed(1)
}
onMounted(() => {
fetchProviders()
fetchDefaultPreset()
fetchMcpStatus()
})
// 是否已根据市场配置完成首次自动开启
let mcpLoaded = false
async function fetchMcpStatus() {
try {
const res = await getMcpServerList()
if (res.code === 200 && res.data && res.data.list) {
const enabled = res.data.list.filter((s) => s.enabled === 1)
mcpServerList.value = enabled.map((s) => ({ id: s.id, name: s.name }))
// 过滤掉已删除/已停用的服务;没有历史选择时默认全选
const valid = selectedMcpServers.value.filter((id) => mcpServerList.value.some((s) => s.id === id))
selectedMcpServers.value = valid.length > 0 ? valid : mcpServerList.value.map((s) => s.id)
// 首次加载且市场存在已启用的服务时,自动开启 MCP(无需再手动打开开关)
if (!mcpLoaded && mcpServerList.value.length > 0) {
mcpEnabled.value = true
}
mcpLoaded = true
}
} catch (e) {
// 静默失败
}
}
async function fetchDefaultPreset() {
try {
const res = await getAiPresetList()
if (res.code === 200 && res.data.list) {
defaultPreset.value = res.data.list.find(p => p.is_default === 1) || null
}
} catch (e) {
// 静默失败
}
}
async function fetchProviders() {
try {
const res = await getAiProviderList()
if (res.code === 200) {
providerList.value = res.data.list || []
const saved = loadModelSetting()
// 优先使用已保存的接入/模型,否则默认选第一个启用的
const firstActive = providerList.value.find(p => p.status === 1)
if (saved.providerId && providerList.value.some(p => p.id === saved.providerId)) {
selectedProviderId.value = saved.providerId
} else if (firstActive) {
selectedProviderId.value = firstActive.id
}
const provider = providerList.value.find(p => p.id === selectedProviderId.value)
const models = provider && provider.models ? provider.models : []
if (saved.model && models.includes(saved.model)) {
selectedModel.value = saved.model
} else if (models.length > 0) {
selectedModel.value = models[0]
} else {
selectedModel.value = ''
}
}
} catch (e) {
// 静默失败,用户可以在设置中配置
}
}
function handleProviderChange() {
selectedModel.value = ''
if (currentModels.value.length > 0) {
selectedModel.value = currentModels.value[0]
}
}
const quickTips = [
'帮我写一份项目计划书',
'解释一下什么是微服务架构',
'用Go语言写一个HTTP服务器',
'总结一下今天的工作要点'
]
watch(
() => props.sessionId,
(val) => {
if (sending.value) {
// 流式回复期间不能重新拉取历史:会把本地正在追加的 assistant 消息整体替换掉,导致回复不显示
if (selfSessionSwitch.value) {
selfSessionSwitch.value = false
return
}
pendingSessionId.value = val
return
}
if (val) {
fetchMessages(val)
} else {
messages.value = []
}
}
)
async function fetchMessages(sessionId) {
loading.value = true
try {
const res = await getChatMessageList(sessionId)
if (res.code === 200) {
messages.value = (res.data.list || []).map((msg) => ({
...msg,
durationMs: msg.durationMs ?? msg.duration_ms ?? 0,
providerName: msg.providerName || msg.provider_name || '',
}))
scrollToBottom()
}
} catch (e) {
ElMessage.error(e.message || '加载消息失败')
} finally {
loading.value = false
}
}
async function handleSend() {
const content = inputText.value.trim()
const images = inputImages.value
if ((!content && images.length === 0) || sending.value) return
inputText.value = ''
const imgs = images.slice()
inputImages.value = []
await doSend(content, imgs)
}
// ===== 消息操作 =====
function startEdit(msg) {
editingId.value = msg.id
editContent.value = msg.content
}
function cancelEdit() {
editingId.value = -1
editContent.value = ''
}
async function saveEdit(msg) {
const content = editContent.value.trim()
if (!content) return
editSaving.value = true
try {
// 删除原消息及之后的所有消息,然后用新内容重新发送
const idx = messages.value.findIndex(m => m.id === msg.id)
if (idx >= 0) {
// 删除该条及之后的消息(本地)
const toDelete = messages.value.slice(idx)
messages.value = messages.value.slice(0, idx)
// 后端删除(有id的消息)
for (const m of toDelete) {
if (m.id) {
try { await deleteChatMessage(m.id) } catch (e) {}
}
}
}
cancelEdit()
// 用新内容重新发送
await doSend(content)
} catch (e) {
ElMessage.error(e.message || '操作失败')
} finally {
editSaving.value = false
}
}
function copyMessage(msg) {
const text = msg.content
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
ElMessage.success('已复制到剪贴板')
} catch (e) {
ElMessage.error('复制失败')
}
document.body.removeChild(textarea)
}
async function resendMessage(msg, idx) {
// 找到对应的用户消息
let userMsg = msg
if (msg.role === 'assistant') {
// 往前找最近的用户消息
for (let i = idx - 1; i >= 0; i--) {
if (messages.value[i].role === 'user') {
userMsg = messages.value[i]
break
}
}
}
if (!userMsg || !userMsg.content) {
ElMessage.warning('找不到对应的用户消息')
return
}
// 删除该用户消息及之后的所有消息
const userIdx = messages.value.findIndex(m => m === userMsg)
if (userIdx >= 0) {
const toDelete = messages.value.slice(userIdx)
messages.value = messages.value.slice(0, userIdx)
for (const m of toDelete) {
if (m.id) {
try { await deleteChatMessage(m.id) } catch (e) {}
}
}
}
// 重新发送
await doSend(userMsg.content)
}
async function deleteMessage(msg, idx) {
try {
if (msg.id) {
await deleteChatMessage(msg.id)
}
messages.value.splice(idx, 1)
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e.message || '删除失败')
}
}
// 选择图片并读取为 base64 dataURL
function removeInputImage(i) {
inputImages.value.splice(i, 1)
}
// 在输入框中粘贴图片(Ctrl+V)
function onInputPaste(e) {
const cd = e.clipboardData || window.clipboardData
if (!cd || !cd.items) return
const items = Array.from(cd.items)
const hasText = items.some(it => it.kind === 'string' && it.type === 'text/plain')
let added = false
for (const it of items) {
if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
const file = it.getAsFile()
if (!file) continue
if (file.size > 8 * 1024 * 1024) {
ElMessage.warning(`图片「${file.name || '剪贴板图片'}」超过 8MB,已跳过`)
continue
}
const reader = new FileReader()
reader.onload = () => { inputImages.value.push(reader.result) }
reader.readAsDataURL(file)
added = true
}
}
// 剪贴板只含图片时阻止默认行为,避免把文件名等当作文本插入
if (added && !hasText) e.preventDefault()
}
// 内部发送方法(供编辑/重新发送调用)
async function doSend(content, images = []) {
if ((!content && (!images || images.length === 0)) || sending.value) return
if (!selectedProviderId.value) {
ElMessage.warning('请先选择接入')
return
}
if (!selectedModel.value) {
ElMessage.warning('请选择模型')
return
}
// 添加用户消息
messages.value.push({ role: 'user', content, images: images ? images.slice() : [] })
// 注意:必须取数组内的响应式代理对象来修改,直接持有 push 进去的原始对象不会触发视图更新
const userMsg = messages.value[messages.value.length - 1]
// 添加空的AI消息,用于流式更新
messages.value.push({ id: null, role: 'assistant', content: '', toolCalls: [], tokens: 0, durationMs: 0, model: '', error: false, firstTokenMs: 0 })
const aiIndex = messages.value.length - 1
const aiMsg = messages.value[aiIndex]
sending.value = true
// 本轮开始时间,用于计算首字耗时(TTFT)
const startTimeMs = Date.now()
scrollToBottom()
try {
const token = localStorage.getItem('token')
const apiBase = import.meta.env.VITE_API_BASE_URL || ''
const response = await fetch(`${apiBase}/backend/ai/chat/send-stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
session_id: props.sessionId || 0,
content,
provider_id: selectedProviderId.value,
model: selectedModel.value,
mcp_enabled: mcpOn.value,
mcp_server_ids: mcpOn.value ? selectedMcpServers.value : [],
images: images
})
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let sessionCreated = false
let currentEvent = ''
let currentData = ''
let streamError = null
const handleSSEEvent = (eventType, data) => {
if (!data) return
try {
const parsed = JSON.parse(data)
if (eventType === 'session') {
if (parsed.user_message_id) {
userMsg.id = parsed.user_message_id
}
if (!props.sessionId && parsed.session_id && !sessionCreated) {
sessionCreated = true
selfSessionSwitch.value = true
emit('session-created', {
id: parsed.session_id,
title: (content || '[图片消息]').length > 20 ? (content || '[图片消息]').slice(0, 20) + '...' : (content || '[图片消息]')
})
}
} else if (eventType === 'content') {
if (!aiMsg.content) {
aiMsg.firstTokenMs = Date.now() - startTimeMs
}
aiMsg.content += parsed.content
scrollToBottom()
} else if (eventType === 'tool_list') {
// 当前会话启用的 MCP 工具
if (parsed.tools) {
activeMcpTools.value = parsed.tools.filter((t) => t.tool_name)
}
} else if (eventType === 'tool_start') {
// AI 开始调用某个 MCP 工具
const tc = {
id: parsed.id,
key: parsed.key,
tool: parsed.tool,
serverName: parsed.server_name,
args: parsed.args || {},
status: 'running',
expanded: false,
result: '',
error: ''
}
aiMsg.toolCalls.push(tc)
scrollToBottom()
} else if (eventType === 'tool_result') {
// 工具调用返回结果
const tc = aiMsg.toolCalls.find((t) => t.id === parsed.id)
if (tc) {
tc.status = parsed.ok ? 'ok' : 'error'
tc.result = parsed.result || ''
tc.error = parsed.error || ''
}
scrollToBottom()
} else if (eventType === 'done') {
aiMsg.id = parsed.message_id
if (parsed.tokens !== undefined) aiMsg.tokens = parsed.tokens
if (parsed.duration_ms !== undefined) aiMsg.durationMs = parsed.duration_ms
if (parsed.model) aiMsg.model = parsed.model
aiMsg.providerName = parsed.provider_name || currentProvider.value?.name || ''
} else if (eventType === 'error') {
streamError = new Error(parsed.error || 'AI调用失败')
}
} catch (e) {
// JSON解析错误忽略
}
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 统一换行符
buffer = buffer.replace(/\r\n/g, '\n')
const lines = buffer.split('\n')
// 最后一行可能不完整,留到下一次
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7)
} else if (line.startsWith('data: ')) {
currentData = line.slice(6)
} else if (line === '') {
// 空行表示一个事件结束
handleSSEEvent(currentEvent, currentData)
currentEvent = ''
currentData = ''
}
}
}
// 处理缓冲区中剩余的最后一个事件
if (currentData) {
handleSSEEvent(currentEvent, currentData)
}
if (streamError) {
throw streamError
}
} catch (e) {
// 将错误以对话形式展示,不弹出顶部提示
const errText = e.message || '发送失败,请稍后重试'
if (messages.value[aiIndex] === aiMsg) {
if (!aiMsg.content) {
aiMsg.content = errText
} else {
aiMsg.content += '\n\n⚠️ ' + errText
}
aiMsg.error = true
}
} finally {
sending.value = false
// 流式期间被挂起的会话切换,此时再加载历史
if (pendingSessionId.value) {
const id = pendingSessionId.value
pendingSessionId.value = null
await fetchMessages(id)
}
scrollToBottom()
}
}
function handleQuickTip(tip) {
inputText.value = tip
}
function scrollToBottom() {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
defineExpose({
clearMessages: () => { messages.value = [] }
})
</script>
<style lang="less" scoped>
.chat-window {
display: flex;
flex-direction: column;
height: 100%;
background: var(--el-bg-color);
}
.chat-header {
padding: 12px 24px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
.chat-title {
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-selectors {
display: flex;
gap: 8px;
flex-shrink: 0;
align-items: center;
.preset-btn {
height: 32px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.has-preset {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
}
.provider-select {
width: 200px;
height: 32px;
:deep(.el-select__wrapper) {
height: 32px;
}
}
.model-select {
width: 180px;
height: 32px;
:deep(.el-select__wrapper) {
height: 32px;
}
}
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.quick-tips {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 500px;
.tip-tag {
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
}
}
}
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
}
.message-content {
flex: 1;
max-width: calc(100% - 60px);
}
.message-bubble {
display: inline-block;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
word-break: break-word;
.message-text {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
font-size: 14px;
}
}
.message-actions {
display: flex;
gap: 4px;
margin-top: 4px;
.action-btn {
padding: 2px;
font-size: 14px;
}
}
// 助手回复下方的响应信息(耗时 / tokens / 模型)
.message-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
.meta-dot {
color: var(--el-border-color);
}
.meta-item {
white-space: nowrap;
}
}
// 模型运行中的加载指示(流式输出期间显示)
.generating-indicator {
display: inline-flex;
align-items: center;
margin-bottom: 4px;
color: var(--el-color-primary);
font-size: 16px;
}
.thinking-text {
color: var(--el-text-color-secondary);
font-size: 13px;
}
// 助手消息报错(对话内错误展示)
.message-bubble.error {
background: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-5);
color: var(--el-color-danger);
white-space: pre-wrap;
}
.edit-area {
display: inline-block;
text-align: left;
width: 100%;
max-width: 500px;
.edit-actions {
display: flex;
gap: 8px;
margin-top: 8px;
justify-content: flex-end;
}
}
&.user-msg {
flex-direction: row-reverse;
.message-avatar {
background: var(--el-color-primary);
color: #fff;
}
.message-content {
text-align: right;
}
.message-actions {
justify-content: flex-end;
}
.message-bubble {
background: var(--el-color-primary);
color: #fff;
border-top-right-radius: 4px;
}
}
&.assistant-msg {
.message-avatar {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.message-bubble {
background: var(--el-fill-color);
border-top-left-radius: 4px;
}
}
}
.typing {
display: flex;
gap: 4px;
align-items: center;
min-height: 24px;
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-text-color-secondary);
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
.chat-input-area {
padding: 16px 24px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
.input-row {
display: flex;
gap: 12px;
align-items: stretch;
}
}
.input-tip {
text-align: center;
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 8px;
}
// 输入框上方状态栏(首字耗时等)
.input-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 2px 4px;
.meta-dot {
color: var(--el-border-color);
}
}
// 待发送图片预览
.input-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.input-image-item {
position: relative;
width: 64px;
height: 64px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--el-border-color);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.input-image-remove {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
padding: 2px;
}
}
// 输入框右侧操作区
.input-actions {
display: flex;
flex-direction: column;
flex-shrink: 0;
.send-btn {
flex: 1;
height: 100%;
}
}
}
// 消息内图片
.message-images {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.message-image {
max-width: 220px;
max-height: 220px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
object-fit: cover;
}
// MCP 状态指示
.mcp-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 16px;
border: 1px solid var(--el-border-color);
color: var(--el-text-color-secondary);
font-size: 12px;
cursor: pointer;
height: 32px;
user-select: none;
&:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
&.has-mcp {
background: var(--el-color-success-light-9);
border-color: var(--el-color-success);
color: var(--el-color-success);
}
.mcp-ind-count {
background: var(--el-color-success);
color: #fff;
border-radius: 8px;
font-size: 11px;
line-height: 16px;
padding: 0 5px;
min-width: 16px;
text-align: center;
}
}
// MCP 开关面板
.mcp-panel {
.mcp-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.mcp-panel-title {
font-size: 14px;
font-weight: 600;
}
.mcp-panel-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-bottom: 10px;
word-break: break-word;
}
.mcp-panel-empty {
font-size: 12px;
color: var(--el-text-color-placeholder);
padding: 6px 0;
}
.mcp-check-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
}
}
// 工具调用卡片
.tool-call-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.tool-call-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-bg-color);
overflow: hidden;
.tool-call-head {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
cursor: pointer;
font-size: 13px;
.tool-call-icon {
color: var(--el-color-primary);
flex-shrink: 0;
}
.tool-call-name {
flex: 1;
font-family: 'JetBrains Mono', Consolas, monospace;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.tool-server {
color: var(--el-text-color-secondary);
font-family: inherit;
margin-right: 4px;
}
}
.tool-status-icon {
color: var(--el-color-primary);
}
.tool-call-toggle {
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
}
.tool-call-detail {
border-top: 1px dashed var(--el-border-color-lighter);
padding: 8px 10px;
.tool-call-block {
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
}
.tool-call-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.tool-call-json {
margin: 0;
background: #f5f7fa;
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow: auto;
&.is-error {
color: var(--el-color-danger);
}
}
}
}
.message-bubble.has-tools {
display: block;
max-width: 100%;
}
</style>