feat(web): 语音输入手势升级:触屏按住说话 + 桌面 Enter/Esc 收尾 + 波浪反馈(bump 0.48.0)

- 触屏(微信/DeepSeek 式):按住录音,松开转写,上滑 60px 松开取消;pointer capture + 合成 click 压制
- 桌面:点击开始,说完 Enter 结束转写 / Esc 取消(capture 拦截防触发发送);不做 VAD 静音自动停
  (口述需求句间停顿 3~5s 常态,自动截断更伤;业界听写类全是手动收尾)
- 录音面板替换 textarea:实时波浪(采集 chunk 顺手算 RMS)+ 计时 + 手势提示;iOS 手势内 resume
- 纯前端(chat.js + dev.html),后端零改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 15:03:47 +08:00
parent a36a8f1da4
commit e911eb2c6b
4 changed files with 120 additions and 15 deletions

View File

@ -23,6 +23,7 @@
### 2026-07
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
- **07-07 / 0.47.0**:web 端语音输入(讯飞语音听写 IAT)——聊天输入区新增「🎙 语音」按钮:点击录音(Web Audio ScriptProcessor 采 PCM → 前端线性插值降采样 16k/16bit/mono)→ 再点停止 → `POST /v1/asr/transcribe`(裸 PCM body,require_user)→ 后端 `core/asr_xfyun.py` 连讯飞 wss(HMAC-SHA256 签名,9600B/帧 40ms 节奏,vad_eos=10000 防句间停顿截尾,未开 dwa 整段拼接)→ 文本 insertText 插输入框光标处(接原生 undo 栈,**不自动发送**)。60s 上限自动停(讯飞单会话限制);录音需 HTTPS/localhost;渠道只读镜像 task 按钮锁定;`XFYUN_APPID/API_KEY/API_SECRET` env 未配返 501(**当前 .env 为测试 key,待换正式**)。诊断 `scripts/diag_asr.py`(静音冒烟 / 带 PCM 文件真测);requirements 显式加 `websockets`。已实测:TTS 合成中文 5.2s 音频经端点转写逐字正确。企微语音消息(AMR 需 ffmpeg 转码)留待二期;将来流式逐字上屏可复用 `build_auth_url` 签临时 URL。
- **07-07 / 0.46.2**:消息分页窗口对齐轮次边界(纯前端,`chat.js`)——原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间(顶部出现孤儿 tool 消息),点目录圆点用 `before_idx=idx+11` 启发式(长轮拉不全、短轮混无关上文)。业界调研确认:分页单位保持消息条数是共识(Discord/OpenAI 均按条 + 每页上限,轮次大小无上界不能当传输单位),轮次归组属渲染层职责。实现:idx 连续 + outline 已含全部 user 轮次起点 → 前端可精确算区间,后端 API 零改动。① 向上翻页 `alignedEarlierLimit`:从窗口顶往前按整轮累加攒够 30 条,新窗口顶恒为 user 消息;② 圆点跳转:`after_idx=起点-1` + 相邻边界差精确拉整轮(短轮补到 30 铺满、超大轮按 `MSG_FETCH_MAX=100` 截断,后续向上加载逐步自愈对齐);③ outline 未加载/idx 不在 outline → 回退旧按条行为。后端 limit 无 clamp,前端以 100 硬上限自律。
- **07-07 / 0.46.1**:撤除企微欢迎推送机制——实际部署采用聊天优先布局(不配应用主页 + 菜单「工作台」按钮)后,点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎推送成了死重:删 `wecom_welcomes` 表(migration 0021 撤下,DB 已 downgrade 回 0020,表建后无一行写入)、`maybe_send_wecom_welcome`、免登回调欢迎钩子、群发脚本、`list_visible_userids`。**保留**:未绑定入站回绑定指引(聊天优先下必需)、`core/branding.py` 品牌单一源。RUN.md 企微段只留推荐布局 + 未绑定指引两条。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.47.0"
__version__ = "0.48.0"

View File

