feat: 配置 Webterm 移动端历史与输出帧率

This commit is contained in:
Codex
2026-07-11 11:29:27 +02:00
parent b12dd85375
commit 2a536e8760
4 changed files with 90 additions and 6 deletions
+1
View File
@@ -134,4 +134,5 @@
- OTel/可观测性: `docs/reference/observability.md`
- YAML-first: `docs/reference/yaml-first-ops.md`
- Platform infrastructure: `docs/reference/platform-infra.md`
- Webterm 配置与受控部署: `docs/reference/webterm.md`
- Secretary: `docs/reference/secretary-reference.md`
+9 -4
View File
@@ -29,6 +29,15 @@ autoResumePrompt:
quietPeriodMs: 1500
timeoutMs: 60000
browserTerminalHistory:
desktopScrollbackLines: 8000
mobileScrollbackLines: 500
mobileMaxWidthPx: 899
browserOutputPolicy:
maxFramesPerSecond: 10
maxFrameBytes: 65536
defaults:
targetId: local-7683
@@ -86,8 +95,6 @@ targets:
BROWSER_SNAPSHOT_WRITE_CHUNK_BYTES: "8192"
BROWSER_MANUAL_REDRAW_MODE: double
BROWSER_MANUAL_REDRAW_DELAY_MS: "60"
OUTPUT_BATCH_MS: "8"
OUTPUT_BATCH_BYTES: "32768"
BROWSER_KEEPALIVE_ENABLED: "true"
BROWSER_RESUME_PROBE_ENABLED: "false"
BROWSER_KEEPALIVE_INTERVAL_MS: "30000"
@@ -167,8 +174,6 @@ targets:
BROWSER_SNAPSHOT_WRITE_CHUNK_BYTES: "8192"
BROWSER_MANUAL_REDRAW_MODE: double
BROWSER_MANUAL_REDRAW_DELAY_MS: "60"
OUTPUT_BATCH_MS: "8"
OUTPUT_BATCH_BYTES: "32768"
BROWSER_KEEPALIVE_ENABLED: "true"
BROWSER_RESUME_PROBE_ENABLED: "false"
BROWSER_KEEPALIVE_INTERVAL_MS: "15000"
+18
View File
@@ -0,0 +1,18 @@
# Webterm 配置与受控部署
- 唯一配置来源:
- `config/platform-infra/webterm.yaml` 是端口、自动标签、启动模式、浏览器历史和输出聚合策略的 owning YAML
- `build/docker-compose.<port>.yaml` 是受控 CLI 渲染产物,禁止手工维护或在部署后恢复旧内容。
- 受控入口:
- 预览使用 `bun scripts/cli.ts platform-infra webterm plan --target <target>`
- 部署使用 `bun scripts/cli.ts platform-infra webterm apply --target <target> --confirm`
- 收口使用同一入口的 `status``validate`
- 浏览器历史策略:
- `browserTerminalHistory.desktopScrollbackLines` 控制桌面端 xterm 历史行数;
- `browserTerminalHistory.mobileScrollbackLines` 控制移动端 xterm 历史行数;
- `browserTerminalHistory.mobileMaxWidthPx` 与粗指针特征共同决定移动端策略;
- 视口类型变化后,已有终端必须同步新上限并截断超出上限的旧行。
- 输出聚合策略:
- `browserOutputPolicy.maxFramesPerSecond` 是每个浏览器连接、每个终端的最大输出消息频率;
- `browserOutputPolicy.maxFrameBytes` 是单条聚合输出消息的字节上限;
- 后端必须按终端独立排队,小数据在频率窗口内合并,积压数据按窗口顺序发送,禁止用字节阈值绕过频率上限。
+62 -2
View File
@@ -23,6 +23,8 @@ interface WebtermConfig {
defaults: { targetId: string };
sessionStartModes: SessionStartModes;
autoResumePrompt: AutoResumePrompt;
browserTerminalHistory: BrowserTerminalHistory;
browserOutputPolicy: BrowserOutputPolicy;
targets: WebtermTarget[];
}
@@ -39,6 +41,17 @@ interface AutoResumePrompt {
readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number };
}
interface BrowserTerminalHistory {
desktopScrollbackLines: number;
mobileScrollbackLines: number;
mobileMaxWidthPx: number;
}
interface BrowserOutputPolicy {
maxFramesPerSecond: number;
maxFrameBytes: number;
}
interface WebtermTarget {
id: string;
enabled: boolean;
@@ -59,6 +72,8 @@ interface WebtermTarget {
autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>;
sessionStartModes: SessionStartModes;
autoResumePrompt: AutoResumePrompt;
browserTerminalHistory: BrowserTerminalHistory;
browserOutputPolicy: BrowserOutputPolicy;
environment: Record<string, string>;
};
publicExposure: {
@@ -256,17 +271,35 @@ function readWebtermConfig(): WebtermConfig {
const defaults = recordField(yaml, "defaults", "webterm");
const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes");
const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt");
const browserTerminalHistory = parseBrowserTerminalHistory(recordField(yaml, "browserTerminalHistory", "webterm"), "webterm.browserTerminalHistory");
const browserOutputPolicy = parseBrowserOutputPolicy(recordField(yaml, "browserOutputPolicy", "webterm"), "webterm.browserOutputPolicy");
const targetsRaw = yaml.targets;
if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array");
return {
defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") },
sessionStartModes,
autoResumePrompt,
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)),
browserTerminalHistory,
browserOutputPolicy,
targets: targetsRaw.map((item, index) => parseTarget(
asRecord(item, `webterm.targets[${index}]`),
`webterm.targets[${index}]`,
sessionStartModes,
autoResumePrompt,
browserTerminalHistory,
browserOutputPolicy,
)),
};
}
function parseTarget(record: Record<string, unknown>, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): WebtermTarget {
function parseTarget(
record: Record<string, unknown>,
path: string,
sessionStartModes: SessionStartModes,
autoResumePrompt: AutoResumePrompt,
browserTerminalHistory: BrowserTerminalHistory,
browserOutputPolicy: BrowserOutputPolicy,
): WebtermTarget {
const runtime = recordField(record, "runtime", path);
const exposure = recordField(record, "publicExposure", path);
const dns = recordField(exposure, "dns", `${path}.publicExposure`);
@@ -295,6 +328,8 @@ function parseTarget(record: Record<string, unknown>, path: string, sessionStart
autoStartSessions,
sessionStartModes,
autoResumePrompt,
browserTerminalHistory,
browserOutputPolicy,
environment,
},
publicExposure: {
@@ -339,6 +374,8 @@ function renderCompose(target: WebtermTarget): string {
SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options),
SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode,
AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt),
BROWSER_TERMINAL_HISTORY_JSON: JSON.stringify(target.runtime.browserTerminalHistory),
BROWSER_OUTPUT_POLICY_JSON: JSON.stringify(target.runtime.browserOutputPolicy),
};
const envLines = Object.entries(environment)
.map(([key, value]) => ` ${key}: ${quoteYaml(value)}`)
@@ -442,6 +479,8 @@ function targetSummary(target: WebtermTarget): Record<string, unknown> {
sendEnter: target.runtime.autoResumePrompt.sendEnter,
enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs,
},
browserTerminalHistory: target.runtime.browserTerminalHistory,
browserOutputPolicy: target.runtime.browserOutputPolicy,
sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`),
hostPort: target.runtime.hostPort,
containerPort: target.runtime.containerPort,
@@ -601,6 +640,27 @@ function parseAutoResumePrompt(record: Record<string, unknown>, path: string): A
};
}
function parseBrowserTerminalHistory(record: Record<string, unknown>, path: string): BrowserTerminalHistory {
return {
desktopScrollbackLines: positiveIntegerField(record, "desktopScrollbackLines", path),
mobileScrollbackLines: positiveIntegerField(record, "mobileScrollbackLines", path),
mobileMaxWidthPx: positiveIntegerField(record, "mobileMaxWidthPx", path),
};
}
function parseBrowserOutputPolicy(record: Record<string, unknown>, path: string): BrowserOutputPolicy {
return {
maxFramesPerSecond: positiveIntegerField(record, "maxFramesPerSecond", path),
maxFrameBytes: positiveIntegerField(record, "maxFrameBytes", path),
};
}
function positiveIntegerField(record: Record<string, unknown>, key: string, path: string): number {
const value = integerField(record, key, path);
if (value <= 0) throw new Error(`${path}.${key} must be positive`);
return value;
}
function parseLiteral<T extends string>(value: string, expected: T, path: string): T {
if (value !== expected) throw new Error(`${path} must be ${expected}`);
return expected;