first commit

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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 依赖目录
node_modules/
# 构建产物
**/dist/
**/coverage/
# 本地环境变量
.env
.env.*
!.env.example
# 日志与缓存
npm-debug.log*
.DS_Store
.npm-cache/
data/tts-cache/
# 小程序本地运维配置(由 .env 生成)
apps/miniprogram/utils/opsEnv.js
docs
data
artifacts

281
CHANGELOG.md Normal file
View File

@@ -0,0 +1,281 @@
# Changelog
本文件提供连续版本索引,便于快速查看版本演进。
- 详细发布说明见 `release.md`
-`release.md` 未单列某个 `vx.y.0`,则按 `history.md` 与 git 历史,按“约每 3 小时补一个 minor 版本”的规则补齐。
- `x.y.z` 的 patch 空档不额外补齐;历史补齐版本只用于保持序列连续,详细内容以后续正式版本口径为准。
## v3.0.1
日期:`2026-03-20`
- 当前版本主要收口使用手册与主题文案:
- 小程序“使用手册”补成图文版正文前置“为什么需要这个APP并接入 `guide-mobile-*` 配图;
- 小程序主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换,并统一压短为更易读的单词标签。
- 版本口径统一升级到 `v3.0.1`
- 发布说明、根 README、小程序 README、项目介绍、相关方案文档与品牌模板统一同步到当前版本。
- 当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。
## v3.0.0
日期:`2026-03-18`
- 小程序终端补上 caret 稳定化:
- 高压 `stdout` 输出期间会优先保留最近稳定 caret减少底部后几行行尾来回跳动
- 最终帧与用户输入相关帧仍会强制提交最新 caret避免稳定化把真实位置长期冻结。
- 小程序 shell 输入焦点补上被动 `blur` 保护:
- 当软键盘仍保持可见时,页面不会因为临时 `blur` 立即判定失焦;
- 豆包输入法长按语音输入过程中,不应再被页面侧主动打断语音录入。
- 小程序终端 perf 日志默认改为关闭:
- 当前仍保留部分性能遗留问题,常态不再持续输出 `perf.summary / perf.snapshot`
- 调试性能问题时,可通过现有开关临时打开日志收集现场。
- 版本口径统一升级到 `v3.0.0`
- 发布说明、根 README、小程序 README、Web / 小程序关于页、多语言文案、小程序工程描述与当前基线文档统一同步到当前版本。
## v2.9.6
日期:`2026-03-13`
- 当前版本仅做文档与对外口径同步:
- 不引入新的功能、协议行为或交互变更;
- 继续沿用 `v2.9.5` 已完成的现有终端、同步与时延诊断能力,以及当前语音播报边界口径。
- 版本口径统一升级到 `v2.9.6`
- 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一同步到当前版本。
## v2.9.5
日期:`2026-03-13`
- 当前版本仅做文档与对外口径同步:
- 不引入新的功能、协议行为或交互变更;
- 继续沿用 `v2.9.4` 已完成的现有终端、同步与时延诊断能力。
- 新登记一条小程序终端语音播报遗留问题:
- 当前播报文本提取与轮次稳定判定仍不够准确,播报内容存在偏差;
- 长时间 `Codex` 交互时,语音播报链路还会放大小程序端的响应压力;
- 现阶段先不建议默认使用,后续继续优化文本提取、触发时机与性能隔离。
- 版本口径统一升级到 `v2.9.5`
- 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一同步到当前版本。
## v2.9.4
日期:`2026-03-12`
- 当前版本仅做文档与对外口径同步:
- 不引入新的功能、协议行为或交互变更;
- 继续沿用 `v2.9.3` 已完成的时延诊断面板与主题对比度收口结果。
- 版本口径统一升级到 `v2.9.4`
- 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、问题闭环记录与当前基线文档统一同步到当前版本。
## v2.9.3
日期:`2026-03-11`
- 小程序时延诊断面板完成一轮结构收口:
- 原先分离的“网关响应 / 网络时延”两张图已合并成一张双轴平滑曲线图;
- 左轴保留网关响应量纲,右轴保留网络时延量纲,顶部摘要卡统一收口为两行 `min / max / avg`
- 诊断浮窗进一步简化:
- 原“诊断信息”文字卡已删除,只保留图表卡;
- 同一服务器断开重连后会优先延续已有采样,尽量补足最近 `30` 个点。
- 小程序终端主题适配继续收口:
- 时延面板改为跟随终端主题的反相面板;
- 深色终端单独切换一套更深的蓝橙曲线与指标色,避免浅底上文字与曲线发灰看不清。
- 版本口径统一升级到 `v2.9.3`
- 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。
## v2.9.1
日期:`2026-03-11`
- 小程序终端会话续接恢复继续收口:
- 首次恢复优先使用保存时的 `lines + bufferCols / bufferRows` 还原当前屏幕;
- 被裁剪的 `replayText` 仅保留给后续真实几何变化时重建使用,避免再次出现历史区顶部空白和裸露 `5;2H`
- 小程序启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会立即刷新,不再需要重进页面才看到同步后的配置。
- 小程序用户隐私政策与 about 隐私页已同步到最新审核口径,补齐录音用途、处理范围与用户权利说明。
- 版本口径统一升级到 `v2.9.1`
- 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。
## v2.9.0
日期:`2026-03-11`
- 小程序终端已修复 `Codex` 交互期间底部提示块缺行与闪动问题:
- normal buffer viewport 不再把光标行之后仍真实存在的 footer 误裁掉;
- `CSI ? 2026 h/l` 同步刷新窗口已做收口,减少重绘中间态直接暴露到界面;
- `Codex` 持续输出时,底部提示块与状态行的可见性和稳定性明显提升。
- 小程序终端统一高亮背景行改为优先在 line 层绘制:
- `> Use /skills to list available skills`、代码块与同类整行高亮区域不再在行与行之间透出底色细线;
- 该调整未增加渲染节点,统一背景场景下反而减少了 segment 级背景重复绘制。
- 新增真实 PTY 抓包回放用例,补齐 footer 保留与同步刷新窗口相关回归测试。
- 小程序终端会话续接恢复口径已修正:
- 首次恢复优先使用保存的 `lines + bufferCols / bufferRows` 还原当前屏幕;
- 被裁剪的 `replayText` 仅保留给后续真实几何变化时重建使用,避免再次出现历史区顶部空白和裸露 `5;2H`
- 新增终端快照几何持久化测试,锁住该回归场景。
- 版本口径统一升级到 `v2.9.0`
- 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。
## v2.8.2
日期:`2026-03-10`
- 小程序服务器列表 `connect` 按钮与底栏 `shell` 按钮在存在活动连接时统一改为高饱和实底高亮,不再依赖描边反馈。
- 连接态 SVG 前景色改为运行时跟随界面前景色,保证高亮底色上的图标对比度。
- about 首页、详情页与 about-app 改为按界面配置推导配色Web about 页同步切到同一套主题变量策略。
- 新登记一条 about 反馈遗留问题:当前“反馈”按钮仍只复制邮箱地址,暂不支持直接拉起系统发送邮件组件。
- 发布说明、根 README、小程序 README、关于页、项目描述与当前基线文档统一更新到 `v2.8.2`
## v2.8.1
日期:`2026-03-10`
- 小程序终端语音区视觉继续收口:展开按钮默认全透明,仅保留 SVG 本体;分类胶囊改为贴文字高度,录音中输入框上方新增更显眼的双环脉冲提示。
- 录音提示文案更新为“正在收音,松开后发送或记录闪念”;松开本身仍只结束本轮输入,不自动发送。
- 小程序终端 VT 语义继续修正:`OSC 10 / 11 / 12` 返回真实 shell 主题色,`alt screen``CSI J / K / X` 擦除空白位统一继承当前擦除背景。
- 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其列为遗留问题。
- 新登记一条低频终端遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;后续需继续优化连接就绪判定与 prompt 首屏兜底。
- 发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 `v2.8.1`
## v2.7.1
日期:`2026-03-10`
- 文档与关于页版本口径统一升级到 `v2.7.1`,补齐同步 SQLite 的当前边界说明。
- 明确 `remoteconn-sync.db-wal` 属于 SQLite 写前日志文件体积不直接等于当前有效同步数据量checkpoint 后数据会回写主库。
- 当前跨设备同步仍仅覆盖 `settings / servers / records`;其中服务器受保护字段继续以加密后的 `secret_blob` 保存,日志、插件运行时日志与终端会话缓冲仍保留本地。
- 当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞,暂列为当前遗留问题。
## v2.7.0
日期:`2026-03-09`
- 小程序设置、服务器配置与闪念记录接入 `Gateway + SQLite` 双层持久化,支持同一微信账号跨设备恢复数据。
- 同步链路补齐 `bootstrap``settings / servers / records` 增量推送;服务器和闪念删除支持 `tombstone` 合并。
- SSH 密码、私钥、口令与证书等受保护字段改为服务端加密保存;终端会话缓冲仍不纳入第一阶段同步范围。
## v2.6.6
日期:`2026-03-09`
- 文档口径统一更新到 `v2.6.6`
- 将“通过 `npm run mini` 生成的 preview 预览包,不作为正式版本地缓存连续性验证依据”记录为当前遗留问题。
- 小程序仍以本地 storage 持久化业务数据,后续再补更直接的调试与备份能力。
## v2.6.5
日期:`2026-03-08`
- 小程序终端 VT P0 基线收口,补齐双缓冲、备用屏幕切换、`DSR / CPR / DA1 / DA2 / DECSTR` 与基础局部重绘。
- 修复 normal buffer 的 live tail 与滚动边界问题,并同步更新当前文档基线到 `v2.6.5`
## v2.6.1
日期:`2026-03-08`
- 将“修改字号后偶发吃字/显示不完整”收口为已知遗留问题,并在设置页增加“修改字号后建议断开重连”提示。
- 收敛数字输入、字体回退与设置页容错逻辑,移除排查阶段的 `terminal.wrap` 调试输出。
## v2.6.0
日期:`2026-03-07`
- 小程序补齐跳转主机、AI 快速启动、后台续接与连接反馈等主链路能力。
- 小程序继续补齐终端可用性细节光标位置计算修复AI 启动链路正式落地到小程序端。
- SSH relay / 跳板机链路从“可配置”推进到“可经 A 主机直接转发连接 B 主机”。
- 文档口径统一升级到 `v2.6.0`,并将历史 `v2.4.0` 说明并入当前对外版本。
## v2.5.0(历史补齐)
日期:`2026-03-07`
- 依据 `v2.3.0 -> v2.6.0` 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。
- 该阶段主要是 `v2.6.0` 之前的小程序终端链路收口与 AI、跳板机场景串联详细内容已并入 `v2.6.0`
## v2.4.0(历史补齐)
日期:`2026-03-07`
- `history.md` 中明确出现 `版本号v2.4.0`,后续在 `v2.6.0` 中并档处理。
- 该阶段集中于小程序终端光标、列宽与 cell 模型重构,以及相关分析文档沉淀,详细内容已并入 `v2.6.0`
## v2.3.0
日期:`2026-03-06`
- 小程序完成一轮大范围能力对齐,补齐 `connect / server settings / terminal / logs / records / settings / plugins` 主页面链路。
- Web 端继续精修交互与稳定性,文档口径统一到 `v2.3.0`
## v2.2.0
日期:`2026-03-06`
- 闪念增强定稿,补齐分类、编辑、搜索、过滤、快速改分类与上下文快照。
- 全局配置补齐闪念分类治理、默认分类与拖拽排序。
## v2.1.0(历史补齐)
日期:`2026-03-01`
- 依据 `v2.0.0 -> v2.2.0` 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。
- 该阶段主要完成小程序连接页、服务器配置页、远程目录选择与基础对齐链路,为 `v2.2.0` 的记录增强和 `v2.3.0` 的大范围对齐打底。
## v2.0.0
日期:`2026-02-27`
- 语音输入交互对齐 Figma闪念记录闭环上线。
- 日志与记录分页、资源缓存治理、文档与发布规范同步统一。
- 语音层与键盘工具层的命中区继续收敛,修复工具栏被误判为外部点击后自动折叠的问题。
## v1.1.0
日期:`2026-02-26`
- 终端语音输入全链路、Gateway ASR 代理与 Web 稳定性增强。
- 终端输入增强为“原生 textarea + 语音输入面板 + 发送确认”组合,并补入 `TAB` 辅助键。
- 路由懒加载失败增加自动恢复,缩放防护补齐到 `double-tap / dblclick` 等路径。
## v1.0.9
日期:`2026-02-25`
- 连接体验治理、服务器列表拖拽排序与移动端防误触优化。
- 连接与服务器管理继续收敛:支持连接重试次数与分组;新增服务器改为先进入配置页,未改动不落库。
- 导航与配置页体验统一:返回语义按历史栈处理,终端配置页增加预览块,配置页 UI 完成一轮重构。
- 终端工具与视觉细节补齐:新增 `paste` 辅助按钮,修复字体选择,补齐夜间模式与重连提示样式。
- 会话恢复体验补强:同连接历史可延续,刷新“我的服务器”页默认不主动断开现有会话。
## v1.0.8
日期:`2026-02-24`
- 配置中心稳定性、深浅色模式与网关 runtime 配置落地。
- 配置中心结构重组:服务器基础/认证绑定到当前服务器,终端/主题/安全切到全局配置。
- 配置与工具区交互继续收敛点击外部区域可折叠右下角键盘工具条Figma 配置区交互并入当前基线。
- 终端提示文案与初始化输出治理:连接提示更友好,中文输入初始化命令回显默认不展示。
## v1.0.6
日期:`2026-02-24`
- Console 触摸工具区可点击性与误触防护优化。
## v1.0.5
日期:`2026-02-23`
- iPhone 触摸滚动动量阶段性优化。
## v1.0.3
日期:`2026-02-23`
- iOS 触摸焦点状态机稳定性修复。
## v1.0.1
日期:`2026-02-23`
- 生产架构版稳定性更新,确立 Web、Gateway 与插件运行时的多包工程基线。
- 补齐多服务器与多认证方式管理,最近连接日志可追溯。
- Codex 模式主链路可用:连接后自动切目录并启动 `codex`
- 主题与界面自定义基础能力上线,含字体、配色与基础配置中心。

576
PLAN_origin.md Normal file
View File

@@ -0,0 +1,576 @@
# remote ssh 实现方案PLAN
重要:这份文档只使用与第一次构建。迭代后没有更新。已经不适用。作为历史文档保留
## 1. 目标与范围
### 1.1 产品目标
- 提供可在 `Web``小程序``iOS Hybrid(App)` 使用的 SSH 连接终端。
- iOS 端支持原生 SSH 能力,连接后在终端内一键进入 Codex 工作流(`cd <项目目录>` + `codex`)。
- 支持多服务器管理、认证方式管理、最近连接日志、主题定制、中文输入。
- 支持插件扩展能力(参考 Obsidian允许用户按规范在 `~/.remoteconn/plugins` 增加功能。
### 1.2 本期范围MVP + 可扩展)
- 必做MVP
- SSH 连接建立与交互(密码 + 私钥)。
- 服务器配置增删改查。
- xterm.js 终端渲染与基础设置(字体/字号/配色)。
- “Codex 模式”一键执行。
- 最近连接记录与会话日志。
- iOS 原生 SSH 插件接入。
- 二期:
- 证书认证OpenSSH certificate
- 小程序网关高可用、审计、限流。
- 高级主题编辑(背景自动对比优化、导入导出主题)。
- 插件系统(本地插件加载、插件 API、权限沙箱、插件市场/导入导出)。
### 1.3 非目标(当前不做)
- 远程文件管理器SFTP UI
- 多人协作会话(共享同一终端)。
- 本地离线执行(必须依赖远程 SSH 主机)。
## 2. 核心技术选型
### 2.1 前端与终端
- `Vue 3 + TypeScript`:统一 UI 与状态逻辑。
- `xterm.js`:终端渲染核心。
- 建议配套插件:
- `xterm-addon-fit`:自适应布局。
- `xterm-addon-webgl`:性能优化(可降级 canvas
- `xterm-addon-search`:日志检索。
- `xterm-addon-unicode11`:中文宽字符兼容。
### 2.2 多端容器
- WebVite 构建 SPA。
- iOS`Capacitor` + 原生 Swift SSH 插件。
- 小程序:使用 `WSS SSH Gateway`(小程序无法直接发起原始 TCP SSH需网关中转
### 2.3 SSH 实现策略
- iOS原生 SSH 客户端能力Swift 封装 libssh2 或 SwiftNIO SSH先做 POC 选型)。
- Web/小程序:统一走 `WSS Gateway -> SSH Server`
- 统一抽象 `TerminalTransport` 接口,屏蔽底层差异。
## 3. 总体架构
### 3.1 分层设计
- `UI 层`:连接页、服务器管理页、终端页、设置页。
- `应用层`连接状态机、命令编排Codex 模式)、日志服务、主题服务。
- `传输层`
- iOSNative SSH Channel。
- Web/小程序WebSocket Channel对接 SSH 网关)。
- `存储层`
- 配置数据本地数据库IndexedDB/SQLite
- 密钥与密码系统安全存储iOS Keychain / Web 安全策略)。
### 3.2 关键模块
- `server-profile`:服务器配置 CRUD、分组、最近使用时间。
- `auth-manager`:密码/私钥/证书认证参数组织与安全存取。
- `session-manager`:连接生命周期(连接、重连、断开、心跳)。
- `terminal-engine`xterm 绑定、输入输出转发、编码处理。
- `codex-orchestrator`:登录后自动执行项目目录切换与 `codex` 启动。
- `logbook`:最近任务和会话日志归档、检索。
- `theme-engine`:字体、字号、配色、背景对比度计算。
- `plugin-runtime`:插件发现、校验、生命周期、错误隔离。
- `plugin-api`命令注册、会话事件、UI 扩展、插件存储。
- `plugin-security`:权限模型、沙箱执行、风控策略。
## 4. 详细功能方案
### 4.1 多服务器管理(增删改查)
- 数据模型建议:
- `ServerProfile`: `id`, `name`, `host`, `port`, `username`, `authType`, `projectPresets[]`, `tags[]`, `lastConnectedAt`
- `CredentialRef`: `id`, `type(password/key/cert)`, `secureStoreKey`, `createdAt`, `updatedAt`
- 交互要点:
- 新建时连通性检测(可选)。
- 列表支持搜索/排序(最近连接优先)。
- 删除前二次确认,避免误删凭据引用。
### 4.2 认证方式
- 支持:
- 密码认证。
- 私钥认证(含 passphrase
- 证书认证(二期)。
- 安全要求:
- 凭据不落明文文件。
- iOS 使用 KeychainWeb 使用受限存储并提示风险。
- 首次连接需主机指纹确认known_hosts 机制)。
### 4.3 终端交互与中文支持
- 输入处理:
- 开启 xterm 中文宽字符能力Unicode11
- 验证拼音输入法、中文标点、组合键Ctrl/Cmd兼容性。
- 输出处理:
- ANSI 颜色、光标控制、清屏、大输出滚动性能。
- 终端尺寸变化(横竖屏切换)触发远端 `pty resize`
### 4.4 Codex 模式
- 入口:终端页主按钮 “Codex 模式”。
- 执行流程:
1. SSH 连接成功后读取用户预设项目路径(支持从 `~` 下候选目录选择)。
2. 发送 `cd <path>`(路径安全转义)。
3. 发送 `command -v codex` 检测。
4. 若存在则发送 `codex` 并进入交互;否则提示安装指引。
- 容错逻辑:
- `cd` 失败时给出“目录不存在/权限不足”提示。
- `codex` 异常退出时记录退出码并允许一键重试。
### 4.5 最近连接任务日志
- 日志结构:
- `SessionLog`: `sessionId`, `serverId`, `startAt`, `endAt`, `status`, `commandMarkers[]`, `error`
- 展示策略:
- 最近 50 条摘要,详情按需加载。
- 支持按服务器过滤。
- 提供导出文本(脱敏)。
### 4.6 主题与界面自定义
- 可配置项:
- 字体、字号、行高、光标样式。
- 终端配色16 色/扩展色关键字段)。
- UI 外观背景色、文本色、强调色、按钮色UI 与 Shell 独立配色域,互不干扰。
- 背景自动对比根据前景色计算最佳对比背景WCAG 对比度优先,`pickBestBackground`)。
- 按钮色自动推导:`pickBtnColor(bg, text)` 在背景与文字之间线性插值t=0.72,偏文本侧),切换预设或自动优化背景时联动更新。
- SVG 图标按钮通过 CSS `mask-image` + `background-color: var(--btn)` 统一随按钮色变化。
- 设置即时生效:
- 所有设置项调整后立即应用CSS 变量实时写入 `document.documentElement`)。
- 变更后 400ms 防抖自动持久化到 IndexedDB**无需手动点击保存**。
- 刷新页面后从 DB 恢复,初始化阶段通过 `initialized` 标志位防止预设联动 watcher 覆盖已保存的自定义值。
- 默认策略:
- 首次使用加载"tide"主题,`uiBtnColor``pickBtnColor` 自动推导(默认 `#adb9cd`)。
- 用户主题本地持久化IndexedDB支持重置默认。
## 5. 跨平台实现路径
### 5.1 Web
- 通过网关建立 WSS 会话,浏览器仅负责终端渲染与输入转发。
- 重点处理浏览器断网重连、页面切后台心跳恢复。
### 5.2 iOSHybrid
- Capacitor 提供 WebView 容器和插件桥接。
- 原生插件职责:
- 建立 SSH 会话(认证/pty/shell/channel
- 读写流双向桥接到 JS。
- 连接状态、错误码、重连事件上报。
- JS 层职责:
- 页面逻辑、xterm 渲染、Codex 编排。
### 5.3 小程序
- 通过 `WSS Gateway` 访问 SSH。
- 网关最小能力:
- 鉴权token/session
- 连接到目标 SSH 主机并建立 channel。
- 双向转发终端数据帧。
- 会话超时与限流控制。
## 6. 网关服务设计Web/小程序共用)
### 6.1 协议建议
- WebSocket 消息类型:
- `init`:目标主机、端口、用户名、认证引用。
- `stdin`:用户输入。
- `stdout`:远端输出。
- `resize`:终端尺寸变化。
- `control`:心跳、断开、错误。
### 6.2 安全与合规
- 强制 WSS。
- 不在网关长期保存明文凭据。
- 审计日志仅记录元信息(不记录完整命令正文,避免敏感泄露)。
- 增加 IP/账号级限流和失败次数保护。
### 6.3 兼容性
- 目标兼容:
- OpenSSH主流版本
- Dropbear。
- Bitvise。
- Windows OpenSSH。
- 通过算法协商与回退策略处理差异KEX、HostKey、Cipher
## 7. 里程碑与迭代计划
### M1脚手架与基础架构1 周)
- 交付:
- Vue3 + TS + xterm.js 基础工程。
- 统一 `TerminalTransport` 接口。
- 页面骨架(连接/终端/设置)。
- 验收:
- 本地可打开终端页并完成假数据输入回显。
### M2服务器管理与本地存储1 周)
- 交付:
- ServerProfile CRUD。
- 凭据引用模型与安全存储接口。
- 验收:
- 可新增/编辑/删除服务器并持久化。
### M3iOS 原生 SSH 插件 POC1-2 周)
- 交付:
- 建立真实 SSH 连接并完成收发。
- 错误码映射与状态事件回传。
- 验收:
- iOS 真机成功连接 Linux 主机并执行命令。
### M4网关服务 MVP1-2 周)
- 交付:
- WSS -> SSH 双向通道。
- 基础鉴权、心跳、断开处理。
- 验收:
- Web 可通过网关连接并稳定交互 30 分钟。
### M5Codex 模式与日志1 周)
- 交付:
- 一键进入 Codex 编排流程。
- 最近连接/任务日志列表与详情。
- 验收:
- 单击按钮后可自动 `cd` 并进入 `codex`
### M6主题、中文输入与兼容性完善1 周)
- 交付:
- 主题系统 + 自动对比背景。
- 中文输入兼容修复与回归。
- SSH 服务端兼容性测试报告。
- 验收:
- 在目标服务端矩阵中通过率达到约定阈值(建议 >= 95%)。
### M7插件系统 MVP1-2 周)
- 交付:
- 插件目录扫描与 `manifest.json` 校验。
- `main.js` 生命周期加载(`onload`/`onunload`)与 `styles.css` 注入。
- 插件 API命令注册、会话事件、插件私有存储
- 插件启停管理页(启用/禁用/失败日志)。
- 验收:
- 手工放入一个符合规范的插件后可成功加载并注册命令。
- 单插件崩溃不会影响 SSH 主链路与终端会话。
## 8. 测试与质量保障
### 8.1 测试分层
- 单元测试:
- 状态机、命令编排、主题对比度算法、日志序列化。
- 集成测试:
- Transport 层收发、断线重连、resize。
- 端到端测试:
- 真机 iOS + Web + 小程序核心链路。
### 8.2 兼容矩阵
- 服务端维度OpenSSH / Dropbear / Bitvise / Windows OpenSSH。
- 认证维度:密码 / 私钥 / 证书(二期)。
- 终端维度:中文输入、复制粘贴、快捷键、长日志滚动。
### 8.3 验收标准DoD
- 核心流程通过:
- 配置服务器 -> 连接 -> 终端交互 -> Codex 模式 -> 断开 -> 日志可查。
- 崩溃率、连接失败率、重连成功率达到设定阈值。
- 安全检查通过(凭据保护、主机指纹校验、传输加密)。
## 9. 风险与应对
### 9.1 小程序网络限制风险
- 风险:不能直接 SSH TCP。
- 应对:网关中转作为必选架构,提前做容量与延迟评估。
### 9.2 SSH 库兼容性风险iOS
- 风险:不同算法和服务端实现差异导致连接失败。
- 应对M3 做双库 POC保留切换空间建立兼容性回归集。
### 9.3 凭据安全风险
- 风险:本地泄露或日志误记敏感信息。
- 应对:安全存储 + 脱敏日志 + 最小权限 + 定期安全审计。
### 9.4 Codex 可用性风险
- 风险:远端未安装 codex 或 PATH 异常。
- 应对:启动前检测并给出安装与修复指引。
### 9.5 插件安全与稳定性风险
- 风险:第三方插件读取敏感数据、阻塞主线程或污染全局样式。
- 应对:权限模型 + 沙箱执行 + 运行时超时熔断 + 样式作用域隔离 + 默认禁用高危权限。
## 10. 实施顺序建议(小步迭代)
- 第一步先打通单平台iOS 或 Web端到端连接链路。
- 第二步:抽象共用层(状态机、命令编排、主题、日志)。
- 第三步:接入第二平台并复用核心模块。
- 第四步:补齐小程序网关与兼容性验证。
- 第五步:做稳定性、性能与安全收敛后再扩展证书认证。
## 11. 需要产品/团队尽快确认的决策
- 小程序目标平台范围(微信/支付宝/字节)及对应发布优先级。
- iOS SSH 库最终选型标准(许可证、性能、维护活跃度)。
- 日志保留周期与隐私策略(是否允许命令内容落盘)。
- Codex 模式默认工作目录来源(手选/记忆上次/项目列表同步)。
- 插件发布策略(仅本地安装/官方市场/私有市场)与审核机制。
- 插件权限默认策略(安装即授权 or 首次调用时授权)。
- Web 与 iOS 端插件目录映射策略(是否支持跨端同步)。
## 12. UI 详细设计MVP 可落地)
### 12.1 设计目标
- 让用户在 3 步内完成连接:选服务器 -> 认证 -> 进入终端。
- 终端页优先级最高,减少非必要干扰。
- 支持 Web、iOS、小程序统一体验交互模型一致视觉密度按端适配。
### 12.2 视觉方向
- 风格关键词:液态、通透、流动、层次感、高可读。
- 视觉语言:`液态渐变背景 + 玻璃卡片 + 软阴影`,以“可视层级”替代“重边框”。
- 字体建议:`Outfit`UI+ `JetBrains Mono`(终端与日志)。
- 圆角:容器 `20px`,卡片 `14px`,输入框 `10px`,标签 `999px`
- 动效:按钮反馈 `180ms`,抽屉/弹窗 `240ms`,背景流体动画 `8-20s` 可配置。
- 移动端策略:优先保证单手操作区,关键按钮放置在拇指热区(屏幕下半部分)。
### 12.3 设计令牌Design Tokens
- `--bg: #192b4d`(主背景,随 `uiBgColor` 实时更新)
- `--surface: rgba(20,32,56,0.64)`(液态面板)
- `--surface-border: rgba(118,156,213,0.2)`(卡片边框)
- `--text: #e6f0ff`(主文本,随 `uiTextColor` 实时更新)
- `--muted: #9cb1cf`(次文本)
- `--accent: #5bd2ff`(主强调色,随 `uiAccentColor` 实时更新)
- `--btn: #adb9cd`(按钮/图标色,随 `uiBtnColor` 实时更新SVG 图标通过 `mask + background-color: var(--btn)` 染色)
- `--success: #79f3bd`
- `--danger: #ff7f92`
### 12.4 信息架构
- 一级导航:`连接``终端``日志``设置`
- Web/iPad左侧导航 + 右侧内容区。
- 手机/小程序:底部 Tab + 顶部标题栏。
- 全局入口:`+ 新建服务器`、全局搜索、连接状态指示器。
### 12.5 页面级设计
#### 12.5.1 连接页(服务器列表)
- 顶部区:搜索框、筛选(标签/认证类型)、排序(最近连接优先)。
- 内容区:服务器卡片列表,显示名称、`user@host:port`、认证类型、最近连接时间、标签。
- 卡片操作:`连接`(主按钮)、`编辑``更多`(复制/删除)。
- 快速入口:支持 `新建服务器`,默认生成草稿并自动定位到配置表单。
- 空状态:引导文案 + `新建第一台服务器`
- 错误状态:网络错误、凭据失效、网关不可达,均提供 `重试`
#### 12.5.2 新建/编辑服务器页
- 字段分组基础信息、认证、Codex 预设、高级选项。
- 必填项:名称、主机、端口、用户名、认证类型。
- 认证区:密码/私钥二选一;私钥支持 passphrase。
- 高级项:连接超时、心跳间隔、启动后默认目录。
- 易用性:按认证类型动态展示字段,隐藏无关输入,减少误填。
- 底部固定操作栏:`测试连接``保存`
- 校验策略:失焦即时校验 + 保存前全量校验。
#### 12.5.3 终端页(核心)
- 顶部工具栏:会话名、延迟指示、重连、复制、清屏、字体缩放、设置。
- 主区xterm 终端占满可视区,支持横竖屏与窗口 resize。
- 侧边抽屉Web/底部抽屉(移动端):会话信息、快捷命令、日志标记。
- 主按钮:`Codex 模式`,首次点击弹出确认说明。
- 断线态:覆盖层提示 + `自动重连中` + `手动重连`
- 预览联动:设置页调整字体、行高、主题后,终端区实时预览变化。
#### 12.5.4 日志页
- 列表项字段:服务器名、开始/结束时间、状态、耗时、错误摘要。
- 过滤器:服务器、状态、时间范围。
- 详情页时间线展示关键事件连接、认证、Codex 启动、断开)。
- 操作:`导出脱敏文本`
#### 12.5.5 设置页
- 分区用户界面UI 外观、Shell终端显示 + 缓冲)、连接策略、日志。
- 外观项:主题预设(切换自动联动颜色)、背景色、文本色、强调色、按钮色(自动推导,可手动覆盖)。
- Shell 项:字体、字号、行高、光标样式、宽字符支持、终端缓冲阈值。
- 连接策略:自动重连、重连上限、主机指纹策略、凭据记忆策略、超时参数、服务器预填值。
- 日志项:保留天数、日志脱敏。
- **即时生效**:所有项更改后立即反映到 UICSS 变量实时写入根节点400ms 防抖后自动写入 IndexedDB无手动保存按钮。
- 自动优化背景:`pickBestBackground` 推导最优背景色,同时联动更新 `uiBtnColor``pickBtnColor`)。
### 12.6 关键组件规范
- `ServerCard`:高度 `88px`,主信息左对齐,主按钮固定右侧。
- `ConnectButton`:默认 `连接`,加载态 `连接中...`,失败态 `重试`
- `AuthTypeSegment`:密码/私钥分段选择,切换保留已输入内容。
- `TerminalToolbar`:图标按钮最小点击区 `40x40`
- `HostKeyConfirmModal`:显示指纹、算法、首次发现时间;按钮 `信任并继续` / `取消`
- `ThemePresetSelect`:切换后同步更新颜色、透明度、模糊参数,支持用户再微调。
- `ConfigTabs`:固定五分组(基础/认证/终端/液态主题/安全),避免长表单滚动疲劳。
### 12.7 关键交互流程(连接与 Codex
1. 用户在连接页点击目标服务器 `连接`
2. 前端进入 `connecting` 状态,展示进度并允许取消。
3. 若为首次连接,弹出主机指纹确认弹窗。
4. 认证成功后进入终端并自动执行 `fit`
5. 顶部出现 `Codex 模式` 入口。
6. 用户点击后依次执行 `cd <path>``command -v codex``codex`
7. 任一步失败时,在终端输出可读错误,并配合 toast 提示。
### 12.8 状态机与反馈规范
- 会话状态:`idle``connecting``auth_pending``connected``reconnecting``disconnected``error`
- 状态反馈:每个状态具备统一颜色、提示文案、按钮可用性规则。
- 消息反馈:信息 `3s` 自动消失,警告 `5s`,错误默认需手动关闭。
### 12.9 响应式布局规则
- `>=1280`:三栏(导航/列表/详情)。
- `768-1279`:两栏(导航/内容)。
- `<768`:单栏 + 底部 Tab。
- 终端最小可用区:宽 `320px`、高 `220px`
### 12.10 可用性与无障碍
- 文本与关键 UI 对比度满足 WCAG AA。
- 关键操作支持键盘可达与焦点可视化。
- 错误提示需定位到字段级,不仅显示“操作失败”。
- 配置面板「实时生效 + 自动保存」400ms 防抖写 IndexedDB刷新不丢失对比度满足 WCAG AA。
### 12.11 配置能力清单MVP 必备)
- 服务器配置:名称、主机、端口、用户名、标签、项目目录、超时、心跳。
- 认证配置:密码、私钥、证书;按类型动态展示所需字段。
- 终端配置字体、字号、行高、光标、Unicode11、自动重连。
- 主题配置:预设主题、背景/文本/强调/按钮色自定义(按钮色自动推导)、自动优化背景。所有项实时生效,自动持久化。
- 安全配置:主机指纹策略、日志保留时长、日志脱敏、凭据记忆策略。
## 13. 插件系统实现方案(参考 Obsidian
### 13.1 目标与边界
- 目标:
- 让用户通过本地目录增加插件,不改主程序即可扩展功能。
- 插件覆盖命令扩展、会话事件处理、轻量 UI 扩展、样式增强。
- 插件失败可隔离,不能拖垮核心 SSH 链路。
- 非目标MVP 不做):
- 插件可执行任意系统命令。
- 插件直接访问凭据明文(密码、私钥内容)。
- 复杂插件依赖解析(如 npm 依赖在线安装)。
### 13.2 目录与文件规范
- 默认根目录:`~/.remoteconn`
- 插件目录:`~/.remoteconn/plugins/<plugin-id>/`
- 每个插件最少三个文件:
- `manifest.json`
- `main.js`
- `styles.css`
- 推荐目录结构:
- `~/.remoteconn/plugins/<plugin-id>/data.json`(插件私有配置,可选)
- `~/.remoteconn/plugins/<plugin-id>/README.md`(插件说明,可选)
- 约束:
- `<plugin-id>` 只能包含小写字母、数字、短横线,正则:`^[a-z0-9][a-z0-9-]{1,62}$`
- 不允许符号链接跳出插件目录(防止路径穿越)。
### 13.3 manifest.json 规范MVP
- 必填字段:
- `id`: 插件唯一标识,必须与目录名一致。
- `name`: 插件展示名。
- `version`: 语义化版本SemVer
- `minAppVersion`: 最低主程序版本。
- `description`: 简介。
- `entry`: 入口文件,固定 `main.js`
- `style`: 样式文件,固定 `styles.css`
- `permissions`: 权限数组。
- 可选字段:
- `author``homepage``keywords``mobileSupported``desktopSupported`
- 示例:
```json
{
"id": "codex-shortcuts",
"name": "Codex Shortcuts",
"version": "0.1.0",
"minAppVersion": "0.1.0",
"description": "提供常用 Codex 快捷命令",
"entry": "main.js",
"style": "styles.css",
"permissions": ["commands.register", "session.read", "session.write"]
}
```
### 13.4 运行时架构
- `PluginManager`:扫描目录、触发加载/卸载、处理启停状态。
- `PluginRegistry`:维护插件清单、版本、权限、运行状态。
- `PluginValidator`:校验 manifest、文件存在性、版本兼容、权限白名单。
- `PluginHost`:创建插件执行上下文,注入受限 API。
- `PluginStyleManager`:注入/移除插件样式,处理作用域和冲突。
- `PluginErrorBoundary`:捕获插件异常并熔断单插件实例。
- 插件状态机:
- `discovered` -> `validated` -> `loading` -> `active` -> `stopped` / `failed`
### 13.5 加载与卸载流程
1. 启动时扫描 `~/.remoteconn/plugins/*/manifest.json`
2. 校验 `manifest.json` 与目录结构;不合法则标记 `failed` 并记录错误。
3. 校验权限是否在白名单内;高危权限要求用户确认。
4. 先注入 `styles.css`(带命名空间),再执行 `main.js`
5.`main.js` 导出 `onload(ctx)`,则调用并等待完成(设置超时,例如 3000ms
6. 用户禁用或退出时调用 `onunload()`,清理命令注册、事件监听、样式节点。
7. 插件异常仅影响自身,主应用继续运行。
### 13.6 main.js 接口约定MVP
- 导出对象:
- `onload(ctx)`:初始化插件。
- `onunload()`:释放资源。
- `ctx` 结构:
- `ctx.app`: 主应用只读元信息(版本、平台)。
- `ctx.commands.register(command)`:注册命令。
- `ctx.session.on(event, handler)`:订阅会话事件。
- `ctx.session.send(input)`:发送终端输入(需 `session.write` 权限)。
- `ctx.storage.get/set`:插件私有 KV 存储(命名空间隔离)。
- `ctx.ui.showNotice(message, level)`:统一通知接口。
- `ctx.logger.info/warn/error`:插件日志。
- 命令对象建议字段:
- `id``title``handler``when`(可见性条件,可选)。
### 13.7 权限模型(首版)
- 权限白名单:
- `commands.register`
- `session.read`
- `session.write`
- `ui.notice`
- `ui.statusbar`
- `storage.read`
- `storage.write`
- `logs.read`
- 权限原则:
- 最小权限默认拒绝。
- 未声明权限时按无权限处理。
- 插件启用时展示权限摘要。
- 后续新增权限必须二次确认。
### 13.8 沙箱与安全策略
- 运行隔离:
- 插件不能直接拿到 `window``document``localStorage` 原始对象。
- 仅通过 `ctx` 暴露的受限 API 与主程序交互。
- 执行控制:
- `onload`/事件回调设置超时阈值,超时自动熔断并禁用插件。
- 连续异常(例如 3 次)自动转 `failed`,需要用户手动恢复。
- 数据安全:
- 明确禁止插件读取凭据明文。
- 插件日志默认脱敏host、token、私钥路径
- 样式安全:
- `styles.css` 注入时添加插件命名空间根节点,避免全局污染。
- 禁止覆盖全局 reset`* {}``body {}`)或在校验时告警。
### 13.9 插件管理页(设置 -> 插件)
- 列表字段:
- 名称、版本、作者、状态、权限摘要、最后错误时间。
- 操作项:
- 启用、禁用、重载、查看日志、打开目录。
- 安装方式MVP
- 手动放入 `~/.remoteconn/plugins` 后点击“刷新插件列表”。
- 失败可观测性:
- 展示加载阶段(解析失败/权限拒绝/运行异常)和错误栈摘要。
### 13.10 跨端路径映射策略
- DesktopmacOS/Linux直接使用 `~/.remoteconn`
- iOS Hybrid映射到应用沙箱目录`Library/Application Support/remoteconn`),在 UI 中仍展示逻辑路径 `~/.remoteconn`
- Web浏览器无法直接访问用户主目录采用“导入插件包(zip)/导出插件包”方案并存储在 IndexedDB。
- 统一抽象:
- 引入 `PluginFsAdapter`,屏蔽不同平台的文件系统差异。
### 13.11 数据模型草案
- `PluginManifest`
- `id`, `name`, `version`, `minAppVersion`, `permissions[]`, `entry`, `style`, `author?`, `description`
- `PluginRecord`
- `id`, `enabled`, `installedAt`, `updatedAt`, `lastError`, `lastLoadedAt`
- `PluginRuntimeState`
- `id`, `status`, `loadDurationMs`, `errorCount`, `lastHeartbeatAt`
### 13.12 测试与验收
- 单元测试:
- manifest 校验、权限校验、状态机转换、API 权限拦截。
- 集成测试:
- 插件加载/卸载、命令注册、样式注入与清理、异常隔离。
- 端到端测试:
- 放置插件 -> 启用 -> 执行命令 -> 卸载 -> 重载,核心链路可复现。
- DoD
- 非法插件不会进入 active。
- 插件异常不影响 SSH 会话。
- 权限未授权时敏感 API 被拒绝且有明确提示。
### 13.13 迭代计划(插件专项)
- P12-3 天目录扫描、manifest 校验、插件列表页(只读)。
- P22-3 天):`main.js` 生命周期 + 命令 API + 样式注入。
- P32-3 天):权限模型 + 插件存储 + 错误熔断。
- P42-3 天):跨端 `PluginFsAdapter` + 导入导出 + 完整测试。

251
README.md Normal file
View File

@@ -0,0 +1,251 @@
<img src="assets/icons/brand-header.svg" alt="RemoteConn / AI矩连" width="420" align="left" />
<p align="right">
<strong>English</strong> | <a href="./README_cn.md">中文</a>
</p>
<br clear="all" />
# RemoteConn
RemoteConn is a production-oriented remote access workspace built around `shell + chat`. It targets Web, mobile app, and Mini Program entry points, so AI-assisted server work can start from a phone and stay attached to the right project context.
The project focuses on three practical problems:
1. Keep an AI-capable shell available on mobile, not only on desktop IDEs.
2. Use voice input to reduce typing cost on phones.
3. Preserve task context, records, and server bindings so long-running work can continue without handoff friction.
<p align="left">
<img src="./微信标准绿版.png" alt="WeChat Standard Green Version" width="420" />
</p>
## Monorepo Structure
- `apps/web`: Vue 3 + TypeScript + xterm.js frontend
- `apps/gateway`: Node.js gateway for `WSS -> SSH`
- `packages/shared`: shared models, state machines, Codex orchestration, theme and security helpers
- `packages/plugin-runtime`: plugin runtime with validation, permission control, lifecycle hooks, isolation, and circuit breaking
- `ios/plugin/RemoteConnSSHPlugin`: Capacitor iOS SSH plugin skeleton
## Implemented Capabilities
1. Server and credential management with restricted local storage
2. Terminal session state machine for connect, reconnect, auth pending, and error states
3. Bidirectional gateway forwarding for `init/stdin/stdout/stderr/resize/control`
4. Codex launch orchestration with remote environment checks
5. Session logging and redacted export
6. Theme system with contrast tuning
7. Plugin runtime foundation
## Latest Status
Current README baseline: `v3.0.1` on `2026-03-20`.
Recent milestones:
- `v3.0.1`: Mini Program manual was expanded with image-based guidance, theme presets were aligned with Web to `21` sets, and the project docs were synchronized.
- `v3.0.0`: Terminal interaction stability was improved during Codex output, including caret stability and keyboard-focus handling.
- Cross-device sync currently covers `settings`, `servers`, and `records`. Sensitive server fields remain encrypted as `secret_blob`.
Known limitations still tracked in the project docs:
- Some Mini Program terminal performance issues can still appear during long AI output sessions.
- Voice playback extraction and turn detection are not yet considered stable enough for default use.
For the full Chinese product narrative and detailed historical notes, see [README_cn.md](./README_cn.md), `CHANGELOG.md`, and `release.md`.
## Operation Guide
This guide reuses the five annotated screenshots from the project introduction and keeps the same visual grouping so the main workflow can be understood directly from the README.
For the narrow vertical mobile layout version, see [Mobile Operation Guide](./docs/mobile-operation-guide-2026-03-20.md).
### 1. Server List And Configuration
![Server List And Configuration](./snapshots/guide-group-01-servers-config-annotated.png)
1. The home page opens on the server list, which is the starting point of the product.
2. The top toolbar is used to add, delete, select, and search servers.
3. The main body of each server card opens the configuration page; the quick actions on the right can duplicate a profile, launch AI, or connect directly.
4. The bottom navigation switches between terminal, logs, records, settings, and about pages.
5. The configuration page is where you fill in the basic connection fields, authentication method, and project directory.
6. `AI Working Directory` defines the default project context for AI startup and is a key part of the collaboration flow.
7. If the target host is reached through a jump host, complete the jump-host fields and its separate authentication settings on the same page.
8. The recommended order is to save the profile first and then start the connection.
### 2. Terminal, AI, And Connection Diagnostics
![Terminal, AI, And Connection Diagnostics](./snapshots/guide-group-02-terminal-status-annotated.png)
1. After entering the terminal, you can run shell commands directly or switch to an AI workflow such as `Codex` from the top-left entry.
2. The top status bar continuously shows connection state, latency, and session overview, making it the primary feedback area for mobile troubleshooting.
3. Tapping `Connected` opens the session detail panel with the target host, connection state, and working directory.
4. Tapping `Latency` opens the diagnostics panel so you can inspect gateway response and network latency trends and judge link quality.
### 3. Voice Input And Shortcut Panel
![Voice Input And Shortcut Panel](./snapshots/guide-group-03-voice-shortcuts-annotated.png)
1. The `voice` button at the bottom of the terminal page opens the voice input panel, supporting a "speak first, decide later" input flow.
2. The voice draft can be sent to the terminal directly or saved as a record together with category tags.
3. The `keyboard` button at the bottom opens the shortcut panel to restore terminal control keys that are missing on mobile.
4. The right-side shortcut panel includes arrow keys, `Esc`, `Ctrl+C`, `Tab`, and other high-frequency controls to reduce input friction on phones.
### 4. Settings Center
![Settings Center](./snapshots/guide-group-04-settings-annotated.png)
1. The settings page is grouped into `UI / Terminal / Connection / Records`, covering appearance, terminal behavior, connection policy, and data management.
2. The UI tab controls theme, colors, and language, which shape the overall experience.
3. The terminal tab controls font family, font size, line height, and terminal palette, directly affecting readability and typing efficiency.
4. The connection tab controls default authentication, reconnect strategy, background keepalive, sync switches, and AI defaults.
5. The records tab controls record retention and category management so the information lifecycle stays structured.
### 5. Records And About
![Records And About](./snapshots/guide-group-05-records-about-annotated.png)
1. The records page is used to capture issues, ideas, TODOs, and context fragments.
2. The top area supports search and category filtering so content can be found quickly.
3. Record cards support actions such as copy, mark, and delete.
4. The bottom area provides pagination, add, and export operations.
5. The about page centralizes the brand, version, and product information entry points.
6. It collects the user guide, feedback channel, privacy policy, change log, and about content in one place.
7. Its role is to act as the final landing page for product documentation inside the app, so users can find a consistent explanation without leaving the product.
## Quick Start
```bash
npm install
npm run dev:gateway
npm run dev:web
```
Default endpoints:
- Web: `http://localhost:5173`
- Gateway: `http://localhost:8787`
- Gateway WS: `ws://localhost:8787/ws/terminal`
## Environment Variables
### Gateway
```bash
cp apps/gateway/.env.example apps/gateway/.env
```
Recommended runtime config:
```dotenv
PORT=8787
HOST=0.0.0.0
GATEWAY_TOKEN=replace-with-strong-random-token
CORS_ORIGIN=https://your-domain.com
DEBUG_IO_HEX=0
ASR_INCLUDE_RAW_RESULT=0
ASR_EMPTY_TEXT_WARN_LIMIT=3
```
### Web
```bash
cp apps/web/.env.example apps/web/.env
```
Build-time config:
```dotenv
VITE_GATEWAY_URL=wss://your-domain.com
VITE_GATEWAY_TOKEN=replace-with-strong-random-token
VITE_ENABLE_PLUGIN_RUNTIME=false
```
Notes:
- `VITE_GATEWAY_TOKEN` must match `GATEWAY_TOKEN`.
- `VITE_ENABLE_PLUGIN_RUNTIME=false` is the recommended initial production posture if plugin support is not needed yet.
- If you expose the service through Nginx or Caddy on `443/80`, the internal gateway can still listen on `8787`.
## Quality Commands
```bash
npm run typecheck
npm run lint
npm run test
```
## Gateway Operations
Unified script: `scripts/gatewayctl.sh`
```bash
# First-time deployment
scripts/gatewayctl.sh ensure-env
scripts/gatewayctl.sh install-service $USER
scripts/gatewayctl.sh deploy
# Daily operations
scripts/gatewayctl.sh status
scripts/gatewayctl.sh logs 200
scripts/gatewayctl.sh logs-clear
scripts/gatewayctl.sh restart
scripts/gatewayctl.sh health
# Foreground local run
scripts/gatewayctl.sh run-local
```
- Linux uses `systemd`.
- macOS uses `launchd` at `~/Library/LaunchAgents/com.remoteconn.gateway.plist`.
## Logs
```shell
launchctl print gui/$(id -u)/com.remoteconn.gateway | rg -n "stdout path|stderr path"
tail -f /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log
```
## Benchmarks
Gateway SSH2 baseline:
```bash
npm --workspace @remoteconn/gateway run bench
```
Example with explicit parameters:
```bash
BENCH_SSH_HOST=127.0.0.1 \
BENCH_SSH_PORT=22 \
BENCH_SSH_USER="$USER" \
BENCH_PRIVATE_KEY=~/.ssh/id_ed25519 \
BENCH_ITERATIONS=30 \
BENCH_PAYLOAD_KB=1024 \
npm --workspace @remoteconn/gateway run bench
```
TTYD baseline:
```bash
ttyd -v
BENCH_SSH_HOST=127.0.0.1 \
BENCH_SSH_PORT=22 \
BENCH_SSH_USER="$USER" \
BENCH_SSH_IDENTITY=~/.ssh/id_ed25519 \
BENCH_ITERATIONS=30 \
BENCH_PAYLOAD_KB=1024 \
npm --workspace @remoteconn/gateway run bench:ttyd
```
## Related Documents
- `docs/server-management-ux-iteration-2026-02-25.md`
- `docs/terminal-voice-input-plan-2026-02-26.md`
- `docs/web-storage-layering-guideline-2026-02-25.md`
- `docs/touch-focus-state-machine-plan-2026-02-23.md`
- `CHANGELOG.md`
- `release.md`

385
README_cn.md Normal file
View File

@@ -0,0 +1,385 @@
<img src="assets/icons/brand-header.svg" alt="RemoteConn / AI矩连" width="420" align="left" />
<p align="right">
<a href="./README.md">English</a> | <strong>中文</strong>
</p>
<br clear="all" />
# RemoteConn - AI矩连
为什么做这个工具:
1. **缘起**下载了几个收费的终端shell工具交互效果不理想比如多语种中文的适配。
2. 有了AI未来的确保留一个shell入口就可以了原来无比重要的IDE可以扔进垃圾桶。原来觉得chatGPT这个名字很Low现在看来很有前瞻性chat很准确。
3. 有了AI屏幕大小没有那么重要因为很多时候并不需要手动与PC交互coding尤其是做手机端应用很多任务可以在手机侧完成。这样手机侧入口与AI的交互性就变得很重要。也就是解决把任务送给AI的问题。这样AI接到任务 - 完成任务 - 手机上验证就闭环了。
=> 所以这个工具的入口就是shell+chat。
4. 语音大模型已经很准确了,借助语音识别解决手机键入效率低问题。任务可以喊话完成。
5. 一个任务往往是反复迭代的过程因为上下文的关系这人任务最好持续否则要写交接文档。但是真实的情况是在解决一个问题的过程中或者调试过程中会有新的想法、思路、需求我称之为闪念怎么办语音记录闪念目的是解决这个问题。因为闪念是与当前任务的平行事件这个闪念可以暂时不必理会。闪念可以随时送回AI处理。这个效率非常高。
6. 因为需要管理很多服务器所以有个服务器管理入口项目是服务器的一个工作目录AI都在这个目录下工作闪念也与当前服务器的工作目录关联这样在任务回送的时候会找到算力和迭代项目。
7. 如何登陆服务器不同类型的终端和入口比如web/APP/小程序没有统一的原生shell与服务器交互所以需要一个gateway。目前gateway接txt和voice分别路由。不同的应用类型APP/小程序/WEB只要写前端统一一套gateway规范即可。
8. 必须好用便捷shell和用户界面使用两套主题。shell等所有元素使用主题配色。
9. 可扩展插件这个目前占位没有实现。思路来自重度使用obsidian良好的plugin体验可以方便的扩展前端能力。但这波openclaw热觉得不能做前端插件应该做个skills插件做热插拔skills插件。
10. shell比较难搞web端直接使用xterm。小程序端没有现成的在textarea基础上做了一个shell很难搞一堆效率难用问题不过目前效果很好与codex交互还是比较丝滑与PC端使用codex CLI体验基本拉齐。
<p align="left">
<img src="./微信标准绿版.png" alt="微信标准绿版" width="420" />
</p>
---
基于 `PLAN.md` 技术栈的多包工程:
- `apps/web`Vue3 + TypeScript + xterm.js含 fit/webgl/search/unicode11
- `apps/gateway`Node.js 网关WSS -> SSH
- `packages/shared`共享模型、状态机、Codex 编排、主题与安全工具
- `packages/plugin-runtime`:插件系统运行时(校验/权限/生命周期/熔断)
- `ios/plugin/RemoteConnSSHPlugin`iOS Capacitor 原生插件骨架
## 已实现能力(对齐 PLAN 核心链路)
1. 服务器管理与凭据管理Web IndexedDB + AES-GCM 受限存储)
2. 终端会话状态机connecting/auth_pending/connected/reconnecting/disconnected/error
3. 网关双向转发init/stdin/stdout/stderr/resize/control
4. Codex 模式编排(`cd` -> `command -v codex` -> `codex --sandbox`
5. 会话日志记录与脱敏导出
6. 主题系统与对比度优化
7. 插件系统manifest 校验、权限白名单、onload/onunload、命令注册、隔离与熔断
## 近期更新v3.0.12026-03-20
- 2026-03-20 `v3.0.1` 使用手册与主题口径同步:
- 小程序“使用手册”补成图文版正文前置“为什么需要这个APP并接入 `guide-mobile-*` 配图;
- 小程序主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换,并尽量收口为单词短名;
- Root README、Changelog、Release、小程序 README、项目介绍、相关方案文档与品牌模板统一升级到 `v3.0.1`
- 当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。
- 2026-03-18 `v3.0.0` 终端交互稳定性与文档口径同步:
- 小程序终端在codex交互过程中的性能优化继续优化卡顿不响应问题。
- 小程序终端新增 caret 稳定窗口,收敛高频 `stdout` 期间底部光标在后几行行尾来回跳的问题;
- shell 激活区在软键盘仍可见时会保护被动 `blur`,避免豆包输入法长按语音输入过程中被页面侧提前打断;
- 当前仍保留一部分性能遗留问题,终端 perf 日志默认改为通过开关关闭,调试时再临时打开;
- Root README、Changelog、Release、小程序 README、关于页、多语言文案、小程序工程描述与当前基线文档统一升级到 `v3.0.0`
- 2026-03-13 `v2.9.6` 文档与对外版本口径同步:
- Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一升级到 `v2.9.6`
- 当前版本不引入新的功能、协议行为或交互变更,继续沿用 `v2.9.5` 已完成的现有终端、同步、时延诊断与语音播报边界口径。
- 2026-03-13 `v2.9.5` 文档与遗留问题口径同步:
- Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一升级到 `v2.9.5`
- 新登记小程序终端语音播报遗留问题:当前播报文本提取与轮次稳定判定仍不够准确,长时间 `Codex` 交互时还会放大小程序客户端响应压力,现阶段暂不建议默认使用。
- 2026-03-12 `v2.9.4` 文档与版本口径同步:
- Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、问题闭环记录与当前基线文档统一升级到 `v2.9.4`
- 当前版本不引入额外功能变更,继续沿用 `v2.9.3` 已完成的时延诊断面板与主题对比度收口结果。
- 2026-03-11 `v2.9.3` 小程序时延诊断面板与主题对比度收口:
- 网关响应与网络时延两张折线图合并为一张双轴平滑曲线图,左轴保留网关响应量纲,右轴保留网络时延量纲;
- 原“诊断信息”文字卡已移除,顶部改为两张两行摘要卡;同一服务器断开重连后会优先延续已有采样,尽量补足最近 30 个点;
- 时延面板配色改为跟随终端主题的反相面板,深色终端额外切换一套更深的蓝橙曲线与指标色,保证浅底上的文字和曲线可读性;
- Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.3`
- 2026-03-11 `v2.9.1` 小程序续接恢复、同步刷新与隐私说明同步:
- 小程序终端会话续接首次恢复改为以 `lines + bufferCols / bufferRows` 为准,避免返回服务器列表再进入时出现历史区顶部空白与裸露 `5;2H`
- 启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会自动刷新,不再需要重新进入页面才能看到同步后的配置;
- 小程序用户隐私政策与 about 隐私页已按最新审核口径同步,补齐录音用途、处理范围与用户权利说明;
- Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.1`
- 2026-03-11 `v2.9.0` 小程序 Codex 交互区缺行与闪动修复:
- 小程序终端已修复 `Codex` 持续输出期间底部提示块缺行、状态行被裁掉与区域反复闪动的问题;
- normal buffer viewport 现在会保留光标行之后仍真实存在的 footer`CSI ? 2026 h/l` 同步刷新窗口也已做兼容收口;
- 统一高亮背景行改为优先在 line 层绘制,`> Use /skills to list available skills` 与代码块这类高亮区域不再透出行间底色细线;
- 新增真实 PTY 抓包回放与对应回归测试,锁住这类交互问题;
- Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.0`
- 2026-03-10 `v2.8.2` 连接态强调与 about 页面主题统一:
- 小程序服务器列表里的 `connect` 按钮,以及底栏里的 `shell` 按钮,在存在活动连接时统一改为高饱和实底高亮,不再依赖描边反馈;
- 连接态 SVG 前景色改为运行时跟随界面前景色,避免落在高亮底色上后继续发灰;
- about 首页、详情页与 about-app 改为统一跟随界面配置出色不再单独维护一套固定浅色主题Web about 页同步采用同一套主题变量策略;
- Root README、Changelog、Release、小程序 README、关于页、项目描述与当前基线文档统一升级到 `v2.8.2`
- 2026-03-10 `v2.8.1` 终端语音区与发布口径收口:
- 小程序终端语音区展开按钮默认改为全透明,仅保留 SVG 本体颜色;
- 分类胶囊改为贴文字高度,选中态背景切到更明显的实色;
- 录音中的输入框上方新增更显眼的双环脉冲提示,文案更新为“正在收音,松开后发送或记录闪念”;
- `OSC 10 / 11 / 12` 颜色查询已返回真实 shell 主题色,擦除空白位与备用屏空白屏统一继承当前擦除背景;
- 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,不再列为当前遗留问题;
- 新登记一个低频连接时序遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;
- Root README、Changelog、Release、小程序 README、关于页与当前基线文档统一升级到 `v2.8.1`
- 2026-03-10 `v2.7.1` 文档与版本口径收口:
- 明确当前跨设备同步仍只覆盖 `settings / servers / records`,其中服务器敏感字段以加密后的 `secret_blob` 保存;
- `logs`、插件运行时日志与终端会话缓冲继续保留在本地,不纳入第一阶段同步;
- 补充 `remoteconn-sync.db-wal` 属于 SQLite WAL 写前日志说明,其文件体积不直接等于当前有效同步数据量;
- 当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞,先作为当前遗留问题登记;
- 根 README、小程序 README、Changelog、Release、同步方案文档与关于页版本展示统一更新到 `v2.7.1`
- 2026-03-13 小程序终端语音播报遗留问题登记:
- 当前播报文本提取与轮次稳定判定仍不够准确,播报内容存在偏差;
- 长时间 `Codex` 交互时,语音播报链路会额外放大小程序客户端响应压力;
- 现阶段暂不建议默认使用,先列为待优化遗留问题,后续继续收口文本提取、触发时机与性能隔离。
- 2026-03-19 小程序终端 AI 交互期输入跳跃问题登记:
-`Codex` 等 AI 持续输出期间,点击 shell 激活区弹出软键盘后,输入态仍可能出现输入框/激活区跳跃,导致无法稳定连续输入;
- 当前仅确认问题与 AI 持续输出期间的终端刷新链路有关,尚未完成稳定修复;
- 先按已知遗留问题登记后续继续抓取输入聚焦、键盘高度变化、viewport 滚动与 stdout 刷新之间的时序关系。
- 2026-03-09 小程序配置跨设备同步第一阶段落地:
- `settings / servers / records` 改为“本地 storage + Gateway + SQLite”双层持久化
- 启动时通过 `bootstrap` 拉取并合并远端数据,日常修改按对象异步推送;
- 服务器和闪念删除支持 `tombstone` 合并,新设备可恢复同账号下的设置、服务器配置与闪念记录;
- SSH 密码、私钥、口令与证书等受保护字段不以明文保存,服务端改为加密后的 `secret_blob` 存储。
- 2026-03-09 同步边界与验证口径同步明确:
- 终端会话缓冲仍保留本地,不纳入第一阶段同步;
- 通过 `npm run mini` 生成的 preview 预览二维码,不作为正式版跨设备同步与本地缓存连续性的验证依据。
- 2026-03-08 小程序终端继续推进 VT 最小可用能力:
- 双缓冲与备用屏幕切换已进入当前基线,`47 / 1047 / 1048 / 1049``DSR / CPR / DA1 / DA2 / DECSTR`、基础局部重绘与输入编码已落地;
- normal buffer 的 live tail 与最大滚动值改为同源,历史回到底部后不再继续把当前命令行往上推;
- 当前 P0 主链路已基本打通,保留为 `v2.7.0` 基线继续迭代 parser 完整度、P1 边界精修与高级交互能力。
- 2026-03-08 小程序终端字号问题按“遗留问题”暂存:
- 当前“修改字号后偶发吃字/显示不完整”仍未彻底解决,暂不继续扩大修改范围;
- 设置页在“字号”下新增提示“修改字号后建议断开重连”,作为当前版本的临时规避方案;
- 终端排查期间新增的 `terminal.wrap` 调试输出已移除,保留当前较稳定实现作为 `v2.6.5` 基线。
- 2026-03-07 小程序终端完成一轮底层重构:
- 连接前先按真实终端几何计算 `cols/rows`,不再固定 `80x24`
- 输出缓冲区、光标推进、换行判断统一按 xterm 风格的 cell 模型执行;
- 中文宽字符 continuation 不再丢失到自然文本流,改为固定列宽渲染;
- 可见光标改为终端自绘,原生 `input` 仅保留键盘代理职责;
- 英文右侧 padding、中文输入尾部空白增长、第二行回跳第一行等问题已收敛。
- 2026-03-07 小程序连接链路补齐后台续接:
- 终端页返回其他页面后,会话默认继续保活 `15` 分钟,支持在设置页改为 `1~60` 分钟;
- 返回服务器列表后再次进入,会优先复用原会话并恢复终端尾部缓冲,避免只有光标没有 prompt
- 服务器列表中的“连接”按钮在连接态保持亮色,并增加外圈描边,减少状态误判。
- 2026-03-07 Web 与小程序补齐 SSH 跳转主机链路:
- 服务器配置页新增“跳转主机”配置,支持主机、端口、用户名、认证方式与独立凭据;
- 网关支持“第一跳 SSH + 第二跳 SSH经 direct-tcpip 转发)”链路;
- 基础信息服务器与跳转主机的主机指纹改为分别上报与校验。
- 2026-03-07 小程序补齐 AI 快速启动链路:
- 服务器列表 `AI` 按钮可直接进入终端,并自动打开 AI 启动面板;
- 终端页支持先确保会话连接,再执行 Codex 预检与启动;
- 项目目录不存在、服务器未安装 `codex` 等失败场景改为统一前置提示,不再依赖用户手动排查。
- 小程序版本完成较大能力对齐:
- 补齐 `connect / server settings / terminal / logs / records / settings / plugins` 主页面链路;
- 闪念支持分类、搜索、过滤、编辑、快速改分类与导出;
- 语音输入支持分类写入、未连接可记录闪念、写入 `服务器名称-项目名` 上下文快照;
- `记录 -> 闪念分类` 管理支持新增、设默认、删除、按住拖动排序;
- 服务器配置支持自定义标签,服务器卡片底部展示项目标签与自定义标签。
- Web 端继续做小幅精修:
- 配置页控件密度、卡片层级和记录页交互细节继续收敛;
- 闪念卡片滑动操作、快速改分类弹层、编辑弹框与分类视觉语义完成多轮细调;
- 终端输入、语音与记录联动补齐边界行为。
- 文档基线统一升级到 `v2.6.0`
- 根 README、发布说明、小程序 README、对齐审计、配置实现方案、闪念实现基线和 parity 机读清单统一更新;
- 新增 SSH 跳转链路加密分层图,作为跳板机能力说明;
-`v2.4.0` 说明已并入当前 `v2.6.0`统一作为当前“Web + 小程序”共同对外口径。
## 操作说明
这一节复用项目介绍中已经标注好的五张截图,按同样的视觉分组说明主流程,方便直接在 README 里理解整体使用路径。
如果需要更适合手机竖屏阅读的版本,见 [移动端操作说明](./docs/mobile-operation-guide-2026-03-20.md)。
### 1. 服务器列表与配置
![服务器列表与配置](./snapshots/guide-group-01-servers-config-annotated.png)
1. 首页默认打开服务器列表,这里是整个产品的主入口。
2. 顶部工具栏用于新增、删除、选择和搜索服务器。
3. 服务器卡片主体进入配置页,右侧快捷操作可用于复制配置、启动 AI 或直接连接。
4. 底部导航用于切换终端、日志、记录、设置和关于页面。
5. 配置页用于填写基础连接字段、认证方式和项目目录。
6. `AI Working Directory` 用于定义 AI 启动时默认进入的项目上下文,是协作链路里的关键字段。
7. 如果目标主机需要经过跳板机访问,可以在同页补全跳板机字段和它自己的认证信息。
8. 推荐顺序是先保存服务器配置,再发起连接。
### 2. 终端、AI 与连接诊断
![终端、AI 与连接诊断](./snapshots/guide-group-02-terminal-status-annotated.png)
1. 进入终端后可以直接执行 shell 命令,也可以从左上角切换到 `Codex` 等 AI 工作流。
2. 顶部状态栏会持续显示连接状态、时延和会话概览,是移动端排障的主反馈区。
3. 点击 `Connected` 会打开会话详情面板,查看当前目标主机、连接状态和工作目录。
4. 点击 `Latency` 会打开诊断面板,查看网关响应和网络时延走势,用于判断链路质量。
### 3. 语音输入与快捷面板
![语音输入与快捷面板](./snapshots/guide-group-03-voice-shortcuts-annotated.png)
1. 终端页底部的 `voice` 按钮会打开语音输入面板,支持“先说出来,再决定怎么处理”的输入方式。
2. 语音草稿可以直接发送到终端,也可以带上分类标签保存为记录。
3. 底部的 `keyboard` 按钮会打开快捷键面板,补足手机上缺失的终端控制键。
4. 右侧快捷面板提供方向键、`Esc``Ctrl+C``Tab` 等高频控制,降低手机输入成本。
### 4. 设置中心
![设置中心](./snapshots/guide-group-04-settings-annotated.png)
1. 设置页分为 `UI / Terminal / Connection / Records` 四个分组,分别覆盖外观、终端行为、连接策略和数据管理。
2. UI 页签负责主题、颜色和语言,决定整体视觉体验。
3. Terminal 页签负责字体、字号、行高和终端配色,直接影响可读性和输入效率。
4. Connection 页签负责默认认证、重连策略、后台保活、同步开关和 AI 默认项。
5. Records 页签负责记录保留策略和分类管理,保持信息生命周期有序。
### 5. 记录与关于
![记录与关于](./snapshots/guide-group-05-records-about-annotated.png)
1. 记录页用于保存问题、想法、TODO 和上下文碎片。
2. 顶部区域支持搜索和分类过滤,方便快速定位内容。
3. 记录卡片支持复制、标记和删除等操作。
4. 底部区域提供分页、新增和导出入口。
5. 关于页集中提供品牌、版本和产品信息入口。
6. 这里会统一收纳使用手册、反馈渠道、隐私政策、变更日志和关于内容。
7. 它的角色是产品内文档的最终落点,让用户不离开产品也能找到一致的说明。
## 快速启动
```bash
npm install
npm run dev:gateway
npm run dev:web
```
默认地址:
- Web: `http://localhost:5173`
- Gateway: `http://localhost:8787`
- Gateway WS: `ws://localhost:8787/ws/terminal`
## 环境变量
### Gateway
复制 `apps/gateway/.env.example`
```bash
cp apps/gateway/.env.example apps/gateway/.env
```
### Web
复制 `apps/web/.env.example`
```bash
cp apps/web/.env.example apps/web/.env
```
### 生产环境建议配置
> 注意:前端 `VITE_*` 是构建时注入;`apps/web/.env` 需要在构建前准备好。
`apps/gateway/.env`(网关进程运行时读取):
```dotenv
PORT=8787
HOST=0.0.0.0
GATEWAY_TOKEN=replace-with-strong-random-token
CORS_ORIGIN=https://your-domain.com
DEBUG_IO_HEX=0
ASR_INCLUDE_RAW_RESULT=0
ASR_EMPTY_TEXT_WARN_LIMIT=3
```
`apps/web/.env`Web 构建时读取):
```dotenv
VITE_GATEWAY_URL=wss://your-domain.com
VITE_GATEWAY_TOKEN=replace-with-strong-random-token
VITE_ENABLE_PLUGIN_RUNTIME=false
```
说明:
- `VITE_GATEWAY_TOKEN` 必须与 `GATEWAY_TOKEN` 一致。
- `VITE_ENABLE_PLUGIN_RUNTIME=false` 可在生产中先关闭插件能力。
- 若使用反向代理Nginx/Caddy对外提供 `443/80`,网关内部仍可监听 `8787`
## 质量命令
```bash
npm run typecheck
npm run lint
npm run test
```
## 非 Docker 运维(简化网关维护)
统一脚本:`scripts/gatewayctl.sh`
```bash
# 1) 首次部署Linux + systemd / macOS + launchd
scripts/gatewayctl.sh ensure-env
scripts/gatewayctl.sh install-service $USER
scripts/gatewayctl.sh deploy
# 2) 日常运维
scripts/gatewayctl.sh status
scripts/gatewayctl.sh logs 200
scripts/gatewayctl.sh logs-clear
scripts/gatewayctl.sh restart
scripts/gatewayctl.sh health
# 3) 本地前台运行(不依赖 systemd
scripts/gatewayctl.sh run-local
```
说明:
- Linux 上脚本自动使用 `systemd`
- macOS 上脚本自动使用 `launchd``~/Library/LaunchAgents/com.remoteconn.gateway.plist`)。
## 日志
```shell
gavin mini remoteconn % launchctl print gui/$(id -u)/com.remoteconn.gateway | rg -n "stdout path|stderr path"
stdout path = /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log
stderr path = /Users/gavin/Library/Logs/com.remoteconn.gateway.err.log
gavin mini remoteconn % tail -f /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log
```
## 性能基线压测Gateway SSH2
```bash
# 默认使用 localhost:22 + ~/.ssh/id_ed25519并自动启动临时 gateway
npm --workspace @remoteconn/gateway run bench
# 可选参数(示例)
BENCH_SSH_HOST=127.0.0.1 \
BENCH_SSH_PORT=22 \
BENCH_SSH_USER="$USER" \
BENCH_PRIVATE_KEY=~/.ssh/id_ed25519 \
BENCH_ITERATIONS=30 \
BENCH_PAYLOAD_KB=1024 \
npm --workspace @remoteconn/gateway run bench
```
## 性能基线压测TTYD
```bash
# 先确保系统已安装 ttyd
ttyd -v
# 使用 ttyd -> ssh 的链路进行压测
BENCH_SSH_HOST=127.0.0.1 \
BENCH_SSH_PORT=22 \
BENCH_SSH_USER="$USER" \
BENCH_SSH_IDENTITY=~/.ssh/id_ed25519 \
BENCH_ITERATIONS=30 \
BENCH_PAYLOAD_KB=1024 \
npm --workspace @remoteconn/gateway run bench:ttyd
```
## 相关文档
- 当日服务器管理交互迭代:`docs/server-management-ux-iteration-2026-02-25.md`
- 当日语音输入迭代:`docs/terminal-voice-input-plan-2026-02-26.md`
- 存储分层规范:`docs/web-storage-layering-guideline-2026-02-25.md`
- 触摸焦点状态机:`docs/touch-focus-state-machine-plan-2026-02-23.md`
- 变更日志:`CHANGELOG.md`
- 发布说明:`release.md`

52
TOP_LEVEL_CONSTRAINTS.md Normal file
View File

@@ -0,0 +1,52 @@
# 移动端终端交互顶级约束 (Mobile Terminal Top-Level Constraints)
本文档定义了 remoteconn 移动端终端界面的**“四大不可回归”顶级约束**。在未来任何针对 CSS、JS 逻辑、甚至 Xterm.js 版本升级的修改中,都必须且优先满足以下条件。
任何破坏以下任一体验的代码修改,都视为 **核心体验回归 (Critical Regression)**,禁止合入。
---
## 🛑 约束 1正常的软键盘弹出与收回 (Keyboard Lifecycle)
用户的输入意图必须被绝对尊重,不能因为防阻断逻辑导致系统键盘失灵或无法正常收起。
- **约束标准**
- 点击终端内最后一行空白处或已有文本,必须能够迅速唤起原生软键盘。
- 在唤起状态下,可以通过 UI 按钮或符合常理的操作顺畅收起键盘,而不引起全屏闪烁或虚假重绘。
- **QA 测试用例 (Test Case 1)**
1. 在手机浏览器中打开终端。
2. 短按屏幕输入区,**验证**:系统软键盘立刻出现。
3. 点击顶部工具栏的隐藏键盘按钮/或进行收起手势,**验证**:软键盘顺利降下,终端不触发无关的字符输入或选中。
## 🛑 约束 2原生且丝滑的滚动 (Native-like Smooth Scroll)
无论渲染层多么复杂(海量 `span`/`div` 标签),滑动都必须 1:1 跟手,并保留操作系统级的惯性阻尼。
- **约束标准**
- `touchmove` 事件绝不能由于 OS 原生手势侦测而在中途被无故截断Frozen Screen
- 松开手指后必须有自然的衰减惯性滚动。
- **QA 测试用例 (Test Case 2)**
1. 打印一长串历史构建输出(如 `ls -la /``dmesg`)。
2. 手指在屏幕上连续、快速且不离开屏幕地上下来回拖拽。**验证**:无论速度多快,列表紧跟指尖,没有丝毫卡顿或断流。
3. 快速向上一划并立刻松手。**验证**:列表如原生 App 一样继续依靠惯性顺滑滚动,直至自然停止。
## 🛑 约束 3完整的长按选中功能 (Long-Press Text Selection)
滚动优化和事件劫持不能杀灭系统底层的文本选取工作流。
- **约束标准**
- 在触摸初始阶段 (`touchstart` / `pointerdown`) 绝对不滥用 `preventDefault` 导致 OS 选区定时器失效。
- 一旦触发选区,一切系统原生拖拽行为享有最高优先级,防劫持和自定义滚动必须全面让路。
- **QA 测试用例 (Test Case 3)**
1. 在终端中找到一段带颜色的日志文本。
2. 手指长按某个单词,**验证**:出现 iOS/Android 原生放大镜,并且选中该词(高亮或系统菜单出现)。
3. 拖拽选区两端的“小拉手”扩大选中范围,**验证**:选区顺利扩大,且此时不应该误触发页面的大幅度重新滚动。
## 🛑 约束 4键盘唤起/收回期间光标与命令行的正确位置 (Viewport Pinning during Transitions)
当可视区域 (`Visual Viewport`) 受到物理屏幕与虚拟键盘挤压变化时,我们必须保证用户正在操作的焦点不丢失。
- **约束标准**
- 软键盘弹出的瞬间,终端内部高度与排版自动重调,强制将**当前活动命令行 (光标所在行)** 顶出至可视范围内(浮于键盘正上方)。
- 软键盘收回时,恢复之前的满铺视图高度,且当前输入行依然停留在舒适可见范围内,不会沉入看不见的“黑洞(屏幕下方)”。
- **QA 测试用例 (Test Case 4)**
1. 屏幕充满日志内容后,最下面的一行是当前的 Shell 提示符 `$ `
2. 短按唤起键盘,此时屏幕下半部分被键盘遮挡。
3. **验证**:整个终端内容自动缩紧或向上推移,最后那行 `$ ` 以及光标刚好贴在这个物理键盘的顶部,能清晰看见输入的内容。
4. 顺势收回键盘,**验证**:屏幕重新展平,最后一行依然保留在屏幕最下方,并未丢失位置。

44
apps/gateway/.env.example Normal file
View File

@@ -0,0 +1,44 @@
PORT=8787
HOST=0.0.0.0
GATEWAY_TOKEN=remoteconn-dev-token
CORS_ORIGIN=*
DEBUG_IO_HEX=0
# 豆包 ASR语音输入
ASR_PROVIDER=volcengine
ASR_APP_ID=
ASR_ACCESS_TOKEN=
ASR_SECRET_KEY=
ASR_RESOURCE_ID=volc.seedasr.sauc.duration
# 可选:当前 v3/sauc/bigmodel_async 默认可不填
ASR_CLUSTER=
ASR_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
# 生产建议 0result 帧不携带上游原始 payload降低下行带宽与前端解析开销
ASR_INCLUDE_RAW_RESULT=0
# 单连接内空文本告警上限,避免上游异常时日志风暴
ASR_EMPTY_TEXT_WARN_LIMIT=3
# 小程序 Codex 语音播报TTS
# 如使用火山引擎 HTTP Chunked/SSE 单向流式 V3请把 TTS_PROVIDER 改为 volcengine。
# 注意:这里填写的是 Access Token虽然请求头字段名仍叫 `X-Api-Access-Key`。
# TTS_RESOURCE_ID 还需要和代码里映射的豆包 1.0 speaker 保持同代匹配。
TTS_PROVIDER=tencent
TTS_APP_ID=
TTS_ACCESS_TOKEN=
TTS_SECRET_ID=
TTS_SECRET_KEY=
TTS_REGION=ap-guangzhou
TTS_CLUSTER=volcano_tts
TTS_RESOURCE_ID=volc.service_type.10029
TTS_VOICE_DEFAULT=female_v1
TTS_SPEED_DEFAULT=1
TTS_TIMEOUT_MS=30000
TTS_CACHE_FILE_MAX_BYTES=8388608
# 小程序配置同步Gateway + SQLite
MINIPROGRAM_APP_ID=
MINIPROGRAM_APP_SECRET=
SYNC_SQLITE_PATH=data/remoteconn-sync.db
SYNC_SECRET_CURRENT=
SYNC_SECRET_VERSION=1
SYNC_TOKEN_TTL_SEC=604800

View File

@@ -0,0 +1,18 @@
{
"GATEWAY_PORT": 8787,
"GATEWAY_HOST": "0.0.0.0",
"GATEWAY_CORS_ORIGIN": "*",
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_DEBUG_IO_HEX": 0,
"RATE_LIMIT_POINTS": 30,
"RATE_LIMIT_DURATION_SEC": 60,
"ASSIST_TXN_TTL_MS": 30000,
"ASSIST_TXN_CACHE_LIMIT": 512,
"SSH_READY_TIMEOUT_MS": 15000,
"SSH_KEEPALIVE_INTERVAL_MS": 10000,
"SSH_KEEPALIVE_COUNT_MAX": 3,
"TERMINAL_RESUME_GRACE_DEFAULT_MS": 20000,
"TERMINAL_RESUME_GRACE_MAX_MS": 3600000,
"PLUGIN_ONLOAD_TIMEOUT_MS": 3000,
"PLUGIN_ONUNLOAD_TIMEOUT_MS": 3000
}

35
apps/gateway/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@remoteconn/gateway",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"bench": "tsx src/bench/gatewayPerfBench.ts",
"bench:ttyd": "tsx src/bench/ttydPerfBench.ts"
},
"dependencies": {
"@remoteconn/shared": "1.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"pino": "^9.9.0",
"pino-http": "^11.0.0",
"rate-limiter-flexible": "^8.1.0",
"ssh2": "^1.17.0",
"ws": "^8.18.3",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"tsx": "^4.20.5"
}
}

View File

@@ -0,0 +1,307 @@
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { WebSocket } from "ws";
interface BenchOptions {
gatewayUrl?: string;
gatewayToken?: string;
sshHost: string;
sshPort: number;
sshUsername: string;
privateKeyPath: string;
iterations: number;
payloadKb: number;
timeoutMs: number;
}
interface BenchStats {
min: number;
max: number;
avg: number;
p50: number;
p95: number;
}
/**
* 将 `~` 展开为用户目录,方便通过环境变量传入私钥路径。
*/
function expandHome(inputPath: string): string {
if (!inputPath.startsWith("~/")) {
return inputPath;
}
return path.join(os.homedir(), inputPath.slice(2));
}
/**
* 从环境变量读取压测参数,保证脚本可在 CI/本地复用。
*/
function loadOptions(): BenchOptions {
return {
gatewayUrl: process.env.BENCH_GATEWAY_URL,
gatewayToken: process.env.BENCH_GATEWAY_TOKEN,
sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1",
sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"),
sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root",
privateKeyPath: expandHome(process.env.BENCH_PRIVATE_KEY ?? "~/.ssh/id_ed25519"),
iterations: Number(process.env.BENCH_ITERATIONS ?? "30"),
payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"),
timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000")
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 循环轮询条件,避免把一堆一次性监听器挂到 WS 上导致泄漏。
*/
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (check()) return;
await delay(10);
}
throw new Error(`等待超时: ${label}`);
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const pos = (sorted.length - 1) * p;
const base = Math.floor(pos);
const rest = pos - base;
const baseValue = sorted[base] ?? 0;
const nextValue = sorted[base + 1];
if (nextValue === undefined) {
return baseValue;
}
return baseValue + rest * (nextValue - baseValue);
}
function buildStats(samples: number[]): BenchStats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((acc, item) => acc + item, 0);
return {
min: sorted[0] ?? 0,
max: sorted[sorted.length - 1] ?? 0,
avg: sorted.length > 0 ? sum / sorted.length : 0,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95)
};
}
function formatMs(value: number): string {
return `${value.toFixed(2)}ms`;
}
function formatMbps(bytes: number, ms: number): string {
if (ms <= 0) return "0.00 MB/s";
const sec = ms / 1000;
const mb = bytes / 1024 / 1024;
return `${(mb / sec).toFixed(2)} MB/s`;
}
/**
* 本地未提供网关地址时,启动一份临时网关实例用于基线压测。
*/
async function startLocalGateway(): Promise<{ url: string; token: string; close: () => Promise<void> }> {
const token = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
process.env.HOST = "127.0.0.1";
process.env.PORT = "0";
process.env.GATEWAY_TOKEN = token;
process.env.CORS_ORIGIN = "*";
const { createGatewayServer } = await import("../server");
const server = createGatewayServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address() as AddressInfo;
return {
url: `ws://127.0.0.1:${address.port}`,
token,
close: async () => {
server.close();
await once(server, "close");
}
};
}
async function main(): Promise<void> {
const options = loadOptions();
const privateKey = await readFile(options.privateKeyPath, "utf8");
const cleanupTasks: Array<() => Promise<void>> = [];
let gatewayUrl = options.gatewayUrl;
let gatewayToken = options.gatewayToken;
if (!gatewayUrl) {
const localGateway = await startLocalGateway();
gatewayUrl = localGateway.url;
gatewayToken = localGateway.token;
cleanupTasks.push(localGateway.close);
}
if (!gatewayToken) {
throw new Error("缺少网关 token请设置 BENCH_GATEWAY_TOKEN或让脚本自动启动本地网关");
}
const endpoint = `${gatewayUrl.replace(/\/$/, "")}/ws/terminal?token=${encodeURIComponent(gatewayToken)}`;
const ws = new WebSocket(endpoint);
let stdoutText = "";
let stdoutBytes = 0;
let connected = false;
let disconnectedReason = "";
let fatalError = "";
ws.on("message", (raw) => {
try {
const frame = JSON.parse(raw.toString()) as {
type: string;
payload?: { data?: string; action?: string; reason?: string; message?: string };
};
if (frame.type === "stdout") {
const data = frame.payload?.data ?? "";
stdoutText += data;
stdoutBytes += Buffer.byteLength(data, "utf8");
return;
}
if (frame.type === "control" && frame.payload?.action === "connected") {
connected = true;
return;
}
if (frame.type === "control" && frame.payload?.action === "disconnect") {
disconnectedReason = frame.payload?.reason ?? "unknown";
return;
}
if (frame.type === "error") {
fatalError = frame.payload?.message ?? "gateway error";
}
} catch {
fatalError = "网关返回了无法解析的消息";
}
});
ws.on("error", (error) => {
fatalError = String(error);
});
const connectedStartedAt = performance.now();
await once(ws, "open");
ws.send(
JSON.stringify({
type: "init",
payload: {
host: options.sshHost,
port: options.sshPort,
username: options.sshUsername,
credential: { type: "privateKey", privateKey },
pty: { cols: 140, rows: 40 }
}
})
);
await waitFor(() => connected || fatalError.length > 0, options.timeoutMs, "SSH 连接建立");
if (fatalError) {
throw new Error(fatalError);
}
const connectMs = performance.now() - connectedStartedAt;
// 关闭 TTY 回显,避免命令内容干扰 RTT 统计(否则 marker 可能因本地 echo 提前出现)。
ws.send(JSON.stringify({ type: "stdin", payload: { data: "stty -echo\n" } }));
await delay(120);
const rttSamples: number[] = [];
for (let index = 0; index < options.iterations; index += 1) {
const marker = `__RCBENCH_${Date.now()}_${index}__`;
const stdoutStart = stdoutText.length;
const startedAt = performance.now();
ws.send(JSON.stringify({ type: "stdin", payload: { data: `printf '${marker}\\n'\n` } }));
await waitFor(
() => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0,
options.timeoutMs,
`命令回显 RTT #${index + 1}`
);
if (fatalError) {
throw new Error(fatalError);
}
rttSamples.push(performance.now() - startedAt);
}
const payloadBytes = options.payloadKb * 1024;
const throughputStartBytes = stdoutBytes;
const throughputStartAt = performance.now();
ws.send(
JSON.stringify({
type: "stdin",
payload: {
data: `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n`
}
})
);
await waitFor(
() => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0,
options.timeoutMs * 4,
"吞吐测试输出收集"
);
if (fatalError) {
throw new Error(fatalError);
}
const throughputMs = performance.now() - throughputStartAt;
ws.send(JSON.stringify({ type: "control", payload: { action: "disconnect" } }));
await delay(80);
ws.close();
const rtt = buildStats(rttSamples);
console.log("=== Gateway SSH2 基线压测结果 ===");
console.log(
JSON.stringify(
{
ts: new Date().toISOString(),
endpoint,
sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`,
iterations: options.iterations,
payloadKb: options.payloadKb,
connect: formatMs(connectMs),
rtt: {
min: formatMs(rtt.min),
p50: formatMs(rtt.p50),
p95: formatMs(rtt.p95),
max: formatMs(rtt.max),
avg: formatMs(rtt.avg)
},
throughput: {
bytes: payloadBytes,
cost: formatMs(throughputMs),
speed: formatMbps(payloadBytes, throughputMs)
},
disconnectReason: disconnectedReason || "client_disconnect"
},
null,
2
)
);
for (const task of cleanupTasks) {
await task();
}
}
main().catch((error) => {
console.error("[bench] 执行失败:", (error as Error).message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,307 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
import net from "node:net";
import { WebSocket, type RawData } from "ws";
interface BenchOptions {
ttydBin: string;
ttydHost: string;
ttydPort: number;
sshHost: string;
sshPort: number;
sshUsername: string;
sshIdentity?: string;
iterations: number;
payloadKb: number;
timeoutMs: number;
}
interface BenchStats {
min: number;
max: number;
avg: number;
p50: number;
p95: number;
}
/**
* 读取环境变量,统一与 gateway 基线脚本保持参数名兼容。
*/
function loadOptions(): BenchOptions {
const identity = process.env.BENCH_SSH_IDENTITY;
return {
ttydBin: process.env.BENCH_TTYD_BIN ?? "ttyd",
ttydHost: process.env.BENCH_TTYD_HOST ?? "127.0.0.1",
ttydPort: Number(process.env.BENCH_TTYD_PORT ?? "0"),
sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1",
sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"),
sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root",
sshIdentity: identity && identity.trim().length > 0 ? identity : undefined,
iterations: Number(process.env.BENCH_ITERATIONS ?? "30"),
payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"),
timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000")
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 轮询等待状态变化,避免事件乱序导致监听器遗漏。
*/
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (check()) return;
await delay(10);
}
throw new Error(`等待超时: ${label}`);
}
async function pickFreePort(host: string): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, host, () => resolve());
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await new Promise<void>((resolve) => server.close(() => resolve()));
if (!port) {
throw new Error("无法分配可用端口");
}
return port;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const pos = (sorted.length - 1) * p;
const base = Math.floor(pos);
const rest = pos - base;
const baseValue = sorted[base] ?? 0;
const nextValue = sorted[base + 1];
if (nextValue === undefined) {
return baseValue;
}
return baseValue + rest * (nextValue - baseValue);
}
function buildStats(samples: number[]): BenchStats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((acc, item) => acc + item, 0);
return {
min: sorted[0] ?? 0,
max: sorted[sorted.length - 1] ?? 0,
avg: sorted.length > 0 ? sum / sorted.length : 0,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95)
};
}
function formatMs(value: number): string {
return `${value.toFixed(2)}ms`;
}
function formatMbps(bytes: number, ms: number): string {
if (ms <= 0) return "0.00 MB/s";
const sec = ms / 1000;
const mb = bytes / 1024 / 1024;
return `${(mb / sec).toFixed(2)} MB/s`;
}
/**
* 启动 ttyd 子进程命令行为ttyd -> ssh -> 目标 shell。
*/
async function startTtyd(options: BenchOptions): Promise<{ proc: ChildProcessByStdio<null, Readable, Readable>; port: number }> {
const port = options.ttydPort > 0 ? options.ttydPort : await pickFreePort(options.ttydHost);
const sshArgs = [
"-tt",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR"
];
if (options.sshIdentity) {
sshArgs.push("-i", options.sshIdentity);
}
sshArgs.push("-p", String(options.sshPort), `${options.sshUsername}@${options.sshHost}`);
const args = ["-i", options.ttydHost, "-p", String(port), "-W", "--", "ssh", ...sshArgs];
const proc = spawn(options.ttydBin, args, {
stdio: ["ignore", "pipe", "pipe"]
});
let stderrText = "";
proc.stderr.on("data", (chunk: Buffer) => {
stderrText += chunk.toString("utf8");
});
await waitFor(
() => stderrText.includes("Listening on port:") || proc.exitCode !== null,
4000,
"ttyd 启动"
);
if (proc.exitCode !== null) {
throw new Error(`ttyd 启动失败(exit=${proc.exitCode}): ${stderrText.trim()}`);
}
return { proc, port };
}
function sendTtydInput(ws: WebSocket, data: string): void {
ws.send(Buffer.concat([Buffer.from("0"), Buffer.from(data, "utf8")]));
}
function toBuffer(raw: RawData): Buffer {
if (Buffer.isBuffer(raw)) {
return raw;
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw);
}
if (Array.isArray(raw)) {
return Buffer.concat(
raw.map((chunk) => {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
return Buffer.from(chunk);
})
);
}
return Buffer.from(raw);
}
async function main(): Promise<void> {
const options = loadOptions();
const { proc, port } = await startTtyd(options);
const endpoint = `ws://${options.ttydHost}:${port}/ws`;
const ws = new WebSocket(endpoint, ["tty"]);
let stdoutText = "";
let stdoutBytes = 0;
let fatalError = "";
let opened = false;
ws.on("open", () => {
opened = true;
ws.send(JSON.stringify({ columns: 140, rows: 40 }));
});
ws.on("message", (raw, isBinary) => {
if (!isBinary) return;
const data = toBuffer(raw);
const cmd = String.fromCharCode(data[0] ?? 0);
if (cmd !== "0") {
return;
}
const text = data.slice(1).toString("utf8");
stdoutText += text;
stdoutBytes += Buffer.byteLength(text, "utf8");
});
ws.on("error", (error) => {
fatalError = String(error);
});
const connectStartedAt = performance.now();
await waitFor(() => opened || fatalError.length > 0, options.timeoutMs, "ttyd ws 连接");
if (fatalError) {
throw new Error(fatalError);
}
const markerInit = `__TTYD_INIT_${Date.now()}__`;
sendTtydInput(ws, `printf '${markerInit}\\n'\n`);
await waitFor(() => stdoutText.includes(markerInit) || fatalError.length > 0, options.timeoutMs, "初始就绪");
if (fatalError) {
throw new Error(fatalError);
}
const connectMs = performance.now() - connectStartedAt;
// 关闭回显,避免输入回显干扰 RTT 检测。
sendTtydInput(ws, "stty -echo\n");
await delay(120);
const rttSamples: number[] = [];
for (let index = 0; index < options.iterations; index += 1) {
const marker = `__TTYDBENCH_${Date.now()}_${index}__`;
const stdoutStart = stdoutText.length;
const startedAt = performance.now();
sendTtydInput(ws, `printf '${marker}\\n'\n`);
await waitFor(
() => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0,
options.timeoutMs,
`命令回显 RTT #${index + 1}`
);
if (fatalError) {
throw new Error(fatalError);
}
rttSamples.push(performance.now() - startedAt);
}
const payloadBytes = options.payloadKb * 1024;
const throughputStartBytes = stdoutBytes;
const throughputStartedAt = performance.now();
sendTtydInput(ws, `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n`);
await waitFor(
() => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0,
options.timeoutMs * 4,
"吞吐测试输出收集"
);
if (fatalError) {
throw new Error(fatalError);
}
const throughputMs = performance.now() - throughputStartedAt;
ws.close();
proc.kill("SIGTERM");
const rtt = buildStats(rttSamples);
console.log("=== TTYD 基线压测结果 ===");
console.log(
JSON.stringify(
{
ts: new Date().toISOString(),
endpoint,
sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`,
iterations: options.iterations,
payloadKb: options.payloadKb,
connect: formatMs(connectMs),
rtt: {
min: formatMs(rtt.min),
p50: formatMs(rtt.p50),
p95: formatMs(rtt.p95),
max: formatMs(rtt.max),
avg: formatMs(rtt.avg)
},
throughput: {
bytes: payloadBytes,
cost: formatMs(throughputMs),
speed: formatMbps(payloadBytes, throughputMs)
}
},
null,
2
)
);
}
main().catch((error) => {
console.error("[bench] 执行失败:", (error as Error).message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("gateway config", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("应为火山 V3 单向流式 TTS 提供新的默认 resource id", async () => {
process.env.TTS_RESOURCE_ID = "";
const imported = await import("./config");
expect(imported.config.tts.resourceId).toBe("volc.service_type.10029");
});
it("应解析 TTS_CACHE_FILE_MAX_BYTES并在非法值时回退默认上限", async () => {
process.env.TTS_CACHE_FILE_MAX_BYTES = "6291456";
let imported = await import("./config");
expect(imported.config.tts.cacheFileMaxBytes).toBe(6 * 1024 * 1024);
vi.resetModules();
process.env.TTS_CACHE_FILE_MAX_BYTES = "0";
imported = await import("./config");
expect(imported.config.tts.cacheFileMaxBytes).toBe(8 * 1024 * 1024);
});
});

400
apps/gateway/src/config.ts Normal file
View File

@@ -0,0 +1,400 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
/**
* 运维配置 schema收敛版
*
* 优先级Env > runtime.json > 代码内置默认值。
* 敏感字段GATEWAY_TOKEN / ASR_ACCESS_TOKEN / ASR_SECRET_KEY只走 Env不写入配置文件或仓库。
*
* 字段对照(按配置计划章节):
* 网关基础GATEWAY_PORT / GATEWAY_HOST / GATEWAY_TOKEN / GATEWAY_CORS_ORIGIN / GATEWAY_LOG_LEVEL / GATEWAY_DEBUG_IO_HEX
* 语音识别ASR_PROVIDER / ASR_APP_ID / ASR_ACCESS_TOKEN / ASR_SECRET_KEY / ASR_RESOURCE_ID / ASR_CLUSTER / ASR_WS_URL / ASR_INCLUDE_RAW_RESULT / ASR_EMPTY_TEXT_WARN_LIMIT
* 语音播报TTS_PROVIDER / TTS_APP_ID / TTS_ACCESS_TOKEN / TTS_SECRET_ID / TTS_SECRET_KEY / TTS_REGION / TTS_CLUSTER / TTS_RESOURCE_ID / TTS_VOICE_DEFAULT / TTS_SPEED_DEFAULT / TTS_TIMEOUT_MS / TTS_CACHE_FILE_MAX_BYTES
* 安全策略RATE_LIMIT_POINTS / RATE_LIMIT_DURATION_SEC
* 会话策略ASSIST_TXN_TTL_MS / ASSIST_TXN_CACHE_LIMIT
* SSH 策略SSH_READY_TIMEOUT_MS / SSH_KEEPALIVE_INTERVAL_MS / SSH_KEEPALIVE_COUNT_MAX / TERMINAL_RESUME_GRACE_DEFAULT_MS / TERMINAL_RESUME_GRACE_MAX_MS
* 插件策略PLUGIN_ONLOAD_TIMEOUT_MS / PLUGIN_ONUNLOAD_TIMEOUT_MS
*/
const schema = z.object({
// ── 网关基础 ─────────────────────────────────────────────────────────────
/** 监听端口(别名 PORT 向下兼容) */
GATEWAY_PORT: z.string().optional(),
PORT: z.string().optional(),
/** 监听地址 */
GATEWAY_HOST: z.string().optional().default("0.0.0.0"),
/** @deprecated 请使用 GATEWAY_HOST */
HOST: z.string().optional(),
/** 访问令牌,仅 Env不可写文件 */
GATEWAY_TOKEN: z.string().min(8).default("remoteconn-dev-token"),
/** CORS Access-Control-Allow-Origin */
GATEWAY_CORS_ORIGIN: z.string().optional().default("*"),
/** 日志级别 */
GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
/** 原始 IO 十六进制调试1 时输出原始帧转储 */
GATEWAY_DEBUG_IO_HEX: z.string().optional().default("0"),
// ── 小程序同步(配置持久化)───────────────────────────────────────────────
/** 微信小程序 AppID用于 code2Session */
MINIPROGRAM_APP_ID: z.string().optional().default(""),
/** 微信小程序 AppSecret仅 Env */
MINIPROGRAM_APP_SECRET: z.string().optional().default(""),
/** 同步 SQLite 文件路径 */
SYNC_SQLITE_PATH: z.string().optional().default("data/remoteconn-sync.db"),
/** 同步敏感字段加密主密钥,仅 Env */
SYNC_SECRET_CURRENT: z.string().optional().default(""),
/** 当前加密密钥版本 */
SYNC_SECRET_VERSION: z.string().optional().default("1"),
/** 同步登录 token 有效期(秒) */
SYNC_TOKEN_TTL_SEC: z.string().optional().default("604800"),
// ── 语音识别(通用)───────────────────────────────────────────────────────
/** 语音供应商标识 */
ASR_PROVIDER: z.string().optional().default("volcengine"),
/** 语音服务 App ID */
ASR_APP_ID: z.string().optional(),
/** 语音服务 Access Token仅 Env */
ASR_ACCESS_TOKEN: z.string().optional(),
/** 语音服务 Secret Key仅 Env */
ASR_SECRET_KEY: z.string().optional(),
/** 语音资源标识ASR 2.0 小时版默认值) */
ASR_RESOURCE_ID: z.string().optional().default("volc.seedasr.sauc.duration"),
/** 集群参数(可选) */
ASR_CLUSTER: z.string().optional(),
/** WebSocket 接入地址 */
ASR_WS_URL: z
.string()
.url()
.optional()
.default("wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"),
/** 是否在 result 帧携带上游原始 payload默认关闭减少传输体积 */
ASR_INCLUDE_RAW_RESULT: z.string().optional().default("0"),
/** 单连接内“空文本结果”告警上限,避免日志风暴 */
ASR_EMPTY_TEXT_WARN_LIMIT: z.string().optional().default("3"),
// ── 语音播报TTS───────────────────────────────────────────────────────
/** TTS 供应商标识 */
TTS_PROVIDER: z.string().optional().default("tencent"),
/** TTS App ID当前腾讯云短文本合成保留配置位未直接参与签名 */
TTS_APP_ID: z.string().optional(),
/** TTS Access TokenVolcengine 使用) */
TTS_ACCESS_TOKEN: z.string().optional(),
/** TTS Secret ID仅 Env */
TTS_SECRET_ID: z.string().optional(),
/** TTS Secret Key仅 Env */
TTS_SECRET_KEY: z.string().optional(),
/** TTS 地域 */
TTS_REGION: z.string().optional().default("ap-guangzhou"),
/** TTS 集群Volcengine 使用) */
TTS_CLUSTER: z.string().optional().default("volcano_tts"),
/** TTS 资源标识(火山 HTTP Chunked/SSE 单向流式 V3 默认值) */
TTS_RESOURCE_ID: z.string().optional().default("volc.service_type.10029"),
/** 默认音色别名 */
TTS_VOICE_DEFAULT: z.string().optional().default("female_v1"),
/** 默认语速 */
TTS_SPEED_DEFAULT: z.string().optional().default("1"),
/** 单次 TTS 请求超时(毫秒) */
TTS_TIMEOUT_MS: z.string().optional().default("30000"),
/** 单个 TTS 音频缓存文件的最大大小(字节) */
TTS_CACHE_FILE_MAX_BYTES: z
.string()
.optional()
.default(String(8 * 1024 * 1024)),
// ── 安全策略(限流)────────────────────────────────────────────────────────
/** 单 IP 在窗口期内最大请求次数 */
RATE_LIMIT_POINTS: z.string().optional().default("30"),
/** 限流计数器重置周期(秒) */
RATE_LIMIT_DURATION_SEC: z.string().optional().default("60"),
// ── 会话策略assist 事务去重缓存)──────────────────────────────────────────
/** 同一事务 ID 在此时间内视为重复(毫秒) */
ASSIST_TXN_TTL_MS: z.string().optional().default("30000"),
/** LRU 缓存最大条目数 */
ASSIST_TXN_CACHE_LIMIT: z.string().optional().default("512"),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
/** 等待 SSH ready 事件的超时时间(毫秒) */
SSH_READY_TIMEOUT_MS: z.string().optional().default("15000"),
/** 心跳包发送间隔(毫秒) */
SSH_KEEPALIVE_INTERVAL_MS: z.string().optional().default("10000"),
/** 连续无响应超过此次数后断开连接 */
SSH_KEEPALIVE_COUNT_MAX: z.string().optional().default("3"),
/** 终端续接驻留默认窗口(毫秒) */
TERMINAL_RESUME_GRACE_DEFAULT_MS: z.string().optional().default("20000"),
/** 终端续接驻留最大窗口(毫秒) */
TERMINAL_RESUME_GRACE_MAX_MS: z.string().optional().default("3600000"),
// ── 插件运行时策略 ────────────────────────────────────────────────────────
/** 单个插件 onLoad 钩子最长执行时间(毫秒) */
PLUGIN_ONLOAD_TIMEOUT_MS: z.string().optional().default("3000"),
/** 单个插件 onUnload 钩子最长执行时间(毫秒) */
PLUGIN_ONUNLOAD_TIMEOUT_MS: z.string().optional().default("3000"),
// ── 向下兼容别名(旧字段,下一个版本窗口删除)────────────────────────────
/** @deprecated 请使用 GATEWAY_CORS_ORIGIN */
CORS_ORIGIN: z.string().optional(),
/** @deprecated 请使用 GATEWAY_DEBUG_IO_HEX */
DEBUG_IO_HEX: z.string().optional()
});
const runtimeFileSchema = z.object({
// ── 网关基础(非敏感)───────────────────────────────────────────────────────
GATEWAY_PORT: z.union([z.string(), z.number()]).optional(),
PORT: z.union([z.string(), z.number()]).optional(),
GATEWAY_HOST: z.string().optional(),
HOST: z.string().optional(),
GATEWAY_CORS_ORIGIN: z.string().optional(),
GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
GATEWAY_DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional(),
ASR_INCLUDE_RAW_RESULT: z.union([z.string(), z.number(), z.boolean()]).optional(),
ASR_EMPTY_TEXT_WARN_LIMIT: z.union([z.string(), z.number()]).optional(),
TTS_PROVIDER: z.string().optional(),
TTS_APP_ID: z.string().optional(),
TTS_ACCESS_TOKEN: z.string().optional(),
TTS_REGION: z.string().optional(),
TTS_CLUSTER: z.string().optional(),
TTS_RESOURCE_ID: z.string().optional(),
TTS_VOICE_DEFAULT: z.string().optional(),
TTS_SPEED_DEFAULT: z.union([z.string(), z.number()]).optional(),
TTS_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
TTS_CACHE_FILE_MAX_BYTES: z.union([z.string(), z.number()]).optional(),
// ── 小程序同步(非敏感)───────────────────────────────────────────────────
MINIPROGRAM_APP_ID: z.string().optional(),
SYNC_SQLITE_PATH: z.string().optional(),
SYNC_SECRET_VERSION: z.union([z.string(), z.number()]).optional(),
SYNC_TOKEN_TTL_SEC: z.union([z.string(), z.number()]).optional(),
// ── 安全策略(限流)────────────────────────────────────────────────────────
RATE_LIMIT_POINTS: z.union([z.string(), z.number()]).optional(),
RATE_LIMIT_DURATION_SEC: z.union([z.string(), z.number()]).optional(),
// ── 会话策略 ─────────────────────────────────────────────────────────────
ASSIST_TXN_TTL_MS: z.union([z.string(), z.number()]).optional(),
ASSIST_TXN_CACHE_LIMIT: z.union([z.string(), z.number()]).optional(),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
SSH_READY_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
SSH_KEEPALIVE_INTERVAL_MS: z.union([z.string(), z.number()]).optional(),
SSH_KEEPALIVE_COUNT_MAX: z.union([z.string(), z.number()]).optional(),
TERMINAL_RESUME_GRACE_DEFAULT_MS: z.union([z.string(), z.number()]).optional(),
TERMINAL_RESUME_GRACE_MAX_MS: z.union([z.string(), z.number()]).optional(),
// ── 插件运行时策略 ────────────────────────────────────────────────────────
PLUGIN_ONLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
PLUGIN_ONUNLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
// ── 向下兼容别名 ─────────────────────────────────────────────────────────
CORS_ORIGIN: z.string().optional(),
DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional()
});
type RuntimeFileConfig = z.infer<typeof runtimeFileSchema>;
function resolveRuntimeConfigPath(): string | null {
const fromEnv = process.env.GATEWAY_RUNTIME_CONFIG_PATH?.trim();
if (fromEnv) return fromEnv;
const candidates = [
path.resolve(process.cwd(), "config/runtime.json"),
"/etc/remoteconn/gateway.runtime.json"
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function scalarToString(value: string | number | boolean): string {
if (typeof value === "boolean") {
return value ? "1" : "0";
}
return String(value);
}
function loadRuntimeFileConfig(): RuntimeFileConfig {
const runtimePath = resolveRuntimeConfigPath();
if (!runtimePath) return {};
try {
const raw = readFileSync(runtimePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
return runtimeFileSchema.parse(parsed);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`invalid runtime config at ${runtimePath}: ${reason}`);
}
}
function toEnvLikeRecord(runtime: RuntimeFileConfig): Record<string, string> {
const entries = Object.entries(runtime)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => [key, scalarToString(value)]);
return Object.fromEntries(entries);
}
/**
* 轻量 .env 解析(不引入 dotenv 依赖):
* - 仅做 K=V 解析与引号去除;
* - 支持 `export KEY=VALUE`
* - 不覆盖已存在的 process.env由合并顺序保证
*/
function parseDotEnv(content: string): Record<string, string> {
const out: Record<string, string> = {};
const lines = content.split(/\r?\n/);
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith("#")) {
continue;
}
const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
const idx = normalized.indexOf("=");
if (idx <= 0) {
continue;
}
const key = normalized.slice(0, idx).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
continue;
}
let value = normalized.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
function loadDotEnvConfig(): Record<string, string> {
const fromEnv = process.env.GATEWAY_ENV_FILE?.trim();
const candidates = [
fromEnv,
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "apps/gateway/.env")
].filter((item): item is string => Boolean(item));
for (const file of candidates) {
if (!existsSync(file)) {
continue;
}
try {
const raw = readFileSync(file, "utf-8");
return parseDotEnv(raw);
} catch {
// 读取失败时跳过,继续尝试其他候选路径。
}
}
return {};
}
const runtimeFileConfig = loadRuntimeFileConfig();
const dotenvConfig = loadDotEnvConfig();
const mergedInput = {
...toEnvLikeRecord(runtimeFileConfig),
...dotenvConfig,
...process.env
};
const env = schema.parse(mergedInput);
function isTtsEnabled(ttsProvider: string): boolean {
const provider = String(ttsProvider || "")
.trim()
.toLowerCase();
if (provider === "volcengine") {
return Boolean((env.TTS_APP_ID ?? "").trim() && (env.TTS_ACCESS_TOKEN ?? "").trim());
}
if (provider === "tencent") {
return Boolean((env.TTS_SECRET_ID ?? "").trim() && (env.TTS_SECRET_KEY ?? "").trim());
}
return false;
}
function parseBool(value: string): boolean {
return /^(1|true|yes|on)$/i.test(value.trim());
}
function parsePositiveInt(value: string, fallback: number): number {
const n = parseInt(value, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const config = {
// ── 网关基础 ─────────────────────────────────────────────────────────────
port: parsePositiveInt(env.GATEWAY_PORT ?? env.PORT ?? "8787", 8787),
host: env.GATEWAY_HOST ?? env.HOST ?? "0.0.0.0",
gatewayToken: env.GATEWAY_TOKEN,
/** 兼容旧字段 CORS_ORIGIN */
corsOrigin: env.GATEWAY_CORS_ORIGIN !== "*" ? env.GATEWAY_CORS_ORIGIN : (env.CORS_ORIGIN ?? "*"),
logLevel: env.GATEWAY_LOG_LEVEL,
/** 兼容旧字段 DEBUG_IO_HEX */
debugIoHex: parseBool(
env.GATEWAY_DEBUG_IO_HEX !== "0" ? env.GATEWAY_DEBUG_IO_HEX : (env.DEBUG_IO_HEX ?? "0")
),
// ── 小程序同步(配置持久化)───────────────────────────────────────────────
sync: {
miniprogramAppId: (env.MINIPROGRAM_APP_ID ?? "").trim(),
miniprogramAppSecret: (env.MINIPROGRAM_APP_SECRET ?? "").trim(),
sqlitePath: (env.SYNC_SQLITE_PATH ?? "data/remoteconn-sync.db").trim(),
secretCurrent: (env.SYNC_SECRET_CURRENT ?? "").trim(),
secretVersion: parsePositiveInt(env.SYNC_SECRET_VERSION ?? "1", 1),
tokenTtlSec: parsePositiveInt(env.SYNC_TOKEN_TTL_SEC ?? "604800", 604800),
enabled: Boolean(
(env.MINIPROGRAM_APP_ID ?? "").trim() &&
(env.MINIPROGRAM_APP_SECRET ?? "").trim() &&
(env.SYNC_SECRET_CURRENT ?? "").trim()
)
},
// ── 安全策略 ─────────────────────────────────────────────────────────────
rateLimitPoints: parsePositiveInt(env.RATE_LIMIT_POINTS, 30),
rateLimitDurationSec: parsePositiveInt(env.RATE_LIMIT_DURATION_SEC, 60),
// ── 会话策略 ─────────────────────────────────────────────────────────────
assistTxnTtlMs: parsePositiveInt(env.ASSIST_TXN_TTL_MS, 30000),
assistTxnCacheLimit: parsePositiveInt(env.ASSIST_TXN_CACHE_LIMIT, 512),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
sshReadyTimeoutMs: parsePositiveInt(env.SSH_READY_TIMEOUT_MS, 15000),
sshKeepaliveIntervalMs: parsePositiveInt(env.SSH_KEEPALIVE_INTERVAL_MS, 10000),
sshKeepaliveCountMax: parsePositiveInt(env.SSH_KEEPALIVE_COUNT_MAX, 3),
terminalResumeGraceDefaultMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_DEFAULT_MS, 20000),
terminalResumeGraceMaxMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_MAX_MS, 60 * 60 * 1000),
// ── 语音识别(通用)───────────────────────────────────────────────────────
asr: {
provider: (env.ASR_PROVIDER ?? "volcengine").trim(),
appId: (env.ASR_APP_ID ?? "").trim(),
accessToken: (env.ASR_ACCESS_TOKEN ?? "").trim(),
secretKey: (env.ASR_SECRET_KEY ?? "").trim(),
resourceId: (env.ASR_RESOURCE_ID ?? "volc.seedasr.sauc.duration").trim(),
cluster: (env.ASR_CLUSTER ?? "").trim(),
wsUrl: (env.ASR_WS_URL ?? "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async").trim(),
includeRawResult: parseBool(env.ASR_INCLUDE_RAW_RESULT ?? "0"),
emptyTextWarnLimit: parsePositiveInt(env.ASR_EMPTY_TEXT_WARN_LIMIT ?? "3", 3)
},
// ── 语音播报TTS───────────────────────────────────────────────────────
tts: {
provider: (env.TTS_PROVIDER ?? "tencent").trim(),
appId: (env.TTS_APP_ID ?? "").trim(),
accessToken: (env.TTS_ACCESS_TOKEN ?? "").trim(),
secretId: (env.TTS_SECRET_ID ?? "").trim(),
secretKey: (env.TTS_SECRET_KEY ?? "").trim(),
region: (env.TTS_REGION ?? "ap-guangzhou").trim() || "ap-guangzhou",
cluster: (env.TTS_CLUSTER ?? "volcano_tts").trim() || "volcano_tts",
resourceId: (env.TTS_RESOURCE_ID ?? "volc.service_type.10029").trim() || "volc.service_type.10029",
voiceDefault: (env.TTS_VOICE_DEFAULT ?? "female_v1").trim() || "female_v1",
speedDefault: Number(env.TTS_SPEED_DEFAULT ?? "1") || 1,
timeoutMs: parsePositiveInt(env.TTS_TIMEOUT_MS ?? "30000", 30000),
cacheFileMaxBytes: parsePositiveInt(
env.TTS_CACHE_FILE_MAX_BYTES ?? String(8 * 1024 * 1024),
8 * 1024 * 1024
),
enabled: isTtsEnabled(env.TTS_PROVIDER ?? "tencent")
},
// ── 插件运行时策略 ────────────────────────────────────────────────────────
pluginOnloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONLOAD_TIMEOUT_MS, 3000),
pluginOnunloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONUNLOAD_TIMEOUT_MS, 3000)
};

View File

@@ -0,0 +1,61 @@
const PREVIEW_CHARS = 48;
const PREVIEW_BYTES = 64;
function toHexBytes(input: Uint8Array): string {
return Array.from(input)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
}
function toHexCodeUnits(input: string, limit = PREVIEW_CHARS): string {
const parts: string[] = [];
const max = Math.min(input.length, limit);
for (let i = 0; i < max; i += 1) {
parts.push(input.charCodeAt(i).toString(16).padStart(4, "0"));
}
return parts.join(" ");
}
function toHexLatin1Bytes(input: string, limit = PREVIEW_CHARS): string {
const parts: string[] = [];
const max = Math.min(input.length, limit);
for (let i = 0; i < max; i += 1) {
const code = input.charCodeAt(i);
if (code <= 0xff) {
parts.push(code.toString(16).padStart(2, "0"));
} else {
parts.push("..");
}
}
return parts.join(" ");
}
/**
* 生成字符串的十六进制调试快照,定位“哪一段链路改坏了字节”。
* - utf8Hex: 按 UTF-8 编码后的字节;
* - latin1Hex: 将每个 code unit 视作单字节(仅 0x00-0xFF 有效);
* - codeUnitHex: 原始 JS code unit16 进制)。
*/
export function inspectStringHex(data: string): Record<string, string | number> {
const utf8Bytes = Buffer.from(data, "utf8");
return {
charLen: data.length,
utf8ByteLen: utf8Bytes.length,
preview: JSON.stringify(data.slice(0, PREVIEW_CHARS)),
codeUnitHex: toHexCodeUnits(data),
latin1Hex: toHexLatin1Bytes(data),
utf8Hex: toHexBytes(utf8Bytes.subarray(0, PREVIEW_BYTES))
};
}
/**
* 生成二进制缓冲区的十六进制快照,用于原始字节层排障。
*/
export function inspectBufferHex(data: Buffer | Uint8Array): Record<string, string | number> {
const bytes = Buffer.from(data);
return {
byteLen: bytes.length,
utf8Preview: JSON.stringify(bytes.toString("utf8").slice(0, PREVIEW_CHARS)),
hex: toHexBytes(bytes.subarray(0, PREVIEW_BYTES))
};
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { createTerminalCaptureArmStore } from "./terminalCaptureArm";
describe("terminalCaptureArm", () => {
it("会拒绝没有匹配条件的录制规则", () => {
const store = createTerminalCaptureArmStore();
expect(() =>
store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 60_000
})
).toThrow(/至少需要提供一个匹配条件/);
});
it("命中后会一次性取走规则", () => {
const store = createTerminalCaptureArmStore();
const rule = store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 60_000,
clientSessionKey: "mini-session-1",
host: "example.com",
username: "gavin"
});
const matched = store.take({
ip: "127.0.0.1",
clientSessionKey: "mini-session-1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(matched?.id).toBe(rule.id);
expect(store.list()).toEqual([]);
});
it("过期规则不会再命中", () => {
const nowSpy = vi.spyOn(Date, "now");
nowSpy.mockReturnValue(1_000);
const store = createTerminalCaptureArmStore();
store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 1_000,
username: "gavin"
});
nowSpy.mockReturnValue(2_500);
const matched = store.take({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(matched).toBeNull();
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
export interface ArmedTerminalCaptureRule {
id: string;
createdAt: number;
expiresAt: number;
captureDir: string;
ip?: string;
clientSessionKey?: string;
host?: string;
port?: number;
username?: string;
}
export interface TerminalCaptureArmInput {
captureDir: string;
ttlMs: number;
ip?: string;
clientSessionKey?: string;
host?: string;
port?: number;
username?: string;
}
export interface TerminalCaptureMatchTarget {
ip: string;
clientSessionKey?: string;
host: string;
port: number;
username: string;
}
export interface TerminalCaptureArmStore {
arm(input: TerminalCaptureArmInput): ArmedTerminalCaptureRule;
take(target: TerminalCaptureMatchTarget): ArmedTerminalCaptureRule | null;
list(): ArmedTerminalCaptureRule[];
}
const DEFAULT_TTL_MS = 5 * 60 * 1000;
const MAX_TTL_MS = 60 * 60 * 1000;
function normalizeString(value: unknown): string {
return String(value || "").trim();
}
function normalizeOptionalString(value: unknown): string | undefined {
const next = normalizeString(value);
return next ? next : undefined;
}
function normalizePort(value: unknown): number | undefined {
const next = Number(value);
if (!Number.isFinite(next) || next <= 0) {
return undefined;
}
return Math.round(next);
}
function normalizeTtlMs(value: unknown): number {
const next = Number(value);
if (!Number.isFinite(next) || next <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(Math.max(Math.round(next), 1_000), MAX_TTL_MS);
}
function hasMatchConstraint(rule: Omit<ArmedTerminalCaptureRule, "id" | "createdAt" | "expiresAt">): boolean {
return Boolean(rule.ip || rule.clientSessionKey || rule.host || rule.port || rule.username);
}
function matchesRule(rule: ArmedTerminalCaptureRule, target: TerminalCaptureMatchTarget): boolean {
if (rule.ip && rule.ip !== target.ip) {
return false;
}
if (rule.clientSessionKey && rule.clientSessionKey !== normalizeString(target.clientSessionKey)) {
return false;
}
if (rule.host && rule.host !== target.host) {
return false;
}
if (rule.port && rule.port !== target.port) {
return false;
}
if (rule.username && rule.username !== target.username) {
return false;
}
return true;
}
export function createTerminalCaptureArmStore(): TerminalCaptureArmStore {
const rules = new Map<string, ArmedTerminalCaptureRule>();
const cleanupExpired = (): void => {
const now = Date.now();
for (const [id, rule] of rules.entries()) {
if (rule.expiresAt <= now) {
rules.delete(id);
}
}
};
return {
arm(input) {
cleanupExpired();
const captureDir = normalizeString(input.captureDir);
if (!captureDir) {
throw new Error("captureDir 不能为空");
}
const normalizedRule = {
captureDir,
ip: normalizeOptionalString(input.ip),
clientSessionKey: normalizeOptionalString(input.clientSessionKey),
host: normalizeOptionalString(input.host),
port: normalizePort(input.port),
username: normalizeOptionalString(input.username)
};
if (!hasMatchConstraint(normalizedRule)) {
throw new Error("至少需要提供一个匹配条件ip/clientSessionKey/host/port/username");
}
const now = Date.now();
const rule: ArmedTerminalCaptureRule = {
id: randomUUID(),
createdAt: now,
expiresAt: now + normalizeTtlMs(input.ttlMs),
...normalizedRule
};
rules.set(rule.id, rule);
return rule;
},
take(target) {
cleanupExpired();
for (const [id, rule] of rules.entries()) {
if (!matchesRule(rule, target)) {
continue;
}
rules.delete(id);
return rule;
}
return null;
},
list() {
cleanupExpired();
return Array.from(rules.values()).sort((left, right) => left.createdAt - right.createdAt);
}
};
}

View File

@@ -0,0 +1,78 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTerminalFrameCaptureRecorder } from "./terminalFrameCapture";
const ORIGINAL_CAPTURE_DIR = process.env.RC_TERMINAL_CAPTURE_DIR;
afterEach(() => {
if (ORIGINAL_CAPTURE_DIR === undefined) {
delete process.env.RC_TERMINAL_CAPTURE_DIR;
} else {
process.env.RC_TERMINAL_CAPTURE_DIR = ORIGINAL_CAPTURE_DIR;
}
});
describe("terminalFrameCapture", () => {
it("未配置目录时不会开启录制", () => {
delete process.env.RC_TERMINAL_CAPTURE_DIR;
const recorder = createTerminalFrameCaptureRecorder({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(recorder).toBeNull();
});
it("会把 meta、frame 和 close 事件写入 jsonl 文件", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "remoteconn-capture-"));
process.env.RC_TERMINAL_CAPTURE_DIR = tempDir;
const recorder = createTerminalFrameCaptureRecorder({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin",
clientSessionKey: "session-1"
});
expect(recorder).not.toBeNull();
recorder?.record("stdin", "hello\n");
recorder?.record("stdout", "world\r\n");
recorder?.close("shell_closed");
const filePath = recorder?.filePath;
expect(filePath).toBeTruthy();
const lines = fs
.readFileSync(String(filePath), "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(lines[0]).toMatchObject({
kind: "meta",
host: "example.com",
username: "gavin",
clientSessionKey: "session-1"
});
expect(lines[1]).toMatchObject({
kind: "frame",
type: "stdin",
data: "hello\n"
});
expect(lines[2]).toMatchObject({
kind: "frame",
type: "stdout",
data: "world\r\n"
});
expect(lines[3]).toMatchObject({
kind: "close",
reason: "shell_closed"
});
fs.rmSync(tempDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
export type TerminalCaptureFrameType = "stdin" | "stdout" | "stderr";
export interface TerminalFrameCaptureRecorder {
filePath: string;
record(type: TerminalCaptureFrameType, data: string): void;
close(reason?: string): void;
}
interface TerminalFrameCaptureMeta {
ip: string;
host: string;
port: number;
username: string;
clientSessionKey?: string;
captureDir?: string;
}
function sanitizePathToken(input: string): string {
return String(input || "")
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "") || "unknown";
}
function resolveCaptureDir(preferredDir?: string): string {
return String(preferredDir || process.env.RC_TERMINAL_CAPTURE_DIR || "").trim();
}
export function createTerminalFrameCaptureRecorder(
meta: TerminalFrameCaptureMeta
): TerminalFrameCaptureRecorder | null {
const captureDir = resolveCaptureDir(meta.captureDir);
if (!captureDir) {
return null;
}
fs.mkdirSync(captureDir, { recursive: true });
const startedAt = Date.now();
const fileName = [
new Date(startedAt).toISOString().replace(/[:.]/g, "-"),
sanitizePathToken(meta.username),
"@",
sanitizePathToken(meta.host),
`-${Number(meta.port) || 22}`,
`-${randomUUID().slice(0, 8)}.jsonl`
].join("");
const filePath = path.join(captureDir, fileName);
let closed = false;
const writeLine = (payload: Record<string, unknown>): void => {
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
};
writeLine({
kind: "meta",
version: 1,
startedAt,
ip: meta.ip,
host: meta.host,
port: meta.port,
username: meta.username,
clientSessionKey: meta.clientSessionKey || ""
});
return {
filePath,
record(type, data) {
if (closed || !data) {
return;
}
const now = Date.now();
writeLine({
kind: "frame",
at: now,
offsetMs: Math.max(0, now - startedAt),
type,
data
});
},
close(reason) {
if (closed) {
return;
}
closed = true;
writeLine({
kind: "close",
at: Date.now(),
offsetMs: Math.max(0, Date.now() - startedAt),
reason: reason || ""
});
}
};
}

View File

@@ -0,0 +1,9 @@
import { config } from "./config";
import { logger } from "./logger";
import { createGatewayServer } from "./server";
const server = createGatewayServer();
server.listen(config.port, config.host, () => {
logger.info({ host: config.host, port: config.port }, "gateway started");
});

View File

@@ -0,0 +1,32 @@
import pino from "pino";
/**
* 将日志时间格式化为紧凑数字串:
* - 目标格式YYYYMMDDHHmmssSSSS
* - 字段含义:年(4)月(2)日(2)时(2)分(2)秒(2)毫秒(4)
*
* 说明:
* 1) JavaScript 原生毫秒精度是 0-999三位
* 2) 这里按产品约定扩展为四位:`毫秒三位 + 末尾补 0`,例如 `120 -> 1200`
* 3) 使用本地时区,便于和用户界面展示时间对齐。
*/
function formatCompactLocalTime(now: Date): string {
const year = String(now.getFullYear());
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hour = String(now.getHours()).padStart(2, "0");
const minute = String(now.getMinutes()).padStart(2, "0");
const second = String(now.getSeconds()).padStart(2, "0");
const millisecond4 = `${String(now.getMilliseconds()).padStart(3, "0")}0`;
return `${year}${month}${day}${hour}${minute}${second}${millisecond4}`;
}
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
/**
* 将默认 epoch 毫秒时间戳(如 1771821864018改为业务约定数字格式
* - 输出示例:`"time":202602231245101200`
* - 仍使用 `time` 字段,避免破坏现有日志消费方字段约定。
*/
timestamp: () => `,"time":${formatCompactLocalTime(new Date())}`
});

View File

@@ -0,0 +1,15 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import { config } from "../config";
/**
* 连接限流:同一 IP 每分钟限制一定连接次数,防止爆破和资源耗尽。
* 参数由 config.rateLimitPoints / config.rateLimitDurationSec 控制。
*/
const limiter = new RateLimiterMemory({
points: config.rateLimitPoints,
duration: config.rateLimitDurationSec
});
export async function checkConnectionRate(ip: string): Promise<void> {
await limiter.consume(ip || "unknown", 1);
}

1557
apps/gateway/src/server.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { connectSpy, streamWriteSpy } = vi.hoisted(() => ({
connectSpy: vi.fn(),
streamWriteSpy: vi.fn()
}));
let capturedStream: MockChannel | null = null;
class MockChannel extends EventEmitter {
public stderr = {
on: (_event: string, _listener: (chunk: Buffer) => void) => {
return this.stderr;
}
};
public write(data: string | Buffer): void {
streamWriteSpy(data);
}
public setWindow(_rows: number, _cols: number, _height: number, _width: number): void {
// no-op for tests
}
public close(): void {
this.emit("close");
}
}
class MockForwardStream extends EventEmitter {}
vi.mock("ssh2", () => ({
Client: class MockClient extends EventEmitter {
public connect(config: unknown): this {
connectSpy(config);
const host = (config as { host?: string })?.host;
if (host === "ready-host" || host === "jump-host") {
queueMicrotask(() => this.emit("ready"));
} else {
queueMicrotask(() => this.emit("error", new Error("mock connect failed")));
}
return this;
}
public shell(
_options: unknown,
callback: (shellError: Error | undefined, stream: MockChannel) => void
): void {
const ch = new MockChannel();
capturedStream = ch;
callback(undefined, ch);
}
public forwardOut(
_srcIP: string,
_srcPort: number,
_dstIP: string,
_dstPort: number,
callback: (error: Error | undefined, stream: MockForwardStream) => void
): void {
callback(undefined, new MockForwardStream());
}
public end(): void {
// 模拟 ssh2 客户端 end 行为,无需实际动作。
}
}
}));
import { createSshSession, encodeInputForSsh, INIT_BEGIN_FOR_TEST, INIT_DONE_FOR_TEST } from "./sshSession";
describe("sshSession", () => {
beforeEach(() => {
connectSpy.mockReset();
streamWriteSpy.mockReset();
capturedStream = null;
});
it("密码认证显式优先 keyboard-interactive 再回退 password", async () => {
await expect(
createSshSession({
host: "127.0.0.1",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
})
).rejects.toThrow("mock connect failed");
expect(connectSpy).toHaveBeenCalledTimes(1);
const config = connectSpy.mock.calls[0]?.[0] as {
authHandler?: Array<{ type: string; username: string; password?: string; prompt?: (...args: unknown[]) => void }>;
tryKeyboard?: boolean;
password?: string;
};
expect(config.password).toBe("secret");
expect(config.tryKeyboard).toBe(true);
expect(config.authHandler).toHaveLength(2);
expect(config.authHandler?.[0]?.type).toBe("keyboard-interactive");
expect(config.authHandler?.[0]?.username).toBe("gavin");
expect(typeof config.authHandler?.[0]?.prompt).toBe("function");
expect(config.authHandler?.[1]).toEqual({
type: "password",
username: "gavin",
password: "secret"
});
});
it("私钥认证不应注入密码认证策略", async () => {
await expect(
createSshSession({
host: "127.0.0.1",
port: 22,
username: "gavin",
credential: { type: "privateKey", privateKey: "mock-key" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
})
).rejects.toThrow("mock connect failed");
expect(connectSpy).toHaveBeenCalledTimes(1);
const config = connectSpy.mock.calls[0]?.[0] as { authHandler?: string[]; privateKey?: string };
expect(config.privateKey).toBe("mock-key");
expect(config.authHandler).toBeUndefined();
});
it("字节串样式输入应按 latin1 还原为原始字节", () => {
const byteString = "\u00e4\u00b8\u00ad\u00e6\u0096\u0087";
const encoded = encodeInputForSsh(byteString);
expect(typeof encoded).toBe("string");
expect(encoded).toBe("中文");
});
it("普通 Unicode 输入应保持字符串,避免误判", () => {
const encoded = encodeInputForSsh("中文");
expect(typeof encoded).toBe("string");
expect(encoded).toBe("中文");
});
it("混合输入Unicode + 字节串)应归一为正确 Unicode 字符串", () => {
const mixed = "测\u00e8\u00af\u0095";
const encoded = encodeInputForSsh(mixed);
expect(typeof encoded).toBe("string");
expect(encoded).toBe("测试");
});
it("非法高位噪声应过滤 C1 控制字符", () => {
const encoded = encodeInputForSsh("\u008b\u0095");
expect(typeof encoded).toBe("string");
expect(encoded).toBe("");
});
it("gateway 建链后应分三次写入w1=stty-echo+BEGIN, w2=init, w3=stty+echo+DONE", async () => {
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
});
// 独立三次写入,确保 BEGIN/DONE 即使 init 失败也一定发出
expect(streamWriteSpy).toHaveBeenCalledTimes(3);
const w1 = Buffer.isBuffer(streamWriteSpy.mock.calls[0]?.[0])
? (streamWriteSpy.mock.calls[0][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[0]?.[0]);
const w2 = Buffer.isBuffer(streamWriteSpy.mock.calls[1]?.[0])
? (streamWriteSpy.mock.calls[1][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[1]?.[0]);
const w3 = Buffer.isBuffer(streamWriteSpy.mock.calls[2]?.[0])
? (streamWriteSpy.mock.calls[2][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[2]?.[0]);
// w1: 关回显 + BEGIN 哨兵
expect(w1).toContain("stty -echo");
expect(w1).toContain("RCSBEGIN");
// w2: shell 初始化,不含 ${VAR:-} 语法,兼容 bash/dash
expect(w2).toContain("setopt MULTIBYTE PRINT_EIGHT_BIT");
expect(w2).not.toContain("${"); // 不用 parameter expansion 默认值语法
// w3: 开回显 + DONE 哨兵
expect(w3).toContain("stty echo");
expect(w3).toContain("RCSDONE");
});
it("配置跳板机后应先连接 jump再通过 sock 连接 target", async () => {
await createSshSession({
host: "ready-host",
port: 22,
username: "target-user",
credential: { type: "password", password: "target-secret" },
jumpHost: {
host: "jump-host",
port: 2222,
username: "jump-user",
credential: { type: "privateKey", privateKey: "jump-key" }
},
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
});
expect(connectSpy).toHaveBeenCalledTimes(2);
const jumpConfig = connectSpy.mock.calls[0]?.[0] as { host?: string; username?: string; privateKey?: string };
const targetConfig = connectSpy.mock.calls[1]?.[0] as {
host?: string;
username?: string;
password?: string;
sock?: unknown;
};
expect(jumpConfig.host).toBe("jump-host");
expect(jumpConfig.username).toBe("jump-user");
expect(jumpConfig.privateKey).toBe("jump-key");
expect(targetConfig.host).toBe("ready-host");
expect(targetConfig.username).toBe("target-user");
expect(targetConfig.password).toBe("target-secret");
expect(targetConfig.sock).toBeTruthy();
});
it("BEGIN 前的内容Last login保留BEGIN→DONE 之间丢弃DONE 后正常转发", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
// Last loginsshd 在 shell 启动前输出)
ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:20:19 2026\r\n", "utf8"));
expect(received).toHaveLength(0); // BEGIN 未到,缓冲中
// BEGIN 哨兵 + 命令回显init 命令被 zsh echo
// 真实场景echo 输出为 SENTINEL\r\n命令回显里 sentinel 后面跟 '
const beginLine = Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8");
ch.emit("data", beginLine.subarray(0, 3));
ch.emit("data", Buffer.concat([
beginLine.subarray(3),
Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE...\r\n", "utf8")
]));
// BEGIN 之前的 Last login 应已转发
expect(received.join("")).toContain("Last login:");
// init 命令回显在 BEGIN 之后,在丢弃区内
expect(received.join("")).not.toContain("setopt MULTIBYTE");
const prevLen = received.length;
// DONE 哨兵(分两个 chunk 验证跨 chunkSENTINEL\r\n 中途截断)
const doneLine = Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8");
ch.emit("data", doneLine.subarray(0, 3));
expect(received.length).toBe(prevLen); // DONE 未完整,仍丢弃
ch.emit("data", Buffer.concat([
doneLine.subarray(3),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:"); // 保留
expect(output).not.toContain("setopt MULTIBYTE"); // init 回显已丢弃
expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发
});
it("rawBefore 末尾含命令回显prompt + stty -echo...)时,仅保留 banner丢弃回显行", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
// 真实 SSH 场景banner → 命令回显 → BEGIN
// sshd banner
ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:00:00 2026\r\n\r\n", "utf8"));
// PTY 对 W1 的 echo提示符 + 命令 + CRLF由 PTY ONLCR 添加)
ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8"));
// BEGIN 哨兵本体echo 输出为 SENTINEL\r\nPTY ONLCR
ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8"));
// W2 回显(在丢弃区内)
ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\r\n", "utf8"));
// DONE 哨兵命令回显sentinel 后跟 '+ 实际 echo 输出sentinel + \r\n
// 这正是产生 bug 的场景:命令回显里有 __RCSDONE_7f3a__' ,实际输出是 __RCSDONE_7f3a__\r\n
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.concat([
Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8"),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:"); // banner 保留
expect(output).not.toContain("stty -echo"); // 命令回显被丢弃
expect(output).not.toContain("setopt MULTIBYTE"); // init 回显在丢弃区内
expect(output).not.toContain("__RCSDONE_7f3a__"); // DONE 哨兵本身不可见
expect(output).not.toContain("stty echo"); // W3 命令回显不可见
expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发
});
it("哨兵行仅为 LF 时,仍应过滤内部初始化命令", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:07:16 2026 from 202.96.99.162\n", "utf8"));
ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\n", "utf8"));
ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\n", "utf8"));
ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\n", "utf8"));
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\n", "utf8"));
ch.emit("data", Buffer.concat([
Buffer.from(INIT_DONE_FOR_TEST + "\n", "utf8"),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:");
expect(output).toContain("gavin mini ~ %");
expect(output).not.toContain("stty -echo");
expect(output).not.toContain("setopt MULTIBYTE");
expect(output).not.toContain("stty echo");
expect(output).not.toContain("__RCSBEGIN_7f3a__");
expect(output).not.toContain("__RCSDONE_7f3a__");
});
it("BEGIN/DONE 未命中并超时时,仍应兜底清理内部初始化命令", async () => {
const received: string[] = [];
vi.useFakeTimers();
try {
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
ch.emit("data", Buffer.from("Activate the web console with: systemctl enable --now cockpit.socket\r\n", "utf8"));
ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:20:32 2026 from 115.193.12.66\r\n", "utf8"));
ch.emit("data", Buffer.from("stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null; unsetopt PROMPT_SP 2>/dev/null; PROMPT_EOL_MARK=''\r\n", "utf8"));
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.from("[gavin@kvm-douboer ~]$ ", "utf8"));
await vi.advanceTimersByTimeAsync(3100);
const output = received.join("");
expect(output).toContain("Activate the web console");
expect(output).toContain("Last login:");
expect(output).toContain("[gavin@kvm-douboer ~]$");
expect(output).not.toContain("stty -echo; echo");
expect(output).not.toContain("stty iutf8");
expect(output).not.toContain("setopt MULTIBYTE");
expect(output).not.toContain("PROMPT_EOL_MARK");
expect(output).not.toContain("stty echo; echo");
expect(output).not.toContain("__RCSBEGIN_7f3a__");
expect(output).not.toContain("__RCSDONE_7f3a__");
} finally {
vi.useRealTimers();
}
});
it("主动 close 后不应重复触发 onClose避免 switch 日志重复)", async () => {
const closeReasons: string[] = [];
const session = await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: (reason) => closeReasons.push(reason)
});
session.close("switch");
expect(closeReasons).toEqual(["switch"]);
});
});

View File

@@ -0,0 +1,517 @@
import { Client, type ClientChannel, type ConnectConfig } from "ssh2";
import { StringDecoder } from "node:string_decoder";
import { config } from "../config";
interface SshHopOptions {
host: string;
port: number;
username: string;
credential:
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
knownHostFingerprint?: string;
}
export interface SshConnectOptions {
host: string;
port: number;
username: string;
credential:
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
jumpHost?: SshHopOptions;
pty: { cols: number; rows: number };
knownHostFingerprint?: string;
onHostFingerprint?: (payload: { fingerprint: string; hostPort: string; role: "target" | "jump" }) => void;
onStdout: (data: string) => void;
onStderr: (data: string) => void;
onClose: (reason: string) => void;
}
export interface ActiveSshSession {
write(data: string, traceId?: number): void;
resize(cols: number, rows: number): void;
close(reason?: string): void;
}
/**
* 初始化哨兵:用于标记“静默初始化命令”的开始与结束。
*/
const INIT_BEGIN = "__RCSBEGIN_7f3a__";
const INIT_DONE = "__RCSDONE_7f3a__";
/**
* 初始化命令分三段写入:
* 1) 关闭回显 + BEGIN 哨兵
* 2) shell 兼容初始化
* 3) 打开回显 + DONE 哨兵
*/
const SHELL_INIT_W1 = "stty -echo; echo '__RCSBEGIN_7f3a__'\r";
const SHELL_INIT_W2 = [
"stty iutf8 2>/dev/null",
'; [ -z "$LANG" ] && export LANG=zh_CN.UTF-8',
'; [ -z "$LC_CTYPE" ] && export LC_CTYPE=zh_CN.UTF-8',
'; [ -z "$LC_ALL" ] && export LC_ALL=zh_CN.UTF-8',
"; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null",
"; unsetopt PROMPT_SP 2>/dev/null",
"; PROMPT_EOL_MARK='' 2>/dev/null"
].join("") + "\r";
const SHELL_INIT_W3 = "stty echo; echo '__RCSDONE_7f3a__'\r";
/**
* 这些特征行一旦出现在 stdout说明是网关内部初始化泄漏必须过滤。
*/
const INIT_LEAK_PATTERNS = [
"stty -echo; echo '__RCSBEGIN_7f3a__'",
"stty iutf8 2>/dev/null",
"setopt MULTIBYTE PRINT_EIGHT_BIT",
"unsetopt PROMPT_SP",
"PROMPT_EOL_MARK=''",
"stty echo; echo '__RCSDONE_7f3a__'",
INIT_BEGIN,
INIT_DONE
];
/** @internal 仅供测试调用。 */
export const INIT_BEGIN_FOR_TEST = INIT_BEGIN;
/** @internal 仅供测试调用。 */
export const INIT_DONE_FOR_TEST = INIT_DONE;
interface SentinelMatch {
index: number;
end: number;
}
/**
* 输入去噪:去除 C1 控制字符0x80-0x9F
*/
function stripC1Controls(data: string): string {
return data.replace(/[\u0080-\u009F]/g, "");
}
/**
* 判断字节串是否可无损按 UTF-8 解释。
*/
function isLosslessUtf8Bytes(bytes: Buffer): boolean {
const decoded = bytes.toString("utf8");
if (decoded.includes("\uFFFD")) {
return false;
}
return Buffer.from(decoded, "utf8").equals(bytes);
}
function decodeByteRun(bytes: number[]): string {
if (bytes.length === 0) {
return "";
}
const buf = Buffer.from(bytes);
if (isLosslessUtf8Bytes(buf)) {
return buf.toString("utf8");
}
return String.fromCharCode(...bytes.filter((b) => (b >= 0x20 && b <= 0x7e) || b === 0x09));
}
/**
* 混合输入归一:宽字符原样保留,单字节段按 UTF-8 尝试解码。
*/
function normalizeMixedInput(data: string): string {
let output = "";
let byteRun: number[] = [];
const flushByteRun = (): void => {
output += decodeByteRun(byteRun);
byteRun = [];
};
for (const ch of data) {
const codePoint = ch.codePointAt(0) ?? 0;
if (codePoint <= 0xff) {
byteRun.push(codePoint);
continue;
}
flushByteRun();
output += ch;
}
flushByteRun();
return output;
}
/**
* 将前端输入转换为 SSH 可写内容。
*/
export function encodeInputForSsh(data: string): string {
let hasHighByte = false;
let hasWideChar = false;
for (let i = 0; i < data.length; i += 1) {
const code = data.charCodeAt(i);
if (code > 0xff) {
hasWideChar = true;
continue;
}
if (code >= 0x80) {
hasHighByte = true;
}
}
if (hasWideChar) {
return normalizeMixedInput(data).replace(/\uFFFD/g, "");
}
if (!hasHighByte) {
return data;
}
const bytes = Buffer.from(data, "latin1");
if (isLosslessUtf8Bytes(bytes)) {
return bytes.toString("utf8");
}
return stripC1Controls(data);
}
/**
* 匹配哨兵真实输出行SENTINEL + \n 或 \r\n
* 注意:不会误匹配命令回显里的 `echo '__RCS...__'`(后面是单引号)。
*/
function findSentinelLine(buffer: string, sentinel: string): SentinelMatch | null {
let from = 0;
while (from < buffer.length) {
const index = buffer.indexOf(sentinel, from);
if (index < 0) {
return null;
}
const next = buffer[index + sentinel.length];
const nextNext = buffer[index + sentinel.length + 1];
if (next === "\n") {
return { index, end: index + sentinel.length + 1 };
}
if (next === "\r" && nextNext === "\n") {
return { index, end: index + sentinel.length + 2 };
}
// 落在 chunk 边界,等下个 chunk 再判定。
if (next === undefined || (next === "\r" && nextNext === undefined)) {
return null;
}
from = index + 1;
}
return null;
}
/**
* 兜底清理初始化泄漏内容,保留 banner 与提示符。
*/
function sanitizeInitLeakOutput(text: string): string {
return text
.split(/(\r\n|\n|\r)/)
.reduce((acc, current, index, parts) => {
if (index % 2 === 1) {
return acc;
}
const shouldDrop = INIT_LEAK_PATTERNS.some((pattern) => current.includes(pattern));
if (shouldDrop) {
return acc;
}
return acc + current + (parts[index + 1] ?? "");
}, "");
}
/**
* 统一可读错误信息,便于前端提示用户。
*/
function normalizeSshError(error: Error): Error {
const raw = String(error.message ?? "");
if (raw.includes("All configured authentication methods failed")) {
return new Error(
`SSH 认证失败:用户名/密码不正确,或目标服务器要求额外认证(如 publickey + keyboard-interactive 多因子)。原始错误: ${raw}`
);
}
if (raw.includes("Timed out while waiting for handshake")) {
return new Error(`SSH 握手超时,请检查目标主机连通性与端口(原始错误: ${raw}`);
}
return error;
}
function toPrivateKeyPayload(
credential: Extract<SshHopOptions["credential"], { type: "privateKey" | "certificate" }>
): string {
if (credential.type === "certificate") {
return `${credential.privateKey}\n${credential.certificate}`;
}
return credential.privateKey;
}
/**
* 统一构造 ssh2 connect 配置:
* - 密码模式优先 keyboard-interactive再回退 password
* - 私钥/证书模式直接走 publickey
* - hostVerifier 在每个 hop 独立上报指纹,便于前端分别确认。
*/
function buildSshConnectConfig(
hop: SshHopOptions,
onHostFingerprint: SshConnectOptions["onHostFingerprint"],
role: "target" | "jump",
sock?: ConnectConfig["sock"]
): ConnectConfig {
const hostPort = `${hop.host}:${hop.port}`;
const baseConfig: ConnectConfig = {
host: hop.host,
port: hop.port,
username: hop.username,
readyTimeout: config.sshReadyTimeoutMs,
keepaliveInterval: config.sshKeepaliveIntervalMs,
keepaliveCountMax: config.sshKeepaliveCountMax,
hostHash: "sha256",
...(sock ? { sock } : {}),
hostVerifier: (keyHash: string) => {
onHostFingerprint?.({ fingerprint: keyHash, hostPort, role });
return !hop.knownHostFingerprint || keyHash === hop.knownHostFingerprint;
}
};
if (hop.credential.type === "password") {
const authPassword = hop.credential.password;
const authHandler = [
{
type: "keyboard-interactive",
username: hop.username,
prompt(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo?: boolean }>,
finish: (responses: string[]) => void
) {
finish(prompts.map(() => authPassword));
}
},
{ type: "password", username: hop.username, password: authPassword }
] as unknown as ConnectConfig["authHandler"];
return {
...baseConfig,
password: authPassword,
tryKeyboard: true,
authHandler
};
}
return {
...baseConfig,
privateKey: toPrivateKeyPayload(hop.credential),
passphrase: hop.credential.passphrase
};
}
/**
* 建立 SSH 会话并返回可操作句柄。
* 仅保留已验证有效的链路:双哨兵过滤 + 超时兜底净化。
*/
export async function createSshSession(options: SshConnectOptions): Promise<ActiveSshSession> {
const targetConn = new Client();
const jumpConn = options.jumpHost ? new Client() : null;
return await new Promise<ActiveSshSession>((resolve, reject) => {
let streamRef: {
write: (data: string | Buffer) => void;
setWindow: (rows: number, cols: number, height: number, width: number) => void;
close: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => void;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => void };
} | null = null;
let closeReported = false;
/**
* 关闭原因只允许上报一次,避免 `close()` / `stream.close` / `conn.close`
* 多路并发触发导致日志重复。
*/
const reportCloseOnce = (reason: string): void => {
if (closeReported) {
return;
}
closeReported = true;
options.onClose(reason);
};
const onError = (error: Error): void => {
reject(normalizeSshError(error));
targetConn.end();
jumpConn?.end();
};
const openShell = (): void => {
targetConn.shell(
{ cols: options.pty.cols, rows: options.pty.rows, term: "xterm-256color", modes: { ECHO: 0 } },
(shellError: Error | undefined, stream: ClientChannel) => {
if (shellError) {
onError(shellError);
return;
}
streamRef = stream as typeof streamRef;
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let initState: "waiting_begin" | "waiting_done" | "done" = "waiting_begin";
let initBuffer = "";
const initTimeoutHandle = setTimeout(() => {
if (initState !== "done") {
initState = "done";
const sanitized = sanitizeInitLeakOutput(initBuffer);
if (sanitized) {
options.onStdout(sanitized);
}
initBuffer = "";
}
}, 3000);
stream.on("data", (chunk: Buffer) => {
const text = stdoutDecoder.write(chunk);
if (!text) {
return;
}
if (initState === "done") {
options.onStdout(text);
return;
}
initBuffer += text;
if (initState === "waiting_begin") {
const beginMatch = findSentinelLine(initBuffer, INIT_BEGIN);
if (!beginMatch) {
return;
}
const rawBefore = initBuffer.slice(0, beginMatch.index);
const echoIndex = rawBefore.lastIndexOf("stty -echo");
const newlineBeforeEcho = echoIndex >= 0 ? rawBefore.lastIndexOf("\n", echoIndex) : -1;
const before =
echoIndex >= 0 ? (newlineBeforeEcho >= 0 ? rawBefore.slice(0, newlineBeforeEcho + 1) : "") : rawBefore;
initBuffer = initBuffer.slice(beginMatch.end);
initState = "waiting_done";
const sanitizedBefore = sanitizeInitLeakOutput(before);
if (sanitizedBefore) {
options.onStdout(sanitizedBefore);
}
}
if (initState === "waiting_done") {
const doneMatch = findSentinelLine(initBuffer, INIT_DONE);
if (!doneMatch) {
const keepTail = INIT_DONE.length + 2;
if (initBuffer.length > keepTail) {
initBuffer = initBuffer.slice(-keepTail);
}
return;
}
const after = sanitizeInitLeakOutput(initBuffer.slice(doneMatch.end));
initBuffer = "";
initState = "done";
clearTimeout(initTimeoutHandle);
if (after) {
options.onStdout(after);
}
}
});
stream.stderr.on("data", (chunk: Buffer) => {
const text = stderrDecoder.write(chunk);
if (text) {
options.onStderr(text);
}
});
try {
stream.write(Buffer.from(SHELL_INIT_W1, "utf8"));
stream.write(Buffer.from(SHELL_INIT_W2, "utf8"));
stream.write(Buffer.from(SHELL_INIT_W3, "utf8"));
} catch {
clearTimeout(initTimeoutHandle);
initState = "done";
}
stream.on("close", () => {
clearTimeout(initTimeoutHandle);
const remainOut = stdoutDecoder.end();
if (remainOut) {
options.onStdout(remainOut);
}
const remainErr = stderrDecoder.end();
if (remainErr) {
options.onStderr(remainErr);
}
reportCloseOnce("shell_closed");
targetConn.end();
jumpConn?.end();
});
resolve({
write(data: string, _traceId?: number) {
const payload = encodeInputForSsh(data).replace(/\uFFFD/g, "");
if (payload) {
streamRef?.write(Buffer.from(payload, "utf8"));
}
},
resize(cols: number, rows: number) {
streamRef?.setWindow(rows, cols, 0, 0);
},
close(reason = "manual") {
reportCloseOnce(reason);
streamRef?.close();
targetConn.end();
jumpConn?.end();
}
});
}
);
};
targetConn.on("ready", openShell);
targetConn.on("error", onError);
targetConn.on("close", () => {
reportCloseOnce("connection_closed");
});
const targetHop: SshHopOptions = {
host: options.host,
port: options.port,
username: options.username,
credential: options.credential,
knownHostFingerprint: options.knownHostFingerprint
};
if (!options.jumpHost) {
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target"));
return;
}
jumpConn?.on("error", onError);
jumpConn?.on("close", () => {
reportCloseOnce("jump_connection_closed");
});
jumpConn?.on("ready", () => {
jumpConn.forwardOut("127.0.0.1", 0, options.host, options.port, (error, stream) => {
if (error || !stream) {
onError(error ?? new Error("jump forward stream missing"));
return;
}
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target", stream));
});
});
jumpConn?.connect(buildSshConnectConfig(options.jumpHost, options.onHostFingerprint, "jump"));
});
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
process.env.MINIPROGRAM_APP_ID = "wx-test-app";
process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret";
process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test";
const { createSyncToken, decryptSecretPayload, encryptSecretPayload, verifySyncToken } = await import(
"./crypto"
);
describe("sync crypto", () => {
it("应能加解密敏感凭据", () => {
const source = {
password: "pw-123456",
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"
};
const encrypted = encryptSecretPayload(source);
const decrypted = decryptSecretPayload(encrypted.secretBlob, encrypted.secretVersion);
expect(decrypted).toEqual(source);
});
it("应能签发并校验同步 token", () => {
const session = createSyncToken("user-1", "openid-1");
const payload = verifySyncToken(session.token);
expect(payload.uid).toBe("user-1");
expect(payload.oid).toBe("openid-1");
expect(payload.exp).toBeGreaterThan(Date.now());
});
});

View File

@@ -0,0 +1,119 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual, randomBytes } from "node:crypto";
import { config } from "../config";
interface EncryptedSecretBlob {
alg: "aes-256-gcm";
keyVersion: number;
iv: string;
tag: string;
ciphertext: string;
}
interface SyncTokenPayload {
uid: string;
oid: string;
exp: number;
}
function toBase64Url(input: Buffer | string): string {
const source = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
return source
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function fromBase64Url(input: string): Buffer {
const normalized = String(input || "")
.replace(/-/g, "+")
.replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return Buffer.from(padded, "base64");
}
function deriveKey(label: string): Buffer {
return createHash("sha256")
.update(`${config.sync.secretCurrent}:${label}`, "utf8")
.digest();
}
function stableJson(value: unknown): string {
return JSON.stringify(value ?? {});
}
export function encryptSecretPayload(payload: unknown): { secretBlob: string; secretVersion: number } {
const plaintext = Buffer.from(stableJson(payload), "utf8");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", deriveKey("sync-secret"), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const blob: EncryptedSecretBlob = {
alg: "aes-256-gcm",
keyVersion: config.sync.secretVersion,
iv: toBase64Url(iv),
tag: toBase64Url(tag),
ciphertext: toBase64Url(ciphertext)
};
return {
secretBlob: JSON.stringify(blob),
secretVersion: config.sync.secretVersion
};
}
export function decryptSecretPayload(secretBlob: string, secretVersion: number): Record<string, unknown> {
if (!secretBlob) {
return {};
}
if (secretVersion !== config.sync.secretVersion) {
throw new Error("unsupported secret version");
}
const blob = JSON.parse(secretBlob) as EncryptedSecretBlob;
const decipher = createDecipheriv("aes-256-gcm", deriveKey("sync-secret"), fromBase64Url(blob.iv));
decipher.setAuthTag(fromBase64Url(blob.tag));
const plaintext = Buffer.concat([
decipher.update(fromBase64Url(blob.ciphertext)),
decipher.final()
]).toString("utf8");
const parsed = JSON.parse(plaintext) as Record<string, unknown>;
return parsed && typeof parsed === "object" ? parsed : {};
}
export function createSyncToken(userId: string, openid: string): { token: string; expiresAt: string } {
const exp = Date.now() + config.sync.tokenTtlSec * 1000;
const payload: SyncTokenPayload = {
uid: userId,
oid: openid,
exp
};
const encodedPayload = toBase64Url(JSON.stringify(payload));
const signature = createHmac("sha256", deriveKey("sync-token"))
.update(encodedPayload, "utf8")
.digest();
return {
token: `v1.${encodedPayload}.${toBase64Url(signature)}`,
expiresAt: new Date(exp).toISOString()
};
}
export function verifySyncToken(token: string): SyncTokenPayload {
const [version, encodedPayload, encodedSignature] = String(token || "").split(".");
if (version !== "v1" || !encodedPayload || !encodedSignature) {
throw new Error("invalid token format");
}
const expected = createHmac("sha256", deriveKey("sync-token"))
.update(encodedPayload, "utf8")
.digest();
const actual = fromBase64Url(encodedSignature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error("invalid token signature");
}
const payload = JSON.parse(fromBase64Url(encodedPayload).toString("utf8")) as SyncTokenPayload;
if (!payload || typeof payload !== "object" || !payload.uid || !payload.oid || !Number.isFinite(payload.exp)) {
throw new Error("invalid token payload");
}
if (Date.now() >= payload.exp) {
throw new Error("token expired");
}
return payload;
}

View File

@@ -0,0 +1,105 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
process.env.MINIPROGRAM_APP_ID = "wx-test-app";
process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret";
process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test";
const { SyncRepository } = await import("./repository");
const { initializeSyncDb } = await import("./sqlite");
const tempDirs: string[] = [];
function createRepository() {
const dir = mkdtempSync(path.join(tmpdir(), "remoteconn-sync-"));
tempDirs.push(dir);
const db = initializeSyncDb(new DatabaseSync(path.join(dir, "sync.db")));
return new SyncRepository(db);
}
afterEach(() => {
while (tempDirs.length) {
const dir = tempDirs.pop();
if (!dir) break;
rmSync(dir, { recursive: true, force: true });
}
});
describe("sync repository", () => {
it("应保存并取回带加密凭据的服务器配置", () => {
const repo = createRepository();
const user = repo.getOrCreateUser("openid-user");
repo.upsertServers(user.id, [
{
id: "srv-1",
name: "server-1",
tags: ["prod"],
host: "10.0.0.1",
port: 22,
username: "root",
authType: "privateKey",
projectPath: "~/workspace",
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway",
jumpHost: {
enabled: true,
host: "10.0.0.2",
port: 22,
username: "jump",
authType: "password"
},
sortOrder: 1,
lastConnectedAt: "",
updatedAt: "2026-03-09T00:00:00.000Z",
deletedAt: null,
password: "",
privateKey: "secret-key",
passphrase: "secret-passphrase",
certificate: "",
jumpPassword: "jump-secret",
jumpPrivateKey: "",
jumpPassphrase: "",
jumpCertificate: ""
}
]);
const rows = repo.listServers(user.id);
expect(rows).toHaveLength(1);
const first = rows[0];
expect(first).toBeDefined();
expect(first && first.host).toBe("10.0.0.1");
expect(first && first.privateKey).toBe("secret-key");
expect(first && first.jumpPassword).toBe("jump-secret");
});
it("应保存并返回闪念记录", () => {
const repo = createRepository();
const user = repo.getOrCreateUser("openid-user-2");
repo.upsertRecords(user.id, [
{
id: "rec-1",
content: "deploy before 18:00",
serverId: "srv-1",
category: "问题",
contextLabel: "prod-api",
processed: false,
discarded: true,
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:10:00.000Z",
deletedAt: null
}
]);
const rows = repo.listRecords(user.id);
expect(rows).toHaveLength(1);
const first = rows[0];
expect(first).toBeDefined();
expect(first && first.content).toBe("deploy before 18:00");
expect(first && first.category).toBe("问题");
expect(first && first.discarded).toBe(true);
});
});

View File

@@ -0,0 +1,254 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { decryptSecretPayload, encryptSecretPayload } from "./crypto";
import { getSyncDb } from "./sqlite";
import type { SyncRecord, SyncServer, SyncServerCommon, SyncServerSecret, SyncSettingsPayload } from "./schema";
export interface UserRow {
id: string;
openid: string;
unionid: string | null;
created_at: string;
updated_at: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function parseJsonObject(input: string): Record<string, unknown> {
try {
const parsed = JSON.parse(input);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function parseJsonArrayLike<T>(input: string): T {
return JSON.parse(input) as T;
}
function pickServerCommon(server: SyncServer): SyncServerCommon {
return {
id: server.id,
name: server.name,
tags: server.tags,
host: server.host,
port: server.port,
username: server.username,
authType: server.authType,
projectPath: server.projectPath,
timeoutSeconds: server.timeoutSeconds,
heartbeatSeconds: server.heartbeatSeconds,
transportMode: server.transportMode,
jumpHost: server.jumpHost,
sortOrder: server.sortOrder,
lastConnectedAt: server.lastConnectedAt,
updatedAt: server.updatedAt,
deletedAt: server.deletedAt ?? null
};
}
function pickServerSecrets(server: SyncServer): SyncServerSecret {
return {
password: server.password,
privateKey: server.privateKey,
passphrase: server.passphrase,
certificate: server.certificate,
jumpPassword: server.jumpPassword,
jumpPrivateKey: server.jumpPrivateKey,
jumpPassphrase: server.jumpPassphrase,
jumpCertificate: server.jumpCertificate
};
}
export class SyncRepository {
private readonly db: DatabaseSync;
constructor(database: DatabaseSync = getSyncDb()) {
this.db = database;
}
getOrCreateUser(openid: string, unionid?: string | null): UserRow {
const found = this.db
.prepare("SELECT id, openid, unionid, created_at, updated_at FROM users WHERE openid = ?")
.get(openid) as UserRow | undefined;
if (found) {
if (unionid && unionid !== found.unionid) {
const updatedAt = nowIso();
this.db
.prepare("UPDATE users SET unionid = ?, updated_at = ? WHERE id = ?")
.run(unionid, updatedAt, found.id);
found.unionid = unionid;
found.updated_at = updatedAt;
}
return found;
}
const row: UserRow = {
id: randomUUID(),
openid,
unionid: unionid || null,
created_at: nowIso(),
updated_at: nowIso()
};
this.db
.prepare(
"INSERT INTO users (id, openid, unionid, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
)
.run(row.id, row.openid, row.unionid, row.created_at, row.updated_at);
return row;
}
getSettings(userId: string): SyncSettingsPayload | null {
const row = this.db
.prepare("SELECT settings_json, updated_at FROM user_settings WHERE user_id = ?")
.get(userId) as { settings_json: string; updated_at: string } | undefined;
if (!row) return null;
return {
data: parseJsonObject(row.settings_json),
updatedAt: row.updated_at
};
}
upsertSettings(userId: string, payload: SyncSettingsPayload): void {
const current = this.getSettings(userId);
if (current && current.updatedAt > payload.updatedAt) {
return;
}
this.db
.prepare(
`
INSERT INTO user_settings (user_id, settings_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
settings_json = excluded.settings_json,
updated_at = excluded.updated_at
`
)
.run(userId, JSON.stringify(payload.data), payload.updatedAt);
}
listServers(userId: string): SyncServer[] {
const rows = this.db
.prepare(
`
SELECT server_json, secret_blob, secret_version, deleted_at
FROM user_servers
WHERE user_id = ?
ORDER BY updated_at DESC
`
)
.all(userId) as Array<{
server_json: string;
secret_blob: string | null;
secret_version: number;
deleted_at: string | null;
}>;
return rows.map((row) => {
const common = parseJsonArrayLike<SyncServerCommon>(row.server_json);
const secrets = row.secret_blob ? (decryptSecretPayload(row.secret_blob, row.secret_version) as SyncServerSecret) : {};
return {
...common,
...secrets,
deletedAt: row.deleted_at ?? common.deletedAt ?? null
} as SyncServer;
});
}
upsertServers(userId: string, servers: SyncServer[]): void {
const selectStmt = this.db.prepare(
"SELECT updated_at FROM user_servers WHERE user_id = ? AND server_id = ?"
);
const upsertStmt = this.db.prepare(`
INSERT INTO user_servers (
id, user_id, server_id, server_json, secret_blob, secret_version, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, server_id) DO UPDATE SET
server_json = excluded.server_json,
secret_blob = excluded.secret_blob,
secret_version = excluded.secret_version,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
`);
this.db.exec("BEGIN");
try {
servers.forEach((server) => {
const current = selectStmt.get(userId, server.id) as { updated_at: string } | undefined;
if (current && current.updated_at > server.updatedAt) {
return;
}
const encrypted = encryptSecretPayload(pickServerSecrets(server));
upsertStmt.run(
randomUUID(),
userId,
server.id,
JSON.stringify(pickServerCommon(server)),
encrypted.secretBlob,
encrypted.secretVersion,
server.updatedAt,
server.deletedAt ?? null
);
});
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
listRecords(userId: string): SyncRecord[] {
const rows = this.db
.prepare(
`
SELECT record_json, deleted_at
FROM user_records
WHERE user_id = ?
ORDER BY updated_at DESC
`
)
.all(userId) as Array<{ record_json: string; deleted_at: string | null }>;
return rows.map((row) => {
const record = parseJsonArrayLike<SyncRecord>(row.record_json);
return {
...record,
deletedAt: row.deleted_at ?? record.deletedAt ?? null
};
});
}
upsertRecords(userId: string, records: SyncRecord[]): void {
const selectStmt = this.db.prepare(
"SELECT updated_at FROM user_records WHERE user_id = ? AND record_id = ?"
);
const upsertStmt = this.db.prepare(`
INSERT INTO user_records (id, user_id, record_id, record_json, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, record_id) DO UPDATE SET
record_json = excluded.record_json,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
`);
this.db.exec("BEGIN");
try {
records.forEach((record) => {
const current = selectStmt.get(userId, record.id) as { updated_at: string } | undefined;
if (current && current.updated_at > record.updatedAt) {
return;
}
upsertStmt.run(
randomUUID(),
userId,
record.id,
JSON.stringify(record),
record.updatedAt,
record.deletedAt ?? null
);
});
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
}

View File

@@ -0,0 +1,214 @@
import type { Express, Request, Response, NextFunction } from "express";
import { config } from "../config";
import { logger } from "../logger";
import { verifySyncToken } from "./crypto";
import { SyncRepository } from "./repository";
import {
syncLoginBodySchema,
syncRecordsPayloadSchema,
syncSettingsPayloadSchema,
syncServersPayloadSchema
} from "./schema";
import { loginMiniprogramUser } from "./userService";
import { registerMiniprogramTtsRoutes } from "../tts/routes";
interface SyncAuthedRequest extends Request {
syncUser?: {
userId: string;
openid: string;
};
}
function ensureSyncEnabled(res: Response): boolean {
if (config.sync.enabled) {
return true;
}
logger.warn(
{
hasAppId: Boolean(config.sync.miniprogramAppId),
hasAppSecret: Boolean(config.sync.miniprogramAppSecret),
hasSecretCurrent: Boolean(config.sync.secretCurrent)
},
"小程序同步服务未启用"
);
res.status(503).json({
ok: false,
code: "SYNC_DISABLED",
message: "小程序同步服务未配置"
});
return false;
}
function requireGatewayToken(req: Request, res: Response): boolean {
const token = String(req.headers["x-gateway-token"] || "");
if (token === config.gatewayToken) {
return true;
}
logger.warn(
{
path: req.path,
hasToken: Boolean(token)
},
"小程序同步登录缺少有效 gateway token"
);
res.status(401).json({
ok: false,
code: "AUTH_FAILED",
message: "gateway token 无效"
});
return false;
}
function requireSyncUser(req: SyncAuthedRequest, res: Response, next: NextFunction): void {
if (!ensureSyncEnabled(res)) return;
const auth = String(req.headers.authorization || "");
const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
if (!token) {
logger.warn({ path: req.path }, "小程序同步请求缺少 Bearer token");
res.status(401).json({ ok: false, code: "SYNC_TOKEN_MISSING", message: "缺少同步令牌" });
return;
}
try {
const payload = verifySyncToken(token);
req.syncUser = {
userId: payload.uid,
openid: payload.oid
};
next();
} catch (error) {
logger.warn(
{
path: req.path,
err: error
},
"小程序同步请求鉴权失败"
);
res.status(401).json({
ok: false,
code: "SYNC_TOKEN_INVALID",
message: error instanceof Error ? error.message : "同步令牌无效"
});
}
}
export function registerSyncRoutes(app: Express): void {
const repository = new SyncRepository();
registerMiniprogramTtsRoutes(app, requireSyncUser);
app.post("/api/miniprogram/auth/login", async (req, res) => {
if (!ensureSyncEnabled(res) || !requireGatewayToken(req, res)) return;
const parsed = syncLoginBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
ok: false,
code: "INVALID_BODY",
message: "登录参数不合法"
});
return;
}
try {
const result = await loginMiniprogramUser(parsed.data.code, repository);
res.json({
ok: true,
token: result.token,
expiresAt: result.expiresAt,
user: {
id: result.user.id
}
});
} catch (error) {
logger.warn({ err: error }, "小程序同步登录失败");
res.status(502).json({
ok: false,
code: "WECHAT_LOGIN_FAILED",
message: error instanceof Error ? error.message : "微信登录失败"
});
}
});
app.get("/api/miniprogram/sync/bootstrap", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({
ok: true,
settings: repository.getSettings(userId),
servers: repository.listServers(userId),
records: repository.listRecords(userId)
});
});
app.get("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, settings: repository.getSettings(userId) });
});
app.put("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncSettingsPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "settings 参数不合法" });
return;
}
repository.upsertSettings(userId, parsed.data);
res.json({ ok: true, settings: repository.getSettings(userId) });
});
app.get("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, servers: repository.listServers(userId) });
});
app.put("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncServersPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "servers 参数不合法" });
return;
}
repository.upsertServers(userId, parsed.data.servers);
res.json({ ok: true, servers: repository.listServers(userId) });
});
app.get("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, records: repository.listRecords(userId) });
});
app.put("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncRecordsPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "records 参数不合法" });
return;
}
repository.upsertRecords(userId, parsed.data.records);
res.json({ ok: true, records: repository.listRecords(userId) });
});
}

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
/**
* 小程序同步公共 schema
* 1. 第一阶段只约束必要字段,避免把本地对象完全写死;
* 2. 服务器普通字段与敏感字段拆开,便于单独加密存储。
*/
export const syncLoginBodySchema = z.object({
code: z.string().trim().min(1)
});
export const syncSettingsPayloadSchema = z.object({
updatedAt: z.string().trim().min(1),
data: z.record(z.string(), z.unknown())
});
export const syncJumpHostSchema = z.object({
enabled: z.boolean().optional().default(false),
host: z.string().optional().default(""),
port: z.number().int().min(1).max(65535).optional().default(22),
username: z.string().optional().default(""),
authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password")
});
export const syncServerSecretSchema = z.object({
password: z.string().optional().default(""),
privateKey: z.string().optional().default(""),
passphrase: z.string().optional().default(""),
certificate: z.string().optional().default(""),
jumpPassword: z.string().optional().default(""),
jumpPrivateKey: z.string().optional().default(""),
jumpPassphrase: z.string().optional().default(""),
jumpCertificate: z.string().optional().default("")
});
export const syncServerCommonSchema = z.object({
id: z.string().trim().min(1),
name: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
host: z.string().optional().default(""),
port: z.number().int().min(1).max(65535).optional().default(22),
username: z.string().optional().default(""),
authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password"),
projectPath: z.string().optional().default(""),
timeoutSeconds: z.number().int().min(1).max(3600).optional().default(15),
heartbeatSeconds: z.number().int().min(1).max(3600).optional().default(10),
transportMode: z.string().optional().default("gateway"),
jumpHost: syncJumpHostSchema.optional().default({ enabled: false, host: "", port: 22, username: "", authType: "password" }),
sortOrder: z.number().int().optional().default(0),
lastConnectedAt: z.string().optional().default(""),
updatedAt: z.string().trim().min(1),
deletedAt: z.string().trim().min(1).nullable().optional().default(null)
});
export const syncServerSchema = syncServerCommonSchema.merge(syncServerSecretSchema);
export const syncServersPayloadSchema = z.object({
servers: z.array(syncServerSchema)
});
export const syncRecordSchema = z.object({
id: z.string().trim().min(1),
content: z.string().optional().default(""),
serverId: z.string().optional().default(""),
category: z.string().optional().default("未分类"),
contextLabel: z.string().optional().default(""),
processed: z.boolean().optional().default(false),
discarded: z.boolean().optional().default(false),
createdAt: z.string().trim().min(1),
updatedAt: z.string().trim().min(1),
deletedAt: z.string().trim().min(1).nullable().optional().default(null)
});
export const syncRecordsPayloadSchema = z.object({
records: z.array(syncRecordSchema)
});
export type SyncSettingsPayload = z.infer<typeof syncSettingsPayloadSchema>;
export type SyncServer = z.infer<typeof syncServerSchema>;
export type SyncServerCommon = z.infer<typeof syncServerCommonSchema>;
export type SyncServerSecret = z.infer<typeof syncServerSecretSchema>;
export type SyncRecord = z.infer<typeof syncRecordSchema>;

View File

@@ -0,0 +1,77 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { config } from "../config";
let db: DatabaseSync | null = null;
export function initializeSyncDb(database: DatabaseSync): DatabaseSync {
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
openid TEXT NOT NULL UNIQUE,
unionid TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_servers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id TEXT NOT NULL,
server_json TEXT NOT NULL,
secret_blob TEXT,
secret_version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_servers_user_server
ON user_servers(user_id, server_id);
CREATE INDEX IF NOT EXISTS idx_user_servers_user_updated
ON user_servers(user_id, updated_at);
CREATE TABLE IF NOT EXISTS user_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id TEXT NOT NULL,
record_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_records_user_record
ON user_records(user_id, record_id);
CREATE INDEX IF NOT EXISTS idx_user_records_user_updated
ON user_records(user_id, updated_at);
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
return database;
}
export function getSyncDb(): DatabaseSync {
if (db) {
return db;
}
const sqlitePath = path.resolve(process.cwd(), config.sync.sqlitePath);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
db = new DatabaseSync(sqlitePath);
initializeSyncDb(db);
return db;
}

View File

@@ -0,0 +1,43 @@
import { SyncRepository } from "./repository";
import { createSyncToken } from "./crypto";
import { config } from "../config";
interface Code2SessionResult {
openid: string;
unionid?: string;
}
async function fetchCode2Session(code: string): Promise<Code2SessionResult> {
const url = new URL("https://api.weixin.qq.com/sns/jscode2session");
url.searchParams.set("appid", config.sync.miniprogramAppId);
url.searchParams.set("secret", config.sync.miniprogramAppSecret);
url.searchParams.set("js_code", code);
url.searchParams.set("grant_type", "authorization_code");
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`wechat code2Session failed: ${response.status}`);
}
const payload = (await response.json()) as {
openid?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
};
if (!payload.openid) {
throw new Error(payload.errmsg || `wechat code2Session failed: ${payload.errcode || "unknown"}`);
}
return {
openid: payload.openid,
unionid: payload.unionid
};
}
export async function loginMiniprogramUser(code: string, repository = new SyncRepository()) {
const result = await fetchCode2Session(code);
const user = repository.getOrCreateUser(result.openid, result.unionid || null);
const session = createSyncToken(user.id, user.openid);
return {
user,
...session
};
}

View File

@@ -0,0 +1,175 @@
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
export interface TtsCacheEntry {
cacheKey: string;
contentType: string;
bytes: number;
createdAt: string;
lastAccessAt: string;
}
interface TtsCacheFileRecord extends TtsCacheEntry {
version: 1;
}
interface TtsCacheStoreOptions {
cacheDir: string;
ttlMs: number;
maxTotalBytes: number;
maxFileBytes: number;
}
/**
* 磁盘缓存采用“音频文件 + metadata sidecar”
* 1. 命中时不再请求上游 TTS
* 2. metadata 只保留必要字段,不记录原始文本;
* 3. 每次写入后顺带做一次轻量淘汰,维持总量上限。
*/
export class TtsCacheStore {
private cacheDir: string;
private ttlMs: number;
private maxTotalBytes: number;
private maxFileBytes: number;
constructor(options: TtsCacheStoreOptions) {
this.cacheDir = options.cacheDir;
this.ttlMs = options.ttlMs;
this.maxTotalBytes = options.maxTotalBytes;
this.maxFileBytes = options.maxFileBytes;
}
private audioPath(cacheKey: string): string {
return path.join(this.cacheDir, `${cacheKey}.mp3`);
}
private metaPath(cacheKey: string): string {
return path.join(this.cacheDir, `${cacheKey}.json`);
}
private async ensureDir(): Promise<void> {
await mkdir(this.cacheDir, { recursive: true });
}
private async removeCacheKey(cacheKey: string): Promise<void> {
await Promise.allSettled([rm(this.audioPath(cacheKey), { force: true }), rm(this.metaPath(cacheKey), { force: true })]);
}
async get(cacheKey: string): Promise<{ entry: TtsCacheEntry; audioPath: string } | null> {
await this.ensureDir();
try {
const metaRaw = await readFile(this.metaPath(cacheKey), "utf8");
const parsed = JSON.parse(metaRaw) as Partial<TtsCacheFileRecord>;
const audioPath = this.audioPath(cacheKey);
const audioStat = await stat(audioPath);
const lastAccessAt = parsed.lastAccessAt || parsed.createdAt || new Date().toISOString();
if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) {
await this.removeCacheKey(cacheKey);
return null;
}
const nowIso = new Date().toISOString();
const entry: TtsCacheEntry = {
cacheKey,
contentType: String(parsed.contentType || "audio/mpeg"),
bytes: Number(parsed.bytes) || audioStat.size,
createdAt: String(parsed.createdAt || nowIso),
lastAccessAt: nowIso
};
await writeFile(
this.metaPath(cacheKey),
JSON.stringify(
{
version: 1,
...entry
} satisfies TtsCacheFileRecord,
null,
2
),
"utf8"
);
return { entry, audioPath };
} catch {
await this.removeCacheKey(cacheKey);
return null;
}
}
async put(cacheKey: string, audio: Buffer, contentType: string): Promise<TtsCacheEntry> {
await this.ensureDir();
if (audio.length <= 0) {
throw new Error("audio buffer is empty");
}
if (audio.length > this.maxFileBytes) {
throw new Error("audio file exceeds cache single-file limit");
}
const nowIso = new Date().toISOString();
const entry: TtsCacheEntry = {
cacheKey,
contentType: contentType || "audio/mpeg",
bytes: audio.length,
createdAt: nowIso,
lastAccessAt: nowIso
};
await writeFile(this.audioPath(cacheKey), audio);
await writeFile(
this.metaPath(cacheKey),
JSON.stringify(
{
version: 1,
...entry
} satisfies TtsCacheFileRecord,
null,
2
),
"utf8"
);
await this.prune();
return entry;
}
async prune(): Promise<void> {
await this.ensureDir();
const names = await readdir(this.cacheDir);
const metaFiles = names.filter((name) => name.endsWith(".json"));
const rows: Array<TtsCacheEntry & { audioPath: string; metaPath: string; sortValue: number }> = [];
for (const file of metaFiles) {
try {
const metaPath = path.join(this.cacheDir, file);
const raw = await readFile(metaPath, "utf8");
const parsed = JSON.parse(raw) as Partial<TtsCacheFileRecord>;
const cacheKey = file.replace(/\.json$/u, "");
const audioPath = this.audioPath(cacheKey);
const audioStat = await stat(audioPath);
const lastAccessAt = String(parsed.lastAccessAt || parsed.createdAt || new Date(0).toISOString());
if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) {
await this.removeCacheKey(cacheKey);
continue;
}
rows.push({
cacheKey,
contentType: String(parsed.contentType || "audio/mpeg"),
bytes: Number(parsed.bytes) || audioStat.size,
createdAt: String(parsed.createdAt || new Date().toISOString()),
lastAccessAt,
audioPath,
metaPath,
sortValue: +new Date(lastAccessAt || parsed.createdAt || 0) || 0
});
} catch {
// 单条损坏直接移除,避免拖垮后续缓存命中。
const cacheKey = file.replace(/\.json$/u, "");
await this.removeCacheKey(cacheKey);
}
}
let totalBytes = rows.reduce((sum, item) => sum + item.bytes, 0);
if (totalBytes <= this.maxTotalBytes) {
return;
}
rows.sort((a, b) => a.sortValue - b.sortValue);
for (const row of rows) {
if (totalBytes <= this.maxTotalBytes) break;
await Promise.allSettled([rm(row.audioPath, { force: true }), rm(row.metaPath, { force: true })]);
totalBytes -= row.bytes;
}
}
}

View File

@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("tts provider helpers", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "tencent";
process.env.TTS_VOICE_DEFAULT = "female_v1";
process.env.TTS_SPEED_DEFAULT = "1";
vi.resetModules();
});
it("normalizeTtsRequest 会压缩空白并生成缓存键", async () => {
const { normalizeTtsRequest } = await import("./provider");
const result = normalizeTtsRequest({
text: "请 先检查\r\n\r\n gateway 配置。。。。",
scene: "codex_terminal"
});
expect(result.normalizedText).toBe("请 先检查\ngateway 配置。");
expect(result.cacheKey).toMatch(/^[a-f0-9]{40}$/);
expect(result.voice.alias).toBe("female_v1");
expect(result.speed).toBe(1);
});
it("resolveTtsVoiceProfile 应映射到豆包 1.0 公共音色", async () => {
const { resolveTtsVoiceProfile } = await import("./provider");
expect(resolveTtsVoiceProfile("female_v1").volcVoiceType).toBe("zh_female_cancan_mars_bigtts");
expect(resolveTtsVoiceProfile("male_v1").volcVoiceType).toBe("zh_male_qingshuangnanda_mars_bigtts");
});
it("normalizeTtsRequest 会拒绝超出腾讯云安全字节上限的文本", async () => {
const { normalizeTtsRequest, TtsServiceError } = await import("./provider");
expect(() =>
normalizeTtsRequest({
text: "测".repeat(151),
scene: "codex_terminal"
})
).toThrowError(TtsServiceError);
});
});

View File

@@ -0,0 +1,209 @@
import { createHash } from "node:crypto";
import { config } from "../config";
export interface TtsSynthesizeInput {
text: string;
scene: "codex_terminal";
voice?: string;
speed?: number;
}
export interface TtsVoiceProfile {
alias: string;
providerVoiceType: number;
volcVoiceType: string;
}
export interface TtsNormalizedRequest {
scene: "codex_terminal";
normalizedText: string;
voice: TtsVoiceProfile;
speed: number;
textHash: string;
cacheKey: string;
provider: string;
}
export interface TtsProviderRequest {
text: string;
voice: TtsVoiceProfile;
speed: number;
traceId: string;
}
export interface TtsProviderResult {
audio: Buffer;
contentType: string;
}
export interface TtsProviderAdapter {
readonly providerName: string;
synthesize(request: TtsProviderRequest): Promise<TtsProviderResult>;
}
export const TTS_UPSTREAM_REJECTED_MESSAGE = "TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限";
const TTS_VOICE_PROFILES: Record<string, TtsVoiceProfile> = Object.freeze({
female_v1: {
alias: "female_v1",
providerVoiceType: 101027,
// 豆包语音合成 1.0 公共女声音色,和 `volc.service_type.10029` 同代可直接配套使用。
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
male_v1: {
alias: "male_v1",
providerVoiceType: 101004,
// 同步切到豆包 1.0 公共男声音色,避免旧 BV700 音色与当前 resource_id 代际不匹配。
volcVoiceType: "zh_male_qingshuangnanda_mars_bigtts"
}
});
const TTS_MAX_NORMALIZED_UTF8_BYTES = 450;
/**
* 对外错误统一带 code / status路由层只做一次翻译。
*/
export class TtsServiceError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status = 400) {
super(message);
this.name = "TtsServiceError";
this.code = code;
this.status = status;
}
}
/**
* 上游错误正文经常包含换行、长追踪串或 HTML 片段:
* 1. 压成单行,便于进入日志和小程序 warning
* 2. 截断到有限长度,避免把整段上游响应直接透给前端;
* 3. 保留最关键的错误码和首句说明。
*/
export function normalizeTtsUpstreamDetail(rawDetail: unknown): string {
const detail = typeof rawDetail === "string" ? rawDetail : String(rawDetail || "");
if (!detail.trim()) return "";
const singleLine = detail.replace(/\s+/g, " ").trim();
return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine;
}
export function buildTtsUpstreamRejectedMessage(detail?: string): string {
const normalizedDetail = normalizeTtsUpstreamDetail(detail);
return normalizedDetail
? `${TTS_UPSTREAM_REJECTED_MESSAGE}${normalizedDetail}`
: TTS_UPSTREAM_REJECTED_MESSAGE;
}
export function isTtsUpstreamRejectedDetail(detail: string): boolean {
return /(not granted|access token|authorization|auth|permission|forbidden|unauthorized|resource|grant|鉴权|权限|令牌|密钥)/i.test(
normalizeTtsUpstreamDetail(detail)
);
}
export function buildTtsUpstreamHttpError(status: number, detail?: string): TtsServiceError {
if (status === 401 || status === 403) {
return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
const normalizedDetail = normalizeTtsUpstreamDetail(detail);
return new TtsServiceError(
"TTS_UPSTREAM_FAILED",
normalizedDetail ? `TTS 上游请求失败: ${status} ${normalizedDetail}` : `TTS 上游请求失败: ${status}`,
502
);
}
export function buildTextHash(text: string): string {
return createHash("sha1")
.update(String(text || ""), "utf8")
.digest("hex");
}
/**
* 网关二次归一化文本:
* 1. 合并 CRLF / 多空格,避免同义文本重复生成缓存;
* 2. 压缩重复标点,降低 TTS 朗读噪音;
* 3. 保留自然语言句间空格,不在服务端做过度语义改写。
*/
export function normalizeTtsText(rawText: string): string {
return String(rawText || "")
.replace(/\r\n?/g, "\n")
.replace(/[ \t\f\v]+/g, " ")
.replace(/\n{2,}/g, "\n")
.replace(/([。!?!?.,;:])\1{1,}/g, "$1")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
export function normalizeTtsSpeed(rawSpeed: unknown): number {
const fallback = Number(config.tts.speedDefault) || 1;
const numeric = Number(rawSpeed);
if (!Number.isFinite(numeric)) {
return Math.max(0.8, Math.min(1.2, fallback));
}
return Math.max(0.8, Math.min(1.2, Number(numeric.toFixed(2))));
}
export function resolveTtsVoiceProfile(rawVoice: unknown): TtsVoiceProfile {
const normalized = String(rawVoice || config.tts.voiceDefault || "female_v1")
.trim()
.toLowerCase();
return TTS_VOICE_PROFILES[normalized] ?? TTS_VOICE_PROFILES.female_v1!;
}
export function buildTtsCacheKey(
providerName: string,
voice: TtsVoiceProfile,
speed: number,
normalizedText: string
): string {
return createHash("sha1")
.update(
[
String(providerName || "")
.trim()
.toLowerCase(),
String(voice.alias || ""),
String(Number(speed).toFixed(2)),
normalizedText,
"v1"
].join("\n"),
"utf8"
)
.digest("hex");
}
export function normalizeTtsRequest(input: TtsSynthesizeInput): TtsNormalizedRequest {
const source: Partial<TtsSynthesizeInput> =
input && typeof input === "object" ? input : { text: "", scene: "codex_terminal" };
const rawText = String(source.text || "");
if (rawText.length > 500) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
const normalizedText = normalizeTtsText(rawText);
if (!normalizedText) {
throw new TtsServiceError("TEXT_NOT_SPEAKABLE", "当前内容不适合播报", 400);
}
if (Buffer.byteLength(normalizedText, "utf8") > TTS_MAX_NORMALIZED_UTF8_BYTES) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
if (normalizedText.length > 280) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
const voice = resolveTtsVoiceProfile(source.voice);
const speed = normalizeTtsSpeed(source.speed);
const providerName =
String(config.tts.provider || "tencent")
.trim()
.toLowerCase() || "tencent";
const scene = source.scene === "codex_terminal" ? "codex_terminal" : "codex_terminal";
return {
scene,
normalizedText,
voice,
speed,
textHash: buildTextHash(normalizedText),
cacheKey: buildTtsCacheKey(providerName, voice, speed, normalizedText),
provider: providerName
};
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("tencent tts provider", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "tencent";
process.env.TTS_SECRET_ID = "secret-id";
process.env.TTS_SECRET_KEY = "secret-key";
process.env.TTS_REGION = "ap-guangzhou";
process.env.TTS_TIMEOUT_MS = "10000";
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("buildTencentTextToVoiceRequest 应生成 TC3 请求头并映射语速", async () => {
const { buildTencentTextToVoiceRequest } = await import("./tencent");
const built = buildTencentTextToVoiceRequest(
{
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1,
traceId: "trace-1"
},
Date.UTC(2026, 2, 12, 8, 0, 0)
);
expect(built.url).toBe("https://tts.tencentcloudapi.com");
expect(built.headers["X-TC-Action"]).toBe("TextToVoice");
expect(built.headers["X-TC-Region"]).toBe("ap-guangzhou");
expect(built.headers.Authorization).toContain("TC3-HMAC-SHA256");
expect(built.payload.VoiceType).toBe(101027);
expect(built.payload.Speed).toBe(0);
});
it("较慢与较快倍速应映射到腾讯云 speed 区间", async () => {
const { buildTencentTextToVoiceRequest } = await import("./tencent");
const slowBuilt = buildTencentTextToVoiceRequest({
text: "slow",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 0.8,
traceId: "trace-slow"
});
const fastBuilt = buildTencentTextToVoiceRequest({
text: "fast",
voice: {
alias: "male_v1",
providerVoiceType: 101004,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1.2,
traceId: "trace-fast"
});
expect(slowBuilt.payload.Speed).toBe(-1);
expect(fastBuilt.payload.Speed).toBe(1);
});
it("上游返回 403 时应识别为鉴权或权限失败", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
Response: {
Error: {
Code: "AuthFailure.InvalidSecretId",
Message: "The SecretId is not found"
}
}
}),
{
status: 403,
headers: {
"Content-Type": "application/json"
}
}
)
)
);
const { TencentTtsProvider } = await import("./tencent");
const provider = new TencentTtsProvider();
await expect(
provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1,
traceId: "trace-auth-403"
})
).rejects.toMatchObject({
code: "TTS_UPSTREAM_REJECTED",
status: 502,
message: expect.stringContaining("AuthFailure.InvalidSecretId")
});
});
});

View File

@@ -0,0 +1,216 @@
import { createHmac, createHash, randomUUID } from "node:crypto";
import { config } from "../../config";
import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider";
import {
buildTtsUpstreamHttpError,
buildTtsUpstreamRejectedMessage,
normalizeTtsUpstreamDetail,
TtsServiceError
} from "../provider";
const TENCENT_TTS_HOST = "tts.tencentcloudapi.com";
const TENCENT_TTS_ACTION = "TextToVoice";
const TENCENT_TTS_VERSION = "2019-08-23";
const TENCENT_TTS_SERVICE = "tts";
interface TencentTtsRequestPayload {
Text: string;
SessionId: string;
ModelType: number;
VoiceType: number;
Codec: "mp3";
SampleRate: number;
PrimaryLanguage: number;
Speed: number;
Volume: number;
}
interface TencentTtsResponse {
Response?: {
Audio?: string;
Error?: {
Code?: string;
Message?: string;
};
};
}
/**
* 小程序侧把 speed 暴露为“倍速语义”:
* - 1.0 表示 1x
* - 0.8 / 1.2 分别对应较慢 / 较快。
* 腾讯云 `Speed` 的 0 才是 1x因此这里做一层线性映射
* 0.8 -> -1
* 1.0 -> 0
* 1.2 -> 1
*/
function mapRatioSpeedToTencentSpeed(speed: number): number {
const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1;
const providerSpeed = (normalized - 1) / 0.2;
return Math.max(-2, Math.min(6, Number(providerSpeed.toFixed(2))));
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
function hmacSha256(
key: Buffer | string,
value: string,
output: "hex" | "buffer" = "buffer"
): Buffer | string {
const digest = createHmac("sha256", key).update(value, "utf8");
return output === "hex" ? digest.digest("hex") : digest.digest();
}
function parseTencentTtsResponse(rawText: string): TencentTtsResponse | null {
try {
return JSON.parse(rawText) as TencentTtsResponse;
} catch {
return null;
}
}
/**
* 腾讯云错误体通常同时带 Code 和 Message
* 1. 优先把 Code 保留下来,便于直接定位 CAM/签名/权限问题;
* 2. 无 JSON 时再退回原始文本,避免完全丢掉上游返回。
*/
function formatTencentErrorDetail(
errorPayload?: { Code?: string; Message?: string } | null,
rawText?: string
): string {
const code = normalizeTtsUpstreamDetail(errorPayload?.Code);
const message = normalizeTtsUpstreamDetail(errorPayload?.Message);
if (code && message) {
return `${code}: ${message}`;
}
if (code) {
return code;
}
if (message) {
return message;
}
return normalizeTtsUpstreamDetail(rawText);
}
/**
* 腾讯云 API 3.0TC3-HMAC-SHA256签名
* 1. 仅签当前固定 header 集合,避免实现过度泛化;
* 2. action / version / host 都来自官方 TextToVoice 接口;
* 3. TTS v1 只走短文本同步合成,返回 base64 音频。
*/
export function buildTencentTextToVoiceRequest(request: TtsProviderRequest, now = Date.now()) {
const secretId = String(config.tts.secretId || "").trim();
const secretKey = String(config.tts.secretKey || "").trim();
if (!secretId || !secretKey) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
const payload: TencentTtsRequestPayload = {
Text: request.text,
SessionId: request.traceId || randomUUID(),
ModelType: 1,
VoiceType: request.voice.providerVoiceType,
Codec: "mp3",
SampleRate: 16000,
PrimaryLanguage: 1,
Speed: mapRatioSpeedToTencentSpeed(request.speed),
Volume: 1
};
const body = JSON.stringify(payload);
const timestamp = Math.max(1, Math.floor(now / 1000));
const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
const canonicalHeaders = [
"content-type:application/json; charset=utf-8",
`host:${TENCENT_TTS_HOST}`,
`x-tc-action:${TENCENT_TTS_ACTION.toLowerCase()}`
].join("\n");
const signedHeaders = "content-type;host;x-tc-action";
const canonicalRequest = ["POST", "/", "", `${canonicalHeaders}\n`, signedHeaders, sha256Hex(body)].join(
"\n"
);
const credentialScope = `${date}/${TENCENT_TTS_SERVICE}/tc3_request`;
const stringToSign = [
"TC3-HMAC-SHA256",
String(timestamp),
credentialScope,
sha256Hex(canonicalRequest)
].join("\n");
const secretDate = hmacSha256(`TC3${secretKey}`, date) as Buffer;
const secretService = hmacSha256(secretDate, TENCENT_TTS_SERVICE) as Buffer;
const secretSigning = hmacSha256(secretService, "tc3_request") as Buffer;
const signature = hmacSha256(secretSigning, stringToSign, "hex") as string;
const authorization = [
"TC3-HMAC-SHA256",
`Credential=${secretId}/${credentialScope}`,
`SignedHeaders=${signedHeaders}`,
`Signature=${signature}`
].join(", ");
return {
url: `https://${TENCENT_TTS_HOST}`,
body,
payload,
headers: {
Authorization: authorization,
"Content-Type": "application/json; charset=utf-8",
Host: TENCENT_TTS_HOST,
"X-TC-Action": TENCENT_TTS_ACTION,
"X-TC-Region": config.tts.region,
"X-TC-Timestamp": String(timestamp),
"X-TC-Version": TENCENT_TTS_VERSION
}
};
}
export class TencentTtsProvider implements TtsProviderAdapter {
readonly providerName = "tencent";
async synthesize(request: TtsProviderRequest): Promise<TtsProviderResult> {
const built = buildTencentTextToVoiceRequest(request);
let response: Response;
try {
response = await fetch(built.url, {
method: "POST",
headers: built.headers,
body: built.body,
signal: AbortSignal.timeout(config.tts.timeoutMs)
});
} catch (error) {
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
error instanceof Error && /timeout/i.test(error.message)
? "语音生成超时,请稍后重试"
: "语音生成失败",
502
);
}
const rawText = await response.text();
const parsed = parseTencentTtsResponse(rawText);
if (!response.ok) {
throw buildTtsUpstreamHttpError(response.status, formatTencentErrorDetail(parsed?.Response?.Error, rawText));
}
if (!parsed) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游返回格式异常", 502);
}
const errorPayload = parsed.Response?.Error;
if (errorPayload) {
const detail = formatTencentErrorDetail(errorPayload, rawText);
if (/^(AuthFailure|UnauthorizedOperation)\b/.test(String(errorPayload.Code || "").trim())) {
throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
detail || "TTS 上游返回错误",
502
);
}
const audioBase64 = String(parsed.Response?.Audio || "").trim();
if (!audioBase64) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502);
}
return {
audio: Buffer.from(audioBase64, "base64"),
contentType: "audio/mpeg"
};
}
}

View File

@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function createStreamResponse(chunks: string[], contentType: string): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
}
});
return new Response(stream, {
status: 200,
headers: {
"Content-Type": contentType
}
});
}
describe("volcengine tts provider", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "volcengine";
process.env.TTS_APP_ID = "app-id";
process.env.TTS_ACCESS_TOKEN = "access-token";
process.env.TTS_RESOURCE_ID = "volc.service_type.10029";
process.env.TTS_TIMEOUT_MS = "200";
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("buildVolcengineTtsRequest 应生成 V3 HTTP 单向流式请求", async () => {
const { buildVolcengineTtsRequest } = await import("./volcengine");
const built = buildVolcengineTtsRequest(
{
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1.2,
traceId: "trace-1"
},
"access-token"
);
expect(built.url).toBe("https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse");
expect(built.headers).toMatchObject({
"Content-Type": "application/json",
"X-Api-App-Id": "app-id",
"X-Api-Access-Key": "access-token",
"X-Api-Resource-Id": "volc.service_type.10029",
"X-Control-Require-Usage-Tokens-Return": "text_words"
});
expect(built.body).toMatchObject({
user: {
uid: "trace-1"
},
req_params: {
text: "请先检查 gateway 配置。",
speaker: "zh_female_cancan_mars_bigtts",
audio_params: {
format: "mp3",
sample_rate: 24000,
speech_rate: 20
},
additions: '{"disable_markdown_filter":true}'
}
});
});
it("synthesize 应拼接 HTTP Chunked 流式音频块", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
createStreamResponse(
[
'{"code":0,"message":"Success","sequence":1,"data":"',
'YXVkaW8tMQ=="}\n{"code":0,"message":"Success","sequence":2,"data":"YXVkaW8tMg=="}',
'\n{"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":7}}\n'
],
"application/json"
)
);
vi.stubGlobal("fetch", fetchMock);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
const result = await provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-demo"
});
expect(result.contentType).toBe("audio/mpeg");
expect(result.audio).toEqual(Buffer.from("audio-1audio-2"));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"X-Api-Resource-Id": "volc.service_type.10029"
})
})
);
});
it("synthesize 应兼容 SSE 单向流式响应", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
createStreamResponse(
[
'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":1,"data":"YXVkaW8tMQ=="}\n\n',
'event: 351\ndata: {"code":0,"message":"Success","event":351,"data":null}\n\n',
'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":2,"data":"YXVkaW8tMg=="}\n\n',
'event: 152\ndata: {"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":9}}\n\n'
],
"text/event-stream"
)
);
vi.stubGlobal("fetch", fetchMock);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
const result = await provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-sse"
});
expect(result.audio).toEqual(Buffer.from("audio-1audio-2"));
expect(result.contentType).toBe("audio/mpeg");
});
it("上游返回鉴权错误时应识别为权限失败", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
code: 45000000,
message: "resource is not authorized"
}),
{
status: 403,
headers: {
"Content-Type": "application/json"
}
}
)
)
);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
await expect(
provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-auth-403"
})
).rejects.toMatchObject({
code: "TTS_UPSTREAM_REJECTED",
status: 502,
message: expect.stringContaining("resource is not authorized")
});
});
});

View File

@@ -0,0 +1,484 @@
import { randomUUID } from "node:crypto";
import { config } from "../../config";
import { logger } from "../../logger";
import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider";
import {
buildTtsUpstreamHttpError,
buildTtsUpstreamRejectedMessage,
isTtsUpstreamRejectedDetail,
normalizeTtsUpstreamDetail,
TtsServiceError
} from "../provider";
// 对齐当前豆包语音 HTTP 单向流式 SSE demo默认走 SSE 端点;
// 同时仍保留 chunked JSON 解析兜底,兼容代理层或上游的回退响应。
const VOLCENGINE_TTS_URL = "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse";
const VOLCENGINE_TTS_SAMPLE_RATE = 24000;
const VOLCENGINE_STREAM_SUCCESS_CODE = 20000000;
const VOLCENGINE_STREAM_AUTH_REJECTED_CODE = 45000000;
const VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE = 40402003;
const VOLCENGINE_STREAM_SENTENCE_END_EVENT = 351;
const VOLCENGINE_STREAM_AUDIO_EVENT = 352;
const VOLCENGINE_STREAM_FINISH_EVENT = 152;
const VOLCENGINE_STREAM_ERROR_EVENT = 153;
interface VolcengineTtsRequestBody {
user: {
uid: string;
};
req_params: {
text: string;
speaker: string;
audio_params: {
format: "mp3";
sample_rate: number;
speech_rate: number;
};
additions: string;
};
}
interface VolcengineTtsHttpRequest {
url: string;
headers: Record<string, string>;
body: VolcengineTtsRequestBody;
}
interface VolcengineTtsStreamPayload {
code?: number;
message?: string;
event?: number;
sequence?: number;
data?: unknown;
usage?: {
text_words?: number;
};
}
interface VolcengineStreamState {
audioChunks: Buffer[];
firstChunkAtMs: number;
finishCode: number | null;
usageTextWords: number | null;
}
function ensureVolcengineConfig(): void {
if (!config.tts.appId || !config.tts.accessToken || !config.tts.resourceId) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
}
/**
* V3 文档中 `speech_rate` 的取值范围为 `[-50, 100]`
* 1. `0` 表示 1.0x
* 2. `100` 表示 2.0x
* 3. 当前产品只暴露 0.8 / 1.0 / 1.2 三档,因此继续保守映射到同一线性区间。
*/
function mapRatioSpeedToVolcengineSpeechRate(speed: number): number {
const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1;
const mapped = Math.round((normalized - 1) * 100);
return Math.max(-50, Math.min(100, mapped));
}
export function buildVolcengineTtsRequest(
request: TtsProviderRequest,
accessToken: string
): VolcengineTtsHttpRequest {
ensureVolcengineConfig();
const requestId = randomUUID();
return {
url: VOLCENGINE_TTS_URL,
headers: {
"Content-Type": "application/json",
"X-Api-App-Id": config.tts.appId,
// 文档里的 header 名仍是 `X-Api-Access-Key`,但其值实际应填写控制台签发的 Access Token。
"X-Api-Access-Key": accessToken,
"X-Api-Resource-Id": config.tts.resourceId,
"X-Api-Request-Id": requestId,
// 要求在结束事件里返回 text_words方便记录计费量和排障。
"X-Control-Require-Usage-Tokens-Return": "text_words"
},
body: {
user: {
uid: request.traceId || requestId
},
req_params: {
text: request.text,
speaker: request.voice.volcVoiceType,
audio_params: {
format: "mp3",
sample_rate: VOLCENGINE_TTS_SAMPLE_RATE,
speech_rate: mapRatioSpeedToVolcengineSpeechRate(request.speed)
},
// 小程序送来的播报文本常带 Markdown/终端痕迹,要求上游先做一次语法过滤,降低朗读噪音。
additions: JSON.stringify({
disable_markdown_filter: true
})
}
}
};
}
function extractHttpErrorDetail(rawText: string): string {
const text = normalizeTtsUpstreamDetail(rawText);
if (!text) {
return "";
}
try {
const parsed = JSON.parse(text) as { message?: string; code?: number | string; data?: unknown };
const detail = normalizeTtsUpstreamDetail(
parsed.message || (typeof parsed.data === "string" ? parsed.data : "") || text
);
if (parsed.code !== undefined) {
return detail ? `code=${parsed.code} ${detail}` : `code=${parsed.code}`;
}
return detail;
} catch {
return text;
}
}
function extractStreamDetail(payload: VolcengineTtsStreamPayload): string {
const directMessage = normalizeTtsUpstreamDetail(payload.message || "");
if (directMessage) {
return directMessage;
}
if (typeof payload.data === "string") {
return normalizeTtsUpstreamDetail(payload.data);
}
return "";
}
function resolveAudioBase64(data: unknown): string {
if (typeof data === "string") {
return data.trim();
}
if (!data || typeof data !== "object") {
return "";
}
const row = data as Record<string, unknown>;
const direct = row.audio_base64 ?? row.audio ?? row.audio_data;
return typeof direct === "string" ? direct.trim() : "";
}
function createVolcengineStreamError(payload: VolcengineTtsStreamPayload): TtsServiceError {
const code = Number(payload.code ?? 0);
const detail = extractStreamDetail(payload);
if (code === VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE) {
return new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
if (/quota exceeded.*concurrency|concurrency.*quota exceeded|too many requests/i.test(detail)) {
return new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503);
}
if (code === VOLCENGINE_STREAM_AUTH_REJECTED_CODE || isTtsUpstreamRejectedDetail(detail)) {
return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
const codeLabel = code > 0 ? `火山 TTS 错误码 ${code}` : "语音生成失败";
return new TtsServiceError("TTS_UPSTREAM_FAILED", detail ? `${codeLabel}: ${detail}` : codeLabel, 502);
}
function extractJsonObjects(source: string): { items: string[]; rest: string } {
const items: string[] = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index]!;
if (start < 0) {
if (char === "{") {
start = index;
depth = 1;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
items.push(source.slice(start, index + 1));
start = -1;
}
}
}
return {
items,
rest: start >= 0 ? source.slice(start) : ""
};
}
function extractSseBlocks(source: string, flush: boolean): { items: string[]; rest: string } {
const items: string[] = [];
let rest = source;
while (true) {
const matched = rest.match(/\r\n\r\n|\n\n/);
if (!matched || matched.index === undefined) {
break;
}
const block = rest.slice(0, matched.index);
rest = rest.slice(matched.index + matched[0].length);
const dataLines = block
.split(/\r\n|\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (dataLines.length > 0) {
items.push(dataLines.join("\n"));
}
}
if (flush && rest.trim()) {
const dataLines = rest
.split(/\r\n|\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (dataLines.length > 0) {
items.push(dataLines.join("\n"));
rest = "";
}
}
return { items, rest };
}
function applyStreamPayload(
state: VolcengineStreamState,
payload: VolcengineTtsStreamPayload,
startedAt: number,
traceId: string
): void {
const event = Number(payload.event ?? 0);
const code = Number(payload.code ?? 0);
if (event === VOLCENGINE_STREAM_ERROR_EVENT || (code !== 0 && code !== VOLCENGINE_STREAM_SUCCESS_CODE)) {
throw createVolcengineStreamError(payload);
}
const audioBase64 = resolveAudioBase64(payload.data);
if (audioBase64 && (event === 0 || event === VOLCENGINE_STREAM_AUDIO_EVENT)) {
if (!state.firstChunkAtMs) {
state.firstChunkAtMs = Date.now();
logger.info(
{
traceId,
resourceId: config.tts.resourceId,
elapsedMs: state.firstChunkAtMs - startedAt
},
"火山 TTS 收到首个音频分片"
);
}
state.audioChunks.push(Buffer.from(audioBase64, "base64"));
return;
}
if (event === VOLCENGINE_STREAM_SENTENCE_END_EVENT) {
return;
}
if (event === VOLCENGINE_STREAM_FINISH_EVENT || code === VOLCENGINE_STREAM_SUCCESS_CODE) {
state.finishCode = code || VOLCENGINE_STREAM_SUCCESS_CODE;
state.usageTextWords =
payload.usage && typeof payload.usage.text_words === "number"
? payload.usage.text_words
: state.usageTextWords;
}
}
async function consumeVolcengineStream(
response: Response,
request: TtsProviderRequest,
startedAt: number
): Promise<VolcengineStreamState> {
if (!response.body) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回流式响应体", 502);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const isSse = contentType.includes("text/event-stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: VolcengineStreamState = {
audioChunks: [],
firstChunkAtMs: 0,
finishCode: null,
usageTextWords: null
};
let streamBuffer = "";
while (true) {
const { done, value } = await reader.read();
if (value) {
streamBuffer += decoder.decode(value, { stream: !done });
if (isSse) {
const parsed = extractSseBlocks(streamBuffer, false);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(
state,
JSON.parse(item) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
}
} else {
const parsed = extractJsonObjects(streamBuffer);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(
state,
JSON.parse(item) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
}
}
}
if (done) {
break;
}
}
streamBuffer += decoder.decode();
if (streamBuffer.trim()) {
if (isSse) {
const parsed = extractSseBlocks(streamBuffer, true);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId);
}
} else {
const parsed = extractJsonObjects(streamBuffer);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId);
}
if (streamBuffer.trim()) {
applyStreamPayload(
state,
JSON.parse(streamBuffer) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
streamBuffer = "";
}
}
}
if (streamBuffer.trim()) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游流式响应不完整", 502);
}
return state;
}
export class VolcengineTtsProvider implements TtsProviderAdapter {
readonly providerName = "volcengine";
async synthesize(request: TtsProviderRequest): Promise<TtsProviderResult> {
const token = String(config.tts.accessToken || "").trim();
if (!token) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
const built = buildVolcengineTtsRequest(request, token);
const timeoutMs = config.tts.timeoutMs;
const startedAt = Date.now();
let stage = "requesting";
logger.info(
{
traceId: request.traceId,
textLength: request.text.length,
resourceId: config.tts.resourceId,
timeoutMs
},
"火山 TTS 合成开始"
);
try {
const response = await fetch(built.url, {
method: "POST",
headers: built.headers,
body: JSON.stringify(built.body),
signal: AbortSignal.timeout(timeoutMs)
});
stage = "response_headers";
if (!response.ok) {
const detail = extractHttpErrorDetail(await response.text());
throw buildTtsUpstreamHttpError(response.status, detail);
}
stage = "streaming";
const streamState = await consumeVolcengineStream(response, request, startedAt);
if (streamState.audioChunks.length === 0) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502);
}
logger.info(
{
traceId: request.traceId,
resourceId: config.tts.resourceId,
chunkCount: streamState.audioChunks.length,
audioBytes: streamState.audioChunks.reduce((sum, item) => sum + item.length, 0),
elapsedMs: Date.now() - startedAt,
firstChunkDelayMs: streamState.firstChunkAtMs ? streamState.firstChunkAtMs - startedAt : null,
usageTextWords: streamState.usageTextWords
},
"火山 TTS 合成完成"
);
return {
audio: Buffer.concat(streamState.audioChunks),
contentType: "audio/mpeg"
};
} catch (error) {
logger.warn(
{
traceId: request.traceId,
resourceId: config.tts.resourceId,
stage,
elapsedMs: Date.now() - startedAt,
err: error
},
"火山 TTS 合成失败"
);
if (error instanceof TtsServiceError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error || "");
if (/timeout|timed out|aborted|超时/i.test(message)) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成超时,请稍后重试", 502);
}
if (isTtsUpstreamRejectedDetail(message)) {
throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(message), 502);
}
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
normalizeTtsUpstreamDetail(message) || "语音生成失败",
502
);
}
}
}

View File

@@ -0,0 +1,434 @@
import { Buffer } from "node:buffer";
import type WebSocket from "ws";
import type { RawData } from "ws";
/**
* 这里只保留 gateway 现阶段真正会用到的播客协议常量:
* 1. 连接生命周期;
* 2. 会话生命周期;
* 3. 播客音频 round 输出。
*/
export enum VolcenginePodcastEventType {
StartConnection = 1,
FinishConnection = 2,
ConnectionStarted = 50,
ConnectionFinished = 52,
StartSession = 100,
FinishSession = 102,
SessionStarted = 150,
SessionFinished = 152,
PodcastRoundStart = 360,
PodcastRoundResponse = 361,
PodcastRoundEnd = 362,
PodcastEnd = 363
}
export enum VolcenginePodcastMsgType {
FullClientRequest = 0b1,
FullServerResponse = 0b1001,
AudioOnlyServer = 0b1011,
Error = 0b1111
}
export enum VolcenginePodcastMsgFlagBits {
NoSeq = 0,
PositiveSeq = 0b1,
NegativeSeq = 0b11,
WithEvent = 0b100
}
enum VolcenginePodcastVersionBits {
Version1 = 1
}
enum VolcenginePodcastHeaderSizeBits {
HeaderSize4 = 1
}
enum VolcenginePodcastSerializationBits {
JSON = 0b1
}
enum VolcenginePodcastCompressionBits {
None = 0
}
export interface VolcenginePodcastMessage {
version: VolcenginePodcastVersionBits;
headerSize: VolcenginePodcastHeaderSizeBits;
type: VolcenginePodcastMsgType;
flag: VolcenginePodcastMsgFlagBits;
serialization: VolcenginePodcastSerializationBits;
compression: VolcenginePodcastCompressionBits;
event?: VolcenginePodcastEventType;
sessionId?: string;
connectId?: string;
sequence?: number;
errorCode?: number;
payload: Uint8Array;
}
const messageQueues = new Map<WebSocket, VolcenginePodcastMessage[]>();
const messageResolvers = new Map<
WebSocket,
Array<{
resolve: (message: VolcenginePodcastMessage) => void;
reject: (error: Error) => void;
timer?: NodeJS.Timeout;
}>
>();
const initializedSockets = new WeakSet<WebSocket>();
export function createVolcenginePodcastMessage(
type: VolcenginePodcastMsgType,
flag: VolcenginePodcastMsgFlagBits
): VolcenginePodcastMessage {
return {
version: VolcenginePodcastVersionBits.Version1,
headerSize: VolcenginePodcastHeaderSizeBits.HeaderSize4,
type,
flag,
serialization: VolcenginePodcastSerializationBits.JSON,
compression: VolcenginePodcastCompressionBits.None,
payload: new Uint8Array(0)
};
}
function writeUint32(value: number): Uint8Array {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setUint32(0, value >>> 0, false);
return new Uint8Array(buffer);
}
function writeInt32(value: number): Uint8Array {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setInt32(0, value | 0, false);
return new Uint8Array(buffer);
}
function writeString(value: string): Uint8Array {
const bytes = Buffer.from(String(value || ""), "utf8");
const result = new Uint8Array(4 + bytes.length);
result.set(writeUint32(bytes.length), 0);
result.set(bytes, 4);
return result;
}
function writePayload(payload: Uint8Array): Uint8Array {
const normalized = payload instanceof Uint8Array ? payload : new Uint8Array(payload || []);
const result = new Uint8Array(4 + normalized.length);
result.set(writeUint32(normalized.length), 0);
result.set(normalized, 4);
return result;
}
export function marshalVolcenginePodcastMessage(message: VolcenginePodcastMessage): Uint8Array {
const parts: Uint8Array[] = [];
const headerSize = 4 * message.headerSize;
const header = new Uint8Array(headerSize);
header[0] = (message.version << 4) | message.headerSize;
header[1] = (message.type << 4) | message.flag;
header[2] = (message.serialization << 4) | message.compression;
parts.push(header);
if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) {
parts.push(writeInt32(message.event ?? 0));
if (
message.event === VolcenginePodcastEventType.ConnectionStarted ||
message.event === VolcenginePodcastEventType.ConnectionFinished
) {
parts.push(writeString(message.connectId || ""));
} else if (
message.event !== VolcenginePodcastEventType.StartConnection &&
message.event !== VolcenginePodcastEventType.FinishConnection
) {
parts.push(writeString(message.sessionId || ""));
}
}
if (
message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq ||
message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq
) {
parts.push(writeInt32(message.sequence ?? 0));
}
if (message.type === VolcenginePodcastMsgType.Error) {
parts.push(writeUint32(message.errorCode ?? 0));
}
parts.push(writePayload(message.payload));
const totalLength = parts.reduce((sum, item) => sum + item.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
merged.set(part, offset);
offset += part.length;
}
return merged;
}
function readUint32(data: Uint8Array, offset: number): number {
return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0, false);
}
function readInt32(data: Uint8Array, offset: number): number {
return new DataView(data.buffer, data.byteOffset + offset, 4).getInt32(0, false);
}
function readLengthPrefixedString(data: Uint8Array, offset: number): { value: string; nextOffset: number } {
if (offset + 4 > data.length) {
throw new Error("播客 TTS 协议帧缺少字符串长度");
}
const size = readUint32(data, offset);
const nextOffset = offset + 4;
const endOffset = nextOffset + size;
if (endOffset > data.length) {
throw new Error("播客 TTS 协议帧字符串数据不完整");
}
return {
value: size > 0 ? new TextDecoder().decode(data.slice(nextOffset, endOffset)) : "",
nextOffset: endOffset
};
}
export function unmarshalVolcenginePodcastMessage(data: Uint8Array): VolcenginePodcastMessage {
if (data.length < 4) {
throw new Error("播客 TTS 协议帧长度不足");
}
let offset = 0;
const versionAndHeaderSize = data[offset] ?? 0;
offset += 1;
const typeAndFlag = data[offset] ?? 0;
offset += 1;
const serializationAndCompression = data[offset] ?? 0;
offset += 1;
offset = 4 * (versionAndHeaderSize & 0b00001111);
const message: VolcenginePodcastMessage = {
version: (versionAndHeaderSize >> 4) as VolcenginePodcastVersionBits,
headerSize: (versionAndHeaderSize & 0b00001111) as VolcenginePodcastHeaderSizeBits,
type: (typeAndFlag >> 4) as VolcenginePodcastMsgType,
flag: (typeAndFlag & 0b00001111) as VolcenginePodcastMsgFlagBits,
serialization: (serializationAndCompression >> 4) as VolcenginePodcastSerializationBits,
compression: (serializationAndCompression & 0b00001111) as VolcenginePodcastCompressionBits,
payload: new Uint8Array(0)
};
if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) {
message.event = readInt32(data, offset) as VolcenginePodcastEventType;
offset += 4;
if (
message.event !== VolcenginePodcastEventType.StartConnection &&
message.event !== VolcenginePodcastEventType.FinishConnection &&
message.event !== VolcenginePodcastEventType.ConnectionStarted &&
message.event !== VolcenginePodcastEventType.ConnectionFinished
) {
const sessionId = readLengthPrefixedString(data, offset);
message.sessionId = sessionId.value;
offset = sessionId.nextOffset;
}
if (
message.event === VolcenginePodcastEventType.ConnectionStarted ||
message.event === VolcenginePodcastEventType.ConnectionFinished
) {
const connectId = readLengthPrefixedString(data, offset);
message.connectId = connectId.value;
offset = connectId.nextOffset;
}
}
if (
message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq ||
message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq
) {
message.sequence = readInt32(data, offset);
offset += 4;
}
if (message.type === VolcenginePodcastMsgType.Error) {
message.errorCode = readUint32(data, offset);
offset += 4;
}
if (offset + 4 > data.length) {
throw new Error("播客 TTS 协议帧缺少 payload 长度");
}
const payloadSize = readUint32(data, offset);
offset += 4;
if (offset + payloadSize > data.length) {
throw new Error("播客 TTS 协议帧 payload 数据不完整");
}
message.payload = payloadSize > 0 ? data.slice(offset, offset + payloadSize) : new Uint8Array(0);
return message;
}
function sendMessage(ws: WebSocket, message: VolcenginePodcastMessage): Promise<void> {
const data = marshalVolcenginePodcastMessage(message);
return new Promise((resolve, reject) => {
ws.send(data, (error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
function toUint8Array(data: RawData): Uint8Array {
if (Buffer.isBuffer(data)) {
return new Uint8Array(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (data instanceof Uint8Array) {
return data;
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((item) => Buffer.from(item)));
}
throw new Error(`不支持的播客 TTS 消息类型: ${typeof data}`);
}
function rejectAllResolvers(ws: WebSocket, error: Error): void {
const resolvers = messageResolvers.get(ws) || [];
while (resolvers.length > 0) {
const resolver = resolvers.shift();
if (!resolver) continue;
if (resolver.timer) {
clearTimeout(resolver.timer);
}
resolver.reject(error);
}
}
function setupMessageHandler(ws: WebSocket): void {
if (initializedSockets.has(ws)) {
return;
}
initializedSockets.add(ws);
messageQueues.set(ws, []);
messageResolvers.set(ws, []);
ws.on("message", (data: RawData) => {
try {
const message = unmarshalVolcenginePodcastMessage(toUint8Array(data));
const resolvers = messageResolvers.get(ws) || [];
const queue = messageQueues.get(ws) || [];
const pending = resolvers.shift();
if (pending) {
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.resolve(message);
return;
}
queue.push(message);
messageQueues.set(ws, queue);
} catch (error) {
rejectAllResolvers(
ws,
error instanceof Error ? error : new Error("解析播客 TTS 消息失败")
);
}
});
ws.on("error", (error) => {
rejectAllResolvers(ws, error instanceof Error ? error : new Error("播客 TTS 连接失败"));
});
ws.on("close", () => {
rejectAllResolvers(ws, new Error("播客 TTS 连接已关闭"));
messageQueues.delete(ws);
messageResolvers.delete(ws);
});
}
export async function receiveVolcenginePodcastMessage(
ws: WebSocket,
timeoutMs: number
): Promise<VolcenginePodcastMessage> {
setupMessageHandler(ws);
const queue = messageQueues.get(ws) || [];
if (queue.length > 0) {
return queue.shift() as VolcenginePodcastMessage;
}
return new Promise((resolve, reject) => {
const resolvers = messageResolvers.get(ws) || [];
const resolver = {
resolve,
reject,
timer:
timeoutMs > 0
? setTimeout(() => {
const currentResolvers = messageResolvers.get(ws) || [];
const index = currentResolvers.indexOf(resolver);
if (index >= 0) {
currentResolvers.splice(index, 1);
}
reject(new Error("播客 TTS 响应超时"));
}, timeoutMs)
: undefined
};
resolvers.push(resolver);
messageResolvers.set(ws, resolvers);
});
}
export async function waitForVolcenginePodcastEvent(
ws: WebSocket,
messageType: VolcenginePodcastMsgType,
eventType: VolcenginePodcastEventType,
timeoutMs: number
): Promise<VolcenginePodcastMessage> {
const message = await receiveVolcenginePodcastMessage(ws, timeoutMs);
if (message.type !== messageType || message.event !== eventType) {
throw new Error(`播客 TTS 返回了未预期事件: type=${message.type}, event=${message.event}`);
}
return message;
}
function buildEventPayload(payload: Uint8Array, event: VolcenginePodcastEventType, sessionId?: string) {
const message = createVolcenginePodcastMessage(
VolcenginePodcastMsgType.FullClientRequest,
VolcenginePodcastMsgFlagBits.WithEvent
);
message.event = event;
if (sessionId) {
message.sessionId = sessionId;
}
message.payload = payload;
return message;
}
export async function startVolcenginePodcastConnection(ws: WebSocket): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.StartConnection)
);
}
export async function finishVolcenginePodcastConnection(ws: WebSocket): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishConnection)
);
}
export async function startVolcenginePodcastSession(
ws: WebSocket,
payload: Uint8Array,
sessionId: string
): Promise<void> {
await sendMessage(ws, buildEventPayload(payload, VolcenginePodcastEventType.StartSession, sessionId));
}
export async function finishVolcenginePodcastSession(ws: WebSocket, sessionId: string): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishSession, sessionId)
);
}

View File

@@ -0,0 +1,170 @@
import type { Express, Request, Response, NextFunction } from "express";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { logger } from "../logger";
import { miniprogramTtsSynthesizeBodySchema } from "./schema";
import { TtsService } from "./service";
import { TtsServiceError } from "./provider";
interface SyncAuthedRequest extends Request {
syncUser?: {
userId: string;
openid: string;
};
}
const ttsService = new TtsService();
const userLimiter = new RateLimiterMemory({
points: 20,
duration: 600
});
const ipLimiter = new RateLimiterMemory({
points: 60,
duration: 600
});
function resolvePublicBaseUrl(req: Request): string {
const forwardedProto = String(req.headers["x-forwarded-proto"] || "")
.split(",")
.at(0)
?.trim();
const forwardedHost = String(req.headers["x-forwarded-host"] || "")
.split(",")
.at(0)
?.trim();
const host = forwardedHost || req.get("host") || "127.0.0.1:8787";
const protocol = forwardedProto || req.protocol || "http";
return `${protocol}://${host}`;
}
function sendTtsError(res: Response, error: unknown) {
if (error && typeof error === "object" && "msBeforeNext" in error) {
res.status(429).json({
ok: false,
code: "TTS_RATE_LIMITED",
message: "语音播报过于频繁,请稍后重试"
});
return;
}
const status = error instanceof TtsServiceError ? error.status : 500;
const code = error instanceof TtsServiceError ? error.code : "TTS_INTERNAL_ERROR";
const message = error instanceof Error ? error.message : "TTS 内部错误";
res.status(status).json({
ok: false,
code,
message
});
}
async function checkTtsRateLimit(userId: string, ip: string): Promise<void> {
await Promise.all([
userLimiter.consume(userId || "unknown_user", 1),
ipLimiter.consume(ip || "unknown_ip", 1)
]);
}
export function registerMiniprogramTtsRoutes(
app: Express,
requireSyncUser: (req: SyncAuthedRequest, res: Response, next: NextFunction) => void
): void {
app.post("/api/miniprogram/tts/synthesize", requireSyncUser, async (req: SyncAuthedRequest, res) => {
const userId = String(req.syncUser?.userId || "").trim();
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = miniprogramTtsSynthesizeBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "TTS 参数不合法" });
return;
}
try {
await checkTtsRateLimit(userId, req.socket.remoteAddress ?? "unknown");
const payload = await ttsService.synthesizeForUser(resolvePublicBaseUrl(req), userId, parsed.data);
res.json(payload);
} catch (error) {
logger.warn(
{
uid: userId,
ip: req.socket.remoteAddress ?? "unknown",
err: error
},
"小程序 TTS 合成失败"
);
sendTtsError(res, error);
}
});
app.get("/api/miniprogram/tts/status/:cacheKey", async (req, res) => {
const cacheKey = String(req.params.cacheKey || "").trim();
const ticket = String(req.query.ticket || "").trim();
if (!cacheKey || !ticket) {
res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" });
return;
}
try {
ttsService.verifyAudioAccess(cacheKey, ticket);
const status = await ttsService.getSynthesisStatus(cacheKey);
if (status.state === "ready") {
res.json({ ok: true, status: "ready" });
return;
}
if (status.state === "pending") {
res.json({ ok: true, status: "pending" });
return;
}
if (status.state === "error") {
res.json({
ok: false,
status: "error",
code: status.code,
message: status.message
});
return;
}
res.json({
ok: false,
status: "missing",
message: "音频仍在生成,请稍后重试"
});
} catch (error) {
logger.warn(
{
cacheKey,
err: error
},
"小程序 TTS 状态查询失败"
);
sendTtsError(res, error);
}
});
app.get("/api/miniprogram/tts/audio/:cacheKey", async (req, res) => {
const cacheKey = String(req.params.cacheKey || "").trim();
const ticket = String(req.query.ticket || "").trim();
if (!cacheKey || !ticket) {
res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" });
return;
}
try {
ttsService.verifyAudioAccess(cacheKey, ticket);
const cached = await ttsService.resolveCachedAudio(cacheKey);
if (!cached) {
res.status(404).json({ ok: false, code: "TTS_AUDIO_NOT_FOUND", message: "音频缓存不存在" });
return;
}
res.setHeader("Content-Type", cached.entry.contentType);
res.setHeader("Content-Length", String(cached.entry.bytes));
res.setHeader("Cache-Control", "private, max-age=300");
res.sendFile(cached.audioPath);
} catch (error) {
logger.warn(
{
cacheKey,
err: error
},
"小程序 TTS 音频读取失败"
);
sendTtsError(res, error);
}
});
}

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
/**
* v1 只开放 Codex 终端播报场景,避免接口泛化过早。
*/
export const miniprogramTtsSynthesizeBodySchema = z.object({
text: z.string().trim().min(1).max(500),
scene: z.literal("codex_terminal"),
voice: z.string().trim().min(1).max(64).optional(),
speed: z.number().min(0.8).max(1.2).optional()
});
export type MiniprogramTtsSynthesizeBody = z.infer<typeof miniprogramTtsSynthesizeBodySchema>;

View File

@@ -0,0 +1,143 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("tts service", () => {
const originalEnv = { ...process.env };
const tempDirs: string[] = [];
interface MockProvider {
providerName: string;
synthesize: () => Promise<{ audio: Buffer; contentType: string }>;
}
beforeEach(() => {
process.env = {
...originalEnv,
TTS_PROVIDER: "volcengine",
TTS_APP_ID: "test-app-id",
TTS_ACCESS_TOKEN: "test-access-token",
GATEWAY_TOKEN: "test-gateway-token",
SYNC_SECRET_CURRENT: "test-sync-secret"
};
vi.resetModules();
});
afterEach(async () => {
process.env = { ...originalEnv };
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createService(provider: MockProvider, options?: { inlineWaitMs?: number }) {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "remoteconn-tts-"));
tempDirs.push(tempDir);
const [{ TtsService }, { TtsCacheStore }] = await Promise.all([import("./service"), import("./cache")]);
const cache = new TtsCacheStore({
cacheDir: tempDir,
ttlMs: 60 * 1000,
maxTotalBytes: 32 * 1024 * 1024,
maxFileBytes: 8 * 1024 * 1024
});
return new TtsService({
provider,
cache,
inlineWaitMs: options?.inlineWaitMs
});
}
async function waitForIdle(service: { getSynthesisStatus: (cacheKey: string) => Promise<{ state: string }> }, cacheKey: string) {
for (let i = 0; i < 20; i += 1) {
const status = await service.getSynthesisStatus(cacheKey);
if (status.state !== "pending") {
return status;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error("后台任务未在预期时间内结束");
}
it("应在缓存未命中时立即返回 pending并在后台合成完成后变为 ready", async () => {
let resolveSynthesize: (value: { audio: Buffer; contentType: string }) => void = () => {};
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(() => {
return new Promise<{ audio: Buffer; contentType: string }>((resolve) => {
resolveSynthesize = resolve;
});
})
};
const service = await createService(provider, { inlineWaitMs: 20 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(payload.status).toBe("pending");
expect(payload.cached).toBe(false);
expect((await service.getSynthesisStatus(payload.cacheKey)).state).toBe("pending");
resolveSynthesize({
audio: Buffer.from("fake-mp3-data"),
contentType: "audio/mpeg"
});
expect((await waitForIdle(service, payload.cacheKey)).state).toBe("ready");
const cachedPayload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(cachedPayload.status).toBe("ready");
expect(cachedPayload.cached).toBe(true);
});
it("应在短时间内完成合成时直接返回 ready减少小程序额外轮询", async () => {
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(
async () =>
await new Promise<{ audio: Buffer; contentType: string }>((resolve) => {
setTimeout(() => {
resolve({
audio: Buffer.from("fake-mp3-data"),
contentType: "audio/mpeg"
});
}, 10);
})
)
};
const service = await createService(provider, { inlineWaitMs: 80 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(payload.status).toBe("ready");
expect(payload.cached).toBe(true);
});
it("应暴露后台合成失败状态,便于小程序轮询时停止等待", async () => {
const { TtsServiceError } = await import("./provider");
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(async () => {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成失败", 502);
})
};
const service = await createService(provider, { inlineWaitMs: 20 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
const status = await waitForIdle(service, payload.cacheKey);
expect(status).toMatchObject({
state: "error",
code: "TTS_UPSTREAM_FAILED",
message: "语音生成失败"
});
});
});

View File

@@ -0,0 +1,339 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { config } from "../config";
import { logger } from "../logger";
import { TtsCacheStore } from "./cache";
import type { TtsNormalizedRequest, TtsProviderAdapter, TtsSynthesizeInput } from "./provider";
import { TtsServiceError, normalizeTtsRequest } from "./provider";
import { TencentTtsProvider } from "./providers/tencent";
import { VolcengineTtsProvider } from "./providers/volcengine";
import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket";
const TTS_AUDIO_TICKET_TTL_MS = 10 * 60 * 1000;
const TTS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const TTS_CACHE_TOTAL_MAX_BYTES = 256 * 1024 * 1024;
const TTS_PROVIDER_CONCURRENCY = 4;
const TTS_PROVIDER_QUEUE_TIMEOUT_MS = 10 * 60 * 1000;
const TTS_BACKGROUND_FAILURE_TTL_MS = 5 * 60 * 1000;
const TTS_SYNTHESIZE_INLINE_WAIT_MS = 800;
interface TtsAudioAccess {
uid: string;
cacheKey: string;
exp: number;
}
interface TtsBackgroundFailure {
code: string;
message: string;
status: number;
expiresAt: number;
}
type TtsSynthesisStatus =
| { state: "ready" }
| { state: "pending" }
| { state: "missing" }
| { state: "error"; code: string; message: string; status: number };
interface TtsServiceOptions {
provider?: TtsProviderAdapter;
cache?: TtsCacheStore;
inlineWaitMs?: number;
}
function sleep(ms: number): Promise<void> {
const waitMs = Math.max(0, Math.round(Number(ms) || 0));
return new Promise((resolve) => {
setTimeout(resolve, waitMs);
});
}
/**
* 最小并发闸门:
* 1. 每实例只允许少量上游 TTS 并发;
* 2. 等待结束后立即唤醒下一个请求;
* 3. v1 不做复杂优先级,保持实现确定性。
*/
class AsyncSemaphore {
private capacity: number;
private active: number;
private queue: Array<() => void>;
constructor(capacity: number) {
this.capacity = Math.max(1, capacity);
this.active = 0;
this.queue = [];
}
async use<T>(task: () => Promise<T>, waitTimeoutMs: number): Promise<T> {
await this.acquire(waitTimeoutMs);
try {
return await task();
} finally {
this.release();
}
}
private acquire(waitTimeoutMs: number): Promise<void> {
if (this.active < this.capacity) {
this.active += 1;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeoutMs = Math.max(0, Math.round(Number(waitTimeoutMs) || 0));
let timeout: NodeJS.Timeout | null = null;
const resume = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
this.active += 1;
resolve();
};
this.queue.push(resume);
if (timeoutMs <= 0) {
return;
}
timeout = setTimeout(() => {
const index = this.queue.indexOf(resume);
if (index >= 0) {
this.queue.splice(index, 1);
}
reject(new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503));
}, timeoutMs);
});
}
private release(): void {
this.active = Math.max(0, this.active - 1);
const next = this.queue.shift();
if (next) next();
}
}
function createProvider(): TtsProviderAdapter {
const providerName = String(config.tts.provider || "tencent")
.trim()
.toLowerCase();
if (providerName === "volcengine") {
return new VolcengineTtsProvider();
}
if (providerName === "tencent") {
return new TencentTtsProvider();
}
throw new TtsServiceError("TTS_DISABLED", `不支持的 TTS provider: ${providerName}`, 503);
}
export class TtsService {
private provider: TtsProviderAdapter;
private cache: TtsCacheStore;
private semaphore: AsyncSemaphore;
private inflight: Map<string, Promise<void>>;
private failures: Map<string, TtsBackgroundFailure>;
private inlineWaitMs: number;
constructor(options?: TtsServiceOptions) {
this.provider = options?.provider ?? createProvider();
this.cache =
options?.cache ??
new TtsCacheStore({
cacheDir: path.resolve(process.cwd(), "data/tts-cache"),
ttlMs: TTS_CACHE_TTL_MS,
maxTotalBytes: TTS_CACHE_TOTAL_MAX_BYTES,
// 不同 TTS 供应商的分片/码率差异较大,单文件上限统一改由配置驱动。
maxFileBytes: config.tts.cacheFileMaxBytes
});
this.semaphore = new AsyncSemaphore(TTS_PROVIDER_CONCURRENCY);
this.inflight = new Map();
this.failures = new Map();
this.inlineWaitMs = Math.max(0, Math.round(Number(options?.inlineWaitMs) || TTS_SYNTHESIZE_INLINE_WAIT_MS));
}
private ticketSecret(): string {
const secret = `${config.sync.secretCurrent}:${config.gatewayToken}`;
if (!config.sync.secretCurrent) {
throw new TtsServiceError("TTS_DISABLED", "同步密钥未配置,无法签发音频票据", 503);
}
return secret;
}
private ensureEnabled(): void {
if (!config.tts.enabled) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
}
private buildAudioAccessUrls(baseUrl: string, uid: string, cacheKey: string) {
const exp = Date.now() + TTS_AUDIO_TICKET_TTL_MS;
const ticket = createTtsAudioTicket(this.ticketSecret(), { uid, cacheKey, exp });
return {
audioUrl: `${baseUrl}/api/miniprogram/tts/audio/${cacheKey}?ticket=${encodeURIComponent(ticket)}`,
statusUrl: `${baseUrl}/api/miniprogram/tts/status/${cacheKey}?ticket=${encodeURIComponent(ticket)}`,
expiresAt: new Date(exp).toISOString()
};
}
private getRecentFailure(cacheKey: string): TtsBackgroundFailure | null {
const row = this.failures.get(cacheKey);
if (!row) {
return null;
}
if (row.expiresAt <= Date.now()) {
this.failures.delete(cacheKey);
return null;
}
return row;
}
private rememberFailure(cacheKey: string, error: unknown): void {
const failure =
error instanceof TtsServiceError
? {
code: error.code,
message: error.message,
status: error.status,
expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS
}
: {
code: "TTS_INTERNAL_ERROR",
message: error instanceof Error && error.message ? error.message : "语音生成失败",
status: 500,
expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS
};
this.failures.set(cacheKey, failure);
}
private async synthesizeCacheMiss(normalized: TtsNormalizedRequest): Promise<void> {
const result = await this.semaphore.use(async () => {
return await this.provider.synthesize({
text: normalized.normalizedText,
voice: normalized.voice,
speed: normalized.speed,
traceId: randomUUID()
});
}, TTS_PROVIDER_QUEUE_TIMEOUT_MS);
await this.cache.put(normalized.cacheKey, result.audio, result.contentType);
}
private ensureBackgroundSynthesis(normalized: TtsNormalizedRequest): void {
if (this.inflight.has(normalized.cacheKey)) {
return;
}
this.failures.delete(normalized.cacheKey);
const job = (async () => {
try {
await this.synthesizeCacheMiss(normalized);
} catch (error) {
this.rememberFailure(normalized.cacheKey, error);
logger.warn(
{
scene: normalized.scene,
textHash: normalized.textHash,
textLength: normalized.normalizedText.length,
cacheKey: normalized.cacheKey,
provider: this.provider.providerName,
err: error
},
"小程序 TTS 后台合成失败"
);
} finally {
this.inflight.delete(normalized.cacheKey);
}
})();
job.catch(() => {
// 后台任务的错误已经在内部收口到日志与 failure map这里只防止未处理拒绝。
});
this.inflight.set(normalized.cacheKey, job);
}
/**
* 首次 miss 时短暂等待后台任务:
* 1. 短文本常在 1 秒内就能合成完成,直接返回 ready 可省掉一轮轮询;
* 2. 等待窗口很短,慢请求仍按原有 pending 模式异步完成;
* 3. 这里复用同一个 inflight 任务,不会增加上游并发。
*/
private async waitInlineForReady(cacheKey: string): Promise<void> {
if (this.inlineWaitMs <= 0) {
return;
}
const inflight = this.inflight.get(cacheKey);
if (!inflight) {
return;
}
await Promise.race([
inflight.catch(() => {
// 失败状态仍交给后续 status 查询和 failure map 处理。
}),
sleep(this.inlineWaitMs)
]);
}
async synthesizeForUser(baseUrl: string, uid: string, input: TtsSynthesizeInput) {
this.ensureEnabled();
const normalized = normalizeTtsRequest(input);
let cached = await this.cache.get(normalized.cacheKey);
if (!cached) {
this.ensureBackgroundSynthesis(normalized);
await this.waitInlineForReady(normalized.cacheKey);
cached = await this.cache.get(normalized.cacheKey);
}
const ticketResult = this.buildAudioAccessUrls(baseUrl, uid, normalized.cacheKey);
const status = cached ? "ready" : "pending";
logger.info(
{
uid,
scene: normalized.scene,
textHash: normalized.textHash,
textLength: normalized.normalizedText.length,
cacheKey: normalized.cacheKey,
provider: this.provider.providerName,
cacheHit: !!cached,
synthStatus: status
},
cached ? "小程序 TTS 合成完成" : "小程序 TTS 合成任务已提交"
);
return {
ok: true,
cacheKey: normalized.cacheKey,
cached: !!cached,
status,
audioUrl: ticketResult.audioUrl,
statusUrl: ticketResult.statusUrl,
expiresAt: ticketResult.expiresAt
};
}
async getSynthesisStatus(cacheKey: string): Promise<TtsSynthesisStatus> {
// 先看内存态,再碰磁盘,避免轮询刚好撞在 cache.put 的写入窗口里把半成品误删。
if (this.inflight.has(cacheKey)) {
return { state: "pending" };
}
const cached = await this.cache.get(cacheKey);
if (cached) {
return { state: "ready" };
}
const failure = this.getRecentFailure(cacheKey);
if (failure) {
return {
state: "error",
code: failure.code,
message: failure.message,
status: failure.status
};
}
return { state: "missing" };
}
verifyAudioAccess(cacheKey: string, ticket: string): TtsAudioAccess {
const payload = verifyTtsAudioTicket(this.ticketSecret(), ticket);
if (payload.cacheKey !== cacheKey) {
throw new TtsServiceError("TTS_TICKET_INVALID", "音频票据无效", 403);
}
return payload;
}
async resolveCachedAudio(cacheKey: string) {
return await this.cache.get(cacheKey);
}
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket";
describe("tts ticket", () => {
it("应能签发并校验短时音频票据", () => {
const ticket = createTtsAudioTicket("ticket-secret", {
uid: "user-1",
cacheKey: "cache-1",
exp: Date.now() + 60_000
});
expect(verifyTtsAudioTicket("ticket-secret", ticket)).toMatchObject({
uid: "user-1",
cacheKey: "cache-1"
});
});
it("签名不一致时应拒绝通过", () => {
const ticket = createTtsAudioTicket("ticket-secret", {
uid: "user-1",
cacheKey: "cache-1",
exp: Date.now() + 60_000
});
expect(() => verifyTtsAudioTicket("other-secret", ticket)).toThrow(/signature invalid/);
});
});

View File

@@ -0,0 +1,64 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export interface TtsTicketPayload {
uid: string;
cacheKey: string;
exp: number;
}
function base64UrlEncode(input: string): string {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function base64UrlDecode(input: string): string {
const normalized = String(input || "")
.replace(/-/g, "+")
.replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Buffer.from(padded, "base64").toString("utf8");
}
function signPayload(secret: string, payload: string): string {
return createHmac("sha256", secret).update(payload, "utf8").digest("base64url");
}
export function createTtsAudioTicket(secret: string, payload: TtsTicketPayload): string {
const normalizedPayload = JSON.stringify({
uid: String(payload.uid || ""),
cacheKey: String(payload.cacheKey || ""),
exp: Math.max(0, Math.round(Number(payload.exp) || 0))
});
const encodedPayload = base64UrlEncode(normalizedPayload);
const signature = signPayload(secret, encodedPayload);
return `${encodedPayload}.${signature}`;
}
export function verifyTtsAudioTicket(secret: string, ticket: string): TtsTicketPayload {
const [encodedPayload, signature] = String(ticket || "").split(".");
if (!encodedPayload || !signature) {
throw new Error("ticket malformed");
}
const expected = signPayload(secret, encodedPayload);
const signatureBuffer = Buffer.from(signature, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new Error("ticket signature invalid");
}
const payload = JSON.parse(base64UrlDecode(encodedPayload)) as Partial<TtsTicketPayload>;
const exp = Math.max(0, Math.round(Number(payload.exp) || 0));
if (!payload.uid || !payload.cacheKey || !exp) {
throw new Error("ticket payload invalid");
}
if (Date.now() >= exp) {
throw new Error("ticket expired");
}
return {
uid: String(payload.uid),
cacheKey: String(payload.cacheKey),
exp
};
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { extractAsrText } from "./asrText";
describe("extractAsrText", () => {
it("支持 result.text 结构", () => {
expect(
extractAsrText({
result: { text: "你好世界" }
})
).toBe("你好世界");
});
it("支持 result 数组结构", () => {
expect(
extractAsrText({
result: [{ text: "数组文本" }]
})
).toBe("数组文本");
});
it("支持 utterances 结构", () => {
expect(
extractAsrText({
result: { utterances: [{ text: "分句一" }, { text: "分句二" }] }
})
).toBe("分句一");
});
it("支持 payload_msg.result 结构", () => {
expect(
extractAsrText({
payload_msg: {
result: [{ text: "包装字段文本" }]
}
})
).toBe("包装字段文本");
});
it("支持 alternatives.transcript 结构", () => {
expect(
extractAsrText({
result: {
alternatives: [{ transcript: "候选转写文本" }]
}
})
).toBe("候选转写文本");
});
it("支持嵌套 data.result.sentence 结构", () => {
expect(
extractAsrText({
data: {
result: {
sentence: "嵌套句子文本"
}
}
})
).toBe("嵌套句子文本");
});
it("无可识别文本时返回空串", () => {
expect(extractAsrText({ result: [{ start_time: 1 }] })).toBe("");
});
});

View File

@@ -0,0 +1,114 @@
function asRecord(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
return input as Record<string, unknown>;
}
function firstNonEmpty(items: unknown[]): string {
for (const item of items) {
const text = pickText(item);
if (text) {
return text;
}
}
return "";
}
/**
* 从未知结构中提取识别文本:
* 兼容 result.text / result[] / utterances[] / payload_msg 等常见形态。
*/
function pickText(input: unknown): string {
if (typeof input === "string") {
return input.trim() ? input : "";
}
if (Array.isArray(input)) {
return firstNonEmpty(input);
}
const record = asRecord(input);
if (!record) {
return "";
}
const directText = record.text;
if (typeof directText === "string" && directText.trim()) {
return directText;
}
const aliasTextKeys = ["transcript", "sentence", "content", "utterance", "final_text", "display_text"];
for (const key of aliasTextKeys) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
const utterances = record.utterances;
if (Array.isArray(utterances)) {
const utterText = firstNonEmpty(utterances);
if (utterText) {
return utterText;
}
}
const arrayLikeKeys = ["alternatives", "results", "hypotheses", "nbest", "sentences", "segments", "list"];
for (const key of arrayLikeKeys) {
const candidateList = record[key];
if (Array.isArray(candidateList)) {
const text = firstNonEmpty(candidateList);
if (text) {
return text;
}
}
}
const nestedKeys = ["result", "payload_msg", "data", "value"];
for (const key of nestedKeys) {
const nested = record[key];
const text = pickText(nested);
if (text) {
return text;
}
}
return "";
}
export function extractAsrText(payload: unknown): string {
if (!payload) {
return "";
}
if (Buffer.isBuffer(payload)) {
const text = payload.toString("utf8");
return text.trim() ? text : "";
}
const root = asRecord(payload);
if (!root) {
return pickText(payload);
}
// 按优先级尝试常见字段,命中即返回。
const candidates: unknown[] = [
root.result,
asRecord(root.result)?.result,
root.payload_msg,
asRecord(root.payload_msg)?.result,
root.data,
asRecord(root.data)?.result,
root
];
for (const candidate of candidates) {
const text = pickText(candidate);
if (text) {
return text;
}
}
return "";
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseVoiceClientFrame } from "./clientProtocol";
describe("parseVoiceClientFrame", () => {
it("文本控制帧即使以 Buffer 形式到达也能解析为 start", () => {
const raw = Buffer.from(
JSON.stringify({
type: "start",
payload: {
audio: { format: "pcm", rate: 16000, bits: 16, channel: 1 }
}
}),
"utf8"
);
const frame = parseVoiceClientFrame(raw, false);
expect(frame.type).toBe("start");
});
it("二进制帧应解析为 audio", () => {
const raw = Buffer.from([1, 2, 3, 4]);
const frame = parseVoiceClientFrame(raw, true);
expect(frame.type).toBe("audio");
if (frame.type !== "audio") {
return;
}
expect(frame.payload.length).toBe(4);
});
});

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import type { RawData } from "ws";
const startPayloadSchema = z
.object({
user: z.record(z.string(), z.unknown()).optional(),
audio: z
.object({
format: z.enum(["pcm", "wav", "ogg", "mp3"]).optional(),
codec: z.enum(["raw", "opus"]).optional(),
rate: z.number().int().positive().optional(),
bits: z.number().int().positive().optional(),
channel: z.number().int().positive().optional(),
language: z.string().min(2).max(16).optional()
})
.optional(),
request: z.record(z.string(), z.unknown()).optional()
})
.optional();
const controlFrameSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("start"), payload: startPayloadSchema }),
z.object({ type: z.literal("stop") }),
z.object({ type: z.literal("cancel") }),
z.object({ type: z.literal("ping") })
]);
export type VoiceClientControlFrame = z.infer<typeof controlFrameSchema>;
export type VoiceClientFrame = VoiceClientControlFrame | { type: "audio"; payload: Buffer };
function rawToBuffer(raw: RawData): Buffer {
if (Buffer.isBuffer(raw)) {
return raw;
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw);
}
if (Array.isArray(raw)) {
const chunks = raw.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
return Buffer.concat(chunks);
}
return Buffer.from(raw);
}
/**
* 前端协议:
* - 文本帧JSON 控制消息start/stop/cancel/ping
* - 二进制帧原始音频分片PCM16LE
*/
export function parseVoiceClientFrame(raw: RawData, isBinary: boolean): VoiceClientFrame {
if (!isBinary) {
const text = typeof raw === "string" ? raw : rawToBuffer(raw).toString("utf8");
return controlFrameSchema.parse(JSON.parse(text));
}
const asBuffer = rawToBuffer(raw);
if (asBuffer.length === 0) {
throw new Error("audio frame is empty");
}
return {
type: "audio",
payload: asBuffer
};
}
export function safeSendVoiceFrame(ws: { send: (data: string) => void }, frame: unknown): void {
ws.send(JSON.stringify(frame));
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { inferAsrJsonFinal, parseLooseJsonPayloads } from "./upstreamPayload";
describe("parseLooseJsonPayloads", () => {
it("支持标准 JSON 文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"你好"}}')).toEqual([{ result: { text: "你好" } }]);
});
it("支持 NDJSON 文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"a"}}\n{"result":{"text":"b"}}')).toEqual([
{ result: { text: "a" } },
{ result: { text: "b" } }
]);
});
it("支持 JSON 粘包文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"a"}}{"result":{"text":"b"}}')).toEqual([
{ result: { text: "a" } },
{ result: { text: "b" } }
]);
});
it("对明显非 JSON 内容返回空数组", () => {
expect(parseLooseJsonPayloads("not-a-json-frame")).toEqual([]);
});
});
describe("inferAsrJsonFinal", () => {
it("识别根节点 final 标记", () => {
expect(inferAsrJsonFinal({ is_final: true })).toBe(true);
});
it("识别嵌套状态完成标记", () => {
expect(inferAsrJsonFinal({ result: { status: "completed" } })).toBe(true);
});
it("无完成标记时返回 false", () => {
expect(inferAsrJsonFinal({ result: { text: "partial" } })).toBe(false);
});
});

View File

@@ -0,0 +1,175 @@
const JSON_FALLBACK_MAX_SCAN_CHARS = 512 * 1024;
function isTruthyFlag(value: unknown): boolean {
if (value === true) {
return true;
}
if (typeof value === "number") {
return value === 1;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return ["1", "true", "yes", "on", "final", "finished", "done", "end", "completed"].includes(normalized);
}
return false;
}
/**
* 兼容上游偶发“非标准文本帧”:
* 1) 标准 JSON
* 2) NDJSON一行一个 JSON
* 3) 多个 JSON 粘包({"a":1}{"b":2})。
*
* 额外约束:
* - 对超大文本直接放弃兼容扫描,避免 CPU 被异常帧拖垮;
* - 仅从首个 `{` / `[` 开始扫描,跳过前缀噪音(例如日志前缀)。
*/
export function parseLooseJsonPayloads(rawText: string): unknown[] {
const trimmed = rawText.trim();
if (!trimmed || trimmed.length > JSON_FALLBACK_MAX_SCAN_CHARS) {
return [];
}
const firstJsonTokenIndex = trimmed.search(/[{[]/);
if (firstJsonTokenIndex < 0) {
return [];
}
const text = trimmed.slice(firstJsonTokenIndex);
if (!text) {
return [];
}
try {
return [JSON.parse(text)];
} catch {
// 继续尝试 line-delimited / 拼接 JSON 形态。
}
const linePayloads: unknown[] = [];
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length > 1) {
for (const line of lines) {
if (!line.startsWith("{") && !line.startsWith("[")) {
continue;
}
try {
linePayloads.push(JSON.parse(line));
} catch {
// 某一行不是 JSON 时忽略,继续尝试其他行。
}
}
if (linePayloads.length > 0) {
return linePayloads;
}
}
const chunkPayloads: unknown[] = [];
let start = -1;
let depth = 0;
let quote: '"' | "'" | null = null;
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i] ?? "";
if (start < 0) {
if (ch === "{" || ch === "[") {
start = i;
depth = 1;
quote = null;
escaped = false;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
quote = null;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch as '"' | "'";
continue;
}
if (ch === "{" || ch === "[") {
depth += 1;
continue;
}
if (ch === "}" || ch === "]") {
depth -= 1;
if (depth === 0 && start >= 0) {
const segment = text.slice(start, i + 1);
start = -1;
try {
chunkPayloads.push(JSON.parse(segment));
} catch {
// 片段不是有效 JSON 时忽略。
}
}
}
}
return chunkPayloads;
}
export function inferAsrJsonFinal(payload: unknown): boolean {
const queue: unknown[] = [payload];
const visited = new Set<object>();
const finalKeys = ["is_final", "isFinal", "final", "finished", "end", "is_end", "isEnd", "complete", "completed"];
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object") {
continue;
}
if (visited.has(current)) {
continue;
}
visited.add(current);
if (Array.isArray(current)) {
queue.push(...current);
continue;
}
const record = current as Record<string, unknown>;
for (const key of finalKeys) {
if (isTruthyFlag(record[key])) {
return true;
}
}
if (typeof record.status === "string" && isTruthyFlag(record.status)) {
return true;
}
if (typeof record.type === "string" && isTruthyFlag(record.type)) {
return true;
}
queue.push(
record.result,
record.payload_msg,
record.data,
record.payload,
record.message,
record.messages,
record.utterances,
record.alternatives
);
}
return false;
}

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { gzipSync } from "node:zlib";
import {
VolcCompression,
VolcMessageFlags,
VolcMessageType,
VolcSerialization,
buildAudioOnlyRequestFrame,
buildVolcHeader,
isFinalServerResponse,
parseVolcServerFrame
} from "./volcAsrProtocol";
describe("volcAsrProtocol", () => {
it("audio-only 最后一包应携带 LAST_PACKAGE flag", () => {
const frame = buildAudioOnlyRequestFrame(Buffer.from([1, 2, 3, 4]), true);
const flag = (frame[1] ?? 0) & 0x0f;
expect(flag).toBe(VolcMessageFlags.LAST_PACKAGE);
});
it("可解析 gzip + json 的 full server response", () => {
const payloadObj = {
result: {
text: "测试文本"
}
};
const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8"));
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_SERVER_RESPONSE,
flags: VolcMessageFlags.POSITIVE_SEQUENCE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const sequence = Buffer.alloc(4);
sequence.writeInt32BE(2, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(payload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload]));
expect(parsed.kind).toBe("server_response");
if (parsed.kind !== "server_response") {
return;
}
expect(parsed.sequence).toBe(2);
expect((parsed.payload as { result: { text: string } }).result.text).toBe("测试文本");
});
it("flags=LAST_PACKAGE 且包含 sequence 时可兼容解析", () => {
const payloadObj = {
result: {
text: "结束包"
}
};
const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8"));
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_SERVER_RESPONSE,
flags: VolcMessageFlags.LAST_PACKAGE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const sequence = Buffer.alloc(4);
sequence.writeInt32BE(7, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(payload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload]));
expect(parsed.kind).toBe("server_response");
if (parsed.kind !== "server_response") {
return;
}
expect(parsed.sequence).toBe(7);
expect((parsed.payload as { result: { text: string } }).result.text).toBe("结束包");
});
it("可解析 error response", () => {
const errorPayload = Buffer.from("invalid request", "utf8");
const header = buildVolcHeader({
messageType: VolcMessageType.ERROR_RESPONSE,
flags: VolcMessageFlags.NONE,
serialization: VolcSerialization.NONE,
compression: VolcCompression.NONE
});
const errorCode = Buffer.alloc(4);
errorCode.writeUInt32BE(45000001, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(errorPayload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, errorCode, payloadSize, errorPayload]));
expect(parsed.kind).toBe("error");
if (parsed.kind !== "error") {
return;
}
expect(parsed.errorCode).toBe(45000001);
expect(Buffer.isBuffer(parsed.payload)).toBe(true);
expect((parsed.payload as Buffer).toString("utf8")).toBe("invalid request");
});
it("final flag 判定覆盖 LAST_PACKAGE 与 NEGATIVE_SEQUENCE", () => {
expect(isFinalServerResponse(VolcMessageFlags.LAST_PACKAGE)).toBe(true);
expect(isFinalServerResponse(VolcMessageFlags.NEGATIVE_SEQUENCE)).toBe(true);
expect(isFinalServerResponse(VolcMessageFlags.POSITIVE_SEQUENCE)).toBe(false);
});
});

View File

@@ -0,0 +1,265 @@
import { gunzipSync, gzipSync } from "node:zlib";
export const VOLC_PROTOCOL_VERSION = 0b0001;
export const VOLC_HEADER_SIZE_WORDS = 0b0001; // 1 * 4 bytes
export const enum VolcMessageType {
FULL_CLIENT_REQUEST = 0b0001,
AUDIO_ONLY_REQUEST = 0b0010,
FULL_SERVER_RESPONSE = 0b1001,
ERROR_RESPONSE = 0b1111
}
export const enum VolcMessageFlags {
NONE = 0b0000,
POSITIVE_SEQUENCE = 0b0001,
LAST_PACKAGE = 0b0010,
NEGATIVE_SEQUENCE = 0b0011
}
export const enum VolcSerialization {
NONE = 0b0000,
JSON = 0b0001
}
export const enum VolcCompression {
NONE = 0b0000,
GZIP = 0b0001
}
export interface VolcFullClientRequestPayload {
user?: Record<string, unknown>;
audio: {
format: "pcm" | "wav" | "ogg" | "mp3";
codec?: "raw" | "opus";
rate?: number;
bits?: number;
channel?: number;
language?: string;
};
request: Record<string, unknown> & {
model_name: string;
};
}
export interface ParsedVolcServerResponse {
kind: "server_response";
flags: number;
sequence: number | null;
payload: unknown;
}
export interface ParsedVolcServerError {
kind: "error";
flags: number;
errorCode: number;
payload: unknown;
}
export interface ParsedVolcUnknownFrame {
kind: "unknown";
messageType: number;
flags: number;
payload: unknown;
}
export type ParsedVolcServerFrame = ParsedVolcServerResponse | ParsedVolcServerError | ParsedVolcUnknownFrame;
function decodePayload(serialization: number, compression: number, payload: Buffer): unknown {
const inflated = compression === VolcCompression.GZIP ? gunzipSync(payload) : payload;
if (serialization === VolcSerialization.NONE) {
return inflated;
}
if (serialization === VolcSerialization.JSON) {
const rawText = inflated.toString("utf8");
return JSON.parse(rawText);
}
throw new Error(`unsupported serialization method: ${serialization}`);
}
function encodeJsonPayload(payload: unknown, compression: VolcCompression): Buffer {
const raw = Buffer.from(JSON.stringify(payload), "utf8");
if (compression === VolcCompression.GZIP) {
return gzipSync(raw);
}
return raw;
}
function encodeBinaryPayload(payload: Buffer, compression: VolcCompression): Buffer {
if (compression === VolcCompression.GZIP) {
return gzipSync(payload);
}
return payload;
}
export function buildVolcHeader(params: {
messageType: number;
flags: number;
serialization: number;
compression: number;
}): Buffer {
const header = Buffer.alloc(4);
header[0] = ((VOLC_PROTOCOL_VERSION & 0x0f) << 4) | (VOLC_HEADER_SIZE_WORDS & 0x0f);
header[1] = ((params.messageType & 0x0f) << 4) | (params.flags & 0x0f);
header[2] = ((params.serialization & 0x0f) << 4) | (params.compression & 0x0f);
header[3] = 0;
return header;
}
/**
* 构造 full client request
* 1) JSON 序列化;
* 2) 使用 GZIP 压缩;
* 3) payload size 使用 4 字节大端无符号整数。
*/
export function buildFullClientRequestFrame(payload: VolcFullClientRequestPayload): Buffer {
const compressedPayload = encodeJsonPayload(payload, VolcCompression.GZIP);
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_CLIENT_REQUEST,
flags: VolcMessageFlags.NONE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(compressedPayload.length, 0);
return Buffer.concat([header, payloadSize, compressedPayload]);
}
/**
* 构造 audio-only request
* - payload 直接是二进制音频PCM16LE
* - 与 full request 保持一致,启用 GZIP 压缩;
* - final=true 时设置 LAST_PACKAGE 标记。
*/
export function buildAudioOnlyRequestFrame(audioPayload: Buffer, final: boolean): Buffer {
const compressedPayload = encodeBinaryPayload(audioPayload, VolcCompression.GZIP);
const header = buildVolcHeader({
messageType: VolcMessageType.AUDIO_ONLY_REQUEST,
flags: final ? VolcMessageFlags.LAST_PACKAGE : VolcMessageFlags.NONE,
serialization: VolcSerialization.NONE,
compression: VolcCompression.GZIP
});
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(compressedPayload.length, 0);
return Buffer.concat([header, payloadSize, compressedPayload]);
}
/**
* 根据文档约定判断“服务端是否为最后一包结果”。
*/
export function isFinalServerResponse(flags: number): boolean {
return flags === VolcMessageFlags.LAST_PACKAGE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE;
}
/**
* 解析服务端二进制帧:
* - FULL_SERVER_RESPONSE: [header][sequence?][payload_size][payload]
* - ERROR_RESPONSE: [header][error_code][payload_size][payload]
*/
export function parseVolcServerFrame(frame: Buffer): ParsedVolcServerFrame {
if (frame.length < 8) {
throw new Error("invalid volc frame: too short");
}
const headerByte0 = frame[0] ?? 0;
const headerByte1 = frame[1] ?? 0;
const headerByte2 = frame[2] ?? 0;
const headerSizeWords = headerByte0 & 0x0f;
const headerSizeBytes = headerSizeWords * 4;
if (headerSizeWords < 1 || frame.length < headerSizeBytes + 4) {
throw new Error("invalid volc frame: bad header size");
}
const messageType = (headerByte1 & 0xf0) >> 4;
const flags = headerByte1 & 0x0f;
const serialization = (headerByte2 & 0xf0) >> 4;
const compression = headerByte2 & 0x0f;
if (messageType === VolcMessageType.FULL_SERVER_RESPONSE) {
const parseServerResponseVariant = (
withSequence: boolean
): { ok: true; value: ParsedVolcServerResponse } | { ok: false; reason: string } => {
let offset = headerSizeBytes;
let sequence: number | null = null;
if (withSequence) {
if (frame.length < offset + 4) {
return { ok: false, reason: "invalid volc frame: missing sequence" };
}
sequence = frame.readInt32BE(offset);
offset += 4;
}
if (frame.length < offset + 4) {
return { ok: false, reason: "invalid volc frame: missing payload size" };
}
const payloadSize = frame.readUInt32BE(offset);
offset += 4;
if (frame.length < offset + payloadSize) {
return { ok: false, reason: "invalid volc frame: payload truncated" };
}
const payloadBuffer = frame.subarray(offset, offset + payloadSize);
try {
return {
ok: true,
value: {
kind: "server_response",
flags,
sequence,
payload: decodePayload(serialization, compression, payloadBuffer)
}
};
} catch (error) {
return {
ok: false,
reason: `invalid volc frame: ${(error as Error).message}`
};
}
};
const preferSequenceFirst = flags === VolcMessageFlags.POSITIVE_SEQUENCE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE;
const firstTry = parseServerResponseVariant(preferSequenceFirst);
if (firstTry.ok) {
return firstTry.value;
}
const secondTry = parseServerResponseVariant(!preferSequenceFirst);
if (secondTry.ok) {
return secondTry.value;
}
throw new Error(secondTry.reason || firstTry.reason);
}
if (messageType === VolcMessageType.ERROR_RESPONSE) {
let offset = headerSizeBytes;
if (frame.length < offset + 8) {
throw new Error("invalid volc frame: bad error frame");
}
const errorCode = frame.readUInt32BE(offset);
offset += 4;
const payloadSize = frame.readUInt32BE(offset);
offset += 4;
if (frame.length < offset + payloadSize) {
throw new Error("invalid volc frame: error payload truncated");
}
const payloadBuffer = frame.subarray(offset, offset + payloadSize);
return {
kind: "error",
flags,
errorCode,
payload: decodePayload(serialization, compression, payloadBuffer)
};
}
let payload: unknown = Buffer.alloc(0);
if (frame.length >= headerSizeBytes + 4) {
const payloadSize = frame.readUInt32BE(headerSizeBytes);
const payloadOffset = headerSizeBytes + 4;
if (frame.length >= payloadOffset + payloadSize) {
payload = decodePayload(serialization, compression, frame.subarray(payloadOffset, payloadOffset + payloadSize));
}
}
return {
kind: "unknown",
messageType,
flags,
payload
};
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistTxnDeduper } from "./assistTxnDeduper";
describe("assistTxnDeduper", () => {
it("仅对 assist + txnId 生效,其它输入不去重", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("keyboard", "a")).toBe(false);
expect(dedupe("assist", undefined)).toBe(false);
expect(dedupe(undefined, "a")).toBe(false);
});
it("相同 txnId 在 ttl 内应判定为重复", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("assist", "txn-1")).toBe(false);
expect(dedupe("assist", "txn-1")).toBe(true);
});
it("超过 ttl 后同一 txnId 可重新通过", () => {
vi.useFakeTimers();
try {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("assist", "txn-1")).toBe(false);
vi.advanceTimersByTime(1001);
expect(dedupe("assist", "txn-1")).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("超过 cacheLimit 时应淘汰最旧记录", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 60_000, cacheLimit: 2 });
expect(dedupe("assist", "txn-1")).toBe(false);
expect(dedupe("assist", "txn-2")).toBe(false);
expect(dedupe("assist", "txn-3")).toBe(false);
expect(dedupe("assist", "txn-2")).toBe(true);
expect(dedupe("assist", "txn-3")).toBe(true);
expect(dedupe("assist", "txn-1")).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
export interface AssistTxnDeduperOptions {
ttlMs: number;
cacheLimit: number;
}
export function createAssistTxnDeduper(options: AssistTxnDeduperOptions): (source?: string, txnId?: string) => boolean {
const seenAt = new Map<string, number>();
return (source?: string, txnId?: string): boolean => {
if (source !== "assist" || !txnId) {
return false;
}
const now = Date.now();
for (const [id, at] of seenAt.entries()) {
if (now - at > options.ttlMs) {
seenAt.delete(id);
}
}
if (seenAt.has(txnId)) {
return true;
}
seenAt.set(txnId, now);
if (seenAt.size > options.cacheLimit) {
const oldestId = seenAt.keys().next().value as string | undefined;
if (oldestId) {
seenAt.delete(oldestId);
}
}
return false;
};
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { parseInboundFrame } from "./protocol";
describe("gateway protocol", () => {
it("解析 init 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "init",
payload: {
host: "127.0.0.1",
port: 22,
username: "root",
resumeGraceMs: 900000,
credential: { type: "password", password: "x" },
pty: { cols: 80, rows: 24 }
}
})
);
expect(parsed.type).toBe("init");
if (parsed.type === "init") {
expect(parsed.payload.resumeGraceMs).toBe(900000);
}
});
it("解析带跳板机的 init 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "init",
payload: {
host: "10.0.0.10",
port: 22,
username: "deploy",
credential: { type: "privateKey", privateKey: "TARGET_KEY" },
jumpHost: {
host: "10.0.0.1",
port: 2222,
username: "bastion",
credential: { type: "password", password: "secret" }
},
pty: { cols: 120, rows: 32 }
}
})
);
expect(parsed.type).toBe("init");
if (parsed.type === "init") {
expect(parsed.payload.jumpHost?.host).toBe("10.0.0.1");
expect(parsed.payload.jumpHost?.port).toBe(2222);
expect(parsed.payload.jumpHost?.username).toBe("bastion");
}
});
it("解析带 meta 的 stdin 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "stdin",
payload: {
data: "测试",
meta: {
source: "assist",
txnId: "assist-1"
}
}
})
);
expect(parsed.type).toBe("stdin");
if (parsed.type === "stdin") {
expect(parsed.payload.meta?.source).toBe("assist");
expect(parsed.payload.meta?.txnId).toBe("assist-1");
}
});
it("解析带原因的 disconnect 控制帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "control",
payload: {
action: "disconnect",
reason: "manual"
}
})
);
expect(parsed.type).toBe("control");
if (parsed.type === "control") {
expect(parsed.payload.action).toBe("disconnect");
expect(parsed.payload.reason).toBe("manual");
}
});
});

View File

@@ -0,0 +1,94 @@
import { z } from "zod";
const initPayloadSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive().max(65535),
username: z.string().min(1),
clientSessionKey: z.string().min(1).max(128).optional(),
/**
* 续接驻留窗口(毫秒):
* - 允许客户端按连接声明“离开页面后保留 SSH 会话多久”;
* - 服务器侧仍会做最小/最大值裁剪。
*/
resumeGraceMs: z
.number()
.int()
.positive()
.max(24 * 60 * 60 * 1000)
.optional(),
credential: z.union([
z.object({ type: z.literal("password"), password: z.string().min(1) }),
z.object({
type: z.literal("privateKey"),
privateKey: z.string().min(1),
passphrase: z.string().optional()
}),
z.object({
type: z.literal("certificate"),
privateKey: z.string().min(1),
passphrase: z.string().optional(),
certificate: z.string().min(1)
})
]),
jumpHost: z
.object({
host: z.string().min(1),
port: z.number().int().positive().max(65535),
username: z.string().min(1),
credential: z.union([
z.object({ type: z.literal("password"), password: z.string().min(1) }),
z.object({
type: z.literal("privateKey"),
privateKey: z.string().min(1),
passphrase: z.string().optional()
}),
z.object({
type: z.literal("certificate"),
privateKey: z.string().min(1),
passphrase: z.string().optional(),
certificate: z.string().min(1)
})
]),
knownHostFingerprint: z.string().optional()
})
.optional(),
knownHostFingerprint: z.string().optional(),
pty: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() })
});
const stdinMetaSchema = z.object({
source: z.enum(["keyboard", "assist"]),
txnId: z.string().min(1).max(128).optional()
});
const inboundFrameSchema = z.union([
z.object({ type: z.literal("init"), payload: initPayloadSchema }),
z.object({
type: z.literal("stdin"),
payload: z.object({
data: z.string(),
meta: stdinMetaSchema.optional()
})
}),
z.object({
type: z.literal("resize"),
payload: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() })
}),
z.object({
type: z.literal("control"),
payload: z.object({
action: z.enum(["ping", "pong", "disconnect"]),
reason: z.string().min(1).max(128).optional()
})
})
]);
export type InboundFrame = z.infer<typeof inboundFrameSchema>;
export function parseInboundFrame(raw: string): InboundFrame {
return inboundFrameSchema.parse(JSON.parse(raw));
}
export function safeSend(socket: { send: (data: string) => void }, frame: unknown): void {
socket.send(JSON.stringify(frame));
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@remoteconn/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,12 @@
# MiniProgram 运维配置(不对用户展示)
GATEWAY_URL=wss://gateway.example.com
GATEWAY_TOKEN=replace-with-strong-random-token
# 可选:其余参数不填则使用代码默认值
HOST_KEY_POLICY=strict
CREDENTIAL_MEMORY_POLICY=remember
GATEWAY_CONNECT_TIMEOUT_MS=12000
WAIT_FOR_CONNECTED_TIMEOUT_MS=15000
TERMINAL_BUFFER_MAX_ENTRIES=5000
TERMINAL_BUFFER_MAX_BYTES=4194304
MASK_SECRETS=true

View File

@@ -0,0 +1,82 @@
# RemoteConn MiniProgram稳定基线 v3.0.1
状态:`已对齐核心功能,当前稳定基线为 v3.0.1`
目标版本:`v3.0.1`
本次 `v3.0.1` 主要收口小程序使用手册与主题口径使用手册补成图文版正文前置“为什么需要这个APP并接入 `guide-mobile-*` 配图;主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换且尽量压短为单词短名。当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。
## 1. 当前已完成范围
1. 页面骨架:`connect``server settings``terminal``logs``records``settings``plugins`
2. 数据基础层:服务器/设置/闪念以“本地 storage + Gateway 同步”双层持久化,日志与插件运行记录仍保留本地存储;已补齐 `updatedAt / category / contextLabel / processed / discarded`
3. 传输:`wx.connectSocket` 网关连接、心跳、收发、断开。
4. 分页:日志与闪念均为 15 条/页。
5. 语音区Frame2256 交互 + `/ws/asr` 实时采集上行 + 结果回填,支持分类选择、未连接可记录闪念、写入 `服务器名称-项目名` 快照。
6. 闪念页:搜索、分类过滤、左滑废弃 / 已处理 / 复制 / 删除、正文编辑、分类快速改写、导出。
7. 配置页:`记录 -> 闪念分类` 管理,支持新增、设默认、删除、按住拖动排序。
8. 服务器:配置页支持自定义标签(逗号分隔),服务器卡片底部展示项目标签与自定义标签,并支持左滑复制 / 删除单台服务器。
9. 插件:运行时启停/重载/移除、JSON 导入导出、命令执行、运行日志。
10. 资源:图标路径统一版本 query`v=2026022701`)。
11. 终端:已按 xterm 风格完成 cell 光标模型重构,补齐真实 `cols/rows`、宽字符 continuation、自绘 caret、固定列宽渲染与双 probe 宽度测量,当前中文/英文混排输入已稳定。
12. 会话续接:终端页返回其他页面后默认保活 `15` 分钟,可在设置页调到 `1~60` 分钟;回到同一服务器会优先复用原会话并恢复尾部缓冲。首次恢复优先使用保存时的 `lines + bufferCols / bufferRows` 还原当前屏幕,仅在后续真实几何变化时再使用 `replayText` 重建,避免顶部空白与裸露 `5;2H` 一类控制串回归。
13. 连接反馈:服务器列表“连接”按钮与底栏 `shell` 按钮在活动连接态统一切到高饱和实底高亮,便于快速识别当前连接状态。
14. 跳转主机:服务器配置页已支持跳板机主机、端口、用户名、认证方式与独立凭据;目录选择与终端连接链路均可透传第一跳/第二跳配置。
15. AI 快速启动:服务器列表 `AI` 按钮和终端左上角 AI 按钮都会按“全局配置 -> 连接 -> AI连接”的默认 AI 直接启动;终端页支持 Codex 目录预检、二进制预检与连接后自动启动。当前同一条终端会话内新增了 AI 前台互斥保护AI 运行期间重复点击会直接提示而不再把启动命令写进前台 TUI当 Codex / Copilot 正常退出回到 shell 后,保护会自动解除。
16. 终端语音与主题视觉已继续收口:展开语音区按钮默认全透明、分类胶囊改为贴文字高度、录音中输入框上方新增脉冲提示;终端顶栏与语音区按钮继续按 UI / Shell 双域规则渲染。
## 2. 当前说明
1. 机读对齐清单已更新为 `done`,见 `apps/miniprogram/parity.v2.6.0.json`
2. 小程序终端 VT 能力已进入 P0 基线:双缓冲、备用屏幕切换、`DSR / CPR / DA1 / DA2 / DECSTR`、基础局部重绘和移动端常用原始按键编码已接入。
3. normal buffer 的 live tail 与最大滚动值已统一到同一口径,回到底部后不会再继续把当前命令行往上推。
4. 当前仍建议通过微信开发者工具和真机做交互验收,尤其是服务器卡片左滑复制 / 删除、闪念左滑废弃 / 已处理 / 复制 / 删除、分类切换、语音记录链路,以及 `top / less / vim` 一类终端程序的抽样验证。
5. 终端字号修改后仍存在偶发“吃字/显示不完整”遗留问题,当前版本在设置页增加了“修改字号后建议断开重连”的提示,作为临时规避方案。
6. 当前业务数据采用“双层持久化”:
服务器配置、用户设置与闪念记录会先写入小程序本地 storage并在同步配置完整时通过 Gateway + SQLite 同步到服务端;
日志、插件运行记录与终端会话缓冲仍仅保留在本地。
通过 `npm run mini` 生成的 preview 预览二维码,不作为正式版跨设备同步与本地缓存连续性的验证依据。
7. Gateway 的同步 SQLite 目前启用 `WAL` 模式:
`data/remoteconn-sync.db-wal` 是写前日志,文件体积不直接等于当前有效同步数据量;
需要结合 checkpoint 后的主库体积、`user_settings / user_servers / user_records` 行数与字段大小一起判断。
8. 当前版本最重要的交互修复是:小程序终端在 `Codex` 持续输出期间底部提示块缺行、状态行被裁掉与区域反复闪动的问题已收口normal buffer viewport 会保留光标行之后仍真实存在的 footer`CSI ? 2026 h/l` 同步刷新窗口也已做兼容。
9. 同一轮交互修复还收口了高亮块透底细线问题:像 `> Use /skills to list available skills` 和代码块这类整行统一背景区域,背景优先提升到 line 层绘制,不再在行与行之间露出底色细缝,也没有新增渲染节点。
10. 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其作为已知遗留问题保留。
11. 当前另记录一个低频连接时序遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;初步定位为 `connected` 状态与光标可见时机早于首个可见 `stdout / prompt`,后续再继续优化。
12. 会话续接恢复口径已明确:首次回到终端页时,以挂起前屏幕快照和当时终端几何作为权威来源;被裁剪过的 `replayText` 不再参与首次恢复,避免历史区顶部空白和定位参数残片再次出现。
13. 时延诊断浮窗已收口成单张双轴平滑曲线图:左轴显示网关响应,右轴显示网络时延;顶部只保留两张摘要卡,面板配色跟随终端主题反相推导,深色终端会自动切到更深的蓝橙曲线与指标色;同一服务器断开重连后会尽量延续最近 `30` 个采样点。
14. 当前小程序终端语音播报仍属于待优化能力:播报文本提取与轮次稳定判定还不够准确,长时间 `Codex` 交互时也会放大小程序端响应压力;现阶段暂不建议默认使用,先作为遗留问题保留。
15. 当前另记录一个 AI 交互期输入遗留问题:在 `Codex` 等 AI 持续输出期间,点击 shell 激活区弹出软键盘后,输入框/激活区仍可能发生跳跃,导致无法稳定连续输入;该问题尚未完成稳定修复,先按已知遗留问题登记。
## 3. 对齐清单
机读清单:`apps/miniprogram/parity.v2.6.0.json`
## 4. 实施依据
1. `docs/records-enhancement-plan-2026-03-06.md`
2. `docs/miniprogram-config-implementation-plan-2026-02-28.md`
3. `docs/xterm-cursor-algorithm-2026-03-07.md`
4. `docs/miniprogram-terminal-cursor-gap-analysis-2026-03-07.md`
5. `docs/ssh-jump-encryption-diagram-2026-03-07.md`
6. `docs/miniprogram-terminal-vt-guardrails-2026-03-08.md`
7. `docs/miniprogram-terminal-vt-implementation-plan-2026-03-08.md`
8. `docs/miniprogram-codex-footer-flicker-optimization-plan-2026-03-11.md`
## 5. 本地调试
1. 使用微信开发者工具打开 `apps/miniprogram` 目录。
2. 如需连通网关,在 `apps/miniprogram/.env` 中配置运维配置:至少包含 `GATEWAY_URL``GATEWAY_TOKEN`(可参考 `apps/miniprogram/.env.example`,该配置不对最终用户展示)。
3. 回到“服务器”页创建服务器并进入终端测试连通性。
## 6. 命令行预览
在仓库根目录可直接使用以下脚本:
1. `npm run mini`
在终端输出小程序预览二维码;若终端二维码首次渲染失败,会自动在 shell 中重画一次。
说明:
1. 该命令会先读取 `apps/miniprogram/.env`,并生成 `apps/miniprogram/utils/opsEnv.js` 供小程序运行时使用。
2. 默认使用仓库根目录私钥:`./private.wxa0e7e5a27599cf6c.key`
3. 执行前,需要在微信公众平台完成上传密钥下载和 IP 白名单配置。

23
apps/miniprogram/app.js Normal file
View File

@@ -0,0 +1,23 @@
/* global App, console, require */
const { ensureSyncBootstrap } = require("./utils/syncService");
/**
* 微信小程序应用入口。
* 说明:
* 1. 当前对外版本口径统一为 v3.0.0
* 2. 服务器、设置与闪念采用“本地 storage + Gateway 同步”双层持久化;
* 3. 日志、插件运行记录与终端运行态仍仅保留本地。
*/
App({
globalData: {
appVersion: "3.0.0",
platform: "wechat-miniprogram"
},
onLaunch() {
// 启动阶段仅记录版本,用于后续灰度/埋点标记。
console.info("[RemoteConn MiniProgram] launch", this.globalData);
ensureSyncBootstrap();
}
});

27
apps/miniprogram/app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"pages": [
"pages/connect/index",
"pages/server-settings/index",
"pages/terminal/index",
"pages/logs/index",
"pages/records/index",
"pages/settings/index",
"pages/plugins/index",
"pages/about/index",
"pages/about-manual/index",
"pages/about-feedback/index",
"pages/about-privacy/index",
"pages/about-changelog/index",
"pages/about-app/index"
],
"window": {
"navigationBarTitleText": "RemoteConn",
"navigationBarBackgroundColor": "#192b4d",
"navigationBarTextStyle": "white",
"backgroundColor": "#192b4d",
"backgroundTextStyle": "light"
},
"style": "v2",
"lazyCodeLoading": "requiredComponents",
"sitemapLocation": "sitemap.json"
}

391
apps/miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,391 @@
/**
* 小程序全局样式:
* 1. 颜色变量与当前小程序基线 v2.9.1 保持一致;
* 2. 抽取页面骨架、工具栏、面板、按钮、底栏等通用样式;
* 3. 页面只做局部差异,尽量复用这里的语义类。
*/
page {
--bg: #192b4d;
--shell-bg: #192b4d;
--shell-text: #e6f0ff;
--shell-accent: #9ca9bf;
--shell-font-family: JetBrains Mono, "SFMono-Regular", Menlo, monospace;
--shell-font-size: 15px;
--shell-line-height: 1.4;
--surface: rgba(20, 32, 56, 0.64);
--surface-border: rgba(118, 156, 213, 0.2);
--surface-shadow: rgba(91, 210, 255, 0.18);
--text: #e6f0ff;
--muted: #9cb1cf;
--accent: #5bd2ff;
--btn: #adb9cd;
--btn-border: #7e8ca4;
--btn-border-strong: #95a2b9;
--btn-bg: #374767;
--btn-bg-strong: #4b5b79;
--btn-bg-active: #3f506e;
--btn-text: #dce6f6;
--btn-danger-border: #4eb1db;
--btn-danger-bg: #285074;
--chip-bg: #2b5a7f;
--chip-text: #e6f0ff;
--accent-divider: rgba(91, 210, 255, 0.6);
--switch-on-bg: #6f7d97;
--switch-off-bg: #4a5a78;
--switch-knob: #f1f7ff;
--icon-btn-bg: #2e3f5f;
--icon-btn-bg-strong: #3d4d6c;
--accent-bg: #25496d;
--accent-bg-strong: #2e6086;
--accent-border: #49a3cd;
--accent-ring: rgba(91, 210, 255, 0.22);
--accent-shadow: rgba(91, 210, 255, 0.28);
--shell-btn-bg: #50607d;
--shell-btn-text: #c1cddf;
--shell-accent-bg: #314262;
--shell-accent-bg-strong: #435371;
--shell-accent-border: #7a88a1;
--shell-accent-ring: rgba(156, 169, 191, 0.24);
--shell-accent-shadow: rgba(156, 169, 191, 0.3);
--terminal-touch-tools-bg: rgba(25, 43, 77, 0.8);
--danger: #ff7f92;
background: var(--bg);
color: var(--text);
font-size: 26rpx;
font-family: "PingFang SC", "SF Pro Text", "Microsoft YaHei", sans-serif;
}
view,
text,
input,
textarea,
button {
box-sizing: border-box;
}
button::after {
border: none;
}
button {
position: relative;
}
button::before {
content: "";
position: absolute;
top: -8px;
right: -8px;
bottom: -8px;
left: -8px;
}
.page-root {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
color: var(--text);
}
.page-toolbar {
flex: 0 0 72rpx;
height: 72rpx;
background: var(--bg);
border-bottom: 1rpx solid var(--accent-divider);
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 32rpx;
}
.toolbar-left,
.toolbar-right {
display: inline-flex;
align-items: center;
gap: 12rpx;
}
.toolbar-spacer {
flex: 1;
}
.page-title {
margin: 0;
font-size: 32rpx;
line-height: 1;
font-weight: 600;
color: var(--text);
}
.settings-save-status {
font-size: 22rpx;
color: var(--muted);
}
.page-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0;
padding: 16rpx 16rpx 0;
}
.surface-panel {
background: var(--bg);
border-top: 1rpx solid transparent;
border-bottom: 1rpx solid transparent;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12rpx;
overflow: hidden;
}
.surface-scroll {
min-height: 0;
overflow: auto;
}
.list-stack {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.card {
border: 1rpx solid var(--surface-border);
background: var(--surface);
border-radius: 20rpx;
padding: 18rpx;
}
.row {
display: flex;
align-items: center;
}
.row.between {
justify-content: space-between;
}
.actions {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.icon-btn {
width: 48rpx !important;
height: 48rpx !important;
min-width: 0 !important;
margin: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
background-color: transparent !important;
color: inherit !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
display: inline-flex !important;
overflow: visible !important;
align-items: center;
justify-content: center;
opacity: 0.95;
}
.icon-btn.disabled,
.icon-btn.wx-button-disabled {
opacity: 0.45 !important;
}
.icon-img {
width: 44rpx;
height: 44rpx;
display: block;
}
/**
* 统一 SVG 按钮按压反馈:
* 1. 所有页面共用同一套 hover-class / touch 状态,不再各页散写重复动画;
* 2. 默认只负责过渡和图标缩放,不强行改常态背景,具体底板/阴影由各页面覆写变量;
* 3. 带文字按钮也能复用,只需覆写圆角、缩放和 hover 背景变量。
*/
.svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: transparent;
--svg-press-active-shadow: none;
--svg-press-active-scale: 0.92;
--svg-press-icon-opacity: 1;
--svg-press-icon-active-opacity: 0.7;
--svg-press-icon-active-scale: 0.9;
transition:
border-radius 160ms ease,
transform 140ms ease,
background-color 140ms ease,
box-shadow 140ms ease,
opacity 140ms ease;
will-change: transform;
}
.svg-press-btn .svg-press-icon {
opacity: var(--svg-press-icon-opacity);
transform: translateY(0) scale(1);
transition:
transform 140ms ease,
opacity 140ms ease;
}
.svg-press-btn:active,
.svg-press-btn-hover {
border-radius: var(--svg-press-active-radius) !important;
background: var(--svg-press-active-bg) !important;
background-color: var(--svg-press-active-bg) !important;
box-shadow: var(--svg-press-active-shadow) !important;
transform: scale(var(--svg-press-active-scale));
}
.svg-press-btn:active .svg-press-icon,
.svg-press-btn-hover .svg-press-icon {
opacity: var(--svg-press-icon-active-opacity);
transform: translateY(1rpx) scale(var(--svg-press-icon-active-scale));
}
.svg-press-btn.disabled,
.svg-press-btn.wx-button-disabled {
transform: none !important;
}
.btn {
width: auto !important;
min-width: 0 !important;
margin: 0 !important;
border: 1rpx solid var(--btn-border) !important;
background: var(--btn-bg) !important;
background-color: var(--btn-bg) !important;
color: var(--btn-text) !important;
border-radius: 16rpx;
padding: 8rpx 14rpx;
font-size: 24rpx;
line-height: 1.4;
}
.btn.primary {
background: var(--btn-bg-strong);
border-color: var(--btn-border-strong);
}
.btn.danger {
border-color: var(--btn-danger-border);
background: var(--btn-danger-bg);
}
.input,
.textarea {
width: 100%;
border-radius: 16rpx;
border: 1rpx solid rgba(141, 187, 255, 0.3);
background: rgba(255, 255, 255, 0.08);
color: var(--text);
padding: 14rpx 16rpx;
font-size: 24rpx;
}
.input {
height: 64rpx;
}
.field-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.field {
display: flex;
flex-direction: column;
gap: 8rpx;
width: calc((100% - 12rpx) / 2);
min-width: 0;
}
.field.wide {
width: 100%;
}
.field text {
color: var(--muted);
font-size: 22rpx;
}
.state-chip {
border-radius: 999rpx;
padding: 4rpx 10rpx;
border: 1rpx solid var(--btn-border);
background: var(--btn-bg);
font-size: 20rpx;
color: var(--btn-text);
}
.state-connected {
border-color: var(--accent-border);
background: var(--accent-bg);
color: var(--text);
}
.state-error {
border-color: var(--danger);
background: var(--btn-danger-bg);
color: var(--text);
}
.muted {
color: var(--muted);
}
.empty {
color: var(--muted);
text-align: center;
padding: 28rpx 0;
}
.bottom-bar {
flex: 0 0 104rpx;
height: 104rpx;
background: var(--bg);
border-top: 1rpx solid var(--accent-divider);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 64rpx 0 32rpx;
}
.bottom-right-actions {
display: inline-flex;
align-items: center;
gap: 24rpx;
}
.bottom-nav-btn.active {
background: var(--btn-bg-active);
}
.records-pagination {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding-top: 8rpx;
}
.records-pagination-text {
min-width: 180rpx;
text-align: center;
font-size: 22rpx;
color: var(--muted);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" d="M11 22c6.075 0 11-4.925 11-11S17.075 0 11 0 0 4.925 0 11s4.925 11 11 11ZM9.281 5.5a1.719 1.719 0 0 1 3.438 0v.687a1.719 1.719 0 0 1-3.438 0V5.5Zm0 5.5a1.719 1.719 0 0 1 3.438 0v5.5a1.719 1.719 0 0 1-3.438 0V11Z"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M10.962 0a10.909 10.909 0 0 0-6.083 1.845A10.988 10.988 0 0 0 .84 6.775a11.05 11.05 0 0 0-.633 6.353 11.017 11.017 0 0 0 2.985 5.636 10.93 10.93 0 0 0 5.599 3.02c2.122.429 4.323.216 6.324-.613a10.958 10.958 0 0 0 4.92-4.04 11.038 11.038 0 0 0 1.858-6.107v-.015A11.033 11.033 0 0 0 21.07 6.8a10.988 10.988 0 0 0-2.364-3.57A10.928 10.928 0 0 0 15.16.842 10.885 10.885 0 0 0 10.975 0h-.013Zm.614 14.925-.772-1.833H7.8l-.772 1.833H5.053l3.276-7.96h1.935l3.278 7.96h-1.966Zm4.511 0H14.4v-7.96h1.687v7.96Z"/>
<path fill="#67D1FF" d="M8.18 11.668h2.255l-1.127-2.78-1.128 2.78Z"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="24" fill="none" viewBox="0 0 52 24">
<path fill="#FFC16E" d="M7.902 0c.033 0 .55 7.989 1.554 23.967L9.423 24H6.708c-.055 0-.29-3.621-.704-10.863H3.452c-.436 7.198-.665 10.814-.687 10.846L2.732 24h-2.7L0 23.967C.992 7.99 1.505 0 1.538 0h6.364Zm-3.78 2.29-.49 8.573h2.192l-.49-8.573H4.122ZM15.642 0c.022 0 .038.016.05.05v23.9c0 .012-.017.028-.05.05h-2.716c-.01 0-.027-.016-.049-.05V.05c0-.023.017-.039.05-.05h2.715Zm3.021 0h2.204v1.102h3.954v1.802h-2.178v5.628h2.178v1.815h-2.204c-.086.64-.13.977-.13 1.011h.623c.026 0 .06.169.104.506.008.008.73 3.963 2.165 11.864.026.086.043.177.052.272h-1.984c-.017 0-.056-.195-.117-.584l-1.452-7.87h-.013L20.672 24h-2.01c.01-.043.022-.13.04-.26.008-.068.017-.12.025-.155.009-.06.013-.117.013-.169.044-.293.087-.574.13-.842.104-.787.216-1.595.337-2.425l.117-.843a4.64 4.64 0 0 1 .039-.246l.013-.13.025-.142c.035-.225.061-.415.078-.57a.335.335 0 0 0 .013-.105l.013-.064.013-.065a2.54 2.54 0 0 1 .04-.285l.038-.26.039-.26c.017-.172.043-.37.078-.595l.026-.13.013-.13.039-.233.363-2.606c.051-.337.108-.726.168-1.167.017-.139.043-.325.078-.558l.039-.272.039-.26.116-.881h-1.931V8.532h1.957V2.904h-1.957V0Zm7.26 0h7.34v1.815h-5.317V8.22a96.79 96.79 0 0 1 1.919-.557l.233-.078.22-.065c.182-.052.33-.09.442-.116.328-.104.613-.186.855-.247.165-.052.342-.103.532-.155.07-.026.147-.052.233-.078l.117-.026.104-.039c.104-.026.199-.052.285-.078l.104-.026a.501.501 0 0 1 .065-.026v.013c0 .649.004.994.013 1.038a172.39 172.39 0 0 0-.013 2.333v1.064c0 1.288.004 2.442.013 3.462a7.516 7.516 0 0 0-.013.583L27.946 16.7v5.485h5.316V24h-7.339V0Zm2.023 14.807 3.099-.895V9.206l-3.099.895v4.706ZM39.37 0c.008 0 .016.009.025.026v3.267c0 .018-.009.03-.026.04h-2.152c-.009 0-.017-.014-.026-.04V.026c0-.009.008-.017.026-.026h2.152Zm5.47 0c.018 0 .027.009.027.026l-.208 1.141c.009.009.013.022.013.039h6.704c.008 0 .021.009.038.026v1.75c0 .009-.013.018-.038.026h-7.04c-.736 4.218-1.107 6.384-1.116 6.496h2.256V4.707c0-.018.009-.03.026-.04h2.152c.018 0 .03.014.04.04v4.797h3.474c.017 0 .03.013.039.039v1.737c0 .018-.013.03-.039.04h-3.475v2.813l3.423-.7.013.013v2.035c0 .026-.168.065-.505.117-.087.026-1.064.233-2.93.622v4.5c0 .008-.014.017-.04.026h-2.152c-.009 0-.017-.01-.026-.026v-4.046h-.013c-.017 0-1.5.307-4.447.92 0-.017-.005-.025-.013-.025v-2.023c0-.026.276-.09.83-.194.008-.009 1.223-.26 3.643-.752v-3.28h-4.434c-.009 0-.022-.014-.04-.04V9.57c.018-.121.394-2.308 1.129-6.56h-1.297c-.008 0-.017-.01-.026-.027v-1.75c0-.009.009-.017.026-.026h1.608c.009 0 .065-.285.169-.856.017-.233.047-.35.09-.35h2.14Zm-5.108 5.29c.017 0 .03.013.04.04v15.22c0 .043.09.138.271.285.96.9 1.457 1.349 1.491 1.349h9.816c.017 0 .03.013.038.039v1.737c0 .017-.012.03-.038.039H40.277c-.035 0-.22-.164-.558-.493a27.372 27.372 0 0 1-.428-.376h-.026c-.147.484-.237.77-.272.856a.115.115 0 0 1-.052.013h-.233c-.58 0-1.202-.004-1.867-.013l-.013-.026c.458-1.53.7-2.368.726-2.515V7.106h-.674c-.044 0-.065-.022-.065-.065V5.329c0-.017.013-.03.039-.039h2.878Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#67D1FF" d="M17.053 10.8h-7.24l3.043-3.045a1.216 1.216 0 0 0 .024-1.718 1.216 1.216 0 0 0-1.72.02l-5.064 5.066c-.011.01-.026.013-.037.024a1.185 1.185 0 0 0-.342.866c0 .305.112.611.344.841.01.01.022.013.032.021l5.067 5.067c.482.48 1.251.493 1.72.024a1.216 1.216 0 0 0-.024-1.718L9.811 13.2h7.242c.68 0 1.232-.538 1.232-1.2 0-.662-.552-1.2-1.232-1.2Z"/>
<path fill="#67D1FF" d="M12 0A11.998 11.998 0 0 0 0 12c0 6.629 5.371 12 12 12s12-5.371 12-12S18.629 0 12 0Zm0 21.6a9.599 9.599 0 1 1 0-19.198A9.599 9.599 0 0 1 12 21.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="16" fill="none" viewBox="0 0 20 16">
<path fill="#FFC16E" d="m15.82 10.519-2.968-3.01 2.969-3.011-1.172-1.173-2.969 3.01-3.008-3.01-1.171 1.173 3.007 3.01-3.007 3.01 1.171 1.173 3.008-3.01 2.97 3.01 1.17-1.172ZM18.32 0c.444 0 .834.17 1.172.508.339.338.508.73.508 1.173v11.652c0 .443-.17.834-.508 1.173-.338.338-.728.508-1.171.508H5.82c-.521 0-.964-.248-1.329-.744L0 7.507 4.492.743C4.857.248 5.3 0 5.821 0h12.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24">
<path fill="#67D1FF" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="23" fill="none" viewBox="0 0 24 23">
<path fill="#E6F0FF" d="M22.313 6.835c0-1.14-.91-2.05-2.05-2.05h-4.098V1.94c0-1.14-.89-1.94-2.03-1.94-1.14 0-2.07.8-2.07 1.94v2.846H8.199c-1.14 0-2.05.91-2.05 2.05v2.049h16.274v-2.05h-.11Zm0 4.1H6.039S4.899 23 0 23h5.809c2.28 0 3.298-6.597 3.298-6.597s1.019 5.918.11 6.597h3.528c1.589-.23 1.819-7.506 1.819-7.506s1.589 7.397 1.37 7.397h-3.189 5.009c1.479-.34 1.588-5.688 1.588-5.688s.679 5.688.46 5.688h-2.158 2.729c4.098.109 4.329-5.578 1.94-11.957Z"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" fill="none" viewBox="0 0 24 22">
<path fill="#E6F0FF" d="M9.7 8.708c-.15-.458-.25-.916-.4-1.375-.3 1.146-.65 2.384-1 3.484l-.35 1.1h2.65l-.35-1.1a32.72 32.72 0 0 0-.55-2.109Z"/>
<path fill="#E6F0FF" d="M24 6.417v-5.5C24 .412 23.55 0 23 0h-6v1.833H7V0H1C.45 0 0 .412 0 .917v5.5h2v9.166H0v5.5C0 21.588.45 22 1 22h6v-1.833h10V22h6c.55 0 1-.413 1-.917v-5.5h-2V6.417h2Zm-9.3 10.129-.05.046H12.1c-.05 0-.05 0-.05-.046l-.85-2.796H7.45l-.85 2.796c0 .046-.05.046-.05.046H4.1s-.05 0-.05-.046V16.5L7.9 5.454c0-.046.05-.046.05-.046h2.85c.05 0 .05 0 .05.046L14.7 16.5v.046Zm3.85-.046c0 .046-.05.046-.1.046h-2.4c-.05 0-.1-.046-.1-.046V5.454c0-.046.05-.046.1-.046h2.4c.05 0 .1.046.1.046V16.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#67D1FF" d="M22.286 9.429h-1.213a9.45 9.45 0 0 0-.857-2.023l.857-.857a1.715 1.715 0 0 0 0-2.422L19.877 2.91a1.714 1.714 0 0 0-2.421 0l-.857.857a9.226 9.226 0 0 0-2.028-.836V1.714A1.713 1.713 0 0 0 12.857 0h-1.714a1.714 1.714 0 0 0-1.714 1.714v1.217a9.227 9.227 0 0 0-2.023.836l-.857-.857a1.714 1.714 0 0 0-2.422 0L2.91 4.123a1.714 1.714 0 0 0 0 2.421l.857.857a9.45 9.45 0 0 0-.857 2.023H1.714A1.714 1.714 0 0 0 0 11.14v1.714a1.714 1.714 0 0 0 1.714 1.714h1.213a9.45 9.45 0 0 0 .857 2.023l-.857.857a1.714 1.714 0 0 0 0 2.422l1.213 1.21a1.714 1.714 0 0 0 2.421 0l.858-.857c.634.36 1.309.644 2.01.845v1.217A1.714 1.714 0 0 0 11.143 24h1.714a1.713 1.713 0 0 0 1.714-1.714v-1.217a9.233 9.233 0 0 0 2.023-.836l.857.857a1.714 1.714 0 0 0 2.422 0l1.213-1.213a1.714 1.714 0 0 0 0-2.421l-.857-.857a9.45 9.45 0 0 0 .857-2.023h1.2A1.714 1.714 0 0 0 24 12.86v-1.718a1.714 1.714 0 0 0-1.714-1.714Zm-7.822 4.954a3.429 3.429 0 1 1-4.93-4.767 3.429 3.429 0 0 1 4.93 4.767Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M11.023 0c6.08.04 10.993 4.985 10.964 11.031C21.957 17.103 17 22.035 10.96 22 4.86 21.966-.057 16.988 0 10.908.06 4.868 5.02-.04 11.024 0ZM4.145 14.27c.096 1.317.73 2.444 2.067 3.066 1.308.61 2.626.497 3.711-.45 1.142-.995 2.204-2.094 3.21-3.229.831-.938.926-2.095.55-3.277-.247-.77-.71-1.066-1.318-.872-.575.185-.77.655-.556 1.417.18.643.135 1.24-.344 1.732-.864.89-1.727 1.783-2.63 2.63-.696.65-1.661.612-2.268-.013-.625-.642-.628-1.629.01-2.33.308-.34.655-.646.968-.982.4-.428.398-1.026.018-1.397-.362-.352-.942-.363-1.355.019a17.04 17.04 0 0 0-1.22 1.24c-.574.658-.835 1.444-.843 2.446Zm4.118-3.929c.014.135.027.396.072.65.03.176.093.347.159.514.254.642.763.924 1.302.73.497-.178.725-.72.525-1.343-.244-.767-.076-1.41.488-1.973.793-.79 1.575-1.594 2.374-2.38.687-.676 1.679-.714 2.318-.108.665.629.682 1.635.025 2.37-.293.328-.625.62-.925.942-.426.458-.437.999-.047 1.39.406.407.947.434 1.387.014.462-.442.92-.895 1.312-1.396 1.093-1.394.914-3.464-.372-4.695-1.305-1.25-3.353-1.363-4.696-.181-1.034.91-2.005 1.896-2.966 2.885-.65.671-.95 1.516-.956 2.581Z"/>
<path fill="#67D1FF" d="M20.166 5.268c0-.11-.002-.219-.004-.328A11.05 11.05 0 0 0 11.022 0C5.019-.04.059 4.87 0 10.908c-.037 3.828 1.898 7.217 4.86 9.212.148.004.297.006.445.006 8.207.002 14.86-6.65 14.86-14.858Zm-7.033 8.39c-1.007 1.133-2.068 2.233-3.21 3.23-1.085.945-2.404 1.057-3.711.449-1.338-.622-1.97-1.75-2.067-3.067.007-1.002.269-1.788.845-2.444a17.04 17.04 0 0 1 1.219-1.24c.413-.382.993-.373 1.355-.02.38.373.382.97-.018 1.399-.313.336-.66.643-.969.983-.637.7-.634 1.687-.009 2.33.607.624 1.574.663 2.267.011.905-.847 1.766-1.74 2.63-2.629.48-.492.526-1.09.345-1.732-.215-.762-.019-1.232.556-1.417.607-.194 1.072.102 1.317.872.376 1.18.281 2.337-.55 3.275Zm1.421-2.524c-.391-.392-.38-.933.047-1.39.3-.323.632-.615.925-.943.657-.735.64-1.74-.025-2.37-.638-.604-1.631-.566-2.318.108-.8.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.488 1.973.199.624-.028 1.164-.525 1.343-.54.194-1.048-.09-1.302-.73a2.59 2.59 0 0 1-.16-.513c-.044-.255-.057-.516-.07-.65.006-1.065.306-1.909.957-2.58.961-.99 1.933-1.974 2.967-2.885 1.343-1.184 3.39-1.07 4.696.18 1.286 1.231 1.465 3.302.372 4.696-.393.5-.852.954-1.312 1.396-.443.418-.984.39-1.39-.015Z"/>
<path fill="#67D1FF" d="M11.73 10.517c-.045-.524.17-.86.635-1.008.109-.036.223-.056.337-.058a14.81 14.81 0 0 0 2.111-3.4c-.545-.138-1.16.025-1.629.488-.799.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.487 1.973.105.33.09.634-.02.876.503-.387.98-.804 1.427-1.251Z"/>
<path fill="#67D1FF" d="M.002 10.908c-.014 1.342.22 2.674.69 3.93 1.169.044 2.339-.05 3.486-.279a4.1 4.1 0 0 1-.031-.291c.006-1.003.268-1.789.845-2.445a17.04 17.04 0 0 1 1.218-1.24c.413-.382.994-.373 1.355-.019.381.373.382.97-.017 1.398-.313.336-.66.643-.97.983-.293.324-.45.708-.473 1.09a14.72 14.72 0 0 0 3.485-1.75c-.46.063-.875-.223-1.095-.781a2.593 2.593 0 0 1-.159-.513c-.044-.255-.058-.516-.071-.651.006-1.065.306-1.908.958-2.58.96-.988 1.932-1.974 2.966-2.885.925-.815 2.183-1.015 3.303-.652.283-.955.474-1.95.559-2.976A11.018 11.018 0 0 0 11.026.002C5.02-.04.06 4.869.002 10.908Z"/>
<path fill="#67D1FF" d="M.127 9.362c5.187-.924 9.441-4.54 11.27-9.35A15.24 15.24 0 0 0 11.023 0C5.543-.036.933 4.053.127 9.362Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path
fill="#67D1FF"
d="M8.25 1.833c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v10.084a1.833 1.833 0 0 1-1.833 1.833H17.05V1.833H8.25Z"
/>
<path
fill="#67D1FF"
d="M3.667 4.583c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v13.75A1.833 1.833 0 0 1 13.75 20.167H5.5a1.833 1.833 0 0 1-1.833-1.834V4.583Zm2.75 1.834v1.833H12.833V6.417H6.417Zm0 4.583v1.833H12.833V11H6.417Zm0 4.583v1.834h4.583v-1.834H6.417Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20">
<path fill="#FFC16E" d="M7.154 1.265h1.994c.008 0 .011.006.011.016v2.562h1.703v1.851H5.788c-.008 0-.012-.005-.012-.015V3.858c0-.01.004-.015.012-.015h1.366V1.265Zm0 4.846H9.16v2.083c0 .335.04.625.118.872.09.185.276.278.56.278.25 0 .517-.075.8-.224.169 1.27.253 1.922.253 1.953h-.006c-.392.272-.933.408-1.624.408h-.022c-.982 0-1.604-.416-1.865-1.25-.146-.396-.219-.99-.219-1.782V6.111Zm8.62-2.276c.235 0 .446.026.633.077v.008c0 .025-.077.81-.23 2.353a1.844 1.844 0 0 0-.711-.139c-.68 0-1.225.324-1.636.972h-.005v-2.16c.201-.458.47-.772.806-.941.194-.114.575-.17 1.143-.17Zm-4.324.139h2.09v7.507h-2.09V3.974ZM16.967 0h2.022c.007 0 .011.005.011.015v11.459h-2.033V0ZM2.879 2.984h.084c.672 0 1.234.244 1.686.733.086.098.179.226.28.386.15.262.26.52.33.771.075.273.13.587.168.942l-1.87.254c-.049-.34-.124-.589-.224-.748-.124-.175-.271-.262-.443-.262-.007 0-.011-.006-.011-.016v-2.06Zm-.275.008v2.106c-.09.016-.2.11-.33.286-.198.334-.297.861-.297 1.581v.108c.004.921.163 1.513.476 1.775a.686.686 0 0 0 .387.124c.388 0 .634-.286.739-.857l.022-.224H5.49c-.019.252-.05.502-.095.749a4.103 4.103 0 0 1-.415 1.196c-.467.807-1.182 1.211-2.145 1.211-.866 0-1.548-.324-2.044-.972C.263 9.38 0 8.377 0 7.065c0-1.717.452-2.924 1.355-3.618a2.6 2.6 0 0 1 .622-.324c.18-.062.388-.106.627-.131Z"/>
<path fill="#5BD2FF" d="M10.821 11.937h.084c.673 0 1.234.244 1.686.732.086.098.18.227.28.386.15.263.26.52.33.772.075.273.131.586.169.941l-1.87.255c-.05-.34-.124-.589-.225-.748-.123-.175-.27-.263-.443-.263-.007 0-.01-.005-.01-.015v-2.06Zm-.274.007v2.107c-.09.015-.2.11-.33.285-.198.335-.297.862-.297 1.582v.108c.003.92.162 1.512.476 1.775a.685.685 0 0 0 .386.123c.389 0 .635-.285.74-.856l.022-.224h1.887c-.018.252-.05.502-.095.748a4.102 4.102 0 0 1-.414 1.196C12.455 19.596 11.74 20 10.776 20c-.866 0-1.547-.324-2.044-.972-.526-.695-.79-1.698-.79-3.01 0-1.718.452-2.924 1.356-3.619.15-.113.357-.22.622-.324a2.88 2.88 0 0 1 .627-.13Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373H5.506v-2.75h11v2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#FFC16E" d="M10 0c5.523 0 10 4.477 10 10s-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0Zm0 2a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8Z"/>
<path fill="#FFC16E" d="M10.714 12.66a1.001 1.001 0 0 1-1.457.047L5.72 9.172a1 1 0 0 1 1.414-1.415l2.827 2.828 2.831-2.832a1 1 0 0 1 1.414 1.415l-3.493 3.493Z"/>
</svg>

After

Width:  |  Height:  |  Size: 430 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="17" fill="none" viewBox="0 0 21 17">
<path fill="#FFC16E" stroke="#FFC16E" stroke-width=".5" d="m14.422.25.312.008a6.495 6.495 0 0 1 4.156 1.75c1.187 1.126 1.858 2.655 1.86 4.254l-.008.3a5.89 5.89 0 0 1-1.852 3.955 6.509 6.509 0 0 1-4.468 1.757H3.134l3.248 3.079H6.38a.783.783 0 0 1 .016 1.166.846.846 0 0 1-.603.231.867.867 0 0 1-.588-.247L.5 12.044a.795.795 0 0 1-.25-.576c0-.219.092-.425.25-.575L5.206 6.43l.007-.006a.857.857 0 0 1 1.154.02.794.794 0 0 1 .25.56.792.792 0 0 1-.23.57l-.005.007H6.38l-3.246 3.077h11.287a4.793 4.793 0 0 0 3.295-1.292 4.278 4.278 0 0 0 1.357-3.104 4.278 4.278 0 0 0-1.357-3.105 4.794 4.794 0 0 0-3.295-1.293H5.793a.855.855 0 0 1-.588-.231.794.794 0 0 1-.25-.576c0-.219.092-.426.25-.577a.855.855 0 0 1 .588-.23h8.629Z"/>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="10" fill="none" viewBox="0 0 19 10">
<path fill="#FFC16E" d="M2.98 0h.132C4.068 0 4.81.496 5.34 1.489c.004.019.044.105.12.257.302.75.453 1.641.453 2.672v1.05H2.258V3.702h1.756a3.7 3.7 0 0 0-.108-.82c-.135-.471-.422-.706-.86-.706-.498 0-.826.32-.985.963a6.815 6.815 0 0 0-.114 1.346c0 .502.028 1.027.084 1.574.075.458.187.77.334.935.156.178.37.268.645.268.474 0 .779-.274.914-.821a.381.381 0 0 1 .024-.105h1.923a7.59 7.59 0 0 1-.179.954c-.243.853-.635 1.447-1.176 1.785-.41.273-.906.41-1.488.41C1.611 9.472.685 8.677.251 7.099.084 6.463 0 5.748 0 4.952V4.82c0-1.304.211-2.376.633-3.216C1.195.534 1.977 0 2.981 0Zm6.398 0v2.118a.83.83 0 0 0-.466.163.648.648 0 0 0-.197.486v.048c0 .286.165.48.495.582.16.051.62.178 1.38.382.434.152.779.318 1.033.496.618.452.926 1.167.926 2.147v.143c0 1.082-.306 1.874-.92 2.376-.473.388-1.134.582-1.983.582h-.012c-1.557 0-2.536-.658-2.938-1.975a4.659 4.659 0 0 1-.185-1.174h2.072c.004 0 .02.067.048.2.032.122.078.233.137.335.172.26.458.39.86.39.518 0 .777-.215.777-.648v-.086c0-.324-.18-.537-.538-.64-.736-.158-1.268-.301-1.594-.429a3.225 3.225 0 0 1-.849-.505c-.521-.484-.782-1.174-.782-2.071 0-1.107.348-1.912 1.045-2.414A2.853 2.853 0 0 1 9.342.01h.006l.03-.01Zm.298.01h.006c.593 0 1.145.168 1.655.505.167.128.336.293.507.497.208.311.325.521.353.63.095.222.175.492.239.81l.041.287-.011.01c-.156.018-.83.114-2.025.286-.048-.287-.132-.506-.251-.659a.607.607 0 0 0-.317-.21c-.012-.013-.077-.028-.197-.048V.01Zm6.541.018h.09c.716 0 1.315.303 1.797.907.092.12.191.28.299.477.159.325.277.643.352.954.08.337.14.726.18 1.164l-1.996.315c-.051-.42-.131-.728-.239-.925-.131-.217-.288-.325-.471-.325-.008 0-.012-.006-.012-.019V.028Zm-.293.01v2.605c-.096.02-.213.137-.352.353-.211.414-.317 1.066-.317 1.956v.134c.004 1.138.173 1.87.508 2.194a.664.664 0 0 0 .412.153c.414 0 .677-.353.788-1.059l.024-.277H19c-.02.312-.054.62-.102.926a5.637 5.637 0 0 1-.442 1.479C17.96 9.5 17.196 10 16.17 10c-.924 0-1.65-.4-2.18-1.202-.562-.86-.843-2.1-.843-3.722 0-2.124.482-3.616 1.446-4.475.16-.14.38-.274.663-.4.191-.077.414-.131.669-.163Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#FFC16E" d="M19.765 6.768 10.321.102a.556.556 0 0 0-.642 0L.235 6.768A.557.557 0 0 0 0 7.222v12.222A.555.555 0 0 0 .556 20h6.11a.555.555 0 0 0 .556-.556v-6.666h5.556v6.666a.556.556 0 0 0 .555.556h6.111a.556.556 0 0 0 .556-.556V7.222a.557.557 0 0 0-.235-.454Z"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="23" height="22" fill="none" viewBox="0 0 23 22">
<path fill="#E6F0FF" d="M10.82 2.827a3.266 3.266 0 0 1 .457-1.634A1.635 1.635 0 0 1 12.65.425a4.707 4.707 0 0 1 1.487.245c.507.164 1.063.392 1.635.621a20.07 20.07 0 0 0 2.043.752 3.465 3.465 0 0 0 2.14.098 2.565 2.565 0 0 0 1.39-1.863L21.427 0h.278l.555.163h.294l-.081.278a3.58 3.58 0 0 1-2.076 2.648 4.674 4.674 0 0 1-2.942 0l-1.078-.262-1.046-.424a7.649 7.649 0 0 0-2.615-.785.621.621 0 0 0-.54.36 2.32 2.32 0 0 0-.18.85h.31l.377 1.928h-2.534l.376-1.929h.295ZM2.73 4.772h16.425a2.746 2.746 0 0 1 2.73 2.73v11.767a2.746 2.746 0 0 1-2.73 2.73H2.811A2.73 2.73 0 0 1 0 19.269V7.502a2.73 2.73 0 0 1 2.73-2.73Zm13.728 12.389a.458.458 0 0 0-.441.458v1.258a.458.458 0 0 0 .441.458h1.88a.457.457 0 0 0 .441-.458V17.62a.457.457 0 0 0-.441-.458h-1.88Zm-9.3 0a.621.621 0 0 0-.604.605v.964a.62.62 0 0 0 .605.605h7.387a.62.62 0 0 0 .605-.605v-.964a.621.621 0 0 0-.605-.605H7.159Zm6.162-9.038a.458.458 0 0 0-.408.458v1.274a.44.44 0 0 0 .44.442H15.2a.44.44 0 0 0 .441-.442V8.581a.458.458 0 0 0-.441-.458h-1.88Zm-3.677 0a.458.458 0 0 0-.441.458v1.274a.441.441 0 0 0 .44.442h1.913a.441.441 0 0 0 .441-.442V8.581a.458.458 0 0 0-.44-.458H9.642Zm-6.015 0a.458.458 0 0 0-.441.458v1.274a.441.441 0 0 0 .441.442h4.56a.441.441 0 0 0 .442-.442V8.581a.458.458 0 0 0-.442-.458h-4.56Zm6.783 3.04a.458.458 0 0 0-.441.457v1.259a.458.458 0 0 0 .441.458h1.88a.458.458 0 0 0 .44-.458V11.62a.458.458 0 0 0-.44-.458h-1.88Zm-3.465 0a.474.474 0 0 0-.457.457v1.259a.474.474 0 0 0 .457.458h1.88a.458.458 0 0 0 .44-.458V11.62a.458.458 0 0 0-.44-.458h-1.88Zm-3.383 0a.457.457 0 0 0-.458.457v1.259a.458.458 0 0 0 .442.458h1.895a.458.458 0 0 0 .442-.458V11.62a.458.458 0 0 0-.442-.458h-1.88Zm9.349 2.909a.441.441 0 0 0-.442.441v1.275a.44.44 0 0 0 .442.441h1.928a.441.441 0 0 0 .441-.44v-1.276a.441.441 0 0 0-.44-.441h-1.93Zm-3.498 0a.44.44 0 0 0-.441.441v1.275a.441.441 0 0 0 .441.441h1.863a.44.44 0 0 0 .442-.44v-1.276a.441.441 0 0 0-.442-.441H9.414Zm-5.933 0a.441.441 0 0 0-.376.441v1.275a.441.441 0 0 0 .442.441H7.86a.44.44 0 0 0 .442-.44v-1.276a.442.442 0 0 0-.442-.441H3.48Zm0 3.089a.457.457 0 0 0-.441.458v1.258a.458.458 0 0 0 .441.458H5.41a.458.458 0 0 0 .441-.458V17.62a.458.458 0 0 0-.441-.458H3.48Zm10.722-5.998a.458.458 0 0 0-.441.457v1.259a.458.458 0 0 0 .44.458h4.495V8.515a.457.457 0 0 0-.457-.441h-1.26a.457.457 0 0 0-.457.441v2.648h-2.321Zm2.272 2.909a.44.44 0 0 0-.442.441v1.275a.44.44 0 0 0 .442.441h1.88a.458.458 0 0 0 .457-.44v-1.276a.458.458 0 0 0-.458-.441h-1.88Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#FFC16E" d="M20 10c0 5.523-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0s10 4.477 10 10Zm-2 0a8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8Z"/>
<path fill="#FFC16E" d="M7.34 10.714a1 1 0 0 1-.047-1.457l3.535-3.536a1 1 0 1 1 1.415 1.414L9.415 9.962l2.832 2.831a1 1 0 0 1-1.415 1.414l-3.493-3.493Z"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#67D1FF" fill-rule="evenodd" d="M11.299 14.804c1.045 0 1.901.316 2.545.961.646.647.956 1.537.956 2.646 0 1.031-.271 1.873-.833 2.506l-.116.124c-.64.645-1.491.959-2.535.959-.987 0-1.803-.275-2.429-.838l-.122-.116c-.64-.642-.95-1.518-.95-2.603 0-.69.105-1.282.327-1.769.164-.356.39-.676.672-.959.196-.2.422-.37.669-.505l.264-.128.016-.005c.45-.184.964-.273 1.536-.273Zm2.181 6.32a2.888 2.888 0 0 1 .119-.101l-.119.101Zm-4.865-.544a2.745 2.745 0 0 0-.017-.024l.017.024Zm2.697-4.265c-.517 0-.92.168-1.227.502-.303.329-.471.844-.471 1.58 0 .723.171 1.237.485 1.576.318.346.718.517 1.213.517.496 0 .893-.17 1.206-.511.308-.335.479-.857.479-1.599 0-.642-.128-1.114-.36-1.439l-.105-.13c-.301-.329-.702-.496-1.22-.496Zm-3.29 1.702c-.003.034-.004.069-.006.104l.01-.162-.004.058Zm5.896-1.886a2.836 2.836 0 0 1-.102-.121l.102.121Zm4.908-1.327c.868 0 1.575.176 2.094.551.515.373.845.888.99 1.535l.042.187-.194.035-1.365.247-.174.032-.046-.166a1.225 1.225 0 0 0-.466-.665l-.09-.058a1.542 1.542 0 0 0-.79-.19c-.557 0-.98.17-1.293.495v-.001c-.31.323-.48.818-.48 1.518 0 .665.134 1.158.38 1.5l.11.138.002.001c.318.349.735.525 1.267.525.263 0 .527-.049.795-.151h.003a2.79 2.79 0 0 0 .62-.323v-.555h-1.318l-.258.188v-1.67H22v2.894l-.058.055c-.313.293-.755.543-1.315.754v-.001A4.786 4.786 0 0 1 18.9 22h-.001c-.74-.001-1.394-.15-1.957-.458a3.006 3.006 0 0 1-1.264-1.308l-.004-.009-.003-.007-.097-.214a4.038 4.038 0 0 1-.316-1.355l-.005-.233v-.038c0-.714.155-1.355.468-1.92a3.177 3.177 0 0 1 1.37-1.299l.007-.003.018-.008c.47-.233 1.043-.344 1.71-.344Zm-3.19 2.321-.01.034a2.55 2.55 0 0 1 .07-.197l-.06.163Z" clip-rule="evenodd"/>
<path fill="#67D1FF" d="M4.079 14.97v5.435h3.417v1.483H2.051l.272-.263V14.97h1.756Z"/>
<path fill="#67D1FF" fill-rule="evenodd" d="M16.812.004c1.383.048 2.591 1.306 2.591 2.734v11.467h-1.957V2.501a.6.6 0 0 0-.227-.444.777.777 0 0 0-.489-.194H2.562c-.315 0-.596.272-.596.638v19.186l-.002.183H0l.008-.193c.012-.254.013-.867.01-1.422a170.26 170.26 0 0 0-.006-.727c0-.095-.002-.173-.003-.227V2.736C.008 1.294 1.132 0 2.56 0h14.248l.003.004Z" clip-rule="evenodd"/>
<path fill="#67D1FF" d="M16.24 11.13H3.177V9.22H16.24v1.91Zm0-4.61H3.177V4.61H16.24v1.91Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" fill="none" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="28" fill="#192B4D" fill-opacity=".7"/>
<path fill="#0F5" d="m18.706 30.834.799.943 3.2-3.777-3.2-3.777-.8.943L21.109 28l-2.402 2.834Z"/>
<path fill="#0F5" d="M42.785 44h-28.63c-1.934 0-3.508-1.463-3.508-3.261V19.167c0-1.799 1.574-3.262 3.509-3.262h28.63c1.934 0 3.508 1.463 3.508 3.261V40.74c0 1.798-1.574 3.261-3.508 3.261Zm-28.63-25.684c-.537 0-.975.381-.975.85V40.74c0 .47.438.85.976.85h28.63c.538 0 .975-.38.975-.85V19.167c0-.47-.438-.851-.976-.851h-28.63ZM28.47 8c1.345 0 2.435 1.038 2.435 2.317 0 1.28-1.09 2.317-2.434 2.317-1.345 0-2.435-1.037-2.435-2.317 0-1.28 1.09-2.317 2.435-2.317Z"/>
<path fill="#0F5" d="M28.47 17.736c-.7 0-1.266-.54-1.266-1.206v-4.017c0-.665.567-1.205 1.267-1.205s1.266.54 1.266 1.205v4.017c0 .666-.567 1.206-1.266 1.206ZM7.268 36.88c-.7 0-1.267-.54-1.267-1.205V24.963c0-.666.567-1.206 1.267-1.206s1.266.54 1.266 1.206v10.712c0 .665-.567 1.205-1.266 1.205Zm42.407 0c-.7 0-1.266-.54-1.266-1.205V24.963c0-.666.567-1.206 1.267-1.206s1.266.54 1.266 1.206v10.712c0 .665-.567 1.205-1.267 1.205Zm-25.087-2.762c.76 1.26 2.161 1.423 3.765 1.423s3.005-.163 3.764-1.423v2c-.759 1.26-2.16 1.294-3.764 1.294-1.604 0-3.006-.035-3.765-1.294v-2Z"/>
<circle cx="35.647" cy="28" r="1.882" fill="#0F5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="21" fill="none" viewBox="0 0 12 21">
<path fill="#FFC16E" d="M2.098 4.195a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.198 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm-6.993 9.79a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.197 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm-6.993 9.79a2.099 2.099 0 0 1 0-4.195 2.099 2.099 0 0 1 0 4.196Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 0 1 0-4.196 2.099 2.099 0 0 1 0 4.196Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Z"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20">
<path fill="#FFC16E" d="M17 9h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2Zm1-2V4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V9a2 2 0 0 1 2-2h10ZM6 4V2h6v2H6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

Some files were not shown because too many files have changed in this diff Show More