WWW.YOUINFO.SITE
标签聚合 ss

/tag/ss

LinuxDo 最新话题 · 2026-06-11 21:05:13+08:00 · tech

我目前并不会炒股,是因为近期的关门事件,才注册了neverless和bit,以及不知道审核有没有成的嘉信。 但因为我注册后目前还没急着去买加密货币然后入金,所以我在被neverless和bit的双重推销邮件攻击之后, 收到了neverless的休眠关户邮件 搞笑的是昨天的时候我在推特跟一个朋友聊交易所这个事,提到过股票和neverless与bit,结果今天就在我的推文回复个 有种被视奸的感觉 oyeah,5月20日注册的,四舍五入也快1个月时间了,有佬真的在用么,这软件没有网页也没有电脑端所以我基本没打开过,体验咋样 1 个帖子 - 1 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-11 21:04:52+08:00 · tech

感觉和去年的识别率完全不一样了,去年的识别率没有现在这么高 去年应该是用的 Whisper Large,加上一些自己的技术改良。 那现在是不是用的 GPT-Realtime-Whisper 那个语音识别模型?或者是完全他们自研的新模型? 目前免费版的 Typeless 识别准确率已经到了很夸张的程度,不知道 Pro 版会不会有提升。直接套那些付费的 API 接口,能不能实现同样的效果? 10 个帖子 - 7 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-11 19:48:23+08:00 · tech

分享一次自己的踩坑经历,给大家提个醒。 我明天的 Business 套餐就要进行第二次续费了,目前用的是泰区优惠订阅。因为明天就是下个月套餐的更新时间,我想着提前看一下是不是需要手动充值,于是打开了结算界面。 结果看到里面有一个“额度余额”之类的入口,我当时误以为这是后续自动续费用的钱包或者账本,于是就往里面充了 1300 THB(折合快300 RMB)。充完之后越想越不对劲,去网上一查才发现,这个余额并不是会员套餐续费用的,而是当前套餐调用超额之后,类似 Codex 等额外用量产生费用时才会扣的额度。 当场泪目 。 更难受的是,这个余额好像还有 12 个月有效期,过期就没了。也就是说,如果后面不用到这些额外额度,这笔钱基本就亏麻了,呜呜。 所以提醒大家,以后一定要注意:Business 套餐续费只要绑定的信用卡里有足够支付下个月扣款的金额就行,不要看到“额度余额”就以为是续费账户,更不要随便往里面充值。 看到这里的陌生人,可以留下一个赞 安慰一下我吗?帮我飞升一下 3 级,感激不尽 。 3 个帖子 - 2 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-11 19:41:18+08:00 · tech

