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:
parent
a36a8f1da4
commit
e911eb2c6b
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 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.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.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 企微段只留推荐布局 + 未绑定指引两条。
|
- **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 企微段只留推荐布局 + 未绑定指引两条。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.47.0"
|
__version__ = "0.48.0"
|
||||||
|
|
|
||||||
|
|
@ -890,9 +890,20 @@
|
||||||
#chat-form textarea { flex: 1; }
|
#chat-form textarea { flex: 1; }
|
||||||
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
|
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
|
||||||
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
|
#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; }
|
#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; } }
|
@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); }
|
#chat-form .hint { font-size: 11px; color: var(--muted); }
|
||||||
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
|
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
|
||||||
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
|
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
|
||||||
|
|
@ -1562,6 +1573,11 @@
|
||||||
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
|
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
|
||||||
<div id="msg-outline-rail" style="display:none;"></div>
|
<div id="msg-outline-rail" style="display:none;"></div>
|
||||||
<form id="chat-form" style="display:none;">
|
<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>
|
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea>
|
||||||
<span id="chat-attach"></span>
|
<span id="chat-attach"></span>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
||||||
|
|
@ -1468,12 +1468,20 @@ async function optimizePrompt() {
|
||||||
$("chat-optimize").onclick = optimizePrompt;
|
$("chat-optimize").onclick = optimizePrompt;
|
||||||
|
|
||||||
// ── 语音输入(讯飞 IAT 整段转写)──────────────────────────────────────────────
|
// ── 语音输入(讯飞 IAT 整段转写)──────────────────────────────────────────────
|
||||||
// 点击开始录音(Web Audio 采 PCM → 降采样 16k/16bit)→ 再点停止 → POST
|
// 采集:Web Audio ScriptProcessor 采 PCM → 停止后降采样 16k/16bit → POST
|
||||||
// /v1/asr/transcribe → 文本插到输入框光标处(insertText 接原生 undo 栈,不自动
|
// /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;
|
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)
|
// 线性插值降采样到 16k 并量化 Int16 小端(讯飞只收 16k/16bit/mono raw)
|
||||||
function _pcm16kFromChunks(chunks, srcRate) {
|
function _pcm16kFromChunks(chunks, srcRate) {
|
||||||
|
|
@ -1492,7 +1500,25 @@ function _pcm16kFromChunks(chunks, srcRate) {
|
||||||
return out;
|
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");
|
const btn = $("chat-mic");
|
||||||
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
|
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
|
||||||
$("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
|
$("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
|
||||||
|
|
@ -1509,37 +1535,60 @@ async function startVoiceInput() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
try { await ctx.resume(); } catch (e) {} // iOS Safari:手势内 resume 才真正开跑
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
// ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
|
// ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
|
||||||
const node = ctx.createScriptProcessor(4096, 1, 1);
|
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||||
const chunks = [];
|
const chunks = [], levels = [];
|
||||||
node.onaudioprocess = (ev) => { chunks.push(new Float32Array(ev.inputBuffer.getChannelData(0))); };
|
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);
|
source.connect(node);
|
||||||
node.connect(ctx.destination); // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声
|
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");
|
btn.classList.add("recording");
|
||||||
|
if (mode === "click") btn.textContent = "■ 停止";
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!_voice) return;
|
if (!_voice) return;
|
||||||
const sec = Math.floor((Date.now() - _voice.t0) / 1000);
|
const sec = Math.floor((Date.now() - _voice.t0) / 1000);
|
||||||
btn.textContent = `■ 停止 ${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
$("vr-time").textContent = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
||||||
if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(); return; }
|
if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(false); return; }
|
||||||
_voice.timer = setTimeout(tick, 500);
|
_voice.timer = setTimeout(tick, 500);
|
||||||
};
|
};
|
||||||
tick();
|
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;
|
const v = _voice;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
_voice = null;
|
_voice = null;
|
||||||
clearTimeout(v.timer);
|
clearTimeout(v.timer);
|
||||||
|
cancelAnimationFrame(v.raf);
|
||||||
const btn = $("chat-mic");
|
const btn = $("chat-mic");
|
||||||
btn.classList.remove("recording");
|
btn.classList.remove("recording");
|
||||||
btn.textContent = "🎙 语音";
|
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) {}
|
try { v.source.disconnect(); v.node.disconnect(); } catch (e) {}
|
||||||
v.stream.getTracks().forEach((t) => t.stop());
|
v.stream.getTracks().forEach((t) => t.stop());
|
||||||
try { await v.ctx.close(); } catch (e) {}
|
try { await v.ctx.close(); } catch (e) {}
|
||||||
|
if (discard) { $("chat-hint").textContent = "已取消"; return; }
|
||||||
const pcm = _pcm16kFromChunks(v.chunks, v.rate);
|
const pcm = _pcm16kFromChunks(v.chunks, v.rate);
|
||||||
if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触
|
if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触
|
||||||
$("chat-hint").textContent = "录音太短,已取消";
|
$("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 内自带"下载")。
|
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
|
||||||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue