Compare commits

...

2 Commits

Author SHA1 Message Date
Lexcubia 5e4f45333b feat(chat): 重构聊天模块并添加新功能
- 重新定义聊天模式,增加 AI 寿险代理人、报价中异议和报价后异议等场景
- 添加自定义头像和背景信息,提升用户体验
- 优化消息处理逻辑,支持标签内容处理
- 调整界面布局和样式,增加头像加载错误处理
- 移除不必要的代码,提高代码可维护性
2025-04-29 17:41:20 +08:00
Lexcubia 5a8c819c35 refactor: 不知道改了什么 2025-04-29 16:34:08 +08:00
15 changed files with 285 additions and 277 deletions

View File

@ -21,6 +21,7 @@ alwaysApply: true
- src/ - src/
- assets/ # 静态资源文件 - assets/ # 静态资源文件
- components/ # 公共组件 - components/ # 公共组件
- layouts/ # 布局组件
- views/ # 页面视图组件 - views/ # 页面视图组件
- router/ # 路由配置 - router/ # 路由配置
- store/ # Pinia状态管理 - store/ # Pinia状态管理
@ -44,6 +45,7 @@ alwaysApply: true
- 组件内使用 `<script setup>` 语法 - 组件内使用 `<script setup>` 语法
- 遵循 Vue3 组合式 API 规范 - 遵循 Vue3 组合式 API 规范
- 使用 JavaScript 进行类型检查 - 使用 JavaScript 进行类型检查
- 保留必要的注释、console.log
3. 组件开发 3. 组件开发
- 组件必须包含 name 属性 - 组件必须包含 name 属性
@ -76,6 +78,7 @@ alwaysApply: true
- 遵循 BEM 命名规范 - 遵循 BEM 命名规范
- 全局样式统一在 styles 目录下管理 - 全局样式统一在 styles 目录下管理
- 组件样式使用 scoped - 组件样式使用 scoped
- 主题适配暗色和亮色
9. 提交规范 9. 提交规范
- 遵循 Conventional Commits 规范 - 遵循 Conventional Commits 规范

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -3,25 +3,17 @@
<h4>语音设置</h4> <h4>语音设置</h4>
<div class="settings-item"> <div class="settings-item">
<span class="settings-label">语音转文字后直接发送</span> <span class="settings-label">语音转文字后直接发送</span>
<el-switch <el-switch v-model="sendDirectly" />
v-model="sendDirectly"
/>
</div> </div>
<div class="settings-item"> <div class="settings-item">
<span class="settings-label">自动语音播报</span> <span class="settings-label">自动语音播报</span>
<el-switch <el-switch v-model="autoTTS" />
v-model="autoTTS"
/>
</div> </div>
<div class="settings-item"> <div class="settings-item">
<span class="settings-label">语音播报音色</span> <span class="settings-label">语音播报音色</span>
<el-select v-model="ttsVoice" placeholder="默认" style="width: 180px"> <el-select v-model="ttsVoice" placeholder="默认" style="width: 180px">
<el-option <el-option v-for="voice in zhVoices" :key="voice.name"
v-for="voice in zhVoices" :label="voice.name + (voice.lang ? ' (' + voice.lang + ')' : '')" :value="voice.name" />
:key="voice.name"
:label="voice.name + (voice.lang ? ' (' + voice.lang + ')' : '')"
:value="voice.name"
/>
<el-option label="默认" value="" /> <el-option label="默认" value="" />
</el-select> </el-select>
</div> </div>
@ -29,9 +21,9 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted } from 'vue'
import { useSpeakStore } from '@/store/speak' import { useSpeakStore } from '@/store/speak'
import { getTTSVoices } from '@/utils/speak' import { getTTSVoices } from '@/utils/speak'
import { computed, onMounted, ref } from 'vue'
const speakStore = useSpeakStore() const speakStore = useSpeakStore()
@ -50,9 +42,11 @@ const ttsVoice = computed({
}) })
const voices = ref([]) const voices = ref([])
const zhVoices = computed(() => const zhVoices = computed(() => {
voices.value.filter(v => v.lang && v.lang.toLowerCase().startsWith('zh')) const filteredVoices = voices.value.filter(v => v.lang && v.lang.toLowerCase().startsWith('zh'))
) console.log('%c 可选语音:', 'color: #000; font-weight: bold', ...filteredVoices)
return filteredVoices
})
onMounted(async () => { onMounted(async () => {
voices.value = await getTTSVoices() voices.value = await getTTSVoices()

View File

@ -1,23 +0,0 @@
{
"training": {
"name": "寿险代理人AI陪练",
"icon": "👨‍💼",
"token": "app-88ae2GN49aUyNO6qGg7tbTfX",
"background": "# 客户信息\n### **基础背景与性格设定** \n**姓名**:刘勇 \n**年龄**34 岁 \n**职业**:腾讯员工 \n**家庭**:妻子为全职太太,女儿 9 岁,双方父母健在 \n**性格**:冷静、理性、务实,偏好客观数据和逻辑分析,对保险持观望态度,防备心较强 \n**近期状态** \n- 体检显示中度脂肪肝,担心投保问题 \n- 已为全家配置百万医疗险,妻子有重疾险,但认为保障不足 (保费 50W\n- 对理财型保险收益不认可,但对补充重疾险有潜在需求 ",
"chatBackground": "通过尚先生介绍,你和他的老同学在他的家里首次面谈。他的家中布置简洁,茶几上摆放着一套茶具。"
},
"quote_objection": {
"name": "报价中异议",
"icon": "💬",
"token": "app-ur2Altw2LHR6niX8Q1S7Cn41",
"background": "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 王大牛\n**年龄**: 40岁\n**职业**: 私营企业主\n**车牌号**: 闽C12345\n**车辆信息**拥有一辆行驶4年的大众途观L购买价格约25万主要用于商务出行及家庭使用。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险还有1个月到期。有过一次轻微追尾事故已通过保司处理。王先生对成本控制非常敏感但也能意识到保险的重要性。他希望得到保障全面、价格合理的产品和优质的服务。",
"chatBackground": "现在你将扮演坐席专员,与系统扮演的客户针对报价中的各类异议开展对练,着重训练处理\"报价中异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
},
"post_quote_objection": {
"name": "报价后异议",
"icon": "💰",
"token": "app-Yiccl0JoXs2QF2lkHxO6f822",
"background": "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 张灵女士\n**年龄**: 32岁\n**职业**: 公司行政主管\n**车牌号**: 粤B56789\n**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险、不计免赔险、车上人员责任险还有2个月到期。无事故记录仅有两次违章停车记录。张女士注重性价比和服务质量希望保险的保障全面、价格合理、服务贴心。",
"chatBackground": "现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理\"报价后异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
}
}

View File

@ -1,8 +1,42 @@
import chatModes from '../data/chatModes.json' const chatModes = {
"training": {
"name": "寿险代理人AI陪练",
"customerAvatar": "/src/assets/custom.png",
"userAvatar": "/src/assets/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": "/src/assets/user.png",
"userAvatar": "/src/assets/custom.png",
"token": "app-eYoE51WXCaEKXvzb9ZCj6lBn",
"background": "# AI寿险代理人\n### **角色设定** \n**身份**AI寿险代理人 \n**特点**:专业、耐心、细致、富有同理心 \n**能力**\n- 精通各类寿险产品知识\n- 擅长需求分析和方案定制\n- 具备优秀的沟通和谈判技巧\n- 能够处理各类客户异议\n- 熟悉保险行业最新动态",
"chatBackground": "一位AI的保险代理人通过聊天来确认用户的需求收集用户的基本信息"
},
"quote_objection": {
"name": "报价中异议",
"customerAvatar": "/src/assets/custom.png",
"userAvatar": "/src/assets/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": "/src/assets/custom.png",
"userAvatar": "/src/assets/user.png",
"token": "app-Yiccl0JoXs2QF2lkHxO6f822",
"background": "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 张灵女士\n**年龄**: 32岁\n**职业**: 公司行政主管\n**车牌号**: 粤B56789\n**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险、不计免赔险、车上人员责任险还有2个月到期。无事故记录仅有两次违章停车记录。张女士注重性价比和服务质量希望保险的保障全面、价格合理、服务贴心。",
"chatBackground": "现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理\"报价后异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
}
}
export default [ export default [
// 获取聊天模式配置 // 获取聊天模式配置
{ {
url: '/api/chat/modes', url: 'api/chat/modes',
method: 'POST', method: 'POST',
response: () => { response: () => {
return { return {

View File

@ -31,7 +31,7 @@ const routes = [
icon: Monitor, icon: Monitor,
title: "API测试" title: "API测试"
}, },
component: () => import("@/views/chat/test/TestApi.vue"), component: () => import("@/views/test/TestApi.vue"),
}, },
]; ];

View File

@ -1,109 +1,45 @@
import { defineStore } from 'pinia' import { getChatModes, sendMessage } from '@/api/chat'
import { sendMessage } from '@/api/chat'
import { useSettingsStore } from '@/store/settings'
import { speakText } from '@/utils/speak'
import { useSpeakStore } from '@/store/speak' import { useSpeakStore } from '@/store/speak'
import { processTagContent } from '@/utils'
import { speakText } from '@/utils/speak'
import { defineStore } from 'pinia'
const chatModes = { const chatModes = {
training: { training: {
name: '寿险代理人AI陪练', name: "寿险代理人AI陪练",
icon: '👨‍💼', customerAvatar: "/images/avatars/custom.png",
token: 'app-88ae2GN49aUyNO6qGg7tbTfX', userAvatar: "/images/avatars/user.png",
background: `# 客户信息 token: "app-88ae2GN49aUyNO6qGg7tbTfX",
### **基础背景与性格设定** background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**:刘勇 \n**年龄**34 岁 \n**职业**:腾讯员工 \n**家庭**:妻子为全职太太,女儿 9 岁,双方父母健在 \n**性格**:冷静、理性、务实,偏好客观数据和逻辑分析,对保险持观望态度,防备心较强 \n**近期状态** \n- 体检显示中度脂肪肝,担心投保问题 \n- 已为全家配置百万医疗险,妻子有重疾险,但认为保障不足 (保费 50W\n- 对理财型保险收益不认可,但对补充重疾险有潜在需求 ",
**姓名**刘勇 chatBackground: "通过尚先生介绍,你和他的老同学在他的家里首次面谈。他的家中布置简洁,茶几上摆放着一套茶具。"
**年龄**34 },
**职业**腾讯员工 ai_agent: {
**家庭**妻子为全职太太女儿 9 双方父母健在 name: "AI寿险代理人",
**性格**冷静理性务实偏好客观数据和逻辑分析对保险持观望态度防备心较强 customerAvatar: "/images/avatars/user.png",
**近期状态** userAvatar: "/images/avatars/custom.png",
- 体检显示中度脂肪肝担心投保问题 token: "app-eYoE51WXCaEKXvzb9ZCj6lBn",
- 已为全家配置百万医疗险妻子有重疾险但认为保障不足 保费 50W background: "# AI寿险代理人\n### **角色设定** \n**身份**AI寿险代理人 \n**特点**:专业、耐心、细致、富有同理心 \n**能力**\n- 精通各类寿险产品知识\n- 擅长需求分析和方案定制\n- 具备优秀的沟通和谈判技巧\n- 能够处理各类客户异议\n- 熟悉保险行业最新动态",
- 对理财型保险收益不认可但对补充重疾险有潜在需求 `, chatBackground: "一位AI的保险代理人通过聊天来确认用户的需求收集用户的基本信息"
chatBackground: '通过尚先生介绍,你和他的老同学在他的家里首次面谈。他的家中布置简洁,茶几上摆放着一套茶具。',
}, },
quote_objection: { quote_objection: {
name: '报价中异议', name: "报价中异议",
icon: '💬', customerAvatar: "/images/avatars/custom.png",
token: 'app-ur2Altw2LHR6niX8Q1S7Cn41', userAvatar: "/images/avatars/user.png",
background: `# 客户信息 token: "app-ur2Altw2LHR6niX8Q1S7Cn41",
### **基础背景与性格设定** background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 王大牛\n**年龄**: 40岁\n**职业**: 私营企业主\n**车牌号**: 闽C12345\n**车辆信息**拥有一辆行驶4年的大众途观L购买价格约25万主要用于商务出行及家庭使用。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险还有1个月到期。有过一次轻微追尾事故已通过保司处理。王先生对成本控制非常敏感但也能意识到保险的重要性。他希望得到保障全面、价格合理的产品和优质的服务。",
**姓名**: 王大牛 chatBackground: "现在你将扮演坐席专员,与系统扮演的客户针对报价中的各类异议开展对练,着重训练处理\"报价中异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
**年龄**: 40
**职业**: 私营企业主
**车牌号**: 闽C12345
**车辆信息**拥有一辆行驶4年的大众途观L购买价格约25万主要用于商务出行及家庭使用
**投保信息**在其他保司购买交强险及商业险三者险车损险还有1个月到期有过一次轻微追尾事故已通过保司处理王先生对成本控制非常敏感但也能意识到保险的重要性他希望得到保障全面价格合理的产品和优质的服务`,
chatBackground: '现在你将扮演坐席专员,与系统扮演的客户针对报价中的各类异议开展对练,着重训练处理"报价中异议"的能力。按照"保全保足"原则给出险种推荐方案。',
}, },
post_quote_objection: { post_quote_objection: {
name: '报价后异议', name: "报价后异议",
icon: '💰', customerAvatar: "/images/avatars/custom.png",
token: 'app-Yiccl0JoXs2QF2lkHxO6f822', userAvatar: "/images/avatars/user.png",
background: `# 客户信息 token: "app-Yiccl0JoXs2QF2lkHxO6f822",
### **基础背景与性格设定** background: "# 客户信息\n### **基础背景与性格设定** \n**姓名**: 张灵女士\n**年龄**: 32岁\n**职业**: 公司行政主管\n**车牌号**: 粤B56789\n**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行。\n**投保信息**在其他保司购买交强险及商业险三者险、车损险、不计免赔险、车上人员责任险还有2个月到期。无事故记录仅有两次违章停车记录。张女士注重性价比和服务质量希望保险的保障全面、价格合理、服务贴心。",
**姓名**: 张灵女士 chatBackground: "现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理\"报价后异议\"的能力。按照\"保全保足\"原则给出险种推荐方案。"
**年龄**: 32
**职业**: 公司行政主管
**车牌号**: 粤B56789
**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行
**投保信息**在其他保司购买交强险及商业险三者险车损险不计免赔险车上人员责任险还有2个月到期无事故记录仅有两次违章停车记录张女士注重性价比和服务质量希望保险的保障全面价格合理服务贴心`,
chatBackground: '现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理"报价后异议"的能力。按照"保全保足"原则给出险种推荐方案。',
} }
} }
// 返回示例 const tagList = ['kehu', 'pingfen', 'zongjie', 'huaxiang', 'dafen']
/*
data: {"event": "message", "conversation_id": "fac9e3e7-2b2e-4fc2-bf19-0a6d4cfcf529", "message_id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "created_at": 1744881805, "task_id": "ec521a05-6068-427a-ac5d-d349f0d05874", "id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "answer": "\u7684\u65b9\u5411", "from_variable_selector": ["17441814078920", "text"]}
data: {"event": "message", "conversation_id": "fac9e3e7-2b2e-4fc2-bf19-0a6d4cfcf529", "message_id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "created_at": 1744881805, "task_id": "ec521a05-6068-427a-ac5d-d349f0d05874", "id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "answer": "\u3002", "from_variable_selector": ["17441814078920", "text"]}
data: {"event": "message_end", "conversation_id": "fac9e3e7-2b2e-4fc2-bf19-0a6d4cfcf529", "message_id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "created_at": 1744881805, "task_id": "ec521a05-6068-427a-ac5d-d349f0d05874", "id": "e8f42230-205c-4f31-b068-86b37a18c2ee", "metadata": {"usage": {"prompt_tokens": 2483, "prompt_unit_price": "4.13", "prompt_price_unit": "0.000001", "prompt_price": "0.0102548", "completion_tokens": 142, "completion_unit_price": "4.13", "completion_price_unit": "0.000001", "completion_price": "0.0005865", "total_tokens": 2625, "total_price": "0.0108413", "currency": "RMB", "latency": 13.045417829882354}}, "files": []}
*/
const tagList = ['kehu', 'pingfen', 'zongjie', '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', { export const useChatStore = defineStore('chat', {
state: () => ({ state: () => ({
@ -111,11 +47,12 @@ export const useChatStore = defineStore('chat', {
currentMode: 'training', currentMode: 'training',
conversationId: null, conversationId: null,
messageCache: {}, messageCache: {},
chatModesLoading: false,
}), }),
getters: { getters: {
chatModes: () => chatModes, chatModes: () => chatModes,
currentToken: (state) => chatModes[state.currentMode].token, currentToken: (state) => state.chatModes[state.currentMode]?.token,
currentConversation: (state) => { currentConversation: (state) => {
return state.conversationId ? state.messages[state.conversationId] : null return state.conversationId ? state.messages[state.conversationId] : null
}, },
@ -150,6 +87,19 @@ export const useChatStore = defineStore('chat', {
}, },
actions: { actions: {
async fetchChatModes() {
if (this.chatModesLoading) return
this.chatModesLoading = true
try {
const response = await getChatModes()
this.chatModes = response.data
} catch (error) {
console.error('获取聊天模式失败:', error)
} finally {
this.chatModesLoading = false
}
},
setCurrentMode(mode) { setCurrentMode(mode) {
this.currentMode = mode this.currentMode = mode
@ -223,10 +173,12 @@ export const useChatStore = defineStore('chat', {
kehu: '', kehu: '',
pingfen: '', pingfen: '',
zongjie: '', zongjie: '',
huaxiang: '',
dafen: '', dafen: '',
kehuFinished: false, kehuFinished: false,
pingfenFinished: false, pingfenFinished: false,
zongjieFinished: false, zongjieFinished: false,
huaxiangFinished: false,
dafenFinished: false, dafenFinished: false,
} }
this.conversationId = message.conversation_id this.conversationId = message.conversation_id
@ -359,7 +311,7 @@ export const useChatStore = defineStore('chat', {
persist: { persist: {
key: 'chatStore', key: 'chatStore',
storage: sessionStorage, storage: localStorage,
paths: ['messages', 'currentMode', 'conversationId'] paths: ['messages', 'currentMode', 'conversationId', 'chatModes']
} }
}) })

View File

@ -1,5 +1,4 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import Speech from 'speak-tts'
export const useSpeakStore = defineStore('speak', { export const useSpeakStore = defineStore('speak', {
state: () => ({ state: () => ({
@ -16,6 +15,7 @@ export const useSpeakStore = defineStore('speak', {
this.ttsStatus = status this.ttsStatus = status
}, },
setTTSVoice(voice) { setTTSVoice(voice) {
console.log('%c 当前语音:', 'color: green; font-weight: bold', voice)
this.ttsVoice = voice this.ttsVoice = voice
}, },
setSendDirectly(val) { setSendDirectly(val) {

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">
<div class="message-wrapper user"> <div class="message-wrapper user">
<el-avatar :src="userAvatarUrl" :size="40" class="avatar" /> <el-avatar :src="userAvatar" :size="40" class="avatar" @error="handleAvatarError" />
<div class="message-content"> <div class="message-content">
<div class="message-text-wrapper"> <div class="message-text-wrapper">
<div v-if="message.createdAt" class="message-timestamp"> <div v-if="message.createdAt" class="message-timestamp">
@ -33,13 +33,16 @@
@toggle="chatStore.toggleEvaluation(message.messageId)" /> @toggle="chatStore.toggleEvaluation(message.messageId)" />
</div> </div>
<!-- 评价总结内容 --> <!-- 评价总结内容 -->
<div v-if="message.showEvaluation && (message.pingfen || message.zongjie)" class="evaluation-section"> <div v-if="message.showEvaluation && (message.pingfen || message.zongjie || message.huaxiang)"
class="evaluation-section">
<el-button class="evaluation-close-btn" type="info" size="small" circle :icon="Close" <el-button class="evaluation-close-btn" type="info" size="small" circle :icon="Close"
@click="chatStore.toggleEvaluation(message.messageId)" /> @click="chatStore.toggleEvaluation(message.messageId)" />
<div class="evaluation-content"> <div class="evaluation-content">
<div class="evaluation-text pingfen" v-html="formatMarkdown(message.pingfen, 'pingfen')"></div> <div class="evaluation-text pingfen" v-html="formatMarkdown(message.pingfen, 'pingfen')"></div>
<div class="evaluation-text zongjie" v-html="formatMarkdown(message.zongjie, 'zongjie')"></div> <div class="evaluation-text zongjie" v-html="formatMarkdown(message.zongjie, 'zongjie')"></div>
<div v-if="['pingfen', 'zongjie'].includes(message?.respondingType)" class="typing-indicator"> <div class="evaluation-text huaxiang" v-html="formatMarkdown(message.huaxiang, 'huaxiang')"></div>
<div v-if="['pingfen', 'zongjie', 'huaxiang'].includes(message?.respondingType)"
class="typing-indicator">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
</div> </div>
@ -50,7 +53,7 @@
<!-- 回答 --> <!-- 回答 -->
<div class="message"> <div class="message">
<div class="message-wrapper assistant"> <div class="message-wrapper assistant">
<el-avatar :src="customAvatarUrl" :size="40" class="avatar" /> <el-avatar :src="customerAvatar" :size="40" class="avatar" @error="handleAvatarError" />
<div class="message-content"> <div class="message-content">
<div class="message-text-wrapper"> <div class="message-text-wrapper">
<div v-if="message.createdAt" class="message-timestamp"> <div v-if="message.createdAt" class="message-timestamp">
@ -97,8 +100,6 @@
</template> </template>
<script setup> <script setup>
import customAvatarUrl from '@/assets/custom.png'
import userAvatarUrl from '@/assets/user.png'
import { useChatStore } from '@/store/chat' import { useChatStore } from '@/store/chat'
import ChatHeader from '@/views/chat/components/ChatHeader.vue' import ChatHeader from '@/views/chat/components/ChatHeader.vue'
import ChatInput from '@/views/chat/components/ChatInput.vue' import ChatInput from '@/views/chat/components/ChatInput.vue'
@ -152,6 +153,22 @@ const lastMessageWithDafen = computed(() => {
return null return null
}) })
//
const customerAvatar = computed(() => {
const avatar = chatStore.chatModes[chatStore.currentMode]?.customerAvatar
return avatar || '/logo.svg'
})
const userAvatar = computed(() => {
const avatar = chatStore.chatModes[chatStore.currentMode]?.userAvatar
return avatar || '/logo.svg'
})
//
const handleAvatarError = (event) => {
event.target.src = '/logo.svg'
}
// marked // marked
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
@ -219,7 +236,7 @@ watch(() => currentMessages.value, (newMessages, oldMessages) => {
// //
const lastMessage = newMessages[newMessages.length - 1] const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && ['kehu', 'pingfen', 'zongjie'].includes(lastMessage.respondingType)) { if (lastMessage && ['kehu', 'pingfen', 'zongjie', 'huaxiang'].includes(lastMessage.respondingType)) {
scrollToBottom(true) // scrollToBottom(true) //
} }
}, { deep: true }) }, { deep: true })
@ -228,7 +245,7 @@ watch(() => currentMessages.value, (newMessages, oldMessages) => {
watch(() => currentMessages.value?.map(msg => msg.respondingType), (newTypes, oldTypes) => { watch(() => currentMessages.value?.map(msg => msg.respondingType), (newTypes, oldTypes) => {
if (!newTypes || !oldTypes) return if (!newTypes || !oldTypes) return
const lastType = newTypes[newTypes.length - 1] const lastType = newTypes[newTypes.length - 1]
if (['kehu', 'pingfen', 'zongjie'].includes(lastType)) { if (['kehu', 'pingfen', 'zongjie', 'huaxiang'].includes(lastType)) {
scrollToBottom(true) // scrollToBottom(true) //
} }
}, { deep: true }) }, { deep: true })
@ -283,13 +300,6 @@ const formatMarkdown = (text, tagType) => {
return marked.parse(text) 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 = () => { const toggleSummary = () => {

View File

@ -1,51 +1,49 @@
<template> <template>
<div class="mode-selector-container"> <div class="mode-selector-container">
<div <div class="mode-selector-backdrop" :class="{ visible: !settingsStore.sidebarCollapsed }"
class="mode-selector-backdrop" @click="settingsStore.toggleSidebar" v-if="isMobile"></div>
:class="{ visible: !settingsStore.sidebarCollapsed }"
@click="settingsStore.toggleSidebar"
v-if="isMobile"
></div>
<div class="mode-selector" :class="{ 'collapsed': settingsStore.sidebarCollapsed }"> <div class="mode-selector" :class="{ 'collapsed': settingsStore.sidebarCollapsed }">
<div class="mode-header"> <div class="mode-header">
<!-- Expanded Logo --> <!-- Expanded Logo -->
<div class="mode-header-title" v-if="!settingsStore.sidebarCollapsed"> <div class="mode-header-title" v-if="!settingsStore.sidebarCollapsed">
<img src="/lgogo.svg" alt="Logo" class="mode-header-icon" /> <img src="/lgogo.svg" alt="Logo" class="mode-header-icon" />
</div> </div>
<!-- Collapsed Logo --> <!-- Collapsed Logo -->
<div class="mode-header-collapsed-logo" v-if="settingsStore.sidebarCollapsed"> <div class="mode-header-collapsed-logo" v-if="settingsStore.sidebarCollapsed">
<img src="/logo.svg" alt="Logo" class="mode-header-icon collapsed" /> <img src="/logo.svg" alt="Logo" class="mode-header-icon collapsed" />
</div> </div>
</div> </div>
<el-menu <el-menu :default-active="chatStore.currentMode" class="mode-menu" :collapse="settingsStore.sidebarCollapsed"
:default-active="chatStore.currentMode" @select="selectMode">
class="mode-menu"
:collapse="settingsStore.sidebarCollapsed"
@select="selectMode"
>
<el-menu-item class="mode-item new-chat-item" @click="handleNewChat"> <el-menu-item class="mode-item new-chat-item" @click="handleNewChat">
<el-icon><Plus /></el-icon> <el-icon>
<Plus />
</el-icon>
<span class="mode-name">新建会话</span> <span class="mode-name">新建会话</span>
</el-menu-item> </el-menu-item>
<!-- 加载状态 -->
<!-- <div v-if="chatStore.chatModesLoading" class="loading-container">
<el-skeleton :rows="3" animated />
</div> -->
<!-- 正常状态 -->
<el-menu-item v-for="mode in chatModes" :key="mode.id" :index="mode.id" class="mode-item"> <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> <el-icon>
<component :is="mode.icon" />
</el-icon>
<span class="mode-name">{{ mode.name }}</span> <span class="mode-name">{{ mode.name }}</span>
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
<ChatHistoryList /> <ChatHistoryList />
<!-- Settings Button Popover --> <!-- Settings Button Popover -->
<el-popover <el-popover placement="top-end" trigger="click" popper-class="settings-popover-popper">
placement="top-end"
trigger="click"
popper-class="settings-popover-popper"
>
<template #reference> <template #reference>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="settings-button" title="设置"> <div class="settings-button" title="设置">
<el-icon><Setting /></el-icon> <el-icon>
<Setting />
</el-icon>
<span v-if="!settingsStore.sidebarCollapsed" class="button-text">设置</span> <span v-if="!settingsStore.sidebarCollapsed" class="button-text">设置</span>
</div> </div>
</div> </div>
@ -59,57 +57,38 @@
</template> </template>
<script setup> <script setup>
import SettingsPanel from '@/components/settings/SettingsPanel.vue'
import { useChatStore } from '@/store/chat' import { useChatStore } from '@/store/chat'
import { useSettingsStore } from '@/store/settings' import { useSettingsStore } from '@/store/settings'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ChatDotRound, ChatLineRound, ChatRound, Service, Setting, Clock, Delete, Plus } from '@element-plus/icons-vue'
import { ElMenu, ElMenuItem, ElIcon, ElPopover, ElMessageBox } from 'element-plus'
import SettingsPanel from '@/components/settings/SettingsPanel.vue'
import ChatHistoryList from '@/views/chat/components/ChatHistoryList.vue' 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 } from 'element-plus'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const chatStore = useChatStore() const chatStore = useChatStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
//
const loadChatModes = async () => {
try {
await chatStore.fetchChatModes()
} catch (error) {
console.error('加载聊天模式失败:', error)
}
}
const chatModes = computed(() => { const chatModes = computed(() => {
return Object.keys(chatStore.chatModes).map(key => ({ const modes = Object.keys(chatStore.chatModes).map(key => ({
id: key, id: key,
name: chatStore.chatModes[key].name, name: chatStore.chatModes[key].name,
icon: key === 'general' ? ChatRound : icon: key === 'general' ? ChatRound :
key === 'assistant' ? Service : key === 'assistant' ? Service :
key === 'chat' ? ChatDotRound : ChatLineRound key === 'chat' ? ChatDotRound : ChatLineRound
})) }))
console.log('%c chatModes:', 'color: #2196F3; font-weight: bold', modes)
return modes
}) })
//
const currentModeHistory = computed(() => {
return chatStore.conversations.filter(chat => chat.mode === chatStore.currentMode)
})
const formatChatTitle = (chat) => {
if (chat.messages && chat.messages.length > 0) {
const firstMessage = chat.messages[0].question
return firstMessage.length > 20 ? firstMessage.substring(0, 20) + '...' : firstMessage
}
return `会话 ${chat.conversationId.substring(0, 8)}`
}
const handleDelete = async (conversationId) => {
try {
await ElMessageBox.confirm(
'确定要删除这个会话吗?删除后无法恢复。',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
chatStore.deleteConversation(conversationId)
} catch {
//
}
}
const selectMode = (id) => { const selectMode = (id) => {
// ID // ID
@ -134,6 +113,8 @@ const checkMobile = () => {
onMounted(() => { onMounted(() => {
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
//
// loadChatModes()
}) })
onUnmounted(() => { onUnmounted(() => {
@ -248,7 +229,7 @@ onUnmounted(() => {
transform: translateX(0); transform: translateX(0);
body:not(.no-animations) & { body:not(.no-animations) & {
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
} }
} }
@ -299,6 +280,7 @@ onUnmounted(() => {
.settings-button { .settings-button {
width: 40px; width: 40px;
height: 40px; height: 40px;
.mode-name, .mode-name,
.button-text { .button-text {
opacity: 0; opacity: 0;
@ -390,9 +372,9 @@ onUnmounted(() => {
} }
.mode-header-icon.collapsed { .mode-header-icon.collapsed {
height: 32px; height: 32px;
filter: drop-shadow(0 0 0.75em rgba(var(--el-color-primary-rgb), 0.6)); filter: drop-shadow(0 0 0.75em rgba(var(--el-color-primary-rgb), 0.6));
vertical-align: middle; vertical-align: middle;
} }
} }
@ -421,6 +403,7 @@ onUnmounted(() => {
.settings-button { .settings-button {
width: 40px; width: 40px;
height: 40px; height: 40px;
.mode-name, .mode-name,
.button-text { .button-text {
opacity: 0; opacity: 0;
@ -480,6 +463,19 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.loading-container {
padding: 12px;
}
.error-container {
padding: 20px;
text-align: center;
.el-button {
margin-top: 12px;
}
}
</style> </style>
<style lang="scss"> <style lang="scss">
@ -495,13 +491,13 @@ onUnmounted(() => {
<style lang="scss"> <style lang="scss">
/* Dark mode override */ /* Dark mode override */
html.dark .mode-selector { html.dark .mode-selector {
background-color: var(--el-color-primary-dark-2); background-color: var(--el-color-primary-dark-2);
border-right-color: var(--el-border-color-dark); border-right-color: var(--el-border-color-dark);
/* Dark mode hover */ /* Dark mode hover */
.mode-menu :deep(.el-menu-item):not(.is-active):hover { .mode-menu :deep(.el-menu-item):not(.is-active):hover {
background-color: var(--el-fill-color); background-color: var(--el-fill-color);
color: var(--el-color-primary); color: var(--el-color-primary);
} }
} }
</style> </style>

View File

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