
1. 先盯住 KuiklyMarkdown 的 16 ms 合帧流式刷新的第一道闸最近在 KuiklyMarkdown 里调 DSH Mobile 的流式对话电脑端 Harness 正用 TaoToken 跑长任务在电脑端 DeepSeek Harness 调模型前先到 TaoToken 官网 获取 Key把 Base URL 指向https://taotoken.net/api。这一步不是可选项因为手机端看到的每个 chunk、每张审批卡、每次 Agent 追问最终都来自电脑端 Harness 对模型的调用。移动端只是连接、交互和渲染层真正决定任务能否继续跑的是电脑侧模型供应商配置是否正确。KuiklyMarkdown 的难点不在“把 Markdown 渲染出来”而在“模型还在输出时页面不能乱”。普通 Markdown 渲染是一次解析、一次提交流式对话是持续追加。代码块可能只出了一半列表缩进可能还没稳定引用和链接会随 chunk 变化。如果每来一个小片段就全量重建 AST 和 UI手机端很快会出现掉帧、闪烁和滚动位置跳动。我在 DSH Mobile 里采用的策略是已经收尾的 Block 不再重建未收尾部分继续追加如果 fence 还没闭合先在解析副本补一个闭合标记UI 提交按 16 ms 窗口合并。16 ms 接近 60 FPS 的一帧既不会让用户感觉明显延迟也不会让每个 token 都触发一次布局。一个可复现的合帧策略可以写成下面这样。注意这不是要求你照搬某个内部 API而是把“到达频率”和“渲染频率”解耦const val FRAME_MS 16L data class MarkdownFramePolicy( val frameIntervalMs: Long FRAME_MS, val reuseStableBlocks: Boolean true, val repairOpenFence: Boolean true, val keepUnknownBlock: Boolean true ) class StreamMarkdownBuffer( private val policy: MarkdownFramePolicy MarkdownFramePolicy() ) { private val pending StringBuilder() private var lastFlushAt 0L fun append(chunk: String, nowMs: Long, flush: (String) - Unit) { pending.append(chunk) if (nowMs - lastFlushAt policy.frameIntervalMs) { val snapshot pending.toString() pending.clear() lastFlushAt nowMs flush(snapshot) } } fun flushNow(flush: (String) - Unit) { if (pending.isNotEmpty()) { val snapshot pending.toString() pending.clear() flush(snapshot) } } }真正接入 KuiklyMarkdown 时还要把“稳定块复用”和“未闭合 fence 修复”分开处理。稳定块复用解决的是长回答越写越长时的重建成本fence 修复解决的是模型刚输出kotlin 但还没输出结尾时的解析失败。两者都做好以后聊天正文前半段不会反复重排尾部还能继续跟着模型输出刷新。这一步和 TaoToken 的关系在于Harness 任务消耗的 Token、流式 chunk 数量、审批等待时间会直接影响移动端收到的事件节奏。如果电脑端供应商配置错误手机端看到的不是“渲染慢”而是根本没有事件可渲染。所以排查顺序应该是先确认电脑端 Harness 能否稳定调用模型再回头调 KuiklyMarkdown 的合帧参数。2. 电脑端 Harness 接 TaoTokenKey、Base URL 与客户端三件套DSH Mobile 的连接模型决定了 Key 不应该下发到手机 App。Agent 循环、工具执行、插件和工作区仍然在电脑端运行手机只通过 Host 协议连接、订阅和渲染。因此正确做法是在电脑端 Harness 或它调用的 CLI 工具里配置 TaoToken。到 TaoToken 官网 获取 Key 后把 Base URL 设置为https://taotoken.net/api注意Base URL 本身不要附加查询参数。Key 使用占位符时写YOUR_API_KEY不要提交到公开仓库。下面给出一份 Harness 侧供应商配置示例字段名按你的 Harness 版本调整核心是base_url、api_key、model三项provider: openai-compatible base_url: https://taotoken.net/api api_key: YOUR_API_KEY model: 你的模型ID stream: true timeout_ms: 600000如果你用的是 Claude Code配置文件通常走settings.json环境变量走ANTHROPIC_*。示例{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: 你的模型ID, ANTHROPIC_SMALL_FAST_MODEL: 你的快速模型ID } }这里要特别提醒Claude Code 使用ANTHROPIC_*但 Codex 不要套这一组变量。Codex 走自己的config.toml配置方式如下model 你的模型ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chat然后在本机环境变量里放入 Keyexport TAOTOKEN_API_KEYYOUR_API_KEY如果客户端版本对wire_api或 provider 字段支持不同以你本地版本实际读取的字段为准。关键是不要把ANTHROPIC_BASE_URL写进 Codex 配置也不要把 Codex 的model_providers写法硬套到 Claude Code。两个客户端的配置体系不同混用只会让排障更乱。如果你用 CC Switch 管理多套供应商可以把它理解成三件套API Key、Base URL、默认模型。一个概念化配置如下{ provider: taotoken, name: TaoToken, base_url: https://taotoken.net/api, api_key: YOUR_API_KEY, model: 你的模型ID }CC Switch 的实际字段名可能随版本变化但切换供应商时始终要核对这三项。尤其是 Base URL很多“请求 404”或“模型不存在”并不是 Key 错而是客户端把请求打到了旧地址或者把 Anthropic 路径和 OpenAI 兼容路径混在了一起。配置完成后先在电脑端跑一个最小任务确认 Harness 能收到流式响应再启动 DSH Mobile。电脑端都调不通时手机端再怎么重连也没有意义。3. 两条 WebSocket 与世代号手机端不重跑任务只补事件DSH Mobile 没有在手机和电脑之间再加一层业务服务而是直接复用 Host 协议。电脑上的 DSH 暴露 HTTP RPC 和两条下行 WebSocket。移动端连接最外层接口不理解内部插件结构。这样做的好处是协议变化时手机端只需要跟着 Host 侧适配而不是自己重新翻译一套业务语义。两条 WebSocket 分工不同/api/events.mux负责当前会话内正在发生的事例如模型输出、工具调用、审批、提问、消息队列和后台任务。/api/events.host负责电脑侧的全局变化例如会话增删、运行状态、工作区变更和 Host 级错误。聊天页面主要消费mux工作区和会话列表更多依赖host。两条流分开以后会话正文和全局状态不会挤在同一个通道里生命周期也可以不同。比如用户正在看某个会话的流式输出时后台会话列表状态更新不应该打断当前滚动。有两个细节必须单独处理。第一session/queue和session/jobs推送的是完整快照不是增量事件。它们不进入会话日志重连时也不能只靠回放恢复所以收到新快照后应直接覆盖本地状态。第二session/projection.value和host/remote-event.args在 carrier 层是宽类型结构由各自业务包负责。App 不能假设所有帧都严格符合固定 data class需要保留未知字段并在解析失败时降级而不是让整条事件流中断。移动网络一定会断。锁屏、切后台、进隧道、Wi-Fi 与蜂窝切换都可能让连接失效。重连后我采用的恢复顺序是重新建立 SSH 或 Relay 隧道。使用上次收到的序号补回遗漏的session/event。请求一次session.history重新对齐聊天记录。使用最新快照覆盖queue和jobs。每次连接还带一个世代号。连接断开后旧连接中迟到的 RPC 响应会被丢弃不能写进新会话状态。一个简化的状态机可以这样表达sealed interface ReconnectStage { data object Tunnel : ReconnectStage data object ReplayEvents : ReconnectStage data object SyncHistory : ReconnectStage data object SnapshotState : ReconnectStage } data class ConnectionEpoch(val value: Long) fun shouldAccept(epoch: ConnectionEpoch, current: ConnectionEpoch): Boolean epoch.value current.value这套状态机放在共享层三端采用同样的补齐顺序和失效规则。平台代码只负责把底层连接事件交给共享层。这样 Android、iOS、鸿蒙在断网恢复上的行为才不会各写各的。最重要的一点如果 Agent 在断线期间仍在电脑端运行App 重连后应该重新订阅这一轮任务而不是把用户的 Prompt 再发一次。重连的目标是恢复观察和控制不是再次触发同一轮推理。4. SSH 直连与扫码 Relay3080 端口不直接暴露公网DSH 默认监听127.0.0.1:3080。这是应该保留的默认值因为能访问这个端口的客户端可能通过 Agent 和工具获得较高的本机操作权限。DSH Mobile 没有把 3080 直接开到公网而是提供 SSH 和扫码 Relay 两种连接方式。有 SSH 环境时直接在手机上建立本地端口转发把 App 侧一个 loopback 端口映射到电脑的127.0.0.1:3080。HTTP RPC 和两条 WebSocket 都通过这条隧道传输。认证发生在 SSH 层DSH 仍然只看到来自本机回环的请求。已经配置好主机和密钥时这条路径最直接。不想配 SSH 时可以用扫码 Relay。电脑端安装扫码插件后DSH Settings 中会出现 Remote Access 页面。电脑生成二维码手机扫描后完成配对。电脑端插件和 App 都主动连接 Relay再通过sealed-tunnel-v1转发 DSH 流量。整个过程中Relay 不需要直接访问 3080电脑也不需要把 DSH 服务开放到局域网或公网。二维码中的主密钥位于 URL fragment 中正常 HTTP 请求不会把 fragment 发给 Relay。隧道数据经过密封后再转发Relay 只负责连接双方和传递数据。本地局域网试用时Relay 需要监听手机能访问的接口。HOST0.0.0.0会把 8787 端口开放到本机所有网络接口请只在可信网络中使用并检查系统防火墙。启动 DSH 后打开 Settings进入 Remote Access再用手机扫码。如果扫码后一直超时先检查三件事curl http://电脑局域网地址:8787/health手机能否访问上面这个健康检查地址。PUBLIC_RELAY_URL是否仍是电脑当前局域网地址。Relay 是否真的监听了手机可达的接口防火墙是否允许 8787 端口。电脑更换 Wi-Fi 或热点后局域网地址通常会变化。此时需要更新PUBLIC_RELAY_URL重启 DSH再扫描新二维码。不要为了省事直接把 3080 暴露到公网移动端远程控制的第一原则是把风险边界留在本机回环和加密隧道里。5. 把 16 ms 合帧和 Token 消耗写进同一张任务记录可复现的流式体验不只看 UI 是否顺滑还要看 Harness 任务消耗了多少 Token、审批等待了多久、重连了几次。电脑端 Harness 调用 TaoToken 时建议在响应侧记录 usage。TaoToken 的入口仍然在 TaoToken 官网Base URL 继续使用https://taotoken.net/api。手机端可以记录一帧提交次数电脑端记录模型 usage两边用task_id关联。下面是一份本地 JSONL 记录示例{ts:2025-01-01T10:00:00Z,task_id:dsh-001,model:你的模型ID,base_url:https://taotoken.net/api,prompt_tokens:18432,completion_tokens:2761,total_tokens:21193,stream_chunks:414,frames_16ms:68,approval_wait_ms:120000,reconnect_count:1}字段含义可以这样理解stream_chunks是模型侧流式片段数量。frames_16ms是 KuiklyMarkdown 实际提交 UI 的帧次数。approval_wait_ms是任务因为人工审批或追问而暂停的时间。reconnect_count是这一轮任务中连接重建次数。如果frames_16ms远小于stream_chunks说明 16 ms 合帧在生效。如果两者接近甚至一 chunk 一帧就要回头检查是不是每个事件都直接触发了 setText 或全量重建。一个本地聚合命令如下SQL 和命令都由读者在自己的机器上执行不要连接任何生产库jq -s group_by(.task_id) | map({ task_id: .[0].task_id, total_tokens: (map(.total_tokens) | add), frames_16ms: (map(.frames_16ms) | add), approval_wait_ms: (map(.approval_wait_ms) | add), reconnects: (map(.reconnect_count) | add) }) harness_usage.jsonl这张记录会暴露很多问题。比如总 Token 很高但approval_wait_ms也高说明主要时间花在等人工确认不是模型慢比如reconnect_count高但任务仍然完成说明断线恢复状态机在起作用比如frames_16ms非常低但用户感觉卡可能是解析或高亮在单帧里做了太多事。把 UI 指标和 Token 指标放在一起看才能判断是渲染问题、连接问题还是任务本身太长。手机端适合看短、轻、高频的交互流式回复、工具状态、命令审批、Agent 追问、后台 jobs、Goal 进度。长 Prompt、大段 Diff 和需要持续思考的修改仍然更适合桌面。这个 App 的定位是远程控制面板不是把完整 IDE 搬到手机上。6. 从组件市场到 commonMain跨端 UI 开发者该下沉什么这个项目对跨端框架的要求并不抽象。聊天正文是持续变化的 Markdown工具调用、审批卡、提问卡和后台任务会同时更新。底层还有 HTTP RPC、两条 WebSocket 事件流、扫码配对、SSH 隧道和本地存储。三端不仅要长得接近连接和恢复行为也要一致。Kuikly 组件市场里已有 KuiklyMarkdown 和 KuiklyWebview 这类现成能力。KuiklyMarkdown 基于 intellij-markdown 解析文本输出 Block 列表并提供流式渲染状态。接入后主要精力可以放在产品逻辑上而不是自己维护三端 Markdown 解析、代码高亮和基础样式。KuiklyWebview 用于 Markdown 链接和外部页面的 App 内打开共享页面里设置 URL再监听开始加载、进度、完成和失败事件即可。真正费时间的通常不是页面而是 WebSocket、扫码、SSH 和数据库这类系统能力。DSH Mobile 在commonMain定义统一的 Module 接口把连接、发消息、收消息和断开等业务语义固定下来。Android、iOS、鸿蒙分别接自己的系统实现。拿 WebSocket 举例三端底层分别是 OkHttp、NSURLSession、NetworkKit但上层页面只面对同一个DshWebSocketModule。平台差异收敛在最底层聊天页、事件处理和业务状态不需要知道自己在哪个系统上。这层下沉的收益会随协议变化放大。DSH 还没有完全稳定往后每多一个 RPC、改一个事件只需改共享层三端一起生效。目前项目约有 1.3 万行commonMainKotlin 代码由三端共享Android、iOS、鸿蒙宿主各有三四千行主要用于接入系统能力和完成平台工程配置。继续开发时入口大致集中在这几处新增 Host 方法时在DshHostProtocol.kt增加定义然后复用现有 RPC 通道。只要不涉及新的系统能力三端不需要分别修改原生代码。新增原生能力时先在commonMain定义 Module再补三端实现。Android 从KuiklyRenderActivity导出模块iOS 实现放在KuiklyExpand/Modules鸿蒙实现放在kuikly/modules。新增页面时创建带Page注解的页面类KSP 会生成路由。如果要让 DSH 内部的新事件出现在手机上可以写一个 DSH 插件监听对应事件并通过host/remote-event转发。具体事件仍要加入 Host 侧允许转发的范围App 端也要对宽类型参数做容错。注册新的官方 RPC 方法或替换整套传输层就不再是单纯修改 App而是需要改 DSH 本体。此类改动更适合按上游项目的方式提交和维护。7. 快速复现清单与文末路径把上面的内容压缩成一份可执行清单电脑端 Harness 配置 TaoToken到 TaoToken 官网 获取 KeyBase URL 填https://taotoken.net/api先用最简任务确认流式响应可用。启动 DSH保留默认127.0.0.1:3080监听不要直接暴露公网。有 SSH 就用本地端口转发没有 SSH 就启动 Relay安装扫码插件确保手机和电脑在同一可信网络。手机 App 扫码连接后聊天页消费/api/events.mux工作区和会话列表消费/api/events.host。KuiklyMarkdown 开启 16 ms 合帧、稳定 Block 复用、未闭合 fence 修复。在 Harness 侧记录 usage在 App 侧记录frames_16ms用task_id汇总分析。断线重连按“隧道、补事件、对历史、覆盖快照”的顺序恢复旧世代响应直接丢弃。如果你还没决定从哪里开始可以按这个顺序走一遍先到 模型对话 看流式输出效果如果你的 Harness 任务经常跑几分钟甚至更久再了解 Coding Plan随后到 API Keys 创建 KeyClaude Code、CLI 和环境变量细节参考 Claude Code 文档。DSH 仍处于 developer preview协议和包结构都可能发生破坏性变化。文中涉及的方法数量、目录和事件名称应对应你验证过的版本。实际开发时先锁定经过验证的 tag再逐步跟进上游变化。对跨端 UI 开发者来说先把 16 ms 合帧、两条 WebSocket、断线恢复和 Token 消耗记录跑通再谈更多页面和更复杂的交互路径会稳得多。