|
|
@ -2,7 +2,7 @@ |
|
|
* 智能客服对话面板 |
|
|
* 智能客服对话面板 |
|
|
*/ |
|
|
*/ |
|
|
import { ref, computed, nextTick, onMounted } from 'vue' |
|
|
import { ref, computed, nextTick, onMounted } from 'vue' |
|
|
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getAccountList, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' |
|
|
|
|
|
|
|
|
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' |
|
|
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js' |
|
|
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js' |
|
|
import { store } from '../js/store.js' |
|
|
import { store } from '../js/store.js' |
|
|
import MessageSources from './MessageSources.js' |
|
|
import MessageSources from './MessageSources.js' |
|
|
@ -38,16 +38,6 @@ export default { |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<div class="side-label">对话账号</div> |
|
|
|
|
|
<select class="select chat-select" v-model="selectedAccount" @change="selectAccount"> |
|
|
|
|
|
<option value="">未选择账号</option> |
|
|
|
|
|
<option v-for="account in accounts" :key="account.id" :value="account.id"> |
|
|
|
|
|
{{ account.name }}{{ account.role_name ? ' / ' + account.role_name : ' / 未绑定角色' }} |
|
|
|
|
|
</option> |
|
|
|
|
|
</select> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="side-section side-section-grow"> |
|
|
<div class="side-section side-section-grow"> |
|
|
<div class="side-label">客服助手</div> |
|
|
<div class="side-label">客服助手</div> |
|
|
<div class="assistant-list"> |
|
|
<div class="assistant-list"> |
|
|
@ -77,6 +67,10 @@ export default { |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="chat-actions"> |
|
|
<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"> |
|
|
<label class="rag-toggle"> |
|
|
<input type="checkbox" v-model="isRagMode"> |
|
|
<input type="checkbox" v-model="isRagMode"> |
|
|
<span>RAG 检索</span> |
|
|
<span>RAG 检索</span> |
|
|
@ -154,12 +148,12 @@ export default { |
|
|
setup() { |
|
|
setup() { |
|
|
const chatId = ref('') |
|
|
const chatId = ref('') |
|
|
const mode = ref('sync') |
|
|
const mode = ref('sync') |
|
|
const selectedAccount = ref('') |
|
|
|
|
|
const accounts = ref([]) |
|
|
|
|
|
const selectedRole = ref('general') |
|
|
const selectedRole = ref('general') |
|
|
const roles = ref([FALLBACK_ROLE]) |
|
|
const roles = ref([FALLBACK_ROLE]) |
|
|
const isRagMode = ref(false) |
|
|
const isRagMode = ref(false) |
|
|
const ragStrategy = ref('MULTI_QUERY') |
|
|
const ragStrategy = ref('MULTI_QUERY') |
|
|
|
|
|
const activeModel = ref(null) |
|
|
|
|
|
const modelLoadError = ref('') |
|
|
const userInput = ref('') |
|
|
const userInput = ref('') |
|
|
const lastUserInput = ref('') |
|
|
const lastUserInput = ref('') |
|
|
const isSending = ref(false) |
|
|
const isSending = ref(false) |
|
|
@ -175,13 +169,7 @@ export default { |
|
|
const editingIndex = ref(-1) |
|
|
const editingIndex = ref(-1) |
|
|
const editingText = ref('') |
|
|
const editingText = ref('') |
|
|
|
|
|
|
|
|
const currentAccount = computed(() => accounts.value.find(account => String(account.id) === String(selectedAccount.value)) || null) |
|
|
|
|
|
const currentRole = computed(() => { |
|
|
|
|
|
if (currentAccount.value && currentAccount.value.role_id) { |
|
|
|
|
|
return roles.value.find(role => String(role.id) === String(currentAccount.value.role_id)) || roles.value[0] || FALLBACK_ROLE |
|
|
|
|
|
} |
|
|
|
|
|
return roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) |
|
|
const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) |
|
|
const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) |
|
|
const selectedCategoryNames = computed(() => { |
|
|
const selectedCategoryNames = computed(() => { |
|
|
const selected = new Set(selectedCategoryIds.value) |
|
|
const selected = new Set(selectedCategoryIds.value) |
|
|
@ -189,6 +177,24 @@ export default { |
|
|
.filter(category => selected.has(String(category.id))) |
|
|
.filter(category => selected.has(String(category.id))) |
|
|
.map(category => category.name) |
|
|
.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(() => { |
|
|
const ragStatusText = computed(() => { |
|
|
if (!isRagMode.value) return '普通对话' |
|
|
if (!isRagMode.value) return '普通对话' |
|
|
const names = { |
|
|
const names = { |
|
|
@ -212,34 +218,16 @@ export default { |
|
|
function newChatId() { |
|
|
function newChatId() { |
|
|
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) |
|
|
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function selectRole(roleKey) { |
|
|
function selectRole(roleKey) { |
|
|
const role = roles.value.find(item => item.key === roleKey) |
|
|
|
|
|
// 双向绑定:选助手时自动同步到绑定该角色的账号;没有对应账号则进入"无账号"模式
|
|
|
|
|
|
const boundAccount = role |
|
|
|
|
|
? accounts.value.find(account => String(account.role_id) === String(role.id) && account.enabled !== false) |
|
|
|
|
|
: null |
|
|
|
|
|
selectedAccount.value = boundAccount ? String(boundAccount.id) : '' |
|
|
|
|
|
selectedRole.value = roleKey |
|
|
selectedRole.value = roleKey |
|
|
newChatId() |
|
|
newChatId() |
|
|
clearMessages(roleKey) |
|
|
clearMessages(roleKey) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function selectAccount() { |
|
|
|
|
|
const account = currentAccount.value |
|
|
|
|
|
if (account && account.role_id) { |
|
|
|
|
|
const role = roles.value.find(item => String(item.id) === String(account.role_id)) |
|
|
|
|
|
if (role) selectedRole.value = role.key |
|
|
|
|
|
} |
|
|
|
|
|
newChatId() |
|
|
|
|
|
clearMessages() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function clearMessages(roleKey = selectedRole.value) { |
|
|
function clearMessages(roleKey = selectedRole.value) { |
|
|
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE |
|
|
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE |
|
|
messages.value = [{ |
|
|
messages.value = [{ |
|
|
role: 'assistant', |
|
|
role: 'assistant', |
|
|
content: `已切换到${role.name}。我会按“${role.tone}”的方式处理问题。`, |
|
|
|
|
|
|
|
|
content: `已切换到${role.name}。请直接输入你的问题。`, |
|
|
streaming: false, |
|
|
streaming: false, |
|
|
time: formatTime() |
|
|
time: formatTime() |
|
|
}] |
|
|
}] |
|
|
@ -250,6 +238,34 @@ export default { |
|
|
send() |
|
|
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() { |
|
|
async function loadRoles() { |
|
|
try { |
|
|
try { |
|
|
const json = await getRoleList() |
|
|
const json = await getRoleList() |
|
|
@ -259,7 +275,7 @@ export default { |
|
|
key: role.role_key || role.roleKey || String(role.id), |
|
|
key: role.role_key || role.roleKey || String(role.id), |
|
|
name: role.name, |
|
|
name: role.name, |
|
|
desc: role.description || '', |
|
|
desc: role.description || '', |
|
|
tone: role.prompt || '专业、耐心、简洁', |
|
|
|
|
|
|
|
|
tone: role.description || '专业、耐心、简洁', |
|
|
badge: role.name ? role.name.slice(0, 2) : '角色', |
|
|
badge: role.name ? role.name.slice(0, 2) : '角色', |
|
|
categoryIds: (role.categoryIds || []).map(String) |
|
|
categoryIds: (role.categoryIds || []).map(String) |
|
|
})) |
|
|
})) |
|
|
@ -272,20 +288,6 @@ export default { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadAccounts() { |
|
|
|
|
|
try { |
|
|
|
|
|
const json = await getAccountList() |
|
|
|
|
|
if (json.success) { |
|
|
|
|
|
accounts.value = json.data || [] |
|
|
|
|
|
if (!selectedAccount.value && accounts.value.length) { |
|
|
|
|
|
selectedAccount.value = String(accounts.value[0].id) |
|
|
|
|
|
selectAccount() |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
toast('加载账号失败:' + e.message, 'error') |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function currentRoleId() { |
|
|
function currentRoleId() { |
|
|
// 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。
|
|
|
// 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。
|
|
|
@ -293,9 +295,6 @@ export default { |
|
|
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' |
|
|
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function currentAccountId() { |
|
|
|
|
|
return currentAccount.value && currentAccount.value.id ? String(currentAccount.value.id) : '' |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function scrollToBottom() { |
|
|
async function scrollToBottom() { |
|
|
await nextTick() |
|
|
await nextTick() |
|
|
@ -320,21 +319,20 @@ export default { |
|
|
// 发送用户原始问题(不再包装角色信息,避免污染会话记忆);
|
|
|
// 发送用户原始问题(不再包装角色信息,避免污染会话记忆);
|
|
|
// 角色人设与知识库范围由服务端依据 roleId 强制套用。
|
|
|
// 角色人设与知识库范围由服务端依据 roleId 强制套用。
|
|
|
const roleId = currentRoleId() |
|
|
const roleId = currentRoleId() |
|
|
const accountId = currentAccountId() |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
try { |
|
|
if (isRagMode.value && mode.value === 'sync') { |
|
|
if (isRagMode.value && mode.value === 'sync') { |
|
|
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId, accountId) |
|
|
|
|
|
|
|
|
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) |
|
|
} else if (isRagMode.value) { |
|
|
} else if (isRagMode.value) { |
|
|
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId, accountId) |
|
|
|
|
|
|
|
|
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) |
|
|
await readSSEStream(url, async (chunk) => { |
|
|
await readSSEStream(url, async (chunk) => { |
|
|
assistantMsg.content += chunk |
|
|
assistantMsg.content += chunk |
|
|
await scrollToBottom() |
|
|
await scrollToBottom() |
|
|
}, () => {}) |
|
|
}, () => {}) |
|
|
} else if (mode.value === 'sync') { |
|
|
} else if (mode.value === 'sync') { |
|
|
assistantMsg.content = await chatSync(text, cid, roleId, accountId) |
|
|
|
|
|
|
|
|
assistantMsg.content = await chatSync(text, cid, roleId) |
|
|
} else { |
|
|
} else { |
|
|
const url = chatSSEUrl(text, cid, mode.value, roleId, accountId) |
|
|
|
|
|
|
|
|
const url = chatSSEUrl(text, cid, mode.value, roleId) |
|
|
await readSSEStream(url, async (chunk) => { |
|
|
await readSSEStream(url, async (chunk) => { |
|
|
assistantMsg.content += chunk |
|
|
assistantMsg.content += chunk |
|
|
await scrollToBottom() |
|
|
await scrollToBottom() |
|
|
@ -343,7 +341,7 @@ export default { |
|
|
// RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方
|
|
|
// RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方
|
|
|
if (isRagMode.value) { |
|
|
if (isRagMode.value) { |
|
|
try { |
|
|
try { |
|
|
const sj = await ragSources(text, cid, ragStrategy.value, roleId, accountId) |
|
|
|
|
|
|
|
|
const sj = await ragSources(text, cid, ragStrategy.value, roleId) |
|
|
if (sj && sj.success) assistantMsg.sources = sj.data || [] |
|
|
if (sj && sj.success) assistantMsg.sources = sj.data || [] |
|
|
} catch (_) { /* 来源获取失败不影响主回答 */ } |
|
|
} catch (_) { /* 来源获取失败不影响主回答 */ } |
|
|
} |
|
|
} |
|
|
@ -461,13 +459,12 @@ export default { |
|
|
onMounted(() => { |
|
|
onMounted(() => { |
|
|
newChatId() |
|
|
newChatId() |
|
|
store.loadCategories() |
|
|
store.loadCategories() |
|
|
loadRoles().then(loadAccounts) |
|
|
|
|
|
|
|
|
loadRoles() |
|
|
|
|
|
loadActiveModel() |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
return { |
|
|
return { |
|
|
store, |
|
|
store, |
|
|
accounts, |
|
|
|
|
|
selectedAccount, |
|
|
|
|
|
roles, |
|
|
roles, |
|
|
quickQuestions: QUICK_QUESTIONS, |
|
|
quickQuestions: QUICK_QUESTIONS, |
|
|
chatId, |
|
|
chatId, |
|
|
@ -476,13 +473,15 @@ export default { |
|
|
selectedCategoryNames, |
|
|
selectedCategoryNames, |
|
|
isRagMode, |
|
|
isRagMode, |
|
|
ragStrategy, |
|
|
ragStrategy, |
|
|
|
|
|
activeModelText, |
|
|
|
|
|
modelTitle, |
|
|
|
|
|
modelLoadError, |
|
|
userInput, |
|
|
userInput, |
|
|
isSending, |
|
|
isSending, |
|
|
messages, |
|
|
messages, |
|
|
msgArea, |
|
|
msgArea, |
|
|
currentRole, |
|
|
currentRole, |
|
|
ragStatusText, |
|
|
ragStatusText, |
|
|
selectAccount, |
|
|
|
|
|
newChatId, |
|
|
newChatId, |
|
|
selectRole, |
|
|
selectRole, |
|
|
clearMessages, |
|
|
clearMessages, |
|
|
|