Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ export function shouldFallbackToServerAsr(errorMessage: string): boolean {
|
||||
/** Whether voice input may work in this browser (mic and/or live speech). */
|
||||
export function isVoiceInputAvailable(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (isWechatBrowser()) return true;
|
||||
if (MicCapture.isSupported()) return true;
|
||||
if (isSpeechRecognitionSupported() && !shouldPreferServerAsr()) return true;
|
||||
return Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getWechatJsSdkSignature } from '../api/client';
|
||||
|
||||
type WxCallback<T = Record<string, unknown>> = T & {
|
||||
errMsg?: string;
|
||||
};
|
||||
|
||||
type WxSdk = {
|
||||
config: (options: {
|
||||
debug: boolean;
|
||||
appId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
jsApiList: string[];
|
||||
}) => void;
|
||||
ready: (callback: () => void) => void;
|
||||
error: (callback: (res: WxCallback) => void) => void;
|
||||
checkJsApi: (options: {
|
||||
jsApiList: string[];
|
||||
success?: (res: WxCallback<{ checkResult?: Record<string, boolean> | string }>) => void;
|
||||
fail?: (res: WxCallback) => void;
|
||||
}) => void;
|
||||
startRecord: (options?: Record<string, unknown>) => void;
|
||||
stopRecord: (options: {
|
||||
success?: (res: WxCallback<{ localId: string }>) => void;
|
||||
fail?: (res: WxCallback) => void;
|
||||
cancel?: (res: WxCallback) => void;
|
||||
}) => void;
|
||||
onVoiceRecordEnd: (options: {
|
||||
complete: (res: WxCallback<{ localId: string }>) => void;
|
||||
}) => void;
|
||||
translateVoice: (options: {
|
||||
localId: string;
|
||||
isShowProgressTips?: 0 | 1;
|
||||
success?: (res: WxCallback<{ translateResult: string }>) => void;
|
||||
fail?: (res: WxCallback) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wx?: WxSdk;
|
||||
}
|
||||
}
|
||||
|
||||
const JWEIXIN_SRC = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configuredUrl = '';
|
||||
let readyUrl = '';
|
||||
let configPromise: Promise<void> | null = null;
|
||||
let autoStopHandler: ((localId: string) => void) | null = null;
|
||||
|
||||
function loadWechatScript() {
|
||||
if (window.wx) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${JWEIXIN_SRC}"]`);
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('微信 JS-SDK 加载失败')), { once: true });
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = JWEIXIN_SRC;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('微信 JS-SDK 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
function currentSignatureUrl() {
|
||||
return window.location.href.split('#')[0];
|
||||
}
|
||||
|
||||
async function ensureWechatConfigured() {
|
||||
const url = currentSignatureUrl();
|
||||
if (window.wx && configPromise && configuredUrl === url) return configPromise;
|
||||
|
||||
await loadWechatScript();
|
||||
if (!window.wx) throw new Error('微信 JS-SDK 不可用');
|
||||
|
||||
configuredUrl = url;
|
||||
configPromise = new Promise<void>(async (resolve, reject) => {
|
||||
try {
|
||||
const signature = await getWechatJsSdkSignature(url);
|
||||
let settled = false;
|
||||
window.wx?.ready(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
readyUrl = url;
|
||||
resolve();
|
||||
});
|
||||
window.wx?.error((res) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
readyUrl = '';
|
||||
reject(new Error(res.errMsg || '微信 JS-SDK 验证失败'));
|
||||
});
|
||||
window.wx?.config({
|
||||
debug: false,
|
||||
appId: signature.appId,
|
||||
timestamp: signature.timestamp,
|
||||
nonceStr: signature.nonceStr,
|
||||
signature: signature.signature,
|
||||
jsApiList: signature.jsApiList,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
function formatWxError(res: WxCallback | null | undefined, fallback: string) {
|
||||
const errMsg = String(res?.errMsg ?? '').trim();
|
||||
if (!errMsg) return fallback;
|
||||
if (/cancel/i.test(errMsg)) return '已取消语音输入';
|
||||
if (/startRecord:fail/i.test(errMsg)) {
|
||||
return `微信录音启动失败:${errMsg}`;
|
||||
}
|
||||
if (/permission|auth|denied/i.test(errMsg)) return '微信语音权限不可用,请检查授权';
|
||||
return errMsg;
|
||||
}
|
||||
|
||||
export function prepareWechatVoiceSdk() {
|
||||
return ensureWechatConfigured();
|
||||
}
|
||||
|
||||
export function isWechatVoiceReady() {
|
||||
return Boolean(window.wx && readyUrl === currentSignatureUrl());
|
||||
}
|
||||
|
||||
export async function checkWechatVoiceApis() {
|
||||
await ensureWechatConfigured();
|
||||
if (!window.wx) throw new Error('微信 JS-SDK 不可用');
|
||||
return new Promise<Record<string, boolean>>((resolve, reject) => {
|
||||
window.wx?.checkJsApi({
|
||||
jsApiList: ['startRecord', 'stopRecord', 'onVoiceRecordEnd', 'translateVoice'],
|
||||
success: (res) => {
|
||||
const raw = res.checkResult;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
resolve(raw || {});
|
||||
},
|
||||
fail: (res) => reject(new Error(formatWxError(res, '微信语音接口检查失败'))),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function startWechatVoiceRecord(onAutoStop: (localId: string) => void) {
|
||||
if (!isWechatVoiceReady()) {
|
||||
return Promise.reject(new Error('微信语音初始化中,请稍后再按住说话'));
|
||||
}
|
||||
if (!window.wx) throw new Error('微信 JS-SDK 不可用');
|
||||
autoStopHandler = onAutoStop;
|
||||
window.wx.onVoiceRecordEnd({
|
||||
complete: (res) => {
|
||||
if (res.localId) autoStopHandler?.(res.localId);
|
||||
},
|
||||
});
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn();
|
||||
};
|
||||
window.wx?.startRecord({
|
||||
success: () => settle(resolve),
|
||||
fail: (res: WxCallback) => settle(() => reject(new Error(formatWxError(res, '微信录音启动失败')))),
|
||||
cancel: (res: WxCallback) => settle(() => reject(new Error(formatWxError(res, '已取消语音输入')))),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopWechatVoiceRecord() {
|
||||
await ensureWechatConfigured();
|
||||
if (!window.wx) throw new Error('微信 JS-SDK 不可用');
|
||||
autoStopHandler = null;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
window.wx?.stopRecord({
|
||||
success: (res) => {
|
||||
if (!res.localId) {
|
||||
reject(new Error('未录制到语音'));
|
||||
return;
|
||||
}
|
||||
resolve(res.localId);
|
||||
},
|
||||
fail: (res) => reject(new Error(formatWxError(res, '微信录音停止失败'))),
|
||||
cancel: (res) => reject(new Error(formatWxError(res, '已取消语音输入'))),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function translateWechatVoice(localId: string) {
|
||||
await ensureWechatConfigured();
|
||||
if (!window.wx) throw new Error('微信 JS-SDK 不可用');
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
window.wx?.translateVoice({
|
||||
localId,
|
||||
isShowProgressTips: 1,
|
||||
success: (res) => resolve(String(res.translateResult ?? '').trim()),
|
||||
fail: (res) => reject(new Error(formatWxError(res, '微信语音转文字失败'))),
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user