289 lines
9.1 KiB
JavaScript
289 lines
9.1 KiB
JavaScript
// 语音输入功能
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
const agentInput = document.getElementById('agentInput');
|
||
const sendAgentBtn = document.getElementById('sendAgentBtn');
|
||
const status = document.getElementById('status');
|
||
|
||
if (!agentInput || !sendAgentBtn || !sendAgentBtn.parentNode) {
|
||
return;
|
||
}
|
||
|
||
const voiceBtn = document.createElement('button');
|
||
voiceBtn.className = 'btn btn-voice';
|
||
voiceBtn.id = 'voiceBtn';
|
||
voiceBtn.type = 'button';
|
||
voiceBtn.textContent = '🎤';
|
||
voiceBtn.title = '按住说话,松开发送';
|
||
sendAgentBtn.parentNode.insertBefore(voiceBtn, sendAgentBtn);
|
||
|
||
let recognition = null;
|
||
let isRecordingVoice = false;
|
||
let microphoneReady = false;
|
||
let spacePressed = false;
|
||
let shouldAutoSend = false;
|
||
|
||
function setStatusMessage(message) {
|
||
if (status) {
|
||
status.innerHTML = `<p>${message}</p>`;
|
||
}
|
||
}
|
||
|
||
function updateVoiceButton(recording) {
|
||
voiceBtn.classList.toggle('recording', recording);
|
||
voiceBtn.textContent = recording ? '🔴' : '🎤';
|
||
}
|
||
|
||
function hasSecureVoiceContext() {
|
||
return window.isSecureContext;
|
||
}
|
||
|
||
function getSecureContextMessage() {
|
||
return '语音输入需要在 HTTPS 或 localhost 环境下使用;当前地址无法申请麦克风权限。';
|
||
}
|
||
|
||
function getRecognitionErrorMessage(error) {
|
||
switch (error) {
|
||
case 'not-allowed':
|
||
case 'service-not-allowed':
|
||
return '浏览器已阻止麦克风权限,请在地址栏站点设置中允许麦克风后刷新页面。';
|
||
case 'audio-capture':
|
||
return '没有检测到可用麦克风,请检查设备或系统录音权限。';
|
||
case 'network':
|
||
return '语音识别服务连接失败,请检查网络后重试。';
|
||
case 'no-speech':
|
||
return '没有识别到语音,请按住按钮后再说话。';
|
||
case 'aborted':
|
||
return '语音识别已取消。';
|
||
default:
|
||
return `语音识别失败:${error}`;
|
||
}
|
||
}
|
||
|
||
function handlePermissionError(error) {
|
||
const permissionDeniedNames = ['NotAllowedError', 'PermissionDeniedError', 'SecurityError'];
|
||
const deviceMissingNames = ['NotFoundError', 'DevicesNotFoundError'];
|
||
|
||
if (permissionDeniedNames.includes(error.name)) {
|
||
setStatusMessage('浏览器未授予麦克风权限,请在站点权限中允许麦克风后重试。');
|
||
return;
|
||
}
|
||
|
||
if (deviceMissingNames.includes(error.name)) {
|
||
setStatusMessage('没有找到可用的麦克风设备,请检查设备连接。');
|
||
return;
|
||
}
|
||
|
||
setStatusMessage(`麦克风初始化失败:${error.message || error.name}`);
|
||
}
|
||
|
||
async function ensureMicrophonePermission() {
|
||
if (microphoneReady) {
|
||
return true;
|
||
}
|
||
|
||
if (!hasSecureVoiceContext()) {
|
||
setStatusMessage(getSecureContextMessage());
|
||
return false;
|
||
}
|
||
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
// 某些支持 SpeechRecognition 的浏览器不会暴露 getUserMedia;
|
||
// 这种情况下交给 recognition.start() 自己触发权限请求。
|
||
microphoneReady = true;
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
microphoneReady = true;
|
||
return true;
|
||
} catch (error) {
|
||
console.error('麦克风权限申请失败:', error);
|
||
handlePermissionError(error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function initSpeechRecognition() {
|
||
if (!SpeechRecognition) {
|
||
return;
|
||
}
|
||
|
||
recognition = new SpeechRecognition();
|
||
recognition.continuous = false;
|
||
recognition.interimResults = true;
|
||
recognition.lang = 'zh-CN';
|
||
|
||
recognition.onstart = function() {
|
||
isRecordingVoice = true;
|
||
updateVoiceButton(true);
|
||
setStatusMessage('正在聆听,请开始说话...');
|
||
};
|
||
|
||
recognition.onresult = function(event) {
|
||
let finalTranscript = '';
|
||
let interimTranscript = '';
|
||
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const transcript = event.results[i][0].transcript;
|
||
if (event.results[i].isFinal) {
|
||
finalTranscript += transcript;
|
||
} else {
|
||
interimTranscript += transcript;
|
||
}
|
||
}
|
||
|
||
if (finalTranscript) {
|
||
agentInput.value = finalTranscript.trim();
|
||
} else if (interimTranscript) {
|
||
agentInput.value = interimTranscript.trim();
|
||
}
|
||
};
|
||
|
||
recognition.onerror = function(event) {
|
||
console.error('语音识别错误:', event.error);
|
||
isRecordingVoice = false;
|
||
updateVoiceButton(false);
|
||
|
||
if (event.error !== 'aborted') {
|
||
setStatusMessage(getRecognitionErrorMessage(event.error));
|
||
}
|
||
};
|
||
|
||
recognition.onend = function() {
|
||
const hasText = Boolean(agentInput.value.trim());
|
||
isRecordingVoice = false;
|
||
updateVoiceButton(false);
|
||
|
||
if (hasText && shouldAutoSend) {
|
||
window.setTimeout(function() {
|
||
sendAgentBtn.click();
|
||
}, 300);
|
||
} else if (!hasText) {
|
||
setStatusMessage('语音识别已结束。');
|
||
}
|
||
|
||
shouldAutoSend = false;
|
||
};
|
||
}
|
||
|
||
async function startVoiceRecognition() {
|
||
if (!SpeechRecognition) {
|
||
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
||
return;
|
||
}
|
||
|
||
if (isRecordingVoice) {
|
||
return;
|
||
}
|
||
|
||
if (!recognition) {
|
||
initSpeechRecognition();
|
||
}
|
||
|
||
const permissionReady = await ensureMicrophonePermission();
|
||
if (!permissionReady || !recognition) {
|
||
return;
|
||
}
|
||
|
||
shouldAutoSend = true;
|
||
|
||
try {
|
||
recognition.start();
|
||
} catch (error) {
|
||
console.error('启动语音识别失败:', error);
|
||
|
||
if (error.name === 'InvalidStateError') {
|
||
try {
|
||
recognition.stop();
|
||
} catch (stopError) {
|
||
console.error('停止语音识别失败:', stopError);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setStatusMessage(`启动语音识别失败:${error.message || error.name}`);
|
||
}
|
||
}
|
||
|
||
function stopVoiceRecognition() {
|
||
if (recognition && isRecordingVoice) {
|
||
recognition.stop();
|
||
}
|
||
}
|
||
|
||
if (!SpeechRecognition) {
|
||
voiceBtn.disabled = true;
|
||
voiceBtn.title = '当前浏览器不支持语音识别';
|
||
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
||
return;
|
||
}
|
||
|
||
if (!hasSecureVoiceContext()) {
|
||
voiceBtn.title = '当前页面不是 HTTPS 或 localhost,浏览器不会授予麦克风权限';
|
||
setStatusMessage(getSecureContextMessage());
|
||
}
|
||
|
||
voiceBtn.addEventListener('pointerdown', async function(event) {
|
||
if (event.pointerType === 'mouse' && event.button !== 0) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
shouldAutoSend = true;
|
||
|
||
if (voiceBtn.setPointerCapture) {
|
||
try {
|
||
voiceBtn.setPointerCapture(event.pointerId);
|
||
} catch (error) {
|
||
console.debug('setPointerCapture skipped:', error);
|
||
}
|
||
}
|
||
|
||
await startVoiceRecognition();
|
||
});
|
||
|
||
voiceBtn.addEventListener('pointerup', function(event) {
|
||
event.preventDefault();
|
||
stopVoiceRecognition();
|
||
});
|
||
|
||
voiceBtn.addEventListener('pointercancel', function() {
|
||
stopVoiceRecognition();
|
||
});
|
||
|
||
voiceBtn.addEventListener('lostpointercapture', function() {
|
||
stopVoiceRecognition();
|
||
});
|
||
|
||
voiceBtn.addEventListener('contextmenu', function(event) {
|
||
event.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('keydown', function(event) {
|
||
if (event.repeat) {
|
||
return;
|
||
}
|
||
|
||
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||
event.preventDefault();
|
||
|
||
if (!spacePressed) {
|
||
spacePressed = true;
|
||
shouldAutoSend = true;
|
||
startVoiceRecognition();
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keyup', function(event) {
|
||
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||
event.preventDefault();
|
||
spacePressed = false;
|
||
stopVoiceRecognition();
|
||
}
|
||
});
|
||
});
|