|
@@ -105,14 +105,41 @@
|
|
|
:questTip="currentQuestion.ccAiQuestTip || ''"
|
|
:questTip="currentQuestion.ccAiQuestTip || ''"
|
|
|
@select-message="handleSelectMessage"
|
|
@select-message="handleSelectMessage"
|
|
|
/>
|
|
/>
|
|
|
|
|
+ <!-- 消息输入框 -->
|
|
|
<el-input
|
|
<el-input
|
|
|
v-model="prompt"
|
|
v-model="prompt"
|
|
|
placeholder="输入问题..."
|
|
placeholder="输入问题..."
|
|
|
class="user-input"
|
|
class="user-input"
|
|
|
@keyup.enter="handleSendByKeydown"
|
|
@keyup.enter="handleSendByKeydown"
|
|
|
>
|
|
>
|
|
|
|
|
+ <!-- 语音输入 -->
|
|
|
|
|
+ <template #prepend>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ @click="toggleSpeechInput"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ :class="{ 'recording': isRecording }"
|
|
|
|
|
+ circle
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-icon v-if="!isRecording"><Microphone /></el-icon>
|
|
|
|
|
+ <el-icon v-else><Mute /></el-icon>
|
|
|
|
|
+ <!-- 显示倒计时(仅录音时显示) -->
|
|
|
|
|
+ <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 终止按钮和发送按钮条件渲染 -->
|
|
|
<template #append>
|
|
<template #append>
|
|
|
- <el-button @click="handleSendByButton" size="large" round
|
|
|
|
|
|
|
+ <!-- 终止问答按钮 -->
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="conversationInProgress"
|
|
|
|
|
+ @click="stopStream"
|
|
|
|
|
+ class="stop-btn"
|
|
|
|
|
+ title="终止问答"
|
|
|
|
|
+ >
|
|
|
|
|
+ <img :src="stopicon" alt="停止" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 发送按钮 -->
|
|
|
|
|
+ <el-button v-if="!conversationInProgress" @click="handleSendByButton" size="large" round
|
|
|
>发送</el-button
|
|
>发送</el-button
|
|
|
>
|
|
>
|
|
|
</template>
|
|
</template>
|
|
@@ -132,6 +159,12 @@ import DefaultMessage from '@/components/DefaultMessage/index.vue'
|
|
|
import MarkdownView from '@/components/MarkdownView/index.vue'
|
|
import MarkdownView from '@/components/MarkdownView/index.vue'
|
|
|
import { saveRecord } from '@/api/personalized/index.js'
|
|
import { saveRecord } from '@/api/personalized/index.js'
|
|
|
|
|
|
|
|
|
|
+// 语音图标导入
|
|
|
|
|
+import { Microphone, Mute } from '@element-plus/icons-vue'
|
|
|
|
|
+
|
|
|
|
|
+// 终止
|
|
|
|
|
+import stopicon from '@/assets/icon/stopicon.png'
|
|
|
|
|
+
|
|
|
// 导入图标
|
|
// 导入图标
|
|
|
import auto from '@/assets/icon/auto_awesome.png'
|
|
import auto from '@/assets/icon/auto_awesome.png'
|
|
|
|
|
|
|
@@ -154,7 +187,7 @@ const messageList = ref([])
|
|
|
const prompt = ref('')
|
|
const prompt = ref('')
|
|
|
const messageContainer = ref(null)
|
|
const messageContainer = ref(null)
|
|
|
const aiQuestionCount = ref(0)
|
|
const aiQuestionCount = ref(0)
|
|
|
-const userScrolled = ref(false)//是否用户手动滚动
|
|
|
|
|
|
|
+const userScrolled = ref(false) //是否用户手动滚动
|
|
|
const xZAiData = ref({})
|
|
const xZAiData = ref({})
|
|
|
const activeConversationId = ref(null)
|
|
const activeConversationId = ref(null)
|
|
|
const conversationInProgress = ref(false)
|
|
const conversationInProgress = ref(false)
|
|
@@ -164,10 +197,16 @@ const isComposing = ref(false)
|
|
|
const inputTimeout = ref()
|
|
const inputTimeout = ref()
|
|
|
const enableContext = ref(true)
|
|
const enableContext = ref(true)
|
|
|
|
|
|
|
|
-//tts
|
|
|
|
|
|
|
+// tts
|
|
|
import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
|
|
import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
|
|
|
const { playAudioChunk } = useAudioPlayer();
|
|
const { playAudioChunk } = useAudioPlayer();
|
|
|
|
|
|
|
|
|
|
+// 语音输入响应式变量
|
|
|
|
|
+const isRecording = ref(false) // 录音状态
|
|
|
|
|
+const recognition = ref(null) // 语音识别实例
|
|
|
|
|
+const countdown = ref(0) // 倒计时剩余秒数
|
|
|
|
|
+const countdownTimer = ref(null) // 倒计时定时器
|
|
|
|
|
+
|
|
|
// 处理选择的默认消息
|
|
// 处理选择的默认消息
|
|
|
const handleSelectMessage = message => {
|
|
const handleSelectMessage = message => {
|
|
|
prompt.value = message
|
|
prompt.value = message
|
|
@@ -195,7 +234,7 @@ const handleAIClick = async () => {
|
|
|
messageList.value = []
|
|
messageList.value = []
|
|
|
showAIDialog.value = true
|
|
showAIDialog.value = true
|
|
|
|
|
|
|
|
- //创建对话
|
|
|
|
|
|
|
+ // 创建对话
|
|
|
await createAiChart()
|
|
await createAiChart()
|
|
|
|
|
|
|
|
if (props.currentQuestion.ccQuestContent) {
|
|
if (props.currentQuestion.ccQuestContent) {
|
|
@@ -209,7 +248,6 @@ const handleAIClick = async () => {
|
|
|
content: props.currentQuestion.ccQuestContent,
|
|
content: props.currentQuestion.ccQuestContent,
|
|
|
contentAnswer: props.currentQuestion.ccAiAnswer,
|
|
contentAnswer: props.currentQuestion.ccAiAnswer,
|
|
|
})
|
|
})
|
|
|
-
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -243,7 +281,6 @@ const getXzAi = async () => {
|
|
|
const createAiChart = async () => {
|
|
const createAiChart = async () => {
|
|
|
// 先获取数字人接口
|
|
// 先获取数字人接口
|
|
|
await getXzAi()
|
|
await getXzAi()
|
|
|
-
|
|
|
|
|
// 智能问答
|
|
// 智能问答
|
|
|
await CreateDialogue({ roleId: xZAiData.value.id })
|
|
await CreateDialogue({ roleId: xZAiData.value.id })
|
|
|
.then(res => {
|
|
.then(res => {
|
|
@@ -254,7 +291,7 @@ const createAiChart = async () => {
|
|
|
console.error('请求出错:', error)
|
|
console.error('请求出错:', error)
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// 发送消息
|
|
// 发送消息
|
|
|
const sendMessage = async () => {
|
|
const sendMessage = async () => {
|
|
|
if (prompt.value.trim()) {
|
|
if (prompt.value.trim()) {
|
|
@@ -266,7 +303,7 @@ const sendMessage = async () => {
|
|
|
|
|
|
|
|
// 增加问答次数
|
|
// 增加问答次数
|
|
|
aiQuestionCount.value++
|
|
aiQuestionCount.value++
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// 保存AI问答次数
|
|
// 保存AI问答次数
|
|
|
try {
|
|
try {
|
|
|
await saveRecord({
|
|
await saveRecord({
|
|
@@ -290,6 +327,89 @@ const sendMessage = async () => {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// =========== 【语音录入】相关 ===========
|
|
|
|
|
+// 初始化语音识别
|
|
|
|
|
+const initSpeechRecognition = () => {
|
|
|
|
|
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
|
|
|
+ if (!SpeechRecognition) {
|
|
|
|
|
+ ElMessage.warning('当前浏览器不支持语音输入功能')
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const instance = new SpeechRecognition()
|
|
|
|
|
+ instance.lang = "zh-CN"
|
|
|
|
|
+ instance.interimResults = false
|
|
|
|
|
+
|
|
|
|
|
+ instance.onresult = (event) => {
|
|
|
|
|
+ if (event.results?.[0]?.[0]) {
|
|
|
|
|
+ prompt.value += event.results[0][0].transcript
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 新增:识别器结束时清除定时器
|
|
|
|
|
+ instance.onend = () => {
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ instance.onerror = (event) => {
|
|
|
|
|
+ console.error("语音识别错误:", event.error)
|
|
|
|
|
+ clearInterval(countdownTimer.value) // 出错时清除定时器
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ ElMessage.error('语音输入失败,请重试')
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ }
|
|
|
|
|
+ return instance
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 切换录音状态
|
|
|
|
|
+const toggleSpeechInput = () => {
|
|
|
|
|
+ // 无论当前状态如何,先清除可能存在的旧定时器
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ countdownTimer.value = null
|
|
|
|
|
+
|
|
|
|
|
+ if (isRecording.value) {
|
|
|
|
|
+ // 手动停止时重置状态
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ recognition.value?.stop()
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 初始化倒计时前再次清除定时器(防止快速点击)
|
|
|
|
|
+ clearInterval(countdownTimer.value)
|
|
|
|
|
+ countdown.value = 10 // 重置为10秒
|
|
|
|
|
+
|
|
|
|
|
+ recognition.value = initSpeechRecognition()
|
|
|
|
|
+ if (!recognition.value) return
|
|
|
|
|
+
|
|
|
|
|
+ navigator.mediaDevices.getUserMedia({ audio: true })
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ recognition.value.start()
|
|
|
|
|
+ isRecording.value = true
|
|
|
|
|
+
|
|
|
|
|
+ // 启动新的倒计时定时器
|
|
|
|
|
+ countdownTimer.value = setInterval(() => {
|
|
|
|
|
+ countdown.value--
|
|
|
|
|
+ if (countdown.value <= 0) {
|
|
|
|
|
+ clearInterval(countdownTimer.value) // 倒计时结束清除
|
|
|
|
|
+ recognition.value.stop()
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 1000)
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((err) => {
|
|
|
|
|
+ console.error("麦克风权限获取失败:", err)
|
|
|
|
|
+ ElMessage.warning('请允许麦克风权限以使用语音输入')
|
|
|
|
|
+ // 出错时重置状态
|
|
|
|
|
+ isRecording.value = false
|
|
|
|
|
+ countdown.value = 0
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
// 模拟 AI 回复
|
|
// 模拟 AI 回复
|
|
|
const simulateAIResponse = question => {
|
|
const simulateAIResponse = question => {
|
|
|
return new Promise(resolve => {
|
|
return new Promise(resolve => {
|
|
@@ -890,12 +1010,24 @@ $text-color: #483d8b; // 文本颜色:靛蓝色
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 终止按钮
|
|
|
|
|
+.stop-btn {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: rpx(5);
|
|
|
|
|
+ img {
|
|
|
|
|
+ width: rpx(20);
|
|
|
|
|
+ height: rpx(20);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 用户输入框样式
|
|
// 用户输入框样式
|
|
|
.user-input {
|
|
.user-input {
|
|
|
|
|
+ gap: rpx(5); // 间距
|
|
|
::v-deep(.el-input__wrapper) {
|
|
::v-deep(.el-input__wrapper) {
|
|
|
height: rpx(23);
|
|
height: rpx(23);
|
|
|
- border-top-left-radius: rpx(5);
|
|
|
|
|
- border-bottom-left-radius: rpx(5);
|
|
|
|
|
|
|
+ border-radius: rpx(5);
|
|
|
border-color: rgba($primary-color, 0.3);
|
|
border-color: rgba($primary-color, 0.3);
|
|
|
|
|
|
|
|
&:focus-within {
|
|
&:focus-within {
|
|
@@ -907,12 +1039,33 @@ $text-color: #483d8b; // 文本颜色:靛蓝色
|
|
|
font-size: rpx(10);
|
|
font-size: rpx(10);
|
|
|
text-indent: 1em;
|
|
text-indent: 1em;
|
|
|
}
|
|
}
|
|
|
- ::v-deep(.el-input-group__append, .el-input-group__prepend) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 语音按钮样式
|
|
|
|
|
+ ::v-deep(.el-input-group__prepend) {
|
|
|
|
|
+ width: rpx(15);
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ border-radius: rpx(5);
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ::v-deep(.el-input-group__prepend .el-button.recording) {
|
|
|
|
|
+ padding: rpx(5) rpx(10);
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: rpx(5);
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ color: #dc3545;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ::v-deep(.el-input-group__append) {
|
|
|
|
|
+ border: none;
|
|
|
background: linear-gradient(to bottom, #ab81ff, #8559dc);
|
|
background: linear-gradient(to bottom, #ab81ff, #8559dc);
|
|
|
- border-top-right-radius: rpx(5);
|
|
|
|
|
- border-bottom-right-radius: rpx(5);
|
|
|
|
|
|
|
+ border-radius: rpx(5);
|
|
|
color: white;
|
|
color: white;
|
|
|
font-size: rpx(9);
|
|
font-size: rpx(9);
|
|
|
|
|
+ border-left: none;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -956,4 +1109,6 @@ $text-color: #483d8b; // 文本颜色:靛蓝色
|
|
|
margin-top: rpx(-10);
|
|
margin-top: rpx(-10);
|
|
|
margin-bottom: rpx(5);
|
|
margin-bottom: rpx(5);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
</style>
|
|
</style>
|