first commit
This commit is contained in:
284
apps/miniprogram/pages/terminal/terminalSpeakableText.js
Normal file
284
apps/miniprogram/pages/terminal/terminalSpeakableText.js
Normal file
@@ -0,0 +1,284 @@
|
||||
/* global module, require */
|
||||
|
||||
const {
|
||||
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_UTF8_BYTES,
|
||||
normalizeTtsSpeakableMaxChars,
|
||||
normalizeTtsSegmentMaxChars,
|
||||
resolveTtsSpeakableUtf8ByteLimit,
|
||||
resolveTtsSegmentUtf8ByteLimit
|
||||
} = require("../../utils/ttsSettings");
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
||||
const BOX_DRAWING_PATTERN = /[┌┐└┘├┤┬┴┼│─╭╮╯╰═║╔╗╚╝]/;
|
||||
const COMMAND_PREFIX_PATTERN =
|
||||
/^\s*(?:[$#>]|>>>\s|(?:cd|ls|pwd|git|npm|pnpm|yarn|bun|node|npx|cat|grep|sed|awk|ssh|scp|rm|mv|cp|mkdir|touch|python|pip|cargo|go|java|docker|kubectl)\b)/i;
|
||||
const CODE_TOKEN_PATTERN = /(?:=>|::|===|!==|&&|\|\||\{|\}|\[|\]|<\/?|\/>|;)/g;
|
||||
const PATH_LINE_PATTERN =
|
||||
/^\s*(?:~?\/\S+|\.{1,2}\/\S+|[A-Za-z]:\\\S+|(?:[A-Za-z0-9._-]+\/){2,}[A-Za-z0-9._-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)\s*$/;
|
||||
const URL_LINE_PATTERN = /^\s*https?:\/\/\S+\s*$/i;
|
||||
const PROGRESS_LINE_PATTERN = /(?:\b\d{1,3}%\b|\[[=>.\- ]{3,}\]|\bETA\b|\b\d+\/\d+\b|spinner|loading)/i;
|
||||
const CODEX_INPUT_LINE_PATTERN = /^\s*[›»❯➜]\s+/;
|
||||
const CODEX_FOOTER_LINE_PATTERN =
|
||||
/\b(?:gpt-\d(?:\.\d+)?|claude(?:-[a-z0-9.-]+)?|gemini(?:-[a-z0-9.-]+)?|deepseek(?:-[a-z0-9.-]+)?|o\d(?:-[a-z0-9.-]+)?|sonnet|haiku|opus)\b.*(?:\b\d{1,3}%\s+(?:left|context left)\b|~\/\S*)/i;
|
||||
const CODEX_FOOTER_FRAGMENT_PATTERN =
|
||||
/(?:\b\d{1,3}%\s+(?:left|context left)\b.*~\/\S*|~\/\S*.*\b\d{1,3}%\s+(?:left|context left)\b)/i;
|
||||
const CODEX_STATUS_LINE_PATTERN =
|
||||
/^\s*(?:[!!⚠■●•]\s*)?(?:Working(?:\s|\(|$)|Tip:|Tips?:|Heads up\b|Conversation interrupted\b|Something went wrong\b|Hit\s+`?\/feedback`?\b|Booting MCP server:|MCP server:)/i;
|
||||
const CHINESE_STATUS_LINE_PATTERN =
|
||||
/^\s*(?:正在(?:分析|处理|读取|扫描|生成|检查|加载|连接|收集|整理|搜索)|(?:分析|处理|读取|加载|连接|生成)(?:中|中\.\.\.|中…+))[^。!?!?]{0,80}(?:\.\.\.|…+)?\s*$/;
|
||||
const NATURAL_TEXT_PATTERN = /[\u3400-\u9fff]|[A-Za-z]{3,}/;
|
||||
const SYMBOL_CHAR_PATTERN = /[\\\/[\]{}()<>_=+*`|#@$%^~]/g;
|
||||
const MAX_SPEAKABLE_CHARS = DEFAULT_TTS_SPEAKABLE_MAX_CHARS;
|
||||
const MAX_SPEAKABLE_UTF8_BYTES = resolveTtsSpeakableUtf8ByteLimit(DEFAULT_TTS_SPEAKABLE_MAX_CHARS);
|
||||
|
||||
function stripTerminalAnsi(text) {
|
||||
return String(text || "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeSpeakableLine(line) {
|
||||
return stripTerminalAnsi(line)
|
||||
.replace(/[ \t\f\v]+/g, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanSpeakableLine(line) {
|
||||
return String(line || "")
|
||||
.replace(/^\s*(?:(?:[-*+]\s+|[•●○◦▪■·]\s*|\d+[.)、]\s+))/, "")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isCommandLikeLine(line) {
|
||||
return COMMAND_PREFIX_PATTERN.test(line);
|
||||
}
|
||||
|
||||
function isCodeLikeLine(line) {
|
||||
if (!line) return false;
|
||||
if (/^\s*```/.test(line)) return true;
|
||||
if (/^\s*(?:const|let|var|function|class|import|export|return|if|for|while)\b/.test(line)) return true;
|
||||
const codeTokenCount = (line.match(CODE_TOKEN_PATTERN) || []).length;
|
||||
return codeTokenCount >= 3;
|
||||
}
|
||||
|
||||
function hasHighSymbolDensity(line) {
|
||||
const visible = String(line || "").replace(/\s/g, "");
|
||||
if (!visible) return false;
|
||||
const symbols = (visible.match(SYMBOL_CHAR_PATTERN) || []).length;
|
||||
return symbols / visible.length >= 0.22;
|
||||
}
|
||||
|
||||
function isSpeakableLine(line) {
|
||||
if (!line) return false;
|
||||
if (!NATURAL_TEXT_PATTERN.test(line)) return false;
|
||||
if (BOX_DRAWING_PATTERN.test(line)) return false;
|
||||
if (/^[-=_*]{4,}$/.test(line)) return false;
|
||||
if (PROGRESS_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_INPUT_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_FOOTER_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_FOOTER_FRAGMENT_PATTERN.test(line)) return false;
|
||||
if (CODEX_STATUS_LINE_PATTERN.test(line)) return false;
|
||||
if (CHINESE_STATUS_LINE_PATTERN.test(line)) return false;
|
||||
if (PATH_LINE_PATTERN.test(line) || URL_LINE_PATTERN.test(line)) return false;
|
||||
if (isCommandLikeLine(line) || isCodeLikeLine(line)) return false;
|
||||
if (hasHighSymbolDensity(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collapseSpeakableText(text) {
|
||||
return String(text || "")
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.replace(/([,。!?;:,.!?;:])\1{1,}/g, "$1")
|
||||
.replace(/([,。!?;:,.!?;:])\s+([A-Za-z\u3400-\u9fff])/g, "$1$2")
|
||||
.replace(/([\u3400-\u9fff])\s+([\u3400-\u9fff])/g, "$1$2")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function utf8ByteLength(text) {
|
||||
let total = 0;
|
||||
const source = String(text || "");
|
||||
for (const char of source) {
|
||||
const codePoint = char.codePointAt(0) || 0;
|
||||
if (codePoint <= 0x7f) {
|
||||
total += 1;
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
total += 2;
|
||||
} else if (codePoint <= 0xffff) {
|
||||
total += 3;
|
||||
} else {
|
||||
total += 4;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function trimSpeakableText(text, maxChars, maxUtf8Bytes) {
|
||||
const source = String(text || "");
|
||||
const charLimit = normalizeTtsSpeakableMaxChars(maxChars);
|
||||
const utf8Limit = Math.max(1, Math.round(Number(maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(charLimit)));
|
||||
if (source.length <= charLimit && utf8ByteLength(source) <= utf8Limit) {
|
||||
return source;
|
||||
}
|
||||
let result = "";
|
||||
let usedBytes = 0;
|
||||
for (const char of source) {
|
||||
if (result.length >= charLimit) {
|
||||
break;
|
||||
}
|
||||
const nextBytes = utf8ByteLength(char);
|
||||
if (usedBytes + nextBytes > utf8Limit) {
|
||||
break;
|
||||
}
|
||||
result += char;
|
||||
usedBytes += nextBytes;
|
||||
}
|
||||
return result
|
||||
.replace(/[,、;:,.!?;:\s]+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitSpeakableTextForTts(text, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const source = collapseSpeakableText(text);
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const maxChars = normalizeTtsSegmentMaxChars(config.maxChars || TTS_SEGMENT_MAX_CHARS);
|
||||
const maxUtf8Bytes = Math.max(
|
||||
1,
|
||||
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSegmentUtf8ByteLimit(maxChars))
|
||||
);
|
||||
const chars = Array.from(source);
|
||||
const segments = [];
|
||||
let cursor = 0;
|
||||
|
||||
/**
|
||||
* 分段策略优先找句号/问号/分号等强断点;
|
||||
* 如果当前窗口里没有完整句子,再退回逗号或空白,避免整段都卡到硬切。
|
||||
*/
|
||||
while (cursor < chars.length) {
|
||||
while (cursor < chars.length && /[\s,、;:,.!?;:]/.test(chars[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
if (cursor >= chars.length) {
|
||||
break;
|
||||
}
|
||||
let usedBytes = 0;
|
||||
let end = cursor;
|
||||
let lastStrongBreak = -1;
|
||||
let lastSoftBreak = -1;
|
||||
while (end < chars.length) {
|
||||
const char = chars[end];
|
||||
const nextBytes = utf8ByteLength(char);
|
||||
if (end - cursor >= maxChars || usedBytes + nextBytes > maxUtf8Bytes) {
|
||||
break;
|
||||
}
|
||||
usedBytes += nextBytes;
|
||||
end += 1;
|
||||
if (/[。!?!?;;::]/.test(char)) {
|
||||
lastStrongBreak = end;
|
||||
} else if (/[,、,.]/.test(char) || /\s/.test(char)) {
|
||||
lastSoftBreak = end;
|
||||
}
|
||||
}
|
||||
|
||||
let nextEnd = end;
|
||||
const consumedChars = end - cursor;
|
||||
const strongBreakFloor = Math.max(12, Math.floor(maxChars * 0.55));
|
||||
const softBreakFloor = Math.max(12, Math.floor(maxChars * 0.45));
|
||||
|
||||
if (end < chars.length) {
|
||||
if (lastStrongBreak >= cursor + strongBreakFloor) {
|
||||
nextEnd = lastStrongBreak;
|
||||
} else if (lastSoftBreak >= cursor + softBreakFloor) {
|
||||
nextEnd = lastSoftBreak;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEnd <= cursor) {
|
||||
nextEnd = Math.max(cursor + 1, end);
|
||||
}
|
||||
|
||||
const segment = chars.slice(cursor, nextEnd).join("").trim();
|
||||
if (!segment && consumedChars > 0) {
|
||||
segments.push(chars.slice(cursor, end).join("").trim());
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if (segment) {
|
||||
segments.push(segment);
|
||||
}
|
||||
cursor = nextEnd;
|
||||
}
|
||||
|
||||
return segments.filter((segment) => !!segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一轮终端可见输出中抽取“最近一批适合朗读的自然语言”:
|
||||
* 1. 仍然优先保留轮次尾部最近内容,但不再要求必须是单个连续段;
|
||||
* 2. 中间若夹杂代码、路径、状态行,直接跳过并继续向上回溯;
|
||||
* 3. 收口逻辑保持在短文本范围内,避免把整轮历史都送进 TTS。
|
||||
*/
|
||||
function buildSpeakableTerminalText(source, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const maxChars = normalizeTtsSpeakableMaxChars(config.maxChars);
|
||||
const maxUtf8Bytes = Math.max(
|
||||
1,
|
||||
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(maxChars))
|
||||
);
|
||||
const text = Array.isArray(source) ? source.join("\n") : String(source || "");
|
||||
const normalized = stripTerminalAnsi(text);
|
||||
if (!normalized.trim()) {
|
||||
return "";
|
||||
}
|
||||
const lines = normalized.split(/\n+/).map(normalizeSpeakableLine);
|
||||
const collected = [];
|
||||
let collectedChars = 0;
|
||||
let collectedBytes = 0;
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (!isSpeakableLine(line)) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = cleanSpeakableLine(line);
|
||||
if (!cleaned) {
|
||||
continue;
|
||||
}
|
||||
const separatorChars = collected.length > 0 ? 1 : 0;
|
||||
const nextChars = cleaned.length + separatorChars;
|
||||
const nextBytes = utf8ByteLength(cleaned) + separatorChars;
|
||||
if (collected.length > 0 && (collectedChars + nextChars > maxChars || collectedBytes + nextBytes > maxUtf8Bytes)) {
|
||||
break;
|
||||
}
|
||||
if (collected.length === 0 && (cleaned.length > maxChars || utf8ByteLength(cleaned) > maxUtf8Bytes)) {
|
||||
collected.unshift(trimSpeakableText(cleaned, maxChars, maxUtf8Bytes));
|
||||
break;
|
||||
}
|
||||
collected.unshift(cleaned);
|
||||
collectedChars += nextChars;
|
||||
collectedBytes += nextBytes;
|
||||
}
|
||||
return trimSpeakableText(collapseSpeakableText(collected.join("\n")), maxChars, maxUtf8Bytes);
|
||||
}
|
||||
|
||||
function isSpeakableTextLikelyComplete(text) {
|
||||
return /(?:[。!?!?::]|\.{1}|。{1})\s*$/.test(String(text || "").trim());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SPEAKABLE_CHARS,
|
||||
buildSpeakableTerminalText,
|
||||
isSpeakableTextLikelyComplete,
|
||||
splitSpeakableTextForTts,
|
||||
stripTerminalAnsi
|
||||
};
|
||||
Reference in New Issue
Block a user