@ -890,9 +890,20 @@
#chat-form textarea { flex: 1; }
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
/* 语音输入按钮:录音中红色呼吸提醒(别忘了停) */
/* 语音输入按钮:录音中红色呼吸提醒(别忘了停);触屏按住说话要禁掉长按选中/滚动 */
#chat-mic { touch-action: none; -webkit-user-select: none; user-select: none; }
#chat-mic.recording { color: #fff; background: #d33; border-color: #d33; animation: mic-pulse 1.6s ease-in-out infinite; }
@keyframes mic-pulse { 50% { opacity: .65; } }
/* 录音面板:录音期替换 textarea(波浪 + 计时 + 手势提示);上滑取消预备时整体变红 */
#voice-rec-panel { display: none; align-items: center; gap: 10px; height: 72px; padding: 0 12px;
border: 1px solid #d33; border-radius: 6px; background: #fff5f5; }
#chat-form.recording #voice-rec-panel { display: flex; }
#chat-form.recording #chat-input { display: none; }
#voice-rec-panel .vr-time { font-variant-numeric: tabular-nums; font-weight: 600; color: #d33; }
#voice-rec-panel canvas { flex: 1; height: 44px; min-width: 0; }
#voice-rec-panel .vr-hint { font-size: 11px; color: var(--muted); white-space: nowrap; }
#voice-rec-panel.cancel-armed { background: #d33; border-color: #b22; }
#voice-rec-panel.cancel-armed .vr-time, #voice-rec-panel.cancel-armed .vr-hint { color: #fff; }
#chat-form .hint { font-size: 11px; color: var(--muted); }
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
@ -1562,6 +1573,11 @@
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
<div id="msg-outline-rail" style="display:none;"></div>
<form id="chat-form" style="display:none;">
<div id="voice-rec-panel">
<span class="vr-time" id="vr-time">0:00</span>
<canvas id="vr-wave"></canvas>
<span class="vr-hint" id="vr-hint"></span>
</div>
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea>
<span id="chat-attach"></span>
<div class="row">

View File

