本地 RAG 知识库
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

/**
* 智能客服对话面板
*/
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">&middot;</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 &#x68C0;&#x7D22;</span>
</label>
<select v-if="isRagMode" class="select rag-strategy" v-model="ragStrategy">
<option value="NONE">&#x4E0D;&#x91CD;&#x5199;</option>
<option value="REWRITE">&#x67E5;&#x8BE2;&#x91CD;&#x5199;</option>
<option value="TRANSLATION">&#x7FFB;&#x8BD1;&#x6269;&#x5C55;</option>
<option value="COMPRESSION">&#x67E5;&#x8BE2;&#x538B;&#x7F29;</option>
<option value="MULTI_QUERY">&#x591A;&#x8DEF;&#x6269;&#x5C55;</option>
</select>
<select class="select chat-mode" v-model="mode">
<option value="sync">&#x540C;&#x6B65;&#x8C03;&#x7528;</option>
<option value="sse">SSE &#x6D41;&#x5F0F;</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
}
}
}