Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
水玉婷
ai-wechat
Commits
a4d5ca91
Commit
a4d5ca91
authored
Nov 25, 2025
by
水玉婷
Browse files
feat:添加语音输入功能
parent
b1f619b0
Changes
6
Hide whitespace changes
Inline
Side-by-side
localhost-key.pem
0 → 100644
View file @
a4d5ca91
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmrZ80Y5XKfRZG
utsLbDeQhBFhpAqvoE9FnN614BsF4lVVNPYR6Oxowa/XYne56AVEleGpFxXVoRw5
j0s3mT8MIpUpK20WKczO3f88Q9WVQFi68pkBVLUigp78fYH8u3ZSv37tG0SMwlle
NlYDQGo66T/a7fybulWNdtmWmr4pz+BO7cLbodCRglVkrg2aRZTIbjVbWWBoyKXO
yRwzgGgzDpbzesgnlrtvAX1cV+oCtt/ixI+qrrkXtpVkKSmqzwrGzDx8h1QP2qU+
ZhfpNqig+ANtNd7a7Gnhsy0+q3pYSY/JHGikJLSSW5hZFgWqXBhSkDG25O2BSwyz
dy2uGFmPAgMBAAECggEAMfMmGtUdNql124xzyGCN5kktzE0Uxr0MBJiWRXr7ni/N
0tMkSwm6j0o8IBfqOVRG/97K2ZmJeZPmmXlP2UGbm09h1AynjFTKg9QTgUPy5d96
t8ur/rIb9lOewZv7MHodY37v0q6xRF2Z2pn9/Mt5Cl6MPFfFtAWLTfGoE3IcOvsN
Br4/XGYKi0zkd3biqLAEJDL1wHL5J+5VqtL8ZmV6qGBQXw0PBI3ZZO8pV5lsIL1Z
qB/2LMeKCv96KFCfpanK3XdBep9KVUdWIxsjJhgi1ETtLdD/8OmBu+YJp78/Xdqv
io7ux2+Bj3gqEqTIdMySRrRg3oFe/zX0/bkHzScJgQKBgQDaO5lXIjg0OGwdCn81
YeS8z9uqjkFlxvqQe8Xkub57Wrvcva4+f2VRVvXM/xOmFhfMIFMqjTQ6OVwol3Nd
pEvDeiy67bozGle++A/RvPEgt1HpWha79CiJ8V4KCc8sonu5GgLZGCRTmwZ3m0ru
aSR9OwQb7y4ttNsk0UoSLmnBhwKBgQDDhf3lRtiTQic4Zlb2rLdvU4OAOYhDsT6h
JQSVGSwrcGyuv3pkg2yQ8/VlZ8M54rIg7m0+/gRCxZ6owvnj7V0AgUFbk5aG/JSV
Atpqcf12pkoBo1xFmcozf5y0E3rrn5D5jGU/lnOXhDAxmP+bJP80O2+ffhCrJOda
OjY2N6RJuQKBgQChbYCqIZftmObwPHmIpVcsC51z9jKN9LgX9FaYMIWkfaOFT5H6
jQYHOworj2ubabBEwIyEZ1sAzrlLFWyzEfsxJ8i6pWscrhnGG3yoKtk62B/xO0Ch
26O5Fh/30PW9EJvwejstF1yXs47/HpI49PGW6PbLKwu/p46LF31xIX/9NQKBgGVK
IdjIFeRbrfPC2KRbn3+1tPcVVukyhi52/eO7sa0jRbpVibNOfkythWAuG+396aez
vLaYY16v/9yPfWM9kSN00oX9dEqjyNlVLA9e1B7GUKp+lYuc+yoonuaO/OvZswIE
YGNLrsA8g7b9+tTFmsvVSqNGbJ4stQmCBJmbw6lJAoGBAL0/LehG2sKvsNDoXT72
2HOYH2jz5w5/20ptEjV4I6XvdRw4tfQDe1casNS1RuE7pNTcSKnN8J0vQ4BImHyR
hX2SAd8gTg6UDVCEpjwRveIhnvR+dbIgoIF8b8EdMIeepzP7Bs5fHcIXS3ikXl9t
eTWeKLyu4ce/Lz7caIlkp6tk
-----END PRIVATE KEY-----
localhost.pem
0 → 100644
View file @
a4d5ca91
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQDzfHp3ypuCvDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjUxMTI1MDgzNDUyWhcNMjYxMTI1MDgzNDUyWjAUMRIwEAYD
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCm
rZ80Y5XKfRZGutsLbDeQhBFhpAqvoE9FnN614BsF4lVVNPYR6Oxowa/XYne56AVE
leGpFxXVoRw5j0s3mT8MIpUpK20WKczO3f88Q9WVQFi68pkBVLUigp78fYH8u3ZS
v37tG0SMwlleNlYDQGo66T/a7fybulWNdtmWmr4pz+BO7cLbodCRglVkrg2aRZTI
bjVbWWBoyKXOyRwzgGgzDpbzesgnlrtvAX1cV+oCtt/ixI+qrrkXtpVkKSmqzwrG
zDx8h1QP2qU+ZhfpNqig+ANtNd7a7Gnhsy0+q3pYSY/JHGikJLSSW5hZFgWqXBhS
kDG25O2BSwyzdy2uGFmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADmQg8xLPZ2R
+sZcSEK6jxxvq6lImC0o+vd664jcZb3wG5YMI7gskpiz7uDluH7Tqt+8DjvA5anr
Yvyt6VGYLsCvFq150j4/nRPajlLWM4y3Ulz9vuVSAqY3HXlA55n8ab19HbmpgR0g
QmWDKI4uJ6gLXpQYvp0UAyMwuD1JDf/SPm1kZOQl1gWO2s8rF7fxZD66a4UbjloB
IwVk7oASGcYk+IN9SS3iF1PKHyaoOMA5BnyZvkWS3h0Q++AgYWXzFTvN/lq4g088
lTvsOwA0mMGIRek0zMkrlVB7exhJpRHEhqS576q2O7H9JsZRwlRxc1G7kWimlT0L
pscjZR5TtQo=
-----END CERTIFICATE-----
src/views/components/AiChat.vue
View file @
a4d5ca91
...
...
@@ -6,7 +6,7 @@
<img
:src=
"props.logoUrl || defaultAvatar"
alt=
"avatar"
class=
"avatar-image"
/>
</div>
<div
class=
"header-info"
>
<h2>
{{
props
.
dialogSessionId
?
props
?.
detailData
?.
title
||
'
继续对话
'
:
'
新建对
333
话
'
}}
</h2>
<h2>
{{
props
.
dialogSessionId
?
props
?.
detailData
?.
title
||
'
继续对话
'
:
'
新建对话
'
}}
</h2>
</div>
</div>
...
...
@@ -69,6 +69,16 @@
<!-- 输入区域 - 始终显示 -->
<div
class=
"chat-input-container"
>
<div
class=
"chat-input"
>
<!-- 语音识别按钮 -->
<VoiceRecognition
ref=
"voiceRecognitionRef"
:disabled=
"loading"
:debug=
"true"
@
audio=
"handleVoiceAudio"
@
error=
"handleVoiceError"
class=
"voice-recognition-wrapper"
/>
<textarea
ref=
"textarea"
v-model=
"messageText"
placeholder=
"输入消息..."
@
keypress=
"handleKeyPress"
@
input=
"adjustTextareaHeight"
:disabled=
"loading"
></textarea>
<button
@
click=
"sendMessage"
:disabled=
"loading"
>
...
...
@@ -88,6 +98,7 @@ import tableTemplate from './tableTemplate';
import
{
SendOutlined
,
UserOutlined
}
from
'
@ant-design/icons-vue
'
;
import
defaultAvatar
from
'
@/assets/logo.png
'
;
import
ChartComponent
from
'
./ChartComponent.vue
'
;
// 导入独立的图表组件
import
VoiceRecognition
from
'
./VoiceRecognition.vue
'
;
// 导入语音识别组件
// 组件属性
const
props
=
withDefaults
(
...
...
@@ -192,13 +203,28 @@ const contentTemplates = {
onerror="console.error('iframe加载失败:', this.src)"
></iframe>
</div>`
;
},
// 音频消息模板
audio
:
(
audioData
:
any
)
=>
{
const
{
audioUrl
,
audioBlob
}
=
audioData
;
let
src
=
audioUrl
;
// 如果提供了Blob对象,创建对象URL
if
(
audioBlob
&&
!
audioUrl
)
{
src
=
URL
.
createObjectURL
(
audioBlob
);
}
return
`<div class="audio-message">
<audio controls src="
${
src
}
">
您的浏览器不支持音频播放
</audio>
</div>`
;
}
};
// 定义消息类型 - 更新接口添加图表相关字段
interface
Message
{
messageType
:
'
received
'
|
'
sent
'
;
type
?:
number
|
string
;
avatar
:
string
;
recordId
:
string
;
promptTokens
:
number
;
...
...
@@ -216,6 +242,13 @@ interface Message {
}[];
}
// 检查是否为音频消息的辅助函数
const
isAudioMessage
=
(
messageData
:
any
):
boolean
=>
{
return
messageData
.
questionType
===
'
audio
'
||
(
messageData
.
question
&&
typeof
messageData
.
question
===
'
object
'
&&
(
messageData
.
question
.
audioUrl
||
messageData
.
question
.
audioData
));
};
interface
SSEData
{
message
:
any
;
status
:
number
|
string
;
...
...
@@ -243,6 +276,86 @@ const isReconnecting = ref(false);
const
timeArr
=
ref
([]);
const
hasStartedConversation
=
ref
(
false
);
// 添加对话开始状态
// 语音事件处理函数
const
handleVoiceAudio
=
(
audioBlob
:
Blob
)
=>
{
console
.
log
(
'
收到音频数据:
'
,
audioBlob
);
// 开始对话
startConversation
();
// 添加音频消息到聊天记录
messages
.
value
.
push
({
messageType
:
'
sent
'
,
avatar
:
'
我
'
,
recordId
:
''
,
promptTokens
:
0
,
completionTokens
:
0
,
totalTokens
:
0
,
date
:
dayjs
().
format
(
'
HH:mm
'
),
contentBlocks
:
[
{
content
:
contentTemplates
.
audio
({
audioBlob
}),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
}
],
});
// 滚动到底部
nextTick
(()
=>
{
scrollToBottom
();
});
// 发送音频到AI
sendAudioMessage
(
audioBlob
);
};
const
handleVoiceError
=
(
error
:
string
)
=>
{
console
.
error
(
'
语音识别错误:
'
,
error
);
// 可以添加错误提示
};
// 发送音频消息
const
sendAudioMessage
=
async
(
audioBlob
:
Blob
)
=>
{
loading
.
value
=
true
;
try
{
// 创建FormData来发送音频文件
const
formData
=
new
FormData
();
formData
.
append
(
'
audio
'
,
audioBlob
,
'
recording.wav
'
);
formData
.
append
(
'
dialogSessionId
'
,
dialogSessionId
.
value
);
formData
.
append
(
'
appId
'
,
props
.
params
?.
appId
||
''
);
formData
.
append
(
'
stage
'
,
props
.
params
?.
stage
||
''
);
// 调用外部传入的消息发送函数
if
(
props
.
onMessageSend
)
{
console
.
log
(
'
调用外部音频发送函数
'
);
// 这里需要根据实际情况调整,可能需要将音频转换为base64或其他格式
await
props
.
onMessageSend
(
audioBlob
);
}
else
{
// 默认的API调用逻辑 - 发送音频
console
.
log
(
'
默认音频API调用逻辑
'
);
const
response
=
await
post
(
`
${
props
.
apiBaseUrl
}
/aiService/ask/audio/app/
${
props
.
params
?.
appId
}
`
,
formData
,
{
headers
:
{
Token
:
props
.
token
||
''
,
'
x-session-id
'
:
props
.
token
||
''
,
'
x-app-code
'
:
props
.
appCode
||
''
,
'
Content-Type
'
:
'
multipart/form-data
'
,
}
});
const
data
=
response
.
data
;
if
(
data
.
code
===
0
)
{
console
.
log
(
'
音频发送成功
'
);
}
}
}
catch
(
e
)
{
console
.
error
(
'
发送音频消息失败:
'
,
e
);
}
finally
{
loading
.
value
=
false
;
}
};
// 开始对话函数 - 修改为在发送消息时调用
const
startConversation
=
()
=>
{
hasStartedConversation
.
value
=
true
;
...
...
@@ -264,7 +377,7 @@ const simulateOptionData = () => {
}
};
// 第二个消息:展示一个options(会走iframe逻辑)
// 第二个消息:展示一个options(会走iframe逻辑)
const
secondOptionData
=
{
status
:
3
,
type
:
'
option
'
,
...
...
@@ -314,7 +427,7 @@ const simulateOptionData = () => {
messages
.
value
.
push
(
secondResult
.
updatedResponse
);
nextTick
(()
=>
{
scrollToBottom
();
});
});
}
};
...
...
@@ -765,81 +878,92 @@ onBeforeUnmount(() => {
});
// 处理历史记录数据
const
processHistoryData
=
(
data
Array
:
any
[]
)
=>
{
const
processHistoryData
=
(
data
:
any
):
Message
[]
=>
{
const
result
:
Message
[]
=
[];
dataArray
.
forEach
((
data
)
=>
{
let
date
=
dayjs
(
data
.
startTime
).
format
(
'
YYYY-MM-DD HH:mm:ss
'
);
if
(
data
.
question
)
{
result
.
push
({
messageType
:
'
sent
'
,
avatar
:
'
我
'
,
recordId
:
''
,
promptTokens
:
0
,
completionTokens
:
0
,
totalTokens
:
0
,
date
,
contentBlocks
:
[
{
content
:
contentTemplates
.
text
(
data
.
question
),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
},
],
const
date
=
dayjs
(
data
.
createTime
).
format
(
'
HH:mm
'
);
// 处理问题消息
if
(
data
.
question
)
{
let
questionContent
=
''
;
// 检查是否为音频消息
if
(
isAudioMessage
(
data
))
{
// 处理音频消息
const
audioData
=
data
.
question
;
questionContent
=
contentTemplates
.
audio
({
audioUrl
:
audioData
.
audioUrl
,
audioBlob
:
audioData
.
audioBlob
});
}
else
{
// 处理文本消息
questionContent
=
contentTemplates
.
text
(
typeof
data
.
question
===
'
string
'
?
data
.
question
:
data
.
question
.
content
||
''
);
}
if
(
data
.
answerInfoList
&&
Array
.
isArray
(
data
.
answerInfoList
))
{
const
aiMessage
:
Message
=
{
messageType
:
'
received
'
,
avatar
:
'
AI
'
,
recordId
:
''
,
promptTokens
:
0
,
completionTokens
:
0
,
totalTokens
:
0
,
contentBlocks
:
[],
date
,
};
result
.
push
({
messageType
:
'
sent
'
,
avatar
:
'
我
'
,
recordId
:
''
,
promptTokens
:
0
,
completionTokens
:
0
,
totalTokens
:
0
,
date
,
contentBlocks
:
[
{
content
:
questionContent
,
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
},
],
});
}
let
currentThinkingMode
=
false
;
let
currentBlockIdx
=
-
1
;
// 历史数据处理,isHistoryData设为true,思考框折叠
data
.
answerInfoList
.
forEach
((
answer
)
=>
{
const
sseData
:
SSEData
=
{
message
:
answer
.
message
||
''
,
status
:
answer
.
status
||
0
,
type
:
answer
.
type
||
0
,
};
const
processResult
=
processSSEMessage
(
sseData
,
aiMessage
,
currentThinkingMode
,
currentBlockIdx
,
true
,
);
// 处理AI回答消息
if
(
data
.
answerInfoList
&&
Array
.
isArray
(
data
.
answerInfoList
))
{
const
aiMessage
:
Message
=
{
messageType
:
'
received
'
,
avatar
:
'
AI
'
,
recordId
:
''
,
promptTokens
:
0
,
completionTokens
:
0
,
totalTokens
:
0
,
contentBlocks
:
[],
date
,
};
currentThinkingMode
=
processResult
.
updatedIsThinking
;
currentBlockIdx
=
processResult
.
updatedBlockIndex
;
aiMessage
.
recordId
=
processResult
.
recordId
;
aiMessage
.
promptTokens
=
processResult
.
promptTokens
;
aiMessage
.
completionTokens
=
processResult
.
completionTokens
;
aiMessage
.
totalTokens
=
processResult
.
totalTokens
;
});
let
currentThinkingMode
=
false
;
let
currentBlockIdx
=
-
1
;
// 确保历史记录中的思考框默认折叠
aiMessage
.
contentBlocks
.
forEach
((
block
)
=>
{
if
(
block
.
hasThinkBox
)
{
block
.
thinkBoxExpanded
=
false
;
}
});
// 历史数据处理,isHistoryData设为true,思考框折叠
data
.
answerInfoList
.
forEach
((
answer
)
=>
{
const
sseData
:
SSEData
=
{
message
:
answer
.
message
||
''
,
status
:
answer
.
status
||
0
,
type
:
answer
.
type
||
''
,
};
if
(
aiMessage
.
contentBlocks
.
length
>
0
)
{
result
.
push
(
aiMessage
);
}
const
processResult
=
processSSEMessage
(
sseData
,
aiMessage
,
currentThinkingMode
,
currentBlockIdx
,
true
,
);
currentThinkingMode
=
processResult
.
updatedIsThinking
;
currentBlockIdx
=
processResult
.
updatedBlockIndex
;
aiMessage
.
recordId
=
processResult
.
recordId
;
aiMessage
.
promptTokens
=
processResult
.
promptTokens
;
aiMessage
.
completionTokens
=
processResult
.
completionTokens
;
aiMessage
.
totalTokens
=
processResult
.
totalTokens
;
});
if
(
aiMessage
.
contentBlocks
.
length
>
0
)
{
result
.
push
(
aiMessage
);
}
}
);
}
return
result
;
};
...
...
src/views/components/VoiceRecognition.vue
0 → 100644
View file @
a4d5ca91
<
template
>
<div
class=
"voice-recognition"
>
<!-- 语音按钮 -->
<button
class=
"voice-btn"
:class=
"
{ 'recording': isRecording, 'disabled': disabled }"
@click="toggleRecording"
:disabled="disabled"
:title="isRecording ? '停止录音' : '开始录音'"
>
<!-- 语音图标始终显示 -->
<span
class=
"voice-icon"
>
<AudioOutlined
/>
</span>
<!-- 录音时显示指示器 -->
<span
v-if=
"isRecording"
class=
"recording-indicator"
>
<span
class=
"pulse"
></span>
<span
class=
"pulse"
></span>
<span
class=
"pulse"
></span>
</span>
</button>
<!-- 语音识别状态提示 -->
<div
v-if=
"showStatus"
class=
"voice-status"
:class=
"statusClass"
>
{{
statusText
}}
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
AudioOutlined
}
from
'
@ant-design/icons-vue
'
// 组件属性
interface
Props
{
disabled
?:
boolean
debug
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
debug
:
false
})
// 组件事件
const
emit
=
defineEmits
<
{
audio
:
[
audioBlob
:
Blob
]
error
:
[
error
:
string
]
}
>
()
// 响应式数据
const
isRecording
=
ref
(
false
)
const
showStatus
=
ref
(
false
)
const
statusText
=
ref
(
''
)
// MediaRecorder相关
const
mediaRecorder
=
ref
<
MediaRecorder
|
null
>
(
null
)
const
audioChunks
=
ref
<
Blob
[]
>
([])
const
audioStream
=
ref
<
MediaStream
|
null
>
(
null
)
// 计算属性
const
statusClass
=
computed
(()
=>
{
return
isRecording
.
value
?
'
recording
'
:
'
idle
'
})
// 检查浏览器是否支持MediaRecorder
const
isMediaRecorderSupported
=
()
=>
{
const
supported
=
'
MediaRecorder
'
in
window
;
if
(
props
.
debug
)
{
console
.
log
(
'
MediaRecorder支持检查:
'
,
supported
);
}
return
supported
;
}
// 显示状态消息
const
showStatusMessage
=
(
message
:
string
)
=>
{
statusText
.
value
=
message
showStatus
.
value
=
true
setTimeout
(()
=>
{
showStatus
.
value
=
false
},
3000
)
}
// 检查麦克风权限
const
checkMicrophonePermission
=
async
()
=>
{
try
{
const
permissionStatus
=
await
navigator
.
permissions
.
query
({
name
:
'
microphone
'
as
PermissionName
});
if
(
props
.
debug
)
{
console
.
log
(
'
麦克风权限状态:
'
,
permissionStatus
.
state
);
}
if
(
permissionStatus
.
state
===
'
denied
'
)
{
showStatusMessage
(
'
麦克风权限被拒绝,请在浏览器设置中允许访问
'
);
return
false
;
}
return
true
;
}
catch
(
error
)
{
if
(
props
.
debug
)
{
console
.
warn
(
'
无法检查麦克风权限:
'
,
error
);
}
return
true
;
// 如果无法检查权限,继续尝试
}
}
// 显示权限引导提示
const
showPermissionGuide
=
()
=>
{
const
guideMessage
=
`
麦克风权限被拒绝,请按以下步骤操作:
1. 点击浏览器地址栏左侧的"锁形图标"或"不安全"标识
2. 选择"网站设置"
3. 找到"麦克风"权限,选择"允许"
4. 刷新页面后重试
`
;
showStatusMessage
(
'
麦克风权限被拒绝,请检查浏览器设置
'
);
// 在调试模式下显示详细引导
if
(
props
.
debug
)
{
console
.
warn
(
'
麦克风权限被拒绝,用户需要手动授权
'
);
console
.
log
(
guideMessage
);
}
}
// 开始录音
const
startRecording
=
async
()
=>
{
if
(
props
.
disabled
)
return
// 检查权限
const
hasPermission
=
await
checkMicrophonePermission
();
if
(
!
hasPermission
)
{
showPermissionGuide
();
return
;
}
// 检查浏览器支持
if
(
!
isMediaRecorderSupported
())
{
const
errorMsg
=
'
您的浏览器不支持音频录制功能
'
;
showStatusMessage
(
errorMsg
);
emit
(
'
error
'
,
errorMsg
);
return
;
}
try
{
// 获取麦克风权限
const
stream
=
await
navigator
.
mediaDevices
.
getUserMedia
({
audio
:
{
echoCancellation
:
true
,
noiseSuppression
:
true
,
sampleRate
:
44100
}
});
audioStream
.
value
=
stream
;
audioChunks
.
value
=
[];
// 创建MediaRecorder实例
mediaRecorder
.
value
=
new
MediaRecorder
(
stream
,
{
mimeType
:
'
audio/webm;codecs=opus
'
});
// 设置数据可用事件处理
mediaRecorder
.
value
.
ondataavailable
=
(
event
)
=>
{
if
(
event
.
data
.
size
>
0
)
{
audioChunks
.
value
.
push
(
event
.
data
);
}
};
// 设置停止事件处理 - 录制完成后直接发送
mediaRecorder
.
value
.
onstop
=
()
=>
{
sendRecordedAudio
();
};
// 开始录制
mediaRecorder
.
value
.
start
(
100
);
// 每100ms收集一次数据
isRecording
.
value
=
true
;
showStatusMessage
(
'
正在录音...
'
);
if
(
props
.
debug
)
{
console
.
log
(
'
开始录音,MediaRecorder状态:
'
,
mediaRecorder
.
value
.
state
);
}
}
catch
(
error
)
{
console
.
error
(
'
启动录音失败:
'
,
error
);
let
errorMessage
=
'
无法启动录音
'
;
if
(
error
&&
error
.
name
===
'
NotAllowedError
'
)
{
errorMessage
=
'
麦克风权限被拒绝
'
;
showPermissionGuide
();
}
else
if
(
error
&&
error
.
name
===
'
NotFoundError
'
)
{
errorMessage
=
'
未找到麦克风设备
'
;
}
showStatusMessage
(
errorMessage
);
emit
(
'
error
'
,
errorMessage
);
}
}
// 停止录音
const
stopRecording
=
()
=>
{
if
(
mediaRecorder
.
value
&&
mediaRecorder
.
value
.
state
===
'
recording
'
)
{
mediaRecorder
.
value
.
stop
();
isRecording
.
value
=
false
;
// 停止所有音频轨道
if
(
audioStream
.
value
)
{
audioStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
audioStream
.
value
=
null
;
}
if
(
props
.
debug
)
{
console
.
log
(
'
停止录音,MediaRecorder状态:
'
,
mediaRecorder
.
value
.
state
);
}
}
}
// 发送录制的音频
const
sendRecordedAudio
=
()
=>
{
if
(
audioChunks
.
value
.
length
===
0
)
{
showStatusMessage
(
'
录音数据为空
'
);
return
;
}
const
audioBlob
=
new
Blob
(
audioChunks
.
value
,
{
type
:
'
audio/webm;codecs=opus
'
});
// 发送音频数据
emit
(
'
audio
'
,
audioBlob
);
showStatusMessage
(
'
音频已发送
'
);
// 清理录音数据
audioChunks
.
value
=
[];
if
(
props
.
debug
)
{
console
.
log
(
'
音频发送完成,大小:
'
,
Math
.
round
(
audioBlob
.
size
/
1024
),
'
KB
'
);
}
}
// 切换录音状态
const
toggleRecording
=
()
=>
{
if
(
isRecording
.
value
)
{
stopRecording
();
}
else
{
startRecording
();
}
}
// 组件卸载时清理资源
onUnmounted
(()
=>
{
if
(
isRecording
.
value
)
{
stopRecording
();
}
if
(
audioStream
.
value
)
{
audioStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
}
})
// 暴露方法给父组件
defineExpose
({
startRecording
,
stopRecording
,
isRecording
:
()
=>
isRecording
.
value
})
</
script
>
<
style
scoped
lang=
"less"
>
@import './style.less';
.voice-recognition {
position: relative;
display: inline-block;
}
.voice-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: @primary-color;
color: @white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
position: relative;
z-index: 100; /* 大幅提高按钮的z-index,确保始终在最上层 */
&:hover:not(.disabled) {
background: @primary-hover;
transform: scale(1.05);
}
&:active:not(.disabled) {
transform: scale(0.95);
}
&.recording {
background: @error-color;
animation: pulse 1.5s infinite;
}
&.disabled {
background: @gray-4;
cursor: not-allowed;
opacity: 0.6;
}
}
.voice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.recording-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 2px;
.pulse {
width: 4px;
height: 4px;
background: @white;
border-radius: 50%;
animation: pulse 1.5s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.voice-status {
position: absolute;
top: -45px; /* 进一步增加距离,确保完全不会遮挡按钮 */
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: @white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1; /* 保持较低的z-index */
pointer-events: none; /* 禁止状态提示框接收点击事件 */
&.recording {
background: rgba(255, 0, 0, 0.8);
}
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
</
style
>
\ No newline at end of file
src/views/components/style.less
View file @
a4d5ca91
...
...
@@ -18,6 +18,7 @@
@gray-6: #666666;
@gray-7: #333333;
@success-color: #52c41a;
@success-hover: #46a51a; // 添加缺失的变量定义
@error-color: #f5222d;
@warning-color: #faad14;
...
...
@@ -333,9 +334,18 @@ p, h1, h2, h3, h4, h5, h6, ul, ol, li {
align-items: flex-end;
position: relative;
// 语音识别按钮容器 - 移动到右边
.voice-recognition-wrapper {
position: absolute;
right: 40px; // 放在发送按钮左边
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
textarea {
flex: 1;
padding: 14px
7
0px 14px 1
8
px;
padding: 14px
11
0px 14px 1
6
px;
// 调整内边距:右侧为两个按钮留空间,左侧恢复正常
border-radius: 12px;
outline: none;
resize: none;
...
...
@@ -378,13 +388,7 @@ p, h1, h2, h3, h4, h5, h6, ul, ol, li {
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
z-index: 10;
transform: translateY(-50%);
&:hover {
color: @primary-hover;
border-color: @primary-hover;
background-color: rgba(91, 138, 254, 0.05);
}
&:active {
background-color: rgba(91, 138, 254, 0.1);
transform: translateY(-50%) scale(0.95);
...
...
@@ -842,4 +846,93 @@ p, h1, h2, h3, h4, h5, h6, ul, ol, li {
}
}
}
}
// 音频消息样式
.message-audio {
background: linear-gradient(135deg, #f0f9ff, #e6f7ff);
border: 1px solid #91d5ff;
border-radius: 12px;
padding: 12px;
margin: 8px 0;
.audio-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.audio-icon {
font-size: 16px;
}
.audio-text {
font-size: 14px;
font-weight: 500;
color: #1890ff;
}
}
.audio-transcript {
font-size: 14px;
line-height: 1.4;
color: #595959;
background: rgba(255, 255, 255, 0.7);
padding: 8px;
border-radius: 6px;
border-left: 3px solid #1890ff;
}
}
// 语音识别组件样式调整
.chat-input {
display: flex;
align-items: flex-end;
gap: 8px;
textarea {
flex: 1;
padding: 14px 110px 14px 16px; // 调整内边距:右侧为两个按钮留空间,左侧恢复正常
border-radius: 12px;
outline: none;
resize: none;
height: 52px;
font-size: 15px;
transition: border-color 0.3s, box-shadow 0.3s;
background-color: @blue-light-2;
border: 1px solid @gray-3;
overflow: hidden;
position: relative;
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.12);
&:focus {
border-color: @primary-color;
box-shadow: 0 0 0 2px rgba(91, 138, 254, 0.1);
}
&:disabled {
background-color: @gray-2;
border-color: @gray-3;
color: @gray-5;
cursor: not-allowed;
}
}
// 语音识别按钮样式
.voice-recognition {
margin: 0;
}
}
.operation-box {
margin-top: 6px;
p {
color: @gray-5;
font-size: 12px;
span {
margin-right: 15px;
}
}
}
\ No newline at end of file
vite.config.js
View file @
a4d5ca91
import
{
defineConfig
}
from
'
vite
'
import
vue
from
'
@vitejs/plugin-vue
'
import
{
resolve
}
from
'
path
'
;
import
fs
from
'
fs
'
;
export
default
defineConfig
({
base
:
'
/ai/
'
,
// 添加基础路径前缀
plugins
:
[
vue
()],
server
:
{
host
:
'
0.0.0.0
'
,
port
:
3000
,
https
:
{
key
:
fs
.
readFileSync
(
'
./localhost-key.pem
'
),
cert
:
fs
.
readFileSync
(
'
./localhost.pem
'
)
},
// 添加history fallback配置
historyApiFallback
:
{
rewrites
:
[
...
...
@@ -40,24 +44,6 @@ export default defineConfig({
console
.
log
(
'
发送请求到:
'
,
options
.
target
);
});
}
},
// 修复pedService代理配置
'
/pedService
'
:
{
target
:
'
http://10.17.86.37:8630
'
,
changeOrigin
:
true
,
// 解决跨域问题
secure
:
false
,
// 允许不安全的SSL连接
rewrite
:
(
path
)
=>
path
.
replace
(
/^
\/
pedService/
,
'
/pedService
'
),
// 保留前缀
configure
:
(
proxy
,
options
)
=>
{
proxy
.
on
(
'
error
'
,
(
err
,
req
,
res
)
=>
{
console
.
log
(
'
pedService代理错误:
'
,
err
);
});
proxy
.
on
(
'
proxyReq
'
,
(
proxyReq
,
req
,
res
)
=>
{
console
.
log
(
'
pedService发送请求到本地服务:
'
,
options
.
target
+
req
.
url
);
});
proxy
.
on
(
'
proxyRes
'
,
(
proxyRes
,
req
,
res
)
=>
{
console
.
log
(
'
pedService收到响应,状态码:
'
,
proxyRes
.
statusCode
);
});
}
}
},
},
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment