优化ai广场
This commit is contained in:
@@ -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 状态指示
|
||||
|
||||
Reference in New Issue
Block a user