/**
* 智能客服对话面板
*/
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: `
将清除此条之后的全部对话并重发
正在思考
{{ m.content }}
{{ m.time }}
`,
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
}
}
}