1391 lines
39 KiB
Vue
1391 lines
39 KiB
Vue
<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>
|