refactor: 不知道改了什么

This commit is contained in:
Lexcubia 2025-04-29 16:34:08 +08:00
parent 8d75703422
commit 5a8c819c35
11 changed files with 209 additions and 235 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 规范

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()
@ -66,11 +60,11 @@ onMounted(async () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 0; padding: 8px 0;
.settings-label { .settings-label {
font-size: 14px; font-size: 14px;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
} }
} }
} }
</style> </style>

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,4 +1,38 @@
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 [
// 获取聊天模式配置 // 获取聊天模式配置
{ {

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,72 +1,16 @@
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 { speakText } from '@/utils/speak'
import { defineStore } from 'pinia'
const chatModes = { const tagList = ['kehu', 'pingfen', 'zongjie', 'huaxiang', 'dafen']
training: {
name: '寿险代理人AI陪练',
icon: '👨‍💼',
token: 'app-88ae2GN49aUyNO6qGg7tbTfX',
background: `# 客户信息
### **基础背景与性格设定**
**姓名**刘勇
**年龄**34
**职业**腾讯员工
**家庭**妻子为全职太太女儿 9 双方父母健在
**性格**冷静理性务实偏好客观数据和逻辑分析对保险持观望态度防备心较强
**近期状态**
- 体检显示中度脂肪肝担心投保问题
- 已为全家配置百万医疗险妻子有重疾险但认为保障不足 保费 50W
- 对理财型保险收益不认可但对补充重疾险有潜在需求 `,
chatBackground: '通过尚先生介绍,你和他的老同学在他的家里首次面谈。他的家中布置简洁,茶几上摆放着一套茶具。',
},
quote_objection: {
name: '报价中异议',
icon: '💬',
token: 'app-ur2Altw2LHR6niX8Q1S7Cn41',
background: `# 客户信息
### **基础背景与性格设定**
**姓名**: 王大牛
**年龄**: 40
**职业**: 私营企业主
**车牌号**: 闽C12345
**车辆信息**拥有一辆行驶4年的大众途观L购买价格约25万主要用于商务出行及家庭使用
**投保信息**在其他保司购买交强险及商业险三者险车损险还有1个月到期有过一次轻微追尾事故已通过保司处理王先生对成本控制非常敏感但也能意识到保险的重要性他希望得到保障全面价格合理的产品和优质的服务`,
chatBackground: '现在你将扮演坐席专员,与系统扮演的客户针对报价中的各类异议开展对练,着重训练处理"报价中异议"的能力。按照"保全保足"原则给出险种推荐方案。',
},
post_quote_objection: {
name: '报价后异议',
icon: '💰',
token: 'app-Yiccl0JoXs2QF2lkHxO6f822',
background: `# 客户信息
### **基础背景与性格设定**
**姓名**: 张灵女士
**年龄**: 32
**职业**: 公司行政主管
**车牌号**: 粤B56789
**车辆信息**拥有一辆行驶4年多的本田思域1.5TCVT燃动版购买价格约15万用于日常通勤及周末短途出行
**投保信息**在其他保司购买交强险及商业险三者险车损险不计免赔险车上人员责任险还有2个月到期无事故记录仅有两次违章停车记录张女士注重性价比和服务质量希望保险的保障全面价格合理服务贴心`,
chatBackground: '现在你将扮演坐席专员,与系统扮演的客户针对报价后的各类异议开展对练,着重训练处理"报价后异议"的能力。按照"保全保足"原则给出险种推荐方案。',
}
}
// 返回示例
/*
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 processTagContent = (answerCache, tagName, cachedMessage) => {
// 检查是否包含完整的开始标签 // 检查是否包含完整的开始标签
const startTag = `<${tagName}>` const startTag = `<${tagName}>`
const endTag = `</${tagName}>` const endTag = `</${tagName}>`
// 如果正在处理该标签 // 如果正在处理该标签
if (cachedMessage.respondingType === tagName) { if (cachedMessage.respondingType === tagName) {
// 检查是否包含结束标签 // 检查是否包含结束标签
@ -83,12 +27,12 @@ const processTagContent = (answerCache, tagName, cachedMessage) => {
return '' return ''
} }
} }
// 检查是否包含开始标签 // 检查是否包含开始标签
if (answerCache.includes(startTag)) { if (answerCache.includes(startTag)) {
cachedMessage.respondingType = tagName cachedMessage.respondingType = tagName
const tagContent = answerCache.split(startTag)[1] || '' const tagContent = answerCache.split(startTag)[1] || ''
// 检查开始标签后是否立即包含结束标签 // 检查开始标签后是否立即包含结束标签
if (tagContent.includes(endTag)) { if (tagContent.includes(endTag)) {
const content = tagContent.split(endTag)[0] const content = tagContent.split(endTag)[0]
@ -101,7 +45,7 @@ const processTagContent = (answerCache, tagName, cachedMessage) => {
return '' return ''
} }
} }
return answerCache return answerCache
} }
@ -111,26 +55,27 @@ export const useChatStore = defineStore('chat', {
currentMode: 'training', currentMode: 'training',
conversationId: null, conversationId: null,
messageCache: {}, messageCache: {},
chatModes: {},
chatModesLoading: false,
}), }),
getters: { getters: {
chatModes: () => chatModes, currentToken: (state) => state.chatModes[state.currentMode]?.token,
currentToken: (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
}, },
currentMessages: (state) => { currentMessages: (state) => {
// 根据conversationId和currentMode找到对应的messageCache的messageId // 根据conversationId和currentMode找到对应的messageCache的messageId
const cache = Object.values(state.messageCache).find(message => const cache = Object.values(state.messageCache).find(message =>
message.mode === state.currentMode && message.conversationId === state.conversationId message.mode === state.currentMode && message.conversationId === state.conversationId
) )
// 获取当前会话的消息 // 获取当前会话的消息
const currentConversation = state.conversationId ? state.messages[state.conversationId] : null const currentConversation = state.conversationId ? state.messages[state.conversationId] : null
const conversationMessages = currentConversation ? currentConversation.chatMessages : [] const conversationMessages = currentConversation ? currentConversation.chatMessages : []
return conversationMessages.filter(message => return conversationMessages.filter(message =>
message.mode === state.currentMode && message.mode === state.currentMode &&
(message.conversationId === state.conversationId || !state.conversationId) && (message.conversationId === state.conversationId || !state.conversationId) &&
(!cache || message.messageId !== cache.messageId) (!cache || message.messageId !== cache.messageId)
).concat(cache ? [cache] : []) ).concat(cache ? [cache] : [])
@ -150,17 +95,30 @@ 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
// 找到当前模式的最后一条会话 // 找到当前模式的最后一条会话
const lastConversation = this.conversations const lastConversation = this.conversations
.filter(conv => conv.mode === mode) .filter(conv => conv.mode === mode)
.sort((a, b) => b.createdAt - a.createdAt)[0] .sort((a, b) => b.createdAt - a.createdAt)[0]
// 设置当前会话ID // 设置当前会话ID
this.conversationId = lastConversation?.conversationId || null this.conversationId = lastConversation?.conversationId || null
console.log('%c currentConversation:', 'color: #2196F3; font-weight: bold', { console.log('%c currentConversation:', 'color: #2196F3; font-weight: bold', {
mode, mode,
lastConversation, lastConversation,
@ -183,14 +141,14 @@ export const useChatStore = defineStore('chat', {
async sendMessage(question) { async sendMessage(question) {
if (!question.trim()) return if (!question.trim()) return
// 设置会话状态为正在输入 // 设置会话状态为正在输入
if (this.conversationId && this.messages[this.conversationId]) { if (this.conversationId && this.messages[this.conversationId]) {
this.messages[this.conversationId].conversationStatus = 'typing' this.messages[this.conversationId].conversationStatus = 'typing'
} }
this.hasStartedResponse = false this.hasStartedResponse = false
const spokenSet = new Set(); // 新增:记录已播报的 messageId const spokenSet = new Set(); // 新增:记录已播报的 messageId
try { try {
const messageStream = await sendMessage({ const messageStream = await sendMessage({
@ -223,10 +181,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
@ -243,7 +203,7 @@ export const useChatStore = defineStore('chat', {
} }
} }
break break
case 'message': case 'message':
if (message.answer) { if (message.answer) {
console.log('%c message:answer:', 'color: #2196F3; font-weight: bold', message.answer) console.log('%c message:answer:', 'color: #2196F3; font-weight: bold', message.answer)
answerCache += message.answer answerCache += message.answer
@ -273,10 +233,10 @@ export const useChatStore = defineStore('chat', {
if (cachedMessage) { if (cachedMessage) {
cachedMessage.respondingType = '' cachedMessage.respondingType = ''
cachedMessage.answerBuffer = message.data.outputs.answer cachedMessage.answerBuffer = message.data.outputs.answer
this.messages[cachedMessage.conversationId].chatMessages.push(cachedMessage) this.messages[cachedMessage.conversationId].chatMessages.push(cachedMessage)
// 如果包含dafen标签将会话标记为完成并将打分内容归档到summary // 如果包含dafen标签将会话标记为完成并将打分内容归档到summary
if (cachedMessage.dafen) { if (cachedMessage.dafen) {
this.messages[cachedMessage.conversationId].conversationStatus = 'finished' this.messages[cachedMessage.conversationId].conversationStatus = 'finished'
@ -285,7 +245,7 @@ export const useChatStore = defineStore('chat', {
// 恢复会话状态为活跃 // 恢复会话状态为活跃
this.messages[cachedMessage.conversationId].conversationStatus = 'active' this.messages[cachedMessage.conversationId].conversationStatus = 'active'
} }
console.log('%c workflow_finished:cachedMessage:', 'color: #ff0000; font-weight: bold', cachedMessage) console.log('%c workflow_finished:cachedMessage:', 'color: #ff0000; font-weight: bold', cachedMessage)
} }
delete this.messageCache[message.message_id] delete this.messageCache[message.message_id]
@ -300,7 +260,7 @@ export const useChatStore = defineStore('chat', {
}, },
toggleEvaluation(messageId) { toggleEvaluation(messageId) {
console.log('%c toggleEvaluation:messageId:', 'color: #2196F3; font-weight: bold', messageId) console.log('%c toggleEvaluation:messageId:', 'color: #2196F3; font-weight: bold', messageId)
const cache = Object.values(this.messageCache).find(message => const cache = Object.values(this.messageCache).find(message =>
message.mode === this.currentMode && message.messageId === messageId message.mode === this.currentMode && message.messageId === messageId
) )
if (cache) { if (cache) {
@ -323,7 +283,7 @@ export const useChatStore = defineStore('chat', {
// if (this.conversationId && this.messages[this.conversationId]) { // if (this.conversationId && this.messages[this.conversationId]) {
// this.messages[this.conversationId].conversationStatus = 'finished' // this.messages[this.conversationId].conversationStatus = 'finished'
// } // }
// 只清除当前会话相关的消息缓存 // 只清除当前会话相关的消息缓存
if (this.conversationId) { if (this.conversationId) {
// 从messageCache中删除当前会话的消息 // 从messageCache中删除当前会话的消息
@ -333,7 +293,7 @@ export const useChatStore = defineStore('chat', {
} }
}) })
} }
// 清除当前会话ID // 清除当前会话ID
this.conversationId = null this.conversationId = null
}, },
@ -349,7 +309,7 @@ export const useChatStore = defineStore('chat', {
}) })
this.conversationId = null this.conversationId = null
} }
// 从messages中删除会话 // 从messages中删除会话
if (this.messages[conversationId]) { if (this.messages[conversationId]) {
delete this.messages[conversationId] delete this.messages[conversationId]
@ -359,7 +319,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) {
@ -30,4 +30,4 @@ export const useSpeakStore = defineStore('speak', {
storage: localStorage, storage: localStorage,
paths: ['ttsVoice', 'sendDirectly', 'autoTTS'] paths: ['ttsVoice', 'sendDirectly', 'autoTTS']
} }
}) })

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" />
<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" />
<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,15 @@ const lastMessageWithDafen = computed(() => {
return null return null
}) })
//
const customerAvatar = computed(() => {
return chatStore.chatModes[chatStore.currentMode]?.customerAvatar
})
const userAvatar = computed(() => {
return chatStore.chatModes[chatStore.currentMode]?.userAvatar
})
// marked // marked
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
@ -219,7 +229,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 +238,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 })

View File

@ -1,51 +1,51 @@
<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>
<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> <div v-if="chatStore.chatModesLoading" class="loading-container">
<span class="mode-name">{{ mode.name }}</span> <el-skeleton :rows="3" animated />
</el-menu-item> </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> </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 +59,37 @@
</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, ElSkeleton } 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(() => {
console.log(chatStore, 'chatModes')
return Object.keys(chatStore.chatModes).map(key => ({ return 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
})) }))
}) })
//
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 +114,8 @@ const checkMobile = () => {
onMounted(() => { onMounted(() => {
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
//
loadChatModes()
}) })
onUnmounted(() => { onUnmounted(() => {
@ -160,7 +142,7 @@ onUnmounted(() => {
body:not(.no-animations) & { body:not(.no-animations) & {
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
} }
&.collapsed { &.collapsed {
width: 64px; width: 64px;
} }
@ -172,21 +154,21 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
&-title { &-title {
display: flex; display: flex;
align-items: center; align-items: center;
.mode-header-icon { .mode-header-icon {
height: 32px; height: 32px;
will-change: filter; will-change: filter;
body:not(.no-animations) & { body:not(.no-animations) & {
transition: filter 300ms; transition: filter 300ms;
} }
&:hover { &:hover {
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));
} }
} }
} }
@ -224,7 +206,7 @@ onUnmounted(() => {
font-size: 18px; font-size: 18px;
width: 18px; width: 18px;
} }
&.is-active { &.is-active {
background-image: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%); background-image: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: var(--el-color-white); color: var(--el-color-white);
@ -234,21 +216,21 @@ onUnmounted(() => {
color: var(--el-color-white); color: var(--el-color-white);
} }
} }
&:not(.is-active):hover { &:not(.is-active):hover {
background-color: var(--el-fill-color-light); background-color: var(--el-fill-color-light);
color: var(--el-color-primary); color: var(--el-color-primary);
} }
.mode-name { .mode-name {
margin-left: 12px; margin-left: 12px;
font-size: 14px; font-size: 14px;
white-space: nowrap; white-space: nowrap;
opacity: 1; opacity: 1;
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;
} }
} }
} }
@ -260,7 +242,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 60px; height: 60px;
.mode-header-collapsed-logo { .mode-header-collapsed-logo {
display: flex; display: flex;
align-items: center; align-items: center;
@ -273,7 +255,7 @@ onUnmounted(() => {
vertical-align: middle; vertical-align: middle;
} }
} }
.mode-menu { .mode-menu {
padding: 8px 12px; padding: 8px 12px;
} }
@ -299,6 +281,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;
@ -358,17 +341,17 @@ onUnmounted(() => {
background-color: var(--el-overlay-color-lighter); background-color: var(--el-overlay-color-lighter);
z-index: 9; z-index: 9;
display: none; display: none;
&.visible { &.visible {
display: block; display: block;
} }
} }
.mode-selector { .mode-selector {
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
&.collapsed { &.collapsed {
transform: translateX(-100%); transform: translateX(-100%);
} }
@ -382,7 +365,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 60px; height: 60px;
.mode-header-collapsed-logo { .mode-header-collapsed-logo {
display: flex; display: flex;
align-items: center; align-items: center;
@ -390,12 +373,12 @@ 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;
} }
} }
.mode-menu { .mode-menu {
padding: 8px 12px; padding: 8px 12px;
} }
@ -421,6 +404,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;
@ -458,12 +442,12 @@ onUnmounted(() => {
padding: 0; padding: 0;
color: #f56c6c; color: #f56c6c;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
color: #f56c6c; color: #f56c6c;
background-color: rgba(245, 108, 108, 0.1); background-color: rgba(245, 108, 108, 0.1);
} }
:deep(.el-icon) { :deep(.el-icon) {
font-size: 14px; font-size: 14px;
} }
@ -480,6 +464,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 +492,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>