first commit
24
.gitignore
vendored
Normal 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
@@ -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
@@ -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 多端容器
|
||||
- Web:Vite 构建 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 模式)、日志服务、主题服务。
|
||||
- `传输层`:
|
||||
- iOS:Native 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 使用 Keychain;Web 使用受限存储并提示风险。
|
||||
- 首次连接需主机指纹确认(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 iOS(Hybrid)
|
||||
- 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。
|
||||
- 凭据引用模型与安全存储接口。
|
||||
- 验收:
|
||||
- 可新增/编辑/删除服务器并持久化。
|
||||
|
||||
### M3:iOS 原生 SSH 插件 POC(1-2 周)
|
||||
- 交付:
|
||||
- 建立真实 SSH 连接并完成收发。
|
||||
- 错误码映射与状态事件回传。
|
||||
- 验收:
|
||||
- iOS 真机成功连接 Linux 主机并执行命令。
|
||||
|
||||
### M4:网关服务 MVP(1-2 周)
|
||||
- 交付:
|
||||
- WSS -> SSH 双向通道。
|
||||
- 基础鉴权、心跳、断开处理。
|
||||
- 验收:
|
||||
- Web 可通过网关连接并稳定交互 30 分钟。
|
||||
|
||||
### M5:Codex 模式与日志(1 周)
|
||||
- 交付:
|
||||
- 一键进入 Codex 编排流程。
|
||||
- 最近连接/任务日志列表与详情。
|
||||
- 验收:
|
||||
- 单击按钮后可自动 `cd` 并进入 `codex`。
|
||||
|
||||
### M6:主题、中文输入与兼容性完善(1 周)
|
||||
- 交付:
|
||||
- 主题系统 + 自动对比背景。
|
||||
- 中文输入兼容修复与回归。
|
||||
- SSH 服务端兼容性测试报告。
|
||||
- 验收:
|
||||
- 在目标服务端矩阵中通过率达到约定阈值(建议 >= 95%)。
|
||||
|
||||
### M7:插件系统 MVP(1-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 项:字体、字号、行高、光标样式、宽字符支持、终端缓冲阈值。
|
||||
- 连接策略:自动重连、重连上限、主机指纹策略、凭据记忆策略、超时参数、服务器预填值。
|
||||
- 日志项:保留天数、日志脱敏。
|
||||
- **即时生效**:所有项更改后立即反映到 UI(CSS 变量实时写入根节点),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 跨端路径映射策略
|
||||
- Desktop(macOS/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 迭代计划(插件专项)
|
||||
- P1(2-3 天):目录扫描、manifest 校验、插件列表页(只读)。
|
||||
- P2(2-3 天):`main.js` 生命周期 + 命令 API + 样式注入。
|
||||
- P3(2-3 天):权限模型 + 插件存储 + 错误熔断。
|
||||
- P4(2-3 天):跨端 `PluginFsAdapter` + 导入导出 + 完整测试。
|
||||
251
README.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
@@ -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.1,2026-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. 服务器列表与配置
|
||||
|
||||

|
||||
|
||||
1. 首页默认打开服务器列表,这里是整个产品的主入口。
|
||||
2. 顶部工具栏用于新增、删除、选择和搜索服务器。
|
||||
3. 服务器卡片主体进入配置页,右侧快捷操作可用于复制配置、启动 AI 或直接连接。
|
||||
4. 底部导航用于切换终端、日志、记录、设置和关于页面。
|
||||
5. 配置页用于填写基础连接字段、认证方式和项目目录。
|
||||
6. `AI Working Directory` 用于定义 AI 启动时默认进入的项目上下文,是协作链路里的关键字段。
|
||||
7. 如果目标主机需要经过跳板机访问,可以在同页补全跳板机字段和它自己的认证信息。
|
||||
8. 推荐顺序是先保存服务器配置,再发起连接。
|
||||
|
||||
### 2. 终端、AI 与连接诊断
|
||||
|
||||

|
||||
|
||||
1. 进入终端后可以直接执行 shell 命令,也可以从左上角切换到 `Codex` 等 AI 工作流。
|
||||
2. 顶部状态栏会持续显示连接状态、时延和会话概览,是移动端排障的主反馈区。
|
||||
3. 点击 `Connected` 会打开会话详情面板,查看当前目标主机、连接状态和工作目录。
|
||||
4. 点击 `Latency` 会打开诊断面板,查看网关响应和网络时延走势,用于判断链路质量。
|
||||
|
||||
### 3. 语音输入与快捷面板
|
||||
|
||||

|
||||
|
||||
1. 终端页底部的 `voice` 按钮会打开语音输入面板,支持“先说出来,再决定怎么处理”的输入方式。
|
||||
2. 语音草稿可以直接发送到终端,也可以带上分类标签保存为记录。
|
||||
3. 底部的 `keyboard` 按钮会打开快捷键面板,补足手机上缺失的终端控制键。
|
||||
4. 右侧快捷面板提供方向键、`Esc`、`Ctrl+C`、`Tab` 等高频控制,降低手机输入成本。
|
||||
|
||||
### 4. 设置中心
|
||||
|
||||

|
||||
|
||||
1. 设置页分为 `UI / Terminal / Connection / Records` 四个分组,分别覆盖外观、终端行为、连接策略和数据管理。
|
||||
2. UI 页签负责主题、颜色和语言,决定整体视觉体验。
|
||||
3. Terminal 页签负责字体、字号、行高和终端配色,直接影响可读性和输入效率。
|
||||
4. Connection 页签负责默认认证、重连策略、后台保活、同步开关和 AI 默认项。
|
||||
5. Records 页签负责记录保留策略和分类管理,保持信息生命周期有序。
|
||||
|
||||
### 5. 记录与关于
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
|
||||
# 生产建议 0:result 帧不携带上游原始 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
|
||||
18
apps/gateway/config/runtime.json
Normal 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
@@ -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"
|
||||
}
|
||||
}
|
||||
307
apps/gateway/src/bench/gatewayPerfBench.ts
Normal 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;
|
||||
});
|
||||
307
apps/gateway/src/bench/ttydPerfBench.ts
Normal 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;
|
||||
});
|
||||
31
apps/gateway/src/config.test.ts
Normal 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
@@ -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 Token(Volcengine 使用) */
|
||||
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)
|
||||
};
|
||||
61
apps/gateway/src/debug/ioHex.ts
Normal 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 unit(16 进制)。
|
||||
*/
|
||||
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))
|
||||
};
|
||||
}
|
||||
58
apps/gateway/src/debug/terminalCaptureArm.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
148
apps/gateway/src/debug/terminalCaptureArm.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
78
apps/gateway/src/debug/terminalFrameCapture.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
97
apps/gateway/src/debug/terminalFrameCapture.ts
Normal 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 || ""
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
9
apps/gateway/src/index.ts
Normal 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");
|
||||
});
|
||||
32
apps/gateway/src/logger.ts
Normal 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())}`
|
||||
});
|
||||
15
apps/gateway/src/security/rateLimit.ts
Normal 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
421
apps/gateway/src/ssh/sshSession.test.ts
Normal 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 login(sshd 在 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 验证跨 chunk,SENTINEL\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\n(PTY 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"]);
|
||||
});
|
||||
});
|
||||
517
apps/gateway/src/ssh/sshSession.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
29
apps/gateway/src/sync/crypto.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
119
apps/gateway/src/sync/crypto.ts
Normal 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;
|
||||
}
|
||||
105
apps/gateway/src/sync/repository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
254
apps/gateway/src/sync/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
214
apps/gateway/src/sync/routes.ts
Normal 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) });
|
||||
});
|
||||
}
|
||||
82
apps/gateway/src/sync/schema.ts
Normal 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>;
|
||||
77
apps/gateway/src/sync/sqlite.ts
Normal 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;
|
||||
}
|
||||
43
apps/gateway/src/sync/userService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
175
apps/gateway/src/tts/cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
apps/gateway/src/tts/provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
209
apps/gateway/src/tts/provider.ts
Normal 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
|
||||
};
|
||||
}
|
||||
112
apps/gateway/src/tts/providers/tencent.test.ts
Normal 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")
|
||||
});
|
||||
});
|
||||
});
|
||||
216
apps/gateway/src/tts/providers/tencent.ts
Normal 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.0(TC3-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"
|
||||
};
|
||||
}
|
||||
}
|
||||
193
apps/gateway/src/tts/providers/volcengine.test.ts
Normal 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")
|
||||
});
|
||||
});
|
||||
});
|
||||
484
apps/gateway/src/tts/providers/volcengine.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
434
apps/gateway/src/tts/providers/volcenginePodcastProtocol.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
170
apps/gateway/src/tts/routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
13
apps/gateway/src/tts/schema.ts
Normal 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>;
|
||||
143
apps/gateway/src/tts/service.test.ts
Normal 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: "语音生成失败"
|
||||
});
|
||||
});
|
||||
});
|
||||
339
apps/gateway/src/tts/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
apps/gateway/src/tts/ticket.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
64
apps/gateway/src/tts/ticket.ts
Normal 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
|
||||
};
|
||||
}
|
||||
64
apps/gateway/src/voice/asrText.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
114
apps/gateway/src/voice/asrText.ts
Normal 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 "";
|
||||
}
|
||||
28
apps/gateway/src/voice/clientProtocol.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
apps/gateway/src/voice/clientProtocol.ts
Normal 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));
|
||||
}
|
||||
40
apps/gateway/src/voice/upstreamPayload.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
apps/gateway/src/voice/upstreamPayload.ts
Normal 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;
|
||||
}
|
||||
103
apps/gateway/src/voice/volcAsrProtocol.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
265
apps/gateway/src/voice/volcAsrProtocol.ts
Normal 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
|
||||
};
|
||||
}
|
||||
44
apps/gateway/src/ws/assistTxnDeduper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
apps/gateway/src/ws/assistTxnDeduper.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
88
apps/gateway/src/ws/protocol.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
94
apps/gateway/src/ws/protocol.ts
Normal 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));
|
||||
}
|
||||
12
apps/gateway/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
apps/miniprogram/.env.example
Normal 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
|
||||
82
apps/miniprogram/README.md
Normal 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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
BIN
apps/miniprogram/assets/guide/guide-mobile-01-server-list.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
apps/miniprogram/assets/guide/guide-mobile-02-server-config.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 60 KiB |
BIN
apps/miniprogram/assets/guide/guide-mobile-05-settings-ui.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/miniprogram/assets/guide/guide-mobile-09-records.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/miniprogram/assets/guide/guide-mobile-10-about.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
3
apps/miniprogram/assets/icons/about.svg
Normal 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 |
3
apps/miniprogram/assets/icons/add.svg
Normal 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 |
4
apps/miniprogram/assets/icons/ai.svg
Normal 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 |
3
apps/miniprogram/assets/icons/ai矩连.svg
Normal 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 |
4
apps/miniprogram/assets/icons/back.svg
Normal 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 |
3
apps/miniprogram/assets/icons/backspace.svg
Normal 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 |
3
apps/miniprogram/assets/icons/cancel.svg
Normal 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 |
3
apps/miniprogram/assets/icons/clear-input.svg
Normal 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 |
3
apps/miniprogram/assets/icons/clear.svg
Normal 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 |
4
apps/miniprogram/assets/icons/codex.svg
Normal 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 |
3
apps/miniprogram/assets/icons/config.svg
Normal 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 |
7
apps/miniprogram/assets/icons/connect.svg
Normal 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 |
10
apps/miniprogram/assets/icons/copy.svg
Normal 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 |
3
apps/miniprogram/assets/icons/create.svg
Normal 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 |
4
apps/miniprogram/assets/icons/ctrlc.svg
Normal 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 |
3
apps/miniprogram/assets/icons/delete.svg
Normal 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 |
4
apps/miniprogram/assets/icons/down.svg
Normal 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 |
3
apps/miniprogram/assets/icons/enter.svg
Normal 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 |
3
apps/miniprogram/assets/icons/esc.svg
Normal 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 |
3
apps/miniprogram/assets/icons/home.svg
Normal 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 |
3
apps/miniprogram/assets/icons/keyboard.svg
Normal 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 |
4
apps/miniprogram/assets/icons/left.svg
Normal 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 |
6
apps/miniprogram/assets/icons/log.svg
Normal 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 |
7
apps/miniprogram/assets/icons/logo.svg
Normal 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 |
3
apps/miniprogram/assets/icons/move.svg
Normal 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 |
3
apps/miniprogram/assets/icons/paste.svg
Normal 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 |