新增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
+10
View File
@@ -5,3 +5,13 @@
DEEPSEEK_API_KEY=
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
ASR_PROVIDER=baidu
BAIDU_ASR_API_KEY=
BAIDU_ASR_SECRET_KEY=
# Optional absolute ffmpeg executable path. Empty = find ffmpeg on PATH. Docker includes it.
FFMPEG_BIN=
# Optional alternative: ASR_PROVIDER=dashscope, with a separate Alibaba Cloud Model Studio key.
ASR_API_KEY=
ASR_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
ASR_MODEL=qwen3-asr-flash
+2 -1
View File
@@ -7,6 +7,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Area | Files and search anchors |
|---|---|
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
@@ -52,7 +53,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs` and `chat.test.cjs` when Node.js is available; no npm dependencies.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, and `voice.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines
+2
View File
@@ -6,6 +6,8 @@ ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt ./
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN python -m pip install --no-cache-dir -r requirements.txt \
&& groupadd --gid 10001 baiyue \
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin baiyue \
+4
View File
@@ -107,6 +107,10 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### 配置说明
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
+1
View File
@@ -115,6 +115,7 @@ tests/ # 按业务模块组织的回归
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
+2
View File
@@ -11,9 +11,11 @@ def create_app(config=None):
from .config import ROOT_DIR, default_config
from . import db, file_service, hooks
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
static_folder=str(ROOT_DIR/'static'))
app.request_class = MemoryAudioRequest
app.config.update(default_config())
if config:
app.config.update(config)
+8
View File
@@ -20,6 +20,14 @@ def default_config():
'BACKUP_DIR': ROOT_DIR / 'backups',
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
'ASR_PROVIDER': str(os.environ.get('ASR_PROVIDER', local_config.get('ASR_PROVIDER')) or 'baidu').strip().lower(),
'BAIDU_ASR_API_KEY': str(os.environ.get('BAIDU_ASR_API_KEY', local_config.get('BAIDU_ASR_API_KEY')) or '').strip(),
'BAIDU_ASR_SECRET_KEY': str(os.environ.get('BAIDU_ASR_SECRET_KEY', local_config.get('BAIDU_ASR_SECRET_KEY')) or '').strip(),
'FFMPEG_BIN': str(os.environ.get('FFMPEG_BIN', local_config.get('FFMPEG_BIN')) or '').strip(),
'ASR_URL': str(os.environ.get('ASR_URL', local_config.get('ASR_URL')) or
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
'MODEL_NAME': 'deepseek-chat',
+2 -2
View File
@@ -2,6 +2,6 @@
def register_routes(app):
from . import admin, business, chat, files, history, human, pages
for module in (admin, business, chat, files, history, human, pages):
from . import admin, business, chat, files, history, human, pages, speech
for module in (admin, business, chat, files, history, human, pages, speech):
app.register_blueprint(module.bp)
+47
View File
@@ -0,0 +1,47 @@
"""HTTP adapters for short voice input; no audio is stored or added to history."""
from io import BytesIO
from flask import Blueprint, Request, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import speech_service
bp = Blueprint('speech', __name__)
class MemoryAudioRequest(Request):
def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None):
if self.path == '/api/speech/transcribe':
return BytesIO()
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
@bp.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
@bp.get('/api/speech/config')
def speech_config():
return {'enabled': speech_service.speech_enabled(), 'max_seconds': speech_service.MAX_DURATION_SECONDS,
'max_bytes': speech_service.MAX_AUDIO_BYTES}, {'Cache-Control': 'no-store'}
@bp.post('/api/speech/transcribe')
def speech_transcribe():
request.max_content_length = speech_service.MAX_AUDIO_BYTES + 64 * 1024
if not speech_service.speech_enabled():
return {'ok': False, 'error': speech_service.UNAVAILABLE}, 503
try:
files = request.files
try:
return speech_service.transcribe_recording(files.get('audio'), request.form.get('duration'),
request.form.get('language'))
finally:
for _, upload in files.items(multi=True):
upload.close()
except RequestEntityTooLarge:
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
+174
View File
@@ -0,0 +1,174 @@
"""Bounded, in-memory recording transcription; separate from text chat credentials."""
import base64
import json
import math
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from urllib.parse import urlsplit
import requests
from flask import current_app
from .config import get_setting
MAX_AUDIO_BYTES = 5 * 1024 * 1024
MAX_DURATION_SECONDS = 60
UNAVAILABLE = '语音输入暂不可用,请使用文字输入。'
class SpeechFailure(Exception):
def __init__(self, message, status=502):
self.message, self.status = message, status
def ffmpeg_binary():
configured = get_setting('FFMPEG_BIN')
return str(configured) if configured and Path(configured).is_file() else shutil.which('ffmpeg')
def speech_enabled():
provider = get_setting('ASR_PROVIDER')
if provider == 'baidu':
return bool(get_setting('BAIDU_ASR_API_KEY') and get_setting('BAIDU_ASR_SECRET_KEY') and ffmpeg_binary())
if provider != 'dashscope':
return False
try:
url = urlsplit(get_setting('ASR_URL'))
except ValueError:
return False
return bool(get_setting('ASR_API_KEY') and get_setting('ASR_MODEL')
and url.scheme == 'https' and url.hostname and not url.username and not url.password)
def post_json(url, **options):
response = requests.post(url, timeout=(5, 30), stream=True, allow_redirects=False, **options)
try:
if response.status_code != 200:
raise SpeechFailure('语音转文字失败,请稍后重试。')
data = bytearray()
for chunk in response.iter_content(4096):
data.extend(chunk)
if len(data) > 64 * 1024:
raise SpeechFailure('语音识别结果无效,请重试。')
return json.loads(data)
finally:
response.close()
def baidu_token():
key, secret = get_setting('BAIDU_ASR_API_KEY'), get_setting('BAIDU_ASR_SECRET_KEY')
identity = hashlib.sha256((key + '\0' + secret).encode()).hexdigest()
lock = current_app.extensions.setdefault('speech_token_lock', threading.Lock())
with lock:
cached = current_app.extensions.get('speech_token')
if cached and cached['identity'] == identity and cached['expires'] > time.time():
return cached['token']
data = post_json('https://aip.baidubce.com/oauth/2.0/token', data={
'grant_type':'client_credentials', 'client_id':key, 'client_secret':secret})
token = data.get('access_token')
if not isinstance(token, str) or not token:
raise SpeechFailure(UNAVAILABLE, 503)
current_app.extensions['speech_token'] = {'identity':identity, 'token':token,
'expires':time.time() + max(0, min(float(data.get('expires_in', 0)), 2592000) - 60)}
return token
def to_pcm(content):
"""Decode browser WebM/MP4 in pipes to mono 16 kHz, without temporary audio files."""
binary = ffmpeg_binary()
if not binary:
raise SpeechFailure(UNAVAILABLE, 503)
result = subprocess.run([binary, '-hide_banner', '-loglevel', 'error', '-protocol_whitelist', 'pipe',
'-i', 'pipe:0', '-map', '0:a:0', '-vn', '-sn', '-dn', '-ac', '1', '-ar', '16000',
'-t', '61', '-f', 's16le', 'pipe:1'], input=content, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
if result.returncode != 0 or len(result.stdout) < 12800:
raise SpeechFailure('录音无法识别,请重新录制。', 422)
if len(result.stdout) > 16000 * 2 * MAX_DURATION_SECONDS:
raise SpeechFailure('录音超过 60 秒,请缩短后重试。', 400)
return result.stdout
def baidu_transcribe(content, language):
pcm = to_pcm(content)
result = post_json('https://vop.baidu.com/server_api', json={'format':'pcm', 'rate':16000, 'channel':1,
'cuid':'baiyue-logistics-chat', 'token':baidu_token(), 'dev_pid':1737 if language == 'en' else 1537,
'speech':base64.b64encode(pcm).decode('ascii'), 'len':len(pcm)})
code = result.get('err_no')
if code == 3301:
raise SpeechFailure('没有识别到文字,请靠近麦克风重试。', 422)
if code != 0:
current_app.logger.warning('Baidu ASR request failed: code=%s', code)
raise SpeechFailure(UNAVAILABLE if code == 3302 else '语音转文字失败,请稍后重试。', 503 if code == 3302 else 502)
parts = result.get('result')
if not isinstance(parts, list) or any(not isinstance(part, str) for part in parts):
raise SpeechFailure('语音识别结果无效,请重试。')
return ''.join(parts)
def audio_format(content, mime):
"""Check container signatures, never trust the uploaded filename."""
mime = mime.split(';', 1)[0].lower()
if mime == 'audio/webm' and content.startswith(b'\x1aE\xdf\xa3'):
return 'audio/webm'
if mime in ('audio/mp4', 'audio/x-m4a') and content[4:8] == b'ftyp':
return 'audio/mp4'
if mime in ('audio/ogg', 'audio/opus') and content.startswith(b'OggS'):
return 'audio/ogg'
if mime in ('audio/wav', 'audio/x-wav') and content[:4] == b'RIFF' and content[8:12] == b'WAVE':
return 'audio/wav'
return None
def transcribe_recording(upload, duration, language):
if not speech_enabled():
return {'ok': False, 'error': UNAVAILABLE}, 503
if not upload:
return {'ok': False, 'error': '没有收到录音,请重新按住说话。'}, 400
try:
duration = float(duration)
except (TypeError, ValueError):
duration = 0
if not math.isfinite(duration) or not 0.4 <= duration <= MAX_DURATION_SECONDS + 1:
return {'ok': False, 'error': '录音时长无效,请录制 1 至 60 秒语音。'}, 400
content = upload.read(MAX_AUDIO_BYTES + 1)
if len(content) > MAX_AUDIO_BYTES:
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
mime = audio_format(content, upload.mimetype)
if len(content) < 16 or not mime:
return {'ok': False, 'error': '录音格式不受支持,请使用文字输入或更换浏览器。'}, 415
language = 'en' if language == 'en' else 'zh'
try:
if get_setting('ASR_PROVIDER') == 'baidu':
text = baidu_transcribe(content, language)
else:
body = {'model': get_setting('ASR_MODEL'), 'stream': False,
'messages': [{'role': 'user', 'content': [{'type': 'input_audio', 'input_audio': {
'data': 'data:' + mime + ';base64,' + base64.b64encode(content).decode('ascii')}}]}],
'asr_options': {'language': language, 'enable_itn': True}}
result = post_json(get_setting('ASR_URL'), json=body,
headers={'Authorization': 'Bearer ' + get_setting('ASR_API_KEY')})
text = result['choices'][0]['message']['content']
if not isinstance(text, str) or not text.strip():
return {'ok': False, 'error': '没有识别到文字,请靠近麦克风重试。'}, 422
text = text.strip()
if len(text) > 2000:
return {'ok': False, 'error': '识别文字过长,请缩短录音后重试。'}, 422
return {'ok': True, 'text': text}, 200
except requests.Timeout:
return {'ok': False, 'error': '语音转文字超时,请重试。'}, 504
except SpeechFailure as error:
return {'ok': False, 'error': error.message}, error.status
except subprocess.TimeoutExpired:
return {'ok': False, 'error': '录音处理超时,请缩短后重试。'}, 504
except OSError:
return {'ok': False, 'error': UNAVAILABLE}, 503
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError):
return {'ok': False, 'error': '语音转文字失败,请稍后重试。'}, 502
+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)};
}
};
+5 -2
View File
@@ -50,27 +50,30 @@
</div>
<footer class="composer">
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="hot-bar">
<div class="composer-toolbar"><div class="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
</div>
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
<div class="input-area">
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
<button type="button" id="send-button">发送</button>
</div>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
</main>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+60 -2
View File
@@ -8,6 +8,7 @@ const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
@@ -97,14 +98,18 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'chat.js']){
for(const file of ['support.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
context.ChatVoice.create=options => {voiceOptions=options; return createVoice(options);};
}
}
listeners.DOMContentLoaded();
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons,
return {context, element, calls, hooks, quickButtons, voiceOptions,
resize:async height => {
if(context.window.visualViewport) context.window.visualViewport.height=height;
(windowListeners.resize || []).forEach(callback => callback());
@@ -628,3 +633,56 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
assert.equal(input.value,'');
assert.equal(input.style.height,'');
});
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
for(const human of [false,true]){
const app=await customerPage({human,width:390});
await app.voiceOptions.onText('语音识别的物流问题');
await flush();
const call=app.calls.find(call=>call.url===(human ? '/api/human/send' : '/stream_chat'));
assert.ok(call);
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
assert.equal(app.element('input-text').value,'');
}
});
test('a failed voice send keeps the transcript editable and a newer draft is not auto-submitted', async () => {
const app=await customerPage({human:false,width:390});
app.hooks['/api/new_chat']=()=>Promise.reject(new Error('offline'));
await app.voiceOptions.onText('语音内容');
assert.equal(app.element('input-text').value,'语音内容');
assert.equal(app.element('.input-area').classList.contains('voice-mode'),false);
app.element('input-text').value='新草稿';
await app.voiceOptions.onText('后到的识别文字');
assert.equal(app.element('input-text').value,'新草稿\n后到的识别文字');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a blocked retry retains its previous answer until a replacement can start', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const log=app.element('chatDom'), retry=log.querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
app.hooks['/api/upload']=options=>new Promise((resolve,reject)=>{
options.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'pending.pdf'}];
app.element('upload-input').onchange();await flush();
await app.click(retry);
assert.match(log.textContent,/测试 AI 回复/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
await app.runTimers(15000);
await app.click(retry);await flush();
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
assert.equal(log.querySelectorAll('.msg-action-btn').filter(button=>button.dataset.action==='retry').length,1);
});
test('an AI regeneration is not forwarded to a human session', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
await app.context.window.showHuman();await app.click(retry);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,0);
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});
+162
View File
@@ -0,0 +1,162 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
function voicePage(options={}){
const elements={}, timers=new Map(), docEvents={}, pageEvents={}, calls=[], messages=[], statuses=[];
let now=1000, timerId=0, contextId='conversation-1', recorder, permissionCalls=0, trackStops=0;
class Element {
constructor(){ this.listeners={}; this.attributes={}; this.classes=new Set(); this.value='';
this.classList={toggle:(name,on) => on ? this.classes.add(name) : this.classes.delete(name),remove:name=>this.classes.delete(name)}; }
setAttribute(name,value){this.attributes[name]=value;}
addEventListener(name,callback){this.listeners[name]=callback;}
setPointerCapture(){}
focus(){}
blur(){}
}
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
class Recorder {
constructor(source, settings){ recorder=this; this.state='inactive'; this.mimeType=settings.mimeType || 'audio/mp4'; }
static isTypeSupported(type){return options.mp4 ? type==='audio/mp4' : type.startsWith('audio/webm');}
start(){this.state='recording';}
stop(){this.state='inactive'; this.ondataavailable({data:new Blob(['audio fixture'],{type:this.mimeType})}); this.onstop();}
}
class ClockDate extends Date {static now(){return now;}}
const response = data => ({ok:true,json:async()=>data});
const sandbox=vm.createContext({console,Blob,AbortController,Date:ClockDate,
FormData:class{constructor(){this.values={};}append(key,value){this.values[key]=value;}},
navigator:{mediaDevices:{getUserMedia:async()=>{permissionCalls++; if(options.permission) return options.permission(); return stream;}}},
document:{hidden:false,addEventListener:(name,callback)=>{docEvents[name]=callback;}},
window:{isSecureContext:options.secure!==false,MediaRecorder:options.unsupported ? undefined : Recorder,
PointerEvent:options.touch ? undefined : function(){},addEventListener:(name,callback)=>{pageEvents[name]=callback;}},
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},
clearTimeout:id=>timers.delete(id),setInterval:()=>++timerId,clearInterval(){},
fetch:async(url,init)=>{calls.push({url,init});
if(url==='/api/speech/config') return response({enabled:options.enabled!==false,max_bytes:5242880,max_seconds:60});
if(options.transcribe) return options.transcribe(init);
return response({ok:true,text:'查询订单 FL20261030001'});
},
});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/voice.js'),'utf8'),sandbox);
const voice=sandbox.ChatVoice.create({...elements,text:key=>key,status:(...args)=>statuses.push(args),
language:()=> 'zh',context:()=>contextId,canStart:()=>options.reason || '',onText:async text=>messages.push(text)});
const event=(extra={})=>({button:0,pointerId:7,clientY:300,preventDefault(){},...extra});
return {voice,elements,calls,messages,statuses,stream,
get permissionCalls(){return permissionCalls;},get trackStops(){return trackStops;},get recorder(){return recorder;},
context:value=>{contextId=value;},
open:()=>elements.toggle.onclick(),
down:async()=>{elements.hold.listeners.pointerdown(event());await flush();},
up:async(duration=1000)=>{now+=duration;elements.hold.listeners.pointerup(event());await flush();},
move:y=>elements.hold.listeners.pointermove(event({clientY:y})),
event,docEvents,pageEvents,sandbox,
expire:async delay=>{for(const [id,timer] of [...timers]) if(timer.delay===delay){timers.delete(id);now+=delay;timer.callback();}await flush();},
};
}
test('hold and release transcribes audio and sends text once',async()=>{
const app=voicePage();app.open();await app.down();
assert.equal(app.recorder.state,'recording');
assert.equal(app.elements.panel.hidden,false);
await app.up();
assert.deepEqual(app.messages,['查询订单 FL20261030001']);
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,1);
assert.ok(app.trackStops>0);
assert.equal(app.elements.panel.hidden,true);
await app.up();
assert.equal(app.messages.length,1);
});
test('sliding up cancels without uploading and supports another recording',async()=>{
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
assert.equal(app.messages.length,0);
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,0);
assert.ok(app.trackStops>0);
await app.down();await app.up();
assert.equal(app.messages.length,1);
});
test('short presses and unavailable service do not upload recordings',async()=>{
const app=voicePage();app.open();await app.down();await app.up(50);
assert.equal(app.messages.length,0);
assert.equal(app.statuses.at(-1)[0],'voiceTooShort');
const disabled=voicePage({enabled:false});disabled.open();await disabled.down();
assert.equal(disabled.permissionCalls,0);
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
});
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.up();grant(app.stream);await flush();
assert.equal(app.messages.length,0);
assert.ok(app.trackStops>0);
assert.equal(app.voice.busy(),false);
});
test('switching conversation or explicitly cancelling ignores a late transcript',async()=>{
for(const cancelled of [false,true]){
let resolve;const app=voicePage({transcribe:()=>new Promise(done=>{resolve=done;})});
app.open();await app.down();await app.up();
if(cancelled) app.voice.cancel(); else app.context('conversation-2');
resolve({ok:true,json:async()=>({ok:true,text:'must not enter the new conversation'})});await flush();
assert.equal(app.messages.length,0);
assert.equal(app.voice.busy(),false);
}
});
test('permission denial and unsupported or insecure browsers leave typing available',async()=>{
const denied=voicePage({permission:()=>Promise.reject(Object.assign(new Error(),{name:'NotAllowedError'}))});
denied.open();await denied.down();
assert.equal(denied.voice.busy(),false);
assert.equal(denied.statuses.at(-1)[0],'voicePermission');
for(const options of [{unsupported:true},{secure:false}]){
const app=voicePage(options);app.open();
assert.equal(app.elements.inputArea.classes.has('voice-mode'),false);
assert.equal(app.permissionCalls,0);
}
});
test('pending drafts and busy chat do not request microphone access',async()=>{
const app=voicePage({reason:'wait for current chat'});app.open();await app.down();
assert.equal(app.permissionCalls,0);
assert.equal(app.calls.length,0);
assert.equal(app.statuses.at(-1)[0],'wait for current chat');
});
test('maximum recording length stops the microphone and sends once',async()=>{
const app=voicePage({mp4:true});app.open();await app.down();await app.expire(60000);
assert.equal(app.messages.length,1);
assert.ok(app.trackStops>0);
assert.equal(app.calls.find(call=>call.url==='/api/speech/transcribe').init.body.values.audio.type,'audio/mp4');
await app.up();assert.equal(app.messages.length,1);
});
test('touch fallback and browser cancellation do not submit accidentally',async()=>{
const app=voicePage({touch:true});app.open();
app.elements.hold.listeners.touchstart(app.event({touches:[{identifier:1,clientY:300}]}));await flush();
app.elements.hold.listeners.touchcancel();await flush();
assert.equal(app.messages.length,0);
assert.ok(app.trackStops>0);
});
test('a stalled transcription times out and releases the recorder state',async()=>{
const app=voicePage({transcribe:init=>new Promise((resolve,reject)=>{
init.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
})});app.open();await app.down();await app.up();await app.expire(40000);
assert.equal(app.voice.busy(),false);
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
assert.equal(app.messages.length,0);
});
test('an unanswered microphone permission prompt times out and a late stream is closed',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.expire(15000);
assert.equal(app.voice.busy(),false);
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
grant(app.stream);await flush();
assert.ok(app.trackStops>0);
assert.equal(app.messages.length,0);
});
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+173
View File
@@ -0,0 +1,173 @@
"""Short recordings are validated and sent only to the separately configured ASR."""
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import subprocess
import requests
from logistics_chat import create_app
from logistics_chat.speech_service import MAX_AUDIO_BYTES
class SpeechResponse:
def __init__(self, payload=None, status=200, raw=None):
self.status_code = status
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
self.closed = False
def iter_content(self, size):
yield self.raw
def close(self):
self.closed = True
class SpeechTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
'duration': duration, 'language':'zh', **extra})
self.addCleanup(response.close)
self.addCleanup(response.request.environ['wsgi.input'].close)
return response
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
self.app.config['ASR_API_KEY'] = ''
response = self.client.get('/api/speech/config')
self.assertFalse(response.json['enabled'])
self.assertIn('no-store', response.headers['Cache-Control'])
self.assertNotIn('key', response.get_data(as_text=True).lower())
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording().status_code, 503)
post.assert_not_called()
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
upstream = SpeechResponse()
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
response = self.recording()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
url = post.call_args.args[0]
options = post.call_args.kwargs
self.assertEqual(url, self.app.config['ASR_URL'])
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
self.assertEqual(options['json']['model'], 'test-asr')
self.assertEqual(options['json']['asr_options']['language'], 'zh')
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
self.assertFalse(options['allow_redirects'])
self.assertTrue(upstream.closed)
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
for content, mime in samples:
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
self.assertEqual(self.recording(content, mime).status_code, 200)
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
with self.subTest(duration=duration):
self.assertEqual(self.recording(duration=duration).status_code, 400)
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
post.assert_not_called()
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
self.assertEqual(response.status_code, 413)
self.assertFalse(response.json['ok'])
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
SpeechResponse(raw=b'x' * 65537)):
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
response = self.recording()
self.assertEqual(response.status_code, 502)
self.assertNotIn('secret', response.get_data(as_text=True))
self.assertTrue(upstream.closed)
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
self.assertEqual(self.recording().status_code, 504)
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
for text in ('', ' ', '字' * 2001):
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
self.assertEqual(self.recording().status_code, 422)
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
for _ in range(2):
response=self.recording()
self.assertEqual(response.status_code,200)
self.assertEqual(response.json['text'],'查询订单。')
self.assertEqual(post.call_count,3)
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
for call in post.call_args_list[1:]:
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
self.assertEqual(call.kwargs['json']['format'],'pcm')
self.assertEqual(call.kwargs['json']['rate'],16000)
self.assertEqual(call.kwargs['json']['channel'],1)
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
self.assertTrue(all(response.closed for response in responses))
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=[
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
response=self.recording()
self.assertEqual(response.status_code,503)
self.assertNotIn('private',response.get_data(as_text=True))
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.requests.post') as post:
for result,status in cases:
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
self.assertEqual(self.recording(duration='1').status_code,status)
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
self.assertEqual(self.recording().status_code,504)
post.assert_not_called()
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
from logistics_chat import speech_service
original=speech_service.transcribe_recording
streams=[]
def inspect(upload,*args):
self.assertIsInstance(upload.stream,io.BytesIO)
streams.append(upload.stream)
return original(upload,*args)
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
self.assertTrue(streams[0].closed)