feat(AI-new): 优化聊天组件并添加新功能

- 重构 chat-new 组件,优化输入框和发送按钮逻辑
- 新增单次请求管控功能,改进多次请求处理
- 优化消息渲染逻辑,处理未闭合标签问题
- 调整卡片样式,增加新图标
- 更新路由配置,修改页面标题
This commit is contained in:
huangzhe
2025-07-29 19:53:56 +08:00
parent b873e378d2
commit 05144d9afb
14 changed files with 157 additions and 114 deletions

BIN
public/family.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

BIN
public/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/note.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/security.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/thumbsup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,18 +1,22 @@
.card {
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
justify-content: flex-start;
gap: 5%;
flex-shrink: 0;
}
.card > div {
line-height: 0;
/* height: fit-content; */
border-radius: 10px;
overflow: hidden;
}
.card > div > img {
width: 80px;
height: 80px;
width: 70px;
height: auto;
}
.card > section > :nth-child(1) {
@@ -27,5 +31,22 @@
.card > section > :nth-child(2) > div {
background-color: #0065ff;
padding: 5px;
padding: 1px 4px;
border-radius: 3px;
}
/* 选择遇到的第一个 div */
.render-container > div:nth-of-type(1) > img {
width: 100%;
background-color: red;
}
.render-container > div {
display: flex;
align-items: center;
justify-content: start;
gap: 5px;
}
.render-container > div > img {
width: 20px;
}

View File

@@ -1,4 +1,5 @@
import Vue from 'vue'
import "./assets/sass/card.css"
import App from './App.vue'
import Router from './router'
import Store from './store'

View File

@@ -51,7 +51,7 @@ export default [
name: 'productRecommend',
component: () => import('@/views/AI-new/views/productRecommend/index.vue'),
meta: {
title: '产品推荐助手',
title: '产品推荐助手-保障型',
},
},
]

View File

