first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
/* global require, module, clearTimeout, setTimeout */
const { ensureSyncAuthToken, resolveSyncBaseUrl, requestJson } = require("./syncAuth");
const TTS_REQUEST_TIMEOUT_MS = 15000;
const TTS_GENERATION_TIMEOUT_MS = 6 * 60 * 1000;
const TTS_STATUS_POLL_INTERVAL_MS = 700;
const TTS_LOCAL_CACHE_PREFIX = "tts-cache-";
const KNOWN_TTS_MESSAGES = new Set([
"当前没有可播报内容",
"当前内容不适合播报",
"播报文本过长",
"语音生成繁忙,请稍后重试",
"语音生成超时,请稍后重试",
"语音生成失败",
"TTS 服务未配置"
]);
const KNOWN_TTS_MESSAGE_PREFIXES = ["TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限"];
function isKnownTtsMessage(message) {
const normalized = String(message || "").trim();
if (!normalized) return false;
if (KNOWN_TTS_MESSAGES.has(normalized)) return true;
return KNOWN_TTS_MESSAGE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function withTimeout(task, timeoutMs, timeoutMessage) {
const waitMs = Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(timeoutMessage || "语音生成超时,请稍后重试"));
}, waitMs);
Promise.resolve(task)
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
function sleep(ms) {
const waitMs = Math.max(0, Math.round(Number(ms) || 0));
return new Promise((resolve) => {
setTimeout(resolve, waitMs);
});
}
function getMiniProgramFs() {
if (typeof wx === "undefined" || typeof wx.getFileSystemManager !== "function") {
return null;
}
try {
return wx.getFileSystemManager();
} catch {
return null;
}
}
function sanitizeTtsCacheKey(cacheKey) {
return String(cacheKey || "")
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "");
}
/**
* 使用小程序用户目录做确定性文件缓存:
* 1. 以服务端 `cacheKey` 命名,天然与内容一一对应;
* 2. 命中后可直接播放本地文件,不再重复下载;
* 3. 不依赖 `saveFile` 的全局列表,避免额外维护 saved file 配额。
*/
function resolveTtsLocalCachePath(cacheKey) {
const normalizedKey = sanitizeTtsCacheKey(cacheKey);
const userDataPath =
typeof wx !== "undefined" && wx && wx.env && typeof wx.env.USER_DATA_PATH === "string"
? wx.env.USER_DATA_PATH
: "";
if (!normalizedKey || !userDataPath) {
return "";
}
return `${userDataPath}/${TTS_LOCAL_CACHE_PREFIX}${normalizedKey}.mp3`;
}
function accessLocalFile(filePath) {
const targetPath = String(filePath || "").trim();
const fs = getMiniProgramFs();
if (!targetPath || !fs || typeof fs.access !== "function") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
fs.access({
path: targetPath,
success: () => resolve(true),
fail: () => resolve(false)
});
});
}
function downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs) {
const targetUrl = String(remoteAudioUrl || "").trim();
const filePath = resolveTtsLocalCachePath(cacheKey);
if (!targetUrl || !filePath || typeof wx === "undefined" || typeof wx.downloadFile !== "function") {
return Promise.resolve("");
}
return new Promise((resolve) => {
wx.downloadFile({
url: targetUrl,
filePath,
timeout: Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS)),
success(res) {
const statusCode = Number(res && res.statusCode);
if (statusCode >= 200 && statusCode < 300) {
resolve(filePath);
return;
}
resolve("");
},
fail() {
resolve("");
}
});
});
}
async function resolvePlayableAudioUrl(remoteAudioUrl, cacheKey, timeoutMs) {
const localPath = resolveTtsLocalCachePath(cacheKey);
if (!localPath) {
return remoteAudioUrl;
}
if (await accessLocalFile(localPath)) {
return localPath;
}
const downloadedPath = await downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs);
return downloadedPath || remoteAudioUrl;
}
/**
* gateway 生成的 TTS 音频/状态地址本质上应与小程序当前同步基地址同源:
* 1. 若反向代理未透传 `x-forwarded-proto/host`,后端可能回出 `http://内网主机/...`
* 2. 小程序 `request` 能成功,不代表 `InnerAudioContext` 也能播放这个错误 origin
* 3. 因此仅对 `/api/miniprogram/tts/` 路径做同源归一化,强制回到当前 `gatewayUrl`。
*/
function normalizeGatewayTtsUrl(rawUrl, baseUrl) {
const candidate = String(rawUrl || "").trim();
const base = String(baseUrl || "").trim();
if (!candidate || !base) {
return candidate;
}
try {
const baseParsed = new URL(base);
const resolved = new URL(candidate, `${baseParsed.origin}/`);
if (!resolved.pathname.startsWith("/api/miniprogram/tts/")) {
return resolved.toString();
}
return `${baseParsed.origin}${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return candidate;
}
}
async function waitForSynthesisReady(statusUrl, timeoutMs) {
const deadline = Date.now() + Math.max(TTS_REQUEST_TIMEOUT_MS, Math.round(Number(timeoutMs) || TTS_GENERATION_TIMEOUT_MS));
while (Date.now() < deadline) {
const payload = await requestJson(statusUrl, {
method: "GET",
timeoutMs: TTS_REQUEST_TIMEOUT_MS
});
const status = payload && payload.status ? String(payload.status) : "";
if (payload && payload.ok === true && status === "ready") {
return;
}
const rawMessage = payload && payload.message ? String(payload.message) : "";
if (status === "error") {
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await sleep(Math.min(TTS_STATUS_POLL_INTERVAL_MS, remainingMs));
}
throw new Error("语音生成超时,请稍后重试");
}
/**
* 小程序 TTS 请求只做两件事:
* 1. 复用同步登录 Bearer token
* 2. 把短文本换成短时可播放音频 URL。
*/
async function synthesizeTerminalSpeech(options) {
const input = options && typeof options === "object" ? options : {};
const text = String(input.text || "").trim();
if (!text) {
throw new Error("当前没有可播报内容");
}
const baseUrl = resolveSyncBaseUrl();
if (!baseUrl) {
throw new Error("语音生成失败");
}
const token = await ensureSyncAuthToken();
let response = null;
try {
response = await withTimeout(
requestJson(`${baseUrl}/api/miniprogram/tts/synthesize`, {
method: "POST",
data: {
text,
scene: "codex_terminal",
...(input.voice ? { voice: String(input.voice) } : {}),
...(Number.isFinite(Number(input.speed)) ? { speed: Number(input.speed) } : {})
},
header: {
"content-type": "application/json",
authorization: `Bearer ${token}`
},
timeoutMs: TTS_REQUEST_TIMEOUT_MS
}),
TTS_REQUEST_TIMEOUT_MS,
"语音生成超时,请稍后重试"
);
if (response && response.status === "pending") {
const statusUrl = normalizeGatewayTtsUrl(response.statusUrl, baseUrl);
if (!statusUrl) {
throw new Error("语音生成失败");
}
await waitForSynthesisReady(statusUrl, input.timeoutMs);
}
} catch (error) {
const rawMessage = error instanceof Error && error.message ? error.message : "";
if (/timeout/i.test(rawMessage)) {
throw new Error("语音生成超时,请稍后重试");
}
if (isKnownTtsMessage(rawMessage)) {
throw new Error(rawMessage);
}
throw new Error("语音生成失败");
}
if (!response || response.ok !== true || !response.audioUrl) {
const rawMessage = response && response.message ? String(response.message) : "";
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
}
const remoteAudioUrl = normalizeGatewayTtsUrl(response.audioUrl, baseUrl);
const audioUrl = await resolvePlayableAudioUrl(
remoteAudioUrl,
response.cacheKey,
TTS_REQUEST_TIMEOUT_MS
);
return {
cacheKey: String(response.cacheKey || ""),
cached: response.cached === true,
audioUrl,
remoteAudioUrl,
expiresAt: String(response.expiresAt || "")
};
}
module.exports = {
TTS_REQUEST_TIMEOUT_MS,
synthesizeTerminalSpeech
};