| | |
| | | <div class="ai-chat-sidebar-wrapper"> |
| | | <!-- 悬浮图标 --> |
| | | <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible"> |
| | | <el-tooltip content="AI 助手" placement="left"> |
| | | <el-tooltip :content="currentAssistant.tooltip" placement="left"> |
| | | <div class="trigger-icon"> |
| | | <el-icon :size="30" color="#fff"><Cpu /></el-icon> |
| | | <el-icon :size="30" color="#fff"><component :is="currentAssistant.icon" /></el-icon> |
| | | </div> |
| | | </el-tooltip> |
| | | </div> |
| | |
| | | <template #header> |
| | | <div class="drawer-header"> |
| | | <div class="header-left"> |
| | | <el-icon :size="20" class="header-icon"><Cpu /></el-icon> |
| | | <span class="title">AI 智能助手</span> |
| | | <el-icon :size="20" class="header-icon"><component :is="currentAssistant.icon" /></el-icon> |
| | | <span class="title">{{ currentAssistant.title }}</span> |
| | | </div> |
| | | <div v-if="showAssistantSwitch" class="assistant-switcher"> |
| | | <el-radio-group v-model="selectedAssistantKey" size="small"> |
| | | <el-radio-button |
| | | v-for="assistant in assistants" |
| | | :key="assistant.key" |
| | | :label="assistant.key" |
| | | > |
| | | {{ assistant.label }} |
| | | </el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div class="header-actions"> |
| | | <el-tooltip content="会话历史" placement="bottom"> |
| | |
| | | <el-icon><Delete /></el-icon> |
| | | </el-button> |
| | | </div> |
| | | <el-empty v-if="sessions.length === 0" description="暂无历史会话" /> |
| | | <el-empty v-if="sessions.length === 0" :description="currentAssistant.emptySessionText" /> |
| | | </div> |
| | | </el-skeleton> |
| | | </div> |
| | |
| | | <el-icon><VideoPause /></el-icon>停止生成 |
| | | </el-button> |
| | | <el-upload |
| | | v-if="currentAssistant.allowFileUpload" |
| | | class="file-upload-trigger" |
| | | action="#" |
| | | :auto-upload="false" |
| | |
| | | v-model="inputMessage" |
| | | type="textarea" |
| | | :rows="selectedFile ? 2 : 3" |
| | | placeholder="请输入您的问题... (Enter 发送, Shift+Enter 换行)" |
| | | :placeholder="currentAssistant.placeholder" |
| | | resize="none" |
| | | @keydown.enter.exact.prevent="sendMessage" |
| | | /> |
| | |
| | | import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue' |
| | | import request from '@/utils/request' |
| | | import * as echarts from 'echarts' |
| | | import { Cpu, User, Plus, Loading, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close } from '@element-plus/icons-vue' |
| | | import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, ShoppingCart } from '@element-plus/icons-vue' |
| | | import { ElMessage } from 'element-plus' |
| | | |
| | | const props = defineProps({ |
| | | assistants: { |
| | | type: Array, |
| | | default: () => [] |
| | | }, |
| | | defaultAssistant: { |
| | | type: String, |
| | | default: '' |
| | | } |
| | | }) |
| | | |
| | | const builtInAssistants = [ |
| | | { |
| | | key: 'general', |
| | | label: '待办助理', |
| | | title: '待办智能助理', |
| | | tooltip: '待办助手', |
| | | icon: Cpu, |
| | | apiBase: '/xiaozhi', |
| | | storageKey: 'ai_chat_uuid', |
| | | placeholder: '请输入您的问题... (Enter 发送, Shift+Enter 换行)', |
| | | welcomeMessage: '你好', |
| | | allowFileUpload: true, |
| | | emptySessionText: '暂无历史会话' |
| | | }, |
| | | { |
| | | key: 'purchase', |
| | | label: '采购助理', |
| | | title: '采购智能助理', |
| | | tooltip: '采购智能助理', |
| | | icon: ShoppingCart, |
| | | apiBase: '/purchase-ai', |
| | | storageKey: 'purchase_ai_chat_uuid', |
| | | placeholder: '请输入采购问题... (Enter 发送, Shift+Enter 换行)', |
| | | welcomeMessage: '你好', |
| | | allowFileUpload: false, |
| | | emptySessionText: '暂无采购会话' |
| | | } |
| | | ] |
| | | |
| | | const assistants = computed(() => props.assistants?.length ? props.assistants : builtInAssistants) |
| | | const selectedAssistantKey = ref(props.defaultAssistant || assistants.value[0]?.key || 'general') |
| | | const currentAssistant = computed(() => assistants.value.find(item => item.key === selectedAssistantKey.value) || assistants.value[0] || builtInAssistants[0]) |
| | | const showAssistantSwitch = computed(() => assistants.value.length > 1) |
| | | |
| | | const visible = ref(false) |
| | | const windowWidth = ref(window.innerWidth) |
| | |
| | | const loadSessions = async () => { |
| | | loadingSessions.value = true |
| | | try { |
| | | const res = await request.get('/xiaozhi/history/sessions') |
| | | const res = await request.get(`${currentAssistant.value.apiBase}/history/sessions`) |
| | | if (res.code === 200) { |
| | | sessions.value = res.data || [] |
| | | } |
| | |
| | | const selectSession = async (session) => { |
| | | showHistory.value = false |
| | | uuid.value = session.memoryId |
| | | localStorage.setItem('ai_chat_uuid', uuid.value) |
| | | localStorage.setItem(currentAssistant.value.storageKey, uuid.value) |
| | | |
| | | // 加载会话消息 |
| | | try { |
| | | const res = await request.get(`/xiaozhi/history/messages/${uuid.value}`) |
| | | const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`) |
| | | if (res.code === 200) { |
| | | disposeCharts() |
| | | messages.value = [] |
| | |
| | | |
| | | const handleDeleteSession = async (memoryId) => { |
| | | try { |
| | | const res = await request.delete(`/xiaozhi/history/${memoryId}`) |
| | | const res = await request.delete(`${currentAssistant.value.apiBase}/history/${memoryId}`) |
| | | if (res.code === 200) { |
| | | loadSessions() |
| | | if (uuid.value === memoryId) { |
| | |
| | | window.removeEventListener('resize', handleWindowResize) |
| | | }) |
| | | |
| | | watch(selectedAssistantKey, (nextKey, prevKey) => { |
| | | if (!prevKey || nextKey === prevKey) return |
| | | |
| | | if (currentAbortController.value) { |
| | | currentAbortController.value.abort() |
| | | currentAbortController.value = null |
| | | } |
| | | |
| | | isSending.value = false |
| | | disposeCharts() |
| | | messages.value = [] |
| | | outputState.value = {} |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | inputMessage.value = '' |
| | | initUUID() |
| | | hello() |
| | | }) |
| | | |
| | | const handleWindowResize = () => { |
| | | windowWidth.value = window.innerWidth |
| | | } |
| | |
| | | } |
| | | |
| | | const initUUID = () => { |
| | | let storedUUID = localStorage.getItem('ai_chat_uuid') |
| | | let storedUUID = localStorage.getItem(currentAssistant.value.storageKey) |
| | | if (!storedUUID) { |
| | | storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4) |
| | | localStorage.setItem('ai_chat_uuid', storedUUID) |
| | | localStorage.setItem(currentAssistant.value.storageKey, storedUUID) |
| | | } |
| | | uuid.value = storedUUID |
| | | } |
| | | |
| | | const hello = () => { |
| | | sendRequest('你好') |
| | | sendRequest(currentAssistant.value.welcomeMessage || '你好') |
| | | } |
| | | |
| | | const newChat = () => { |
| | | disposeCharts() |
| | | messages.value = [] |
| | | outputState.value = {} |
| | | localStorage.removeItem('ai_chat_uuid') |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | localStorage.removeItem(currentAssistant.value.storageKey) |
| | | initUUID() |
| | | hello() |
| | | } |
| | |
| | | formData.append('message', message.trim()) |
| | | } |
| | | |
| | | request.post('/xiaozhi/analyze-file', formData, { |
| | | request.post(`${currentAssistant.value.apiBase}/analyze-file`, formData, { |
| | | headers: { |
| | | 'Content-Type': 'multipart/form-data' |
| | | }, |
| | |
| | | |
| | | scrollToBottom() |
| | | |
| | | request.post('/xiaozhi/chat', |
| | | request.post(`${currentAssistant.value.apiBase}/chat`, |
| | | { memoryId: uuid.value, message }, |
| | | { |
| | | signal: currentAbortController.value.signal, |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | .assistant-switcher { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex: 1; |
| | | padding: 0 12px; |
| | | position: relative; |
| | | z-index: 1; |
| | | |
| | | :deep(.el-radio-group) { |
| | | display: flex; |
| | | gap: 6px; |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | } |
| | | |
| | | :deep(.el-radio-button__inner) { |
| | | border-radius: 999px; |
| | | border: 1px solid rgba(255, 255, 255, 0.18); |
| | | background: rgba(255, 255, 255, 0.12); |
| | | color: rgba(255, 255, 255, 0.86); |
| | | box-shadow: none; |
| | | padding: 7px 14px; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | :deep(.el-radio-button:first-child .el-radio-button__inner), |
| | | :deep(.el-radio-button:last-child .el-radio-button__inner) { |
| | | border-radius: 999px; |
| | | } |
| | | |
| | | :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { |
| | | background: #fff; |
| | | color: $primary-blue; |
| | | border-color: #fff; |
| | | box-shadow: 0 6px 14px rgba(0, 40, 120, 0.16); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @keyframes headerGlow { |