You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
531 lines
20 KiB
531 lines
20 KiB
/**
|
|
* 智能客服对话面板
|
|
*/
|
|
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: `
|
|
<div class="chat-shell">
|
|
<aside class="chat-sidebar">
|
|
<div class="agent-card">
|
|
<div class="agent-avatar">SB</div>
|
|
<div>
|
|
<div class="agent-name">Support Bot</div>
|
|
<div class="agent-status"><span></span>在线客服助手</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="side-section side-section-grow">
|
|
<div class="side-label">客服助手</div>
|
|
<div class="assistant-list">
|
|
<button
|
|
v-for="role in roles"
|
|
:key="role.key"
|
|
:class="['assistant-card', selectedRole === role.key ? 'active' : '']"
|
|
@click="selectRole(role.key)"
|
|
>
|
|
<span class="assistant-avatar-sm">{{ role.badge }}</span>
|
|
<span class="assistant-meta">
|
|
<strong>{{ role.name }}</strong>
|
|
<small>{{ role.desc }}</small>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="chat-main">
|
|
<header class="chat-header">
|
|
<div class="chat-title">
|
|
<h2>{{ currentRole.name }}</h2>
|
|
<div class="chat-subline">
|
|
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
|
|
<span class="dot-sep">·</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }}
|
|
</div>
|
|
</div>
|
|
<div class="chat-actions">
|
|
<div class="model-chip" :class="{'model-chip-error': modelLoadError}" :title="modelTitle">
|
|
<span>调用模型</span>
|
|
<strong>{{ activeModelText }}</strong>
|
|
</div>
|
|
<label class="rag-toggle">
|
|
<input type="checkbox" v-model="isRagMode">
|
|
<span>RAG 检索</span>
|
|
</label>
|
|
<select v-if="isRagMode" class="select rag-strategy" v-model="ragStrategy">
|
|
<option value="NONE">不重写</option>
|
|
<option value="REWRITE">查询重写</option>
|
|
<option value="TRANSLATION">翻译扩展</option>
|
|
<option value="COMPRESSION">查询压缩</option>
|
|
<option value="MULTI_QUERY">多路扩展</option>
|
|
</select>
|
|
<select class="select chat-mode" v-model="mode">
|
|
<option value="sync">同步调用</option>
|
|
<option value="sse">SSE 流式</option>
|
|
<option value="sse2">ServerSentEvent</option>
|
|
<option value="sse3">SseEmitter</option>
|
|
</select>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="quick-row" v-if="messages.length <= 1">
|
|
<button v-for="q in quickQuestions" :key="q" @click="useQuickQuestion(q)">{{ q }}</button>
|
|
</div>
|
|
|
|
<div class="msg-area chat-msg-area" ref="msgArea">
|
|
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
|
|
<div class="msg-content">
|
|
<template v-if="editingIndex === i">
|
|
<textarea class="textarea edit-textarea" v-model="editingText" rows="2"
|
|
@keydown.enter.exact.prevent="submitEdit(i)"></textarea>
|
|
<div class="edit-actions">
|
|
<button class="btn btn-primary btn-sm" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</button>
|
|
<button class="btn btn-outline btn-sm" @click="cancelEdit">取消</button>
|
|
<span>将清除此条之后的全部对话并重发</span>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<!-- MCP 工具调用展示 -->
|
|
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
|
|
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
|
|
<div class="mcp-tool-header">
|
|
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
|
|
<span class="mcp-tool-name">{{ tc.tool }}</span>
|
|
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div>
|
|
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
|
|
<div v-else class="msg-bubble">{{ m.content }}</div>
|
|
<message-sources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources"></message-sources>
|
|
<div class="msg-tools">
|
|
<span>{{ m.time }}</span>
|
|
<button v-if="m.role === 'assistant' && m.content" @click="copyMessage(m.content)">复制</button>
|
|
<button v-if="m.role === 'assistant' && m.content && !m.streaming" @click="regenerate(i)">重新生成</button>
|
|
<button v-if="m.role === 'user' && !m.streaming" @click="startEditMessage(i)">编辑</button>
|
|
<button v-if="m.error" @click="retryLast">重试</button>
|
|
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
|
|
<button @click="submitFeedback(m.id, 'up')" :style="{color: m.feedback === 'up' ? 'var(--primary)' : ''}" title="有帮助">👍</button>
|
|
<button @click="submitFeedback(m.id, 'down')" :style="{color: m.feedback === 'down' ? '#dc3545' : ''}" title="没帮助">👎</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="chat-composer">
|
|
<div class="composer-box">
|
|
<textarea
|
|
class="chat-textarea"
|
|
v-model="userInput"
|
|
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
|
|
@keydown.enter.exact.prevent="send"
|
|
:disabled="isSending"
|
|
></textarea>
|
|
<button class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
|
|
{{ isSending ? '发送中' : '发送' }}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
`,
|
|
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
|
|
}
|
|
}
|
|
}
|