feat(chat): 重构聊天模块并添加新功能

- 重新定义聊天模式,增加 AI 寿险代理人、报价中异议和报价后异议等场景
- 添加自定义头像和背景信息,提升用户体验
- 优化消息处理逻辑,支持标签内容处理
- 调整界面布局和样式,增加头像加载错误处理
- 移除不必要的代码,提高代码可维护性
This commit is contained in:
Lexcubia 2025-04-29 17:41:20 +08:00
parent 5a8c819c35
commit 5e4f45333b
8 changed files with 105 additions and 71 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -36,7 +36,7 @@ const chatModes = {
export default [
// 获取聊天模式配置
{
url: '/api/chat/modes',
url: 'api/chat/modes',
method: 'POST',
response: () => {
return {

View File

@ -1,53 +1,45 @@
import { getChatModes, sendMessage } from '@/api/chat'
import { useSpeakStore } from '@/store/speak'
import { processTagContent } from '@/utils'
import { speakText } from '@/utils/speak'
import { defineStore } from 'pinia'
const chatModes = {
training: {
name: "寿险代理人AI陪练",
customerAvatar: "/images/avatars/custom.png",
userAvatar: "/images/avatars/user.png",
token: "app-88ae2GN49aUyNO6qGg7tbTfX",
background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**:刘勇 \n**年龄**34 岁 \n**职业**:腾讯员工 \n**家庭**:妻子为全职太太,女儿 9 岁,双方父母健在 \n**性格**:冷静、理性、务实,偏好客观数据和逻辑分析,对保险持观望态度,防备心较强 \n**近期状态** \n- 体检显示中度脂肪肝,担心投保问题 \n- 已为全家配置百万医疗险,妻子有重疾险,但认为保障不足 (保费 50W\n- 对理财型保险收益不认可,但对补充重疾险有潜在需求 ",
chatBackground: "通过尚先生介绍,你和他的老同学在他的家里首次面谈。他的家中布置简洁,茶几上摆放着一套茶具。"
},
ai_agent: {
name: "AI寿险代理人",
customerAvatar: "/images/avatars/user.png",
userAvatar: "/images/avatars/custom.png",
token: "app-eYoE51WXCaEKXvzb9ZCj6lBn",
background: "# AI寿险代理人\n### **角色设定** \n**身份**AI寿险代理人 \n**特点**:专业、耐心、细致、富有同理心 \n**能力**\n- 精通各类寿险产品知识\n- 擅长需求分析和方案定制\n- 具备优秀的沟通和谈判技巧\n- 能够处理各类客户异议\n- 熟悉保险行业最新动态",
chatBackground: "一位AI的保险代理人通过聊天来确认用户的需求收集用户的基本信息"
},
quote_objection: {
name: "报价中异议",
customerAvatar: "/images/avatars/custom.png",
userAvatar: "/images/avatars/user.png",
token: "app-ur2Altw2LHR6niX8Q1S7Cn41",
background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 王大牛\n**年龄**: 40岁\n**职业**: 私营企业主\n**车牌号**: 闽C12345\n**车辆信息**拥有一辆行驶4年的大众途观L购买价格约25万主要用于商务出行及家庭使用。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险还有1个月到期。有过一次轻微追尾事故已通过保司处理。王先生对成本控制非常敏感但也能意识到保险的重要性。他希望得到保障全面、价格合理的产品和优质的服务。",
chatBackground: "现在你将扮演坐席专员,与系统扮演的客户针对报价中的各类异议开展对练,着重训练处理\"报价中异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
},
post_quote_objection: {
name: "报价后异议",
customerAvatar: "/images/avatars/custom.png",
userAvatar: "/images/avatars/user.png",
token: "app-Yiccl0JoXs2QF2lkHxO6f822",
background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 张灵女士\n**年龄**: 32岁\n**职业**: 公司行政主管\n**车牌号**: 粤B56789\n**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险、不计免赔险、车上人员责任险还有2个月到期。无事故记录仅有两次违章停车记录。张女士注重性价比和服务质量希望保险的保障全面、价格合理、服务贴心。",
chatBackground: "现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理\"报价后异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
}
}
const tagList = ['kehu', 'pingfen', 'zongjie', 'huaxiang', 'dafen']
// 处理标签内容的辅助函数
const processTagContent = (answerCache, tagName, cachedMessage) => {
// 检查是否包含完整的开始标签
const startTag = `<${tagName}>`
const endTag = `</${tagName}>`
// 如果正在处理该标签
if (cachedMessage.respondingType === tagName) {
// 检查是否包含结束标签
if (answerCache.includes(endTag)) {
// 提取结束标签前的内容
const content = answerCache.split(endTag)[0]
cachedMessage[tagName] += content
cachedMessage.respondingType = ''
cachedMessage[`${tagName}Finished`] = true
return answerCache.split(endTag)[1] || ''
} else {
// 继续累积内容
cachedMessage[tagName] += answerCache
return ''
}
}
// 检查是否包含开始标签
if (answerCache.includes(startTag)) {
cachedMessage.respondingType = tagName
const tagContent = answerCache.split(startTag)[1] || ''
// 检查开始标签后是否立即包含结束标签
if (tagContent.includes(endTag)) {
const content = tagContent.split(endTag)[0]
cachedMessage[tagName] = content
cachedMessage.respondingType = ''
return answerCache.split(endTag)[1] || ''
} else {
// 只有开始标签,累积内容
cachedMessage[tagName] = tagContent
return ''
}
}
return answerCache
}
export const useChatStore = defineStore('chat', {
state: () => ({
@ -55,11 +47,11 @@ export const useChatStore = defineStore('chat', {
currentMode: 'training',
conversationId: null,
messageCache: {},
chatModes: {},
chatModesLoading: false,
}),
getters: {
chatModes: () => chatModes,
currentToken: (state) => state.chatModes[state.currentMode]?.token,
currentConversation: (state) => {
return state.conversationId ? state.messages[state.conversationId] : null

43
src/utils/index.js Normal file
View File

@ -0,0 +1,43 @@
// 处理标签内容的辅助函数
export const processTagContent = (answerCache, tagName, cachedMessage) => {
// 检查是否包含完整的开始标签
const startTag = `<${tagName}>`
const endTag = `</${tagName}>`
// 如果正在处理该标签
if (cachedMessage.respondingType === tagName) {
// 检查是否包含结束标签
if (answerCache.includes(endTag)) {
// 提取结束标签前的内容
const content = answerCache.split(endTag)[0]
cachedMessage[tagName] += content
cachedMessage.respondingType = ''
cachedMessage[`${tagName}Finished`] = true
return answerCache.split(endTag)[1] || ''
} else {
// 继续累积内容
cachedMessage[tagName] += answerCache
return ''
}
}
// 检查是否包含开始标签
if (answerCache.includes(startTag)) {
cachedMessage.respondingType = tagName
const tagContent = answerCache.split(startTag)[1] || ''
// 检查开始标签后是否立即包含结束标签
if (tagContent.includes(endTag)) {
const content = tagContent.split(endTag)[0]
cachedMessage[tagName] = content
cachedMessage.respondingType = ''
return answerCache.split(endTag)[1] || ''
} else {
// 只有开始标签,累积内容
cachedMessage[tagName] = tagContent
return ''
}
}
return answerCache
}

View File

@ -19,7 +19,7 @@
<!-- 用户消息 -->
<div class="message">
<div class="message-wrapper user">
<el-avatar :src="userAvatar" :size="40" class="avatar" />
<el-avatar :src="userAvatar" :size="40" class="avatar" @error="handleAvatarError" />
<div class="message-content">
<div class="message-text-wrapper">
<div v-if="message.createdAt" class="message-timestamp">
@ -53,7 +53,7 @@
<!-- 回答 -->
<div class="message">
<div class="message-wrapper assistant">
<el-avatar :src="customerAvatar" :size="40" class="avatar" />
<el-avatar :src="customerAvatar" :size="40" class="avatar" @error="handleAvatarError" />
<div class="message-content">
<div class="message-text-wrapper">
<div v-if="message.createdAt" class="message-timestamp">
@ -155,13 +155,20 @@ const lastMessageWithDafen = computed(() => {
//
const customerAvatar = computed(() => {
return chatStore.chatModes[chatStore.currentMode]?.customerAvatar
const avatar = chatStore.chatModes[chatStore.currentMode]?.customerAvatar
return avatar || '/logo.svg'
})
const userAvatar = computed(() => {
return chatStore.chatModes[chatStore.currentMode]?.userAvatar
const avatar = chatStore.chatModes[chatStore.currentMode]?.userAvatar
return avatar || '/logo.svg'
})
//
const handleAvatarError = (event) => {
event.target.src = '/logo.svg'
}
// marked
marked.setOptions({
breaks: true,
@ -293,13 +300,6 @@ const formatMarkdown = (text, tagType) => {
return marked.parse(text)
}
//
const adjustTextareaHeight = () => {
const textarea = messageInputRef.value
textarea.style.height = 'auto'
const newHeight = Math.min(textarea.scrollHeight, maxRows * 24)
textarea.style.height = newHeight + 'px'
}
//
const toggleSummary = () => {

View File

@ -23,19 +23,17 @@
</el-menu-item>
<!-- 加载状态 -->
<div v-if="chatStore.chatModesLoading" class="loading-container">
<!-- <div v-if="chatStore.chatModesLoading" class="loading-container">
<el-skeleton :rows="3" animated />
</div>
</div> -->
<!-- 正常状态 -->
<template v-else>
<el-menu-item v-for="mode in chatModes" :key="mode.id" :index="mode.id" class="mode-item">
<el-icon>
<component :is="mode.icon" />
</el-icon>
<span class="mode-name">{{ mode.name }}</span>
</el-menu-item>
</template>
</el-menu>
<ChatHistoryList />
<!-- Settings Button Popover -->
@ -64,7 +62,7 @@ import { useChatStore } from '@/store/chat'
import { useSettingsStore } from '@/store/settings'
import ChatHistoryList from '@/views/chat/components/ChatHistoryList.vue'
import { ChatDotRound, ChatLineRound, ChatRound, Plus, Service, Setting } from '@element-plus/icons-vue'
import { ElIcon, ElMenu, ElMenuItem, ElPopover, ElSkeleton } from 'element-plus'
import { ElIcon, ElMenu, ElMenuItem, ElPopover } from 'element-plus'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const chatStore = useChatStore()
@ -80,14 +78,15 @@ const loadChatModes = async () => {
}
const chatModes = computed(() => {
console.log(chatStore, 'chatModes')
return Object.keys(chatStore.chatModes).map(key => ({
const modes = Object.keys(chatStore.chatModes).map(key => ({
id: key,
name: chatStore.chatModes[key].name,
icon: key === 'general' ? ChatRound :
key === 'assistant' ? Service :
key === 'chat' ? ChatDotRound : ChatLineRound
}))
console.log('%c chatModes:', 'color: #2196F3; font-weight: bold', modes)
return modes
})
@ -115,7 +114,7 @@ onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
//
loadChatModes()
// loadChatModes()
})
onUnmounted(() => {

View File

@ -29,7 +29,7 @@ export default defineConfig(({ command, mode }) => {
viteMockServe({
mockPath: 'src/mocks',
localEnabled: true,
prodEnabled: false,
prodEnabled: true,
supportTs: true,
watchFiles: true,
logger: true,