Medical Xpress – 10 Jun 26 CRISPR enzyme precisely detects and shreds DNA in cancer mutations once... In 2020, Jennifer Doudna won the Nobel Prize in chemistry for her work on the CRISPR-Cas9 gene-editing technology that allows scientists to precisely modify DNA by cutting it at specific locations. Six years later, a new study in Nature by a team led... [!quote]+ 2020 年,珍妮弗-杜德娜(Jennifer Doudna)因研究 CRISPR-Cas9 基因编辑技术而获得诺贝尔化学奖,该技术允许科学家通过在特定位置切割 DNA 来精确修改 DNA。六年后,杜德娜领导的团队在《自然》杂志上发表的一项新研究发现了一种强大的新方法,可以利用一种名为 Cas12a2 的 CRISPR 酶选择性地杀死癌细胞。 一旦这种酶检测到癌症特异性基因特征,它就会开始切碎目标细胞内的染色质–一种由 DNA 和蛋白质组成染色体的混合物。 许多癌症都是由肿瘤抑制蛋白(如 TP53)的突变引起的。然而,这些突变一直难以治疗,因为它们缺乏传统药物可以抓住的结合口袋。因此,许多致癌突变长期以来一直被认为是无法治疗的。 有了这种新方法,现在就能精确锁定并消除这些无法治愈的致病突变,因为它并不依赖于附着在突变蛋白质上。相反,一旦检测到与癌症有关的特征,它就会开始疯狂破坏 DNA。 癌细胞具有正常细胞所缺乏的独特蛋白质特征,因此很容易被识别和靶向治疗。TP53,即转录因子 p53,是癌症中最常见的突变基因之一,出现在高达 90% 的卵巢和胰腺肿瘤中。这种突变通常出现在早期,并持续存在于肿瘤的整个生长期,因此是一个极具吸引力的治疗靶点。然而,这种蛋白质很难被药物靶向。 在这项新研究中,研究小组将细菌防御系统变成了精确杀癌工具。他们选择了 Cas12a2 酶,并用引导 RNA 对其进行编程,以识别癌症特异性特征,包括 p53 和表皮生长因子受体的常见突变,以及 MYC 等致癌基因的异常高水平。 CRISPR Medicine News: CRISPR-Cas12a2 Targets Mutant Cancer Transcripts for Selective Cell... A new CRISPR-based strategy turns cancer cells' own mutant transcripts against them, opening a potential route to one of oncology's most stubborn unsolved problems: the targeting of mutant p53. Rather than attempting to fix or restore the defective... 2 个帖子 - 2 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-11 17:55:32+08:00 · tech

