优化ai广场

This commit is contained in:
2026-09-10 22:31:04 +08:00
parent 9f60450ffa
commit 1304e71b7a
4 changed files with 314 additions and 54 deletions
@@ -17,7 +17,6 @@
<el-select
v-model="selectedProviderId"
placeholder="选择接入"
size="small"
class="provider-select"
@change="handleProviderChange"
>
@@ -31,7 +30,6 @@
<el-select
v-model="selectedModel"
placeholder="选择模型"
size="small"
class="model-select"
:disabled="!selectedProviderId"
>
@@ -124,6 +122,9 @@
<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>
@@ -157,9 +158,11 @@
</template>
</div>
<div
v-if="msg.role === 'assistant' && (msg.tokens || msg.durationMs)"
v-if="msg.role === 'assistant' && (msg.tokens || msg.durationMs || msg.firstTokenMs)"
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 class="meta-item">{{ formatDuration(msg.durationMs) }}</span>
<span class="meta-dot">·</span>
<span class="meta-item">{{ msg.tokens }} tokens</span>
@@ -186,26 +189,47 @@
</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">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="输入消息,Enter发送,Shift+Enter换行"
@keydown.enter.exact.prevent="handleSend"
resize="none"
/>
<el-button
type="primary"
class="send-btn"
:icon="Promotion"
:loading="sending"
:disabled="!inputText.trim()"
@click="handleSend"
>
发送
</el-button>
<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>
@@ -217,7 +241,7 @@
<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 } from '@element-plus/icons-vue'
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'
@@ -231,6 +255,8 @@ 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 变化的暂存值(等待本轮结束后再加载历史)
@@ -254,14 +280,21 @@ const defaultPreset = ref(null)
// MCP 接入状态
const activeMcpTools = ref([])
// 本轮对话是否启用 MCP 工具(本地记忆上次选择)
// 本轮对话是否启用 MCP 工具;默认开启,以 MCP 市场配置为准(市场已启用服务即可被调用)
const savedMcp = loadMcpSetting()
const mcpEnabled = ref(savedMcp.enabled)
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 工具'
@@ -276,10 +309,8 @@ const mcpTip = computed(() => {
})
function loadMcpSetting() {
const setting = { enabled: true, serverIds: [] }
const setting = { serverIds: [] }
try {
const e = localStorage.getItem('ai_mcp_enabled')
if (e !== null) setting.enabled = e === '1'
const s = localStorage.getItem('ai_mcp_servers')
if (s) setting.serverIds = JSON.parse(s) || []
} catch (e) {
@@ -290,13 +321,42 @@ function loadMcpSetting() {
function persistMcpSetting() {
try {
localStorage.setItem('ai_mcp_enabled', mcpEnabled.value ? '1' : '0')
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'
@@ -315,6 +375,8 @@ onMounted(() => {
fetchMcpStatus()
})
// 是否已根据市场配置完成首次自动开启
let mcpLoaded = false
async function fetchMcpStatus() {
try {
const res = await getMcpServerList()
@@ -324,6 +386,11 @@ async function fetchMcpStatus() {
// 过滤掉已删除/已停用的服务;没有历史选择时默认全选
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) {
// 静默失败
@@ -346,13 +413,22 @@ async function fetchProviders() {
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 (firstActive) {
if (saved.providerId && providerList.value.some(p => p.id === saved.providerId)) {
selectedProviderId.value = saved.providerId
} else if (firstActive) {
selectedProviderId.value = firstActive.id
if (firstActive.models && firstActive.models.length > 0) {
selectedModel.value = firstActive.models[0]
}
}
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) {
@@ -411,9 +487,12 @@ async function fetchMessages(sessionId) {
async function handleSend() {
const content = inputText.value.trim()
if (!content || sending.value) return
const images = inputImages.value
if ((!content && images.length === 0) || sending.value) return
inputText.value = ''
await doSend(content)
const imgs = images.slice()
inputImages.value = []
await doSend(content, imgs)
}
// ===== 消息操作 =====
@@ -529,9 +608,39 @@ async function deleteMessage(msg, idx) {
}
}
// 选择图片并读取为 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) {
if (!content || sending.value) return
async function doSend(content, images = []) {
if ((!content && (!images || images.length === 0)) || sending.value) return
if (!selectedProviderId.value) {
ElMessage.warning('请先选择接入')
return
@@ -542,14 +651,16 @@ async function doSend(content) {
}
// 添加用户消息
messages.value.push({ role: 'user', content })
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 })
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 {
@@ -567,7 +678,8 @@ async function doSend(content) {
provider_id: selectedProviderId.value,
model: selectedModel.value,
mcp_enabled: mcpOn.value,
mcp_server_ids: mcpOn.value ? selectedMcpServers.value : []
mcp_server_ids: mcpOn.value ? selectedMcpServers.value : [],
images: images
})
})
@@ -596,10 +708,13 @@ async function doSend(content) {
selfSessionSwitch.value = true
emit('session-created', {
id: parsed.session_id,
title: content.length > 20 ? content.slice(0, 20) + '...' : content
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') {
@@ -762,10 +877,20 @@ defineExpose({
.provider-select {
width: 200px;
height: 32px;
:deep(.el-select__wrapper) {
height: 32px;
}
}
.model-select {
width: 180px;
height: 32px;
:deep(.el-select__wrapper) {
height: 32px;
}
}
}
}
@@ -979,12 +1104,13 @@ defineExpose({
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
flex-direction: column;
gap: 8px;
.send-btn {
height: 40px;
flex-shrink: 0;
.input-row {
display: flex;
gap: 12px;
align-items: stretch;
}
}
@@ -994,6 +1120,83 @@ defineExpose({
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 状态指示