7674 lines
278 KiB
JavaScript
7674 lines
278 KiB
JavaScript
/* global Page, wx, require, clearTimeout, setTimeout, clearInterval, setInterval, console */
|
||
|
||
const {
|
||
listServers,
|
||
markServerConnected,
|
||
appendLog,
|
||
addRecord,
|
||
getSettings,
|
||
DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
|
||
normalizeVoiceRecordCategories,
|
||
normalizeVoiceRecordDefaultCategory
|
||
} = require("../../utils/storage");
|
||
const { createGatewayClient } = require("../../utils/gateway");
|
||
const { createAsrGatewayClient } = require("../../utils/asrGateway");
|
||
const { synthesizeTerminalSpeech } = require("../../utils/ttsGateway");
|
||
const { getOpsConfig, isOpsConfigReady } = require("../../utils/opsConfig");
|
||
const { validateServerForConnect } = require("../../utils/serverValidation");
|
||
const { resolveSocketDomainHint } = require("../../utils/socketDomain");
|
||
const { resolveSyncBaseUrl } = require("../../utils/syncAuth");
|
||
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
|
||
const {
|
||
buildActiveButtonIconMap,
|
||
buildAccentButtonIconMap,
|
||
buildButtonIconMap,
|
||
resolveButtonIcon
|
||
} = require("../../utils/themedIcons");
|
||
const { buildTerminalToolActiveIconMap, buildTerminalToolIconMap } = require("../../utils/terminalIcons");
|
||
const {
|
||
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||
DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||
normalizeTtsSpeakableMaxChars,
|
||
normalizeTtsSegmentMaxChars
|
||
} = require("../../utils/ttsSettings");
|
||
const { getWindowMetrics } = require("../../utils/systemInfoCompat");
|
||
const { emitSessionEvent, setActiveSessionSender } = require("../../utils/sessionBus");
|
||
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
|
||
const {
|
||
buildPageCopy,
|
||
formatTemplate,
|
||
getStatusLabel,
|
||
localizeServerValidationMessage,
|
||
normalizeUiLanguage
|
||
} = require("../../utils/i18n");
|
||
const {
|
||
CODEX_BOOTSTRAP_TOKEN_DIR_MISSING,
|
||
CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING,
|
||
CODEX_BOOTSTRAP_TOKEN_READY,
|
||
buildCdCommand,
|
||
buildCopilotLaunchCommand,
|
||
buildCodexBootstrapCommand,
|
||
buildCodexResumeCommand,
|
||
consumeAiRuntimeExitMarkers,
|
||
isAiSessionReady,
|
||
normalizeAiProvider,
|
||
normalizeCodexSandboxMode,
|
||
normalizeCopilotPermissionMode
|
||
} = require("../../utils/aiLaunch");
|
||
const { getPendingTerminalIntent, clearPendingTerminalIntent } = require("../../utils/terminalIntent");
|
||
const {
|
||
getTerminalSessionSnapshot,
|
||
createTerminalSessionSnapshot,
|
||
markTerminalSessionResumable,
|
||
getTerminalBufferSnapshot,
|
||
saveTerminalBufferSnapshot,
|
||
clearTerminalSessionSnapshot
|
||
} = require("../../utils/terminalSession");
|
||
const { resolveTerminalResumeGraceMs } = require("../../utils/terminalSessionState");
|
||
const {
|
||
buildLineCellRenderRuns,
|
||
cloneTerminalCell,
|
||
lineCellsToText,
|
||
resolveUniformLineBackground
|
||
} = require("./terminalCursorModel");
|
||
const {
|
||
deserializeTerminalSnapshotRows,
|
||
serializeTerminalSnapshotRows
|
||
} = require("./terminalSnapshotCodec.js");
|
||
const { buildTerminalSessionInfoModel } = require("./terminalSessionInfo.js");
|
||
const {
|
||
appendDiagnosticSample,
|
||
buildCombinedDiagnosticSparkline
|
||
} = require("./connectionDiagnosticsSparkline");
|
||
const {
|
||
buildSpeakableTerminalText,
|
||
isSpeakableTextLikelyComplete,
|
||
splitSpeakableTextForTts
|
||
} = require("./terminalSpeakableText");
|
||
const {
|
||
ANSI_RESET_STATE,
|
||
applyTerminalOutput,
|
||
buildTerminalCellsFromText,
|
||
cloneAnsiState,
|
||
cloneTerminalBufferState,
|
||
createEmptyTerminalBufferState,
|
||
getActiveTerminalBuffer,
|
||
getTerminalModeState,
|
||
rebuildTerminalBufferStateFromReplayText,
|
||
syncActiveBufferSnapshot,
|
||
trimTerminalReplayTextToMaxBytes,
|
||
utf8ByteLength
|
||
} = require("./terminalBufferState");
|
||
const {
|
||
consumeTerminalSyncUpdateFrames,
|
||
createTerminalSyncUpdateState,
|
||
takeTerminalReplaySlice
|
||
} = require("./vtParser.js");
|
||
const {
|
||
TERMINAL_TOUCH_DIRECTION_KEYS,
|
||
TERMINAL_TOUCH_ACTION_BUTTONS,
|
||
encodeTerminalKey,
|
||
encodeTerminalPaste
|
||
} = require("./terminalKeyEncoder");
|
||
const {
|
||
TOUCH_SHIFT_MODE_OFF,
|
||
TOUCH_SHIFT_MODE_ONCE,
|
||
TOUCH_SHIFT_DOUBLE_TAP_MS,
|
||
applyTouchShiftToValue,
|
||
resolveTouchShiftModeOnTap
|
||
} = require("./touchShiftState");
|
||
const { createTerminalPerfLogBuffer, pickPerfScore } = require("./terminalPerfLogBuffer");
|
||
const { createTerminalRenderScheduler } = require("./terminalRenderScheduler");
|
||
const {
|
||
resolveTerminalStdoutOverlayDecision,
|
||
resolveTerminalStdoutRenderDecision
|
||
} = require("./terminalStdoutRenderPolicy");
|
||
const { buildTerminalViewportState, normalizeActiveBufferName } = require("./terminalViewportModel");
|
||
const { resolveVoicePrivacyErrorMessage } = require("./voicePrivacy");
|
||
const { resolveVoiceGatewayErrorState } = require("./voiceGatewayError");
|
||
|
||
/**
|
||
* 终端页只负责“几何测量、渲染投影、键盘代理”。
|
||
*
|
||
* 顶级约束(后续补 VT 功能时不得破坏):
|
||
* 1. 原生 `input` 仅作为键盘代理,不再承担终端视觉 caret 职责。
|
||
* 2. `outputRenderLines`、overlay caret、激活带必须共享同一份 buffer cursor 结果;
|
||
* 禁止重新引入 prompt 正则猜测、字符串长度推光标或浏览器软换行回推。
|
||
* 3. `cols/rows` 必须来自真实输出区域几何;列数变化时必须允许按 replay 文本重建 buffer。
|
||
* 4. 后续即使增加 alternate screen,也只能改变“当前渲染使用哪份 buffer”,不能把页面层改回
|
||
* 直接依赖自然文本流的实现。
|
||
* 5. 继承仓库级移动端终端交互约束:键盘弹起/收起必须顺畅、滚动必须保留原生惯性、长按选区链路
|
||
* 优先级高于自定义手势、键盘过渡期间当前命令行必须保持可见。
|
||
*/
|
||
|
||
const RECONNECT_STATES = new Set(["idle", "disconnected", "error"]);
|
||
const AUTO_RECONNECT_IGNORED_REASONS = new Set(["manual", "host_key_rejected", "ws_peer_normal_close"]);
|
||
const CONNECTION_DIAGNOSTIC_SAMPLE_LIMIT = 30;
|
||
const CONNECTION_DIAGNOSTIC_SAMPLE_INTERVAL_MS = 10000;
|
||
const CONNECTION_DIAGNOSTIC_PANEL_SAMPLE_INTERVAL_MS = 3000;
|
||
const CONNECTION_DIAGNOSTIC_NETWORK_TIMEOUT_MS = 6000;
|
||
const VOICE_CLOSE_TIMEOUT_MS = 2600;
|
||
const VOICE_HOLD_DELAY_MS = 180;
|
||
const VOICE_DRAG_THRESHOLD_PX = 10;
|
||
const VOICE_FLOAT_GAP_PX = 16;
|
||
const VOICE_BUTTON_SIZE_RPX = 64;
|
||
const VOICE_PANEL_MIN_WIDTH_PX = 220;
|
||
const VOICE_PANEL_FALLBACK_HEIGHT_PX = 220;
|
||
const VOICE_IDLE_OPACITY = 1;
|
||
const VOICE_ACTIVE_OPACITY = 1;
|
||
const VOICE_MAIN_BUTTON_SIZE_PX = 32;
|
||
const VOICE_SECONDARY_BUTTON_SIZE_PX = 25;
|
||
const VOICE_SIDE_ACTION_BUTTON_RPX = 48;
|
||
const VOICE_ACTION_BUTTON_GAP_PX = 16;
|
||
const VOICE_ACTIONS_SIDE_PADDING_PX = 16;
|
||
const VOICE_ACTIONS_VERTICAL_PADDING_PX = 8;
|
||
const VOICE_ACTIONS_MIN_HEIGHT_RPX = 70;
|
||
const VOICE_CATEGORY_PILL_MIN_HEIGHT_RPX = 52;
|
||
const VOICE_CATEGORY_BOTTOM_PADDING_PX = 12;
|
||
const VOICE_FRAME_BOTTOM_PADDING_RPX = 10;
|
||
const OUTPUT_HORIZONTAL_PADDING_RPX = 16;
|
||
const OUTPUT_RIGHT_SAFE_PADDING_PX = 8;
|
||
const SHELL_ACTIVATION_RADIUS_LINES = 2;
|
||
const SHELL_CHAR_WIDTH_FACTOR = 0.62;
|
||
const SHELL_INPUT_MIN_WIDTH_PX = 1;
|
||
const OUTPUT_LONG_PRESS_SUPPRESS_MS = 420;
|
||
const ENABLE_SHELL_ACTIVATION_DEBUG_OVERLAY = true;
|
||
const DISCONNECTED_HINT_TEXT = "请点击右上角“重连”开关或左上角AI按钮,重新连接。";
|
||
const DEFAULT_BUFFER_MAX_ENTRIES = 5000;
|
||
const DEFAULT_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
|
||
const TERMINAL_BUFFER_SNAPSHOT_MAX_BYTES = 128 * 1024;
|
||
const SHELL_METRICS_ASCII_PROBE_TEXT = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
||
const SHELL_METRICS_WIDE_PROBE_TEXT = "汉汉汉汉汉汉汉汉汉汉汉汉汉汉汉汉";
|
||
const RECORDER_OPTIONS = {
|
||
duration: 600000,
|
||
sampleRate: 16000,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: 96000,
|
||
format: "PCM",
|
||
frameSize: 4
|
||
};
|
||
const CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS = 6000;
|
||
const CODEX_BOOTSTRAP_RELEASE_DELAY_MS = 260;
|
||
const CODEX_BOOTSTRAP_BUFFER_MAX_CHARS = 8192;
|
||
const TTS_STABLE_SENTENCE_MS = 700;
|
||
const TTS_STABLE_INCOMPLETE_MS = 1400;
|
||
const TTS_STABLE_CONFIRM_MS = 150;
|
||
/**
|
||
* `InnerAudioContext` 在部分机型上会出现两类不稳定:
|
||
* 1. `src` 已切换,但 `onCanplay` 迟迟不触发;
|
||
* 2. 本地缓存文件偶发加载失败,而同一资源的远端地址其实可播。
|
||
*
|
||
* 这里保留两级兜底:
|
||
* 1. 短延迟主动 `play()` 一次,补偿 `onCanplay` 偶发不触发;
|
||
* 2. 超时后优先回退到远端 URL,再决定是否报错。
|
||
*/
|
||
const TTS_PLAYBACK_START_KICK_MS = 220;
|
||
const TTS_PLAYBACK_LOAD_TIMEOUT_MS = 4000;
|
||
const TTS_ROUND_TEXT_MAX_CHARS = 24000;
|
||
// 终端 perf 日志默认关闭。当前仍有性能遗留问题,常态输出会进一步放大 console 压力;
|
||
// 调试性能问题时,可临时改为 true 收集 `perf.summary / perf.snapshot` 现场。
|
||
const ENABLE_TERMINAL_PERF_LOGS = false;
|
||
const TERMINAL_PERF_LOG_PREFIX = "[terminal.perf]";
|
||
const TERMINAL_PERF_LOG_WINDOW_MS = 5000;
|
||
// 终端性能日志默认只关心“肉眼明显可感知”的慢步骤,避免 Codex 启动期间被碎片输出刷屏。
|
||
const TERMINAL_PERF_SLOW_STEP_MS = 1000;
|
||
const TERMINAL_PERF_LONG_TASK_MS = 1000;
|
||
const TERMINAL_OUTPUT_RENDER_BATCH_MS = 16;
|
||
/**
|
||
* 小程序 terminal 的 stdout 处理、`setData` 桥接、overlay 同步和点击事件共用同一条页面主线程。
|
||
* 这里把单次 VT slice 再压短一点,优先保证“按钮/输入先有响应”,吞吐留给后续 slice 续跑。
|
||
*/
|
||
const TERMINAL_OUTPUT_WRITE_SLICE_MS = 6;
|
||
const TERMINAL_OUTPUT_WRITE_SLICE_CHARS = 1024;
|
||
const TERMINAL_OUTPUT_WRITE_SLICE_INITIAL_LOG_LIMIT = 3;
|
||
const TERMINAL_VIEWPORT_SCROLL_REFRESH_MARGIN_ROWS = 24;
|
||
const TERMINAL_SCROLL_OVERLAY_THROTTLE_MS = 32;
|
||
const TERMINAL_SCROLL_VIEWPORT_PREFETCH_DELAY_MS = 16;
|
||
const TERMINAL_SCROLL_VIEWPORT_PREFETCH_THROTTLE_MS = 48;
|
||
const TERMINAL_SCROLL_IDLE_SETTLE_MS = 96;
|
||
const TERMINAL_PERF_LAG_SAMPLE_MS = 1000;
|
||
const TERMINAL_PERF_LAG_WARN_MS = 180;
|
||
const TERMINAL_PERF_INITIAL_FRAME_LOG_LIMIT = 6;
|
||
const TERMINAL_PERF_RECENT_RECORD_LIMIT = 24;
|
||
const TERMINAL_PERF_DIAG_SNAPSHOT_LIMIT = 8;
|
||
const TERMINAL_PERF_DIAG_SNAPSHOT_COOLDOWN_MS = 4000;
|
||
const TERMINAL_PERF_STDOUT_BACKLOG_WARN_MS = 400;
|
||
const TERMINAL_PERF_STDOUT_BACKLOG_WARN_BYTES = 48 * 1024;
|
||
const TERMINAL_CARET_STABILITY_MS = 120;
|
||
|
||
const TERMINAL_PERF_RECENT_NUMERIC_KEYS = Object.freeze([
|
||
"totalCostMs",
|
||
"costMs",
|
||
"driftMs",
|
||
"queueWaitMs",
|
||
"schedulerWaitMs",
|
||
"cloneCostMs",
|
||
"applyCostMs",
|
||
"trimCostMs",
|
||
"stateApplyCostMs",
|
||
"layoutCostMs",
|
||
"setDataCostMs",
|
||
"postLayoutCostMs",
|
||
"overlayCostMs",
|
||
"buildCostMs",
|
||
"renderBuildCostMs",
|
||
"batchWaitMs",
|
||
"remainingBytes",
|
||
"visibleBytes",
|
||
"chunkCount",
|
||
"sliceCount",
|
||
"renderPassCount",
|
||
"layoutPassCount",
|
||
"overlayPassCount",
|
||
"deferredRenderPassCount",
|
||
"skippedOverlayPassCount",
|
||
"totalRenderBuildCostMs",
|
||
"totalSetDataCostMs",
|
||
"totalPostLayoutCostMs",
|
||
"maxLayoutCostMs",
|
||
"maxRenderBuildCostMs",
|
||
"maxSetDataCostMs",
|
||
"maxPostLayoutCostMs",
|
||
"maxOverlayCostMs",
|
||
"renderRowCount",
|
||
"activeRowCount",
|
||
"activeCellCount",
|
||
"pendingStdoutSamples",
|
||
"pendingStdoutBytes",
|
||
"activeStdoutAgeMs",
|
||
"activeStdoutBytes"
|
||
]);
|
||
|
||
function escapeRegExpLiteral(value) {
|
||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
/**
|
||
* 仅匹配“独立一行”的 bootstrap token,避免命令回显中的字面量误判。
|
||
*/
|
||
function hasCodexBootstrapTokenLine(source, token) {
|
||
const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`);
|
||
return pattern.test(String(source || ""));
|
||
}
|
||
|
||
function stripCodexBootstrapTokenLine(source, token) {
|
||
const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`, "g");
|
||
return String(source || "").replace(pattern, "$1");
|
||
}
|
||
|
||
function extractAfterFirstCodexBootstrapTokenLine(source, token) {
|
||
const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(\\r?\\n|$)`);
|
||
const match = pattern.exec(String(source || ""));
|
||
if (!match) {
|
||
return { found: false };
|
||
}
|
||
const prefix = match[1] || "";
|
||
const suffix = match[2] || "";
|
||
const tokenStart = match.index + prefix.length;
|
||
const tokenEnd = tokenStart + token.length + suffix.length;
|
||
return {
|
||
found: true,
|
||
after: String(source || "").slice(tokenEnd)
|
||
};
|
||
}
|
||
|
||
function buildStableTextHash(source) {
|
||
let hash = 2166136261;
|
||
const text = String(source || "");
|
||
for (let index = 0; index < text.length; index += 1) {
|
||
hash ^= text.charCodeAt(index);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(16);
|
||
}
|
||
|
||
function countTerminalRows(rows) {
|
||
return Array.isArray(rows) ? rows.length : 0;
|
||
}
|
||
|
||
function countTerminalCells(rows) {
|
||
const source = Array.isArray(rows) ? rows : [];
|
||
let total = 0;
|
||
for (let index = 0; index < source.length; index += 1) {
|
||
total += Array.isArray(source[index]) ? source[index].length : 0;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function cloneTerminalCaretSnapshot(caret) {
|
||
const source = caret && typeof caret === "object" ? caret : null;
|
||
if (!source) {
|
||
return null;
|
||
}
|
||
return {
|
||
left: Math.round(Number(source.left) || 0),
|
||
top: Math.round(Number(source.top) || 0),
|
||
height: Math.max(0, Math.round(Number(source.height) || 0)),
|
||
visible: !!source.visible,
|
||
cursorRow: Math.max(0, Math.round(Number(source.cursorRow) || 0)),
|
||
cursorCol: Math.max(0, Math.round(Number(source.cursorCol) || 0)),
|
||
scrollTop: Math.max(0, Math.round(Number(source.scrollTop) || 0)),
|
||
rawTop: Math.round(Number(source.rawTop) || 0),
|
||
rawLeft: Math.round(Number(source.rawLeft) || 0),
|
||
rectWidth: Math.max(0, Math.round(Number(source.rectWidth) || 0)),
|
||
rectHeight: Math.max(0, Math.round(Number(source.rectHeight) || 0)),
|
||
lineHeight: Math.max(0, Math.round(Number(source.lineHeight) || 0)),
|
||
charWidth: Math.max(0, Number(source.charWidth) || 0)
|
||
};
|
||
}
|
||
|
||
function isSameTerminalCaretSnapshot(left, right) {
|
||
const prev = left && typeof left === "object" ? left : null;
|
||
const next = right && typeof right === "object" ? right : null;
|
||
if (!prev || !next) {
|
||
return prev === next;
|
||
}
|
||
return (
|
||
prev.visible === next.visible &&
|
||
prev.left === next.left &&
|
||
prev.top === next.top &&
|
||
prev.height === next.height &&
|
||
prev.cursorRow === next.cursorRow &&
|
||
prev.cursorCol === next.cursorCol &&
|
||
prev.scrollTop === next.scrollTop
|
||
);
|
||
}
|
||
|
||
function pickTerminalPerfSuspectedBottleneck(payload) {
|
||
const source = payload && typeof payload === "object" ? payload : {};
|
||
const candidates = [
|
||
["scheduler_backlog", Number(source.queueWaitMs) || 0],
|
||
["state_clone", Number(source.cloneCostMs) || 0],
|
||
["stdout_apply", Number(source.applyCostMs) || 0],
|
||
["buffer_trim", Number(source.trimCostMs) || 0],
|
||
["state_projection", Number(source.stateApplyCostMs) || 0],
|
||
["layout_build", Number(source.buildCostMs) || 0],
|
||
["render_rows", Number(source.renderBuildCostMs) || 0],
|
||
["set_data", Number(source.setDataCostMs) || 0],
|
||
["post_layout", Number(source.postLayoutCostMs) || 0],
|
||
["overlay", Number(source.overlayCostMs) || 0],
|
||
["main_thread_lag", Number(source.driftMs) || 0]
|
||
];
|
||
let bestLabel = "";
|
||
let bestScore = 0;
|
||
for (let index = 0; index < candidates.length; index += 1) {
|
||
const [label, score] = candidates[index];
|
||
if (score > bestScore) {
|
||
bestLabel = label;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
if (!bestLabel && (Number(source.pendingStdoutSamples) > 0 || Number(source.pendingStdoutBytes) > 0)) {
|
||
return "scheduler_backlog";
|
||
}
|
||
return bestLabel;
|
||
}
|
||
|
||
function buildCompactTerminalPerfRecord(record) {
|
||
const source = record && typeof record === "object" ? record : {};
|
||
const compact = {
|
||
event: String(source.event || ""),
|
||
at: Number(source.at) || 0,
|
||
scoreMs: pickPerfScore(source)
|
||
};
|
||
if (source.status) {
|
||
compact.status = String(source.status);
|
||
}
|
||
if (source.renderReason) {
|
||
compact.renderReason = String(source.renderReason);
|
||
}
|
||
if (source.lastRenderDecisionReason) {
|
||
compact.lastRenderDecisionReason = String(source.lastRenderDecisionReason);
|
||
}
|
||
if (source.lastRenderDecisionPolicy) {
|
||
compact.lastRenderDecisionPolicy = String(source.lastRenderDecisionPolicy);
|
||
}
|
||
const suspectedBottleneck = source.suspectedBottleneck || pickTerminalPerfSuspectedBottleneck(source) || "";
|
||
if (suspectedBottleneck) {
|
||
compact.suspectedBottleneck = suspectedBottleneck;
|
||
}
|
||
for (let index = 0; index < TERMINAL_PERF_RECENT_NUMERIC_KEYS.length; index += 1) {
|
||
const key = TERMINAL_PERF_RECENT_NUMERIC_KEYS[index];
|
||
const value = Number(source[key]);
|
||
if (Number.isFinite(value) && value > 0) {
|
||
compact[key] = value;
|
||
}
|
||
}
|
||
return compact;
|
||
}
|
||
|
||
const recorderManager = wx.getRecorderManager();
|
||
let recorderBound = false;
|
||
let activeTerminalPage = null;
|
||
const connectionDiagnosticSampleCache = new Map();
|
||
|
||
function resolveActivationDebugSetting(settings) {
|
||
if (!settings || typeof settings !== "object") return ENABLE_SHELL_ACTIVATION_DEBUG_OVERLAY;
|
||
if (settings.shellActivationDebugOutline === undefined) return ENABLE_SHELL_ACTIVATION_DEBUG_OVERLAY;
|
||
return !!settings.shellActivationDebugOutline;
|
||
}
|
||
|
||
function resolveVoiceInputButtonSetting(settings) {
|
||
if (!settings || typeof settings !== "object") return true;
|
||
if (settings.showVoiceInputButton === undefined) return true;
|
||
return !!settings.showVoiceInputButton;
|
||
}
|
||
|
||
function resolveTtsSpeakableMaxChars(settings) {
|
||
if (!settings || typeof settings !== "object") {
|
||
return DEFAULT_TTS_SPEAKABLE_MAX_CHARS;
|
||
}
|
||
return normalizeTtsSpeakableMaxChars(settings.ttsSpeakableMaxChars);
|
||
}
|
||
|
||
function resolveTtsSegmentMaxChars(settings) {
|
||
if (!settings || typeof settings !== "object") {
|
||
return DEFAULT_TTS_SEGMENT_MAX_CHARS;
|
||
}
|
||
return normalizeTtsSegmentMaxChars(settings.ttsSegmentMaxChars);
|
||
}
|
||
|
||
function resolveDisconnectedHintVisible(status) {
|
||
const normalized = String(status || "");
|
||
return normalized === "disconnected" || normalized === "error";
|
||
}
|
||
|
||
function normalizePositiveInt(value, fallback, min) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
const normalized = Math.round(parsed);
|
||
if (normalized < min) return fallback;
|
||
return normalized;
|
||
}
|
||
|
||
/**
|
||
* 诊断曲线的来源既有运行时内存,也有本地缓存。
|
||
* 这里统一做一次归一化,确保:
|
||
* 1. 只保留非负数;
|
||
* 2. 全部取整,避免图表 meta 出现小数;
|
||
* 3. 长度始终截断到最近 30 个点。
|
||
*/
|
||
function normalizeConnectionDiagnosticSamples(samplesInput) {
|
||
const samples = Array.isArray(samplesInput)
|
||
? samplesInput
|
||
.map((sample) => Number(sample))
|
||
.filter((sample) => Number.isFinite(sample) && sample >= 0)
|
||
.map((sample) => Math.round(sample))
|
||
: [];
|
||
return samples.slice(-CONNECTION_DIAGNOSTIC_SAMPLE_LIMIT);
|
||
}
|
||
|
||
function resolveTouchClientPoint(event) {
|
||
const point =
|
||
(event && event.touches && event.touches[0]) ||
|
||
(event && event.changedTouches && event.changedTouches[0]) ||
|
||
null;
|
||
if (!point) return null;
|
||
const x = Number(point.clientX);
|
||
const y = Number(point.clientY);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||
return { x, y };
|
||
}
|
||
|
||
function resolveTapClientPoint(event) {
|
||
if (event && event.detail) {
|
||
const x = Number(event.detail.x);
|
||
const y = Number(event.detail.y);
|
||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||
return { x, y };
|
||
}
|
||
}
|
||
return resolveTouchClientPoint(event);
|
||
}
|
||
|
||
function cloneOutputRectSnapshot(rect) {
|
||
if (!rect || typeof rect !== "object") {
|
||
return null;
|
||
}
|
||
return {
|
||
left: Number(rect.left) || 0,
|
||
top: Number(rect.top) || 0,
|
||
right: Number(rect.right) || 0,
|
||
bottom: Number(rect.bottom) || 0,
|
||
width: Number(rect.width) || 0,
|
||
height: Number(rect.height) || 0
|
||
};
|
||
}
|
||
|
||
function resolveRuntimeMessage(error, fallback) {
|
||
if (!error) return fallback;
|
||
if (typeof error === "string") return error;
|
||
if (typeof error.message === "string" && error.message.trim()) return error.message.trim();
|
||
if (typeof error.errMsg === "string" && error.errMsg.trim()) return error.errMsg.trim();
|
||
if (typeof error.msg === "string" && error.msg.trim()) return error.msg.trim();
|
||
return fallback;
|
||
}
|
||
|
||
function isRecorderBusyError(error) {
|
||
const message = resolveRuntimeMessage(error, "").toLowerCase();
|
||
return /is recording or paused|operateRecorder:fail/i.test(message);
|
||
}
|
||
|
||
function isRecorderNotStartError(error) {
|
||
const message = resolveRuntimeMessage(error, "").toLowerCase();
|
||
return /operateRecorder:fail recorder not start|recorder not start/i.test(message);
|
||
}
|
||
|
||
function isNormalVoiceCloseError(error) {
|
||
const message = resolveRuntimeMessage(error, "");
|
||
return /语音连接已关闭\(1000\)(?::\s*.*)?$/i.test(message);
|
||
}
|
||
|
||
function isBenignVoiceRuntimeError(error) {
|
||
if (!error) return false;
|
||
if (isRecorderNotStartError(error)) return true;
|
||
if (isNormalVoiceCloseError(error)) return true;
|
||
return false;
|
||
}
|
||
|
||
function buildAnsiInlineStyle(state, options) {
|
||
const source = state || ANSI_RESET_STATE;
|
||
const normalizedOptions = options && typeof options === "object" ? options : {};
|
||
const lineBackground = String(normalizedOptions.lineBackground || "");
|
||
const styles = [];
|
||
if (source.fg) styles.push(`color:${source.fg}`);
|
||
if (source.bg && source.bg !== lineBackground) {
|
||
styles.push(`background-color:${source.bg}`);
|
||
}
|
||
if (source.bold) styles.push("font-weight:700");
|
||
if (source.underline) styles.push("text-decoration:underline");
|
||
return styles.join(";");
|
||
}
|
||
|
||
function buildRenderSegmentStyle(state, renderMetrics, run, options) {
|
||
const style = buildAnsiInlineStyle(state, options);
|
||
const styles = style ? [style] : [];
|
||
if (run && run.fixed) {
|
||
const columns = Math.max(1, Math.round(Number(run.columns) || 1));
|
||
const charWidth = Math.max(1, Number(renderMetrics && renderMetrics.charWidth) || 1);
|
||
const widthPx = (columns * charWidth).toFixed(3).replace(/\.?0+$/, "");
|
||
styles.push(`width:${widthPx}px`);
|
||
}
|
||
return styles.join(";");
|
||
}
|
||
|
||
function buildRenderSegmentsFromLineCells(lineCells, renderMetrics) {
|
||
const runs = buildLineCellRenderRuns(lineCells);
|
||
if (runs.length === 0) {
|
||
return {
|
||
lineStyle: "",
|
||
segments: []
|
||
};
|
||
}
|
||
const lineBackground = resolveUniformLineBackground(runs);
|
||
return {
|
||
lineStyle: lineBackground ? `background-color:${lineBackground};` : "",
|
||
segments: runs.map((run) => ({
|
||
text: run.text,
|
||
fixed: !!run.fixed,
|
||
style: buildRenderSegmentStyle((run && run.style) || ANSI_RESET_STATE, renderMetrics, run, {
|
||
lineBackground
|
||
})
|
||
}))
|
||
};
|
||
}
|
||
|
||
function normalizeAuthType(value) {
|
||
const authType = String(value || "").trim();
|
||
if (authType === "privateKey" || authType === "certificate") return authType;
|
||
return "password";
|
||
}
|
||
|
||
function resolveCredential(server, prefix) {
|
||
const source = server && typeof server === "object" ? server : {};
|
||
const fieldPrefix = String(prefix || "");
|
||
const authType = normalizeAuthType(
|
||
fieldPrefix ? source.jumpHost && source.jumpHost.authType : source.authType
|
||
);
|
||
const passwordKey = fieldPrefix ? `${fieldPrefix}Password` : "password";
|
||
const privateKeyKey = fieldPrefix ? `${fieldPrefix}PrivateKey` : "privateKey";
|
||
const passphraseKey = fieldPrefix ? `${fieldPrefix}Passphrase` : "passphrase";
|
||
const certificateKey = fieldPrefix ? `${fieldPrefix}Certificate` : "certificate";
|
||
if (authType === "privateKey") {
|
||
return {
|
||
type: "privateKey",
|
||
privateKey: String(source[privateKeyKey] || ""),
|
||
passphrase: String(source[passphraseKey] || "")
|
||
};
|
||
}
|
||
if (authType === "certificate") {
|
||
return {
|
||
type: "certificate",
|
||
privateKey: String(source[privateKeyKey] || ""),
|
||
passphrase: String(source[passphraseKey] || ""),
|
||
certificate: String(source[certificateKey] || "")
|
||
};
|
||
}
|
||
return {
|
||
type: "password",
|
||
password: String(source[passwordKey] || "")
|
||
};
|
||
}
|
||
|
||
function bindRecorderEvents() {
|
||
if (recorderBound) return;
|
||
recorderBound = true;
|
||
|
||
recorderManager.onFrameRecorded((event) => {
|
||
if (activeTerminalPage && typeof activeTerminalPage.handleRecorderFrame === "function") {
|
||
activeTerminalPage.handleRecorderFrame(event);
|
||
}
|
||
});
|
||
|
||
recorderManager.onStart(() => {
|
||
if (activeTerminalPage && typeof activeTerminalPage.handleRecorderStart === "function") {
|
||
activeTerminalPage.handleRecorderStart();
|
||
}
|
||
});
|
||
|
||
recorderManager.onStop((event) => {
|
||
if (activeTerminalPage && typeof activeTerminalPage.handleRecorderStop === "function") {
|
||
activeTerminalPage.handleRecorderStop(event);
|
||
}
|
||
});
|
||
|
||
recorderManager.onError((error) => {
|
||
if (activeTerminalPage && typeof activeTerminalPage.handleRecorderError === "function") {
|
||
activeTerminalPage.handleRecorderError(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
function resolveVoiceRecordCategories(settings) {
|
||
const source = settings && typeof settings === "object" ? settings : {};
|
||
const categories = normalizeVoiceRecordCategories(source.voiceRecordCategories);
|
||
return categories.length > 0 ? categories : [DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK];
|
||
}
|
||
|
||
function resolveProjectDirectoryName(projectPath) {
|
||
const normalized = String(projectPath || "")
|
||
.trim()
|
||
.replace(/\/+$/g, "");
|
||
if (!normalized) return "";
|
||
const segments = normalized.split("/").filter(Boolean);
|
||
return segments[segments.length - 1] || "";
|
||
}
|
||
|
||
function buildTerminalTouchDirectionKeys(iconMap, pressedIconMap) {
|
||
return TERMINAL_TOUCH_DIRECTION_KEYS.map((item) => ({
|
||
...item,
|
||
pressKey: `terminal:direction:${item.key}`,
|
||
icon: resolveButtonIcon(item.icon, iconMap),
|
||
pressedIcon: resolveButtonIcon(item.icon, pressedIconMap)
|
||
}));
|
||
}
|
||
|
||
function buildTerminalTouchActionButtons(iconMap, pressedIconMap) {
|
||
return TERMINAL_TOUCH_ACTION_BUTTONS.map((item) => ({
|
||
...item,
|
||
pressKey: `terminal:action:${item.key}`,
|
||
icon: resolveButtonIcon(item.icon, iconMap),
|
||
pressedIcon: resolveButtonIcon(item.icon, pressedIconMap)
|
||
}));
|
||
}
|
||
|
||
function buildTerminalThemePayload(settings) {
|
||
const terminalToolIcons = buildTerminalToolIconMap(settings);
|
||
const terminalToolActiveIcons = buildTerminalToolActiveIconMap(settings);
|
||
return {
|
||
themeStyle: buildThemeStyle(settings),
|
||
uiButtonIcons: buildButtonIconMap(settings),
|
||
uiButtonActiveIcons: buildActiveButtonIconMap(settings),
|
||
uiButtonAccentIcons: buildAccentButtonIconMap(settings),
|
||
terminalToolIcons,
|
||
terminalToolActiveIcons,
|
||
terminalTouchDirectionKeys: buildTerminalTouchDirectionKeys(terminalToolIcons, terminalToolActiveIcons),
|
||
terminalTouchActionButtons: buildTerminalTouchActionButtons(terminalToolIcons, terminalToolActiveIcons),
|
||
terminalTouchToggleIcon: resolveButtonIcon("/assets/icons/keyboard.svg", terminalToolIcons),
|
||
terminalTouchTogglePressedIcon: resolveButtonIcon("/assets/icons/keyboard.svg", terminalToolActiveIcons)
|
||
};
|
||
}
|
||
|
||
function buildTerminalRuntimeTheme(settings) {
|
||
const source = settings && typeof settings === "object" ? settings : {};
|
||
return {
|
||
defaultForeground: String(source.shellTextColor || "#e6f0ff"),
|
||
defaultBackground: String(source.shellBgColor || "#192b4d"),
|
||
defaultCursor: String(source.shellAccentColor || source.shellTextColor || "#9ca9bf")
|
||
};
|
||
}
|
||
|
||
function parseHexColor(value) {
|
||
const raw = String(value || "").trim();
|
||
const match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||
if (!match) return null;
|
||
const normalized =
|
||
match[1].length === 3
|
||
? match[1]
|
||
.split("")
|
||
.map((char) => char + char)
|
||
.join("")
|
||
: match[1];
|
||
return {
|
||
r: parseInt(normalized.slice(0, 2), 16),
|
||
g: parseInt(normalized.slice(2, 4), 16),
|
||
b: parseInt(normalized.slice(4, 6), 16)
|
||
};
|
||
}
|
||
|
||
function toHexChannel(value) {
|
||
const normalized = Math.max(0, Math.min(255, Math.round(Number(value) || 0)));
|
||
return normalized.toString(16).padStart(2, "0");
|
||
}
|
||
|
||
function mixThemeHexColor(leftInput, rightInput, ratio, fallback) {
|
||
const left = parseHexColor(leftInput);
|
||
const right = parseHexColor(rightInput);
|
||
if (!left || !right) {
|
||
return String(fallback || leftInput || rightInput || "#192b4d");
|
||
}
|
||
const t = Math.max(0, Math.min(1, Number(ratio) || 0));
|
||
return `#${toHexChannel(left.r + (right.r - left.r) * t)}${toHexChannel(
|
||
left.g + (right.g - left.g) * t
|
||
)}${toHexChannel(left.b + (right.b - left.b) * t)}`;
|
||
}
|
||
|
||
function invertThemeHexColor(value, fallback) {
|
||
const rgb = parseHexColor(value);
|
||
if (!rgb) {
|
||
return String(fallback || "#e6f0ff");
|
||
}
|
||
return `#${toHexChannel(255 - rgb.r)}${toHexChannel(255 - rgb.g)}${toHexChannel(255 - rgb.b)}`;
|
||
}
|
||
|
||
/**
|
||
* 相对亮度用于判断“该颜色上面应该压深字还是浅字”。
|
||
* 这里只做主题对比度推断,不参与业务数据计算。
|
||
*/
|
||
function getThemeRelativeLuminance(value) {
|
||
const rgb = parseHexColor(value);
|
||
if (!rgb) {
|
||
return 0;
|
||
}
|
||
const normalizeChannel = (channel) => {
|
||
const normalized = channel / 255;
|
||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
return (
|
||
0.2126 * normalizeChannel(rgb.r) + 0.7152 * normalizeChannel(rgb.g) + 0.0722 * normalizeChannel(rgb.b)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 根据底色挑一组稳定的高对比文案色:
|
||
* - 亮底返回深色;
|
||
* - 暗底返回浅色;
|
||
* - 这样图表背景随终端主题翻转时,文字和网格不需要再写死成白色。
|
||
*/
|
||
function resolveThemeContrastColor(backgroundInput, darkColor, lightColor) {
|
||
const background = String(backgroundInput || "");
|
||
return getThemeRelativeLuminance(background) >= 0.42
|
||
? String(darkColor || "#07101d")
|
||
: String(lightColor || "#f5f8ff");
|
||
}
|
||
|
||
function toThemeRgba(value, alpha, fallback) {
|
||
const rgb = parseHexColor(value);
|
||
if (!rgb) {
|
||
return String(fallback || `rgba(25, 43, 77, ${alpha})`);
|
||
}
|
||
const safeAlpha = Math.max(0, Math.min(1, Number(alpha) || 0));
|
||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${safeAlpha})`;
|
||
}
|
||
|
||
/**
|
||
* 诊断曲线卡的主底色直接跟终端前景色走,
|
||
* 深浅终端再分别切一套响应色:
|
||
* - 终端背景偏暗时,卡片会落到浅底,因此曲线与数值改用更深的蓝橙;
|
||
* - 终端背景偏亮时,继续使用当前亮色高光,保证暗底上能看清。
|
||
*/
|
||
function buildConnectionDiagnosticThemeStyles(runtimeThemeInput) {
|
||
const runtimeTheme =
|
||
runtimeThemeInput && typeof runtimeThemeInput === "object"
|
||
? runtimeThemeInput
|
||
: buildTerminalRuntimeTheme({});
|
||
const foreground = String(runtimeTheme.defaultForeground || "#e6f0ff");
|
||
const background = String(runtimeTheme.defaultBackground || "#192b4d");
|
||
const panelBackground = foreground;
|
||
const contentBackground = background;
|
||
const isDarkTerminal = getThemeRelativeLuminance(background) < 0.36;
|
||
const panelText = resolveThemeContrastColor(panelBackground, "#07101d", "#f5f8ff");
|
||
const mixedPanelBottom = mixThemeHexColor(panelBackground, contentBackground, 0.14, panelBackground);
|
||
const metricSurface = mixThemeHexColor(panelBackground, contentBackground, 0.08, panelBackground);
|
||
const chartSurface = mixThemeHexColor(panelBackground, contentBackground, 0.05, panelBackground);
|
||
const cardOutline = mixThemeHexColor(panelBackground, panelText, 0.22, panelText);
|
||
const metricOutline = mixThemeHexColor(panelBackground, panelText, 0.18, panelText);
|
||
const invertedForeground = invertThemeHexColor(panelBackground, contentBackground);
|
||
const responsePalette = isDarkTerminal
|
||
? {
|
||
line: "#0E7AA7",
|
||
glow: "#0B5F82"
|
||
}
|
||
: {
|
||
line: "#67D1FF",
|
||
glow: "#B7F1FF"
|
||
};
|
||
const networkPalette = isDarkTerminal
|
||
? {
|
||
line: "#B66A0A",
|
||
glow: "#8A4F00"
|
||
}
|
||
: {
|
||
line: "#FFB35C",
|
||
glow: "#FFE0A3"
|
||
};
|
||
return {
|
||
cardStyle: `background: radial-gradient(circle at top left, rgba(103, 209, 255, 0.16), transparent 38%), radial-gradient(circle at top right, rgba(255, 179, 92, 0.16), transparent 38%), linear-gradient(180deg, ${toThemeRgba(
|
||
panelBackground,
|
||
0.98,
|
||
"rgba(230, 240, 255, 0.98)"
|
||
)}, ${toThemeRgba(mixedPanelBottom, 0.96, "rgba(215, 226, 243, 0.96)")}); border-color: ${toThemeRgba(
|
||
cardOutline,
|
||
0.18,
|
||
"rgba(7, 16, 29, 0.18)"
|
||
)}; box-shadow: 0 18rpx 40rpx -28rpx ${toThemeRgba(
|
||
contentBackground,
|
||
0.32,
|
||
"rgba(9, 16, 29, 0.32)"
|
||
)}, inset 0 1rpx 0 ${toThemeRgba(panelText, 0.05, "rgba(7, 16, 29, 0.05)")}; color: ${panelText};`,
|
||
responseMetricStyle: `background: linear-gradient(180deg, ${toThemeRgba(
|
||
metricSurface,
|
||
0.94,
|
||
"rgba(230, 240, 255, 0.94)"
|
||
)}, ${toThemeRgba(panelBackground, 0.98, "rgba(230, 240, 255, 0.98)")}), linear-gradient(90deg, ${toThemeRgba(
|
||
responsePalette.line,
|
||
0.1,
|
||
"rgba(103, 209, 255, 0.1)"
|
||
)}, transparent 72%); border-color: ${toThemeRgba(
|
||
metricOutline,
|
||
0.18,
|
||
"rgba(7, 16, 29, 0.18)"
|
||
)}; --diagnostic-stat-label-color: ${toThemeRgba(
|
||
panelText,
|
||
0.74,
|
||
"rgba(7, 16, 29, 0.74)"
|
||
)}; --diagnostic-stat-divider-color: ${toThemeRgba(
|
||
panelText,
|
||
0.56,
|
||
"rgba(7, 16, 29, 0.56)"
|
||
)}; --diagnostic-axis-pill-bg: ${toThemeRgba(
|
||
responsePalette.line,
|
||
isDarkTerminal ? 0.12 : 0.14,
|
||
"rgba(103, 209, 255, 0.14)"
|
||
)}; --diagnostic-axis-pill-border: ${toThemeRgba(
|
||
responsePalette.line,
|
||
isDarkTerminal ? 0.26 : 0.22,
|
||
"rgba(103, 209, 255, 0.22)"
|
||
)}; --diagnostic-axis-pill-color: ${isDarkTerminal ? responsePalette.line : responsePalette.glow}; --diagnostic-stat-value-border: ${toThemeRgba(
|
||
responsePalette.line,
|
||
isDarkTerminal ? 0.34 : 0.24,
|
||
"rgba(103, 209, 255, 0.24)"
|
||
)}; --diagnostic-stat-value-color: ${isDarkTerminal ? responsePalette.line : responsePalette.glow};`,
|
||
networkMetricStyle: `background: linear-gradient(180deg, ${toThemeRgba(
|
||
metricSurface,
|
||
0.94,
|
||
"rgba(230, 240, 255, 0.94)"
|
||
)}, ${toThemeRgba(panelBackground, 0.98, "rgba(230, 240, 255, 0.98)")}), linear-gradient(90deg, ${toThemeRgba(
|
||
networkPalette.line,
|
||
0.1,
|
||
"rgba(255, 179, 92, 0.1)"
|
||
)}, transparent 72%); border-color: ${toThemeRgba(
|
||
metricOutline,
|
||
0.18,
|
||
"rgba(7, 16, 29, 0.18)"
|
||
)}; --diagnostic-stat-label-color: ${toThemeRgba(
|
||
panelText,
|
||
0.74,
|
||
"rgba(7, 16, 29, 0.74)"
|
||
)}; --diagnostic-stat-divider-color: ${toThemeRgba(
|
||
panelText,
|
||
0.56,
|
||
"rgba(7, 16, 29, 0.56)"
|
||
)}; --diagnostic-axis-pill-bg: ${toThemeRgba(
|
||
networkPalette.line,
|
||
isDarkTerminal ? 0.12 : 0.16,
|
||
"rgba(255, 179, 92, 0.16)"
|
||
)}; --diagnostic-axis-pill-border: ${toThemeRgba(
|
||
networkPalette.line,
|
||
isDarkTerminal ? 0.28 : 0.24,
|
||
"rgba(255, 179, 92, 0.24)"
|
||
)}; --diagnostic-axis-pill-color: ${isDarkTerminal ? networkPalette.line : networkPalette.glow}; --diagnostic-stat-value-border: ${toThemeRgba(
|
||
networkPalette.line,
|
||
isDarkTerminal ? 0.34 : 0.26,
|
||
"rgba(255, 179, 92, 0.26)"
|
||
)}; --diagnostic-stat-value-color: ${isDarkTerminal ? networkPalette.line : networkPalette.glow};`,
|
||
chartCardBgColor: toThemeRgba(chartSurface, 0.98, "rgba(18, 29, 49, 0.98)"),
|
||
chartCardBorderColor: "transparent",
|
||
chartCardGlowColor: toThemeRgba(invertedForeground, 0.08, "rgba(151, 194, 255, 0.08)"),
|
||
chartGridColor: toThemeRgba(panelText, 0.16, "rgba(7, 16, 29, 0.16)"),
|
||
responseLineColor: responsePalette.line,
|
||
responseGlowColor: responsePalette.glow,
|
||
responseFillColor: responsePalette.line,
|
||
networkLineColor: networkPalette.line,
|
||
networkGlowColor: networkPalette.glow,
|
||
networkFillColor: networkPalette.line,
|
||
chartImageShellStyle: `background: ${toThemeRgba(chartSurface, 0.98, "rgba(230, 240, 255, 0.98)")};`
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 会话信息浮层与时延浮层保持同源配色:
|
||
* - 外层卡片直接复用时延面板的主题底色;
|
||
* - 通过 CSS 变量把 hero、胶囊、明细卡的颜色一次性下发给 WXML;
|
||
* - 这样页面层可以大胆重排结构,但仍保持和终端主题同一套色彩逻辑。
|
||
*/
|
||
function buildSessionInfoThemeStyles(runtimeThemeInput) {
|
||
const runtimeTheme =
|
||
runtimeThemeInput && typeof runtimeThemeInput === "object"
|
||
? runtimeThemeInput
|
||
: buildTerminalRuntimeTheme({});
|
||
const foreground = String(runtimeTheme.defaultForeground || "#e6f0ff");
|
||
const background = String(runtimeTheme.defaultBackground || "#192b4d");
|
||
const panelBackground = foreground;
|
||
const contentBackground = background;
|
||
const panelText = resolveThemeContrastColor(panelBackground, "#07101d", "#f5f8ff");
|
||
const rowSurfaceTop = mixThemeHexColor(panelBackground, contentBackground, 0.1, panelBackground);
|
||
const rowSurfaceBottom = mixThemeHexColor(panelBackground, contentBackground, 0.04, panelBackground);
|
||
const rowOutline = mixThemeHexColor(panelBackground, panelText, 0.18, panelText);
|
||
const accent = String(runtimeTheme.defaultCursor || "#5bd2ff");
|
||
const accentSoft = mixThemeHexColor(accent, panelBackground, 0.18, accent);
|
||
const warmAccent = mixThemeHexColor("#ffb35c", accent, 0.18, "#ffb35c");
|
||
const successAccent = mixThemeHexColor("#34c759", accent, 0.12, "#34c759");
|
||
const dangerAccent = mixThemeHexColor("#ff6b6b", panelText, 0.08, "#ff6b6b");
|
||
const themeCard = buildConnectionDiagnosticThemeStyles(runtimeTheme);
|
||
return {
|
||
cardStyle: `${themeCard.cardStyle}; --session-info-text: ${panelText}; --session-info-muted: ${toThemeRgba(
|
||
panelText,
|
||
0.68,
|
||
"rgba(7, 16, 29, 0.68)"
|
||
)}; --session-info-outline: ${toThemeRgba(rowOutline, 0.16, "rgba(7, 16, 29, 0.16)")}; --session-info-outline-strong: ${toThemeRgba(
|
||
rowOutline,
|
||
0.3,
|
||
"rgba(7, 16, 29, 0.3)"
|
||
)}; --session-info-shadow: ${toThemeRgba(contentBackground, 0.22, "rgba(9, 16, 29, 0.22)")}; --session-info-surface-top: ${toThemeRgba(
|
||
rowSurfaceTop,
|
||
0.94,
|
||
"rgba(230, 240, 255, 0.94)"
|
||
)}; --session-info-surface-bottom: ${toThemeRgba(
|
||
rowSurfaceBottom,
|
||
0.98,
|
||
"rgba(230, 240, 255, 0.98)"
|
||
)}; --session-info-accent: ${accent}; --session-info-accent-soft: ${toThemeRgba(
|
||
accentSoft,
|
||
0.28,
|
||
"rgba(91, 210, 255, 0.28)"
|
||
)}; --session-info-warm: ${warmAccent}; --session-info-warm-soft: ${toThemeRgba(
|
||
warmAccent,
|
||
0.24,
|
||
"rgba(255, 179, 92, 0.24)"
|
||
)}; --session-info-success: ${successAccent}; --session-info-success-soft: ${toThemeRgba(
|
||
successAccent,
|
||
0.18,
|
||
"rgba(52, 199, 89, 0.18)"
|
||
)}; --session-info-danger: ${dangerAccent}; --session-info-danger-soft: ${toThemeRgba(
|
||
dangerAccent,
|
||
0.16,
|
||
"rgba(255, 107, 107, 0.16)"
|
||
)};`,
|
||
titleStyle: `color: ${panelText};`,
|
||
heroStyle: `box-shadow: inset 0 0 0 1rpx ${toThemeRgba(
|
||
rowOutline,
|
||
0.12,
|
||
"rgba(7, 16, 29, 0.12)"
|
||
)}, 0 24rpx 50rpx -34rpx ${toThemeRgba(contentBackground, 0.28, "rgba(9, 16, 29, 0.28)")};`
|
||
};
|
||
}
|
||
|
||
function localizeTerminalMessage(language, copyInput, rawMessage, extra) {
|
||
const copy = copyInput && typeof copyInput === "object" ? copyInput : buildPageCopy(language, "terminal");
|
||
const message = String(rawMessage || "").trim();
|
||
if (!message) return message;
|
||
const errors = (copy && copy.errors) || {};
|
||
if (message === "服务器不存在") return errors.serverNotFound || message;
|
||
if (message === "会话未连接") return errors.sessionNotConnected || message;
|
||
if (message === "运维配置缺失,请联系管理员") return errors.opsConfigMissing || message;
|
||
if (message === "网关错误") return errors.gatewayError || message;
|
||
if (message === "连接异常") return errors.connectionException || message;
|
||
if (message === "会话就绪超时") return errors.sessionReadyTimeout || message;
|
||
if (message === "服务器配置不完整") return errors.serverConfigIncomplete || message;
|
||
if (message === "连接失败") return errors.connectionFailed || message;
|
||
if (message === "会话已断开") return errors.sessionDisconnected || message;
|
||
if (message === "等待会话连接超时") return errors.waitSessionTimeout || message;
|
||
if (message === "Codex 启动失败") return errors.codexLaunchFailed || message;
|
||
if (message === "Copilot 启动失败") return errors.copilotLaunchFailed || message;
|
||
if (message === "服务器未装codex") return errors.codexNotInstalled || message;
|
||
if (message === "Codex 正在启动中") return errors.codexLaunching || message;
|
||
if (message === "等待 Codex 启动结果超时") return errors.waitCodexTimeout || message;
|
||
if (message === "隐私授权失败") return errors.privacyAuthFailed || message;
|
||
if (message === "未同意隐私协议,暂时无法使用录音") return errors.privacyDenied || message;
|
||
if (
|
||
message ===
|
||
"小程序后台未完成隐私声明,录音接口已被微信平台禁用,请在微信公众平台补充用户隐私保护指引后重新提审并发布"
|
||
) {
|
||
return errors.privacyApiBanned || message;
|
||
}
|
||
if (
|
||
message ===
|
||
"小程序隐私指引未声明录音相关用途,请在微信公众平台“服务内容声明-用户隐私保护指引”补充麦克风采集说明"
|
||
) {
|
||
return errors.privacyScopeUndeclared || message;
|
||
}
|
||
if (message === "麦克风权限未开启,请在设置中允许录音") return errors.micPermissionDenied || message;
|
||
if (message === "读取麦克风权限失败") return errors.micPermissionReadFailed || message;
|
||
if (message === "录音器忙,请稍后重试") return errors.recorderBusy || message;
|
||
if (message === "录音采集失败") return errors.recordCaptureFailed || message;
|
||
if (message === "语音识别失败") return errors.asrFailed || message;
|
||
if (message === "语音网关连接失败,请检查小程序 socket 合法域名") return errors.asrGatewayFailed || message;
|
||
if (message === "语音网关连接失败,请检查网络或网关配置") {
|
||
return errors.asrGatewayConnectFailed || message;
|
||
}
|
||
if (message === "语音服务连接超时,请稍后重试") return errors.asrTimeout || message;
|
||
if (message === "剪贴板为空") return errors.clipboardEmpty || message;
|
||
if (message === "读取剪贴板失败") return errors.clipboardReadFailed || message;
|
||
if (message === "无可记录内容") return errors.noRecordContent || message;
|
||
if (message === "已记录到闪念列表") return errors.recordSaved || message;
|
||
if (message === "当前没有可播报内容") return errors.noSpeakableContent || message;
|
||
if (message === "当前内容不适合播报") return errors.contentNotSpeakable || message;
|
||
if (message === "播报文本过长") return errors.ttsTextTooLong || message;
|
||
if (message === "语音生成超时,请稍后重试") return errors.ttsTimeout || message;
|
||
if (message.startsWith("TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限")) {
|
||
return errors.ttsUpstreamRejected || message;
|
||
}
|
||
if (message === "语音生成失败") return errors.ttsSynthesizeFailed || message;
|
||
if (message === "音频播放失败,请检查网络") return errors.ttsPlayFailed || message;
|
||
if (message === "TTS 服务未配置") return errors.ttsUnavailable || message;
|
||
if (/^codex工作目录(.+)不存在$/.test(message)) {
|
||
const matched = message.match(/^codex工作目录(.+)不存在$/);
|
||
const projectPath = matched ? matched[1] : (extra && extra.projectPath) || "";
|
||
if (errors.codexWorkingDirMissing) {
|
||
return formatTemplate(errors.codexWorkingDirMissing, { projectPath });
|
||
}
|
||
if (normalizeUiLanguage(language) === "en")
|
||
return `Codex working directory ${projectPath} does not exist`;
|
||
if (normalizeUiLanguage(language) === "zh-Hant") return `codex 工作目錄 ${projectPath} 不存在`;
|
||
}
|
||
return localizeServerValidationMessage(language, message);
|
||
}
|
||
|
||
/**
|
||
* 终端页(对齐 Web TerminalView 的视觉骨架):
|
||
* 1. 顶部工具栏:codex/清屏 + 状态/时延芯片 + 连接动作开关;
|
||
* 2. 中部:输出区 + 输入区;
|
||
* 3. 底部:Frame2256 语音区(voice/record/send + clear/cancel)。
|
||
*/
|
||
Page({
|
||
data: {
|
||
...buildSvgButtonPressData(),
|
||
themeStyle: "",
|
||
uiButtonIcons: {},
|
||
uiButtonActiveIcons: {},
|
||
uiButtonAccentIcons: {},
|
||
terminalToolIcons: {},
|
||
terminalToolActiveIcons: {},
|
||
terminalTouchToggleIcon: "",
|
||
terminalTouchTogglePressedIcon: "",
|
||
copy: buildPageCopy("zh-Hans", "terminal"),
|
||
serverId: "",
|
||
serverLabel: "remoteconn",
|
||
sessionId: "-",
|
||
statusText: "idle",
|
||
statusLabel: getStatusLabel("zh-Hans", "idle"),
|
||
statusClass: "idle",
|
||
disconnectedHintVisible: false,
|
||
disconnectedHintText: DISCONNECTED_HINT_TEXT,
|
||
latencyMs: "--",
|
||
connectionDiagnosticsVisible: false,
|
||
sessionInfoVisible: false,
|
||
sessionInfoTitle: "会话信息",
|
||
sessionInfoHero: {
|
||
eyebrow: "",
|
||
name: "",
|
||
subtitle: "",
|
||
routeLabel: "",
|
||
route: ""
|
||
},
|
||
sessionInfoStatusChips: [],
|
||
sessionInfoDetailItems: [],
|
||
sessionInfoTheme: {
|
||
cardStyle: "",
|
||
titleStyle: "",
|
||
heroStyle: ""
|
||
},
|
||
connectionDiagnosticResponseSamples: [],
|
||
connectionDiagnosticNetworkSamples: [],
|
||
connectionDiagnosticCombinedChart: {
|
||
imageUri: "",
|
||
cardStyle: "",
|
||
responseMetricStyle: "",
|
||
responseCardLabel: "网关响应(ms)",
|
||
responseStatItems: [],
|
||
networkMetricStyle: "",
|
||
networkCardLabel: "网络时延(ms)",
|
||
networkStatItems: [],
|
||
chartImageShellStyle: ""
|
||
},
|
||
connectionActionText: "重连",
|
||
connectionActionReconnect: true,
|
||
connectionActionDisabled: false,
|
||
ttsEnabled: false,
|
||
ttsState: "idle",
|
||
ttsRoundId: "",
|
||
ttsCurrentRoundText: "",
|
||
ttsLastStableHash: "",
|
||
ttsLastAudioUrl: "",
|
||
ttsErrorMessage: "",
|
||
ttsSpeakableMaxChars: DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||
ttsSegmentMaxChars: DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||
aiLaunchBusy: false,
|
||
activeAiProvider: "",
|
||
outputRenderLines: [],
|
||
outputScrollTop: 0,
|
||
outputTopSpacerPx: 0,
|
||
outputBottomSpacerPx: 0,
|
||
outputKeyboardInsetPx: 0,
|
||
outputLineHeightPx: 21,
|
||
shellInputFocus: false,
|
||
shellInputValue: "",
|
||
shellInputCursor: 0,
|
||
terminalCaretVisible: false,
|
||
terminalCaretLeftPx: 0,
|
||
terminalCaretTopPx: 0,
|
||
terminalCaretHeightPx: 24,
|
||
activationDebugEnabled: ENABLE_SHELL_ACTIVATION_DEBUG_OVERLAY,
|
||
shellMetricsAsciiProbeText: SHELL_METRICS_ASCII_PROBE_TEXT,
|
||
shellMetricsWideProbeText: SHELL_METRICS_WIDE_PROBE_TEXT,
|
||
activationDebugVisible: ENABLE_SHELL_ACTIVATION_DEBUG_OVERLAY,
|
||
activationDebugTopPx: 0,
|
||
activationDebugHeightPx: 0,
|
||
inputText: "",
|
||
draftText: "",
|
||
showVoiceInputButton: true,
|
||
voiceRecordCategories: [],
|
||
selectedRecordCategory: "",
|
||
voicePanelVisible: false,
|
||
voiceFloatLeft: VOICE_FLOAT_GAP_PX,
|
||
voiceFloatBottom: VOICE_FLOAT_GAP_PX,
|
||
voicePanelWidthPx: 320,
|
||
voiceButtonSizePx: 32,
|
||
voiceActionsOffsetX: 0,
|
||
frameOpacity: VOICE_IDLE_OPACITY,
|
||
voiceHolding: false,
|
||
touchToolsExpanded: false,
|
||
touchShiftMode: TOUCH_SHIFT_MODE_OFF,
|
||
terminalTouchDirectionKeys: TERMINAL_TOUCH_DIRECTION_KEYS,
|
||
terminalTouchActionButtons: TERMINAL_TOUCH_ACTION_BUTTONS
|
||
},
|
||
|
||
getCurrentLanguage(settingsInput) {
|
||
const source = settingsInput && typeof settingsInput === "object" ? settingsInput : getSettings();
|
||
return normalizeUiLanguage(source.uiLanguage);
|
||
},
|
||
|
||
applyLocale(settingsInput, statusInput) {
|
||
const settings = settingsInput && typeof settingsInput === "object" ? settingsInput : getSettings();
|
||
const language = this.getCurrentLanguage(settings);
|
||
const copy = buildPageCopy(language, "terminal");
|
||
const status = String(statusInput || this.data.statusText || "idle");
|
||
wx.setNavigationBarTitle({ title: copy.navTitle || "终端" });
|
||
return {
|
||
copy,
|
||
statusLabel: getStatusLabel(language, status),
|
||
disconnectedHintText: copy.disconnectedHint || DISCONNECTED_HINT_TEXT,
|
||
connectionActionText: RECONNECT_STATES.has(status)
|
||
? copy?.connectionAction?.reconnect || "重连"
|
||
: copy?.connectionAction?.disconnect || "断开"
|
||
};
|
||
},
|
||
|
||
localizeTerminalMessage(rawMessage, extra) {
|
||
return localizeTerminalMessage(this.getCurrentLanguage(), this.data.copy, rawMessage, extra);
|
||
},
|
||
|
||
showLocalizedToast(rawMessage, icon, extra) {
|
||
wx.showToast({
|
||
title: this.localizeTerminalMessage(rawMessage, extra),
|
||
icon: icon || "none"
|
||
});
|
||
},
|
||
|
||
normalizeActiveAiProvider(provider) {
|
||
const normalized = String(provider || "").trim();
|
||
return normalized === "codex" || normalized === "copilot" ? normalized : "";
|
||
},
|
||
|
||
resolveActiveAiProviderLabel(provider) {
|
||
return this.normalizeActiveAiProvider(provider) === "copilot" ? "Copilot" : "Codex";
|
||
},
|
||
|
||
/**
|
||
* AI 前台态需要跟着当前终端会话一起续接:
|
||
* 1. 挂起/恢复时保留 provider,避免回到旧会话后又把启动命令打进正在运行的 AI;
|
||
* 2. 断开/异常时清空,避免把已销毁的前台态误带到新 SSH 会话。
|
||
*/
|
||
syncActiveAiProvider(provider, options) {
|
||
const normalized = this.normalizeActiveAiProvider(provider);
|
||
this.activeAiProvider = normalized;
|
||
if (this.data.activeAiProvider !== normalized) {
|
||
this.setData({
|
||
activeAiProvider: normalized,
|
||
...this.buildSessionInfoPayload({ activeAiProvider: normalized })
|
||
});
|
||
}
|
||
if (normalized !== "codex") {
|
||
this.activeCodexSandboxMode = "";
|
||
}
|
||
if (!normalized) {
|
||
this.aiRuntimeExitCarry = "";
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
}
|
||
if (this.ttsRuntime && normalized !== "codex") {
|
||
this.ttsRuntime.codexReady = false;
|
||
}
|
||
if (options && options.persist === false) {
|
||
return normalized;
|
||
}
|
||
const status = String(this.data.statusText || "");
|
||
if (["connecting", "auth_pending", "connected"].includes(status)) {
|
||
this.persistTerminalSessionStatus(status);
|
||
}
|
||
return normalized;
|
||
},
|
||
|
||
notifyAiAlreadyRunning(provider) {
|
||
const label = this.resolveActiveAiProviderLabel(provider || this.activeAiProvider);
|
||
const template =
|
||
(this.data.copy && this.data.copy.toast && this.data.copy.toast.aiAlreadyRunning) ||
|
||
"{provider} 正在当前会话中运行,请先退出后再重启";
|
||
wx.showToast({
|
||
title: formatTemplate(template, { provider: label }),
|
||
icon: "none"
|
||
});
|
||
},
|
||
|
||
ensureAiLaunchAllowed() {
|
||
const activeProvider = this.normalizeActiveAiProvider(this.activeAiProvider);
|
||
if (!activeProvider) {
|
||
return true;
|
||
}
|
||
this.notifyAiAlreadyRunning(activeProvider);
|
||
return false;
|
||
},
|
||
|
||
/**
|
||
* 退出标记使用未知 OSC:
|
||
* - 命中时解除当前会话的 AI 前台锁;
|
||
* - 不命中时原样透传文本,不影响 VT 语义;
|
||
* - 若 chunk 末尾只到了一半控制序列,仅缓存那段 `ESC ] ...` 尾巴。
|
||
*/
|
||
consumeAiRuntimeOutput(data) {
|
||
const chunk = String(data || "");
|
||
if (!chunk) {
|
||
return chunk;
|
||
}
|
||
const result = consumeAiRuntimeExitMarkers(chunk, this.aiRuntimeExitCarry);
|
||
this.aiRuntimeExitCarry = result.carry;
|
||
if (Array.isArray(result.exitedProviders) && result.exitedProviders.length > 0) {
|
||
const exitedProvider = this.normalizeActiveAiProvider(
|
||
result.exitedProviders[result.exitedProviders.length - 1]
|
||
);
|
||
if (!this.activeAiProvider || !exitedProvider || this.activeAiProvider === exitedProvider) {
|
||
this.syncActiveAiProvider("");
|
||
}
|
||
}
|
||
return result.text;
|
||
},
|
||
|
||
applyTtsInnerAudioOptions() {
|
||
if (typeof wx === "undefined" || typeof wx.setInnerAudioOption !== "function") {
|
||
return;
|
||
}
|
||
try {
|
||
wx.setInnerAudioOption({
|
||
mixWithOther: true,
|
||
obeyMuteSwitch: false,
|
||
fail: (error) => {
|
||
console.warn("[terminal.tts.set_inner_audio_option]", error);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.warn("[terminal.tts.set_inner_audio_option]", error);
|
||
}
|
||
},
|
||
|
||
initTtsRuntime() {
|
||
this.ttsRuntime = {
|
||
codexReady: false,
|
||
roundSeq: 0,
|
||
round: null,
|
||
stableTimer: null,
|
||
confirmTimer: null,
|
||
audioContext: null,
|
||
pendingAudioUrl: "",
|
||
playbackJobSeq: 0,
|
||
activePlaybackJobId: 0,
|
||
playQueue: [],
|
||
playingSegmentIndex: -1,
|
||
playbackPhase: "idle",
|
||
playbackKickTimer: null,
|
||
playbackLoadTimer: null
|
||
};
|
||
},
|
||
|
||
getTtsSpeakableBuildOptions() {
|
||
return {
|
||
maxChars: normalizeTtsSpeakableMaxChars(this.data.ttsSpeakableMaxChars)
|
||
};
|
||
},
|
||
|
||
getTtsSegmentSplitOptions() {
|
||
return {
|
||
maxChars: normalizeTtsSegmentMaxChars(this.data.ttsSegmentMaxChars)
|
||
};
|
||
},
|
||
|
||
clearTtsStabilityTimers() {
|
||
if (!this.ttsRuntime) return;
|
||
if (this.ttsRuntime.stableTimer) {
|
||
clearTimeout(this.ttsRuntime.stableTimer);
|
||
this.ttsRuntime.stableTimer = null;
|
||
}
|
||
if (this.ttsRuntime.confirmTimer) {
|
||
clearTimeout(this.ttsRuntime.confirmTimer);
|
||
this.ttsRuntime.confirmTimer = null;
|
||
}
|
||
},
|
||
|
||
clearTtsPlaybackTimers() {
|
||
if (!this.ttsRuntime) return;
|
||
if (this.ttsRuntime.playbackKickTimer) {
|
||
clearTimeout(this.ttsRuntime.playbackKickTimer);
|
||
this.ttsRuntime.playbackKickTimer = null;
|
||
}
|
||
if (this.ttsRuntime.playbackLoadTimer) {
|
||
clearTimeout(this.ttsRuntime.playbackLoadTimer);
|
||
this.ttsRuntime.playbackLoadTimer = null;
|
||
}
|
||
},
|
||
|
||
finishTtsRoundCapture() {
|
||
if (!this.ttsRuntime) return;
|
||
this.clearTtsStabilityTimers();
|
||
this.ttsRuntime.round = null;
|
||
this.setData({
|
||
ttsRoundId: "",
|
||
ttsCurrentRoundText: ""
|
||
});
|
||
},
|
||
|
||
clearTtsPlaybackQueue() {
|
||
if (!this.ttsRuntime) return;
|
||
this.clearTtsPlaybackTimers();
|
||
this.ttsRuntime.activePlaybackJobId = 0;
|
||
this.ttsRuntime.playQueue = [];
|
||
this.ttsRuntime.playingSegmentIndex = -1;
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
this.ttsRuntime.playbackPhase = "idle";
|
||
},
|
||
|
||
createTtsPlaybackJob(segments) {
|
||
if (!this.ttsRuntime) {
|
||
this.initTtsRuntime();
|
||
}
|
||
const runtime = this.ttsRuntime;
|
||
runtime.playbackJobSeq += 1;
|
||
runtime.activePlaybackJobId = runtime.playbackJobSeq;
|
||
runtime.playQueue = (Array.isArray(segments) ? segments : []).map((text) => ({
|
||
text: String(text || ""),
|
||
playbackUrl: "",
|
||
remoteAudioUrl: "",
|
||
ready: false,
|
||
promise: null,
|
||
useRemotePlayback: false
|
||
}));
|
||
runtime.playingSegmentIndex = -1;
|
||
runtime.pendingAudioUrl = "";
|
||
runtime.playbackPhase = "idle";
|
||
return runtime.activePlaybackJobId;
|
||
},
|
||
|
||
isActiveTtsPlaybackJob(jobId) {
|
||
return !!this.ttsRuntime && Number(jobId) > 0 && this.ttsRuntime.activePlaybackJobId === Number(jobId);
|
||
},
|
||
|
||
async prepareTtsQueueItem(jobId, segmentIndex) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return null;
|
||
}
|
||
const runtime = this.ttsRuntime;
|
||
const item = runtime.playQueue[segmentIndex];
|
||
if (!item) {
|
||
return null;
|
||
}
|
||
if (item.ready && item.playbackUrl) {
|
||
return item;
|
||
}
|
||
if (item.promise) {
|
||
return item.promise;
|
||
}
|
||
|
||
const task = synthesizeTerminalSpeech({
|
||
text: item.text
|
||
})
|
||
.then((response) => {
|
||
if (!this.isActiveTtsPlaybackJob(jobId)) {
|
||
return null;
|
||
}
|
||
item.playbackUrl = String(response.audioUrl || "").trim();
|
||
item.remoteAudioUrl = String(response.remoteAudioUrl || response.audioUrl || "").trim();
|
||
item.ready = !!item.playbackUrl;
|
||
return item.ready ? item : null;
|
||
})
|
||
.finally(() => {
|
||
if (item.promise === task) {
|
||
item.promise = null;
|
||
}
|
||
});
|
||
|
||
item.promise = task;
|
||
return task;
|
||
},
|
||
|
||
prefetchNextTtsQueueItem(jobId, currentIndex) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return;
|
||
}
|
||
const nextIndex = Number(currentIndex) + 1;
|
||
const nextItem = this.ttsRuntime.playQueue[nextIndex];
|
||
if (!nextItem || nextItem.ready || nextItem.promise) {
|
||
return;
|
||
}
|
||
this.prepareTtsQueueItem(jobId, nextIndex).catch((error) => {
|
||
if (!this.isActiveTtsPlaybackJob(jobId)) {
|
||
return;
|
||
}
|
||
console.warn("[terminal.tts.prefetch]", error);
|
||
});
|
||
},
|
||
|
||
failTtsPlayback(rawMessage) {
|
||
const message = this.localizeTerminalMessage(rawMessage);
|
||
if (this.ttsRuntime && this.ttsRuntime.audioContext) {
|
||
this.clearTtsPlaybackTimers();
|
||
this.ttsRuntime.playbackPhase = "stopping";
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
try {
|
||
this.ttsRuntime.audioContext.stop();
|
||
} catch (error) {
|
||
console.warn("[terminal.tts.fail.stop]", error);
|
||
}
|
||
}
|
||
if (this.ttsRuntime) {
|
||
this.clearTtsPlaybackQueue();
|
||
}
|
||
this.setData({
|
||
ttsState: "error",
|
||
ttsErrorMessage: message
|
||
});
|
||
this.showLocalizedToast(rawMessage, "none");
|
||
},
|
||
|
||
scheduleTtsPlaybackLoadGuards(jobId, segmentIndex, playbackUrl) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime || !this.ttsRuntime.audioContext) {
|
||
return;
|
||
}
|
||
const runtime = this.ttsRuntime;
|
||
const expectedUrl = String(playbackUrl || "").trim();
|
||
if (!expectedUrl) {
|
||
return;
|
||
}
|
||
this.clearTtsPlaybackTimers();
|
||
runtime.playbackKickTimer = setTimeout(() => {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime || !this.ttsRuntime.audioContext) {
|
||
return;
|
||
}
|
||
if (this.ttsRuntime.playbackPhase !== "loading") {
|
||
return;
|
||
}
|
||
if (this.ttsRuntime.playingSegmentIndex !== segmentIndex) {
|
||
return;
|
||
}
|
||
const pendingAudioUrl = String(this.ttsRuntime.pendingAudioUrl || "");
|
||
if (pendingAudioUrl !== expectedUrl) {
|
||
return;
|
||
}
|
||
if (String(this.ttsRuntime.audioContext.src || "") !== expectedUrl) {
|
||
return;
|
||
}
|
||
try {
|
||
this.ttsRuntime.audioContext.play();
|
||
} catch (error) {
|
||
console.warn("[terminal.tts.play.kick]", error);
|
||
}
|
||
}, TTS_PLAYBACK_START_KICK_MS);
|
||
runtime.playbackLoadTimer = setTimeout(() => {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return;
|
||
}
|
||
if (this.ttsRuntime.playbackPhase !== "loading") {
|
||
return;
|
||
}
|
||
if (this.ttsRuntime.playingSegmentIndex !== segmentIndex) {
|
||
return;
|
||
}
|
||
const pendingAudioUrl = String(this.ttsRuntime.pendingAudioUrl || "");
|
||
if (pendingAudioUrl !== expectedUrl) {
|
||
return;
|
||
}
|
||
const recovered = this.retryTtsQueueSegmentPlayback(jobId, segmentIndex, "load_timeout");
|
||
if (!recovered) {
|
||
this.failTtsPlayback("音频播放失败,请检查网络");
|
||
}
|
||
}, TTS_PLAYBACK_LOAD_TIMEOUT_MS);
|
||
},
|
||
|
||
retryTtsQueueSegmentPlayback(jobId, segmentIndex, reason) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return false;
|
||
}
|
||
const item = this.ttsRuntime.playQueue[segmentIndex];
|
||
if (!item) {
|
||
return false;
|
||
}
|
||
const remoteAudioUrl = String(item.remoteAudioUrl || "").trim();
|
||
const localAudioUrl = String(item.playbackUrl || "").trim();
|
||
/**
|
||
* 优先用本地缓存是为了缩短首播等待;
|
||
* 但若缓存文件在个别机型上偶发不可播,则只允许回退一次远端 URL。
|
||
*/
|
||
if (!remoteAudioUrl || remoteAudioUrl === localAudioUrl || item.useRemotePlayback) {
|
||
return false;
|
||
}
|
||
const audioContext = this.replaceTtsAudioContext();
|
||
item.useRemotePlayback = true;
|
||
this.clearTtsPlaybackTimers();
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.playbackPhase = "switching";
|
||
}
|
||
if (!this.ttsRuntime) {
|
||
return false;
|
||
}
|
||
this.ttsRuntime.pendingAudioUrl = remoteAudioUrl;
|
||
this.ttsRuntime.playbackPhase = "loading";
|
||
this.setData({
|
||
ttsLastAudioUrl: remoteAudioUrl,
|
||
ttsErrorMessage: "",
|
||
ttsState: "preparing"
|
||
});
|
||
audioContext.src = remoteAudioUrl;
|
||
this.scheduleTtsPlaybackLoadGuards(jobId, segmentIndex, remoteAudioUrl);
|
||
console.warn("[terminal.tts.retry_remote]", { reason, segmentIndex });
|
||
return true;
|
||
},
|
||
|
||
async playTtsQueueSegment(jobId, segmentIndex) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return false;
|
||
}
|
||
const item = await this.prepareTtsQueueItem(jobId, segmentIndex);
|
||
if (!item || !this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return false;
|
||
}
|
||
const playbackUrl =
|
||
item.useRemotePlayback && item.remoteAudioUrl
|
||
? String(item.remoteAudioUrl || "").trim()
|
||
: String(item.playbackUrl || "").trim();
|
||
if (!playbackUrl) {
|
||
return false;
|
||
}
|
||
const audioContext = this.replaceTtsAudioContext();
|
||
this.ttsRuntime.playingSegmentIndex = segmentIndex;
|
||
this.ttsRuntime.pendingAudioUrl = playbackUrl;
|
||
this.ttsRuntime.playbackPhase = "loading";
|
||
this.setData({
|
||
ttsLastAudioUrl: item.remoteAudioUrl,
|
||
ttsErrorMessage: "",
|
||
ttsState: "preparing"
|
||
});
|
||
audioContext.src = playbackUrl;
|
||
this.scheduleTtsPlaybackLoadGuards(jobId, segmentIndex, playbackUrl);
|
||
return true;
|
||
},
|
||
|
||
async continueTtsPlaybackQueue(jobId) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId) || !this.ttsRuntime) {
|
||
return;
|
||
}
|
||
const nextIndex = this.ttsRuntime.playingSegmentIndex + 1;
|
||
if (nextIndex >= this.ttsRuntime.playQueue.length) {
|
||
this.clearTtsPlaybackQueue();
|
||
this.setData({ ttsState: "idle" });
|
||
return;
|
||
}
|
||
try {
|
||
const started = await this.playTtsQueueSegment(jobId, nextIndex);
|
||
if (!started && this.isActiveTtsPlaybackJob(jobId)) {
|
||
this.clearTtsPlaybackQueue();
|
||
this.setData({ ttsState: "idle" });
|
||
}
|
||
} catch (error) {
|
||
if (!this.isActiveTtsPlaybackJob(jobId)) {
|
||
return;
|
||
}
|
||
this.clearTtsPlaybackQueue();
|
||
const rawMessage = error instanceof Error && error.message ? error.message : "语音生成失败";
|
||
const message = this.localizeTerminalMessage(rawMessage);
|
||
this.setData({
|
||
ttsState: "error",
|
||
ttsErrorMessage: message
|
||
});
|
||
this.showLocalizedToast(rawMessage, "none");
|
||
}
|
||
},
|
||
|
||
releaseTtsAudioContext(audioContext, logSuffix) {
|
||
const target = audioContext || null;
|
||
if (!target) {
|
||
return;
|
||
}
|
||
try {
|
||
if (typeof target.stop === "function") {
|
||
target.stop();
|
||
}
|
||
} catch (error) {
|
||
console.warn(`[terminal.tts.stop.${logSuffix || "release"}]`, error);
|
||
}
|
||
try {
|
||
if (typeof target.destroy === "function") {
|
||
target.destroy();
|
||
}
|
||
} catch (error) {
|
||
console.warn(`[terminal.tts.destroy.${logSuffix || "release"}]`, error);
|
||
}
|
||
},
|
||
|
||
createTtsAudioContext() {
|
||
if (!this.ttsRuntime) {
|
||
this.initTtsRuntime();
|
||
}
|
||
this.applyTtsInnerAudioOptions();
|
||
const audioContext = wx.createInnerAudioContext();
|
||
if ("autoplay" in audioContext) {
|
||
audioContext.autoplay = false;
|
||
}
|
||
if ("obeyMuteSwitch" in audioContext) {
|
||
audioContext.obeyMuteSwitch = false;
|
||
}
|
||
audioContext.onCanplay(() => {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime) return;
|
||
if (this.ttsRuntime.audioContext !== audioContext) return;
|
||
if (this.ttsRuntime.playbackPhase !== "loading") return;
|
||
if (!this.ttsRuntime.activePlaybackJobId) return;
|
||
const pendingAudioUrl = String(this.ttsRuntime.pendingAudioUrl || "");
|
||
if (!pendingAudioUrl) return;
|
||
if (String(audioContext.src || "") !== pendingAudioUrl) return;
|
||
try {
|
||
audioContext.play();
|
||
} catch (error) {
|
||
console.warn("[terminal.tts.play.on_canplay]", error);
|
||
}
|
||
});
|
||
audioContext.onPlay(() => {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime) return;
|
||
if (this.ttsRuntime.audioContext !== audioContext) return;
|
||
if (!this.ttsRuntime.activePlaybackJobId) return;
|
||
if (this.ttsRuntime.playbackPhase !== "loading" && this.ttsRuntime.playbackPhase !== "playing") {
|
||
return;
|
||
}
|
||
this.clearTtsPlaybackTimers();
|
||
this.ttsRuntime.playbackPhase = "playing";
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
this.prefetchNextTtsQueueItem(this.ttsRuntime.activePlaybackJobId, this.ttsRuntime.playingSegmentIndex);
|
||
this.setData({
|
||
ttsState: "playing",
|
||
ttsErrorMessage: ""
|
||
});
|
||
});
|
||
audioContext.onEnded(() => {
|
||
if (!this.data.ttsEnabled) return;
|
||
if (!this.ttsRuntime || this.ttsRuntime.audioContext !== audioContext) {
|
||
return;
|
||
}
|
||
if (!this.ttsRuntime || this.ttsRuntime.playbackPhase !== "playing") {
|
||
return;
|
||
}
|
||
this.clearTtsPlaybackTimers();
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
this.ttsRuntime.playbackPhase = "advancing";
|
||
}
|
||
const jobId = this.ttsRuntime ? this.ttsRuntime.activePlaybackJobId : 0;
|
||
if (jobId && this.ttsRuntime) {
|
||
this.setData({ ttsState: "preparing" });
|
||
this.continueTtsPlaybackQueue(jobId);
|
||
return;
|
||
}
|
||
this.setData({ ttsState: "idle" });
|
||
});
|
||
audioContext.onStop(() => {
|
||
if (!this.data.ttsEnabled) return;
|
||
if (!this.ttsRuntime || this.ttsRuntime.audioContext !== audioContext) {
|
||
return;
|
||
}
|
||
const playbackPhase = String((this.ttsRuntime && this.ttsRuntime.playbackPhase) || "idle");
|
||
if (playbackPhase === "switching") {
|
||
return;
|
||
}
|
||
const pendingAudioUrl = String((this.ttsRuntime && this.ttsRuntime.pendingAudioUrl) || "");
|
||
const currentSrc = String(audioContext.src || "");
|
||
/**
|
||
* 切换到下一段音频前会先 `stop()` 当前播放,但 `onStop` 回调可能晚于新 `src` 赋值。
|
||
* 这时若直接清掉 `pendingAudioUrl`,后续 `onCanplay` 将不会触发真正播放。
|
||
*/
|
||
if (playbackPhase === "loading" && pendingAudioUrl && currentSrc === pendingAudioUrl) {
|
||
return;
|
||
}
|
||
this.clearTtsPlaybackTimers();
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
this.ttsRuntime.playbackPhase = "idle";
|
||
}
|
||
if (this.data.ttsState === "error") return;
|
||
this.setData({ ttsState: "idle" });
|
||
});
|
||
audioContext.onError((error) => {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime) return;
|
||
if (this.ttsRuntime.audioContext !== audioContext) return;
|
||
const playbackPhase = String(this.ttsRuntime.playbackPhase || "idle");
|
||
const jobId = this.ttsRuntime ? this.ttsRuntime.activePlaybackJobId : 0;
|
||
const segmentIndex = this.ttsRuntime ? this.ttsRuntime.playingSegmentIndex : -1;
|
||
if (
|
||
!jobId ||
|
||
playbackPhase === "idle" ||
|
||
playbackPhase === "stopping" ||
|
||
playbackPhase === "switching"
|
||
) {
|
||
return;
|
||
}
|
||
if (
|
||
jobId > 0 &&
|
||
segmentIndex >= 0 &&
|
||
this.retryTtsQueueSegmentPlayback(jobId, segmentIndex, "play_error")
|
||
) {
|
||
return;
|
||
}
|
||
console.warn("[terminal.tts.play.error]", error);
|
||
this.failTtsPlayback("音频播放失败,请检查网络");
|
||
});
|
||
return audioContext;
|
||
},
|
||
|
||
replaceTtsAudioContext() {
|
||
if (!this.ttsRuntime) {
|
||
this.initTtsRuntime();
|
||
}
|
||
const previousAudioContext = this.ttsRuntime.audioContext;
|
||
this.ttsRuntime.audioContext = null;
|
||
if (previousAudioContext) {
|
||
/**
|
||
* 每次切段或从本地缓存回退到远端 URL,都直接换一个全新的播放器实例。
|
||
* 这样旧实例迟到的 `stop/error/ended` 事件会因为身份不匹配被丢弃,不再污染新片段状态。
|
||
*/
|
||
this.releaseTtsAudioContext(previousAudioContext, "before_replace");
|
||
}
|
||
const nextAudioContext = this.createTtsAudioContext();
|
||
this.ttsRuntime.audioContext = nextAudioContext;
|
||
return nextAudioContext;
|
||
},
|
||
|
||
ensureTtsAudioContext() {
|
||
if (!this.ttsRuntime) {
|
||
this.initTtsRuntime();
|
||
}
|
||
if (this.ttsRuntime.audioContext) {
|
||
return this.ttsRuntime.audioContext;
|
||
}
|
||
const audioContext = this.createTtsAudioContext();
|
||
this.ttsRuntime.audioContext = audioContext;
|
||
return audioContext;
|
||
},
|
||
|
||
stopTtsPlayback(options) {
|
||
if (!this.ttsRuntime || !this.ttsRuntime.audioContext) {
|
||
if (!options || options.clearQueue !== false) {
|
||
this.clearTtsPlaybackQueue();
|
||
}
|
||
return;
|
||
}
|
||
if (!options || options.clearQueue !== false) {
|
||
this.clearTtsPlaybackQueue();
|
||
}
|
||
this.clearTtsPlaybackTimers();
|
||
this.ttsRuntime.playbackPhase = "stopping";
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
try {
|
||
this.ttsRuntime.audioContext.stop();
|
||
} catch (error) {
|
||
console.warn("[terminal.tts.stop]", error);
|
||
}
|
||
},
|
||
|
||
destroyTtsAudioContext() {
|
||
if (!this.ttsRuntime || !this.ttsRuntime.audioContext) {
|
||
return;
|
||
}
|
||
const audioContext = this.ttsRuntime.audioContext;
|
||
this.clearTtsPlaybackQueue();
|
||
this.clearTtsPlaybackTimers();
|
||
this.ttsRuntime.pendingAudioUrl = "";
|
||
this.ttsRuntime.playbackPhase = "stopping";
|
||
this.ttsRuntime.audioContext = null;
|
||
this.releaseTtsAudioContext(audioContext, "before_destroy");
|
||
},
|
||
|
||
resetTtsRoundState(options) {
|
||
const runtime = this.ttsRuntime;
|
||
if (!runtime) return;
|
||
const config = options && typeof options === "object" ? options : {};
|
||
this.clearTtsStabilityTimers();
|
||
runtime.round = null;
|
||
if (config.stopPlayback !== false) {
|
||
this.stopTtsPlayback();
|
||
} else {
|
||
this.clearTtsPlaybackQueue();
|
||
}
|
||
const nextData = {
|
||
ttsRoundId: "",
|
||
ttsCurrentRoundText: "",
|
||
ttsLastStableHash: config.keepStableHash ? this.data.ttsLastStableHash : "",
|
||
ttsLastAudioUrl: config.keepAudioUrl ? this.data.ttsLastAudioUrl : "",
|
||
ttsState: config.keepState ? this.data.ttsState : "idle",
|
||
ttsErrorMessage: config.keepError ? this.data.ttsErrorMessage : ""
|
||
};
|
||
if (config.enabled === false) {
|
||
nextData.ttsEnabled = false;
|
||
}
|
||
this.setData(nextData);
|
||
},
|
||
|
||
setTtsEnabled(enabled) {
|
||
const nextEnabled = !!enabled;
|
||
if (!nextEnabled) {
|
||
this.resetTtsRoundState({
|
||
enabled: false,
|
||
stopPlayback: true
|
||
});
|
||
return;
|
||
}
|
||
this.setData({
|
||
ttsEnabled: true,
|
||
ttsState: "idle",
|
||
ttsErrorMessage: ""
|
||
});
|
||
},
|
||
|
||
onToggleTts() {
|
||
this.setTtsEnabled(!this.data.ttsEnabled);
|
||
},
|
||
|
||
maybeStartTtsRound(source) {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime || !this.ttsRuntime.codexReady) {
|
||
return;
|
||
}
|
||
this.clearTtsStabilityTimers();
|
||
this.stopTtsPlayback();
|
||
const roundId = `tts-${Date.now()}-${++this.ttsRuntime.roundSeq}`;
|
||
this.ttsRuntime.round = {
|
||
id: roundId,
|
||
source: String(source || "unknown"),
|
||
text: "",
|
||
changeSeq: 0
|
||
};
|
||
this.setData({
|
||
ttsRoundId: roundId,
|
||
ttsCurrentRoundText: "",
|
||
ttsLastStableHash: "",
|
||
ttsLastAudioUrl: "",
|
||
ttsState: "idle",
|
||
ttsErrorMessage: ""
|
||
});
|
||
},
|
||
|
||
appendTtsRoundOutput(text) {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return;
|
||
}
|
||
const chunk = String(text || "");
|
||
if (!chunk) {
|
||
return;
|
||
}
|
||
const round = this.ttsRuntime.round;
|
||
round.text = `${round.text}${chunk}`;
|
||
if (round.text.length > TTS_ROUND_TEXT_MAX_CHARS) {
|
||
round.text = round.text.slice(-TTS_ROUND_TEXT_MAX_CHARS);
|
||
}
|
||
round.changeSeq += 1;
|
||
if (this.data.ttsState === "preparing") {
|
||
this.setData({ ttsState: "idle" });
|
||
}
|
||
this.scheduleTtsStabilityCheck();
|
||
},
|
||
|
||
resolveTtsStableDelay() {
|
||
if (!this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return TTS_STABLE_INCOMPLETE_MS;
|
||
}
|
||
const candidate = buildSpeakableTerminalText(
|
||
this.ttsRuntime.round.text,
|
||
this.getTtsSpeakableBuildOptions()
|
||
);
|
||
return isSpeakableTextLikelyComplete(candidate) ? TTS_STABLE_SENTENCE_MS : TTS_STABLE_INCOMPLETE_MS;
|
||
},
|
||
|
||
scheduleTtsStabilityCheck() {
|
||
if (!this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return;
|
||
}
|
||
const roundId = this.ttsRuntime.round.id;
|
||
const changeSeq = this.ttsRuntime.round.changeSeq;
|
||
this.clearTtsStabilityTimers();
|
||
this.ttsRuntime.stableTimer = setTimeout(() => {
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.stableTimer = null;
|
||
}
|
||
this.confirmTtsRoundStable(roundId, changeSeq);
|
||
}, this.resolveTtsStableDelay());
|
||
},
|
||
|
||
confirmTtsRoundStable(roundId, changeSeq) {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return;
|
||
}
|
||
const round = this.ttsRuntime.round;
|
||
if (round.id !== roundId || round.changeSeq !== changeSeq) {
|
||
return;
|
||
}
|
||
const speakableOptions = this.getTtsSpeakableBuildOptions();
|
||
const firstText = buildSpeakableTerminalText(round.text, speakableOptions);
|
||
const firstHash = buildStableTextHash(firstText);
|
||
this.ttsRuntime.confirmTimer = setTimeout(() => {
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.confirmTimer = null;
|
||
}
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return;
|
||
}
|
||
const activeRound = this.ttsRuntime.round;
|
||
if (activeRound.id !== roundId || activeRound.changeSeq !== changeSeq) {
|
||
return;
|
||
}
|
||
const stableText = buildSpeakableTerminalText(activeRound.text, speakableOptions);
|
||
if (!stableText) {
|
||
this.setData({
|
||
ttsState: "idle",
|
||
ttsCurrentRoundText: "",
|
||
ttsErrorMessage: ""
|
||
});
|
||
return;
|
||
}
|
||
const stableHash = buildStableTextHash(stableText);
|
||
if (stableHash !== firstHash) {
|
||
this.scheduleTtsStabilityCheck();
|
||
return;
|
||
}
|
||
if (this.data.ttsLastStableHash === stableHash) {
|
||
return;
|
||
}
|
||
this.setData({
|
||
ttsState: "preparing",
|
||
ttsCurrentRoundText: stableText,
|
||
ttsLastStableHash: stableHash,
|
||
ttsErrorMessage: ""
|
||
});
|
||
this.requestTtsPlayback(roundId, changeSeq, stableText, stableHash);
|
||
}, TTS_STABLE_CONFIRM_MS);
|
||
},
|
||
|
||
async requestTtsPlayback(roundId, changeSeq, stableText, stableHash) {
|
||
try {
|
||
const segments = splitSpeakableTextForTts(stableText, this.getTtsSegmentSplitOptions());
|
||
if (!segments.length) {
|
||
this.setData({
|
||
ttsState: "idle",
|
||
ttsCurrentRoundText: "",
|
||
ttsErrorMessage: ""
|
||
});
|
||
this.finishTtsRoundCapture();
|
||
return;
|
||
}
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime || !this.ttsRuntime.round) {
|
||
return;
|
||
}
|
||
const round = this.ttsRuntime.round;
|
||
if (round.id !== roundId || round.changeSeq !== changeSeq) {
|
||
return;
|
||
}
|
||
this.stopTtsPlayback();
|
||
const playbackJobId = this.createTtsPlaybackJob(segments);
|
||
this.setData({
|
||
ttsLastStableHash: stableHash,
|
||
ttsLastAudioUrl: "",
|
||
ttsErrorMessage: "",
|
||
ttsState: "preparing"
|
||
});
|
||
this.finishTtsRoundCapture();
|
||
const started = await this.playTtsQueueSegment(playbackJobId, 0);
|
||
if (!started && this.isActiveTtsPlaybackJob(playbackJobId)) {
|
||
this.clearTtsPlaybackQueue();
|
||
this.setData({
|
||
ttsState: "idle",
|
||
ttsErrorMessage: ""
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (!this.data.ttsEnabled || !this.ttsRuntime) {
|
||
return;
|
||
}
|
||
const round = this.ttsRuntime.round;
|
||
if (round && (round.id !== roundId || round.changeSeq !== changeSeq)) {
|
||
return;
|
||
}
|
||
const rawMessage = error instanceof Error && error.message ? error.message : "语音生成失败";
|
||
const message = this.localizeTerminalMessage(rawMessage);
|
||
this.clearTtsPlaybackQueue();
|
||
this.setData({
|
||
ttsState: "error",
|
||
ttsErrorMessage: message
|
||
});
|
||
this.showLocalizedToast(rawMessage, "none");
|
||
this.finishTtsRoundCapture();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 单条时延序列的摘要信息单独计算:
|
||
* 1. 返回标准化后的 samples,供合成曲线图直接复用;
|
||
* 2. 统计只保留 min / max / avg,方便头部卡片压缩成两行;
|
||
* 3. 单位统一由标题承担,因此数值本身不再重复拼 `ms`。
|
||
*/
|
||
buildConnectionDiagnosticSeriesSummary(samplesInput) {
|
||
const samples = normalizeConnectionDiagnosticSamples(samplesInput);
|
||
const stats =
|
||
samples.length === 0
|
||
? {
|
||
min: null,
|
||
max: null,
|
||
avg: null
|
||
}
|
||
: {
|
||
min: Math.min(...samples),
|
||
max: Math.max(...samples),
|
||
avg: Math.round(samples.reduce((total, sample) => total + sample, 0) / samples.length)
|
||
};
|
||
return {
|
||
samples,
|
||
stats
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 头部指标卡固定输出 `min / max / avg` 三组数字:
|
||
* - 标签使用英文短词,便于压缩到单行;
|
||
* - 空态统一显示 `--`,避免布局跳动;
|
||
* - 数字单独输出,供 WXML 套无底色描边胶囊。
|
||
*/
|
||
buildConnectionDiagnosticStatItems(statsInput, dataInput) {
|
||
const data = dataInput && typeof dataInput === "object" ? dataInput : this.data;
|
||
const diagnosticsCopy = (data.copy && data.copy.diagnostics) || {};
|
||
const stats = statsInput && typeof statsInput === "object" ? statsInput : {};
|
||
const emptyValue = diagnosticsCopy.chartStatEmpty || "--";
|
||
return [
|
||
{
|
||
key: "min",
|
||
label: diagnosticsCopy.chartMinShort || "min",
|
||
valueLabel: stats.min == null ? emptyValue : String(stats.min),
|
||
divider: true
|
||
},
|
||
{
|
||
key: "max",
|
||
label: diagnosticsCopy.chartMaxShort || "max",
|
||
valueLabel: stats.max == null ? emptyValue : String(stats.max),
|
||
divider: true
|
||
},
|
||
{
|
||
key: "avg",
|
||
label: diagnosticsCopy.chartAvgShort || "avg",
|
||
valueLabel: stats.avg == null ? emptyValue : String(stats.avg),
|
||
divider: false
|
||
}
|
||
];
|
||
},
|
||
|
||
/**
|
||
* 诊断浮窗把两条时延序列叠到同一张平滑曲线图里:
|
||
* 1. 网关响应与网络时延共享 x 轴时间窗口;
|
||
* 2. 左轴保留网关响应量纲,右轴保留网络时延量纲,读数不再混在一起;
|
||
* 3. 头部仍保留两组数值摘要,避免单图叠加后失去读数能力。
|
||
*/
|
||
buildConnectionDiagnosticCombinedChartModel(dataInput) {
|
||
const data = dataInput && typeof dataInput === "object" ? dataInput : this.data;
|
||
const diagnosticsCopy = (data.copy && data.copy.diagnostics) || {};
|
||
const chartThemeStyles = buildConnectionDiagnosticThemeStyles(
|
||
this.terminalThemeRuntime || buildTerminalRuntimeTheme(getSettings())
|
||
);
|
||
const response = this.buildConnectionDiagnosticSeriesSummary(data.connectionDiagnosticResponseSamples);
|
||
const network = this.buildConnectionDiagnosticSeriesSummary(data.connectionDiagnosticNetworkSamples);
|
||
return {
|
||
imageUri: buildCombinedDiagnosticSparkline(
|
||
{
|
||
responseSamples: response.samples,
|
||
networkSamples: network.samples
|
||
},
|
||
{
|
||
responseLineColor: chartThemeStyles.responseLineColor,
|
||
responseGlowColor: chartThemeStyles.responseGlowColor,
|
||
responseFillColor: chartThemeStyles.responseFillColor,
|
||
networkLineColor: chartThemeStyles.networkLineColor,
|
||
networkGlowColor: chartThemeStyles.networkGlowColor,
|
||
networkFillColor: chartThemeStyles.networkFillColor,
|
||
cardBgColor: chartThemeStyles.chartCardBgColor,
|
||
cardBorderColor: chartThemeStyles.chartCardBorderColor,
|
||
cardGlowColor: chartThemeStyles.chartCardGlowColor,
|
||
gridColor: chartThemeStyles.chartGridColor
|
||
}
|
||
),
|
||
cardStyle: chartThemeStyles.cardStyle,
|
||
responseMetricStyle: chartThemeStyles.responseMetricStyle,
|
||
responseCardLabel: diagnosticsCopy.responseAxisCardLabel || "网关响应(ms)",
|
||
responseStatItems: this.buildConnectionDiagnosticStatItems(response.stats, data),
|
||
networkMetricStyle: chartThemeStyles.networkMetricStyle,
|
||
networkCardLabel: diagnosticsCopy.networkAxisCardLabel || "网络时延(ms)",
|
||
networkStatItems: this.buildConnectionDiagnosticStatItems(network.stats, data),
|
||
chartImageShellStyle: chartThemeStyles.chartImageShellStyle
|
||
};
|
||
},
|
||
|
||
buildConnectionDiagnosticPayload(extraData) {
|
||
const nextExtra = extraData && typeof extraData === "object" ? extraData : {};
|
||
const nextData = {
|
||
...this.data,
|
||
...nextExtra
|
||
};
|
||
return {
|
||
...nextExtra,
|
||
connectionDiagnosticCombinedChart: this.buildConnectionDiagnosticCombinedChartModel(nextData)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 会话信息只依赖当前页已知的 server 配置与本地多语言文案。
|
||
* 这里统一收口,避免模板层自己拼接 host/port/projectPath。
|
||
*/
|
||
buildSessionInfoPayload(extraData) {
|
||
const nextExtra = extraData && typeof extraData === "object" ? extraData : {};
|
||
const nextData = {
|
||
...this.data,
|
||
...nextExtra
|
||
};
|
||
const sessionInfo = buildTerminalSessionInfoModel({
|
||
server: this.server,
|
||
serverLabel: nextData.serverLabel,
|
||
copy: nextData.copy,
|
||
statusText: nextData.statusText,
|
||
activeAiProvider: nextData.activeAiProvider
|
||
});
|
||
const sessionInfoTheme = buildSessionInfoThemeStyles(
|
||
this.terminalThemeRuntime || buildTerminalRuntimeTheme(getSettings())
|
||
);
|
||
return {
|
||
sessionInfoTitle: sessionInfo.title,
|
||
sessionInfoHero: sessionInfo.hero,
|
||
sessionInfoStatusChips: sessionInfo.statusChips,
|
||
sessionInfoDetailItems: sessionInfo.detailItems,
|
||
sessionInfoTheme
|
||
};
|
||
},
|
||
|
||
onOpenConnectionDiagnostics() {
|
||
if (this.data.connectionDiagnosticsVisible) {
|
||
this.syncConnectionDiagnosticSampling(true);
|
||
return;
|
||
}
|
||
this.setData({ connectionDiagnosticsVisible: true, sessionInfoVisible: false }, () => {
|
||
this.syncConnectionDiagnosticSampling(true);
|
||
});
|
||
},
|
||
|
||
onCloseConnectionDiagnostics() {
|
||
if (!this.data.connectionDiagnosticsVisible) {
|
||
this.syncConnectionDiagnosticSampling(false);
|
||
return;
|
||
}
|
||
this.setData({ connectionDiagnosticsVisible: false }, () => {
|
||
this.syncConnectionDiagnosticSampling(false);
|
||
});
|
||
},
|
||
|
||
onOpenSessionInfo() {
|
||
if (this.data.sessionInfoVisible) {
|
||
return;
|
||
}
|
||
const closeDiagnostics = !!this.data.connectionDiagnosticsVisible;
|
||
this.setData(
|
||
{
|
||
...this.buildSessionInfoPayload({}),
|
||
sessionInfoVisible: true,
|
||
connectionDiagnosticsVisible: false
|
||
},
|
||
() => {
|
||
if (closeDiagnostics) {
|
||
this.syncConnectionDiagnosticSampling(false);
|
||
}
|
||
}
|
||
);
|
||
},
|
||
|
||
onCloseSessionInfo() {
|
||
if (!this.data.sessionInfoVisible) {
|
||
return;
|
||
}
|
||
this.setData({ sessionInfoVisible: false });
|
||
},
|
||
|
||
/**
|
||
* 会话信息浮层里的两张状态卡各自复用主工具栏现有入口:
|
||
* 1. SSH 卡片复用右上角连接开关,已连接时断开、断开时重连;
|
||
* 2. AI 卡片复用左上角 AI 按钮,断开时会先自动建链再启动 AI;
|
||
* 3. 两边都不新增独立分支,统一沿用已有的状态判断、提示与异常处理。
|
||
*/
|
||
onSessionInfoStatusTap(event) {
|
||
const key = String(event?.currentTarget?.dataset?.key || "").trim();
|
||
if (key === "sshConnection") {
|
||
if (this.data.connectionActionDisabled) {
|
||
return;
|
||
}
|
||
this.onConnectionAction();
|
||
return;
|
||
}
|
||
if (key !== "aiConnection" || this.data.aiLaunchBusy) {
|
||
return;
|
||
}
|
||
void this.onOpenCodex();
|
||
},
|
||
|
||
/**
|
||
* 诊断缓存按服务器维度隔离,并放在模块级内存里:
|
||
* - 切到配置页再回来时可以复用上一段曲线;
|
||
* - 切换到其他服务器时不会把采样串线;
|
||
* - 不做持久化,保证读写足够轻。
|
||
*/
|
||
getConnectionDiagnosticCacheKey(serverIdInput) {
|
||
const serverId = String(serverIdInput || this.data.serverId || "").trim();
|
||
return serverId || "";
|
||
},
|
||
|
||
restoreConnectionDiagnosticSamples(serverIdInput) {
|
||
const cacheKey = this.getConnectionDiagnosticCacheKey(serverIdInput);
|
||
if (!cacheKey) {
|
||
return {
|
||
connectionDiagnosticResponseSamples: [],
|
||
connectionDiagnosticNetworkSamples: []
|
||
};
|
||
}
|
||
const cached = connectionDiagnosticSampleCache.get(cacheKey);
|
||
return {
|
||
connectionDiagnosticResponseSamples: normalizeConnectionDiagnosticSamples(
|
||
cached && cached.responseSamples
|
||
),
|
||
connectionDiagnosticNetworkSamples: normalizeConnectionDiagnosticSamples(
|
||
cached && cached.networkSamples
|
||
)
|
||
};
|
||
},
|
||
|
||
hasConnectionDiagnosticSamples(samplesInput) {
|
||
const samples = samplesInput && typeof samplesInput === "object" ? samplesInput : {};
|
||
const responseSamples = normalizeConnectionDiagnosticSamples(samples.connectionDiagnosticResponseSamples);
|
||
const networkSamples = normalizeConnectionDiagnosticSamples(samples.connectionDiagnosticNetworkSamples);
|
||
return responseSamples.length > 0 || networkSamples.length > 0;
|
||
},
|
||
|
||
/**
|
||
* 同一服务器断线重连时优先复用上一段曲线:
|
||
* 1. 旧点先回填,避免每次重连都从 0 开始;
|
||
* 2. 错误文本清空,避免成功重连后仍残留旧报错;
|
||
* 3. 后续新样本继续 append,直到窗口补满 30 个点。
|
||
*/
|
||
applyConnectionDiagnosticSamples(samplesInput, serverIdInput) {
|
||
const normalized = {
|
||
connectionDiagnosticResponseSamples: normalizeConnectionDiagnosticSamples(
|
||
samplesInput && samplesInput.connectionDiagnosticResponseSamples
|
||
),
|
||
connectionDiagnosticNetworkSamples: normalizeConnectionDiagnosticSamples(
|
||
samplesInput && samplesInput.connectionDiagnosticNetworkSamples
|
||
)
|
||
};
|
||
const nextPayload = this.buildConnectionDiagnosticPayload(normalized);
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.setData(nextPayload);
|
||
this.persistConnectionDiagnosticSamples(nextPayload, serverIdInput);
|
||
},
|
||
|
||
persistConnectionDiagnosticSamples(dataInput, serverIdInput) {
|
||
const cacheKey = this.getConnectionDiagnosticCacheKey(serverIdInput);
|
||
if (!cacheKey) {
|
||
return;
|
||
}
|
||
const data = dataInput && typeof dataInput === "object" ? dataInput : this.data;
|
||
connectionDiagnosticSampleCache.set(cacheKey, {
|
||
responseSamples: normalizeConnectionDiagnosticSamples(data.connectionDiagnosticResponseSamples),
|
||
networkSamples: normalizeConnectionDiagnosticSamples(data.connectionDiagnosticNetworkSamples)
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 每次开始新连接前清空采样窗口,避免把旧会话曲线混进来。
|
||
* 错误文本也在这里顺手归零,避免成功重连后仍展示上次失败信息。
|
||
*/
|
||
resetConnectionDiagnosticSamples() {
|
||
const nextPayload = this.buildConnectionDiagnosticPayload({
|
||
connectionDiagnosticResponseSamples: [],
|
||
connectionDiagnosticNetworkSamples: []
|
||
});
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.setData(nextPayload);
|
||
this.persistConnectionDiagnosticSamples(nextPayload);
|
||
},
|
||
|
||
/**
|
||
* 网络时延不能直接在小程序里做 ICMP ping。
|
||
* 这里统一退化为客户端对当前网关 `/health` 的 HTTPS RTT,既可执行也与当前接入链路一致。
|
||
*/
|
||
resolveConnectionDiagnosticNetworkProbeUrl() {
|
||
const baseUrl = String(resolveSyncBaseUrl() || "").trim();
|
||
if (!baseUrl) {
|
||
return "";
|
||
}
|
||
return `${baseUrl}/health?ts=${Date.now()}`;
|
||
},
|
||
|
||
sampleConnectionDiagnosticNetworkLatency(force) {
|
||
const allowDisconnected = force === true;
|
||
if (!allowDisconnected && String(this.data.statusText || "") !== "connected") {
|
||
return;
|
||
}
|
||
if (typeof wx.request !== "function" || this.connectionDiagnosticNetworkProbePending) {
|
||
return;
|
||
}
|
||
const url = this.resolveConnectionDiagnosticNetworkProbeUrl();
|
||
if (!url) {
|
||
return;
|
||
}
|
||
const startedAt = Date.now();
|
||
this.connectionDiagnosticNetworkProbePending = true;
|
||
wx.request({
|
||
url,
|
||
method: "GET",
|
||
timeout: CONNECTION_DIAGNOSTIC_NETWORK_TIMEOUT_MS,
|
||
success: (res) => {
|
||
const statusCode = Number(res && res.statusCode);
|
||
if (!(statusCode >= 200 && statusCode < 300)) {
|
||
return;
|
||
}
|
||
const nextSamples = appendDiagnosticSample(
|
||
this.data.connectionDiagnosticNetworkSamples,
|
||
Date.now() - startedAt,
|
||
CONNECTION_DIAGNOSTIC_SAMPLE_LIMIT
|
||
);
|
||
const nextPayload = this.buildConnectionDiagnosticPayload({
|
||
connectionDiagnosticNetworkSamples: nextSamples
|
||
});
|
||
this.setData(nextPayload);
|
||
this.persistConnectionDiagnosticSamples(nextPayload);
|
||
},
|
||
complete: () => {
|
||
this.connectionDiagnosticNetworkProbePending = false;
|
||
}
|
||
});
|
||
},
|
||
|
||
resolveConnectionDiagnosticSampleIntervalMs() {
|
||
return this.data.connectionDiagnosticsVisible
|
||
? CONNECTION_DIAGNOSTIC_PANEL_SAMPLE_INTERVAL_MS
|
||
: CONNECTION_DIAGNOSTIC_SAMPLE_INTERVAL_MS;
|
||
},
|
||
|
||
/**
|
||
* 时延面板展开时,两条曲线都切到 3 秒采样;
|
||
* 收起后恢复默认 10 秒,避免后台长期高频请求。
|
||
*/
|
||
syncConnectionDiagnosticSampling(forceImmediate) {
|
||
const connected = String(this.data.statusText || "") === "connected";
|
||
const intervalMs = this.resolveConnectionDiagnosticSampleIntervalMs();
|
||
if (this.client && typeof this.client.setLatencySampleInterval === "function") {
|
||
this.client.setLatencySampleInterval(intervalMs);
|
||
}
|
||
if (!connected || !this.client) {
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
return;
|
||
}
|
||
this.startConnectionDiagnosticNetworkProbe(forceImmediate, intervalMs);
|
||
if (forceImmediate && typeof this.client.sampleLatency === "function") {
|
||
this.client.sampleLatency();
|
||
}
|
||
},
|
||
|
||
startConnectionDiagnosticNetworkProbe(forceImmediate, intervalMsInput) {
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
const intervalMs =
|
||
Number.isFinite(Number(intervalMsInput)) && Number(intervalMsInput) >= 1000
|
||
? Math.round(Number(intervalMsInput))
|
||
: this.resolveConnectionDiagnosticSampleIntervalMs();
|
||
this.connectionDiagnosticNetworkProbeTimer = setInterval(() => {
|
||
this.sampleConnectionDiagnosticNetworkLatency(false);
|
||
}, intervalMs);
|
||
if (forceImmediate) {
|
||
this.sampleConnectionDiagnosticNetworkLatency(true);
|
||
}
|
||
},
|
||
|
||
stopConnectionDiagnosticNetworkProbe() {
|
||
if (this.connectionDiagnosticNetworkProbeTimer) {
|
||
clearInterval(this.connectionDiagnosticNetworkProbeTimer);
|
||
this.connectionDiagnosticNetworkProbeTimer = null;
|
||
}
|
||
this.connectionDiagnosticNetworkProbePending = false;
|
||
},
|
||
|
||
onLoad(options) {
|
||
const settings = getSettings();
|
||
applyNavigationBarTheme(settings);
|
||
this.opsConfig = getOpsConfig();
|
||
this.connectionDiagnosticNetworkProbeTimer = null;
|
||
this.connectionDiagnosticNetworkProbePending = false;
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = false;
|
||
this.initTerminalPerfState();
|
||
this.initTerminalRenderScheduler();
|
||
this.syncTerminalThemeRuntime(settings);
|
||
this.terminalBufferMaxEntries = normalizePositiveInt(
|
||
this.opsConfig.terminalBufferMaxEntries,
|
||
DEFAULT_BUFFER_MAX_ENTRIES,
|
||
100
|
||
);
|
||
this.terminalBufferMaxBytes = normalizePositiveInt(
|
||
this.opsConfig.terminalBufferMaxBytes,
|
||
DEFAULT_BUFFER_MAX_BYTES,
|
||
1024
|
||
);
|
||
this.terminalBufferSnapshotMaxLines = Math.min(
|
||
MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
normalizePositiveInt(
|
||
settings.shellBufferSnapshotMaxLines,
|
||
DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||
)
|
||
);
|
||
this.waitForConnectedTimer = null;
|
||
this.aiConnectionWaitTimer = null;
|
||
this.touchShiftLastTapAt = 0;
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
this.outputCursorRow = 0;
|
||
this.outputCursorCol = 0;
|
||
this.outputCells = [[]];
|
||
this.outputReplayText = "";
|
||
this.outputReplayBytes = 0;
|
||
this.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE);
|
||
this.outputRectWidth = 0;
|
||
this.outputRectHeight = 0;
|
||
this.stdoutReplayCarryText = "";
|
||
this.terminalSyncUpdateState = createTerminalSyncUpdateState();
|
||
this.terminalStdoutUserInputPending = false;
|
||
this.shellInputPassiveBlurPending = false;
|
||
this.terminalStableCaretSnapshot = null;
|
||
this.terminalPendingCaretSnapshot = null;
|
||
this.terminalPendingCaretSince = 0;
|
||
this.terminalCols = 80;
|
||
this.terminalRows = 24;
|
||
this.outputTerminalState = createEmptyTerminalBufferState({
|
||
bufferCols: this.terminalCols,
|
||
bufferRows: this.terminalRows
|
||
});
|
||
this.applyTerminalBufferState(this.outputTerminalState);
|
||
this.voiceRound = {
|
||
phase: "idle",
|
||
client: null,
|
||
baseText: "",
|
||
discardResults: false,
|
||
stopRequestedBeforeReady: false,
|
||
closeTimer: null
|
||
};
|
||
this.voiceHoldTimer = null;
|
||
this.voiceGesture = {
|
||
holdArmed: false,
|
||
holdStarted: false,
|
||
dragCandidate: false,
|
||
dragActive: false,
|
||
dragMoved: false,
|
||
dragJustFinishedAt: 0,
|
||
dragStartX: 0,
|
||
dragStartY: 0,
|
||
originLeft: VOICE_FLOAT_GAP_PX,
|
||
originBottom: VOICE_FLOAT_GAP_PX
|
||
};
|
||
this.windowWidth = 375;
|
||
this.windowHeight = 667;
|
||
this.voicePanelHeightPx = VOICE_PANEL_FALLBACK_HEIGHT_PX;
|
||
this.voiceActionsMaxOffsetPx = null;
|
||
this.voiceActionsDrag = {
|
||
active: false,
|
||
moved: false,
|
||
startX: 0,
|
||
startOffset: 0
|
||
};
|
||
this.initVoiceFloatMetrics();
|
||
this.currentOutputScrollTop = 0;
|
||
this.outputRectSnapshot = null;
|
||
this.outputViewportWindow = null;
|
||
this.outputViewportScrollRefreshPending = false;
|
||
this.terminalScrollOverlayTimer = null;
|
||
this.terminalScrollIdleTimer = null;
|
||
this.terminalScrollViewportPrefetchTimer = null;
|
||
this.terminalScrollLastOverlayAt = 0;
|
||
this.terminalScrollLastViewportRefreshAt = 0;
|
||
this.terminalScrollDirection = 0;
|
||
this.shellFontSizePx = 15;
|
||
this.shellLineHeightRatio = 1.4;
|
||
this.shellLineHeightPx = Math.round(this.shellFontSizePx * this.shellLineHeightRatio);
|
||
this.shellCharWidthPx = Math.max(6, Math.round(this.shellFontSizePx * SHELL_CHAR_WIDTH_FACTOR));
|
||
this.outputHorizontalPaddingPx = Math.max(
|
||
4,
|
||
Math.round((this.windowWidth * OUTPUT_HORIZONTAL_PADDING_RPX) / 750)
|
||
);
|
||
this.outputRightPaddingPx = OUTPUT_RIGHT_SAFE_PADDING_PX;
|
||
this.lastOutputLongPressAt = 0;
|
||
this.keyboardVisibleHeightPx = 0;
|
||
this.keyboardRestoreScrollTop = null;
|
||
this.keyboardSessionActive = false;
|
||
this.keyboardHeightChangeHandler = null;
|
||
this.sessionSuspended = false;
|
||
this.codexBootstrapGuard = null;
|
||
this.activeAiProvider = "";
|
||
this.activeCodexSandboxMode = "";
|
||
this.aiSessionShellReady = false;
|
||
this.aiRuntimeExitCarry = "";
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
this.autoReconnectTimer = null;
|
||
this.autoReconnectAttempts = 0;
|
||
this.autoReconnectSuppressed = false;
|
||
this.initTtsRuntime();
|
||
this.pendingOpenCodex = String(options && options.openCodex) === "1";
|
||
this.logTerminalPerf("page.load", {
|
||
openCodex: this.pendingOpenCodex,
|
||
hasServerId: !!(options && options.serverId)
|
||
});
|
||
|
||
const serverId = options.serverId || "";
|
||
const rows = listServers();
|
||
const server = rows.find((item) => item.id === serverId);
|
||
if (!server) {
|
||
clearTerminalSessionSnapshot();
|
||
const language = this.getCurrentLanguage(settings);
|
||
const copy = buildPageCopy(language, "terminal");
|
||
wx.showToast({
|
||
title: localizeTerminalMessage(language, copy, "服务器不存在"),
|
||
icon: "none"
|
||
});
|
||
return;
|
||
}
|
||
|
||
bindRecorderEvents();
|
||
activeTerminalPage = this;
|
||
|
||
const sessionSnapshot = getTerminalSessionSnapshot();
|
||
const reusableSnapshot =
|
||
sessionSnapshot && sessionSnapshot.serverId === serverId ? sessionSnapshot : null;
|
||
const sessionId = reusableSnapshot ? reusableSnapshot.sessionId : `mini-${Date.now()}`;
|
||
const localePayload = this.applyLocale(settings, "connecting");
|
||
const serverLabel = server.name || `${server.username || "-"}@${server.host || "-"}`;
|
||
const restoredConnectionDiagnostics = this.restoreConnectionDiagnosticSamples(serverId);
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = this.hasConnectionDiagnosticSamples(
|
||
restoredConnectionDiagnostics
|
||
);
|
||
this.server = server;
|
||
this.client = this.createTerminalGatewayClient();
|
||
this.resumeGraceMs = resolveTerminalResumeGraceMs(settings);
|
||
this.sessionKey =
|
||
(reusableSnapshot && reusableSnapshot.sessionKey) ||
|
||
`mini-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
this.syncActiveAiProvider(reusableSnapshot ? reusableSnapshot.activeAiProvider : "", { persist: false });
|
||
this.activeCodexSandboxMode =
|
||
this.normalizeActiveAiProvider(reusableSnapshot ? reusableSnapshot.activeAiProvider : "") === "codex"
|
||
? normalizeCodexSandboxMode(reusableSnapshot ? reusableSnapshot.codexSandboxMode : "")
|
||
: "";
|
||
this.pendingCodexResumeAfterReconnect =
|
||
this.normalizeActiveAiProvider(reusableSnapshot ? reusableSnapshot.activeAiProvider : "") === "codex";
|
||
this.restorePersistedTerminalBuffer();
|
||
|
||
setActiveSessionSender((input, meta) => {
|
||
if (!this.client) {
|
||
throw new Error("会话未连接");
|
||
}
|
||
this.client.sendStdin(input, meta);
|
||
});
|
||
|
||
this.syncShellInputMetrics(settings);
|
||
this.syncTerminalBufferLimits(settings);
|
||
applyNavigationBarTheme(settings);
|
||
createTerminalSessionSnapshot({
|
||
serverId,
|
||
serverLabel,
|
||
sessionId,
|
||
sessionKey: this.sessionKey,
|
||
status: "connecting",
|
||
activeAiProvider: this.normalizeActiveAiProvider(this.activeAiProvider),
|
||
codexSandboxMode: this.activeAiProvider === "codex" ? this.activeCodexSandboxMode : "",
|
||
resumeGraceMs: this.resumeGraceMs
|
||
});
|
||
this.setData(
|
||
{
|
||
...buildTerminalThemePayload(settings),
|
||
...localePayload,
|
||
activationDebugEnabled: resolveActivationDebugSetting(settings),
|
||
activationDebugVisible: resolveActivationDebugSetting(settings),
|
||
showVoiceInputButton: resolveVoiceInputButtonSetting(settings),
|
||
ttsSpeakableMaxChars: resolveTtsSpeakableMaxChars(settings),
|
||
ttsSegmentMaxChars: resolveTtsSegmentMaxChars(settings),
|
||
voiceRecordCategories: resolveVoiceRecordCategories(settings),
|
||
selectedRecordCategory: this.resolveSelectedRecordCategory(settings),
|
||
voicePanelVisible: resolveVoiceInputButtonSetting(settings) ? this.data.voicePanelVisible : false,
|
||
serverId,
|
||
sessionId,
|
||
serverLabel,
|
||
statusText: "connecting",
|
||
statusClass: this.normalizeStatusClass("connecting"),
|
||
disconnectedHintVisible: resolveDisconnectedHintVisible("connecting"),
|
||
...restoredConnectionDiagnostics,
|
||
...this.buildSessionInfoPayload({
|
||
...localePayload,
|
||
serverLabel,
|
||
statusText: "connecting",
|
||
activeAiProvider: this.activeAiProvider
|
||
}),
|
||
...this.buildConnectionDiagnosticPayload({
|
||
...localePayload,
|
||
serverLabel,
|
||
statusText: "connecting",
|
||
statusLabel: getStatusLabel(this.getCurrentLanguage(settings), "connecting"),
|
||
statusClass: this.normalizeStatusClass("connecting"),
|
||
disconnectedHintVisible: resolveDisconnectedHintVisible("connecting"),
|
||
...restoredConnectionDiagnostics
|
||
})
|
||
},
|
||
() => {
|
||
this.consumePendingAiLaunchRequest();
|
||
this.runAfterTerminalLayout(() => {
|
||
this.measureShellMetrics(() => {
|
||
this.requestTerminalRender({}, () => {
|
||
this.connectGateway();
|
||
});
|
||
});
|
||
});
|
||
}
|
||
);
|
||
this.syncConnectionAction("connecting");
|
||
},
|
||
|
||
onShow() {
|
||
this.startTerminalPerfLagProbe();
|
||
const sessionSnapshot = getTerminalSessionSnapshot();
|
||
this.logTerminalPerf("page.show", {
|
||
hasClient: !!this.client,
|
||
resumed: !!sessionSnapshot
|
||
});
|
||
const settings = getSettings();
|
||
applyNavigationBarTheme(settings);
|
||
this.syncTerminalThemeRuntime(settings);
|
||
this.resumeGraceMs = resolveTerminalResumeGraceMs(settings);
|
||
const localePayload = this.applyLocale(settings);
|
||
setActiveSessionSender((input, meta) => {
|
||
if (!this.client) {
|
||
throw new Error("会话未连接");
|
||
}
|
||
this.client.sendStdin(input, meta);
|
||
});
|
||
this.syncShellInputMetrics(settings);
|
||
this.syncTerminalBufferLimits(settings);
|
||
/**
|
||
* 终端页可能在导航栈中保留多个隐藏实例。
|
||
* 键盘/窗口类全局监听必须按可见态绑定,否则会在 onHide 后继续累积。
|
||
*/
|
||
this.bindShellKeyboardHeightChange();
|
||
this.setData(
|
||
{
|
||
...buildTerminalThemePayload(settings),
|
||
...localePayload,
|
||
activationDebugEnabled: resolveActivationDebugSetting(settings),
|
||
activationDebugVisible: resolveActivationDebugSetting(settings),
|
||
showVoiceInputButton: resolveVoiceInputButtonSetting(settings),
|
||
ttsSpeakableMaxChars: resolveTtsSpeakableMaxChars(settings),
|
||
ttsSegmentMaxChars: resolveTtsSegmentMaxChars(settings),
|
||
voiceRecordCategories: resolveVoiceRecordCategories(settings),
|
||
selectedRecordCategory: this.resolveSelectedRecordCategory(settings),
|
||
voicePanelVisible: resolveVoiceInputButtonSetting(settings) ? this.data.voicePanelVisible : false,
|
||
...this.buildSessionInfoPayload(localePayload)
|
||
},
|
||
() => {
|
||
this.consumePendingAiLaunchRequest();
|
||
this.setData(this.buildConnectionDiagnosticPayload({}));
|
||
if (this.client && String(this.data.statusText || "") === "connected") {
|
||
this.syncConnectionDiagnosticSampling(true);
|
||
}
|
||
/**
|
||
* 字号/行高切换后,CSS 变量要先真正提交到视图层,再测量隐藏 probe。
|
||
* 否则这里可能仍拿到旧字号的字宽,导致 cols/rows 与实际显示不一致。
|
||
*/
|
||
this.runAfterTerminalLayout(() => {
|
||
this.measureShellMetrics(() => {
|
||
this.requestTerminalRender({ sendResize: true }, () => {
|
||
const snapshot = getTerminalSessionSnapshot();
|
||
if (!this.client && snapshot && snapshot.serverId === this.data.serverId) {
|
||
this.connectGateway();
|
||
return;
|
||
}
|
||
if (
|
||
!this.client &&
|
||
!snapshot &&
|
||
["connecting", "auth_pending", "connected"].includes(String(this.data.statusText || ""))
|
||
) {
|
||
this.setStatus("disconnected");
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
);
|
||
},
|
||
|
||
onHide() {
|
||
this.logTerminalPerf("page.hide", {
|
||
hasClient: !!this.client,
|
||
sessionSuspended: !!this.sessionSuspended
|
||
});
|
||
this.suppressAutoReconnect();
|
||
this.stopTerminalPerfLagProbe();
|
||
this.flushTerminalPerfLogs("page_hide");
|
||
this.persistConnectionDiagnosticSamples();
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = true;
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.clearPendingTerminalRender();
|
||
this.clearTerminalScrollSyncTimers();
|
||
this.shellInputPassiveBlurPending = false;
|
||
this.resetTerminalCaretStabilityState();
|
||
this.unbindShellKeyboardHeightChange();
|
||
this.clearVoiceHoldTimer();
|
||
this.clearWaitForConnectedTimer();
|
||
this.clearAiConnectionWaitTimer();
|
||
this.stopVoiceRound(true);
|
||
this.teardownAsrClient("page_hide");
|
||
this.resetTtsRoundState({
|
||
keepAudioUrl: true,
|
||
stopPlayback: true
|
||
});
|
||
this.destroyTtsAudioContext();
|
||
this.sendFocusModeReport(false);
|
||
if (this.voiceRound && this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
this.clearCodexBootstrapGuard();
|
||
if (this.isRecorderRunning) {
|
||
try {
|
||
recorderManager.stop();
|
||
} catch (error) {
|
||
console.warn("[terminal.recorder.stop]", error);
|
||
}
|
||
this.isRecorderRunning = false;
|
||
}
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
this.suspendTerminalSession("page_hide");
|
||
setActiveSessionSender(null);
|
||
},
|
||
|
||
onUnload() {
|
||
this.logTerminalPerf("page.unload", {
|
||
hasClient: !!this.client,
|
||
sessionSuspended: !!this.sessionSuspended
|
||
});
|
||
this.suppressAutoReconnect();
|
||
this.stopTerminalPerfLagProbe();
|
||
this.flushTerminalPerfLogs("page_unload");
|
||
this.persistConnectionDiagnosticSamples();
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = true;
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.clearPendingTerminalRender();
|
||
this.clearTerminalScrollSyncTimers();
|
||
this.shellInputPassiveBlurPending = false;
|
||
this.resetTerminalCaretStabilityState();
|
||
this.unbindShellKeyboardHeightChange();
|
||
this.clearVoiceHoldTimer();
|
||
this.clearWaitForConnectedTimer();
|
||
this.clearAiConnectionWaitTimer();
|
||
this.stopVoiceRound(true);
|
||
this.teardownAsrClient("page_unload");
|
||
this.resetTtsRoundState({
|
||
keepAudioUrl: true,
|
||
stopPlayback: true
|
||
});
|
||
this.destroyTtsAudioContext();
|
||
if (this.voiceRound && this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
this.clearCodexBootstrapGuard();
|
||
if (this.isRecorderRunning) {
|
||
try {
|
||
recorderManager.stop();
|
||
} catch (error) {
|
||
console.warn("[terminal.recorder.stop]", error);
|
||
}
|
||
this.isRecorderRunning = false;
|
||
}
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
this.suspendTerminalSession("page_unload");
|
||
if (activeTerminalPage === this) {
|
||
activeTerminalPage = null;
|
||
}
|
||
setActiveSessionSender(null);
|
||
},
|
||
|
||
/**
|
||
* 同步当前终端会话快照,供连接页高亮与返回终端时续接使用。
|
||
*/
|
||
persistTerminalSessionStatus(status) {
|
||
if (!this.data.serverId || !this.data.sessionId || !this.sessionKey) {
|
||
return null;
|
||
}
|
||
return createTerminalSessionSnapshot({
|
||
serverId: this.data.serverId,
|
||
serverLabel: this.data.serverLabel,
|
||
sessionId: this.data.sessionId,
|
||
sessionKey: this.sessionKey,
|
||
status,
|
||
activeAiProvider: this.normalizeActiveAiProvider(this.activeAiProvider),
|
||
codexSandboxMode:
|
||
this.normalizeActiveAiProvider(this.activeAiProvider) === "codex"
|
||
? normalizeCodexSandboxMode(this.activeCodexSandboxMode)
|
||
: "",
|
||
resumeGraceMs: this.resumeGraceMs
|
||
});
|
||
},
|
||
|
||
buildTerminalBufferOptions() {
|
||
return {
|
||
bufferCols: Math.max(1, Math.round(Number(this.terminalCols) || 80)),
|
||
bufferRows: Math.max(1, Math.round(Number(this.terminalRows) || 24)),
|
||
maxEntries: normalizePositiveInt(this.terminalBufferMaxEntries, DEFAULT_BUFFER_MAX_ENTRIES, 100),
|
||
maxBytes: normalizePositiveInt(this.terminalBufferMaxBytes, DEFAULT_BUFFER_MAX_BYTES, 1024)
|
||
};
|
||
},
|
||
|
||
syncTerminalThemeRuntime(settings) {
|
||
this.terminalThemeRuntime = buildTerminalRuntimeTheme(settings);
|
||
},
|
||
|
||
buildTerminalBufferRuntimeOptions(runtimeOptions) {
|
||
return {
|
||
...(this.terminalThemeRuntime || buildTerminalRuntimeTheme(getSettings())),
|
||
...(runtimeOptions && typeof runtimeOptions === "object" ? runtimeOptions : {})
|
||
};
|
||
},
|
||
|
||
captureTerminalBufferState(runtimeOptions) {
|
||
return cloneTerminalBufferState(
|
||
this.outputTerminalState,
|
||
this.buildTerminalBufferOptions(),
|
||
this.buildTerminalBufferRuntimeOptions(runtimeOptions)
|
||
);
|
||
},
|
||
|
||
/**
|
||
* 运行态同步只更新 JS 侧终端语义:
|
||
* 1. 让 `getTerminalModes()`、cursor/ansi 等逻辑态跟上最新 VT 输出;
|
||
* 2. 不立刻改 `outputCells`,避免 stdout backlog 期间每个 defer slice 都做一次页面投影。
|
||
*/
|
||
applyTerminalBufferRuntimeState(state, runtimeOptions) {
|
||
const useReferences = !!(runtimeOptions && runtimeOptions.useReferences);
|
||
const options = this.buildTerminalBufferOptions();
|
||
const normalized =
|
||
useReferences && state && typeof state === "object"
|
||
? syncActiveBufferSnapshot(state, options, { cloneRows: false })
|
||
: cloneTerminalBufferState(
|
||
state && typeof state === "object" ? state : createEmptyTerminalBufferState(options),
|
||
options,
|
||
this.buildTerminalBufferRuntimeOptions(runtimeOptions)
|
||
);
|
||
this.outputTerminalState = normalized;
|
||
const active = getActiveTerminalBuffer(normalized);
|
||
const activeAnsiState =
|
||
active && active.ansiState ? active.ansiState : normalized.ansiState || ANSI_RESET_STATE;
|
||
this.outputAnsiState = cloneAnsiState(activeAnsiState);
|
||
this.outputCursorRow = Math.max(0, Math.round(Number(active && active.cursorRow) || 0));
|
||
this.outputCursorCol = Math.max(0, Math.round(Number(active && active.cursorCol) || 0));
|
||
return normalized;
|
||
},
|
||
|
||
applyTerminalBufferState(state, runtimeOptions) {
|
||
const useReferences = !!(runtimeOptions && runtimeOptions.useReferences);
|
||
const normalized = this.applyTerminalBufferRuntimeState(state, runtimeOptions);
|
||
const active = getActiveTerminalBuffer(normalized);
|
||
const activeCells =
|
||
Array.isArray(active && active.cells) && active.cells.length > 0 ? active.cells : [[]];
|
||
this.outputCells = useReferences
|
||
? activeCells
|
||
: activeCells.map((lineCells) =>
|
||
Array.isArray(lineCells) ? lineCells.map((cell) => cloneTerminalCell(cell)) : []
|
||
);
|
||
},
|
||
|
||
getTerminalModes() {
|
||
return getTerminalModeState(this.outputTerminalState);
|
||
},
|
||
|
||
syncTerminalReplayBuffer(cleanText) {
|
||
const chunk = String(cleanText || "");
|
||
if (!chunk) {
|
||
return;
|
||
}
|
||
const maxBytes = this.buildTerminalBufferOptions().maxBytes;
|
||
this.outputReplayText = `${this.outputReplayText || ""}${chunk}`;
|
||
this.outputReplayBytes += utf8ByteLength(chunk);
|
||
if (this.outputReplayBytes <= maxBytes) {
|
||
return;
|
||
}
|
||
this.outputReplayText = trimTerminalReplayTextToMaxBytes(this.outputReplayText, maxBytes);
|
||
this.outputReplayBytes = utf8ByteLength(this.outputReplayText);
|
||
},
|
||
|
||
/**
|
||
* 字号、行高、容器宽度变化时,旧字号下写死的自动折行必须按当前列宽重放重建。
|
||
* 否则页面会继续显示“上一次连接时的几何”,看起来就像某个字号被永久锁死。
|
||
*/
|
||
rebuildTerminalBufferFromReplay() {
|
||
if (!this.outputReplayText) {
|
||
return false;
|
||
}
|
||
const startedAt = Date.now();
|
||
this.applyTerminalBufferState(
|
||
rebuildTerminalBufferStateFromReplayText(
|
||
this.outputReplayText,
|
||
this.buildTerminalBufferOptions(),
|
||
this.buildTerminalBufferRuntimeOptions()
|
||
)
|
||
);
|
||
const costMs = Date.now() - startedAt;
|
||
if (costMs >= TERMINAL_PERF_SLOW_STEP_MS) {
|
||
this.logTerminalPerf("buffer.rebuild_from_replay", {
|
||
costMs,
|
||
replayBytes: this.outputReplayBytes,
|
||
cols: this.terminalCols,
|
||
rows: this.terminalRows
|
||
});
|
||
}
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* 将当前终端缓冲裁剪为“尾部可恢复快照”:
|
||
* 1. 只保留最近若干行,避免本地存储被海量输出占满;
|
||
* 2. 光标行按裁剪后的相对位置回写;
|
||
* 3. 恢复目标是补回 prompt 和最近上下文,而不是替代完整 scrollback。
|
||
*/
|
||
persistTerminalBufferSnapshot() {
|
||
if (!this.sessionKey) {
|
||
return null;
|
||
}
|
||
const rows = this.getOutputBufferRows();
|
||
const cursorState = this.getOutputCursorState();
|
||
/**
|
||
* 续接快照只负责“离页后回来先看到最近上下文”。
|
||
* 这里允许用户调大或调小快照行数,但仍会继续受字节上限约束,避免本地存储被单次会话挤爆。
|
||
*/
|
||
const snapshotMaxLines = Math.min(
|
||
MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
normalizePositiveInt(
|
||
this.terminalBufferSnapshotMaxLines,
|
||
DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||
)
|
||
);
|
||
let startIndex = Math.max(0, rows.length - snapshotMaxLines);
|
||
let trimmedRows = rows.slice(startIndex);
|
||
let trimmedLines = trimmedRows.map((lineCells) => lineCellsToText(lineCells));
|
||
let { styleTable, styledLines } = serializeTerminalSnapshotRows(trimmedRows);
|
||
let snapshotVisualBytes = utf8ByteLength(
|
||
JSON.stringify({
|
||
lines: trimmedLines,
|
||
styleTable,
|
||
styledLines
|
||
})
|
||
);
|
||
while (trimmedRows.length > 1 && snapshotVisualBytes > TERMINAL_BUFFER_SNAPSHOT_MAX_BYTES) {
|
||
trimmedRows = trimmedRows.slice(1);
|
||
startIndex += 1;
|
||
trimmedLines = trimmedRows.map((lineCells) => lineCellsToText(lineCells));
|
||
const nextStyledSnapshot = serializeTerminalSnapshotRows(trimmedRows);
|
||
styleTable = nextStyledSnapshot.styleTable;
|
||
styledLines = nextStyledSnapshot.styledLines;
|
||
snapshotVisualBytes = utf8ByteLength(
|
||
JSON.stringify({
|
||
lines: trimmedLines,
|
||
styleTable,
|
||
styledLines
|
||
})
|
||
);
|
||
}
|
||
if (snapshotVisualBytes > TERMINAL_BUFFER_SNAPSHOT_MAX_BYTES) {
|
||
styleTable = [];
|
||
styledLines = [];
|
||
}
|
||
const cursorRow = Math.max(
|
||
0,
|
||
Math.min(cursorState.row - startIndex, Math.max(0, trimmedLines.length - 1))
|
||
);
|
||
const replayText = trimTerminalReplayTextToMaxBytes(
|
||
this.outputReplayText,
|
||
TERMINAL_BUFFER_SNAPSHOT_MAX_BYTES
|
||
);
|
||
const snapshot = saveTerminalBufferSnapshot({
|
||
sessionKey: this.sessionKey,
|
||
lines: trimmedLines,
|
||
styleTable,
|
||
styledLines,
|
||
replayText,
|
||
bufferCols: this.terminalCols,
|
||
bufferRows: this.terminalRows,
|
||
cursorRow,
|
||
cursorCol: cursorState.col
|
||
});
|
||
return snapshot;
|
||
},
|
||
|
||
restorePersistedTerminalBuffer() {
|
||
const snapshot = getTerminalBufferSnapshot(this.sessionKey);
|
||
if (!snapshot) {
|
||
return;
|
||
}
|
||
/**
|
||
* 真实日志已证明:页面恢复时若直接拿“已裁剪的 replayText”按默认 80x24 先重建,
|
||
* 会把本来正确的快照行内容重建成空白和半截 footer。
|
||
* 因此恢复第一页时优先使用精确的 line snapshot;后续若真实测量发现列数变化,
|
||
* 仍可继续依赖 replayText 做几何重建。
|
||
*/
|
||
this.outputReplayText = String(snapshot.replayText || "");
|
||
this.outputReplayBytes = utf8ByteLength(this.outputReplayText);
|
||
this.terminalCols = Math.max(1, Math.round(Number(snapshot.bufferCols) || this.terminalCols || 80));
|
||
this.terminalRows = Math.max(1, Math.round(Number(snapshot.bufferRows) || this.terminalRows || 24));
|
||
const rows =
|
||
Array.isArray(snapshot.styledLines) && snapshot.styledLines.length > 0
|
||
? deserializeTerminalSnapshotRows(snapshot.styledLines, snapshot.styleTable)
|
||
: null;
|
||
const restoredRows =
|
||
Array.isArray(rows) && rows.length > 0
|
||
? rows
|
||
: Array.isArray(snapshot.lines) && snapshot.lines.length > 0
|
||
? snapshot.lines.map((line) => buildTerminalCellsFromText(line))
|
||
: [[]];
|
||
const cursorRow = Math.max(
|
||
0,
|
||
Math.min(Math.round(Number(snapshot.cursorRow) || 0), restoredRows.length - 1)
|
||
);
|
||
const cursorCol = Math.max(0, Math.round(Number(snapshot.cursorCol) || 0));
|
||
this.applyTerminalBufferState({
|
||
cells: restoredRows.length > 0 ? restoredRows : [[]],
|
||
ansiState: cloneAnsiState(ANSI_RESET_STATE),
|
||
cursorRow,
|
||
cursorCol
|
||
});
|
||
},
|
||
|
||
clearAutoReconnectTimer() {
|
||
if (!this.autoReconnectTimer) {
|
||
return;
|
||
}
|
||
clearTimeout(this.autoReconnectTimer);
|
||
this.autoReconnectTimer = null;
|
||
},
|
||
|
||
/**
|
||
* 本地主动结束当前连接时,必须先抑制自动重连:
|
||
* 1. 手动断开;
|
||
* 2. 页面 hide/unload 触发的 suspend;
|
||
* 3. 其他本地明确终止场景。
|
||
* 否则底层稍后补上的 `ws_closed` 会被误判成异常断线。
|
||
*/
|
||
suppressAutoReconnect() {
|
||
this.autoReconnectSuppressed = true;
|
||
this.clearAutoReconnectTimer();
|
||
},
|
||
|
||
resetAutoReconnectState() {
|
||
this.clearAutoReconnectTimer();
|
||
this.autoReconnectAttempts = 0;
|
||
this.autoReconnectSuppressed = false;
|
||
},
|
||
|
||
shouldAutoReconnect(reason) {
|
||
const normalizedReason = String(reason || "").trim();
|
||
const settings = getSettings();
|
||
const reconnectLimit = Math.max(0, Number(settings.reconnectLimit) || 0);
|
||
return (
|
||
settings.autoReconnect === true &&
|
||
reconnectLimit > 0 &&
|
||
!this.autoReconnectSuppressed &&
|
||
!this.sessionSuspended &&
|
||
!AUTO_RECONNECT_IGNORED_REASONS.has(normalizedReason) &&
|
||
!!this.server &&
|
||
Number(this.autoReconnectAttempts || 0) < reconnectLimit
|
||
);
|
||
},
|
||
|
||
/**
|
||
* 小程序端自动重连策略:
|
||
* 1. 仅对 SSH 非主动断开生效;
|
||
* 2. 延迟采用轻量递增退避,避免瞬时抖动时立刻打爆网关;
|
||
* 3. 用户下次主动点击“连接/重连”时会重置计数。
|
||
*/
|
||
scheduleAutoReconnect(reason) {
|
||
if (!this.shouldAutoReconnect(reason)) {
|
||
return false;
|
||
}
|
||
this.autoReconnectAttempts = Number(this.autoReconnectAttempts || 0) + 1;
|
||
const delayMs = Math.min(5000, this.autoReconnectAttempts * 1200);
|
||
this.clearAutoReconnectTimer();
|
||
this.setStatus("reconnecting");
|
||
wx.showToast({
|
||
title: `连接中断,${Math.max(1, Math.round(delayMs / 1000))} 秒后重连`,
|
||
icon: "none"
|
||
});
|
||
this.autoReconnectTimer = setTimeout(() => {
|
||
this.autoReconnectTimer = null;
|
||
if (!this.shouldAutoReconnect(reason)) {
|
||
return;
|
||
}
|
||
void this.connectGateway(true);
|
||
}, delayMs);
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* 页面离开时挂起终端:
|
||
* 1. 不向服务端发送 disconnect 控制帧,避免把 SSH 直接关掉;
|
||
* 2. 先写入“可续接”快照,再关闭本地 WebSocket;
|
||
* 3. 返回终端页时用同一个 sessionKey 恢复。
|
||
*/
|
||
suspendTerminalSession(reason) {
|
||
if (!this.client) {
|
||
return;
|
||
}
|
||
if (this.sessionSuspended) {
|
||
return;
|
||
}
|
||
this.sessionSuspended = true;
|
||
this.suppressAutoReconnect();
|
||
this.persistTerminalBufferSnapshot();
|
||
markTerminalSessionResumable({
|
||
serverId: this.data.serverId,
|
||
serverLabel: this.data.serverLabel,
|
||
sessionId: this.data.sessionId,
|
||
sessionKey: this.sessionKey,
|
||
activeAiProvider: this.normalizeActiveAiProvider(this.activeAiProvider),
|
||
codexSandboxMode:
|
||
this.normalizeActiveAiProvider(this.activeAiProvider) === "codex"
|
||
? normalizeCodexSandboxMode(this.activeCodexSandboxMode)
|
||
: "",
|
||
resumeGraceMs: this.resumeGraceMs
|
||
});
|
||
this.client.suspend(reason || "page_hide");
|
||
this.aiSessionShellReady = false;
|
||
this.client = null;
|
||
},
|
||
|
||
syncConnectionAction(status) {
|
||
const reconnect = RECONNECT_STATES.has(status);
|
||
const copy = this.data.copy || buildPageCopy(this.getCurrentLanguage(), "terminal");
|
||
this.setData({
|
||
connectionActionReconnect: reconnect,
|
||
connectionActionText: reconnect
|
||
? copy?.connectionAction?.reconnect || "重连"
|
||
: copy?.connectionAction?.disconnect || "断开",
|
||
connectionActionDisabled:
|
||
status === "connecting" || status === "auth_pending" || status === "reconnecting"
|
||
});
|
||
},
|
||
|
||
setStatus(status) {
|
||
const previous = this.data.statusText;
|
||
const nextStatusClass = this.normalizeStatusClass(status);
|
||
const nextDisconnectedHintVisible = resolveDisconnectedHintVisible(status);
|
||
if (
|
||
status === "connected" ||
|
||
status === "disconnected" ||
|
||
status === "error" ||
|
||
status === "config_required"
|
||
) {
|
||
this.clearWaitForConnectedTimer();
|
||
}
|
||
this.setData(
|
||
{
|
||
...this.buildConnectionDiagnosticPayload({
|
||
statusText: status,
|
||
statusLabel: getStatusLabel(this.getCurrentLanguage(), status),
|
||
statusClass: nextStatusClass,
|
||
disconnectedHintVisible: nextDisconnectedHintVisible
|
||
}),
|
||
...this.buildSessionInfoPayload({ statusText: status })
|
||
},
|
||
() => {
|
||
const syncAfterStatusChange = () => {
|
||
if (previous !== status) {
|
||
this.syncTerminalOverlay();
|
||
}
|
||
};
|
||
if (status === "connected") {
|
||
syncAfterStatusChange();
|
||
return;
|
||
}
|
||
if (!this.data.shellInputFocus && !this.data.shellInputValue && !this.keyboardSessionActive) {
|
||
syncAfterStatusChange();
|
||
return;
|
||
}
|
||
this.setData(
|
||
{
|
||
shellInputFocus: false,
|
||
shellInputValue: "",
|
||
shellInputCursor: 0
|
||
},
|
||
() => this.restoreOutputScrollAfterKeyboard(syncAfterStatusChange)
|
||
);
|
||
}
|
||
);
|
||
this.syncConnectionAction(status);
|
||
if (status !== "connected") {
|
||
this.resetTtsRoundState({
|
||
keepAudioUrl: true,
|
||
stopPlayback: true
|
||
});
|
||
}
|
||
if (status === "connecting" || status === "auth_pending" || status === "connected") {
|
||
this.persistTerminalSessionStatus(status);
|
||
}
|
||
|
||
if (previous !== status && status === "connected") {
|
||
markServerConnected(this.data.serverId);
|
||
emitSessionEvent("connected", { serverId: this.data.serverId, sessionId: this.data.sessionId });
|
||
}
|
||
if (previous !== status && status === "disconnected") {
|
||
emitSessionEvent("disconnected", { serverId: this.data.serverId, sessionId: this.data.sessionId });
|
||
}
|
||
if (previous !== status) {
|
||
this.logTerminalPerf("status.change", { from: previous, to: status });
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 终端性能埋点只记录“阶段切换”和“慢步骤”,避免把 console 刷爆。
|
||
*/
|
||
initTerminalPerfState() {
|
||
const now = Date.now();
|
||
this.terminalPerf = {
|
||
pageLoadAt: now,
|
||
connectStartedAt: 0,
|
||
gatewaySocketOpenAt: 0,
|
||
authPendingAt: 0,
|
||
firstConnectedAt: 0,
|
||
firstStdoutAt: 0,
|
||
firstVisibleStdoutAt: 0,
|
||
stdoutFrameCount: 0,
|
||
visibleStdoutFrameCount: 0,
|
||
stdoutBytes: 0,
|
||
visibleStdoutBytes: 0,
|
||
frameSeq: 0,
|
||
layoutSeq: 0,
|
||
overlaySeq: 0,
|
||
codexLaunchAt: 0,
|
||
codexCommandSentAt: 0,
|
||
codexReadyAt: 0
|
||
};
|
||
this.terminalPerfRecentRecords = [];
|
||
this.terminalPerfLastSnapshotAt = 0;
|
||
this.terminalPerfLagTimer = null;
|
||
this.terminalPerfLagExpectedAt = 0;
|
||
this.activeTerminalStdoutTask = null;
|
||
this.terminalPerfLogBuffer = ENABLE_TERMINAL_PERF_LOGS
|
||
? createTerminalPerfLogBuffer({
|
||
windowMs: TERMINAL_PERF_LOG_WINDOW_MS,
|
||
write: (summary) => {
|
||
try {
|
||
console.info(`${TERMINAL_PERF_LOG_PREFIX} ${JSON.stringify(summary)}`);
|
||
} catch {
|
||
console.info(TERMINAL_PERF_LOG_PREFIX, "perf.summary", summary);
|
||
}
|
||
}
|
||
})
|
||
: null;
|
||
},
|
||
|
||
/**
|
||
* stdout 输出会非常碎,尤其是 Codex 启动阶段。
|
||
* 这里单独维护一个“渲染调度器”,只负责:
|
||
* 1. 把多个 stdout chunk 合并为一轮 layout + overlay;
|
||
* 2. 当上一轮仍在飞行时,仅保留一轮待补跑请求,避免把 scroll-view 刷新堆积成风暴。
|
||
*/
|
||
initTerminalRenderScheduler() {
|
||
this.terminalRenderScheduler = createTerminalRenderScheduler({
|
||
batchWindowMs: TERMINAL_OUTPUT_RENDER_BATCH_MS,
|
||
runRender: (request, done) => this.performTerminalRenderRequest(request, done),
|
||
onError: (error) => {
|
||
if (!error) return;
|
||
console.warn("[terminal.render.scheduler]", error);
|
||
}
|
||
});
|
||
},
|
||
|
||
clearPendingTerminalRender() {
|
||
if (!this.terminalRenderScheduler || typeof this.terminalRenderScheduler.clearPending !== "function") {
|
||
return;
|
||
}
|
||
this.terminalRenderScheduler.clearPending();
|
||
},
|
||
|
||
/**
|
||
* stdout 时间片处理需要感知“用户刚操作过”:
|
||
* 1. 当前 slice 到预算后应尽快让出主线程;
|
||
* 2. 下一轮渲染不用追求吞吐优先,而应优先把按钮/输入反馈放出去。
|
||
*/
|
||
markTerminalUserInput() {
|
||
this.terminalStdoutUserInputPending = true;
|
||
},
|
||
|
||
consumeTerminalUserInputHint() {
|
||
const value = !!this.terminalStdoutUserInputPending;
|
||
this.terminalStdoutUserInputPending = false;
|
||
return value;
|
||
},
|
||
|
||
clearTerminalStdoutCarry() {
|
||
this.stdoutReplayCarryText = "";
|
||
this.terminalSyncUpdateState = createTerminalSyncUpdateState();
|
||
},
|
||
|
||
requestTerminalRender(options, callback) {
|
||
if (!this.terminalRenderScheduler) {
|
||
this.initTerminalRenderScheduler();
|
||
}
|
||
this.terminalRenderScheduler.requestImmediate(options, callback);
|
||
},
|
||
|
||
queueTerminalOutputRender(sample) {
|
||
if (!this.terminalRenderScheduler) {
|
||
this.initTerminalRenderScheduler();
|
||
}
|
||
this.terminalRenderScheduler.requestStdout(sample);
|
||
const schedulerSnapshot = this.getTerminalRenderSchedulerSnapshot();
|
||
const pending = schedulerSnapshot && schedulerSnapshot.pending ? schedulerSnapshot.pending : null;
|
||
if (
|
||
pending &&
|
||
(Number(pending.waitMs) >= TERMINAL_PERF_STDOUT_BACKLOG_WARN_MS ||
|
||
Number(pending.stdoutRawBytes) >= TERMINAL_PERF_STDOUT_BACKLOG_WARN_BYTES)
|
||
) {
|
||
const payload = {
|
||
queueWaitMs: Number(pending.waitMs) || 0,
|
||
pendingStdoutSamples: Number(pending.stdoutSampleCount) || 0,
|
||
pendingStdoutBytes: Number(pending.stdoutRawBytes) || 0,
|
||
visibleBytes: Number(pending.stdoutVisibleBytes) || 0
|
||
};
|
||
payload.suspectedBottleneck = pickTerminalPerfSuspectedBottleneck(payload);
|
||
this.logTerminalPerf("stdout.scheduler.backlog", payload);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 为当前调度请求创建 stdout 任务:
|
||
* 1. 把上一轮残留的不完整控制序列前缀拼回本轮文本;
|
||
* 2. 记录整批指标,供最终 `stdout.append` 汇总;
|
||
* 3. 真正的处理在后续 slice 中逐步推进,不再一次性把整批文本同步做完。
|
||
*/
|
||
createQueuedTerminalOutputTask(request) {
|
||
const samples = Array.isArray(request && request.stdoutSamples) ? request.stdoutSamples : [];
|
||
const bufferOptions = this.buildTerminalBufferOptions();
|
||
const earliestAppendStartedAt = samples.reduce((min, item) => {
|
||
const value = Number(item && item.appendStartedAt) || 0;
|
||
if (!value) return min;
|
||
if (!min) return value;
|
||
return Math.min(min, value);
|
||
}, 0);
|
||
const combinedText = `${this.stdoutReplayCarryText || ""}${samples.map((item) => String(item && item.text ? item.text : "")).join("")}`;
|
||
this.stdoutReplayCarryText = "";
|
||
const cloneStartedAt = Date.now();
|
||
const activeRows = this.getOutputBufferRows();
|
||
const task = {
|
||
createdAt: cloneStartedAt,
|
||
chunkCount: samples.length,
|
||
totalRawBytes: utf8ByteLength(combinedText),
|
||
totalVisibleBytes: samples.reduce((sum, item) => sum + (Number(item && item.visibleBytes) || 0), 0),
|
||
visibleFrameCount: samples.reduce(
|
||
(max, item) => Math.max(max, Number(item && item.visibleFrameCount) || 0),
|
||
0
|
||
),
|
||
appendStartedAt: earliestAppendStartedAt || 0,
|
||
processingStartedAt: 0,
|
||
processingDoneAt: 0,
|
||
cloneCostMs: 0,
|
||
applyCostMs: 0,
|
||
trimCostMs: 0,
|
||
stateApplyCostMs: 0,
|
||
responseCount: 0,
|
||
sliceCount: 0,
|
||
renderPassCount: 0,
|
||
layoutPassCount: 0,
|
||
overlayPassCount: 0,
|
||
deferredRenderPassCount: 0,
|
||
layoutCostMs: 0,
|
||
overlayCostMs: 0,
|
||
totalRenderBuildCostMs: 0,
|
||
totalSetDataCostMs: 0,
|
||
totalPostLayoutCostMs: 0,
|
||
maxLayoutCostMs: 0,
|
||
maxRenderBuildCostMs: 0,
|
||
maxSetDataCostMs: 0,
|
||
maxPostLayoutCostMs: 0,
|
||
maxOverlayCostMs: 0,
|
||
lastRenderCompletedAt: 0,
|
||
lastOverlayCompletedAt: 0,
|
||
skippedOverlayPassCount: 0,
|
||
carriedBytes: 0,
|
||
processedTextBytes: 0,
|
||
maxEntriesTrimmedRows: 0,
|
||
maxBytesTrimmedRows: 0,
|
||
maxBytesTrimmedColumns: 0,
|
||
bufferOptions,
|
||
layoutRect: null,
|
||
remainingText: combinedText,
|
||
state: samples.length > 0 ? this.captureTerminalBufferState({ cloneRows: false }) : null,
|
||
activeRowCount: countTerminalRows(activeRows),
|
||
diagnosticSummary: null,
|
||
pendingReplayText: "",
|
||
pendingReplayBytes: 0,
|
||
pendingResponses: [],
|
||
lastSliceMetrics: null,
|
||
slicesSinceLastRender: 0,
|
||
lastRenderDecisionReason: "",
|
||
lastRenderDecisionPolicy: ""
|
||
};
|
||
task.cloneCostMs = Date.now() - cloneStartedAt;
|
||
this.activeTerminalStdoutTask = task;
|
||
if (samples.length > 0 && !task.appendStartedAt) {
|
||
task.appendStartedAt = cloneStartedAt;
|
||
}
|
||
return task;
|
||
},
|
||
|
||
/**
|
||
* 将当前 stdout 任务在一个很短的时间片内推进一段:
|
||
* 1. 单次只处理若干个“安全切片”;
|
||
* 2. 控制序列和 CRLF 不会被从中间截断;
|
||
* 3. 当用户刚有输入时,本轮会更积极让出主线程。
|
||
*/
|
||
applyQueuedTerminalOutputBatch(request) {
|
||
if (!request || !Array.isArray(request.stdoutSamples) || request.stdoutSamples.length === 0) {
|
||
return null;
|
||
}
|
||
const task =
|
||
request.stdoutTask && typeof request.stdoutTask === "object"
|
||
? request.stdoutTask
|
||
: this.createQueuedTerminalOutputTask(request);
|
||
request.stdoutTask = task;
|
||
this.activeTerminalStdoutTask = task;
|
||
if (!task.state) {
|
||
this.activeTerminalStdoutTask = null;
|
||
return {
|
||
task,
|
||
done: true,
|
||
shouldRender: false,
|
||
sliceMetrics: null
|
||
};
|
||
}
|
||
const sliceStartedAt = Date.now();
|
||
const yieldToUserInput = this.consumeTerminalUserInputHint();
|
||
const sliceBudgetMs = yieldToUserInput
|
||
? Math.max(1, Math.floor(TERMINAL_OUTPUT_WRITE_SLICE_MS / 2))
|
||
: TERMINAL_OUTPUT_WRITE_SLICE_MS;
|
||
let processedTextBytes = 0;
|
||
let processedUnitCount = 0;
|
||
let applyCostMs = 0;
|
||
let trimCostMs = 0;
|
||
let carryBytes = 0;
|
||
if (!task.processingStartedAt) {
|
||
task.processingStartedAt = sliceStartedAt;
|
||
}
|
||
|
||
while (task.remainingText) {
|
||
const nextSlice = takeTerminalReplaySlice(task.remainingText, TERMINAL_OUTPUT_WRITE_SLICE_CHARS);
|
||
if (!nextSlice || !nextSlice.slice) {
|
||
carryBytes = utf8ByteLength(task.remainingText);
|
||
task.carriedBytes += carryBytes;
|
||
this.stdoutReplayCarryText = task.remainingText;
|
||
task.remainingText = "";
|
||
break;
|
||
}
|
||
const applyStartedAt = Date.now();
|
||
const result = applyTerminalOutput(task.state, nextSlice.slice, task.bufferOptions, {
|
||
...this.buildTerminalBufferRuntimeOptions(),
|
||
reuseState: true,
|
||
reuseRows: true
|
||
});
|
||
const currentApplyCostMs = Date.now() - applyStartedAt;
|
||
task.applyCostMs += currentApplyCostMs;
|
||
applyCostMs += currentApplyCostMs;
|
||
task.state = result.state;
|
||
task.diagnosticSummary = null;
|
||
task.pendingReplayText = `${task.pendingReplayText || ""}${String(result.cleanText || "")}`;
|
||
task.pendingReplayBytes += utf8ByteLength(String(result.cleanText || ""));
|
||
if (Array.isArray(result.responses) && result.responses.length > 0) {
|
||
task.pendingResponses.push(...result.responses.filter(Boolean));
|
||
task.responseCount += result.responses.length;
|
||
}
|
||
task.remainingText = nextSlice.rest;
|
||
processedTextBytes += utf8ByteLength(nextSlice.slice);
|
||
task.processedTextBytes += utf8ByteLength(nextSlice.slice);
|
||
processedUnitCount += 1;
|
||
task.sliceCount += 1;
|
||
if (result.metrics) {
|
||
const currentTrimCostMs = Number(result.metrics.trimCostMs) || 0;
|
||
task.trimCostMs += currentTrimCostMs;
|
||
trimCostMs += currentTrimCostMs;
|
||
task.maxEntriesTrimmedRows += Number(result.metrics.maxEntriesTrimmedRows) || 0;
|
||
task.maxBytesTrimmedRows += Number(result.metrics.maxBytesTrimmedRows) || 0;
|
||
task.maxBytesTrimmedColumns += Number(result.metrics.maxBytesTrimmedColumns) || 0;
|
||
}
|
||
if (yieldToUserInput || Date.now() - sliceStartedAt >= sliceBudgetMs) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
let stateApplyCostMs = 0;
|
||
const hasPendingVisualCommit =
|
||
processedUnitCount > 0 || !!task.pendingReplayText || task.pendingResponses.length > 0;
|
||
const remainingBytes = utf8ByteLength(task.remainingText || "");
|
||
const taskDone = remainingBytes <= 0;
|
||
const nextSlicesSinceLastRender = task.slicesSinceLastRender + processedUnitCount;
|
||
const schedulerSnapshot = this.getTerminalRenderSchedulerSnapshot();
|
||
const pendingScheduler =
|
||
schedulerSnapshot && schedulerSnapshot.pending ? schedulerSnapshot.pending : null;
|
||
const timeSinceLastRenderMs = task.lastRenderCompletedAt
|
||
? Math.max(0, Date.now() - Number(task.lastRenderCompletedAt))
|
||
: Number.MAX_SAFE_INTEGER;
|
||
const activeStdoutAgeMs = task.processingStartedAt
|
||
? Math.max(0, Date.now() - Number(task.processingStartedAt))
|
||
: 0;
|
||
const renderDecision = hasPendingVisualCommit
|
||
? resolveTerminalStdoutRenderDecision({
|
||
yieldedToUserInput: yieldToUserInput,
|
||
pendingResponseCount: task.pendingResponses.length,
|
||
remainingBytes,
|
||
pendingReplayBytes: task.pendingReplayBytes,
|
||
nextSlicesSinceLastRender,
|
||
timeSinceLastRenderMs,
|
||
taskDone,
|
||
totalRawBytes: task.totalRawBytes,
|
||
pendingStdoutBytes: Number(pendingScheduler && pendingScheduler.stdoutRawBytes) || 0,
|
||
pendingStdoutSamples: Number(pendingScheduler && pendingScheduler.stdoutSampleCount) || 0,
|
||
schedulerWaitMs: Number(pendingScheduler && pendingScheduler.waitMs) || 0,
|
||
activeStdoutAgeMs
|
||
})
|
||
: { defer: false, reason: "", policy: "" };
|
||
const deferVisualCommit = hasPendingVisualCommit && renderDecision.defer;
|
||
const shouldRender = hasPendingVisualCommit && !deferVisualCommit;
|
||
if (hasPendingVisualCommit) {
|
||
const stateApplyStartedAt = Date.now();
|
||
/**
|
||
* stdout 冷却期里的 defer slice 不需要立刻把 replay 文本和可视 rows 全量投影回页面:
|
||
* 1. 模式位、cursor 等逻辑态仍需更新,保证键盘编码/焦点协议正确;
|
||
* 2. 真正要 render 时,再一次性提交累积的 replay 文本和可视 rows。
|
||
*/
|
||
if (shouldRender && task.pendingReplayText) {
|
||
this.syncTerminalReplayBuffer(task.pendingReplayText);
|
||
}
|
||
if (shouldRender) {
|
||
this.applyTerminalBufferState(task.state, { useReferences: true });
|
||
} else {
|
||
this.applyTerminalBufferRuntimeState(task.state, { useReferences: true });
|
||
}
|
||
if (this.client && task.pendingResponses.length > 0) {
|
||
try {
|
||
task.pendingResponses.forEach((payload) => {
|
||
if (payload) {
|
||
this.client.sendStdin(payload);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
if (shouldRender) {
|
||
task.pendingReplayText = "";
|
||
}
|
||
task.pendingResponses = [];
|
||
stateApplyCostMs = Date.now() - stateApplyStartedAt;
|
||
task.stateApplyCostMs += stateApplyCostMs;
|
||
}
|
||
if (shouldRender) {
|
||
task.pendingReplayBytes = 0;
|
||
task.slicesSinceLastRender = 0;
|
||
task.lastRenderDecisionReason = String(renderDecision.reason || "");
|
||
task.lastRenderDecisionPolicy = String(renderDecision.policy || "");
|
||
} else if (hasPendingVisualCommit) {
|
||
task.slicesSinceLastRender = nextSlicesSinceLastRender;
|
||
task.deferredRenderPassCount += 1;
|
||
task.lastRenderDecisionReason = String(renderDecision.reason || "");
|
||
task.lastRenderDecisionPolicy = String(renderDecision.policy || "");
|
||
}
|
||
|
||
task.processingDoneAt = Date.now();
|
||
const overBudgetMs = Math.max(0, task.processingDoneAt - sliceStartedAt - sliceBudgetMs);
|
||
task.lastSliceMetrics = {
|
||
sliceStartedAt,
|
||
sliceDoneAt: task.processingDoneAt,
|
||
processedTextBytes,
|
||
processedUnitCount,
|
||
applyCostMs,
|
||
trimCostMs,
|
||
stateApplyCostMs,
|
||
carryBytes,
|
||
yieldedToUserInput: yieldToUserInput,
|
||
remainingBytes,
|
||
deferredVisualCommit: deferVisualCommit,
|
||
overBudgetMs,
|
||
renderDecisionReason: String(renderDecision.reason || ""),
|
||
renderDecisionPolicy: String(renderDecision.policy || "")
|
||
};
|
||
return {
|
||
task,
|
||
done: !task.remainingText,
|
||
shouldRender,
|
||
sliceMetrics: task.lastSliceMetrics
|
||
};
|
||
},
|
||
|
||
logTerminalStdoutSlicePerf(request, task, sliceMetrics, layoutDoneAt, renderDoneAt) {
|
||
if (!task || !sliceMetrics || sliceMetrics.processedUnitCount <= 0) {
|
||
return;
|
||
}
|
||
const totalCostMs = Math.max(0, renderDoneAt - sliceMetrics.sliceStartedAt);
|
||
const layoutCostMs = Math.max(0, layoutDoneAt - sliceMetrics.sliceDoneAt);
|
||
const overlayCostMs = Math.max(0, renderDoneAt - layoutDoneAt);
|
||
task.layoutCostMs += layoutCostMs;
|
||
task.overlayCostMs += overlayCostMs;
|
||
const shouldLog =
|
||
task.sliceCount <= TERMINAL_OUTPUT_WRITE_SLICE_INITIAL_LOG_LIMIT ||
|
||
sliceMetrics.yieldedToUserInput ||
|
||
totalCostMs >= TERMINAL_PERF_SLOW_STEP_MS;
|
||
if (!shouldLog) {
|
||
return;
|
||
}
|
||
this.logTerminalPerf(totalCostMs >= TERMINAL_PERF_LONG_TASK_MS ? "stdout.slice.long" : "stdout.slice", {
|
||
totalCostMs,
|
||
queueWaitMs: Math.max(
|
||
0,
|
||
Number(task.processingStartedAt || sliceMetrics.sliceStartedAt) -
|
||
Number(task.appendStartedAt || task.createdAt || sliceMetrics.sliceStartedAt)
|
||
),
|
||
applyCostMs: sliceMetrics.applyCostMs,
|
||
trimCostMs: sliceMetrics.trimCostMs,
|
||
stateApplyCostMs: sliceMetrics.stateApplyCostMs,
|
||
layoutCostMs,
|
||
overlayCostMs,
|
||
processedTextBytes: sliceMetrics.processedTextBytes,
|
||
processedUnitCount: sliceMetrics.processedUnitCount,
|
||
remainingBytes: sliceMetrics.remainingBytes,
|
||
carryBytes: sliceMetrics.carryBytes,
|
||
sliceCount: task.sliceCount,
|
||
yieldedToUserInput: sliceMetrics.yieldedToUserInput,
|
||
activeRowCount: Number(task.activeRowCount) || 0,
|
||
activeCellCount: Number((this.ensureStdoutTaskDiagnosticSummary(task) || {}).activeCellCount) || 0,
|
||
overBudgetMs: Number(sliceMetrics.overBudgetMs) || 0,
|
||
renderDecisionReason: String(sliceMetrics.renderDecisionReason || ""),
|
||
renderDecisionPolicy: String(sliceMetrics.renderDecisionPolicy || ""),
|
||
renderReason: String((request && request.reason) || "")
|
||
});
|
||
},
|
||
|
||
flushScheduledStdoutRenderPerf(request, outputMetrics, renderDoneAt) {
|
||
const metrics = outputMetrics && typeof outputMetrics === "object" ? outputMetrics : null;
|
||
if (!metrics || !metrics.chunkCount) {
|
||
return;
|
||
}
|
||
const appendStartedAt =
|
||
Number(metrics.appendStartedAt) || Number(metrics.processingStartedAt) || renderDoneAt;
|
||
const totalCostMs = Math.max(0, renderDoneAt - appendStartedAt);
|
||
if (!this.shouldLogTerminalPerfFrame(metrics.visibleFrameCount, totalCostMs)) {
|
||
return;
|
||
}
|
||
const schedulerSnapshot = this.getTerminalRenderSchedulerSnapshot();
|
||
const pendingScheduler =
|
||
schedulerSnapshot && schedulerSnapshot.pending ? schedulerSnapshot.pending : null;
|
||
const activeTaskSummary = this.ensureStdoutTaskDiagnosticSummary(metrics) || {};
|
||
const event = totalCostMs >= TERMINAL_PERF_LONG_TASK_MS ? "stdout.append.long" : "stdout.append";
|
||
const payload = {
|
||
totalCostMs,
|
||
queueWaitMs: Math.max(0, (Number(metrics.processingStartedAt) || appendStartedAt) - appendStartedAt),
|
||
schedulerWaitMs: Number(pendingScheduler && pendingScheduler.waitMs) || 0,
|
||
batchWaitMs: Math.max(0, (Number(metrics.processingStartedAt) || appendStartedAt) - appendStartedAt),
|
||
cloneCostMs: Number(metrics.cloneCostMs) || 0,
|
||
applyCostMs: Number(metrics.applyCostMs) || 0,
|
||
trimCostMs: Number(metrics.trimCostMs) || 0,
|
||
stateApplyCostMs: Number(metrics.stateApplyCostMs) || 0,
|
||
layoutCostMs: Number(metrics.layoutCostMs) || 0,
|
||
overlayCostMs: Number(metrics.overlayCostMs) || 0,
|
||
visibleBytes: Number(metrics.totalVisibleBytes) || 0,
|
||
activeStdoutBytes: Number(metrics.totalRawBytes) || 0,
|
||
responseCount: Number(metrics.responseCount) || 0,
|
||
renderRowCount: this.data.outputRenderLines.length,
|
||
replayBytes: this.outputReplayBytes,
|
||
chunkCount: Number(metrics.chunkCount) || 0,
|
||
sliceCount: Number(metrics.sliceCount) || 0,
|
||
renderPassCount: Number(metrics.renderPassCount) || 0,
|
||
layoutPassCount: Number(metrics.layoutPassCount) || 0,
|
||
overlayPassCount: Number(metrics.overlayPassCount) || 0,
|
||
deferredRenderPassCount: Number(metrics.deferredRenderPassCount) || 0,
|
||
skippedOverlayPassCount: Number(metrics.skippedOverlayPassCount) || 0,
|
||
carryBytes: Number(metrics.carriedBytes) || 0,
|
||
totalRenderBuildCostMs: Number(metrics.totalRenderBuildCostMs) || 0,
|
||
totalSetDataCostMs: Number(metrics.totalSetDataCostMs) || 0,
|
||
totalPostLayoutCostMs: Number(metrics.totalPostLayoutCostMs) || 0,
|
||
maxLayoutCostMs: Number(metrics.maxLayoutCostMs) || 0,
|
||
maxRenderBuildCostMs: Number(metrics.maxRenderBuildCostMs) || 0,
|
||
maxSetDataCostMs: Number(metrics.maxSetDataCostMs) || 0,
|
||
maxPostLayoutCostMs: Number(metrics.maxPostLayoutCostMs) || 0,
|
||
maxOverlayCostMs: Number(metrics.maxOverlayCostMs) || 0,
|
||
activeRowCount: Number(activeTaskSummary.activeRowCount) || 0,
|
||
activeCellCount: Number(activeTaskSummary.activeCellCount) || 0,
|
||
pendingStdoutSamples: Number(pendingScheduler && pendingScheduler.stdoutSampleCount) || 0,
|
||
pendingStdoutBytes: Number(pendingScheduler && pendingScheduler.stdoutRawBytes) || 0,
|
||
lastRenderDecisionReason: String(metrics.lastRenderDecisionReason || ""),
|
||
lastRenderDecisionPolicy: String(metrics.lastRenderDecisionPolicy || ""),
|
||
renderReason: String((request && request.reason) || "")
|
||
};
|
||
payload.suspectedBottleneck = pickTerminalPerfSuspectedBottleneck(payload);
|
||
this.logTerminalPerf(event, payload);
|
||
if (event === "stdout.append.long") {
|
||
this.emitTerminalPerfDiagnosticSnapshot("stdout_append_long", {
|
||
suspectedBottleneck: payload.suspectedBottleneck,
|
||
stdoutTask: this.getActiveStdoutTaskSnapshot(),
|
||
scheduler: schedulerSnapshot
|
||
});
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 页面层真正的“重渲染”仍保持原有顺序:
|
||
* 1. stdout 若仍有剩余,会按“处理一个 slice -> 渲染一次 -> setTimeout 续跑”的顺序推进;
|
||
* 2. 这样可以让按钮/输入事件在 slice 之间获得调度机会;
|
||
* 3. 非 stdout 的立即渲染仍保持原有顺序:先刷新输出布局,再同步 overlay。
|
||
*/
|
||
performTerminalRenderRequest(request, callback) {
|
||
const done = typeof callback === "function" ? callback : null;
|
||
const options = request && typeof request === "object" ? request.options : {};
|
||
const stdoutResult = this.applyQueuedTerminalOutputBatch(request);
|
||
const outputMetrics = stdoutResult && stdoutResult.task ? stdoutResult.task : null;
|
||
const shouldRender = !stdoutResult || stdoutResult.shouldRender;
|
||
const continueStdout = !!(stdoutResult && !stdoutResult.done);
|
||
const layoutOptions =
|
||
outputMetrics && outputMetrics.layoutRect && continueStdout && !options.sendResize
|
||
? {
|
||
...options,
|
||
rect: outputMetrics.layoutRect,
|
||
reuseRect: true,
|
||
skipPostLayoutRectQuery: true
|
||
}
|
||
: options;
|
||
if (!shouldRender) {
|
||
if (continueStdout) {
|
||
setTimeout(() => {
|
||
this.performTerminalRenderRequest(request, callback);
|
||
}, 0);
|
||
return;
|
||
}
|
||
this.activeTerminalStdoutTask = null;
|
||
if (done) {
|
||
done(null);
|
||
}
|
||
return;
|
||
}
|
||
if (outputMetrics) {
|
||
outputMetrics.renderPassCount += 1;
|
||
}
|
||
this.refreshOutputLayout(layoutOptions, (viewState) => {
|
||
const layoutDoneAt = Date.now();
|
||
if (outputMetrics) {
|
||
outputMetrics.layoutRect = cloneOutputRectSnapshot(viewState && viewState.rect);
|
||
if (viewState && viewState.perf) {
|
||
outputMetrics.layoutPassCount += 1;
|
||
outputMetrics.totalRenderBuildCostMs += Number(viewState.perf.renderBuildCostMs) || 0;
|
||
outputMetrics.totalSetDataCostMs += Number(viewState.perf.setDataCostMs) || 0;
|
||
outputMetrics.totalPostLayoutCostMs += Number(viewState.perf.postLayoutCostMs) || 0;
|
||
outputMetrics.maxLayoutCostMs = Math.max(
|
||
Number(outputMetrics.maxLayoutCostMs) || 0,
|
||
Number(viewState.perf.totalCostMs) || 0
|
||
);
|
||
outputMetrics.maxRenderBuildCostMs = Math.max(
|
||
Number(outputMetrics.maxRenderBuildCostMs) || 0,
|
||
Number(viewState.perf.renderBuildCostMs) || 0
|
||
);
|
||
outputMetrics.maxSetDataCostMs = Math.max(
|
||
Number(outputMetrics.maxSetDataCostMs) || 0,
|
||
Number(viewState.perf.setDataCostMs) || 0
|
||
);
|
||
outputMetrics.maxPostLayoutCostMs = Math.max(
|
||
Number(outputMetrics.maxPostLayoutCostMs) || 0,
|
||
Number(viewState.perf.postLayoutCostMs) || 0
|
||
);
|
||
}
|
||
}
|
||
const overlayContext =
|
||
viewState && viewState.rect
|
||
? {
|
||
rect: viewState.rect,
|
||
stabilizeCaretDuringStdout: true,
|
||
forceCaretCommit: !!(
|
||
!continueStdout ||
|
||
(stdoutResult &&
|
||
stdoutResult.sliceMetrics &&
|
||
stdoutResult.sliceMetrics.yieldedToUserInput)
|
||
),
|
||
cursorMetrics: {
|
||
lineHeight: viewState.lineHeight,
|
||
charWidth: viewState.charWidth,
|
||
paddingLeft: viewState.paddingLeft,
|
||
paddingRight: viewState.paddingRight,
|
||
cursorRow: viewState.cursorRow,
|
||
cursorCol: viewState.cursorCol,
|
||
rows: viewState.rows
|
||
}
|
||
}
|
||
: null;
|
||
const overlayDecision = resolveTerminalStdoutOverlayDecision({
|
||
isFinalRender: !continueStdout,
|
||
yieldedToUserInput: !!(
|
||
stdoutResult &&
|
||
stdoutResult.sliceMetrics &&
|
||
stdoutResult.sliceMetrics.yieldedToUserInput
|
||
),
|
||
overlayPassCount: Number(outputMetrics && outputMetrics.overlayPassCount) || 0,
|
||
timeSinceLastOverlayMs:
|
||
outputMetrics && outputMetrics.lastOverlayCompletedAt
|
||
? Math.max(0, layoutDoneAt - Number(outputMetrics.lastOverlayCompletedAt))
|
||
: Number.MAX_SAFE_INTEGER
|
||
});
|
||
const finalizeAfterRender = (overlayPerf) => {
|
||
const renderDoneAt = Date.now();
|
||
if (outputMetrics && overlayPerf) {
|
||
outputMetrics.overlayPassCount += 1;
|
||
outputMetrics.lastOverlayCompletedAt = renderDoneAt;
|
||
outputMetrics.maxOverlayCostMs = Math.max(
|
||
Number(outputMetrics.maxOverlayCostMs) || 0,
|
||
Number(overlayPerf.costMs) || 0
|
||
);
|
||
} else if (outputMetrics) {
|
||
outputMetrics.skippedOverlayPassCount += 1;
|
||
}
|
||
if (outputMetrics) {
|
||
outputMetrics.lastRenderCompletedAt = renderDoneAt;
|
||
}
|
||
if (stdoutResult && stdoutResult.sliceMetrics) {
|
||
this.logTerminalStdoutSlicePerf(
|
||
request,
|
||
outputMetrics,
|
||
stdoutResult.sliceMetrics,
|
||
layoutDoneAt,
|
||
renderDoneAt
|
||
);
|
||
}
|
||
if (continueStdout) {
|
||
setTimeout(() => {
|
||
this.performTerminalRenderRequest(request, callback);
|
||
}, 0);
|
||
return;
|
||
}
|
||
this.flushScheduledStdoutRenderPerf(request, outputMetrics, renderDoneAt);
|
||
if (request && request.stdoutTask) {
|
||
request.stdoutTask = null;
|
||
}
|
||
this.activeTerminalStdoutTask = null;
|
||
if (done) {
|
||
done(viewState);
|
||
}
|
||
};
|
||
if (!overlayDecision.sync) {
|
||
finalizeAfterRender(null);
|
||
return;
|
||
}
|
||
this.syncTerminalOverlay(overlayContext || undefined, (overlayPerf) => {
|
||
finalizeAfterRender(overlayPerf || null);
|
||
});
|
||
});
|
||
},
|
||
|
||
logTerminalPerf(event, payload) {
|
||
if (!ENABLE_TERMINAL_PERF_LOGS) {
|
||
return;
|
||
}
|
||
const perf = this.terminalPerf || {};
|
||
const now = Date.now();
|
||
const record = {
|
||
event,
|
||
at: now,
|
||
sinceLoadMs: Math.max(0, now - (Number(perf.pageLoadAt) || now)),
|
||
status: String(this.data.statusText || ""),
|
||
...(payload && typeof payload === "object" ? payload : {})
|
||
};
|
||
if (!record.suspectedBottleneck) {
|
||
record.suspectedBottleneck = pickTerminalPerfSuspectedBottleneck(record);
|
||
}
|
||
this.rememberTerminalPerfRecord(record);
|
||
if (!this.terminalPerfLogBuffer) {
|
||
return;
|
||
}
|
||
this.terminalPerfLogBuffer.push(record);
|
||
},
|
||
|
||
rememberTerminalPerfRecord(record) {
|
||
if (!ENABLE_TERMINAL_PERF_LOGS) {
|
||
return;
|
||
}
|
||
if (!Array.isArray(this.terminalPerfRecentRecords)) {
|
||
this.terminalPerfRecentRecords = [];
|
||
}
|
||
this.terminalPerfRecentRecords.push(buildCompactTerminalPerfRecord(record));
|
||
if (this.terminalPerfRecentRecords.length > TERMINAL_PERF_RECENT_RECORD_LIMIT) {
|
||
this.terminalPerfRecentRecords.splice(
|
||
0,
|
||
this.terminalPerfRecentRecords.length - TERMINAL_PERF_RECENT_RECORD_LIMIT
|
||
);
|
||
}
|
||
},
|
||
|
||
flushTerminalPerfLogs(reason) {
|
||
if (!this.terminalPerfLogBuffer) {
|
||
return null;
|
||
}
|
||
return this.terminalPerfLogBuffer.flush(reason || "manual");
|
||
},
|
||
|
||
clearTerminalPerfLogs() {
|
||
if (!this.terminalPerfLogBuffer) {
|
||
return;
|
||
}
|
||
this.terminalPerfLogBuffer.clear();
|
||
this.terminalPerfRecentRecords = [];
|
||
},
|
||
|
||
getTerminalRenderSchedulerSnapshot() {
|
||
if (!this.terminalRenderScheduler || typeof this.terminalRenderScheduler.getSnapshot !== "function") {
|
||
return null;
|
||
}
|
||
return this.terminalRenderScheduler.getSnapshot();
|
||
},
|
||
|
||
ensureStdoutTaskDiagnosticSummary(task) {
|
||
const source = task && typeof task === "object" ? task : null;
|
||
if (!source) {
|
||
return null;
|
||
}
|
||
if (source.diagnosticSummary) {
|
||
return source.diagnosticSummary;
|
||
}
|
||
const state = source.state && typeof source.state === "object" ? source.state : null;
|
||
const activeBuffer =
|
||
state && state.buffers
|
||
? state.activeBuffer === "alt"
|
||
? state.buffers.alt
|
||
: state.buffers.normal
|
||
: null;
|
||
const activeRows = Array.isArray(activeBuffer && activeBuffer.cells) ? activeBuffer.cells : [];
|
||
source.diagnosticSummary = {
|
||
activeBufferName: activeBuffer && activeBuffer.isAlt ? "alt" : "normal",
|
||
activeRowCount: countTerminalRows(activeRows),
|
||
activeCellCount: countTerminalCells(activeRows)
|
||
};
|
||
return source.diagnosticSummary;
|
||
},
|
||
|
||
getActiveStdoutTaskSnapshot() {
|
||
const task =
|
||
this.activeTerminalStdoutTask && typeof this.activeTerminalStdoutTask === "object"
|
||
? this.activeTerminalStdoutTask
|
||
: null;
|
||
if (!task) {
|
||
return null;
|
||
}
|
||
const summary = this.ensureStdoutTaskDiagnosticSummary(task) || {};
|
||
return {
|
||
chunkCount: Number(task.chunkCount) || 0,
|
||
totalRawBytes: Number(task.totalRawBytes) || 0,
|
||
remainingBytes: utf8ByteLength(task.remainingText || ""),
|
||
pendingReplayBytes: Number(task.pendingReplayBytes) || 0,
|
||
responseCount: Number(task.responseCount) || 0,
|
||
sliceCount: Number(task.sliceCount) || 0,
|
||
renderPassCount: Number(task.renderPassCount) || 0,
|
||
layoutPassCount: Number(task.layoutPassCount) || 0,
|
||
overlayPassCount: Number(task.overlayPassCount) || 0,
|
||
deferredRenderPassCount: Number(task.deferredRenderPassCount) || 0,
|
||
skippedOverlayPassCount: Number(task.skippedOverlayPassCount) || 0,
|
||
queueWaitMs: task.processingStartedAt
|
||
? Math.max(0, Number(task.processingStartedAt) - Number(task.appendStartedAt || task.createdAt || 0))
|
||
: Math.max(0, Date.now() - Number(task.appendStartedAt || task.createdAt || Date.now())),
|
||
activeStdoutAgeMs: Math.max(
|
||
0,
|
||
Date.now() - Number(task.processingStartedAt || task.createdAt || Date.now())
|
||
),
|
||
cloneCostMs: Number(task.cloneCostMs) || 0,
|
||
applyCostMs: Number(task.applyCostMs) || 0,
|
||
trimCostMs: Number(task.trimCostMs) || 0,
|
||
stateApplyCostMs: Number(task.stateApplyCostMs) || 0,
|
||
layoutCostMs: Number(task.layoutCostMs) || 0,
|
||
overlayCostMs: Number(task.overlayCostMs) || 0,
|
||
totalRenderBuildCostMs: Number(task.totalRenderBuildCostMs) || 0,
|
||
totalSetDataCostMs: Number(task.totalSetDataCostMs) || 0,
|
||
totalPostLayoutCostMs: Number(task.totalPostLayoutCostMs) || 0,
|
||
maxLayoutCostMs: Number(task.maxLayoutCostMs) || 0,
|
||
maxRenderBuildCostMs: Number(task.maxRenderBuildCostMs) || 0,
|
||
maxSetDataCostMs: Number(task.maxSetDataCostMs) || 0,
|
||
maxPostLayoutCostMs: Number(task.maxPostLayoutCostMs) || 0,
|
||
maxOverlayCostMs: Number(task.maxOverlayCostMs) || 0,
|
||
activeRowCount: Number(summary.activeRowCount) || 0,
|
||
activeCellCount: Number(summary.activeCellCount) || 0,
|
||
activeBufferName: summary.activeBufferName || "normal",
|
||
lastRenderDecisionReason: String(task.lastRenderDecisionReason || ""),
|
||
lastRenderDecisionPolicy: String(task.lastRenderDecisionPolicy || "")
|
||
};
|
||
},
|
||
|
||
emitTerminalPerfDiagnosticSnapshot(reason, extra) {
|
||
if (!ENABLE_TERMINAL_PERF_LOGS) {
|
||
return null;
|
||
}
|
||
const now = Date.now();
|
||
if (now - Number(this.terminalPerfLastSnapshotAt || 0) < TERMINAL_PERF_DIAG_SNAPSHOT_COOLDOWN_MS) {
|
||
return null;
|
||
}
|
||
this.terminalPerfLastSnapshotAt = now;
|
||
const snapshot = {
|
||
event: "perf.snapshot",
|
||
reason: String(reason || "manual"),
|
||
at: now,
|
||
status: String(this.data.statusText || ""),
|
||
extra: extra && typeof extra === "object" ? extra : {},
|
||
recent: Array.isArray(this.terminalPerfRecentRecords)
|
||
? this.terminalPerfRecentRecords.slice(-TERMINAL_PERF_DIAG_SNAPSHOT_LIMIT)
|
||
: []
|
||
};
|
||
try {
|
||
console.info(`${TERMINAL_PERF_LOG_PREFIX} ${JSON.stringify(snapshot)}`);
|
||
} catch {
|
||
console.info(TERMINAL_PERF_LOG_PREFIX, "perf.snapshot", snapshot);
|
||
}
|
||
return snapshot;
|
||
},
|
||
|
||
shouldLogTerminalPerfFrame(index, costMs) {
|
||
return index <= TERMINAL_PERF_INITIAL_FRAME_LOG_LIMIT || costMs >= TERMINAL_PERF_SLOW_STEP_MS;
|
||
},
|
||
|
||
startTerminalPerfLagProbe() {
|
||
if (!ENABLE_TERMINAL_PERF_LOGS) {
|
||
return;
|
||
}
|
||
if (this.terminalPerfLagTimer) {
|
||
return;
|
||
}
|
||
this.terminalPerfLagExpectedAt = Date.now() + TERMINAL_PERF_LAG_SAMPLE_MS;
|
||
this.terminalPerfLagTimer = setInterval(() => {
|
||
const now = Date.now();
|
||
const driftMs = now - this.terminalPerfLagExpectedAt;
|
||
this.terminalPerfLagExpectedAt = now + TERMINAL_PERF_LAG_SAMPLE_MS;
|
||
if (driftMs < TERMINAL_PERF_LAG_WARN_MS) {
|
||
return;
|
||
}
|
||
const perf = this.terminalPerf || {};
|
||
const schedulerSnapshot = this.getTerminalRenderSchedulerSnapshot();
|
||
const activeTaskSnapshot = this.getActiveStdoutTaskSnapshot();
|
||
const pendingScheduler =
|
||
schedulerSnapshot && schedulerSnapshot.pending ? schedulerSnapshot.pending : null;
|
||
const activeScheduler = schedulerSnapshot && schedulerSnapshot.active ? schedulerSnapshot.active : null;
|
||
const payload = {
|
||
driftMs,
|
||
stdoutFrames: perf.stdoutFrameCount || 0,
|
||
visibleStdoutFrames: perf.visibleStdoutFrameCount || 0,
|
||
stdoutBytes: perf.stdoutBytes || 0,
|
||
visibleStdoutBytes: perf.visibleStdoutBytes || 0,
|
||
pendingStdoutSamples: Number(pendingScheduler && pendingScheduler.stdoutSampleCount) || 0,
|
||
pendingStdoutBytes: Number(pendingScheduler && pendingScheduler.stdoutRawBytes) || 0,
|
||
schedulerWaitMs: Number(pendingScheduler && pendingScheduler.waitMs) || 0,
|
||
activeStdoutAgeMs: Number(activeTaskSnapshot && activeTaskSnapshot.activeStdoutAgeMs) || 0,
|
||
activeStdoutBytes: Number(activeTaskSnapshot && activeTaskSnapshot.totalRawBytes) || 0,
|
||
queueWaitMs: Number(activeTaskSnapshot && activeTaskSnapshot.queueWaitMs) || 0,
|
||
cloneCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.cloneCostMs) || 0,
|
||
applyCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.applyCostMs) || 0,
|
||
trimCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.trimCostMs) || 0,
|
||
stateApplyCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.stateApplyCostMs) || 0,
|
||
layoutCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.layoutCostMs) || 0,
|
||
overlayCostMs: Number(activeTaskSnapshot && activeTaskSnapshot.overlayCostMs) || 0,
|
||
renderPassCount: Number(activeTaskSnapshot && activeTaskSnapshot.renderPassCount) || 0,
|
||
layoutPassCount: Number(activeTaskSnapshot && activeTaskSnapshot.layoutPassCount) || 0,
|
||
overlayPassCount: Number(activeTaskSnapshot && activeTaskSnapshot.overlayPassCount) || 0,
|
||
deferredRenderPassCount:
|
||
Number(activeTaskSnapshot && activeTaskSnapshot.deferredRenderPassCount) || 0,
|
||
skippedOverlayPassCount:
|
||
Number(activeTaskSnapshot && activeTaskSnapshot.skippedOverlayPassCount) || 0,
|
||
activeRowCount: Number(activeTaskSnapshot && activeTaskSnapshot.activeRowCount) || 0,
|
||
activeCellCount: Number(activeTaskSnapshot && activeTaskSnapshot.activeCellCount) || 0,
|
||
inFlightStdoutSamples: Number(activeScheduler && activeScheduler.stdoutSampleCount) || 0,
|
||
inFlightStdoutBytes: Number(activeScheduler && activeScheduler.stdoutRawBytes) || 0
|
||
};
|
||
payload.suspectedBottleneck = pickTerminalPerfSuspectedBottleneck(payload);
|
||
this.logTerminalPerf("main_thread_lag", payload);
|
||
this.emitTerminalPerfDiagnosticSnapshot("main_thread_lag", {
|
||
suspectedBottleneck: payload.suspectedBottleneck,
|
||
driftMs,
|
||
scheduler: schedulerSnapshot,
|
||
stdoutTask: activeTaskSnapshot
|
||
});
|
||
}, TERMINAL_PERF_LAG_SAMPLE_MS);
|
||
},
|
||
|
||
stopTerminalPerfLagProbe() {
|
||
if (!this.terminalPerfLagTimer) {
|
||
return;
|
||
}
|
||
clearInterval(this.terminalPerfLagTimer);
|
||
this.terminalPerfLagTimer = null;
|
||
this.terminalPerfLagExpectedAt = 0;
|
||
},
|
||
|
||
/**
|
||
* 状态芯片仅保留稳定的视觉分组,避免把所有内部状态直接映射为样式类。
|
||
*/
|
||
normalizeStatusClass(status) {
|
||
if (status === "connected") return "connected";
|
||
if (status === "error") return "error";
|
||
if (status === "disconnected") return "disconnected";
|
||
return "idle";
|
||
},
|
||
|
||
async connectGateway(isReconnectAttempt) {
|
||
const profileError = validateServerForConnect(this.server || {});
|
||
if (profileError) {
|
||
clearTerminalSessionSnapshot();
|
||
this.showLocalizedToast(profileError, "none");
|
||
this.setStatus("config_required");
|
||
return;
|
||
}
|
||
if (!isOpsConfigReady(this.opsConfig)) {
|
||
clearTerminalSessionSnapshot();
|
||
this.showLocalizedToast("运维配置缺失,请联系管理员", "none");
|
||
this.setStatus("config_required");
|
||
return;
|
||
}
|
||
this.clearAutoReconnectTimer();
|
||
if (isReconnectAttempt !== true) {
|
||
this.resetAutoReconnectState();
|
||
}
|
||
if (!this.client) {
|
||
this.client = this.createTerminalGatewayClient();
|
||
}
|
||
const currentServerId = String((this.server && this.server.id) || this.data.serverId || "").trim();
|
||
const cachedSamples = this.restoreConnectionDiagnosticSamples(currentServerId);
|
||
const currentSamples = {
|
||
connectionDiagnosticResponseSamples: this.data.connectionDiagnosticResponseSamples,
|
||
connectionDiagnosticNetworkSamples: this.data.connectionDiagnosticNetworkSamples
|
||
};
|
||
const preserveSamples =
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect === true ||
|
||
this.hasConnectionDiagnosticSamples(currentSamples) ||
|
||
this.hasConnectionDiagnosticSamples(cachedSamples);
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = false;
|
||
if (preserveSamples) {
|
||
this.applyConnectionDiagnosticSamples(
|
||
this.hasConnectionDiagnosticSamples(currentSamples) ? currentSamples : cachedSamples,
|
||
currentServerId
|
||
);
|
||
} else {
|
||
this.resetConnectionDiagnosticSamples();
|
||
}
|
||
this.clearTerminalStdoutCarry();
|
||
this.sessionSuspended = false;
|
||
/**
|
||
* 新一轮建链时先清掉旧 shell-ready 标记:
|
||
* 1. 避免上一轮 SSH 的 ready 状态泄漏到当前连接;
|
||
* 2. AI 启动必须等待当前会话再次收到网关 `control.connected`。
|
||
*/
|
||
this.aiSessionShellReady = false;
|
||
this.persistTerminalSessionStatus("connecting");
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.connectStartedAt = Date.now();
|
||
this.terminalPerf.gatewaySocketOpenAt = 0;
|
||
this.terminalPerf.authPendingAt = 0;
|
||
this.terminalPerf.firstConnectedAt = 0;
|
||
this.terminalPerf.firstStdoutAt = 0;
|
||
this.terminalPerf.firstVisibleStdoutAt = 0;
|
||
this.terminalPerf.stdoutFrameCount = 0;
|
||
this.terminalPerf.visibleStdoutFrameCount = 0;
|
||
this.terminalPerf.stdoutBytes = 0;
|
||
this.terminalPerf.visibleStdoutBytes = 0;
|
||
this.terminalPerf.frameSeq = 0;
|
||
}
|
||
this.logTerminalPerf("gateway.connect.start", {
|
||
host:
|
||
this.server.jumpHost && this.server.jumpHost.enabled ? this.server.jumpHost.host : this.server.host,
|
||
port:
|
||
this.server.jumpHost && this.server.jumpHost.enabled
|
||
? Number(this.server.jumpHost.port) || 22
|
||
: this.server.port,
|
||
cols: Math.max(1, Math.round(Number(this.terminalCols) || 80)),
|
||
rows: Math.max(1, Math.round(Number(this.terminalRows) || 24)),
|
||
hasJumpHost: !!(this.server.jumpHost && this.server.jumpHost.enabled)
|
||
});
|
||
|
||
try {
|
||
await this.client.connect({
|
||
host:
|
||
this.server.jumpHost && this.server.jumpHost.enabled ? this.server.jumpHost.host : this.server.host,
|
||
port:
|
||
this.server.jumpHost && this.server.jumpHost.enabled
|
||
? Number(this.server.jumpHost.port) || 22
|
||
: this.server.port,
|
||
username:
|
||
this.server.jumpHost && this.server.jumpHost.enabled
|
||
? this.server.jumpHost.username
|
||
: this.server.username,
|
||
clientSessionKey: this.sessionKey,
|
||
resumeGraceMs: this.resumeGraceMs,
|
||
credential:
|
||
this.server.jumpHost && this.server.jumpHost.enabled
|
||
? resolveCredential(this.server, "jump")
|
||
: resolveCredential(this.server),
|
||
...(this.server.jumpHost && this.server.jumpHost.enabled
|
||
? {
|
||
jumpHost: {
|
||
host: this.server.host,
|
||
port: this.server.port,
|
||
username: this.server.username,
|
||
credential: resolveCredential(this.server)
|
||
}
|
||
}
|
||
: {}),
|
||
cols: Math.max(1, Math.round(Number(this.terminalCols) || 80)),
|
||
rows: Math.max(1, Math.round(Number(this.terminalRows) || 24))
|
||
});
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.authPendingAt = Date.now();
|
||
}
|
||
this.setStatus("auth_pending");
|
||
this.startWaitForConnectedTimer();
|
||
this.logTerminalPerf("gateway.connect.open", {
|
||
waitMs: Math.max(
|
||
0,
|
||
Date.now() - ((this.terminalPerf && this.terminalPerf.connectStartedAt) || Date.now())
|
||
)
|
||
});
|
||
appendLog({
|
||
serverId: this.data.serverId,
|
||
status: "connecting",
|
||
summary: "小程序端发起网关连接"
|
||
});
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
},
|
||
|
||
handleFrame(frame) {
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.frameSeq += 1;
|
||
}
|
||
if (frame.type === "stdout") {
|
||
const rawText = frame.payload?.data || "";
|
||
const rawBytes = utf8ByteLength(rawText);
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.stdoutFrameCount += 1;
|
||
this.terminalPerf.stdoutBytes += rawBytes;
|
||
if (!this.terminalPerf.firstStdoutAt && rawBytes > 0) {
|
||
this.terminalPerf.firstStdoutAt = Date.now();
|
||
this.logTerminalPerf("stdout.first_frame", {
|
||
waitMs: Math.max(
|
||
0,
|
||
this.terminalPerf.firstStdoutAt -
|
||
(this.terminalPerf.connectStartedAt || this.terminalPerf.pageLoadAt || Date.now())
|
||
),
|
||
bytes: rawBytes
|
||
});
|
||
}
|
||
}
|
||
const bootstrapStartedAt = Date.now();
|
||
const text = this.consumeAiRuntimeOutput(this.consumeCodexBootstrapOutput(rawText));
|
||
const bootstrapCostMs = Date.now() - bootstrapStartedAt;
|
||
if (rawText) {
|
||
this.setStatus("connected");
|
||
}
|
||
if (bootstrapCostMs >= TERMINAL_PERF_SLOW_STEP_MS) {
|
||
this.logTerminalPerf("stdout.bootstrap_filter.slow", {
|
||
costMs: bootstrapCostMs,
|
||
rawBytes,
|
||
visibleBytes: utf8ByteLength(text)
|
||
});
|
||
}
|
||
if (
|
||
rawBytes > 0 &&
|
||
!text &&
|
||
this.shouldLogTerminalPerfFrame(
|
||
(this.terminalPerf && this.terminalPerf.stdoutFrameCount) || 0,
|
||
bootstrapCostMs
|
||
)
|
||
) {
|
||
this.logTerminalPerf("stdout.hidden_by_bootstrap", {
|
||
bytes: rawBytes,
|
||
frameSeq: (this.terminalPerf && this.terminalPerf.frameSeq) || 0
|
||
});
|
||
}
|
||
if (text) {
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.visibleStdoutFrameCount += 1;
|
||
this.terminalPerf.visibleStdoutBytes += utf8ByteLength(text);
|
||
if (!this.terminalPerf.firstVisibleStdoutAt) {
|
||
this.terminalPerf.firstVisibleStdoutAt = Date.now();
|
||
this.logTerminalPerf("stdout.first_visible_frame", {
|
||
waitMs: Math.max(
|
||
0,
|
||
this.terminalPerf.firstVisibleStdoutAt -
|
||
(this.terminalPerf.codexCommandSentAt ||
|
||
this.terminalPerf.connectStartedAt ||
|
||
this.terminalPerf.pageLoadAt ||
|
||
Date.now())
|
||
),
|
||
bytes: utf8ByteLength(text)
|
||
});
|
||
}
|
||
}
|
||
this.appendOutput(text);
|
||
this.appendTtsRoundOutput(text);
|
||
emitSessionEvent("stdout", text);
|
||
}
|
||
return;
|
||
}
|
||
if (frame.type === "stderr") {
|
||
const text = this.consumeAiRuntimeOutput(frame.payload?.data || "");
|
||
this.appendOutput(`[stderr] ${text}`);
|
||
emitSessionEvent("stderr", text);
|
||
return;
|
||
}
|
||
if (frame.type === "control" && frame.payload?.action === "connected") {
|
||
/**
|
||
* 只有“无指纹负载”的 connected 才表示 shell ready:
|
||
* 1. 指纹上报复用了同一个 action,但那一拍仍在建链流程中;
|
||
* 2. AI 启动必须等真正 shell ready 后再放行。
|
||
*/
|
||
if (!frame.payload?.fingerprint) {
|
||
this.aiSessionShellReady = true;
|
||
}
|
||
if (this.terminalPerf && !this.terminalPerf.firstConnectedAt) {
|
||
this.terminalPerf.firstConnectedAt = Date.now();
|
||
}
|
||
this.setStatus("connected");
|
||
// shell ready 后按当前面板状态同步采样节奏,展开态提到 3 秒,收起态恢复默认。
|
||
this.syncConnectionDiagnosticSampling(true);
|
||
this.logTerminalPerf("gateway.control.connected", {
|
||
waitMs: Math.max(
|
||
0,
|
||
Date.now() - ((this.terminalPerf && this.terminalPerf.connectStartedAt) || Date.now())
|
||
),
|
||
resumed: frame.payload?.resumed === true
|
||
});
|
||
appendLog({
|
||
serverId: this.data.serverId,
|
||
status: "connected",
|
||
summary: frame.payload?.resumed ? "会话已恢复" : "会话已连接"
|
||
});
|
||
this.clearAutoReconnectTimer();
|
||
this.autoReconnectAttempts = 0;
|
||
this.autoReconnectSuppressed = false;
|
||
if (frame.payload?.resumed) {
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
this.runAfterTerminalLayout(() => {
|
||
this.measureShellMetrics(() => {
|
||
this.requestTerminalRender({ sendResize: true });
|
||
});
|
||
});
|
||
} else if (this.pendingCodexResumeAfterReconnect) {
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
void this.executeAiLaunch(() => this.resumeCodexAfterReconnect(), "Codex 恢复失败", "codex");
|
||
}
|
||
return;
|
||
}
|
||
if (frame.type === "error") {
|
||
this.handleError(new Error(frame.payload?.message || "网关错误"));
|
||
}
|
||
},
|
||
|
||
handleDisconnect(reason) {
|
||
this.logTerminalPerf("gateway.disconnect", { reason });
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.persistConnectionDiagnosticSamples();
|
||
this.connectionDiagnosticKeepSamplesOnNextConnect = true;
|
||
const suspendedClose = this.sessionSuspended === true;
|
||
const activeAiProvider = this.normalizeActiveAiProvider(this.activeAiProvider);
|
||
this.aiSessionShellReady = false;
|
||
this.client = null;
|
||
this.clearTerminalStdoutCarry();
|
||
this.sessionSuspended = false;
|
||
this.clearCodexBootstrapGuard();
|
||
this.persistTerminalBufferSnapshot();
|
||
const resumable =
|
||
reason === "ws_closed" &&
|
||
!!markTerminalSessionResumable({
|
||
serverId: this.data.serverId,
|
||
serverLabel: this.data.serverLabel,
|
||
sessionId: this.data.sessionId,
|
||
sessionKey: this.sessionKey,
|
||
activeAiProvider,
|
||
codexSandboxMode:
|
||
activeAiProvider === "codex" ? normalizeCodexSandboxMode(this.activeCodexSandboxMode) : "",
|
||
resumeGraceMs: this.resumeGraceMs
|
||
});
|
||
this.pendingCodexResumeAfterReconnect = resumable && activeAiProvider === "codex";
|
||
if (!resumable) {
|
||
this.syncActiveAiProvider("", { persist: false });
|
||
clearTerminalSessionSnapshot();
|
||
}
|
||
this.setStatus("disconnected");
|
||
this.stopVoiceRound(true);
|
||
this.teardownAsrClient("session_disconnected");
|
||
appendLog({
|
||
serverId: this.data.serverId,
|
||
status: "disconnected",
|
||
summary: resumable
|
||
? `会话已挂起,可在 ${Math.round(this.resumeGraceMs / 60000)} 分钟内恢复`
|
||
: `会话断开: ${reason}`,
|
||
endAt: new Date().toISOString()
|
||
});
|
||
if (!suspendedClose) {
|
||
this.scheduleAutoReconnect(reason);
|
||
}
|
||
},
|
||
|
||
handleError(error) {
|
||
this.logTerminalPerf("gateway.error", {
|
||
message: (error && error.message) || "连接异常"
|
||
});
|
||
this.clearAutoReconnectTimer();
|
||
this.stopConnectionDiagnosticNetworkProbe();
|
||
this.aiSessionShellReady = false;
|
||
this.client = null;
|
||
this.clearTerminalStdoutCarry();
|
||
this.sessionSuspended = false;
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
this.clearCodexBootstrapGuard();
|
||
this.syncActiveAiProvider("", { persist: false });
|
||
clearTerminalSessionSnapshot();
|
||
this.setStatus("error");
|
||
const rawMessage = (error && error.message) || "连接异常";
|
||
const message = this.localizeTerminalMessage(rawMessage);
|
||
this.appendOutput(`[error] ${message}`);
|
||
wx.showToast({ title: message, icon: "none" });
|
||
if (/url not in domain list/i.test(rawMessage)) {
|
||
const domainHint = resolveSocketDomainHint(this.opsConfig && this.opsConfig.gatewayUrl);
|
||
wx.showModal({
|
||
title: this.data.copy?.modal?.socketDomainTitle || "Socket 域名未配置",
|
||
content: domainHint
|
||
? formatTemplate(this.data.copy?.modal?.socketDomainContent, { domainHint })
|
||
: this.data.copy?.modal?.socketDomainContentNoHint || "当前网关地址不在小程序 socket 合法域名列表",
|
||
showCancel: false
|
||
});
|
||
}
|
||
appendLog({
|
||
serverId: this.data.serverId,
|
||
status: "error",
|
||
summary: message,
|
||
endAt: new Date().toISOString()
|
||
});
|
||
},
|
||
|
||
appendOutput(text) {
|
||
const appendStartedAt = Date.now();
|
||
const syncResult = consumeTerminalSyncUpdateFrames(text, this.terminalSyncUpdateState);
|
||
this.terminalSyncUpdateState = syncResult.state;
|
||
const visibleText = String(syncResult.text || "");
|
||
if (!visibleText) {
|
||
return;
|
||
}
|
||
const visibleBytes = utf8ByteLength(visibleText);
|
||
this.queueTerminalOutputRender({
|
||
text: visibleText,
|
||
appendStartedAt,
|
||
visibleBytes,
|
||
visibleFrameCount: (this.terminalPerf && this.terminalPerf.visibleStdoutFrameCount) || 0
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 仅在终端显式打开 `1004` sendFocus 模式时,回写 FocusIn / FocusOut。
|
||
* 这对 Codex 这类全屏 TUI 很重要,但不能默认污染普通 shell 输入流。
|
||
*/
|
||
sendFocusModeReport(focused) {
|
||
if (!this.client || this.data.statusText !== "connected") {
|
||
return;
|
||
}
|
||
if (!this.getTerminalModes().sendFocus) {
|
||
return;
|
||
}
|
||
try {
|
||
this.client.sendStdin(focused ? "\u001b[I" : "\u001b[O");
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
},
|
||
|
||
clearWaitForConnectedTimer() {
|
||
if (!this.waitForConnectedTimer) return;
|
||
clearTimeout(this.waitForConnectedTimer);
|
||
this.waitForConnectedTimer = null;
|
||
},
|
||
|
||
startWaitForConnectedTimer() {
|
||
this.clearWaitForConnectedTimer();
|
||
const timeoutMs = normalizePositiveInt(this.opsConfig.waitForConnectedTimeoutMs, 15000, 1000);
|
||
this.waitForConnectedTimer = setTimeout(() => {
|
||
const current = this.data.statusText;
|
||
if (current !== "auth_pending" && current !== "connecting") {
|
||
return;
|
||
}
|
||
this.handleError(new Error("会话就绪超时"));
|
||
if (this.client) {
|
||
this.client.disconnect("wait_for_connected_timeout");
|
||
}
|
||
}, timeoutMs);
|
||
},
|
||
|
||
/**
|
||
* 进入终端页后的 AI 启动请求消费逻辑:
|
||
* 1. 历史上仍沿用 query 参数 `openCodex=1`;
|
||
* 2. 复用已有终端页返回时,继续使用全局 pending intent 通知;
|
||
* 3. 当前行为已改为“直接启动默认 AI”,不再弹出选择面板。
|
||
*/
|
||
consumePendingAiLaunchRequest() {
|
||
let shouldLaunch = false;
|
||
if (this.pendingOpenCodex) {
|
||
shouldLaunch = true;
|
||
this.pendingOpenCodex = false;
|
||
}
|
||
|
||
const pendingIntent = getPendingTerminalIntent();
|
||
if (
|
||
pendingIntent &&
|
||
pendingIntent.action === "open_ai" &&
|
||
pendingIntent.serverId === this.data.serverId
|
||
) {
|
||
shouldLaunch = true;
|
||
clearPendingTerminalIntent();
|
||
}
|
||
|
||
if (!shouldLaunch) {
|
||
return false;
|
||
}
|
||
|
||
void this.launchConfiguredAi();
|
||
return true;
|
||
},
|
||
|
||
clearAiConnectionWaitTimer() {
|
||
if (!this.aiConnectionWaitTimer) return;
|
||
clearTimeout(this.aiConnectionWaitTimer);
|
||
this.aiConnectionWaitTimer = null;
|
||
},
|
||
|
||
waitForAiConnected() {
|
||
this.clearAiConnectionWaitTimer();
|
||
const timeoutMs = normalizePositiveInt(this.opsConfig.waitForConnectedTimeoutMs, 15000, 1000) + 1000;
|
||
const startedAt = Date.now();
|
||
this.logTerminalPerf("ai.wait_connected.start", { timeoutMs });
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const poll = () => {
|
||
const status = String(this.data.statusText || "");
|
||
if (isAiSessionReady(status, this.client, this.aiSessionShellReady)) {
|
||
this.clearAiConnectionWaitTimer();
|
||
this.logTerminalPerf("ai.wait_connected.done", { waitMs: Date.now() - startedAt });
|
||
resolve(true);
|
||
return;
|
||
}
|
||
|
||
if (status === "config_required") {
|
||
this.clearAiConnectionWaitTimer();
|
||
this.logTerminalPerf("ai.wait_connected.abort", {
|
||
waitMs: Date.now() - startedAt,
|
||
reason: "config_required"
|
||
});
|
||
reject(new Error("服务器配置不完整"));
|
||
return;
|
||
}
|
||
|
||
if (status === "error") {
|
||
this.clearAiConnectionWaitTimer();
|
||
this.logTerminalPerf("ai.wait_connected.abort", {
|
||
waitMs: Date.now() - startedAt,
|
||
reason: "error"
|
||
});
|
||
reject(new Error("连接失败"));
|
||
return;
|
||
}
|
||
|
||
if (status === "disconnected" && Date.now() - startedAt > 600) {
|
||
this.clearAiConnectionWaitTimer();
|
||
this.logTerminalPerf("ai.wait_connected.abort", {
|
||
waitMs: Date.now() - startedAt,
|
||
reason: "disconnected"
|
||
});
|
||
reject(new Error("会话已断开"));
|
||
return;
|
||
}
|
||
|
||
if (Date.now() - startedAt >= timeoutMs) {
|
||
this.clearAiConnectionWaitTimer();
|
||
this.logTerminalPerf("ai.wait_connected.timeout", { waitMs: Date.now() - startedAt });
|
||
reject(new Error("等待会话连接超时"));
|
||
return;
|
||
}
|
||
|
||
this.aiConnectionWaitTimer = setTimeout(poll, 120);
|
||
};
|
||
|
||
poll();
|
||
});
|
||
},
|
||
|
||
async ensureConnectedForAi() {
|
||
if (!this.server) {
|
||
throw new Error("服务器不存在");
|
||
}
|
||
|
||
const status = String(this.data.statusText || "");
|
||
if (isAiSessionReady(status, this.client, this.aiSessionShellReady)) {
|
||
return this.server;
|
||
}
|
||
|
||
if (status === "connecting" || status === "auth_pending") {
|
||
await this.waitForAiConnected();
|
||
return this.server;
|
||
}
|
||
|
||
await this.connectGateway();
|
||
await this.waitForAiConnected();
|
||
return this.server;
|
||
},
|
||
|
||
sendTerminalCommand(command) {
|
||
if (!this.client || this.data.statusText !== "connected") {
|
||
throw new Error("会话未连接");
|
||
}
|
||
this.client.sendStdin(`${String(command || "")}\r`);
|
||
},
|
||
|
||
clearCodexBootstrapGuard(target) {
|
||
const guard = target || this.codexBootstrapGuard;
|
||
if (!guard) {
|
||
return;
|
||
}
|
||
if (guard.timeoutTimer) {
|
||
clearTimeout(guard.timeoutTimer);
|
||
guard.timeoutTimer = null;
|
||
}
|
||
if (guard.releaseTimer) {
|
||
clearTimeout(guard.releaseTimer);
|
||
guard.releaseTimer = null;
|
||
}
|
||
if (this.codexBootstrapGuard === guard) {
|
||
this.codexBootstrapGuard = null;
|
||
}
|
||
},
|
||
|
||
settleCodexBootstrapGuardAsResult(result, target, keepActive) {
|
||
const guard = target || this.codexBootstrapGuard;
|
||
if (!guard || guard.settled) {
|
||
return;
|
||
}
|
||
guard.settled = true;
|
||
if (guard.timeoutTimer) {
|
||
clearTimeout(guard.timeoutTimer);
|
||
guard.timeoutTimer = null;
|
||
}
|
||
if (!keepActive) {
|
||
this.clearCodexBootstrapGuard(guard);
|
||
}
|
||
guard.resolve(!!result);
|
||
},
|
||
|
||
settleCodexBootstrapGuardAsError(error, target) {
|
||
const guard = target || this.codexBootstrapGuard;
|
||
if (!guard || guard.settled) {
|
||
return;
|
||
}
|
||
this.logTerminalPerf("codex.bootstrap.error", {
|
||
waitMs: Math.max(0, Date.now() - (Number(guard.startedAt) || Date.now())),
|
||
message: resolveRuntimeMessage(error, "Codex 启动失败")
|
||
});
|
||
guard.settled = true;
|
||
this.clearCodexBootstrapGuard(guard);
|
||
guard.reject(error instanceof Error ? error : new Error(resolveRuntimeMessage(error, "Codex 启动失败")));
|
||
},
|
||
|
||
scheduleCodexBootstrapGuardRelease(target) {
|
||
const guard = target || this.codexBootstrapGuard;
|
||
if (!guard || guard.releaseTimer) {
|
||
return;
|
||
}
|
||
guard.releaseTimer = setTimeout(() => {
|
||
this.clearCodexBootstrapGuard(guard);
|
||
}, CODEX_BOOTSTRAP_RELEASE_DELAY_MS);
|
||
},
|
||
|
||
/**
|
||
* 预检阶段吞掉命令回显和 token,仅把真正的 Codex 输出放回终端。
|
||
*/
|
||
consumeCodexBootstrapOutput(data) {
|
||
const guard = this.codexBootstrapGuard;
|
||
if (!guard || !guard.active) {
|
||
return data;
|
||
}
|
||
|
||
if (!guard.firstChunkAt && data) {
|
||
guard.firstChunkAt = Date.now();
|
||
this.logTerminalPerf("codex.bootstrap.first_chunk", {
|
||
waitMs: Math.max(0, guard.firstChunkAt - guard.startedAt),
|
||
chunkBytes: utf8ByteLength(data)
|
||
});
|
||
}
|
||
|
||
guard.buffer = `${guard.buffer}${String(data || "")}`;
|
||
if (guard.buffer.length > CODEX_BOOTSTRAP_BUFFER_MAX_CHARS) {
|
||
guard.buffer = guard.buffer.slice(-CODEX_BOOTSTRAP_BUFFER_MAX_CHARS);
|
||
}
|
||
|
||
let working = guard.buffer;
|
||
const hasDirMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING);
|
||
const hasCodexMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING);
|
||
|
||
if (hasDirMissing && !guard.notifiedDirMissing) {
|
||
guard.notifiedDirMissing = true;
|
||
this.showLocalizedToast(`codex工作目录${guard.projectPath}不存在`, "none", {
|
||
projectPath: guard.projectPath
|
||
});
|
||
}
|
||
|
||
if (hasCodexMissing && !guard.notifiedCodexMissing) {
|
||
guard.notifiedCodexMissing = true;
|
||
this.showLocalizedToast("服务器未装codex", "none");
|
||
}
|
||
|
||
if (hasDirMissing) {
|
||
working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING);
|
||
}
|
||
if (hasCodexMissing) {
|
||
working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING);
|
||
}
|
||
|
||
const readyLine = extractAfterFirstCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_READY);
|
||
if (readyLine.found) {
|
||
const afterReady = readyLine.after.replace(/^\r?\n/, "");
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.codexReady = true;
|
||
}
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.codexReadyAt = Date.now();
|
||
}
|
||
this.logTerminalPerf("codex.bootstrap.ready", {
|
||
waitMs: Math.max(0, Date.now() - guard.startedAt),
|
||
bufferedChars: guard.buffer.length,
|
||
visibleBytes: utf8ByteLength(afterReady)
|
||
});
|
||
this.settleCodexBootstrapGuardAsResult(true, guard, false);
|
||
return afterReady;
|
||
}
|
||
|
||
if (guard.notifiedDirMissing || guard.notifiedCodexMissing) {
|
||
guard.buffer = "";
|
||
this.logTerminalPerf("codex.bootstrap.failed", {
|
||
waitMs: Math.max(0, Date.now() - guard.startedAt),
|
||
dirMissing: !!guard.notifiedDirMissing,
|
||
codexMissing: !!guard.notifiedCodexMissing
|
||
});
|
||
this.settleCodexBootstrapGuardAsResult(false, guard, true);
|
||
this.scheduleCodexBootstrapGuardRelease(guard);
|
||
return "";
|
||
}
|
||
|
||
guard.buffer = working;
|
||
return "";
|
||
},
|
||
|
||
startCodexBootstrapGuard(projectPath) {
|
||
if (this.codexBootstrapGuard && this.codexBootstrapGuard.active) {
|
||
throw new Error("Codex 正在启动中");
|
||
}
|
||
if (this.ttsRuntime) {
|
||
this.ttsRuntime.codexReady = false;
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const guard = {
|
||
active: true,
|
||
projectPath,
|
||
startedAt: Date.now(),
|
||
firstChunkAt: 0,
|
||
buffer: "",
|
||
notifiedDirMissing: false,
|
||
notifiedCodexMissing: false,
|
||
timeoutTimer: null,
|
||
releaseTimer: null,
|
||
settled: false,
|
||
resolve,
|
||
reject
|
||
};
|
||
guard.timeoutTimer = setTimeout(() => {
|
||
this.logTerminalPerf("codex.bootstrap.timeout", {
|
||
waitMs: Math.max(0, Date.now() - guard.startedAt),
|
||
bufferedChars: guard.buffer.length
|
||
});
|
||
this.settleCodexBootstrapGuardAsError(new Error("等待 Codex 启动结果超时"), guard);
|
||
}, CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS);
|
||
this.codexBootstrapGuard = guard;
|
||
});
|
||
},
|
||
|
||
async runCodex(sandbox) {
|
||
const server = await this.ensureConnectedForAi();
|
||
const plan = buildCodexBootstrapCommand(server.projectPath, sandbox);
|
||
const normalizedSandbox = normalizeCodexSandboxMode(sandbox);
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.codexLaunchAt = Date.now();
|
||
this.terminalPerf.codexReadyAt = 0;
|
||
}
|
||
this.logTerminalPerf("codex.launch.requested", {
|
||
sandbox,
|
||
projectPath: plan.projectPath
|
||
});
|
||
const bootstrapResultPromise = this.startCodexBootstrapGuard(plan.projectPath);
|
||
|
||
try {
|
||
this.sendTerminalCommand(plan.command);
|
||
if (this.terminalPerf) {
|
||
this.terminalPerf.codexCommandSentAt = Date.now();
|
||
}
|
||
this.logTerminalPerf("codex.command.sent", {
|
||
commandBytes: utf8ByteLength(plan.command)
|
||
});
|
||
const launched = await bootstrapResultPromise;
|
||
this.activeCodexSandboxMode = launched ? normalizedSandbox : "";
|
||
return launched;
|
||
} catch (error) {
|
||
this.settleCodexBootstrapGuardAsError(error, this.codexBootstrapGuard);
|
||
this.activeCodexSandboxMode = "";
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 旧 SSH 会话未续上时,尝试恢复最近一次 Codex CLI 会话。
|
||
* 这里不再做 bootstrap token 预检,而是直接把恢复命令打进新 shell,
|
||
* 让 Codex CLI 自己决定是否能从最近会话继续。
|
||
*/
|
||
async resumeCodexAfterReconnect() {
|
||
if (this.normalizeActiveAiProvider(this.activeAiProvider) !== "codex") {
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
return false;
|
||
}
|
||
const server = await this.ensureConnectedForAi();
|
||
const plan = buildCodexResumeCommand(
|
||
server.projectPath,
|
||
this.activeCodexSandboxMode || "workspace-write"
|
||
);
|
||
this.sendTerminalCommand(plan.command);
|
||
return true;
|
||
},
|
||
|
||
async runCopilot(command) {
|
||
const server = await this.ensureConnectedForAi();
|
||
const plan = buildCopilotLaunchCommand(server.projectPath, command);
|
||
this.sendTerminalCommand(plan.command);
|
||
return true;
|
||
},
|
||
|
||
async executeAiLaunch(task, fallbackMessage, provider) {
|
||
if (this.data.aiLaunchBusy) {
|
||
return false;
|
||
}
|
||
const startedAt = Date.now();
|
||
this.logTerminalPerf("ai.launch.start", { fallbackMessage });
|
||
|
||
this.setData({ aiLaunchBusy: true });
|
||
|
||
try {
|
||
const result = await task();
|
||
if (result) {
|
||
if (provider === "codex") {
|
||
this.pendingCodexResumeAfterReconnect = false;
|
||
}
|
||
this.syncActiveAiProvider(provider);
|
||
}
|
||
this.logTerminalPerf("ai.launch.done", {
|
||
costMs: Date.now() - startedAt,
|
||
success: !!result
|
||
});
|
||
return result;
|
||
} catch (error) {
|
||
this.logTerminalPerf("ai.launch.error", {
|
||
costMs: Date.now() - startedAt,
|
||
message: resolveRuntimeMessage(error, fallbackMessage)
|
||
});
|
||
this.showLocalizedToast(resolveRuntimeMessage(error, fallbackMessage), "none");
|
||
return false;
|
||
} finally {
|
||
this.setData({ aiLaunchBusy: false });
|
||
}
|
||
},
|
||
|
||
initVoiceFloatMetrics() {
|
||
try {
|
||
const windowInfo = getWindowMetrics(wx);
|
||
this.windowWidth = Number(windowInfo.windowWidth) || this.windowWidth;
|
||
this.windowHeight = Number(windowInfo.windowHeight) || this.windowHeight;
|
||
} catch (error) {
|
||
console.warn("[terminal.voice.system_info]", error);
|
||
}
|
||
const buttonSizePx = (this.windowWidth * VOICE_BUTTON_SIZE_RPX) / 750;
|
||
const panelWidthPx = Math.max(VOICE_PANEL_MIN_WIDTH_PX, this.windowWidth - VOICE_FLOAT_GAP_PX * 2);
|
||
const defaultVoiceButtonPosition = this.resolveCollapsedVoiceButtonPositionFromExpandedOrigin(
|
||
VOICE_FLOAT_GAP_PX,
|
||
VOICE_FLOAT_GAP_PX
|
||
);
|
||
this.setData({
|
||
voiceButtonSizePx: Math.round(buttonSizePx),
|
||
voicePanelWidthPx: Math.round(panelWidthPx),
|
||
voiceFloatLeft: defaultVoiceButtonPosition.left,
|
||
voiceFloatBottom: defaultVoiceButtonPosition.bottom
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 等待当前这轮 WXML 布局提交完成,再读取 scroll-view 的真实尺寸和滚动位置。
|
||
* 这里只服务于 overlay 同步时机,不参与任何 cursor cell 计算。
|
||
*/
|
||
runAfterTerminalLayout(callback) {
|
||
const done = typeof callback === "function" ? callback : null;
|
||
if (!done) return;
|
||
if (typeof wx.nextTick === "function") {
|
||
wx.nextTick(done);
|
||
return;
|
||
}
|
||
setTimeout(done, 0);
|
||
},
|
||
|
||
/**
|
||
* 绑定软键盘高度监听:
|
||
* 1. 输入框原生事件作为主路径;
|
||
* 2. 全局监听只做兜底,覆盖部分机型丢事件的情况。
|
||
*/
|
||
bindShellKeyboardHeightChange() {
|
||
if (typeof wx.onKeyboardHeightChange !== "function" || this.keyboardHeightChangeHandler) return;
|
||
this.keyboardHeightChangeHandler = (event) => {
|
||
const height = this.resolveKeyboardHeight(event);
|
||
this.handleShellKeyboardHeightChange(height);
|
||
};
|
||
wx.onKeyboardHeightChange(this.keyboardHeightChangeHandler);
|
||
},
|
||
|
||
unbindShellKeyboardHeightChange() {
|
||
if (!this.keyboardHeightChangeHandler) return;
|
||
if (typeof wx.offKeyboardHeightChange === "function") {
|
||
wx.offKeyboardHeightChange(this.keyboardHeightChangeHandler);
|
||
}
|
||
this.keyboardHeightChangeHandler = null;
|
||
},
|
||
|
||
syncShellInputMetrics(settings) {
|
||
const source = settings || {};
|
||
const shellFontSize = normalizePositiveInt(source.shellFontSize, 15, 12);
|
||
const shellLineHeight = Number(source.shellLineHeight);
|
||
const lineHeightRatio =
|
||
Number.isFinite(shellLineHeight) && shellLineHeight >= 1 && shellLineHeight <= 2
|
||
? shellLineHeight
|
||
: 1.4;
|
||
this.shellFontSizePx = shellFontSize;
|
||
this.shellLineHeightRatio = lineHeightRatio;
|
||
this.shellLineHeightPx = Math.max(16, Math.round(shellFontSize * lineHeightRatio));
|
||
this.shellCharWidthPx = Math.max(6, Math.round(shellFontSize * SHELL_CHAR_WIDTH_FACTOR));
|
||
this.shellAsciiCharWidthPx = this.shellCharWidthPx;
|
||
this.shellWideGlyphWidthPx = this.shellCharWidthPx * 2;
|
||
this.outputHorizontalPaddingPx = Math.max(
|
||
4,
|
||
Math.round((this.windowWidth * OUTPUT_HORIZONTAL_PADDING_RPX) / 750)
|
||
);
|
||
this.outputRightPaddingPx = OUTPUT_RIGHT_SAFE_PADDING_PX;
|
||
},
|
||
|
||
/**
|
||
* 通过隐藏探针读取真实单列宽度与宽字符字宽:
|
||
* 1. `W` probe 对齐 xterm 的单列测量思路;
|
||
* 2. 宽字符 probe 只用于兜底,确保 2 列槽位至少装得下当前字体里的 CJK glyph。
|
||
*/
|
||
measureShellMetrics(callback) {
|
||
const done = typeof callback === "function" ? callback : null;
|
||
const startedAt = Date.now();
|
||
const asciiProbeText = String(this.data.shellMetricsAsciiProbeText || SHELL_METRICS_ASCII_PROBE_TEXT);
|
||
const wideProbeText = String(this.data.shellMetricsWideProbeText || SHELL_METRICS_WIDE_PROBE_TEXT);
|
||
const asciiProbeLength = Math.max(1, asciiProbeText.length);
|
||
const wideProbeLength = Math.max(1, wideProbeText.length);
|
||
const query = wx.createSelectorQuery().in(this);
|
||
query.select(".shell-metrics-probe-line-ascii").boundingClientRect();
|
||
query.select(".shell-metrics-probe-line-wide").boundingClientRect();
|
||
query.exec((results) => {
|
||
const asciiRect = results && results[0];
|
||
const wideRect = results && results[1];
|
||
const asciiWidth = Number(asciiRect && asciiRect.width) || 0;
|
||
const asciiHeight = Number(asciiRect && asciiRect.height) || 0;
|
||
const wideWidth = Number(wideRect && wideRect.width) || 0;
|
||
const wideHeight = Number(wideRect && wideRect.height) || 0;
|
||
const asciiCharWidth =
|
||
asciiWidth > 0 ? Math.max(1, asciiWidth / asciiProbeLength) : Math.max(1, this.shellCharWidthPx || 1);
|
||
const wideGlyphWidth =
|
||
wideWidth > 0
|
||
? Math.max(1, wideWidth / wideProbeLength)
|
||
: Math.max(1, (this.shellCharWidthPx || 1) * 2);
|
||
const wideCellWidth = wideGlyphWidth / 2;
|
||
this.shellAsciiCharWidthPx = asciiCharWidth;
|
||
this.shellWideGlyphWidthPx = wideGlyphWidth;
|
||
this.shellCharWidthPx = Math.max(1, asciiCharWidth, wideCellWidth);
|
||
const measuredLineHeight = Math.max(asciiHeight, wideHeight, 0);
|
||
if (measuredLineHeight > 0) {
|
||
this.shellLineHeightPx = Math.max(16, measuredLineHeight);
|
||
}
|
||
const costMs = Date.now() - startedAt;
|
||
if (costMs >= TERMINAL_PERF_SLOW_STEP_MS) {
|
||
this.logTerminalPerf("shell.measure_metrics", {
|
||
costMs,
|
||
asciiCharWidth: Number(this.shellAsciiCharWidthPx || 0).toFixed(2),
|
||
charWidth: Number(this.shellCharWidthPx || 0).toFixed(2),
|
||
lineHeight: this.shellLineHeightPx
|
||
});
|
||
}
|
||
if (done) done();
|
||
});
|
||
},
|
||
|
||
syncTerminalBufferLimits(settings) {
|
||
const source = settings || {};
|
||
const opsEntries = normalizePositiveInt(
|
||
this.opsConfig.terminalBufferMaxEntries,
|
||
DEFAULT_BUFFER_MAX_ENTRIES,
|
||
100
|
||
);
|
||
const opsBytes = normalizePositiveInt(
|
||
this.opsConfig.terminalBufferMaxBytes,
|
||
DEFAULT_BUFFER_MAX_BYTES,
|
||
1024
|
||
);
|
||
this.terminalBufferMaxEntries = normalizePositiveInt(source.shellBufferMaxEntries, opsEntries, 100);
|
||
this.terminalBufferMaxBytes = normalizePositiveInt(source.shellBufferMaxBytes, opsBytes, 1024);
|
||
this.terminalBufferSnapshotMaxLines = Math.min(
|
||
MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
normalizePositiveInt(
|
||
source.shellBufferSnapshotMaxLines,
|
||
DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||
MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||
)
|
||
);
|
||
},
|
||
|
||
buildOutputRenderLines(bufferRows, renderMetrics, startRow) {
|
||
const rows = Array.isArray(bufferRows) ? bufferRows : [];
|
||
const baseRow = Math.max(0, Math.round(Number(startRow) || 0));
|
||
return rows.map((lineCells, index) => ({
|
||
bufferRow: baseRow + index,
|
||
...buildRenderSegmentsFromLineCells(Array.isArray(lineCells) ? lineCells : [], renderMetrics)
|
||
}));
|
||
},
|
||
|
||
getOutputBufferRows() {
|
||
return Array.isArray(this.outputCells) && this.outputCells.length > 0 ? this.outputCells : [[]];
|
||
},
|
||
|
||
getActiveBufferName() {
|
||
return normalizeActiveBufferName(this.outputTerminalState && this.outputTerminalState.activeBufferName);
|
||
},
|
||
|
||
resolveTerminalViewportState(visibleRows, lineHeight, options) {
|
||
const source = options && typeof options === "object" ? options : {};
|
||
return buildTerminalViewportState({
|
||
bufferRows: this.getOutputBufferRows(),
|
||
cursorRow: this.getOutputCursorState().row,
|
||
activeBufferName: this.getActiveBufferName(),
|
||
visibleRows,
|
||
lineHeight,
|
||
scrollTop: source.scrollTop,
|
||
followTail: source.followTail === true,
|
||
scrollDirection: source.scrollDirection
|
||
});
|
||
},
|
||
|
||
getOutputCursorState() {
|
||
const rows = this.getOutputBufferRows();
|
||
const maxCols = Math.max(1, Math.round(Number(this.terminalCols) || 80));
|
||
const row = Number.isFinite(Number(this.outputCursorRow))
|
||
? Math.max(0, Math.min(Math.round(Number(this.outputCursorRow)), rows.length - 1))
|
||
: Math.max(0, rows.length - 1);
|
||
const col = Number.isFinite(Number(this.outputCursorCol))
|
||
? Math.max(0, Math.min(Math.round(Number(this.outputCursorCol)), maxCols))
|
||
: 0;
|
||
return { row, col };
|
||
},
|
||
|
||
getOutputScrollTop() {
|
||
const runtime = Number(this.currentOutputScrollTop);
|
||
if (Number.isFinite(runtime) && runtime >= 0) return runtime;
|
||
const fallback = Number(this.data.outputScrollTop);
|
||
if (Number.isFinite(fallback) && fallback >= 0) return fallback;
|
||
return 0;
|
||
},
|
||
|
||
resolveTerminalGeometry(rect) {
|
||
const rectWidth = Number(rect && rect.width);
|
||
const rectHeight = Number(rect && rect.height);
|
||
const width = Math.max(
|
||
1,
|
||
Number.isFinite(rectWidth) ? rectWidth : this.outputRectWidth || this.windowWidth || 1
|
||
);
|
||
const lineHeight = this.shellLineHeightPx || 21;
|
||
const charWidth = Math.max(1, this.shellCharWidthPx || 9);
|
||
const paddingLeft = Math.max(0, this.outputHorizontalPaddingPx || 8);
|
||
const paddingRight = Math.max(0, this.outputRightPaddingPx || OUTPUT_RIGHT_SAFE_PADDING_PX);
|
||
const usableWidth = Math.max(SHELL_INPUT_MIN_WIDTH_PX, Math.round(width - paddingLeft - paddingRight));
|
||
const heightFallback = Math.max(lineHeight, this.outputRectHeight || lineHeight * 24);
|
||
const viewportHeight = Math.max(lineHeight, Number.isFinite(rectHeight) ? rectHeight : heightFallback);
|
||
const cols = Math.max(1, Math.floor(usableWidth / charWidth));
|
||
const rows = Math.max(1, Math.floor(viewportHeight / lineHeight));
|
||
return { lineHeight, charWidth, paddingLeft, paddingRight, usableWidth, viewportHeight, cols, rows };
|
||
},
|
||
|
||
syncTerminalGeometryFromRect(rect, options) {
|
||
const nextRect = rect || null;
|
||
const geometry = this.resolveTerminalGeometry(nextRect);
|
||
const nextWidth = Math.max(
|
||
1,
|
||
Math.round(Number(nextRect && nextRect.width) || this.outputRectWidth || geometry.usableWidth)
|
||
);
|
||
const nextHeight = Math.max(
|
||
geometry.lineHeight,
|
||
Math.round(Number(nextRect && nextRect.height) || this.outputRectHeight || geometry.viewportHeight)
|
||
);
|
||
const nextCols = Math.max(1, Math.round(Number(geometry.cols) || 80));
|
||
const nextRows = Math.max(1, Math.round(Number(geometry.rows) || 24));
|
||
const widthChanged = nextWidth !== this.outputRectWidth;
|
||
const heightChanged = nextHeight !== this.outputRectHeight;
|
||
const colsChanged = nextCols !== this.terminalCols;
|
||
const rowsChanged = nextRows !== this.terminalRows;
|
||
|
||
this.outputRectWidth = nextWidth;
|
||
this.outputRectHeight = nextHeight;
|
||
this.terminalCols = nextCols;
|
||
this.terminalRows = nextRows;
|
||
|
||
if (colsChanged) {
|
||
this.rebuildTerminalBufferFromReplay();
|
||
}
|
||
|
||
const shouldSendResize = !!(options && options.sendResize);
|
||
if (shouldSendResize && this.client && (colsChanged || rowsChanged)) {
|
||
try {
|
||
this.client.resize(nextCols, nextRows);
|
||
} catch (error) {
|
||
console.warn("[terminal.resize]", error);
|
||
}
|
||
}
|
||
|
||
return {
|
||
...geometry,
|
||
rect: nextRect,
|
||
changed: widthChanged || heightChanged || colsChanged || rowsChanged
|
||
};
|
||
},
|
||
|
||
queryOutputRect(callback) {
|
||
const query = wx.createSelectorQuery().in(this);
|
||
query.select(".terminal-output").boundingClientRect();
|
||
query.select(".terminal-output").scrollOffset();
|
||
query.exec((results) => {
|
||
const rect = (results && results[0]) || null;
|
||
const offset = results && results[1];
|
||
const scrollTop = Number(offset && offset.scrollTop);
|
||
if (Number.isFinite(scrollTop) && scrollTop >= 0) {
|
||
this.currentOutputScrollTop = scrollTop;
|
||
}
|
||
this.outputRectSnapshot = cloneOutputRectSnapshot(rect);
|
||
callback(rect || null);
|
||
});
|
||
},
|
||
|
||
resolveKeyboardHeight(event) {
|
||
const detailHeight = Number(event && event.detail && event.detail.height);
|
||
if (Number.isFinite(detailHeight) && detailHeight >= 0) return detailHeight;
|
||
const directHeight = Number(event && event.height);
|
||
if (Number.isFinite(directHeight) && directHeight >= 0) return directHeight;
|
||
return 0;
|
||
},
|
||
|
||
/**
|
||
* 计算输出区被键盘遮挡的像素高度,只服务于可见区滚动调整。
|
||
*/
|
||
resolveKeyboardViewportOverlap(rect) {
|
||
const keyboardHeight = Math.max(0, Number(this.keyboardVisibleHeightPx) || 0);
|
||
if (!rect || keyboardHeight <= 0) return 0;
|
||
const rectTop = Number(rect.top) || 0;
|
||
const rectHeight = Math.max(0, Number(rect.height) || 0);
|
||
const rectBottom = Number(rect.bottom) || rectTop + rectHeight;
|
||
const keyboardTop = Math.max(0, this.windowHeight - keyboardHeight);
|
||
if (keyboardTop >= rectBottom) return 0;
|
||
if (keyboardTop <= rectTop) return rectHeight;
|
||
return Math.max(0, rectBottom - keyboardTop);
|
||
},
|
||
|
||
/**
|
||
* 输出区底部会在键盘弹起时补一段 spacer,高度等于被遮挡的区域。
|
||
* overlay 计算可见行数时要扣掉这段空间,否则 caret 会继续按“未被遮挡”处理。
|
||
*/
|
||
resolveEffectiveOutputVisibleHeight(rect, lineHeight) {
|
||
const baseHeight = Math.max(lineHeight, Number(rect && rect.height) || 0);
|
||
const insetHeight = Math.max(0, Number(this.data.outputKeyboardInsetPx) || 0);
|
||
return Math.max(lineHeight, Math.round(baseHeight - insetHeight));
|
||
},
|
||
|
||
/**
|
||
* 统一计算键盘打开时的输出区补白与目标 scrollTop。
|
||
* 这样可以让“终端内容刷新”和“键盘可见区修正”共用同一套结果,
|
||
* 避免一次按键先后触发两轮 scrollTop 更新。
|
||
*/
|
||
resolveKeyboardLayoutAdjustment(rect, cursorMetrics, lineHeight, baseScrollTop) {
|
||
const resolvedLineHeight = Math.max(1, Number(lineHeight) || this.shellLineHeightPx || 21);
|
||
const overlapHeight = this.resolveKeyboardViewportOverlap(rect);
|
||
const insetHeight = Math.max(0, Math.round(overlapHeight));
|
||
const effectiveVisibleHeight = Math.max(
|
||
resolvedLineHeight,
|
||
Math.round((Number(rect && rect.height) || 0) - insetHeight)
|
||
);
|
||
const cursorRow =
|
||
cursorMetrics && Number.isFinite(Number(cursorMetrics.cursorRow))
|
||
? Math.max(0, Math.round(Number(cursorMetrics.cursorRow)))
|
||
: 0;
|
||
const cursorBottom = (cursorRow + 1) * resolvedLineHeight;
|
||
const currentScrollTop = Math.max(0, Number(baseScrollTop) || 0);
|
||
const minScrollTop = Math.max(0, Math.ceil(cursorBottom - effectiveVisibleHeight));
|
||
return {
|
||
insetHeight,
|
||
effectiveVisibleHeight,
|
||
cursorRow,
|
||
nextScrollTop: Math.max(currentScrollTop, minScrollTop)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 键盘打开期间,只把 scroll-view 向下推到“当前 caret 刚好可见”的位置。
|
||
* 这里不更改 caret 本身的坐标公式,因此不会影响已有光标计算。
|
||
*/
|
||
adjustOutputScrollForKeyboard(callback) {
|
||
const done = typeof callback === "function" ? callback : null;
|
||
if (!this.keyboardSessionActive || this.keyboardVisibleHeightPx <= 0) {
|
||
if (done) done();
|
||
return;
|
||
}
|
||
this.queryOutputRect((rect) => {
|
||
if (!rect) {
|
||
if (done) done();
|
||
return;
|
||
}
|
||
this.syncTerminalGeometryFromRect(rect, { sendResize: false });
|
||
const cursorMetrics = this.getTerminalCursorMetrics(rect, { sendResize: false });
|
||
const lineHeight = Math.max(
|
||
1,
|
||
Number(this.data.outputLineHeightPx) ||
|
||
Number(cursorMetrics && cursorMetrics.lineHeight) ||
|
||
this.shellLineHeightPx ||
|
||
21
|
||
);
|
||
const adjustment = this.resolveKeyboardLayoutAdjustment(
|
||
rect,
|
||
cursorMetrics,
|
||
lineHeight,
|
||
this.getOutputScrollTop()
|
||
);
|
||
const nextInsetHeight = adjustment.insetHeight;
|
||
const applyScroll = () => {
|
||
const currentScrollTop = this.getOutputScrollTop();
|
||
const nextScrollTop = adjustment.nextScrollTop;
|
||
if (nextScrollTop === currentScrollTop) {
|
||
this.syncTerminalOverlay(() => {
|
||
if (done) done();
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.currentOutputScrollTop = nextScrollTop;
|
||
this.setData({ outputScrollTop: nextScrollTop }, () => {
|
||
this.runAfterTerminalLayout(() => {
|
||
this.queryOutputRect(() => {
|
||
this.syncTerminalOverlay(() => {
|
||
if (done) done();
|
||
});
|
||
});
|
||
});
|
||
});
|
||
};
|
||
|
||
if (Number(this.data.outputKeyboardInsetPx) !== nextInsetHeight) {
|
||
this.setData({ outputKeyboardInsetPx: nextInsetHeight }, () => {
|
||
this.runAfterTerminalLayout(() => {
|
||
this.queryOutputRect(() => {
|
||
applyScroll();
|
||
});
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
applyScroll();
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 收起键盘后恢复到弹起前的 scrollTop,避免打断用户原来的浏览位置。
|
||
*/
|
||
restoreOutputScrollAfterKeyboard(callback) {
|
||
const done = typeof callback === "function" ? callback : null;
|
||
const hasSession = this.keyboardSessionActive || this.keyboardRestoreScrollTop !== null;
|
||
const restoreTop = Number(this.keyboardRestoreScrollTop);
|
||
|
||
this.keyboardVisibleHeightPx = 0;
|
||
this.keyboardRestoreScrollTop = null;
|
||
this.keyboardSessionActive = false;
|
||
|
||
if (!hasSession) {
|
||
if (done) done();
|
||
return;
|
||
}
|
||
|
||
const nextScrollTop = Number.isFinite(restoreTop) && restoreTop >= 0 ? restoreTop : 0;
|
||
this.currentOutputScrollTop = nextScrollTop;
|
||
this.setData({ outputKeyboardInsetPx: 0, outputScrollTop: nextScrollTop }, () => {
|
||
this.runAfterTerminalLayout(() => {
|
||
this.queryOutputRect(() => {
|
||
this.syncTerminalOverlay(() => {
|
||
if (done) done();
|
||
});
|
||
});
|
||
});
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 统一接收键盘高度变化:
|
||
* - 第一次弹起时冻结恢复点;
|
||
* - 键盘展开时确保 caret 可见;
|
||
* - 键盘收起时恢复原滚动位置。
|
||
*/
|
||
handleShellKeyboardHeightChange(height) {
|
||
const nextHeight = Math.max(0, Math.round(Number(height) || 0));
|
||
if (nextHeight <= 0) {
|
||
this.clearTouchShiftState();
|
||
if (this.shellInputPassiveBlurPending) {
|
||
this.finalizeShellInputBlur({ markUserInput: false });
|
||
return;
|
||
}
|
||
this.restoreOutputScrollAfterKeyboard();
|
||
return;
|
||
}
|
||
|
||
this.clearShellInputPassiveBlurState();
|
||
this.keyboardVisibleHeightPx = nextHeight;
|
||
if (!this.keyboardSessionActive) {
|
||
this.keyboardSessionActive = true;
|
||
this.keyboardRestoreScrollTop = this.getOutputScrollTop();
|
||
}
|
||
this.adjustOutputScrollForKeyboard();
|
||
},
|
||
|
||
/**
|
||
* 软键盘收起后清空 shift 状态,避免隐藏状态下遗留大写锁定。
|
||
*/
|
||
clearTouchShiftState() {
|
||
this.touchShiftLastTapAt = 0;
|
||
if (this.data.touchShiftMode === TOUCH_SHIFT_MODE_OFF) {
|
||
return;
|
||
}
|
||
this.setData({ touchShiftMode: TOUCH_SHIFT_MODE_OFF });
|
||
},
|
||
|
||
clearShellInputPassiveBlurState() {
|
||
this.shellInputPassiveBlurPending = false;
|
||
},
|
||
|
||
/**
|
||
* 输入法内部切语音输入时,个别机型会先抛一次临时 blur,但软键盘仍保持可见。
|
||
* 只有在键盘真的收起,或状态已不允许继续输入时,才把这次 blur 真正兑现为失焦。
|
||
*/
|
||
finalizeShellInputBlur(options) {
|
||
if (!this.data.shellInputFocus && !this.data.shellInputValue) return;
|
||
const source = options && typeof options === "object" ? options : {};
|
||
if (source.markUserInput !== false) {
|
||
this.markTerminalUserInput();
|
||
}
|
||
this.clearShellInputPassiveBlurState();
|
||
this.clearTouchShiftState();
|
||
this.sendFocusModeReport(false);
|
||
this.setData(
|
||
{
|
||
shellInputFocus: false,
|
||
shellInputCursor: String(this.data.shellInputValue || "").length
|
||
},
|
||
() => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay())
|
||
);
|
||
},
|
||
|
||
buildTerminalLayoutState(rect, options) {
|
||
const geometry = this.syncTerminalGeometryFromRect(rect, options);
|
||
const cursor = this.getOutputCursorState();
|
||
const normalizedOptions = options && typeof options === "object" ? options : {};
|
||
const preserveScrollTop = normalizedOptions.preserveScrollTop === true;
|
||
let viewportState = this.resolveTerminalViewportState(geometry.rows, geometry.lineHeight, {
|
||
scrollTop: preserveScrollTop ? this.getOutputScrollTop() : undefined,
|
||
followTail: !preserveScrollTop,
|
||
scrollDirection: preserveScrollTop ? this.terminalScrollDirection : 1,
|
||
scrollViewport: normalizedOptions.scrollViewport === true
|
||
});
|
||
let nextScrollTop = preserveScrollTop ? viewportState.clampedScrollTop : viewportState.maxScrollTop;
|
||
let keyboardInsetHeight = Math.max(0, Number(this.data.outputKeyboardInsetPx) || 0);
|
||
if (this.keyboardSessionActive && this.keyboardVisibleHeightPx > 0 && rect) {
|
||
const adjustment = this.resolveKeyboardLayoutAdjustment(
|
||
rect,
|
||
{
|
||
lineHeight: geometry.lineHeight,
|
||
cursorRow: cursor.row,
|
||
cursorCol: Math.min(cursor.col, Math.max(0, geometry.cols))
|
||
},
|
||
geometry.lineHeight,
|
||
nextScrollTop
|
||
);
|
||
keyboardInsetHeight = adjustment.insetHeight;
|
||
nextScrollTop = adjustment.nextScrollTop;
|
||
}
|
||
if (viewportState.clampedScrollTop !== nextScrollTop) {
|
||
viewportState = this.resolveTerminalViewportState(geometry.rows, geometry.lineHeight, {
|
||
scrollTop: nextScrollTop,
|
||
followTail: false,
|
||
scrollDirection: preserveScrollTop ? this.terminalScrollDirection : 1,
|
||
scrollViewport: normalizedOptions.scrollViewport === true
|
||
});
|
||
}
|
||
const renderBuildStartedAt = Date.now();
|
||
const renderLines = this.buildOutputRenderLines(
|
||
viewportState.renderRows,
|
||
geometry,
|
||
viewportState.renderStartRow
|
||
);
|
||
const renderBuildCostMs = Date.now() - renderBuildStartedAt;
|
||
return {
|
||
lineHeight: geometry.lineHeight,
|
||
charWidth: geometry.charWidth,
|
||
paddingLeft: geometry.paddingLeft,
|
||
paddingRight: geometry.paddingRight,
|
||
renderLines,
|
||
renderBuildCostMs,
|
||
visibleStartRow: viewportState.visibleStartRow,
|
||
visibleEndRow: viewportState.visibleEndRow,
|
||
renderStartRow: viewportState.renderStartRow,
|
||
renderEndRow: viewportState.renderEndRow,
|
||
contentRowCount: viewportState.contentRowCount,
|
||
topSpacerHeight: viewportState.topSpacerHeight,
|
||
bottomSpacerHeight: viewportState.bottomSpacerHeight,
|
||
backwardBufferRows: viewportState.backwardBufferRows,
|
||
forwardBufferRows: viewportState.forwardBufferRows,
|
||
keyboardInsetHeight,
|
||
maxScrollTop: viewportState.maxScrollTop,
|
||
nextScrollTop,
|
||
cursorRow: cursor.row,
|
||
cursorCol: Math.min(cursor.col, Math.max(0, geometry.cols)),
|
||
cols: geometry.cols,
|
||
rows: geometry.rows,
|
||
rect
|
||
};
|
||
},
|
||
|
||
refreshOutputLayout(options, callback) {
|
||
const normalizedOptions =
|
||
options && typeof options === "object" && typeof options !== "function" ? options : {};
|
||
const done = typeof options === "function" ? options : typeof callback === "function" ? callback : null;
|
||
const layoutSeq = this.terminalPerf ? (this.terminalPerf.layoutSeq += 1) : 0;
|
||
const startedAt = Date.now();
|
||
const executeWithRect = (rect, queryCostMs) => {
|
||
const baseRect = cloneOutputRectSnapshot(rect);
|
||
const viewState = this.buildTerminalLayoutState(rect, normalizedOptions);
|
||
const builtAt = Date.now();
|
||
this.outputRectSnapshot = cloneOutputRectSnapshot(viewState.rect) || baseRect;
|
||
this.outputViewportWindow = {
|
||
visibleStartRow: viewState.visibleStartRow,
|
||
visibleEndRow: viewState.visibleEndRow,
|
||
renderStartRow: viewState.renderStartRow,
|
||
renderEndRow: viewState.renderEndRow,
|
||
contentRowCount: viewState.contentRowCount,
|
||
lineHeight: viewState.lineHeight,
|
||
visibleRows: viewState.rows,
|
||
backwardBufferRows: viewState.backwardBufferRows,
|
||
forwardBufferRows: viewState.forwardBufferRows
|
||
};
|
||
this.setData(
|
||
(() => {
|
||
const shouldPreserveScrollTop = normalizedOptions.preserveScrollTop === true;
|
||
const nextData = {
|
||
outputRenderLines: viewState.renderLines,
|
||
outputLineHeightPx: viewState.lineHeight,
|
||
outputTopSpacerPx: viewState.topSpacerHeight,
|
||
outputBottomSpacerPx: viewState.bottomSpacerHeight,
|
||
outputKeyboardInsetPx: viewState.keyboardInsetHeight
|
||
};
|
||
const nextScrollTop = viewState.nextScrollTop;
|
||
if (!shouldPreserveScrollTop) {
|
||
nextData.outputScrollTop = nextScrollTop;
|
||
}
|
||
this.currentOutputScrollTop = nextScrollTop;
|
||
return nextData;
|
||
})(),
|
||
() => {
|
||
const setDataDoneAt = Date.now();
|
||
this.runAfterTerminalLayout(() => {
|
||
const afterLayoutAt = Date.now();
|
||
const finalize = (latestRect) => {
|
||
const totalCostMs = Date.now() - startedAt;
|
||
const layoutPerf = {
|
||
layoutSeq,
|
||
totalCostMs,
|
||
queryCostMs,
|
||
buildCostMs: Math.max(0, builtAt - startedAt - queryCostMs),
|
||
renderBuildCostMs: Number(viewState.renderBuildCostMs) || 0,
|
||
setDataCostMs: setDataDoneAt - builtAt,
|
||
postLayoutCostMs: afterLayoutAt - setDataDoneAt,
|
||
renderRows: viewState.renderLines.length,
|
||
renderStartRow: viewState.renderStartRow,
|
||
renderEndRow: viewState.renderEndRow,
|
||
contentRowCount: viewState.contentRowCount
|
||
};
|
||
if (this.shouldLogTerminalPerfFrame(layoutSeq, totalCostMs)) {
|
||
this.logTerminalPerf(
|
||
totalCostMs >= TERMINAL_PERF_LONG_TASK_MS ? "layout.refresh.long" : "layout.refresh",
|
||
{
|
||
...layoutPerf,
|
||
cursorRow: viewState.cursorRow,
|
||
cursorCol: viewState.cursorCol,
|
||
cols: viewState.cols,
|
||
rows: viewState.rows,
|
||
scrollTop: this.currentOutputScrollTop
|
||
}
|
||
);
|
||
/**
|
||
* 这里单独补一条 viewport 提交日志,目的是把“正文最终用了哪套 cursor/scrollTop”
|
||
* 和后面的 overlay caret 日志一一对齐,方便判断:
|
||
* 1. 是 buffer cursor 本身在跳;
|
||
* 2. 还是正文已稳定,但 overlay 取了另一套 scrollTop。
|
||
*/
|
||
this.logTerminalPerf("layout.viewport_commit", {
|
||
layoutSeq,
|
||
activeBufferName: this.getActiveBufferName(),
|
||
renderRowCount: viewState.renderLines.length,
|
||
renderStartRow: viewState.renderStartRow,
|
||
renderEndRow: viewState.renderEndRow,
|
||
contentRowCount: viewState.contentRowCount,
|
||
cursorRow: viewState.cursorRow,
|
||
cursorCol: viewState.cursorCol,
|
||
viewportMaxScrollTop: viewState.maxScrollTop,
|
||
committedScrollTop: this.currentOutputScrollTop,
|
||
outputScrollTopData: Number(this.data.outputScrollTop) || 0,
|
||
lineHeight: viewState.lineHeight,
|
||
charWidth: viewState.charWidth,
|
||
cols: viewState.cols,
|
||
rows: viewState.rows
|
||
});
|
||
}
|
||
if (done) {
|
||
this.outputRectSnapshot =
|
||
cloneOutputRectSnapshot(latestRect) || cloneOutputRectSnapshot(viewState.rect) || baseRect;
|
||
done({
|
||
...viewState,
|
||
rect: cloneOutputRectSnapshot(latestRect) || cloneOutputRectSnapshot(viewState.rect),
|
||
perf: layoutPerf
|
||
});
|
||
}
|
||
};
|
||
if (normalizedOptions.reuseRect && normalizedOptions.skipPostLayoutRectQuery) {
|
||
finalize(baseRect || viewState.rect);
|
||
return;
|
||
}
|
||
this.queryOutputRect((latestRect) => {
|
||
finalize(latestRect);
|
||
});
|
||
});
|
||
}
|
||
);
|
||
};
|
||
|
||
if (normalizedOptions.reuseRect && normalizedOptions.rect) {
|
||
executeWithRect(normalizedOptions.rect, 0);
|
||
return;
|
||
}
|
||
|
||
this.queryOutputRect((rect) => {
|
||
executeWithRect(rect, Date.now() - startedAt);
|
||
});
|
||
},
|
||
|
||
getCursorBufferRow() {
|
||
return this.getOutputCursorState().row;
|
||
},
|
||
|
||
getTerminalCursorMetrics(rect, options) {
|
||
const geometry = this.syncTerminalGeometryFromRect(rect, options);
|
||
const cursor = this.getOutputCursorState();
|
||
return {
|
||
lineHeight: geometry.lineHeight,
|
||
charWidth: geometry.charWidth,
|
||
paddingLeft: geometry.paddingLeft,
|
||
paddingRight: geometry.paddingRight,
|
||
cursorRow: cursor.row,
|
||
cursorCol: Math.min(cursor.col, Math.max(0, geometry.cols)),
|
||
rows: geometry.rows
|
||
};
|
||
},
|
||
|
||
resolveOutputScrollTopForRect(rect, cursorMetrics) {
|
||
const lineHeight = (cursorMetrics && cursorMetrics.lineHeight) || this.shellLineHeightPx || 21;
|
||
const raw = this.getOutputScrollTop();
|
||
const visibleHeight = this.resolveEffectiveOutputVisibleHeight(rect, lineHeight);
|
||
const visibleRows = Math.max(1, Math.floor(visibleHeight / lineHeight));
|
||
const viewportState = this.resolveTerminalViewportState(visibleRows, lineHeight);
|
||
const maxScrollable = viewportState.maxScrollTop;
|
||
return Math.min(maxScrollable, Math.max(0, raw));
|
||
},
|
||
|
||
/**
|
||
* 窗口化后,scroll-view 只保留“可视区附近 + overscan”的正文。
|
||
* 用户滚动到窗口边缘时,需要补一轮 preserveScrollTop 刷新,把新区域换进来。
|
||
*/
|
||
getCachedOutputRectSnapshot() {
|
||
return cloneOutputRectSnapshot(this.outputRectSnapshot);
|
||
},
|
||
|
||
clearTerminalScrollSyncTimers() {
|
||
if (this.terminalScrollOverlayTimer) {
|
||
clearTimeout(this.terminalScrollOverlayTimer);
|
||
this.terminalScrollOverlayTimer = null;
|
||
}
|
||
if (this.terminalScrollIdleTimer) {
|
||
clearTimeout(this.terminalScrollIdleTimer);
|
||
this.terminalScrollIdleTimer = null;
|
||
}
|
||
if (this.terminalScrollViewportPrefetchTimer) {
|
||
clearTimeout(this.terminalScrollViewportPrefetchTimer);
|
||
this.terminalScrollViewportPrefetchTimer = null;
|
||
}
|
||
this.terminalScrollLastOverlayAt = 0;
|
||
this.terminalScrollLastViewportRefreshAt = 0;
|
||
this.terminalScrollDirection = 0;
|
||
},
|
||
|
||
buildTerminalOverlayContext(rect) {
|
||
const snapshot = cloneOutputRectSnapshot(rect);
|
||
if (!snapshot) {
|
||
return null;
|
||
}
|
||
return {
|
||
rect: snapshot,
|
||
cursorMetrics: this.getTerminalCursorMetrics(snapshot, { sendResize: false })
|
||
};
|
||
},
|
||
|
||
updateTerminalScrollDirection(nextScrollTop) {
|
||
const currentScrollTop = Math.max(0, Number(this.currentOutputScrollTop) || 0);
|
||
const normalizedNextScrollTop = Math.max(0, Number(nextScrollTop) || 0);
|
||
const delta = normalizedNextScrollTop - currentScrollTop;
|
||
if (delta > 1) {
|
||
this.terminalScrollDirection = 1;
|
||
} else if (delta < -1) {
|
||
this.terminalScrollDirection = -1;
|
||
}
|
||
},
|
||
|
||
shouldRefreshOutputViewportWindow(scrollTop) {
|
||
const windowState =
|
||
this.outputViewportWindow && typeof this.outputViewportWindow === "object"
|
||
? this.outputViewportWindow
|
||
: null;
|
||
if (!windowState) {
|
||
return false;
|
||
}
|
||
const lineHeight = Math.max(1, Number(windowState.lineHeight) || this.shellLineHeightPx || 21);
|
||
const visibleRows = Math.max(1, Math.round(Number(windowState.visibleRows) || this.terminalRows || 24));
|
||
const renderStartRow = Math.max(0, Math.round(Number(windowState.renderStartRow) || 0));
|
||
const renderEndRow = Math.max(
|
||
renderStartRow,
|
||
Math.round(Number(windowState.renderEndRow) || renderStartRow)
|
||
);
|
||
const contentRowCount = Math.max(1, Math.round(Number(windowState.contentRowCount) || renderEndRow || 1));
|
||
if (renderEndRow - renderStartRow >= contentRowCount) {
|
||
return false;
|
||
}
|
||
const clampedScrollTop = Math.max(0, Number(scrollTop) || 0);
|
||
const maxVisibleStart = Math.max(0, contentRowCount - visibleRows);
|
||
const visibleStartRow = Math.min(maxVisibleStart, Math.max(0, Math.floor(clampedScrollTop / lineHeight)));
|
||
const visibleEndRow = Math.min(contentRowCount, visibleStartRow + visibleRows);
|
||
const backwardBufferRows = Math.max(0, visibleStartRow - renderStartRow);
|
||
const forwardBufferRows = Math.max(0, renderEndRow - visibleEndRow);
|
||
const direction = Number(this.terminalScrollDirection) || 0;
|
||
const prefetchRows = Math.max(2, TERMINAL_VIEWPORT_SCROLL_REFRESH_MARGIN_ROWS);
|
||
if (direction > 0) {
|
||
return (
|
||
forwardBufferRows <= prefetchRows || backwardBufferRows <= Math.max(4, Math.floor(prefetchRows / 2))
|
||
);
|
||
}
|
||
if (direction < 0) {
|
||
return (
|
||
backwardBufferRows <= prefetchRows || forwardBufferRows <= Math.max(4, Math.floor(prefetchRows / 2))
|
||
);
|
||
}
|
||
return backwardBufferRows <= prefetchRows || forwardBufferRows <= prefetchRows;
|
||
},
|
||
|
||
/**
|
||
* 如果当前可视区已经压进 top/bottom spacer,用户会先看到一块空白,
|
||
* 这时不能再等预取节流,必须立刻把正文窗口换进来。
|
||
*/
|
||
shouldRefreshOutputViewportWindowImmediately(scrollTop) {
|
||
const windowState =
|
||
this.outputViewportWindow && typeof this.outputViewportWindow === "object"
|
||
? this.outputViewportWindow
|
||
: null;
|
||
if (!windowState) {
|
||
return false;
|
||
}
|
||
const lineHeight = Math.max(1, Number(windowState.lineHeight) || this.shellLineHeightPx || 21);
|
||
const visibleRows = Math.max(1, Math.round(Number(windowState.visibleRows) || this.terminalRows || 24));
|
||
const renderStartRow = Math.max(0, Math.round(Number(windowState.renderStartRow) || 0));
|
||
const renderEndRow = Math.max(
|
||
renderStartRow,
|
||
Math.round(Number(windowState.renderEndRow) || renderStartRow)
|
||
);
|
||
const clampedScrollTop = Math.max(0, Number(scrollTop) || 0);
|
||
const visibleBottom = clampedScrollTop + visibleRows * lineHeight;
|
||
const renderTop = renderStartRow * lineHeight;
|
||
const renderBottom = renderEndRow * lineHeight;
|
||
return clampedScrollTop < renderTop || visibleBottom > renderBottom;
|
||
},
|
||
|
||
runTerminalScrollOverlaySync() {
|
||
this.terminalScrollLastOverlayAt = Date.now();
|
||
const overlayContext = this.buildTerminalOverlayContext(this.getCachedOutputRectSnapshot());
|
||
this.syncTerminalOverlay(overlayContext || undefined);
|
||
},
|
||
|
||
scheduleTerminalScrollOverlaySync() {
|
||
if (this.terminalScrollOverlayTimer) {
|
||
return;
|
||
}
|
||
const lastOverlayAt = Number(this.terminalScrollLastOverlayAt) || 0;
|
||
const elapsedMs = lastOverlayAt > 0 ? Math.max(0, Date.now() - lastOverlayAt) : 0;
|
||
const waitMs =
|
||
lastOverlayAt > 0
|
||
? Math.max(0, TERMINAL_SCROLL_OVERLAY_THROTTLE_MS - elapsedMs)
|
||
: TERMINAL_SCROLL_OVERLAY_THROTTLE_MS;
|
||
this.terminalScrollOverlayTimer = setTimeout(() => {
|
||
this.terminalScrollOverlayTimer = null;
|
||
this.runTerminalScrollOverlaySync();
|
||
}, waitMs);
|
||
},
|
||
|
||
scheduleTerminalScrollViewportPrefetch() {
|
||
if (this.terminalScrollViewportPrefetchTimer || this.outputViewportScrollRefreshPending) {
|
||
return;
|
||
}
|
||
const currentScrollTop = this.getOutputScrollTop();
|
||
if (!this.shouldRefreshOutputViewportWindow(currentScrollTop)) {
|
||
return;
|
||
}
|
||
if (this.shouldRefreshOutputViewportWindowImmediately(currentScrollTop)) {
|
||
this.refreshOutputViewportWindowForScroll();
|
||
return;
|
||
}
|
||
const lastRefreshAt = Number(this.terminalScrollLastViewportRefreshAt) || 0;
|
||
const elapsedMs = lastRefreshAt > 0 ? Math.max(0, Date.now() - lastRefreshAt) : Number.POSITIVE_INFINITY;
|
||
const waitMs =
|
||
lastRefreshAt > 0
|
||
? Math.max(
|
||
TERMINAL_SCROLL_VIEWPORT_PREFETCH_DELAY_MS,
|
||
TERMINAL_SCROLL_VIEWPORT_PREFETCH_THROTTLE_MS - elapsedMs
|
||
)
|
||
: TERMINAL_SCROLL_VIEWPORT_PREFETCH_DELAY_MS;
|
||
this.terminalScrollViewportPrefetchTimer = setTimeout(() => {
|
||
this.terminalScrollViewportPrefetchTimer = null;
|
||
this.refreshOutputViewportWindowForScroll();
|
||
}, waitMs);
|
||
},
|
||
|
||
finishTerminalScrollSync() {
|
||
if (this.terminalScrollOverlayTimer) {
|
||
clearTimeout(this.terminalScrollOverlayTimer);
|
||
this.terminalScrollOverlayTimer = null;
|
||
}
|
||
if (this.refreshOutputViewportWindowForScroll()) {
|
||
return;
|
||
}
|
||
this.runTerminalScrollOverlaySync();
|
||
},
|
||
|
||
scheduleTerminalScrollSettle() {
|
||
if (this.terminalScrollIdleTimer) {
|
||
clearTimeout(this.terminalScrollIdleTimer);
|
||
}
|
||
this.terminalScrollIdleTimer = setTimeout(() => {
|
||
this.terminalScrollIdleTimer = null;
|
||
this.finishTerminalScrollSync();
|
||
}, TERMINAL_SCROLL_IDLE_SETTLE_MS);
|
||
},
|
||
|
||
refreshOutputViewportWindowForScroll() {
|
||
if (this.outputViewportScrollRefreshPending) {
|
||
return true;
|
||
}
|
||
if (!this.shouldRefreshOutputViewportWindow(this.getOutputScrollTop())) {
|
||
return false;
|
||
}
|
||
this.outputViewportScrollRefreshPending = true;
|
||
this.terminalScrollLastViewportRefreshAt = Date.now();
|
||
const cachedRect = this.getCachedOutputRectSnapshot();
|
||
const layoutOptions = cachedRect
|
||
? {
|
||
preserveScrollTop: true,
|
||
scrollViewport: true,
|
||
rect: cachedRect,
|
||
reuseRect: true,
|
||
skipPostLayoutRectQuery: true
|
||
}
|
||
: { preserveScrollTop: true, scrollViewport: true };
|
||
this.refreshOutputLayout(layoutOptions, (viewState) => {
|
||
this.outputViewportScrollRefreshPending = false;
|
||
const overlayContext =
|
||
viewState && viewState.rect
|
||
? {
|
||
rect: viewState.rect,
|
||
cursorMetrics: {
|
||
lineHeight: viewState.lineHeight,
|
||
charWidth: viewState.charWidth,
|
||
paddingLeft: viewState.paddingLeft,
|
||
paddingRight: viewState.paddingRight,
|
||
cursorRow: viewState.cursorRow,
|
||
cursorCol: viewState.cursorCol,
|
||
rows: viewState.rows
|
||
}
|
||
}
|
||
: null;
|
||
this.syncTerminalOverlay(overlayContext || undefined);
|
||
});
|
||
return true;
|
||
},
|
||
|
||
resolveActivationBandInRect(rect, cursorMetrics) {
|
||
const lineHeight = (cursorMetrics && cursorMetrics.lineHeight) || this.shellLineHeightPx || 21;
|
||
const cursorRow =
|
||
cursorMetrics && Number.isFinite(Number(cursorMetrics.cursorRow))
|
||
? Math.max(0, Math.round(Number(cursorMetrics.cursorRow)))
|
||
: 0;
|
||
const scrollTop = this.resolveOutputScrollTopForRect(rect, cursorMetrics);
|
||
const centerY = cursorRow * lineHeight - scrollTop + lineHeight / 2;
|
||
const radius = lineHeight * (SHELL_ACTIVATION_RADIUS_LINES + 0.5);
|
||
const rectHeight = Math.max(0, Math.round(Number(rect && rect.height) || 0));
|
||
const top = Math.max(0, Math.round(centerY - radius));
|
||
const bottom = Math.min(rectHeight, Math.round(centerY + radius));
|
||
return {
|
||
top,
|
||
height: Math.max(2, bottom - top)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 激活带必须和最终提交到页面的 caret 使用同一份稳定快照:
|
||
* 1. 否则 stdout 高频刷新时,caret 已被冻结,激活带仍会按实时 cursor 抢先跳动;
|
||
* 2. 这里直接复用 caret 的 `top/lineHeight`,确保两者总是同帧移动;
|
||
* 3. 仅在缺少 caret 快照时,才回退到实时 cursor 计算。
|
||
*/
|
||
resolveActivationBandFromCaretSnapshot(rect, caret, cursorMetrics) {
|
||
const snapshot = caret && typeof caret === "object" ? caret : null;
|
||
if (!snapshot) {
|
||
return this.resolveActivationBandInRect(rect, cursorMetrics);
|
||
}
|
||
const lineHeight = Math.max(1, Number(snapshot.lineHeight) || this.shellLineHeightPx || 21);
|
||
const centerY = Math.round(Number(snapshot.top) || 0) + lineHeight / 2;
|
||
const radius = lineHeight * (SHELL_ACTIVATION_RADIUS_LINES + 0.5);
|
||
const rectHeight = Math.max(0, Math.round(Number(rect && rect.height) || 0));
|
||
const top = Math.max(0, Math.round(centerY - radius));
|
||
const bottom = Math.min(rectHeight, Math.round(centerY + radius));
|
||
return {
|
||
top,
|
||
height: Math.max(2, bottom - top)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 可见 caret 完全由终端 buffer 的视觉光标推导,不再借用原生 input 的内部排版结果。
|
||
*/
|
||
resolveTerminalCaretRect(rect, cursorMetrics) {
|
||
const lineHeight = (cursorMetrics && cursorMetrics.lineHeight) || this.shellLineHeightPx || 21;
|
||
const charWidth = Math.max(1, (cursorMetrics && cursorMetrics.charWidth) || this.shellCharWidthPx || 9);
|
||
const paddingLeft = Math.max(0, (cursorMetrics && cursorMetrics.paddingLeft) || 0);
|
||
const paddingRight = Math.max(
|
||
0,
|
||
(cursorMetrics && cursorMetrics.paddingRight) || OUTPUT_RIGHT_SAFE_PADDING_PX
|
||
);
|
||
const cursorRow =
|
||
cursorMetrics && Number.isFinite(Number(cursorMetrics.cursorRow))
|
||
? Math.max(0, Math.round(Number(cursorMetrics.cursorRow)))
|
||
: 0;
|
||
const cursorCol =
|
||
cursorMetrics && Number.isFinite(Number(cursorMetrics.cursorCol))
|
||
? Math.max(0, Math.round(Number(cursorMetrics.cursorCol)))
|
||
: 0;
|
||
const scrollTop = this.resolveOutputScrollTopForRect(rect, cursorMetrics);
|
||
const rectWidth = Math.max(0, Number(rect && rect.width) || 0);
|
||
const rectHeight = Math.max(0, Number(rect && rect.height) || 0);
|
||
const caretHeight = Math.max(16, Math.round(lineHeight));
|
||
const caretWidth = 2;
|
||
const rawTop = cursorRow * lineHeight - scrollTop;
|
||
const rawLeft = paddingLeft + cursorCol * charWidth;
|
||
const maxLeft = Math.max(paddingLeft, Math.round(rectWidth - paddingRight - caretWidth));
|
||
const left = Math.min(maxLeft, Math.max(paddingLeft, Math.round(rawLeft)));
|
||
const top = Math.round(rawTop);
|
||
const visible =
|
||
this.data.statusClass === "connected" &&
|
||
!this.getTerminalModes().cursorHidden &&
|
||
rectWidth > 0 &&
|
||
rectHeight > 0 &&
|
||
top + caretHeight > 0 &&
|
||
top < rectHeight;
|
||
return {
|
||
left,
|
||
top,
|
||
height: caretHeight,
|
||
visible,
|
||
cursorRow,
|
||
cursorCol,
|
||
scrollTop,
|
||
rawTop,
|
||
rawLeft,
|
||
rectWidth,
|
||
rectHeight,
|
||
lineHeight,
|
||
charWidth
|
||
};
|
||
},
|
||
|
||
resetTerminalCaretStabilityState() {
|
||
this.terminalStableCaretSnapshot = null;
|
||
this.terminalPendingCaretSnapshot = null;
|
||
this.terminalPendingCaretSince = 0;
|
||
},
|
||
|
||
hasActiveTerminalStdoutWork() {
|
||
const task =
|
||
this.activeTerminalStdoutTask && typeof this.activeTerminalStdoutTask === "object"
|
||
? this.activeTerminalStdoutTask
|
||
: null;
|
||
if (!task) {
|
||
return false;
|
||
}
|
||
return !!(
|
||
task.remainingText ||
|
||
task.pendingReplayText ||
|
||
Number(task.pendingReplayBytes) > 0 ||
|
||
Number(task.responseCount) > 0
|
||
);
|
||
},
|
||
|
||
/**
|
||
* caret 稳定化只作用于 stdout 驱动的 overlay:
|
||
* 1. 同一位置持续一个短窗口后再提交,吞掉底部状态区的瞬时跳动;
|
||
* 2. 非 stdout 场景仍走即时更新,避免滚动/聚焦体感变钝;
|
||
* 3. 最终帧或用户输入相关帧可强制提交最新 caret。
|
||
*/
|
||
resolveStableTerminalCaret(caret, options) {
|
||
const snapshot = cloneTerminalCaretSnapshot(caret);
|
||
if (!snapshot) {
|
||
this.resetTerminalCaretStabilityState();
|
||
return null;
|
||
}
|
||
const source = options && typeof options === "object" ? options : {};
|
||
const forceCommit = source.forceCommit === true;
|
||
const stabilizeDuringStdout = source.stabilizeDuringStdout === true;
|
||
const shouldStabilize = stabilizeDuringStdout && !forceCommit && this.hasActiveTerminalStdoutWork();
|
||
const now = Date.now();
|
||
|
||
if (!shouldStabilize || !snapshot.visible) {
|
||
this.terminalPendingCaretSnapshot = cloneTerminalCaretSnapshot(snapshot);
|
||
this.terminalPendingCaretSince = now;
|
||
this.terminalStableCaretSnapshot = cloneTerminalCaretSnapshot(snapshot);
|
||
return snapshot;
|
||
}
|
||
|
||
const pending = this.terminalPendingCaretSnapshot;
|
||
const stable = this.terminalStableCaretSnapshot;
|
||
if (!pending || !isSameTerminalCaretSnapshot(pending, snapshot)) {
|
||
this.terminalPendingCaretSnapshot = cloneTerminalCaretSnapshot(snapshot);
|
||
this.terminalPendingCaretSince = now;
|
||
if (!stable || !stable.visible) {
|
||
this.terminalStableCaretSnapshot = cloneTerminalCaretSnapshot(snapshot);
|
||
return snapshot;
|
||
}
|
||
return cloneTerminalCaretSnapshot(stable);
|
||
}
|
||
|
||
if (now - Number(this.terminalPendingCaretSince || 0) >= TERMINAL_CARET_STABILITY_MS) {
|
||
this.terminalStableCaretSnapshot = cloneTerminalCaretSnapshot(snapshot);
|
||
return snapshot;
|
||
}
|
||
|
||
return stable && stable.visible ? cloneTerminalCaretSnapshot(stable) : snapshot;
|
||
},
|
||
|
||
withOutputRect(callback) {
|
||
this.queryOutputRect((rect) => {
|
||
this.syncTerminalGeometryFromRect(rect, { sendResize: true });
|
||
callback(rect);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 统一同步终端 overlay:
|
||
* 1. 自绘 caret
|
||
* 2. 调试用激活带
|
||
* 所有位置都只读取同一份终端缓冲区 cursor 结果。
|
||
*/
|
||
syncTerminalOverlay(options, callback) {
|
||
const normalizedOptions =
|
||
options && typeof options === "object" && typeof options !== "function" ? options : {};
|
||
const done = typeof options === "function" ? options : typeof callback === "function" ? callback : null;
|
||
const overlaySeq = this.terminalPerf ? (this.terminalPerf.overlaySeq += 1) : 0;
|
||
const startedAt = Date.now();
|
||
const applyOverlay = (rect, presetCursorMetrics) => {
|
||
const next = {};
|
||
let changed = false;
|
||
let caret = null;
|
||
const activationDebugEnabled = !!this.data.activationDebugEnabled;
|
||
|
||
if (!rect) {
|
||
this.resetTerminalCaretStabilityState();
|
||
if (this.data.terminalCaretVisible) {
|
||
next.terminalCaretVisible = false;
|
||
changed = true;
|
||
}
|
||
if (this.data.activationDebugVisible) {
|
||
next.activationDebugVisible = false;
|
||
changed = true;
|
||
}
|
||
} else {
|
||
const cursorMetrics =
|
||
presetCursorMetrics && typeof presetCursorMetrics === "object"
|
||
? presetCursorMetrics
|
||
: this.getTerminalCursorMetrics(rect, { sendResize: false });
|
||
caret = this.resolveStableTerminalCaret(this.resolveTerminalCaretRect(rect, cursorMetrics), {
|
||
stabilizeDuringStdout: normalizedOptions.stabilizeCaretDuringStdout === true,
|
||
forceCommit: normalizedOptions.forceCaretCommit === true
|
||
});
|
||
if (this.data.terminalCaretVisible !== caret.visible) {
|
||
next.terminalCaretVisible = caret.visible;
|
||
changed = true;
|
||
}
|
||
if (this.data.terminalCaretLeftPx !== caret.left) {
|
||
next.terminalCaretLeftPx = caret.left;
|
||
changed = true;
|
||
}
|
||
if (this.data.terminalCaretTopPx !== caret.top) {
|
||
next.terminalCaretTopPx = caret.top;
|
||
changed = true;
|
||
}
|
||
if (this.data.terminalCaretHeightPx !== caret.height) {
|
||
next.terminalCaretHeightPx = caret.height;
|
||
changed = true;
|
||
}
|
||
|
||
if (activationDebugEnabled) {
|
||
const band = this.resolveActivationBandFromCaretSnapshot(rect, caret, cursorMetrics);
|
||
if (this.data.activationDebugVisible !== true) {
|
||
next.activationDebugVisible = true;
|
||
changed = true;
|
||
}
|
||
if (this.data.activationDebugTopPx !== band.top) {
|
||
next.activationDebugTopPx = band.top;
|
||
changed = true;
|
||
}
|
||
if (this.data.activationDebugHeightPx !== band.height) {
|
||
next.activationDebugHeightPx = band.height;
|
||
changed = true;
|
||
}
|
||
} else if (this.data.activationDebugVisible) {
|
||
next.activationDebugVisible = false;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (!changed) {
|
||
const costMs = Date.now() - startedAt;
|
||
const overlayPerf = {
|
||
overlaySeq,
|
||
costMs,
|
||
changed: false
|
||
};
|
||
if (this.shouldLogTerminalPerfFrame(overlaySeq, costMs) && costMs >= TERMINAL_PERF_SLOW_STEP_MS) {
|
||
this.logTerminalPerf("overlay.sync", {
|
||
overlaySeq,
|
||
costMs,
|
||
changed: false,
|
||
caretVisible: !!(rect && !this.getTerminalModes().cursorHidden),
|
||
caretLeft: rect ? this.data.terminalCaretLeftPx : -1,
|
||
caretTop: rect ? this.data.terminalCaretTopPx : -1
|
||
});
|
||
}
|
||
if (done) done(overlayPerf);
|
||
return;
|
||
}
|
||
this.setData(next, () => {
|
||
const costMs = Date.now() - startedAt;
|
||
const overlayPerf = {
|
||
overlaySeq,
|
||
costMs,
|
||
changed: true
|
||
};
|
||
if (this.shouldLogTerminalPerfFrame(overlaySeq, costMs)) {
|
||
/**
|
||
* caret overlay 是这次问题的核心观察对象。
|
||
* 这里把 buffer cursor、scrollTop 与最终像素 top/left 一起打出来,
|
||
* 复现时即可直接判断“哪一层先跳了”。
|
||
*/
|
||
this.logTerminalPerf(costMs >= TERMINAL_PERF_LONG_TASK_MS ? "overlay.sync.long" : "overlay.sync", {
|
||
overlaySeq,
|
||
costMs,
|
||
changed: true,
|
||
caretVisible: !!next.terminalCaretVisible,
|
||
cursorRow: caret.cursorRow,
|
||
cursorCol: caret.cursorCol,
|
||
scrollTop: caret.scrollTop,
|
||
rawTop: caret.rawTop,
|
||
rawLeft: caret.rawLeft,
|
||
caretTop: caret.top,
|
||
caretLeft: caret.left,
|
||
caretHeight: caret.height,
|
||
rectWidth: caret.rectWidth,
|
||
rectHeight: caret.rectHeight,
|
||
lineHeight: caret.lineHeight,
|
||
charWidth: caret.charWidth
|
||
});
|
||
}
|
||
if (done) done(overlayPerf);
|
||
});
|
||
};
|
||
|
||
if (normalizedOptions.rect) {
|
||
applyOverlay(normalizedOptions.rect, normalizedOptions.cursorMetrics || null);
|
||
return;
|
||
}
|
||
this.withOutputRect((rect) => {
|
||
applyOverlay(rect, null);
|
||
});
|
||
},
|
||
|
||
isPointInCursorActivationBand(point, rect) {
|
||
if (!point || !rect) return false;
|
||
const cursorMetrics = this.getTerminalCursorMetrics(rect, { sendResize: false });
|
||
const band = this.resolveActivationBandInRect(rect, cursorMetrics);
|
||
const bandTop = Number(rect.top) + band.top;
|
||
const bandBottom = bandTop + band.height;
|
||
return point.y >= bandTop && point.y <= bandBottom;
|
||
},
|
||
|
||
focusShellInput() {
|
||
if (this.data.statusClass !== "connected") return;
|
||
const value = String(this.data.shellInputValue || "");
|
||
this.setData(
|
||
{
|
||
shellInputFocus: true,
|
||
shellInputCursor: value.length
|
||
},
|
||
() => {
|
||
if (this.keyboardSessionActive && this.keyboardVisibleHeightPx > 0) {
|
||
this.adjustOutputScrollForKeyboard();
|
||
return;
|
||
}
|
||
this.syncTerminalOverlay();
|
||
}
|
||
);
|
||
},
|
||
|
||
blurShellInput() {
|
||
if (!this.data.shellInputFocus && !this.data.shellInputValue) return;
|
||
/**
|
||
* 软键盘临时收起时必须保留当前输入草稿:
|
||
* 1. 远端 shell 里这行内容还存在;
|
||
* 2. 如果这里清空本地 value,二次聚焦后原生 input 会变成空串;
|
||
* 3. 此时用户再按 backspace,不会产生任何删除事件。
|
||
*/
|
||
this.setData(
|
||
{
|
||
shellInputFocus: false,
|
||
shellInputCursor: String(this.data.shellInputValue || "").length
|
||
},
|
||
() => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay())
|
||
);
|
||
},
|
||
|
||
clearVoiceHoldTimer() {
|
||
if (!this.voiceHoldTimer) return;
|
||
clearTimeout(this.voiceHoldTimer);
|
||
this.voiceHoldTimer = null;
|
||
},
|
||
|
||
getVoiceWidgetSize() {
|
||
const width = this.data.voicePanelVisible ? this.data.voicePanelWidthPx : this.data.voiceButtonSizePx;
|
||
const height = this.data.voicePanelVisible ? this.voicePanelHeightPx : this.data.voiceButtonSizePx;
|
||
return {
|
||
width: Math.max(1, Number(width) || 1),
|
||
height: Math.max(1, Number(height) || 1)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 浮层拖拽边界统一按“展开后的面板占位”计算:
|
||
* 1. 收起态按钮只能停留在展开面板可完全容纳的区域内;
|
||
* 2. 这样长按展开时,主语音按钮才能稳定落在按压位置附近,不会被二次夹回。
|
||
*/
|
||
getVoiceExpandedWidgetSize() {
|
||
const width = Math.max(
|
||
1,
|
||
Number(this.data.voicePanelWidthPx) ||
|
||
Math.max(VOICE_PANEL_MIN_WIDTH_PX, this.windowWidth - VOICE_FLOAT_GAP_PX * 2)
|
||
);
|
||
const height = Math.max(1, Number(this.voicePanelHeightPx) || VOICE_PANEL_FALLBACK_HEIGHT_PX);
|
||
return { width, height };
|
||
},
|
||
|
||
clampVoiceExpandedOrigin(left, bottom) {
|
||
const widget = this.getVoiceExpandedWidgetSize();
|
||
const maxLeft = Math.max(VOICE_FLOAT_GAP_PX, this.windowWidth - widget.width - VOICE_FLOAT_GAP_PX);
|
||
const maxBottom = Math.max(VOICE_FLOAT_GAP_PX, this.windowHeight - widget.height - VOICE_FLOAT_GAP_PX);
|
||
return {
|
||
left: Math.min(maxLeft, Math.max(VOICE_FLOAT_GAP_PX, Number(left) || VOICE_FLOAT_GAP_PX)),
|
||
bottom: Math.min(maxBottom, Math.max(VOICE_FLOAT_GAP_PX, Number(bottom) || VOICE_FLOAT_GAP_PX))
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 估算展开态主 voice 按钮相对于面板左下角的锚点偏移。
|
||
* 这里直接复用当前 CSS 常量,避免再走一次额外节点测量。
|
||
*/
|
||
resolveVoiceMainButtonAnchorInset() {
|
||
const rpxUnit = this.windowWidth / 750;
|
||
const frameBottomPaddingPx = VOICE_FRAME_BOTTOM_PADDING_RPX * rpxUnit;
|
||
const actionsMinHeightPx = VOICE_ACTIONS_MIN_HEIGHT_RPX * rpxUnit;
|
||
const categoryPillHeightPx = VOICE_CATEGORY_PILL_MIN_HEIGHT_RPX * rpxUnit;
|
||
const rowCenteringInsetPx = Math.max(0, actionsMinHeightPx - VOICE_MAIN_BUTTON_SIZE_PX) / 2;
|
||
return {
|
||
left: VOICE_ACTIONS_SIDE_PADDING_PX,
|
||
bottom:
|
||
frameBottomPaddingPx +
|
||
VOICE_CATEGORY_BOTTOM_PADDING_PX +
|
||
categoryPillHeightPx +
|
||
VOICE_ACTIONS_VERTICAL_PADDING_PX +
|
||
rowCenteringInsetPx
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 收起态按钮的左右活动范围来自展开态底部动作区:
|
||
* - 左端 = 面板左边距 + 主按钮自身左边距;
|
||
* - 右端 = 左侧动作组在 track 内的最大平移终点。
|
||
*/
|
||
resolveVoiceActionsMaxOffsetPx() {
|
||
const measured = Number(this.voiceActionsMaxOffsetPx);
|
||
if (Number.isFinite(measured) && measured > 0) {
|
||
return measured;
|
||
}
|
||
const rpxUnit = this.windowWidth / 750;
|
||
const sideActionButtonPx = VOICE_SIDE_ACTION_BUTTON_RPX * rpxUnit;
|
||
const leftGroupWidth =
|
||
VOICE_MAIN_BUTTON_SIZE_PX +
|
||
VOICE_ACTION_BUTTON_GAP_PX +
|
||
VOICE_SECONDARY_BUTTON_SIZE_PX +
|
||
VOICE_ACTION_BUTTON_GAP_PX +
|
||
VOICE_SECONDARY_BUTTON_SIZE_PX;
|
||
const rightGroupWidth = sideActionButtonPx + VOICE_ACTION_BUTTON_GAP_PX + sideActionButtonPx;
|
||
const panelWidth = this.getVoiceExpandedWidgetSize().width;
|
||
const rowInnerWidth = Math.max(0, panelWidth - VOICE_ACTIONS_SIDE_PADDING_PX * 2);
|
||
const trackWidth = Math.max(0, rowInnerWidth - (rightGroupWidth + VOICE_ACTION_BUTTON_GAP_PX));
|
||
return Math.max(0, Math.round(trackWidth - leftGroupWidth));
|
||
},
|
||
|
||
getCollapsedVoiceButtonBounds() {
|
||
const anchorInset = this.resolveVoiceMainButtonAnchorInset();
|
||
const minExpandedOrigin = this.clampVoiceExpandedOrigin(VOICE_FLOAT_GAP_PX, VOICE_FLOAT_GAP_PX);
|
||
const maxExpandedOrigin = this.clampVoiceExpandedOrigin(VOICE_FLOAT_GAP_PX, this.windowHeight);
|
||
const actionsMaxOffsetPx = this.resolveVoiceActionsMaxOffsetPx();
|
||
return {
|
||
minLeft: Math.round(minExpandedOrigin.left + anchorInset.left),
|
||
maxLeft: Math.round(minExpandedOrigin.left + anchorInset.left + actionsMaxOffsetPx),
|
||
minBottom: Math.round(minExpandedOrigin.bottom + anchorInset.bottom),
|
||
maxBottom: Math.round(maxExpandedOrigin.bottom + anchorInset.bottom)
|
||
};
|
||
},
|
||
|
||
clampCollapsedVoiceButtonPosition(left, bottom) {
|
||
const bounds = this.getCollapsedVoiceButtonBounds();
|
||
return {
|
||
left: Math.min(bounds.maxLeft, Math.max(bounds.minLeft, Number(left) || bounds.minLeft)),
|
||
bottom: Math.min(bounds.maxBottom, Math.max(bounds.minBottom, Number(bottom) || bounds.minBottom))
|
||
};
|
||
},
|
||
|
||
resolveVoiceActionsOffsetFromCollapsedLeft(left) {
|
||
const bounds = this.getCollapsedVoiceButtonBounds();
|
||
return Math.max(
|
||
0,
|
||
Math.min(this.resolveVoiceActionsMaxOffsetPx(), Math.round((Number(left) || 0) - bounds.minLeft))
|
||
);
|
||
},
|
||
|
||
resolveCollapsedVoiceButtonPositionFromExpandedOrigin(left, bottom, offsetX) {
|
||
const anchorInset = this.resolveVoiceMainButtonAnchorInset();
|
||
const normalizedOffset = Math.max(
|
||
0,
|
||
Math.min(
|
||
this.resolveVoiceActionsMaxOffsetPx(),
|
||
Math.round(offsetX === undefined ? Number(this.data.voiceActionsOffsetX) || 0 : Number(offsetX) || 0)
|
||
)
|
||
);
|
||
return {
|
||
left: Math.round((Number(left) || 0) + anchorInset.left + normalizedOffset),
|
||
bottom: Math.round((Number(bottom) || 0) + anchorInset.bottom)
|
||
};
|
||
},
|
||
|
||
resolveExpandedVoiceOriginFromCollapsedButton(left, bottom, offsetX) {
|
||
const anchorInset = this.resolveVoiceMainButtonAnchorInset();
|
||
const normalizedOffset =
|
||
offsetX === undefined ? this.resolveVoiceActionsOffsetFromCollapsedLeft(left) : Number(offsetX) || 0;
|
||
return this.clampVoiceExpandedOrigin(
|
||
(Number(left) || 0) - anchorInset.left - normalizedOffset,
|
||
(Number(bottom) || 0) - anchorInset.bottom
|
||
);
|
||
},
|
||
|
||
clampVoiceFloatPosition(left, bottom) {
|
||
if (this.data.voicePanelVisible) {
|
||
return this.clampVoiceExpandedOrigin(left, bottom);
|
||
}
|
||
return this.clampCollapsedVoiceButtonPosition(left, bottom);
|
||
},
|
||
|
||
syncVoiceFloatWithinBounds() {
|
||
const next = this.clampVoiceFloatPosition(this.data.voiceFloatLeft, this.data.voiceFloatBottom);
|
||
const nextActionsOffsetX = this.data.voicePanelVisible
|
||
? this.data.voiceActionsOffsetX
|
||
: this.resolveVoiceActionsOffsetFromCollapsedLeft(next.left);
|
||
if (
|
||
next.left === this.data.voiceFloatLeft &&
|
||
next.bottom === this.data.voiceFloatBottom &&
|
||
nextActionsOffsetX === this.data.voiceActionsOffsetX
|
||
) {
|
||
return;
|
||
}
|
||
this.setData({
|
||
voiceFloatLeft: next.left,
|
||
voiceFloatBottom: next.bottom,
|
||
voiceActionsOffsetX: nextActionsOffsetX
|
||
});
|
||
},
|
||
|
||
measureVoicePanelHeight() {
|
||
const query = wx.createSelectorQuery().in(this);
|
||
query.select(".frame2256").boundingClientRect((rect) => {
|
||
if (!rect) return;
|
||
const nextHeight = Number(rect.height) || 0;
|
||
if (nextHeight <= 0) return;
|
||
this.voicePanelHeightPx = nextHeight;
|
||
this.syncVoiceFloatWithinBounds();
|
||
this.measureVoiceActionsBounds();
|
||
});
|
||
query.exec();
|
||
},
|
||
|
||
measureVoiceActionsBounds() {
|
||
if (!this.data.voicePanelVisible) return;
|
||
const query = wx.createSelectorQuery().in(this);
|
||
query.select(".voice-actions-left-track").boundingClientRect();
|
||
query.select(".voice-actions-left").boundingClientRect();
|
||
query.exec((rects) => {
|
||
const trackRect = rects && rects[0];
|
||
const leftGroupRect = rects && rects[1];
|
||
if (!trackRect || !leftGroupRect) return;
|
||
const trackWidth = Number(trackRect.width) || 0;
|
||
const leftGroupWidth = Number(leftGroupRect.width) || 0;
|
||
const maxOffset = Math.max(0, Math.round(trackWidth - leftGroupWidth));
|
||
this.voiceActionsMaxOffsetPx = maxOffset;
|
||
const current = Number(this.data.voiceActionsOffsetX) || 0;
|
||
const next = Math.min(maxOffset, Math.max(0, Math.round(current)));
|
||
if (next !== current) {
|
||
this.setData({ voiceActionsOffsetX: next });
|
||
}
|
||
});
|
||
},
|
||
|
||
stopRecorderIfRunning(reason) {
|
||
if (this.isRecorderStarting && !this.isRecorderRunning) {
|
||
this.stopRecorderAfterStart = true;
|
||
return;
|
||
}
|
||
if (!this.isRecorderRunning) return;
|
||
try {
|
||
recorderManager.stop();
|
||
} catch (error) {
|
||
console.warn("[terminal.recorder.stop.safe]", reason || "", error);
|
||
}
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
},
|
||
|
||
forceResetVoiceRoundForRestart(reason) {
|
||
if (this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
this.voiceRound.stopRequestedBeforeReady = false;
|
||
this.voiceRound.discardResults = true;
|
||
this.voiceRound.phase = "idle";
|
||
this.teardownAsrClient(reason || "force_restart");
|
||
// 仅在录音器处于启动/运行态时请求停止,避免触发 recorder not start 噪声错误。
|
||
this.stopRecorderIfRunning("force_restart");
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
},
|
||
|
||
/**
|
||
* 先触发微信隐私授权同步,再继续申请麦克风权限。
|
||
* 这样在后台隐私声明已补齐的前提下,录音链路能走到官方隐私弹窗或自带授权流程。
|
||
*/
|
||
ensureVoicePrivacyAuthorization() {
|
||
return new Promise((resolve, reject) => {
|
||
if (typeof wx.requirePrivacyAuthorize !== "function") {
|
||
resolve();
|
||
return;
|
||
}
|
||
try {
|
||
wx.requirePrivacyAuthorize({
|
||
success: () => resolve(),
|
||
fail: (error) => {
|
||
const rawMessage = resolveRuntimeMessage(error, "隐私授权失败");
|
||
reject(new Error(resolveVoicePrivacyErrorMessage(rawMessage, rawMessage)));
|
||
}
|
||
});
|
||
} catch (error) {
|
||
const rawMessage = resolveRuntimeMessage(error, "隐私授权失败");
|
||
reject(new Error(resolveVoicePrivacyErrorMessage(rawMessage, rawMessage)));
|
||
}
|
||
});
|
||
},
|
||
|
||
async ensureRecordPermission() {
|
||
await this.ensureVoicePrivacyAuthorization();
|
||
return new Promise((resolve, reject) => {
|
||
wx.getSetting({
|
||
success: (res) => {
|
||
const authSetting = (res && res.authSetting) || {};
|
||
if (authSetting["scope.record"] === true) {
|
||
resolve();
|
||
return;
|
||
}
|
||
wx.authorize({
|
||
scope: "scope.record",
|
||
success: () => resolve(),
|
||
fail: (error) => {
|
||
reject(new Error(resolveRuntimeMessage(error, "麦克风权限未开启,请在设置中允许录音")));
|
||
}
|
||
});
|
||
},
|
||
fail: (error) => {
|
||
reject(new Error(resolveRuntimeMessage(error, "读取麦克风权限失败")));
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
onInputText(event) {
|
||
const value = String((event && event.detail && event.detail.value) || "");
|
||
if (/[\r\n]/.test(value)) {
|
||
const next = value.replace(/[\r\n]+/g, "");
|
||
this.setData({ inputText: next, draftText: next }, () => this.onSendDraft());
|
||
return;
|
||
}
|
||
this.setData({ inputText: value, draftText: value });
|
||
},
|
||
|
||
onInputConfirm() {
|
||
this.onSendDraft();
|
||
},
|
||
|
||
/**
|
||
* 读取当前全局配置中的分类,并在分类变化后保持当前选中项尽量稳定。
|
||
*/
|
||
resolveSelectedRecordCategory(settings) {
|
||
const categories = resolveVoiceRecordCategories(settings);
|
||
const current = String(this.data.selectedRecordCategory || "").trim();
|
||
if (current && categories.includes(current)) return current;
|
||
return normalizeVoiceRecordDefaultCategory(settings && settings.voiceRecordDefaultCategory, categories);
|
||
},
|
||
|
||
/**
|
||
* 语音面板中的分类切换,只影响本次“记录到闪念”的默认归属。
|
||
*/
|
||
onSelectRecordCategory(event) {
|
||
const category = String(event.currentTarget.dataset.category || "").trim();
|
||
const categories = resolveVoiceRecordCategories(getSettings());
|
||
if (!category || !categories.includes(category)) return;
|
||
this.setData({
|
||
voiceRecordCategories: categories,
|
||
selectedRecordCategory: category
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 统一计算写入闪念时的上下文快照,格式固定为“服务器名称-项目名”。
|
||
*/
|
||
resolveRecordContext() {
|
||
const server = this.server || {};
|
||
const serverName =
|
||
String(server.name || `${server.username || "-"}@${server.host || "-"}`).trim() ||
|
||
this.data.copy?.fallback?.unnamedServer ||
|
||
"未命名服务器";
|
||
const projectName =
|
||
resolveProjectDirectoryName(server.projectPath) || this.data.copy?.fallback?.noProject || "未设置项目";
|
||
return {
|
||
serverId: this.data.serverId,
|
||
contextLabel: `${serverName}-${projectName}`
|
||
};
|
||
},
|
||
|
||
forwardShellInputDelta(previousValue, nextValue) {
|
||
if (!this.client) return;
|
||
const previous = String(previousValue || "");
|
||
const next = String(nextValue || "");
|
||
if (previous === next) return;
|
||
|
||
let payload = "";
|
||
if (next.startsWith(previous)) {
|
||
payload = next.slice(previous.length);
|
||
} else if (previous.startsWith(next)) {
|
||
payload = "\u007f".repeat(previous.length - next.length);
|
||
} else {
|
||
payload = `${"\u007f".repeat(previous.length)}${next}`;
|
||
}
|
||
if (!payload) return;
|
||
this.client.sendStdin(payload);
|
||
},
|
||
|
||
onShellInputChange(event) {
|
||
const rawNextValue = String((event && event.detail && event.detail.value) || "");
|
||
const previousValue = String(this.data.shellInputValue || "");
|
||
const shiftResult = applyTouchShiftToValue(previousValue, rawNextValue, this.data.touchShiftMode);
|
||
const nextShiftMode =
|
||
shiftResult.consumedOnce && this.data.touchShiftMode === TOUCH_SHIFT_MODE_ONCE
|
||
? TOUCH_SHIFT_MODE_OFF
|
||
: this.data.touchShiftMode;
|
||
try {
|
||
this.markTerminalUserInput();
|
||
this.forwardShellInputDelta(previousValue, shiftResult.value);
|
||
const nextData = {
|
||
shellInputValue: shiftResult.value,
|
||
shellInputCursor: shiftResult.value.length,
|
||
touchShiftMode: nextShiftMode
|
||
};
|
||
if (shiftResult.consumedOnce) {
|
||
this.touchShiftLastTapAt = 0;
|
||
}
|
||
this.setData(nextData);
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 点击激活区后,优先使用 focus 事件携带的键盘高度启动可见区调整。
|
||
* 这样即使后续 keyboardheightchange 在某些机型上不稳定,也不会错过第一次弹键盘。
|
||
*/
|
||
onShellInputFocus(event) {
|
||
if (this.data.statusClass !== "connected") {
|
||
this.setData(
|
||
{
|
||
shellInputFocus: false,
|
||
shellInputValue: "",
|
||
shellInputCursor: 0
|
||
},
|
||
() => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay())
|
||
);
|
||
return;
|
||
}
|
||
this.clearShellInputPassiveBlurState();
|
||
this.markTerminalUserInput();
|
||
const keyboardHeight = this.resolveKeyboardHeight(event);
|
||
if (!this.keyboardSessionActive) {
|
||
this.keyboardSessionActive = true;
|
||
this.keyboardRestoreScrollTop = this.getOutputScrollTop();
|
||
}
|
||
if (keyboardHeight > 0) {
|
||
this.keyboardVisibleHeightPx = keyboardHeight;
|
||
this.sendFocusModeReport(true);
|
||
this.adjustOutputScrollForKeyboard();
|
||
return;
|
||
}
|
||
this.sendFocusModeReport(true);
|
||
this.syncTerminalOverlay();
|
||
},
|
||
|
||
onShellInputConfirm(event) {
|
||
const confirmedValue = String((event && event.detail && event.detail.value) || "");
|
||
const previousValue = String(this.data.shellInputValue || "");
|
||
try {
|
||
this.markTerminalUserInput();
|
||
this.forwardShellInputDelta(previousValue, confirmedValue);
|
||
if (!this.client) {
|
||
this.showLocalizedToast("会话未连接", "none");
|
||
return;
|
||
}
|
||
this.client.sendStdin("\r");
|
||
this.maybeStartTtsRound("shell_confirm");
|
||
this.setData(
|
||
{
|
||
shellInputValue: "",
|
||
shellInputCursor: 0
|
||
},
|
||
() => {
|
||
if (this.keyboardSessionActive && this.keyboardVisibleHeightPx > 0) {
|
||
this.adjustOutputScrollForKeyboard();
|
||
return;
|
||
}
|
||
this.syncTerminalOverlay();
|
||
}
|
||
);
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
},
|
||
|
||
onShellInputKeyboardHeightChange(event) {
|
||
const height = this.resolveKeyboardHeight(event);
|
||
this.handleShellKeyboardHeightChange(height);
|
||
},
|
||
|
||
onShellInputBlur() {
|
||
if (!this.data.shellInputFocus && !this.data.shellInputValue) return;
|
||
if (this.data.statusClass === "connected" && this.keyboardVisibleHeightPx > 0) {
|
||
this.shellInputPassiveBlurPending = true;
|
||
return;
|
||
}
|
||
this.finalizeShellInputBlur();
|
||
},
|
||
|
||
onClearInput() {
|
||
this.setData({ inputText: "" });
|
||
},
|
||
|
||
onSendInput() {
|
||
const text = String(this.data.inputText || "");
|
||
if (!text.trim()) return false;
|
||
if (!this.client) {
|
||
this.showLocalizedToast("会话未连接", "none");
|
||
return false;
|
||
}
|
||
try {
|
||
this.markTerminalUserInput();
|
||
this.client.sendStdin(`${text}\r`);
|
||
this.maybeStartTtsRound("draft_send");
|
||
this.setData({ inputText: "", draftText: "" });
|
||
return true;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
onOpenCodex() {
|
||
return this.launchConfiguredAi();
|
||
},
|
||
|
||
launchConfiguredAi() {
|
||
if (!this.ensureAiLaunchAllowed()) {
|
||
return false;
|
||
}
|
||
const settings = getSettings();
|
||
const provider = normalizeAiProvider(settings.aiDefaultProvider);
|
||
if (provider === "copilot") {
|
||
return this.onRunCopilot();
|
||
}
|
||
return this.onRunCodex();
|
||
},
|
||
|
||
onRunCodex() {
|
||
if (!this.ensureAiLaunchAllowed()) {
|
||
return false;
|
||
}
|
||
const settings = getSettings();
|
||
const sandbox = normalizeCodexSandboxMode(settings.aiCodexSandboxMode);
|
||
this.markTerminalUserInput();
|
||
return this.executeAiLaunch(() => this.runCodex(sandbox), "Codex 启动失败", "codex");
|
||
},
|
||
|
||
onRunCopilot() {
|
||
if (!this.ensureAiLaunchAllowed()) {
|
||
return false;
|
||
}
|
||
const settings = getSettings();
|
||
const command = normalizeCopilotPermissionMode(settings.aiCopilotPermissionMode);
|
||
this.markTerminalUserInput();
|
||
return this.executeAiLaunch(() => this.runCopilot(command), "Copilot 启动失败", "copilot");
|
||
},
|
||
|
||
onClearScreen() {
|
||
/**
|
||
* Codex 占据前台时不允许清屏:
|
||
* 1. 本地清空显示不会改变远端 Codex 状态;
|
||
* 2. 为避免用户误以为“退出/重置了 Codex”,这里直接忽略。
|
||
*/
|
||
if (
|
||
String(this.data.statusText || "") === "connected" &&
|
||
this.normalizeActiveAiProvider(this.activeAiProvider) === "codex"
|
||
) {
|
||
return false;
|
||
}
|
||
const cellsLines = this.getOutputBufferRows();
|
||
const cursorState = this.getOutputCursorState();
|
||
const tailCells = (cellsLines[cursorState.row] || []).map((cell) => cloneTerminalCell(cell));
|
||
const nextState = this.captureTerminalBufferState();
|
||
const active = getActiveTerminalBuffer(nextState);
|
||
if (active && active.isAlt) {
|
||
active.cells = Array.isArray(active.cells) ? active.cells.map(() => []) : [[]];
|
||
active.cells[0] = tailCells.length > 0 ? tailCells : [];
|
||
} else {
|
||
active.cells = tailCells.length > 0 ? [tailCells] : [[]];
|
||
}
|
||
active.cursorRow = 0;
|
||
active.cursorCol = Math.max(0, cursorState.col);
|
||
active.ansiState = cloneAnsiState(this.outputAnsiState || ANSI_RESET_STATE);
|
||
this.outputReplayText = lineCellsToText(tailCells);
|
||
this.outputReplayBytes = utf8ByteLength(this.outputReplayText);
|
||
this.applyTerminalBufferState(nextState);
|
||
this.currentOutputScrollTop = 0;
|
||
this.requestTerminalRender();
|
||
},
|
||
|
||
onConnectionAction() {
|
||
this.markTerminalUserInput();
|
||
if (this.data.connectionActionDisabled) {
|
||
return;
|
||
}
|
||
if (this.data.connectionActionReconnect) {
|
||
this.connectGateway();
|
||
return;
|
||
}
|
||
this.suppressAutoReconnect();
|
||
if (this.client) {
|
||
this.client.disconnect("manual");
|
||
}
|
||
this.handleDisconnect("manual");
|
||
},
|
||
|
||
createTerminalGatewayClient() {
|
||
return createGatewayClient({
|
||
gatewayUrl: this.opsConfig.gatewayUrl,
|
||
gatewayToken: this.opsConfig.gatewayToken,
|
||
connectTimeoutMs: this.opsConfig.gatewayConnectTimeoutMs,
|
||
debugLog: ENABLE_TERMINAL_PERF_LOGS ? (event, payload) => this.logTerminalPerf(event, payload) : null,
|
||
onLatency: (value) => {
|
||
const nextSamples = appendDiagnosticSample(
|
||
this.data.connectionDiagnosticResponseSamples,
|
||
value,
|
||
CONNECTION_DIAGNOSTIC_SAMPLE_LIMIT
|
||
);
|
||
const nextPayload = this.buildConnectionDiagnosticPayload({
|
||
latencyMs: value,
|
||
connectionDiagnosticResponseSamples: nextSamples
|
||
});
|
||
this.setData(nextPayload);
|
||
this.persistConnectionDiagnosticSamples(nextPayload);
|
||
emitSessionEvent("latency", value);
|
||
},
|
||
onFrame: (frame) => this.handleFrame(frame),
|
||
onClose: () => this.handleDisconnect("ws_closed"),
|
||
onError: (error) => this.handleError(error)
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 点击终端主体空白区域时,收起键盘工具浮层,减少遮挡。
|
||
*/
|
||
onPanelTap() {
|
||
const next = {};
|
||
let changed = false;
|
||
if (this.data.touchToolsExpanded) {
|
||
next.touchToolsExpanded = false;
|
||
changed = true;
|
||
}
|
||
if (this.data.shellInputFocus) {
|
||
next.shellInputFocus = false;
|
||
next.shellInputCursor = String(this.data.shellInputValue || "").length;
|
||
changed = true;
|
||
}
|
||
if (!changed) return;
|
||
this.setData(next, () => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay()));
|
||
},
|
||
|
||
onOutputScroll(event) {
|
||
const scrollTop = Number(event && event.detail && event.detail.scrollTop);
|
||
const nextScrollTop = Number.isFinite(scrollTop) && scrollTop >= 0 ? scrollTop : 0;
|
||
this.updateTerminalScrollDirection(nextScrollTop);
|
||
this.currentOutputScrollTop = nextScrollTop;
|
||
this.scheduleTerminalScrollOverlaySync();
|
||
this.scheduleTerminalScrollViewportPrefetch();
|
||
this.scheduleTerminalScrollSettle();
|
||
},
|
||
|
||
onOutputLongPress() {
|
||
this.lastOutputLongPressAt = Date.now();
|
||
},
|
||
|
||
shouldSuppressTapAfterLongPress() {
|
||
const last = Number(this.lastOutputLongPressAt);
|
||
if (!Number.isFinite(last) || last <= 0) return false;
|
||
return Date.now() - last <= OUTPUT_LONG_PRESS_SUPPRESS_MS;
|
||
},
|
||
|
||
onOutputLineTap(event) {
|
||
if (this.shouldSuppressTapAfterLongPress()) return;
|
||
const tappedLine = Number(event && event.currentTarget && event.currentTarget.dataset.lineIndex);
|
||
if (!Number.isFinite(tappedLine)) return;
|
||
const cursorRow = this.getCursorBufferRow();
|
||
if (Math.abs(cursorRow - tappedLine) > SHELL_ACTIVATION_RADIUS_LINES) {
|
||
const next = {};
|
||
let changed = false;
|
||
if (this.data.touchToolsExpanded) {
|
||
next.touchToolsExpanded = false;
|
||
changed = true;
|
||
}
|
||
if (this.data.shellInputFocus || this.data.shellInputValue) {
|
||
next.shellInputFocus = false;
|
||
next.shellInputCursor = String(this.data.shellInputValue || "").length;
|
||
changed = true;
|
||
}
|
||
if (changed) {
|
||
this.setData(next, () => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay()));
|
||
}
|
||
return;
|
||
}
|
||
this.focusShellInput();
|
||
},
|
||
|
||
onOutputTap(event) {
|
||
if (this.shouldSuppressTapAfterLongPress()) return;
|
||
const point = resolveTapClientPoint(event);
|
||
if (!point) return;
|
||
this.withOutputRect((rect) => {
|
||
if (!rect) return;
|
||
if (this.isPointInCursorActivationBand(point, rect)) {
|
||
this.focusShellInput();
|
||
return;
|
||
}
|
||
const next = {};
|
||
let changed = false;
|
||
if (this.data.touchToolsExpanded) {
|
||
next.touchToolsExpanded = false;
|
||
changed = true;
|
||
}
|
||
if (this.data.shellInputFocus || this.data.shellInputValue) {
|
||
next.shellInputFocus = false;
|
||
next.shellInputCursor = String(this.data.shellInputValue || "").length;
|
||
changed = true;
|
||
}
|
||
if (changed) {
|
||
this.setData(next, () => this.restoreOutputScrollAfterKeyboard(() => this.syncTerminalOverlay()));
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 浮层内部事件阻断(占位函数)。
|
||
*/
|
||
noop() {},
|
||
|
||
/**
|
||
* 键盘工具区展开/折叠切换:
|
||
* - 折叠态仅展示 keyboard 图标;
|
||
* - 展开态按 Figma frame 2250 分成方向区和 SH 常用操作区。
|
||
*/
|
||
onToggleTouchTools() {
|
||
const nextExpanded = !this.data.touchToolsExpanded;
|
||
this.setData({ touchToolsExpanded: nextExpanded });
|
||
},
|
||
|
||
/**
|
||
* 把工具按钮映射为终端控制序列发送到会话。
|
||
* shift 采用三态语义:
|
||
* 1. 单击进入“下一次英文输入大写”;
|
||
* 2. 双击进入持续大写;
|
||
* 3. 锁定态下再次点击退出。
|
||
* 其它控制键始终保持原始终端行为,不消费 shift 状态;
|
||
* `home` 是唯一例外,它会下发“切回当前服务器工作目录”的 shell 命令。
|
||
*/
|
||
onTouchKeyTap(event) {
|
||
const key = String(event.currentTarget.dataset.key || "");
|
||
if (!key) return;
|
||
if (key === "home") {
|
||
this.onTouchHomeTap();
|
||
return;
|
||
}
|
||
this.markTerminalUserInput();
|
||
if (key === "shift") {
|
||
const now = Date.now();
|
||
const nextMode = resolveTouchShiftModeOnTap(
|
||
this.data.touchShiftMode,
|
||
this.touchShiftLastTapAt,
|
||
now,
|
||
TOUCH_SHIFT_DOUBLE_TAP_MS
|
||
);
|
||
this.touchShiftLastTapAt = nextMode === TOUCH_SHIFT_MODE_ONCE ? now : 0;
|
||
this.setData({ touchShiftMode: nextMode });
|
||
return;
|
||
}
|
||
const seq = this.resolveControlSequence(key);
|
||
if (!seq) {
|
||
return;
|
||
}
|
||
const sent = this.sendControlSequence(seq);
|
||
if (sent && key === "enter") {
|
||
this.maybeStartTtsRound("touch_enter");
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Home 快捷键并不发送 VT Home,而是显式把 shell 拉回服务器工作目录:
|
||
* 1. 复用 AI 启动同一套 `cd` 构造逻辑,避免 `~`、空格路径、单引号路径再次分叉;
|
||
* 2. AI 前台态下静默忽略,避免把 `cd` 命令误发给 Codex / Copilot 前台会话;
|
||
* 3. 末尾补一个回车,语义与用户手输 `cd 工作目录` 后回车一致。
|
||
*/
|
||
onTouchHomeTap() {
|
||
if (this.normalizeActiveAiProvider(this.activeAiProvider)) {
|
||
return false;
|
||
}
|
||
return this.sendControlSequence(this.buildTouchHomeCommand());
|
||
},
|
||
|
||
/**
|
||
* 工作目录优先使用当前服务器配置里的 `projectPath`。
|
||
* 如果该字段为空,则退回 `~`,保证 Home 按钮始终有稳定目标。
|
||
*/
|
||
buildTouchHomeCommand() {
|
||
const server = this.server && typeof this.server === "object" ? this.server : {};
|
||
const projectPath = String(server.projectPath || "").trim() || "~";
|
||
return `${buildCdCommand(projectPath)}\r`;
|
||
},
|
||
|
||
/**
|
||
* 控制键序列映射:
|
||
* - 方向键使用 ANSI ESC 序列;
|
||
* - enter/tab/ctrlc 直接使用控制字符;
|
||
* - shift 大写逻辑只在输入框文本变更时生效,这里不做额外修饰;
|
||
* - `home` 已在上层单独走 shell 命令,不进入这里的 VT 编码分支。
|
||
*/
|
||
resolveControlSequence(key) {
|
||
return encodeTerminalKey(key, this.getTerminalModes());
|
||
},
|
||
|
||
/**
|
||
* 统一发送控制序列,避免各按钮重复 try/catch 逻辑。
|
||
*/
|
||
maybeReleaseAiLockOnInterrupt(sequence) {
|
||
/**
|
||
* `Ctrl+C` 是用户对当前前台程序发出的显式中断信号:
|
||
* 1. Codex / Copilot 正常退出时,远端 wrapper 会打印 OSC 标记,本地会自然解锁;
|
||
* 2. 但若用户直接用 `Ctrl+C` 打断,wrapper 可能来不及执行收尾 `printf`,导致本地一直误以为 AI 仍在前台;
|
||
* 3. 这里仅在“控制字符已成功发出”后,补一层本地解锁,避免再次点击 AI 按钮被旧锁拦住。
|
||
*/
|
||
if (String(sequence || "") !== "\u0003") {
|
||
return;
|
||
}
|
||
if (!this.normalizeActiveAiProvider(this.activeAiProvider)) {
|
||
return;
|
||
}
|
||
this.syncActiveAiProvider("");
|
||
},
|
||
|
||
sendControlSequence(sequence) {
|
||
if (!this.client) {
|
||
this.showLocalizedToast("会话未连接", "none");
|
||
return false;
|
||
}
|
||
try {
|
||
this.markTerminalUserInput();
|
||
this.client.sendStdin(sequence);
|
||
this.maybeReleaseAiLockOnInterrupt(sequence);
|
||
return true;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 从剪贴板读取文本并发送到会话,便于移动端快速粘贴命令。
|
||
*/
|
||
onPasteFromClipboard() {
|
||
if (!this.client) {
|
||
this.showLocalizedToast("会话未连接", "none");
|
||
return;
|
||
}
|
||
wx.getClipboardData({
|
||
success: (res) => {
|
||
const text = String((res && res.data) || "");
|
||
if (!text) {
|
||
this.showLocalizedToast("剪贴板为空", "none");
|
||
return;
|
||
}
|
||
try {
|
||
this.markTerminalUserInput();
|
||
const payload = encodeTerminalPaste(text, this.getTerminalModes());
|
||
if (!payload) {
|
||
this.showLocalizedToast("剪贴板为空", "none");
|
||
return;
|
||
}
|
||
this.client.sendStdin(payload);
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
},
|
||
fail: () => {
|
||
this.showLocalizedToast("读取剪贴板失败", "none");
|
||
}
|
||
});
|
||
},
|
||
|
||
onVoiceActionsTouchStart(event) {
|
||
if (!this.data.voicePanelVisible) return;
|
||
const point = resolveTouchClientPoint(event);
|
||
if (!point) return;
|
||
this.measureVoiceActionsBounds();
|
||
this.voiceActionsDrag.active = true;
|
||
this.voiceActionsDrag.moved = false;
|
||
this.voiceActionsDrag.startX = point.x;
|
||
this.voiceActionsDrag.startOffset = Number(this.data.voiceActionsOffsetX) || 0;
|
||
},
|
||
|
||
onVoiceActionsTouchMove(event) {
|
||
const runtime = this.voiceActionsDrag;
|
||
if (!runtime || !runtime.active) return;
|
||
const point = resolveTouchClientPoint(event);
|
||
if (!point) return;
|
||
const deltaX = point.x - runtime.startX;
|
||
if (!runtime.moved && Math.abs(deltaX) < 2) {
|
||
return;
|
||
}
|
||
runtime.moved = true;
|
||
const maxOffset = Math.max(0, Number(this.voiceActionsMaxOffsetPx) || 0);
|
||
const next = Math.min(maxOffset, Math.max(0, Math.round(runtime.startOffset + deltaX)));
|
||
if (next === this.data.voiceActionsOffsetX) return;
|
||
this.setData({ voiceActionsOffsetX: next });
|
||
},
|
||
|
||
onVoiceActionsTouchEnd() {
|
||
if (!this.voiceActionsDrag) return;
|
||
this.voiceActionsDrag.active = false;
|
||
this.voiceActionsDrag.moved = false;
|
||
},
|
||
|
||
onVoiceLayerTouchStart(event) {
|
||
const point = resolveTouchClientPoint(event);
|
||
if (!point) return;
|
||
const dragHandle = String(
|
||
(event.target && event.target.dataset && event.target.dataset.dragHandle) || ""
|
||
);
|
||
const runtime = this.voiceGesture;
|
||
runtime.dragCandidate = dragHandle === "1";
|
||
runtime.dragActive = false;
|
||
runtime.dragMoved = false;
|
||
runtime.dragStartX = point.x;
|
||
runtime.dragStartY = point.y;
|
||
runtime.originLeft = this.data.voiceFloatLeft;
|
||
runtime.originBottom = this.data.voiceFloatBottom;
|
||
},
|
||
|
||
onVoiceLayerTouchMove(event) {
|
||
const runtime = this.voiceGesture;
|
||
if (!runtime || !runtime.dragCandidate) return;
|
||
const point = resolveTouchClientPoint(event);
|
||
if (!point) return;
|
||
const deltaX = point.x - runtime.dragStartX;
|
||
const deltaY = point.y - runtime.dragStartY;
|
||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||
if (!runtime.dragActive && distance < VOICE_DRAG_THRESHOLD_PX) {
|
||
return;
|
||
}
|
||
runtime.dragActive = true;
|
||
runtime.dragMoved = true;
|
||
this.clearVoiceHoldTimer();
|
||
runtime.holdArmed = false;
|
||
|
||
const next = this.clampVoiceFloatPosition(runtime.originLeft + deltaX, runtime.originBottom - deltaY);
|
||
if (this.data.voicePanelVisible) {
|
||
this.setData({
|
||
voiceFloatLeft: next.left,
|
||
voiceFloatBottom: next.bottom
|
||
});
|
||
return;
|
||
}
|
||
const nextActionsOffsetX = this.resolveVoiceActionsOffsetFromCollapsedLeft(next.left);
|
||
this.setData({
|
||
voiceFloatLeft: next.left,
|
||
voiceFloatBottom: next.bottom,
|
||
voiceActionsOffsetX: nextActionsOffsetX
|
||
});
|
||
},
|
||
|
||
onVoiceLayerTouchEnd() {
|
||
const runtime = this.voiceGesture;
|
||
if (!runtime) return;
|
||
if (runtime.holdArmed && !runtime.holdStarted) {
|
||
this.clearVoiceHoldTimer();
|
||
runtime.holdArmed = false;
|
||
}
|
||
runtime.dragCandidate = false;
|
||
runtime.dragActive = false;
|
||
if (runtime.dragMoved) {
|
||
runtime.dragJustFinishedAt = Date.now();
|
||
}
|
||
runtime.dragMoved = false;
|
||
},
|
||
|
||
onVoiceTap() {
|
||
// 设计调整:轻触不展开语音面板,仅长按 voice 按钮触发展开与录音。
|
||
},
|
||
|
||
onVoicePressStart(event) {
|
||
this.setSvgButtonPressStateFromEvent(event, true);
|
||
if (
|
||
this.voiceRound.phase === "stopping" ||
|
||
(this.voiceRound.phase === "connecting" && this.voiceRound.stopRequestedBeforeReady)
|
||
) {
|
||
this.forceResetVoiceRoundForRestart("restart_from_press");
|
||
}
|
||
if (this.voiceRound.phase !== "idle" || this.isRecorderRunning || this.isRecorderStarting) return;
|
||
const runtime = this.voiceGesture;
|
||
runtime.holdArmed = true;
|
||
runtime.holdStarted = false;
|
||
this.setData({ voiceHolding: true, frameOpacity: VOICE_ACTIVE_OPACITY });
|
||
this.clearVoiceHoldTimer();
|
||
this.voiceHoldTimer = setTimeout(() => {
|
||
if (!runtime.holdArmed || runtime.dragActive || runtime.dragMoved) {
|
||
this.setData({ voiceHolding: false, frameOpacity: VOICE_IDLE_OPACITY });
|
||
return;
|
||
}
|
||
if (this.voiceRound.phase !== "idle" || this.isRecorderRunning || this.isRecorderStarting) return;
|
||
runtime.holdStarted = true;
|
||
const begin = () => {
|
||
try {
|
||
wx.vibrateShort({ type: "light" });
|
||
} catch (error) {
|
||
console.warn("[terminal.voice.vibrate]", error);
|
||
}
|
||
this.startVoiceRound();
|
||
};
|
||
if (!this.data.voicePanelVisible) {
|
||
const expandedActionsOffsetX = this.resolveVoiceActionsOffsetFromCollapsedLeft(
|
||
this.data.voiceFloatLeft
|
||
);
|
||
const expandedOrigin = this.resolveExpandedVoiceOriginFromCollapsedButton(
|
||
this.data.voiceFloatLeft,
|
||
this.data.voiceFloatBottom,
|
||
expandedActionsOffsetX
|
||
);
|
||
this.setData(
|
||
{
|
||
voicePanelVisible: true,
|
||
voiceFloatLeft: expandedOrigin.left,
|
||
voiceFloatBottom: expandedOrigin.bottom,
|
||
voiceActionsOffsetX: expandedActionsOffsetX
|
||
},
|
||
() => {
|
||
this.measureVoicePanelHeight();
|
||
this.measureVoiceActionsBounds();
|
||
begin();
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
begin();
|
||
}, VOICE_HOLD_DELAY_MS);
|
||
|
||
if (event) {
|
||
const point = resolveTouchClientPoint(event);
|
||
if (point) {
|
||
runtime.dragCandidate = true;
|
||
runtime.dragActive = false;
|
||
runtime.dragMoved = false;
|
||
runtime.dragStartX = point.x;
|
||
runtime.dragStartY = point.y;
|
||
runtime.originLeft = this.data.voiceFloatLeft;
|
||
runtime.originBottom = this.data.voiceFloatBottom;
|
||
}
|
||
}
|
||
},
|
||
|
||
onVoicePressEnd(event) {
|
||
this.setSvgButtonPressStateFromEvent(event, false);
|
||
const runtime = this.voiceGesture;
|
||
this.clearVoiceHoldTimer();
|
||
const shouldStopRound = !!(runtime && runtime.holdStarted);
|
||
if (runtime) {
|
||
if (runtime.dragMoved) {
|
||
runtime.dragJustFinishedAt = Date.now();
|
||
}
|
||
runtime.holdArmed = false;
|
||
runtime.holdStarted = false;
|
||
runtime.dragCandidate = false;
|
||
runtime.dragActive = false;
|
||
runtime.dragMoved = false;
|
||
}
|
||
this.setData({ voiceHolding: false, frameOpacity: VOICE_IDLE_OPACITY });
|
||
if (!shouldStopRound) return;
|
||
this.stopVoiceRound(false);
|
||
},
|
||
|
||
async startVoiceRound() {
|
||
if (this.voiceRound.phase !== "idle" || this.isRecorderRunning) {
|
||
return;
|
||
}
|
||
if (!isOpsConfigReady(this.opsConfig)) {
|
||
this.showLocalizedToast("运维配置缺失,请联系管理员", "none");
|
||
return;
|
||
}
|
||
// 语音识别不依赖终端会话;未连接时允许先转文字,再决定是否记录到闪念。
|
||
// 先上锁,避免权限检查/建链阶段被重复触发导致并发 start。
|
||
this.voiceRound.phase = "connecting";
|
||
this.voiceRound.discardResults = false;
|
||
this.voiceRound.stopRequestedBeforeReady = false;
|
||
|
||
try {
|
||
await this.ensureRecordPermission();
|
||
} catch (error) {
|
||
this.handleAsrError(error);
|
||
this.voiceRound.phase = "idle";
|
||
return;
|
||
}
|
||
this.voiceRound.baseText = String(this.data.inputText || "");
|
||
this.teardownAsrClient("restart_round");
|
||
|
||
let client = null;
|
||
client = createAsrGatewayClient({
|
||
gatewayUrl: this.opsConfig.gatewayUrl,
|
||
gatewayToken: this.opsConfig.gatewayToken,
|
||
onResult: (payload) => this.handleAsrResult(payload, client),
|
||
onRoundEnd: () => this.handleAsrRoundEnd(client),
|
||
onError: (error) => this.handleAsrError(error, client),
|
||
onClose: () => this.handleAsrClose(client)
|
||
});
|
||
this.voiceRound.client = client;
|
||
|
||
try {
|
||
await client.connect();
|
||
if (this.voiceRound.phase !== "connecting" || this.voiceRound.client !== client) {
|
||
client.close("round_abort_before_start");
|
||
return;
|
||
}
|
||
client.startRound({
|
||
audio: {
|
||
format: "pcm",
|
||
codec: "raw",
|
||
rate: 16000,
|
||
bits: 16,
|
||
channel: 1
|
||
},
|
||
request: {
|
||
model_name: "bigmodel",
|
||
enable_itn: true,
|
||
enable_punc: true,
|
||
result_type: "full"
|
||
}
|
||
});
|
||
try {
|
||
recorderManager.start(RECORDER_OPTIONS);
|
||
} catch (error) {
|
||
if (isRecorderBusyError(error)) {
|
||
this.handleAsrError(new Error("录音器忙,请稍后重试"));
|
||
} else {
|
||
this.handleAsrError(error);
|
||
}
|
||
this.teardownAsrClient("recorder_start_failed");
|
||
this.voiceRound.phase = "idle";
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
return;
|
||
}
|
||
this.isRecorderStarting = true;
|
||
this.stopRecorderAfterStart = false;
|
||
this.isRecorderRunning = false;
|
||
this.voiceRound.phase = "recording";
|
||
|
||
if (this.voiceRound.stopRequestedBeforeReady) {
|
||
this.voiceRound.stopRequestedBeforeReady = false;
|
||
this.stopVoiceRound(false);
|
||
}
|
||
} catch (error) {
|
||
this.handleAsrError(error);
|
||
this.teardownAsrClient("round_start_failed");
|
||
this.voiceRound.phase = "idle";
|
||
this.stopRecorderIfRunning("round_start_failed");
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
}
|
||
},
|
||
|
||
stopVoiceRound(sendCancel) {
|
||
const cancel = !!sendCancel;
|
||
const phase = this.voiceRound.phase;
|
||
if (phase === "idle") {
|
||
this.stopRecorderIfRunning("stop_when_idle");
|
||
return;
|
||
}
|
||
if (phase === "connecting") {
|
||
if (cancel) {
|
||
this.voiceRound.stopRequestedBeforeReady = false;
|
||
this.voiceRound.phase = "idle";
|
||
this.teardownAsrClient("cancel_before_ready");
|
||
} else {
|
||
this.voiceRound.stopRequestedBeforeReady = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const client = this.voiceRound.client;
|
||
this.stopRecorderIfRunning("stop_voice_round");
|
||
|
||
if (client) {
|
||
if (cancel) {
|
||
client.cancelRound();
|
||
} else {
|
||
client.stopRound();
|
||
}
|
||
}
|
||
|
||
this.voiceRound.phase = "stopping";
|
||
if (this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
this.voiceRound.closeTimer = setTimeout(() => {
|
||
this.teardownAsrClient("round_timeout");
|
||
this.voiceRound.phase = "idle";
|
||
}, VOICE_CLOSE_TIMEOUT_MS);
|
||
},
|
||
|
||
teardownAsrClient(reason) {
|
||
const client = this.voiceRound.client;
|
||
this.voiceRound.client = null;
|
||
if (client) {
|
||
client.close(reason || "client_close");
|
||
}
|
||
},
|
||
|
||
handleRecorderStart() {
|
||
this.isRecorderStarting = false;
|
||
this.isRecorderRunning = true;
|
||
if (!this.stopRecorderAfterStart) return;
|
||
this.stopRecorderAfterStart = false;
|
||
this.stopRecorderIfRunning("stop_after_start");
|
||
},
|
||
|
||
handleRecorderFrame(event) {
|
||
if (this.voiceRound.phase !== "recording") return;
|
||
const frame = event && event.frameBuffer;
|
||
if (!frame || !(frame instanceof ArrayBuffer) || frame.byteLength <= 0) {
|
||
return;
|
||
}
|
||
const client = this.voiceRound.client;
|
||
if (!client || !client.isReady()) return;
|
||
client.sendAudio(frame);
|
||
},
|
||
|
||
handleRecorderStop() {
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
},
|
||
|
||
handleRecorderError(error) {
|
||
const message = resolveRuntimeMessage(error, "录音采集失败");
|
||
if (isRecorderNotStartError(error)) {
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
return;
|
||
}
|
||
if (this.isRecorderRunning) {
|
||
try {
|
||
recorderManager.stop();
|
||
} catch (stopError) {
|
||
console.warn("[terminal.recorder.stop.on_error]", stopError);
|
||
}
|
||
}
|
||
this.isRecorderRunning = false;
|
||
this.isRecorderStarting = false;
|
||
this.stopRecorderAfterStart = false;
|
||
this.handleAsrError(new Error(message));
|
||
this.teardownAsrClient("recorder_error");
|
||
this.voiceRound.phase = "idle";
|
||
},
|
||
|
||
handleAsrResult(payload, sourceClient) {
|
||
if (sourceClient && sourceClient !== this.voiceRound.client) {
|
||
return;
|
||
}
|
||
if (this.voiceRound.discardResults) {
|
||
return;
|
||
}
|
||
const text = String((payload && payload.text) || "");
|
||
this.setData({
|
||
inputText: `${this.voiceRound.baseText}${text}`,
|
||
draftText: `${this.voiceRound.baseText}${text}`
|
||
});
|
||
},
|
||
|
||
handleAsrRoundEnd(sourceClient) {
|
||
if (sourceClient && sourceClient !== this.voiceRound.client) {
|
||
return;
|
||
}
|
||
this.stopRecorderIfRunning("asr_round_end");
|
||
if (this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
this.voiceRound.phase = "idle";
|
||
this.teardownAsrClient("round_end");
|
||
},
|
||
|
||
handleAsrError(error, sourceClient) {
|
||
if (sourceClient && sourceClient !== this.voiceRound.client) {
|
||
return;
|
||
}
|
||
if (isBenignVoiceRuntimeError(error)) {
|
||
this.setData({ voiceHolding: false, frameOpacity: VOICE_IDLE_OPACITY });
|
||
return;
|
||
}
|
||
const raw = resolveRuntimeMessage(error, "语音识别失败");
|
||
const state = resolveVoiceGatewayErrorState(raw, raw);
|
||
const message = state.message || raw;
|
||
this.appendOutput(`[voice-error] ${message}`);
|
||
this.showLocalizedToast(message, "none");
|
||
if (state.showSocketDomainModal) {
|
||
const domainHint = resolveSocketDomainHint(this.opsConfig && this.opsConfig.gatewayUrl);
|
||
wx.showModal({
|
||
title: this.data.copy?.modal?.socketDomainTitle || "Socket 域名未配置",
|
||
content: domainHint
|
||
? formatTemplate(this.data.copy?.modal?.socketDomainContent, { domainHint })
|
||
: this.data.copy?.modal?.socketDomainContentNoHint || "当前网关地址不在小程序 socket 合法域名列表",
|
||
showCancel: false
|
||
});
|
||
}
|
||
this.setData({ voiceHolding: false, frameOpacity: VOICE_IDLE_OPACITY });
|
||
},
|
||
|
||
handleAsrClose(sourceClient) {
|
||
if (sourceClient && sourceClient !== this.voiceRound.client) {
|
||
return;
|
||
}
|
||
this.stopRecorderIfRunning("asr_close");
|
||
if (this.voiceRound.closeTimer) {
|
||
clearTimeout(this.voiceRound.closeTimer);
|
||
this.voiceRound.closeTimer = null;
|
||
}
|
||
if (this.voiceRound.phase !== "idle") {
|
||
this.voiceRound.phase = "idle";
|
||
}
|
||
this.voiceRound.client = null;
|
||
},
|
||
|
||
onRecordDraft() {
|
||
const source = this.data.inputText || this.data.draftText;
|
||
const settings = getSettings();
|
||
const selectedCategory = this.resolveSelectedRecordCategory(settings);
|
||
const recordContext = this.resolveRecordContext();
|
||
const collapsedButtonPosition = this.resolveCollapsedVoiceButtonPositionFromExpandedOrigin(
|
||
this.data.voiceFloatLeft,
|
||
this.data.voiceFloatBottom,
|
||
this.data.voiceActionsOffsetX
|
||
);
|
||
this.voiceRound.discardResults = true;
|
||
this.stopVoiceRound(true);
|
||
this.voiceRound.baseText = "";
|
||
this.setData(
|
||
{
|
||
draftText: "",
|
||
inputText: "",
|
||
voiceFloatLeft: collapsedButtonPosition.left,
|
||
voiceFloatBottom: collapsedButtonPosition.bottom,
|
||
voicePanelVisible: false,
|
||
frameOpacity: VOICE_IDLE_OPACITY,
|
||
voiceHolding: false
|
||
},
|
||
() => this.syncVoiceFloatWithinBounds()
|
||
);
|
||
const created = addRecord(source, this.data.serverId, {
|
||
category: selectedCategory,
|
||
contextLabel: recordContext.contextLabel
|
||
});
|
||
if (!created) {
|
||
this.showLocalizedToast("无可记录内容", "none");
|
||
return;
|
||
}
|
||
this.showLocalizedToast("已记录到闪念列表", "success");
|
||
},
|
||
|
||
onSendDraft() {
|
||
const collapsedButtonPosition = this.resolveCollapsedVoiceButtonPositionFromExpandedOrigin(
|
||
this.data.voiceFloatLeft,
|
||
this.data.voiceFloatBottom,
|
||
this.data.voiceActionsOffsetX
|
||
);
|
||
this.voiceRound.discardResults = true;
|
||
if (this.voiceRound.phase !== "idle") {
|
||
this.stopVoiceRound(true);
|
||
}
|
||
this.voiceRound.baseText = "";
|
||
const sent = this.onSendInput();
|
||
if (sent) {
|
||
this.setData(
|
||
{
|
||
draftText: "",
|
||
inputText: "",
|
||
voiceFloatLeft: collapsedButtonPosition.left,
|
||
voiceFloatBottom: collapsedButtonPosition.bottom,
|
||
voicePanelVisible: false,
|
||
frameOpacity: VOICE_IDLE_OPACITY,
|
||
voiceHolding: false
|
||
},
|
||
() => this.syncVoiceFloatWithinBounds()
|
||
);
|
||
}
|
||
},
|
||
|
||
onClearDraft() {
|
||
this.setData({ draftText: "", inputText: "" });
|
||
},
|
||
|
||
onCancelDraft() {
|
||
const collapsedButtonPosition = this.resolveCollapsedVoiceButtonPositionFromExpandedOrigin(
|
||
this.data.voiceFloatLeft,
|
||
this.data.voiceFloatBottom,
|
||
this.data.voiceActionsOffsetX
|
||
);
|
||
this.voiceRound.discardResults = true;
|
||
this.stopVoiceRound(true);
|
||
this.voiceRound.baseText = "";
|
||
this.setData(
|
||
{
|
||
draftText: "",
|
||
inputText: "",
|
||
voiceFloatLeft: collapsedButtonPosition.left,
|
||
voiceFloatBottom: collapsedButtonPosition.bottom,
|
||
voicePanelVisible: false,
|
||
frameOpacity: VOICE_IDLE_OPACITY,
|
||
voiceHolding: false
|
||
},
|
||
() => this.syncVoiceFloatWithinBounds()
|
||
);
|
||
},
|
||
|
||
...createSvgButtonPressMethods()
|
||
});
|