@@ -14,7 +14,7 @@
<!-- 输入框 or 按住说话提示 -->
<div class="input-wrapper">
<input v-if="!isVoiceMode" type="text" v-model="newMessage" placeholder="你有什么想知道的?"
@keyup.enter="sendMessage" />
@keyup.enter="beforeSend" />
<div v-else class="voice-hint-container" :class="{ disabled: messageStatus === 'send' }"
@mousedown="startRecording" @selectstart="() => false" @mouseup="stopRecording" @mouseleave="stopRecording"
@touchend="stopRecording" @touchstart="startRecording">
@@ -25,11 +25,11 @@
<span class="bar"></span>
<span class="bar"></span>
</div>
<!-- <div class="hint-text" v-if='!isRecording'>按住说话</div>-->
<!--<div class="hint-text" v-if='!isRecording'>按住说话</div>-->
</div>
<!-- 发送按钮 -->
<button @click="sendMessage" :disabled="messageStatus === 'send'"
:class="{ disabled: messageStatus === 'send' }" class="active send">
<button @click="beforeSend" :disabled="messageStatus === 'send'"
:class="{ disabled: messageStatus === 'send' }" class="ml10 active send">
发送
</button>
</div>
@@ -39,24 +39,13 @@
<span v-else class="ml15 mr5 input-icon"></span> -->
</button>
</footer>
<!-- <section class="section pb10" > -->
<!-- <button @click="deepInternet" :class="{ active: isDeep }">
<svg-icon icon-class="think" class-name="chat-icon"></svg-icon>
深度思考
</button> -->
<!-- 发送按钮 -->
<!-- <button @click="sendMessage" :disabled="messageStatus === 'send'"
:class="{ disabled: messageStatus === 'send' }" class="mr10 active send fs16">
发送
</button> -->
<!-- </section> -->
</div>
</div>
</template>
<script>
import SvgIcon from '@/components/svg-icon/index.vue'
import { audioToText, gwcsChat, chatProduct } from '@/api/generatedApi'
import { audioToText, gwcsChat } from '@/api/generatedApi'
export default {
components: {
@@ -95,10 +84,16 @@ export default {
type: Object,
default: () => ({}),
},
// action: {
// type: String,
// default: 'normal_chat',
// }
},
data() {
return {
requestSingle: undefined,
// 管控单次请求,当 abort 之后, while 后面的会进行重复请求
single: false,
isThink: false,
newMessage: '',
isRecording: false,
@@ -108,7 +103,10 @@ export default {
isVoiceMode: false,
answerMap: '',
currentMessage: null,
messageInfo: {
is_complete: false.toString(),
information: '',
},
// 打字机相关
typingText: '',
typingQueue: [],
@@ -118,6 +116,14 @@ export default {
typingTimeout: null,
}
},
watch: {
isTyping(value) {
// 通过 typing 控制发送的状态
// console.log(`typing status change `, value)
if (!value) this.$emit('update:messageStatus', 'stop')
else this.$emit('update:messageStatus', 'send')
}
},
methods: {
deepInternet() {
this.$emit('update:isDeep', !this.isDeep)
@@ -152,19 +158,19 @@ export default {
console.error(err)
}
},
hasTreasureBox() {
chatProduct({ query: this.newMessage })
.then((res) => {
if (res) {
this.messageStatus = 'stop'
this.messages.push({ type: 'box', text: this.newMessage, detail: res.content })
this.newMessage = ''
}
})
.catch(() => {
this.messageStatus = 'stop'
})
},
// hasTreasureBox() {
// chatProduct({ query: this.newMessage })
// .then((res) => {
// if (res) {
// this.messageStatus = 'stop'
// this.messages.push({ type: 'box', text: this.newMessage, detail: res.content })
// this.newMessage = ''
// }
// })
// .catch(() => {
// this.messageStatus = 'stop'
// })
// },
stopRecording() {
if (this.mediaRecorder && this.isRecording) {
this.mediaRecorder.stop()
@@ -207,21 +213,22 @@ export default {
axiosGetAiChat() {
const abortController = new AbortController()
this.requestSingle = abortController
let message = {
is_complete: false.toString(),
information: this.newMessage,
}
this.messageInfo.information = this.single ? this.messageInfo.information : this.newMessage
this.currentMessage = JSON.parse(JSON.stringify(message))
// 重置 answerMap
this.answerMap = ''
this.currentMessage = JSON.parse(JSON.stringify(this.messageInfo))
let params = {
appType: "gwcsHelper",
conversationId: "",
message: JSON.stringify(message),
message: JSON.stringify(this.messageInfo),
user: "gwcs-test",
inputs: {}
inputs: {},
// action: this.action
}
this.currentMessage = {
...message,
...this.messageInfo,
type: 'bot',
text: '',
think: '',
@@ -230,28 +237,37 @@ export default {
isLike: false,
isDisLike: false,
}
this.messages.push(this.currentMessage)
if (this.single) {
// this.messages[this.messages.length - 1]
this.messages.splice(this.messages.length - 1, 1, this.currentMessage)
} else {
this.messages.push(this.currentMessage)
}
// 如果有自定义参数
if (this.chatData) {
for (let k in this.chatData) {
params[k] = this.chatData[k]
}
}
if (this.$route.query.compareId) {
params.compareResult = JSON.parse(sessionStorage.getItem('results'))
}
// if (this.$route.query.compareId) {
// params.compareResult = JSON.parse(sessionStorage.getItem('results'))
// }
this.newMessage = ''
fetch(gwcsChat(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify(params),
timeout: 60000,
})
.then(async (res) => {
this.newMessage = ''
await this.processStreamResponse(res)
this.single = false
})
.catch((err) => {
// debugger
this.$emit('update:messageStatus', 'stop')
})
},
@@ -264,6 +280,8 @@ export default {
const reader = response.body.getReader()
let buffer = ''
while (true) {
// if (this.single) break
try {
const { done, value } = await reader.read()
if (done) break
@@ -286,12 +304,30 @@ export default {
if (!cleanLine) return null
const data = JSON.parse(cleanLine)
// console.log(data)
// console.log(data)
if (data.answer) {
this.answerMap += data.answer
const match = /<is_complete>([^<]*)(?:<\/is_complete>)?/.exec(data.answer)
if (match && match[1] === 'true') {
const is_complete = /<is_complete>([^<]*)(?:<\/is_complete>)?/.exec(this.answerMap)
const information = /<information>([^<]*)(?:<\/information>)?/.exec(this.answerMap)
const text = /<text>([^<]*)(?:<\/text>)?/.exec(this.answerMap)
// console.log(`is_complete, information, text`, is_complete, information, text)
// const isCompleteRes = is_complete.some(item => item === 'true')
this.messageInfo.information = information ? information[1].trim() : this.newMessage
this.messageInfo.is_complete = is_complete ? is_complete[1].trim() : 'false'
if (is_complete && is_complete[1] === 'true' && text && text[1].trim() === '') {
// alert("message end")
this.requestSingle.abort()
// setTimeout(() => {
// debugger
// this.$emit('update:messageStatus', 'send')
this.single = true
// debugger
this.axiosGetAiChat()
// }, 1000)
}
}
@@ -324,9 +360,10 @@ export default {
},
updateMessageContent(parse) {
let { event, answer, isThink } = parse
if (event === 'message_end') {
this.$emit('update:messageStatus', 'stop')
}
// 会导致发送按钮提前高亮展示
// if (event === 'message_end') {
// this.$emit('update:messageStatus', 'stop')
// }
if (!this.currentMessage || !answer) return
if (event !== 'message') return
// console.log(parse);
@@ -342,7 +379,6 @@ export default {
},
startTypingAnimation() {
this.isTyping = true
const typeNextChar = () => {
if (this.typingQueue.length === 0) {
this.isTyping = false
@@ -353,8 +389,7 @@ export default {
const chunk = this.typingQueue.shift()
// console.log(this.messages);
const chars = /* chunk.answer.split('') */ Array.from(chunk.answer)
// console.log(chars);
const chars = Array.from(chunk.answer)
const isThink = chunk.isThink
// 内部递归函数,用于逐字输出当前块
@@ -365,6 +400,7 @@ export default {
return
}
const char = chars.shift() || ''
// if (this.single)
this.$set(this.currentMessage, isThink ? 'think' : 'text', this.currentMessage[isThink ? 'think' : 'text'] + char)
const delay = this.getTypingDelay(char)
setTimeout(outputChar, delay)
@@ -381,26 +417,23 @@ export default {
}
return this.typingSpeed
},
sendMessage() {
beforeSend() {
if (this.messageStatus === 'send') return
if (this.newMessage.trim() === '') return
this.newMessage = this.newMessage.replace(/<[^>]+>/g, '')
this.messages.push({ type: 'user', text: this.newMessage })
this.$emit('update:messageStatus', 'send')
if (this.newMessage.includes('工具箱')) {
this.hasTreasureBox()
return
}
this.$emit('update:autoScrollEnabled', true)
this.axiosGetAiChat()
this.sendMessage()
},
cellClick(item) {
this.newMessage = item.title
this.messages.push({ type: 'user', text: this.newMessage })
this.sendMessage()
},
sendMessage() {
this.$emit('update:messageStatus', 'send')
this.$emit('update:autoScrollEnabled', true)
this.axiosGetAiChat()
}
},
}
}
</script>
@@ -517,7 +550,7 @@ $primary-trans-color: rgba(135, 162, 208, 0.5);
}
input {
width: 110%;
width: 100%;
padding: 10px;
border: none;
background: #fff;

View File

@@ -21,7 +21,7 @@
<p v-html="md.render(message.think)" v-if="message.think && message.showThink" class="thinkText" />
</span>
<div style="width: 100%">
<p v-html="render(message)"></p>
<p v-html="render(message)" class='render-container'></p>
<span class="speakLoadingToast pv10" v-if="!filterVisible(message)">
<van-loading type="spinner" :color="primaryColor" size="20px" />
</span>
@@ -79,25 +79,33 @@ export default {
},
methods: {
render(message) {
// this.filterVisible(message);
// console.log(md.render(message.text));
return md.render(this.filterVisible(message))
},
setProductName(e) {
this.$emit('setProductName', e)
},
filterVisible(message) {
// 捕获 不包含 < 的后置标签 ( .*>)
let _text = message.text.replace(/(?<![/|<])^\w*>/g, '')
// 把 未闭合的标签替换成空白
_text = _text.replace(/<\/\w*$/g, '')
return _text
if (!message.text.startsWith('<')) {
return message.text
}
// 只把 text 标签内容渲染
const match = /<text>([^<]*)(?:<\/text>)?/.exec(message.text)
return match ? match[1] : ''
let match = /<text>([^<]*)(?:<\/text>)?/.exec(message.text)
let text = match ? match[1] : ''
// 捕获 不包含 < 的后置标签 ( .*>)
text = text.replace(/(?<![/|<])^\w*>/g, '')
// 把 未闭合的标签替换成空白
text = text.replace(/<\/\w*$/g, '')
console.log(text);
return text
},
showThink(message) {
this.$set(message, 'showThink', !message.showThink)
console.log(message.showThink)
},
// 处理点赞和踩的逻辑
@@ -113,6 +121,9 @@ export default {
this.$emit('update-message', { ...message })
},
},
mounted() {
window.md = md
}
}
</script>

View File

@@ -31,7 +31,7 @@
<chatMessage ref="chatMessage" :messages.sync="messages" :messageStatus.sync="messageStatus" :is-deep.sync="isDeep"
:conversation-id.sync="conversationId" :is-searching.sync="isSearching" :product-name.sync="productName"
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink"></chatMessage>
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink" action="normal_chat"></chatMessage>
</div>
</template>

View File

@@ -21,13 +21,13 @@
<chatMessage :messages.sync="messages" :messageStatus.sync="messageStatus" :is-deep.sync="isDeep"
:conversation-id.sync="conversationId" :is-searching.sync="isSearching" :product-name.sync="productName"
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink"></chatMessage>
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink" action="chat"/>
</div>
</template>
<script>
import { Icon, NavBar } from 'vant'
import messageComponent from '@/views/AI/components/message.vue'
import messageComponent from '@/views/AI-new/components/message.vue'
import SvgIcon from '@/components/svg-icon/index.vue'
import HotProducts from '@/views/AI-new/components/HotProducts.vue'
import sticky from '@/views/AI/components/sticky.vue'
@@ -67,17 +67,7 @@ export default {
sessionStorage.removeItem('results')
this.messages.push({
type: 'bot',
text: `
这里是产品知识小助手,请告诉我您想要了解哪个产品?也可以输入以下信息,我帮您进行精准查询:
1.投保规则
2.保障责任
3.增值服务
您可以直接向我提问~
`,
text: `这里是产品知识小助手,请告诉我您想要了解哪个产品?也可以输入以下信息,我帮您进行精准查询:\n\n1.投保规则\n\n2.保障责任\n\n3.增值服务\n\n您可以直接向我提问~`,
})
} else {
// 可以调用接口展示 名字 或者存在session里
@@ -136,8 +126,7 @@ export default {
watch: {
messages: {
handler() {
console.log(this.messages, 'messages');
// console.log(this.messages, 'messages');
this.$nextTick(() => this.scrollToBottom())
},
deep: true,

View File

@@ -1,6 +1,6 @@
<template>
<div class="chat-page">
<van-nav-bar title="产品推荐助手" left-text="返回" left-arrow @click-left="$router.history.go(-1)" />
<van-nav-bar title="产品推荐助手-保障型" left-text="返回" left-arrow @click-left="$router.history.go(-1)" />
<sticky :hotList="hotList" :productName="productName" :messagesList.sync="messages"
:autoScrollEnabled.sync="autoScrollEnabled" @setProductName="setProductName"
:isDisabled="messageStatus === 'send'" :conversationId="conversationId"></sticky>
@@ -21,13 +21,14 @@
<chatMessage :messages.sync="messages" :messageStatus.sync="messageStatus" :is-deep.sync="isDeep"
:conversation-id.sync="conversationId" :is-searching.sync="isSearching" :product-name.sync="productName"
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink"></chatMessage>
:autoScrollEnabled.sync="autoScrollEnabled" @getIsThink="getIsThink" action="product_recommend">
</chatMessage>
</div>
</template>
<script>
import { Icon, NavBar } from 'vant'
import messageComponent from '@/views/AI/components/message.vue'
import messageComponent from '@/views/AI-new/components/message.vue'
import SvgIcon from '@/components/svg-icon/index.vue'
import HotProducts from '@/views/AI-new/components/HotProducts.vue'
import sticky from '@/views/AI/components/sticky.vue'
@@ -67,29 +68,16 @@ export default {
sessionStorage.removeItem('results')
this.messages.push({
type: 'bot',
text: `
这里是产品推荐小助手,请告诉我以下客户的信息, 我帮您精准推荐:
1.如果您需要推荐保障类产品,您可以告诉我以下信息:
• 客户需要哪类保障?(重疾/医疗/意外
• 客户的年龄、性别、有无既往病症
• 客户的保障场景(住院医疗/百万医疗/出行意外/综合意外/
交通意外)
2.如果您需要推荐理财储蓄类产品,您可以告诉我以下信息:
• 客户主要目标是什么?(财富升值/子女教育/资产传承/养老保障)
• 客户的年龄、性别、年收入、风险承受能力
`,
text: `这里是产品推荐小助手,请告诉我以下客户的信息, 我帮您精准推荐:\n\n1.如果您需要推荐保障类产品,您可以告诉我以下信息:
\n\n• 客户需要哪类保障?(重疾/医疗/意外)
\n\n• 客户的年龄、性别、有无既往病症
\n\n• 客户的保障场景(住院医疗/百万医疗/出行意外/综合意外/交通意外)
\n\n2.如果您需要推荐理财储蓄类产品,您可以告诉我以下信息:
\n\n• 客户主要目标是什么?(财富升值/子女教育/资产传承/养老保障
\n\n• 客户的年龄、性别、年收入、风险承受能力`,
})
} else {
// 可以调用接口展示 名字 或者存在session里
let sesstions = JSON.parse(sessionStorage.getItem('results')).productResults
console.log(sesstions)
let text = sesstions.map((item) => {

View File

@@ -50,7 +50,7 @@ export default {
[
{ title: "产品助手", icon: 'product', path: '/productAssistant' },
{ title: "产品知识助手", icon: 'product', path: '/productKnowledge' },
{ title: "产品推荐助手", icon: 'product', path: '/productRecommend' },
{ title: "产品推荐助手-保障型", icon: 'product', path: '/productRecommend' },
],
[
{ title: 'AI智能助手', icon: 'product', path: '/chatPage' },