Files
ebiz-base-ai/src/views/AI-new/components/chat-new.vue
2025-07-31 15:35:27 +08:00

680 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chat-container">
<div class="mb15">
<div v-if="isVoiceMode && newMessage" class="isVoiceModeText">
<textarea class="textarea" placeholder="请输入内容" v-model="newMessage"></textarea>
</div>
<div class="new-session ">
<button @click="startNewConversation">
<svg-icon icon-class="add" class-name="chat-icon" style="color: #000;"></svg-icon>
开启新会话
</button>
</div>
<footer class="chat-footer pt10 pb10">
<!-- 输入框 or 按住说话提示 -->
<div class="input-wrapper">
<input v-if="!isVoiceMode" type="text" v-model="newMessage" placeholder="你有什么想知道的?"
@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">
<div class="waveform" :class="{ active: isRecording }">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</div>
<!--<div class="hint-text" v-if='!isRecording'>按住说话</div>-->
</div>
<!-- 发送按钮 -->
<button @click="beforeSend" :disabled="messageStatus === 'send'"
:class="{ disabled: messageStatus === 'send' }" class="ml10 active send">
发送
</button>
<!-- <button @click="cancelSend">取消</button> -->
</div>
<!-- 语音按钮按住说话 -->
<button @click="isVoiceMode = !isVoiceMode" class="mic-button ml10 mr10" v-if="false">
<!-- <svg-icon v-if="!isVoiceMode" icon-class="voice" class-name="chat-icon ml10 wh25"></svg-icon>
<span v-else class="ml15 mr5 input-icon"></span> -->
</button>
</footer>
</div>
</div>
</template>
<script>
import SvgIcon from '@/components/svg-icon/index.vue'
import { audioToText, gwcsChat } from '@/api/generatedApi'
export default {
components: {
SvgIcon,
},
props: {
messages: {
type: Array,
default: () => [],
},
messageStatus: {
type: String,
default: 'stop',
},
isDeep: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
conversationId: {
type: String,
default: '',
},
productName: {
type: String,
default: '',
},
autoScrollEnabled: {
type: Boolean,
default: false,
},
chatData: {
type: Object,
default: () => ({}),
},
// action: {
// type: String,
// default: 'normal_chat',
// }
},
data() {
return {
requestIndex: 1,
requestSingle: undefined,
// 管控单次请求,当 abort 之后, while 后面的会进行重复请求
single: false,
isThink: false,
newMessage: '',
isRecording: false,
mediaRecorder: null,
audioChunks: [],
isRecognizing: false,
isVoiceMode: false,
answerMap: '',
currentMessage: null,
// 随后生成的消息
genMessage: null,
messageInfo: {
is_complete: false.toString(),
information: '',
},
currentMessageID: "",
// 打字机相关
typingText: '',
typingQueue: [],
typingQueueText: [],
isTyping: false,
typingSpeed: 30,
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)
},
searchInternet() {
this.$emit('update:isSearching', !this.isSearching)
},
startNewConversation() {
this.$emit('initBotMessage')
// this.$emit('update:messages', [])
this.$emit('update:conversationId', '')
this.$emit('update:productName', '')
this.$emit('update:messageStatus', 'stop')
this.typingQueue = []
},
async startRecording() {
if (this.messageStatus === 'send') return
if (this.isRecognizing) return
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
this.mediaRecorder = new MediaRecorder(stream)
this.audioChunks = []
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data)
}
}
this.mediaRecorder.onstop = () => {
this.handleStopRecording()
}
this.mediaRecorder.start()
this.isRecording = true
} catch (err) {
alert('无法访问麦克风,请检查权限')
console.error(err)
}
},
stopRecording() {
if (this.mediaRecorder && this.isRecording) {
this.mediaRecorder.stop()
this.isRecording = false
}
},
async handleStopRecording() {
this.isRecognizing = true
const blob = new Blob(this.audioChunks, { type: 'audio/webm' })
try {
const text = await this.callVoiceRecognitionAPI(blob)
if (text) {
this.newMessage = text
this.$emit('update:messageStatus', 'stop')
}
} catch (error) {
console.error('语音识别失败:', error)
this.newMessage = ''
} finally {
this.isRecognizing = false
}
},
callVoiceRecognitionAPI(blob) {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', blob)
formData.append('appType', 'haslBigHelper')
formData.append('user', 'chenyuda')
audioToText(formData)
.then((res) => {
if (res) {
resolve(res.content)
}
})
.catch((err) => {
reject(err)
})
})
},
axiosGetAiChat() {
const abortController = new AbortController()
this.requestSingle = abortController
this.messageInfo.information = this.single ? this.messageInfo.information : this.newMessage
// 重置 answerMap
this.answerMap = ''
// this.typingQueue = []
this.currentMessage = JSON.parse(JSON.stringify(this.messageInfo))
let params = {
appType: "gwcsHelper",
conversationId: this.conversationId,
message: JSON.stringify(this.messageInfo),
user: "gwcs-test",
inputs: {},
}
this.currentMessage = {
...this.messageInfo,
type: 'bot',
text: '',
think: '',
isThink: false,
showThink: false,
isLike: false,
isDisLike: false,
}
if (this.single) {
// this.$set(this.messages, this.messages.length - 1, { ...this.currentMessage })
this.$set(this.messages, this.messages.length, this.genMessage = { ...this.currentMessage })
} else {
this.messages.push(this.currentMessage)
}
// 如果有自定义参数
if (this.chatData) {
for (let k in this.chatData) {
params[k] = this.chatData[k]
}
}
this.newMessage = ''
fetch(gwcsChat(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify(params),
timeout: 60000,
})
.then(async (res) => {
await this.processStreamResponse(res, this.requestIndex)
this.single = false
})
.catch((err) => {
// debugger
this.$emit('update:messageStatus', 'stop')
})
},
async processStreamResponse(response, requestIndex) {
if (!response.ok) throw new Error(`HTTP错误: ${response.status}`)
if (!response.body) {
console.error('响应体不存在:', response)
return
}
const reader = response.body.getReader()
let buffer = ''
while (true) {
try {
const { done, value } = await reader.read()
if (done) break
buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n')
lines.slice(0, -1).forEach((line) => {
const parsed = this.parseStreamLine(line)
if (parsed) this.updateMessageContent(parsed, requestIndex)
})
buffer = lines[lines.length - 1] || ''
} catch (error) {
console.error('读取流数据时发生错误:', error)
break
}
}
},
parseStreamLine(line) {
try {
const cleanLine = line.replace(/^data:\s*/, '')
if (!cleanLine) return null
const data = JSON.parse(cleanLine)
// debugger/
// this.conversationId = data.conversation_id
this.$emit('update:conversationId', data.conversation_id)
if (data.answer) {
this.answerMap += data.answer
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(this.answerMap);
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() === '') {
this.requestSingle.abort()
this.single = true
this.axiosGetAiChat()
this.requestIndex++
// this.typingQueue = []
return null
}
}
return this.updateConversationState(data)
} catch (error) {
console.error('流数据解析失败:', error)
return null
}
},
updateConversationState(data) {
this.$emit('update:conversationId', data.conversation_id || '')
if (data.answer && data.answer.indexOf('<think>') !== -1) {
data.isThink = true
this.isThink = true
this.$emit('getIsThink', true)
}
if (data.answer && data.answer.indexOf('</think>') !== -1) {
data.isThink = true
this.isThink = false
this.$emit('getIsThink', false)
}
if (data.answer && this.isThink) {
data.isThink = true
}
if (data.answer && !this.isThink) {
data.isThink = false
}
return data
},
updateMessageContent(parse, requestIndex) {
let { event, answer, isThink, message_id } = parse
this.currentMessageID = message_id
if (!this.currentMessage || !answer) return
if (event !== 'message') return
const mode = isThink ? 'think' : 'text'
const chars = {
answer: answer,
isThink: isThink,
message_id
}
this.typingQueue.push(chars)
if (!this.isTyping) {
this.startTypingAnimation(mode, requestIndex)
}
},
startTypingAnimation(mode, requestIndex) {
this.isTyping = true
const typeNextChar = () => {
if (this.typingQueue.length === 0) {
this.isTyping = false
return
}
// 取出一个完整文本块
const chunk = this.typingQueue.shift()
if (chunk.message_id !== this.currentMessageID) {
console.log('message_id !== this.currentMessageID');
typeNextChar()
return
}
// console.log(this.messages);
const chars = Array.from(chunk.answer)
const isThink = chunk.isThink
// 内部递归函数,用于逐字输出当前块
const outputChar = () => {
if (chars.length === 0) {
// 当前块输出完毕,继续处理下一个
setTimeout(typeNextChar, 10)
return
}
const char = chars.shift() || ''
// this.$set(this.currentMessage, isThink ? 'think' : 'text', this.currentMessage[isThink ? 'think' : 'text'] + char)
if (requestIndex === 2) {
console.log('requestIndex === 2', char);
this.$set(this.genMessage, 'text', this.genMessage.text + char)
// this.messages.splice(this.messages.length - 1, 1, this.genMessage)
} if (requestIndex === 1) {
console.log('requestIndex === 1', char);
this.$set(this.currentMessage, 'text', this.currentMessage.text + char)
}
const delay = this.getTypingDelay(char)
setTimeout(outputChar, delay)
}
outputChar()
}
typeNextChar()
},
getTypingDelay(char) {
if (['。', '', '', '', '\n'].includes(char)) {
return this.typingSpeed * 3
}
return this.typingSpeed
},
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.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()
},
cancelSend() {
this.requestSingle.abort()
}
}
}
</script>
<style lang="scss" scoped>
$primary-color: #57a6fc;
$primary-text-color: #f6aa21;
$primary-trans-color: rgba(135, 162, 208, 0.5);
.chat-container {
background-color: #fafafa;
display: flex;
&>div {
margin: 0 auto;
background-color: #fff;
width: 90%;
border-radius: 10px;
}
}
.input-icon {
font-size: 20px;
color: #707070;
}
.wh25 {
width: 25px;
height: 25px;
}
.isVoiceModeText {
display: flex;
textarea {
flex: 1;
max-height: 80px;
resize: none;
outline: none;
overflow-y: auto;
padding: 10px;
border: none;
}
}
.section {
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 0 10px;
gap: 10px;
button {
transition: all 0.3s ease-in-out;
padding: 4px 8px;
border: none;
background-color: $primary-trans-color;
color: #fff;
font-weight: 600;
border-radius: 20px;
cursor: pointer;
}
.send {}
.active {
background-color: $primary-color;
color: #fff;
}
}
.new-session {
color: white;
margin-left: 10px;
margin-top: 10px;
background-color: $primary-color;
border-radius: 10px;
padding: 2px 4px;
width: fit-content;
height: fit-content;
button {
border-radius: 10px;
background-color: transparent;
border: none;
svg {
color: #090909;
}
}
}
.chat-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 5px 0 5px;
//padding-bottom: constant(safe-area-inset-bottom);
//padding-bottom: env(safe-area-inset-bottom);
// background-color: #fff;
.input-wrapper {
flex: 1;
// display: flex;
// align-items: center;
// justify-content: space-between;
display: grid;
grid-template-columns: 1fr 50px;
&>button {
// width: 50px;
color: $primary-color;
}
input {
width: 100%;
padding: 10px;
border: none;
background: #fff;
border-radius: 5px;
font-size: 14px;
}
.voice-hint-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
background-color: #f5f5f5;
border-radius: 5px;
color: #888;
font-size: 14px;
//-webkit-user-select: none;
//-moz-user-select: none;
//-ms-user-select: none;
//user-select: none;
&:active {
background-color: #eaeaea;
}
.waveform {
display: flex;
align-items: flex-end;
align-items: center;
//height: 30px;
//width: 60px;
gap: 2px;
&.disabled {
cursor: not-allowed;
}
.bar {
width: 4px;
background-color: $primary-color;
margin: 0 1px;
&:nth-child(1) {
height: 10px;
animation-delay: 0s;
}
&:nth-child(2) {
height: 16px;
animation-delay: 0.1s;
}
&:nth-child(3) {
height: 12px;
animation-delay: 0.2s;
}
&:nth-child(4) {
height: 18px;
animation-delay: 0.3s;
}
&:nth-child(5) {
height: 14px;
animation-delay: 0.4s;
}
}
}
.hint-text {
margin-top: 4px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
}
.mic-button {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
border: none;
background: transparent;
color: $primary-color;
cursor: pointer;
font-size: 12px;
margin-left: 5px;
&:active {
color: #e6454a;
/* 按下变红 */
}
}
button {
border: none;
outline: none;
color: $primary-text-color;
border-radius: 5px;
background: transparent;
font-weight: 600;
cursor: pointer;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
}
@keyframes wave-animation {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.5);
}
}
</style>