新增H5长按录音并接入百度语音转文字

This commit is contained in:
2026-10-30 11:09:50 +08:00
parent 99c26a393a
commit 3f1cee8006
18 changed files with 954 additions and 16 deletions
+12
View File
@@ -86,6 +86,14 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer-toolbar { display: flex; gap: 8px; align-items: flex-start; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
.voice-toggle, .voice-hold { display: none; }
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
.voice-panel p { margin: 9px 0; font-size: 13px; }
.voice-panel.is-cancelling { color: var(--danger); border-color: var(--danger); }
.voice-wave { display: block; font-size: 26px; letter-spacing: 7px; }
#voice-clock { font-size: 12px; color: var(--muted); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
@@ -210,6 +218,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.composer { width: calc(100% - 40px); }
.user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
}
@media (max-width: 520px) {
.header-kicker { font-size: 9px; }
+70 -8
View File
@@ -10,6 +10,7 @@ let uploadVersion = 0;
let uploadController = null;
let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
@@ -239,6 +240,7 @@ const I18N = {
copy: "复制",
copied: "已复制",
retry: "重答",
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
@@ -256,6 +258,18 @@ const I18N = {
uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
voiceInput: "语音输入", voiceKeyboard: "键盘输入", voiceHold: "按住说话",
voiceRelease: "松开发送,上滑取消", voicePreparing: "正在准备麦克风…",
voiceCancelTip: "正在录音,松开发送,上滑取消", voiceCancelRelease: "松开取消发送",
voiceTranscribing: "正在转文字…", voiceCancelled: "已取消语音",
voiceUnavailable: "语音输入暂不可用,请使用文字输入。",
voiceUnsupported: "当前浏览器不支持录音,请使用文字输入或在其他浏览器打开。",
voiceHttps: "语音输入需要 HTTPS 安全连接,请使用安全访问地址。",
voicePermission: "未获得麦克风权限,请允许录音后重试。", voiceNoMic: "没有找到麦克风,请使用文字输入。",
voiceTooShort: "按住时间太短,请按住按钮说话后再松开。", voiceTooLarge: "录音过大,请缩短后重试。",
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
attachmentReady: "已准备好,发送后将附在消息中",
removeAttachment: "移除待发送附件",
you: "您",
@@ -336,6 +350,7 @@ const I18N = {
copy: "Copy",
copied: "Copied",
retry: "Regenerate",
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeSub: "Track a parcel, explore freight options, or speak to our team.",
@@ -353,6 +368,20 @@ const I18N = {
uploading: "Uploading your attachment…",
uploadFailed: "Upload failed. Please select the file again.",
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
voiceInput: "Voice input", voiceKeyboard: "Keyboard input", voiceHold: "Hold to speak",
voiceRelease: "Release to send, slide up to cancel", voicePreparing: "Preparing the microphone…",
voiceCancelTip: "Recording · Release to send · Slide up to cancel", voiceCancelRelease: "Release to cancel",
voiceTranscribing: "Converting speech to text…", voiceCancelled: "Voice input cancelled",
voiceUnavailable: "Voice input is unavailable. Please type your message.",
voiceUnsupported: "This browser cannot record audio. Please type or open another browser.",
voiceHttps: "Voice input requires a secure HTTPS connection.",
voicePermission: "Microphone permission is required. Please allow recording and try again.",
voiceNoMic: "No microphone found. Please type your message.",
voiceTooShort: "Hold the button while speaking, then release.", voiceTooLarge: "The recording is too large. Please record a shorter message.",
voiceEmpty: "No speech was recognized. Please try again closer to the microphone.",
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
voiceWait: "Please wait for the current operation to finish before recording.",
voiceDraft: "Please send or clear your existing draft before recording.",
attachmentReady: "Ready to attach to your next message",
removeAttachment: "Remove pending attachment",
you: "You",
@@ -428,6 +457,7 @@ function applyLanguage(lang){
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
if(voiceInput) voiceInput.refresh();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -788,6 +818,7 @@ function ensureConv(){
}
function resetConversation(id){
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
conversationVersion += 1;
@@ -1083,6 +1114,7 @@ async function humanRequest(path, data){
}
window.showHuman = async function(){
if(voiceInput) voiceInput.cancel();
if(openingConv === 'clearing') return;
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
@@ -1152,10 +1184,12 @@ async function sendHumanMessage(content){
if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
inputDom.focus();
return true;
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
return false;
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
@@ -1348,6 +1382,14 @@ function mdToPlain(md){
.replace(/“|”/g, '"');
}
async function retryAnswer(bubble, question){
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
if(await doSend(question)){
const row = bubble.closest('.msg-row');
if(row) row.remove();
}
}
// 给一条回答挂上「复制 / 重答」按钮
function attachActions(bubble, rawText, question, showRetry){
const old = bubble.querySelector(".msg-actions");
@@ -1390,11 +1432,7 @@ function attachActions(bubble, rawText, question, showRetry){
retryBtn.className = "msg-action-btn";
retryBtn.dataset.action = "retry";
retryBtn.textContent = t("retry");
retryBtn.onclick = function(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(question); // 传字符串 = 只重发,不重复插入提问气泡
};
retryBtn.onclick = function(){ return retryAnswer(bubble, question); };
bar.appendChild(retryBtn);
}
@@ -1443,6 +1481,7 @@ function renderFollowups(bubble){
}
async function doSend(retryText){
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
var imgUrl = isRetry ? "" : (window.__pendingImg || "");
@@ -1540,9 +1579,7 @@ async function doSend(retryText){
setBusy(false);
if(activeBubble === aiBubble) activeBubble = null;
renderError(aiBubble, text || t("busyRetry"), function(){
const row = aiBubble.closest(".msg-row");
if(row) row.remove();
doSend(fullContent);
return retryAnswer(aiBubble, fullContent);
});
}
@@ -1672,6 +1709,7 @@ async function doSend(retryText){
if(settled) return;
fail(t("networkFail"));
});
return true;
}
// ===== 发送 / 停止 按钮状态 =====
@@ -1792,6 +1830,30 @@ window.addEventListener("resize", function(){
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
syncChatViewport();
voiceInput = ChatVoice.create({
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
text:t, status:setComposerStatus, language:() => LANG,
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
canStart:function(){
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
return inputDom.value.trim() ? t('voiceDraft') : '';
},
onText:async function(text){
if(inputDom.value.trim()){
inputDom.value += '\n' + text;
autoGrow(); voiceInput.setMode(false); return;
}
inputDom.value = text;
autoGrow();
if(!await doSend()) voiceInput.setMode(false);
}
});
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]);
+219
View File
@@ -0,0 +1,219 @@
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
var ChatVoice = {
create(options){
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
}
function clearTimers(current){
clearTimeout(current.limitTimer);
clearTimeout(current.permissionTimer);
clearInterval(current.clockTimer);
}
function render(){
toggle.setAttribute('aria-pressed', String(mode));
toggle.setAttribute('aria-label', text(mode ? 'voiceKeyboard' : 'voiceInput'));
toggle.title = text(mode ? 'voiceKeyboard' : 'voiceInput');
toggle.textContent = mode ? '⌨' : '🎙';
inputArea.classList.toggle('voice-mode', mode);
hold.textContent = text(session ? 'voiceRelease' : 'voiceHold');
hold.classList.toggle('is-recording', Boolean(session));
panel.hidden = !session;
}
function cancel(message){
const current = session;
session = null;
if(current){
current.controller.abort();
clearTimers(current);
if(current.recorder && current.recorder.state !== 'inactive'){
try{ current.recorder.stop(); }catch(error){}
}
stopTracks(current);
}
render();
if(message) status(text(message), true);
}
function setMode(on){
cancel();
mode = on;
render();
if(on && input.blur) input.blur();
}
function supported(){
if(window.isSecureContext === false){ status(text('voiceHttps'), true); return false; }
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder){
status(text('voiceUnsupported'), true);
return false;
}
return true;
}
async function jsonRequest(current, url, init, timeout){
const timer = setTimeout(function(){ current.timedOut = true; current.controller.abort(); }, timeout);
try{
const response = await fetch(url, Object.assign({}, init, {signal:current.controller.signal}));
const data = await response.json();
if(!response.ok) throw new Error(data.error || data.e || text('voiceFailed'));
return data;
}finally{ clearTimeout(timer); }
}
async function transcribe(current){
if(!active(current)) return;
if(!current.chunks.length){ cancel('voiceEmpty'); return; }
current.phase = 'transcribing';
label.textContent = text('voiceTranscribing');
const blob = new Blob(current.chunks, {type:current.recorder.mimeType || current.mime});
if(!blob.size || blob.size > current.maxBytes){ cancel('voiceTooLarge'); return; }
const form = new FormData();
form.append('audio', blob, 'recording');
form.append('duration', String(Math.min(60, current.duration / 1000)));
form.append('language', options.language());
try{
const data = await jsonRequest(current, '/api/speech/transcribe', {method:'POST',body:form}, 40000);
if(!active(current)) return;
if(current.context !== options.context()){ cancel(); return; }
if(!data.ok || typeof data.text !== 'string' || !data.text.trim()){
cancel('voiceEmpty'); return;
}
session = null;
render();
await options.onText(data.text.trim());
}catch(error){
if(!active(current)) return;
const message = current.timedOut ? text('voiceTimeout') : error.message || text('voiceFailed');
cancel();
status(message, true);
}
}
function finish(current, cancelled){
if(!active(current) || !current.holding) return;
current.holding = false;
if(cancelled || current.cancelled){ cancel(); status(text('voiceCancelled')); return; }
if(current.phase !== 'recording'){ cancel('voiceTooShort'); return; }
current.duration = Date.now() - current.startedAt;
if(current.duration < 400){ cancel('voiceTooShort'); return; }
current.phase = 'stopping';
label.textContent = text('voiceTranscribing');
clearTimers(current);
try{ current.recorder.stop(); }
catch(error){ cancel('voiceFailed'); }
finally{ stopTracks(current); }
}
async function start(id, y){
if(session || !mode) return;
const reason = options.canStart();
if(reason){ status(reason, true); return; }
if(!supported()) return;
const current = {id:id,startY:y,holding:true,chunks:[],bytes:0,phase:'preparing',
controller:new AbortController(),context:options.context()};
session = current;
label.textContent = text('voicePreparing');
clock.textContent = '';
panel.classList.remove('is-cancelling');
status('');
render();
try{
const config = await jsonRequest(current, '/api/speech/config', {}, 5000);
if(!active(current)) return;
if(!config.enabled){ cancel('voiceUnavailable'); return; }
current.maxBytes = Math.min(Number(config.max_bytes) || 5242880, 5242880);
current.permissionTimer = setTimeout(function(){ if(active(current)) cancel('voiceTimeout'); }, 15000);
const stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true},video:false});
clearTimeout(current.permissionTimer);
if(!active(current)){ stream.getTracks().forEach(track => track.stop()); return; }
current.stream = stream;
const formats = ['audio/webm;codecs=opus','audio/mp4','audio/webm','audio/ogg;codecs=opus'];
current.mime = window.MediaRecorder.isTypeSupported
? formats.find(mime => window.MediaRecorder.isTypeSupported(mime)) || '' : '';
current.recorder = new window.MediaRecorder(stream, Object.assign({audioBitsPerSecond:64000},
current.mime ? {mimeType:current.mime} : {}));
current.recorder.ondataavailable = function(event){
if(!active(current) || !event.data || !event.data.size) return;
current.bytes += event.data.size;
if(current.bytes > current.maxBytes){ cancel('voiceTooLarge'); return; }
current.chunks.push(event.data);
};
current.recorder.onerror = function(){ if(active(current)) cancel('voiceFailed'); };
current.recorder.onstop = function(){
if(!active(current)) return;
if(current.phase === 'stopping') transcribe(current);
else cancel('voiceFailed');
};
current.recorder.start(250);
current.startedAt = Date.now();
current.phase = 'recording';
label.textContent = text('voiceCancelTip');
clock.textContent = '0 / 60 s';
current.clockTimer = setInterval(function(){
clock.textContent = Math.min(60, Math.floor((Date.now()-current.startedAt)/1000)) + ' / 60 s';
}, 250);
current.limitTimer = setTimeout(function(){ finish(current, false); }, 60000);
}catch(error){
if(!active(current)) return;
const key = current.timedOut ? 'voiceTimeout'
: ['NotAllowedError','PermissionDeniedError'].includes(error.name) ? 'voicePermission'
: ['NotFoundError','DevicesNotFoundError'].includes(error.name) ? 'voiceNoMic' : 'voiceFailed';
cancel(key);
}
}
function move(id, y){
if(!session || !session.holding || session.id !== id) return;
session.cancelled = session.startY - y > 72;
panel.classList.toggle('is-cancelling', session.cancelled);
if(session.phase === 'recording') label.textContent = text(session.cancelled ? 'voiceCancelRelease' : 'voiceCancelTip');
}
toggle.onclick = function(){
if(!mode && !supported()) return;
setMode(!mode);
if(!mode) input.focus();
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
event.preventDefault();
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
for(const touch of event.changedTouches) move(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
};