@ -1468,12 +1468,20 @@ async function optimizePrompt() {
$("chat-optimize").onclick = optimizePrompt;
// ── 语音输入(讯飞 IAT 整段转写)──────────────────────────────────────────────
// 点击开始录音(Web Audio 采 PCM → 降采样 16k/16bit)→ 再点停止 → POST
// 采集:Web Audio ScriptProcessor 采 PCM → 停止后降采样 16k/16bit → POST
// /v1/asr/transcribe → 文本插到输入框光标处(insertText 接原生 undo 栈,不自动
// 发送,用户确认后再发)。录音需安全上下文(HTTPS / localhost);60s 自动停
// (讯飞单会话上限)。
// 发送)。两种手势共用同一条采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
// - 桌面(鼠标点击):点开始 → 说完 Enter / 再点一次结束并转写,Esc 取消。
// 注:不做 VAD 静音自动停 —— 口述任务需求时"边想边说"停顿 3~5s 是常态,自动截断
// 比多按一下更伤;业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾。
// - 触屏(按住说话):按住录音,松开转写,上滑超过阈值松开取消(微信 / DeepSeek 式)。
// 录音期 #voice-rec-panel 替换 textarea:实时波浪(录音 chunk 顺手算 RMS,零额外
// 采集开销)+ 计时 + 手势提示。
const ASR_MAX_SECONDS = 60;
let _voice = null; // {stream, ctx, source, node, chunks, rate, t0, timer} — 非空 = 录音中
const HOLD_CANCEL_PX = 60; // 按住说话:上滑多远进入"松开取消"
let _voice = null; // 录音态:{stream,ctx,source,node,chunks,levels,rate,t0,timer,raf,mode,startY,cancelArmed}
let _holdReleased = false; // 手指在 getUserMedia 授权完成前已松开 → 启动完成后立即收尾
let _suppressClickUntil = 0; // touch 序列结束后浏览器补发的合成 click,不当 toggle 用
// 线性插值降采样到 16k 并量化 Int16 小端(讯飞只收 16k/16bit/mono raw)
function _pcm16kFromChunks(chunks, srcRate) {
@ -1492,7 +1500,25 @@ function _pcm16kFromChunks(chunks, srcRate) {
return out;
}
async function startVoiceInput() {
function _vrHint(text) { $("vr-hint").textContent = text; }
function _drawWave() {
if (!_voice) return;
const cv = $("vr-wave"), g = cv.getContext("2d");
const W = cv.width, H = cv.height, dpr = window.devicePixelRatio || 1;
g.clearRect(0, 0, W, H);
g.fillStyle = _voice.cancelArmed ? "#fff" : "#d33";
const bw = 3 * dpr, gap = 2 * dpr;
const lv = _voice.levels.slice(-Math.floor(W / (bw + gap)));
for (let i = 0; i < lv.length; i++) {
const h = Math.max(2 * dpr, Math.min(1, lv[i] * 6) * H);
g.fillRect(W - (lv.length - i) * (bw + gap), (H - h) / 2, bw, h);
}
_voice.raf = requestAnimationFrame(_drawWave);
}
async function startVoiceInput(mode, startY) {
if (_voice) return;
const btn = $("chat-mic");
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
$("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
@ -1509,37 +1535,60 @@ async function startVoiceInput() {
return;
}
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {} // iOS Safari:手势内 resume 才真正开跑
const source = ctx.createMediaStreamSource(stream);
// ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = (ev) => { chunks.push(new Float32Array(ev.inputBuffer.getChannelData(0))); };
const chunks = [], levels = [];
node.onaudioprocess = (ev) => {
const buf = new Float32Array(ev.inputBuffer.getChannelData(0));
chunks.push(buf);
let sum = 0, n = 0;
for (let i = 0; i < buf.length; i += 8) { sum += buf[i] * buf[i]; n++; } // 1/8 抽样够画波浪
levels.push(Math.sqrt(sum / n));
};
source.connect(node);
node.connect(ctx.destination); // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声
_voice = { stream, ctx, source, node, chunks, rate: ctx.sampleRate, t0: Date.now(), timer: 0 };
_voice = { stream, ctx, source, node, chunks, levels, rate: ctx.sampleRate,
t0: Date.now(), timer: 0, raf: 0, mode, startY: startY || 0, cancelArmed: false };
$("chat-form").classList.add("recording");
$("voice-rec-panel").classList.remove("cancel-armed");
const cv = $("vr-wave"), dpr = window.devicePixelRatio || 1;
cv.width = Math.max(1, cv.clientWidth * dpr); // 面板刚显示,读 clientWidth 强制同步布局
cv.height = Math.max(1, cv.clientHeight * dpr);
_vrHint(mode === "hold" ? "松开转写 · 上滑取消" : "说完按 Enter 结束 · Esc 取消");
btn.classList.add("recording");
if (mode === "click") btn.textContent = "■ 停止";
const tick = () => {
if (!_voice) return;
const sec = Math.floor((Date.now() - _voice.t0) / 1000);
btn.textContent = `■ 停止 ${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(); return; }
$("vr-time").textContent = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(false); return; }
_voice.timer = setTimeout(tick, 500);
};
tick();
$("chat-hint").textContent = "录音中…再点一次结束并转写(最长 60 秒)";
_drawWave();
$("chat-hint").textContent = "录音中…";
// 按住说话:授权弹窗期间手指已松开 → 启动完成立即按"松开"收尾(通常太短走取消)
if (mode === "hold" && _holdReleased) stopVoiceInput(_voice.cancelArmed);
}
async function stopVoiceInput() {
async function stopVoiceInput(discard) {
const v = _voice;
if (!v) return;
_voice = null;
clearTimeout(v.timer);
cancelAnimationFrame(v.raf);
const btn = $("chat-mic");
btn.classList.remove("recording");
btn.textContent = "🎙 语音";
$("chat-form").classList.remove("recording");
$("voice-rec-panel").classList.remove("cancel-armed");
$("vr-time").textContent = "0:00";
try { v.source.disconnect(); v.node.disconnect(); } catch (e) {}
v.stream.getTracks().forEach((t) => t.stop());
try { await v.ctx.close(); } catch (e) {}
if (discard) { $("chat-hint").textContent = "已取消"; return; }
const pcm = _pcm16kFromChunks(v.chunks, v.rate);
if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触
$("chat-hint").textContent = "录音太短,已取消";
@ -1572,7 +1621,46 @@ async function stopVoiceInput() {
}
}
$("chat-mic").onclick = () => { if (_voice) stopVoiceInput(); else startVoiceInput(); };
// 手势接线:触屏(pointerType touch/pen)按住说话,鼠标走 click 开/停。触屏松手后
// 浏览器会补发合成 click,用时间窗压掉,避免"松开转写完又立刻开始新录音"。
const _micBtn = $("chat-mic");
_micBtn.addEventListener("pointerdown", (e) => {
if (e.pointerType === "mouse") return;
e.preventDefault(); // 压掉长按选中 / 页面滚动(按钮 CSS 已 touch-action:none)
if (_voice || _micBtn.disabled) return;
_holdReleased = false;
try { _micBtn.setPointerCapture(e.pointerId); } catch (err) {} // 手指滑出按钮仍收 move/up
startVoiceInput("hold", e.clientY);
});
_micBtn.addEventListener("pointermove", (e) => {
if (e.pointerType === "mouse" || !_voice || _voice.mode !== "hold") return;
const armed = (_voice.startY - e.clientY) > HOLD_CANCEL_PX;
if (armed !== _voice.cancelArmed) {
_voice.cancelArmed = armed;
$("voice-rec-panel").classList.toggle("cancel-armed", armed);
_vrHint(armed ? "松开取消" : "松开转写 · 上滑取消");
}
});
const _holdEnd = (e) => {
if (e.pointerType === "mouse") return;
_suppressClickUntil = Date.now() + 700;
_holdReleased = true;
if (_voice && _voice.mode === "hold") stopVoiceInput(_voice.cancelArmed || e.type === "pointercancel");
};
_micBtn.addEventListener("pointerup", _holdEnd);
_micBtn.addEventListener("pointercancel", _holdEnd);
_micBtn.addEventListener("contextmenu", (e) => e.preventDefault()); // 长按不出系统菜单
_micBtn.addEventListener("click", () => {
if (Date.now() < _suppressClickUntil) return;
if (_voice) { if (_voice.mode === "click") stopVoiceInput(false); }
else startVoiceInput("click");
});
// 桌面录音收尾快捷键(capture 拦截,防 Enter 落回 textarea 触发发消息)
document.addEventListener("keydown", (e) => {
if (!_voice || _voice.mode !== "click") return;
if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(false); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(true); }
}, true);
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —