/** * 智能客服对话面板 */ import { ref, computed, nextTick, onMounted } from 'vue' import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' import { toast, readSSEStream, readSSEStreamWithEvents, renderMarkdown } from '../js/utils.js' import { store } from '../js/store.js' import MessageSources from './MessageSources.js' const FALLBACK_ROLE = { id: '', key: 'general', name: '客服', desc: '可检索全部知识库', tone: '用户咨询、业务办理、常见问题、问题受理与进度说明', badge: '默认', categoryIds: [] } const QUICK_QUESTIONS = [ '我的问题应该找哪个部门处理?', '如何办理业务申请?', '流程进度一直没更新怎么办?', '费用报销需要准备哪些材料?', '办公用品或资产怎么申请?' ] export default { components: { 'message-sources': MessageSources }, template: `

{{ currentRole.name }}

{{ ragStatusText }} ·{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }}
调用模型 {{ activeModelText }}
`, setup() { const chatId = ref('') const mode = ref('sync') const selectedRole = ref('general') const roles = ref([FALLBACK_ROLE]) const isRagMode = ref(false) const ragStrategy = ref('MULTI_QUERY') const activeModel = ref(null) const modelLoadError = ref('') const userInput = ref('') const lastUserInput = ref('') const isSending = ref(false) const messages = ref([ { role: 'assistant', content: '\u60a8\u597d\uff0c\u6211\u662f\u667a\u80fd\u5ba2\u670d\u52a9\u624b\u3002\u53ef\u4ee5\u54a8\u8be2\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u76f8\u5173\u95ee\u9898\uff1b\u9700\u8981\u57fa\u4e8e\u77e5\u8bc6\u5e93\u56de\u7b54\u65f6\uff0c\u53ef\u4ee5\u5728\u4e0a\u65b9\u5f00\u542f RAG \u68c0\u7d22\u3002', streaming: false, time: formatTime() } ]) const msgArea = ref(null) const editingIndex = ref(-1) const editingText = ref('') const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) const selectedCategoryNames = computed(() => { const selected = new Set(selectedCategoryIds.value) return store.categories .filter(category => selected.has(String(category.id))) .map(category => category.name) }) const activeModelText = computed(() => { if (activeModel.value) { const provider = providerLabel(activeModel.value.provider) const modelName = activeModel.value.modelName || activeModel.value.model_name || '-' return provider ? `${provider} / ${modelName}` : modelName } return modelLoadError.value ? '未配置' : '加载中' }) const modelTitle = computed(() => { if (activeModel.value) { const model = activeModel.value const name = model.name ? `${model.name}:` : '' const provider = providerLabel(model.provider) const modelName = model.modelName || model.model_name || '-' return `对话模型:${name}${provider ? provider + ' / ' : ''}${modelName}` } return modelLoadError.value || '正在加载对话模型' }) const ragStatusText = computed(() => { if (!isRagMode.value) return '普通对话' const names = { NONE: 'RAG:不重写', REWRITE: 'RAG:查询重写', TRANSLATION: 'RAG:翻译扩展', COMPRESSION: 'RAG:查询压缩', MULTI_QUERY: 'RAG:多路扩展' } return names[ragStrategy.value] || 'RAG 已启用' }) function formatTime() { return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } function generateMsgId() { return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8) } function newChatId() { chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) } function selectRole(roleKey) { selectedRole.value = roleKey newChatId() clearMessages(roleKey) } function clearMessages(roleKey = selectedRole.value) { const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE messages.value = [{ role: 'assistant', content: `已切换到${role.name}。请直接输入你的问题。`, streaming: false, time: formatTime() }] } function useQuickQuestion(question) { userInput.value = question send() } function providerLabel(provider) { const map = { dashscope: '通义千问', openai: 'OpenAI', volcengine: '豆包', zhipu: '智谱', baidu: '百度千帆', other: '自定义' } return map[String(provider || '').toLowerCase()] || provider || '' } async function loadActiveModel() { try { const json = await getActiveModelConfig('CHAT') if (json.success) { activeModel.value = json.data modelLoadError.value = '' } else { activeModel.value = null modelLoadError.value = json.message || '对话模型未配置' } } catch (e) { activeModel.value = null modelLoadError.value = e.message || '模型加载失败' } } async function loadRoles() { try { const json = await getRoleList() if (json.success) { roles.value = (json.data || []).map(role => ({ id: role.id, key: role.role_key || role.roleKey || String(role.id), name: role.name, desc: role.description || '', tone: role.description || '专业、耐心、简洁', badge: role.name ? role.name.slice(0, 2) : '角色', categoryIds: (role.categoryIds || []).map(String) })) if (!roles.value.some(role => role.key === selectedRole.value)) { selectedRole.value = roles.value[0]?.key || 'general' } } } catch (e) { toast('加载客服角色失败:' + e.message, 'error') } } function currentRoleId() { // 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。 // 空ID(如未加载完成的兜底角色)时返回空串,服务端将退回基础提示词与全量知识库。 return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' } async function scrollToBottom() { await nextTick() if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight } async function send() { const text = userInput.value.trim() if (!text || isSending.value) return userInput.value = '' lastUserInput.value = text isSending.value = true messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() }) const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [], toolCalls: [] } messages.value.push(assistantMsg) await scrollToBottom() const cid = chatId.value || ('web_' + Date.now()) chatId.value = cid // 发送用户原始问题(不再包装角色信息,避免污染会话记忆); // 角色人设与知识库范围由服务端依据 roleId 强制套用。 const roleId = currentRoleId() try { if (isRagMode.value && mode.value === 'sync') { assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) } else if (isRagMode.value) { const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) await readSSEStreamWithEvents(url, { onMessage: async (chunk) => { assistantMsg.content += chunk await scrollToBottom() }, onToolCallStart: (data) => { assistantMsg.toolCalls.push({ tool: data.tool, input: data.input, status: 'running', result: null }) scrollToBottom() }, onToolCallResult: (data) => { const tc = assistantMsg.toolCalls.find(t => t.tool === data.tool && t.status === 'running') if (tc) { tc.status = 'done' tc.result = data.result tc.latencyMs = data.latencyMs } scrollToBottom() }, onError: (data) => { assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错') }, onDone: () => {} }) } else if (mode.value === 'sync') { assistantMsg.content = await chatSync(text, cid, roleId) } else { const url = chatSSEUrl(text, cid, mode.value, roleId) await readSSEStream(url, async (chunk) => { assistantMsg.content += chunk await scrollToBottom() }, () => {}) } // RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方 if (isRagMode.value) { try { const sj = await ragSources(text, cid, ragStrategy.value, roleId) if (sj && sj.success) assistantMsg.sources = sj.data || [] } catch (_) { /* 来源获取失败不影响主回答 */ } } } catch (e) { assistantMsg.content = '请求失败:' + e.message assistantMsg.error = true toast('对话失败:' + e.message, 'error') } finally { assistantMsg.streaming = false isSending.value = false await scrollToBottom() } } function retryLast() { if (!lastUserInput.value || isSending.value) return userInput.value = lastUserInput.value send() } function startEditMessage(i) { if (isSending.value) return editingIndex.value = i editingText.value = messages.value[i].content || '' } function cancelEdit() { editingIndex.value = -1 editingText.value = '' } async function submitEdit(i) { const text = editingText.value.trim() if (!text || isSending.value) return // 这是第几条用户消息(1-based,忽略问候语 / AI 消息) let userTurn = 0 for (let j = 0; j <= i; j++) { if (messages.value[j].role === 'user') userTurn++ } // 先让后端清掉这条用户消息及其之后的全部消息(修复对话记忆,避免污染上下文) try { const json = await truncateConversation(chatId.value, userTurn) if (json && json.success === false) { toast(json.message || '截断失败', 'error') return } } catch (e) { toast('截断失败:' + e.message, 'error') return } // 切掉本地的本条及其之后消息,再用新内容重发 messages.value = messages.value.slice(0, i) editingIndex.value = -1 editingText.value = '' userInput.value = text await send() } async function regenerate(i) { if (isSending.value) return // 找到这条 AI 回答对应的上一条用户消息 let userIndex = -1 for (let j = i - 1; j >= 0; j--) { if (messages.value[j].role === 'user') { userIndex = j; break } } if (userIndex < 0) return const text = messages.value[userIndex].content let userTurn = 0 for (let j = 0; j <= userIndex; j++) { if (messages.value[j].role === 'user') userTurn++ } // 截断该用户消息及其之后(含本条回答),用相同问题重新生成 try { const json = await truncateConversation(chatId.value, userTurn) if (json && json.success === false) { toast(json.message || '重新生成失败', 'error'); return } } catch (e) { toast('重新生成失败:' + e.message, 'error'); return } messages.value = messages.value.slice(0, userIndex) userInput.value = text await send() } async function copyMessage(content) { try { await navigator.clipboard.writeText(content) toast('已复制回复', 'success') } catch (e) { toast('复制失败', 'error') } } // ===== P0-002: 消息反馈 ===== async function submitFeedback(msgId, type) { const msgIndex = messages.value.findIndex(m => m.id === msgId && m.role === 'assistant') if (msgIndex === -1) return const msg = messages.value[msgIndex] const wasActive = msg.feedback === type const newFeedback = wasActive ? null : type msg.feedback = newFeedback try { await submitFeedbackApi({ messageId: String(msgId), conversationId: chatId.value, feedbackType: newFeedback ? (newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN') : null }) if (newFeedback) { toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success') } } catch (e) { console.error('反馈提交失败:', e) } } onMounted(() => { newChatId() store.loadCategories() loadRoles() loadActiveModel() }) return { store, roles, quickQuestions: QUICK_QUESTIONS, chatId, mode, selectedRole, selectedCategoryNames, isRagMode, ragStrategy, activeModelText, modelTitle, modelLoadError, userInput, isSending, messages, msgArea, currentRole, ragStatusText, newChatId, selectRole, clearMessages, useQuickQuestion, send, retryLast, copyMessage, renderMd: renderMarkdown, editingIndex, editingText, startEditMessage, cancelEdit, submitEdit, regenerate, submitFeedback } } }