agent 是智能体的意思,什么是智能体呢,为啥不叫AI了啊,也不叫大模型了,其实这并不是孤立的概念,AI中文就是人工智能,英文全称:Artificial Intelligence,其实就是计算机科学的一个分支,用来研究开发模拟,延伸人的理论方法技术和应用研究。大模型是ai具象化的技术产品,大模型还分了LLM语言大模型、VLM视觉大模型、MLLM多模态、技术上又出现了很多细节,比如混合专家模型-MOE。 MCP 是定的ai识别的上下文协议,用来,调用外部的服务器,返回固定内容信息的一个规则,大家都用这个规则,不就方便了ai调用外部工具获取信息了。方便打通不同企业数据库和ai的交互。 tools 就是工具的意思,这里和mcp紧密相连,tools泛指一类工具,遵循的上下文协议也未必是mcp。方便ai通过这个工具进行获取信息。 plugin是插件的意思,就是个扩展包,这不是ai独有的概念,浏览器有插件,任何应用都可能有插件,一个插件里面东西就多了,可以包含skill,agents,hooks,mcp severs等内容。 prompt是提示词的意思,大模型学的东西多了,大模型要在知识汪洋中预测你想要的下一个词,简直不要太难,那么就帮她缩小范围降低幻觉,那就是定人物,定任务范围,定目标,这样将结合以上的信息,进行数据处理,就大大降低了,大模型胡说八道的可能性。大模型本身就是个统计学问题,根本不具备任何智慧,和反思能力,并非动态进化的,而是提前通过人类社会无数的现有文档,向量化,然后通过多维向量的训练出来的,一个具备无数维度的数学矩阵,通过通过上下文的切割成token又称词元,一个词元就是一个数字,多个词元就组成了一个数学矩阵,将这个数学矩阵扔到transform架构的数学矩阵中。我也不知道是不是百亿参数是不是也决定了词元的数量呢,会影响回应呢? workflow就是工作流,针对一项工作设计的工作流程,使其完成特定的任务,取代繁重的工作。 hook钩子的意思,什么是钩子啊就是,当执行到特定情况或者涉及特殊判断的时候就会触发的程序,相当于一个钩子,勾住了你的工作流,在特定情况下触发,进而保证进程的稳定和顺利。 skill技能的意思,技能可以是一个md说明的工作文档,也可以是md说明文档加一些小程序、或者一些模板的综合体,目标就是让大模型能按你的md说明文档进行工作。 harness就是一个工作台,工作台上啥也有,自由搭配,想用啥就用啥,比如有plugin、tools、prompt、workflow、hook、skill、和设定好的agent。 AI / 人工智能 └── 大模型 / LLM / VLM / MLLM └── Agentic System / 智能体系统 ├── Prompt:给模型的指令 ├── Context:当前任务上下文 ├── Memory:可长期保存或检索的历史信息 ├── Tools:模型可调用的外部能力 │ └── MCP:连接 tools / resources / prompts 的标准协议之一 ├── Workflow:预设流程 ├── Hook:生命周期触发器 ├── Skill:可复用能力包 ├── Plugin:可安装扩展包 └── Harness:运行框架 / 执行外壳 agent 是配置了 instructions、tools,以及可选运行行为的 LLM MCP Server 可以向 AI 应用暴露 resources、prompts 和 tools。这样不同 AI 应用和不同外部系统之间就不用每次都重新写一套私有接口。 Tool:一个具体能力 MCP Tool:通过 MCP 协议暴露出来的 tool MCP Server:把一组 tools / resources / prompts 提供给 AI 应用 Agent:根据任务需要决定是否调用这些工具 plugin 可能包含 tools、skills、agents、hooks、MCP servers 等内容。简单说,plugin 是“打包和分发能力”的方式。 prompt 帮模型缩小范围,降低幻觉。这个是对的。OpenAI 文档也把 prompt engineering 描述为编写有效指令,让模型更稳定地产生符合要求的内容。 大模型本质上是通过大量数据训练出来的神经网络,它没有人类意义上的主观意识,也不会在普通对话中自动修改自己的模型参数。它的回答来自当前输入、上下文、训练得到的参数,以及推理时的生成过程。我们看到的“推理”“反思”“自我检查”,更多是模型在特定提示、上下文或工具流程下表现出来的能力,而不是人类式的自我意识。 Token:文本被切分后的处理单位。 Token ID:token 被映射成的数字编号。 Embedding:token ID 进入模型后对应的向量表示。 Parameter:模型训练出来的权重和偏置。 Context window:一次输入/输出能处理的 token 上限。 Training tokens:训练时看过的数据 token 数量。 Vocabulary size:分词器支持的 token 种类数量。 文本会先被 tokenizer 切成 token,再映射成 token ID。模型会把 token ID 转成向量表示,也就是 embedding,然后送入 Transformer 网络中计算。Transformer 通过注意力机制和多层神经网络,结合上下文预测后续 token。参数量指的是模型内部训练出来的权重数量,和输入 token 数不是同一个概念。 Workflow 是预先设计好的流程。它强调“步骤固定、路径清楚、可控性强”。比如先读订单,再判断退款规则,再调用退款接口,再发送通知。workflow 里可以用大模型,也可以不用大模型。它和 agent 的区别是:workflow 的路径主要由人或程序提前写好;agent 的路径更多由模型根据目标和中间结果动态决定。 Anthropic 对这个区别说得很清楚:workflows 是 LLM 和工具通过预定义代码路径编排;agents 则是 LLM 动态决定自己的流程和工具使用。 这个方向对。Anthropic 的 Agent Skills 文档也把 skill 描述为模块化能力包,包含 instructions、metadata 和可选资源,比如 scripts、templates,Claude 会在相关任务中自动使用。 另一个官方指南也说 Skills 可以是由 instructions、scripts、resources 组成的文件夹 Context:这次对话/这次任务临时放进来的信息。 Memory:跨会话保存、以后还能拿出来用的信息。 Context 是模型当前这次任务能看到的信息,比如用户问题、系统指令、聊天历史、检索到的文档、工具返回结果等。Memory 是被长期保存、之后还能被取出来的信息,比如用户偏好、项目背景、历史决策、常用规则等。Memory 不是模型参数本身发生了变化,而是系统把相关历史信息保存下来,在需要时重新塞回 context。 5 个帖子 - 4 位参与者 阅读完整话题

v2ex · 2026-06-11 11:23:15+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest

v2ex · 2026-06-11 10:45:03+08:00 · tech

那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest