dsh-plugin-mobile-gateway 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现。当前源码适配 DSH 0.2.0-rc.2 / Session format 4;移动端实时流接入见 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md),插件管理见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
3
+ 移动端通过经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现。当前源码适配 DSH 0.2.1-rc.2 / Session format 4;移动端实时流接入见 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md),插件管理见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
4
+
5
+ DSH Remote 要求的 `peer` 由网关在 Host 内部创建并注入,身份沿用已配对设备的本机用户权限,生命周期随插件释放;移动端请求帧不增加 `peer` 字段,`dsh-mobile-v1` 协议不变。
4
6
 
5
7
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
8
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -661,6 +663,21 @@ let image = [
661
663
  | `tasks` | `sessionId` | 当前任务列表(`todos` projection) |
662
664
  | `goal` | `sessionId` | 当前目标及其 CAS 版本(`goal` projection) |
663
665
 
666
+ ### 会话列表投影
667
+
668
+ `sessions.items[]` 保留 Host 返回的会话元信息(包括 `sessionId`、`updatedAt`、`running`、`blank`、`cwd`、`agentPreset`、`parentSessionId` 和 `origin`)。每项 `projections` 仅保留原有的 `asOfSeq` 和 `values.title`,不随列表下发大纲、任务、上下文等完整投影,避免大量会话导致公网隧道传输超时。标题仍从 `projections.values.title` 读取,缺失或 `null` 时沿用客户端的默认标题规则。
669
+
670
+ ```json
671
+ { "type": "sessions" }
672
+ → { "kind": "sessions", "items": [
673
+ { "sessionId": "session-abc", "updatedAt": 1786937352, "running": false, "blank": false,
674
+ "cwd": "/workspace", "agentPreset": "standard",
675
+ "projections": { "asOfSeq": 200, "values": { "title": "排查网关连接" } } }
676
+ ] }
677
+ ```
678
+
679
+ 此裁剪仅用于 `sessions` 响应,不修改 Host 数据;`history`、`session-snapshot`、控制投影基线及实时投影通知继续提供各自原有的详情。现有客户端无需修改请求或标题解析方式。
680
+
664
681
  ### Session 归档与重命名
665
682
 
666
683
  在 App 端归档 Session:
@@ -706,9 +723,26 @@ WebUI、App 或其他客户端造成的变化通过以下帧主动推送:
706
723
  - `view: "conversation"`:**对话裁剪模式**——隐藏系统消息与 request/header、request/context,移除 Assistant 事件的内嵌 `data.stream`,同时保留 `data.finish`(结束类型和结构化失败原因);`tool/result` 原始文本块截断到 2000 字符,另用 `data.output/outputTruncated` 保留最多约 16000 字符的首尾输出
707
724
  - 分页:`hasMore` 为真时用 `beforeSeq: nextBeforeSeq, historyFormatVersion: 4` 请求更早一页
708
725
 
726
+ ### 会话轮次大纲
727
+
728
+ `hello.capabilities` 的 `turn-outline-v1` 表示支持完整轮次大纲传输;不要求 Host 开启全文搜索,也不保证自定义 Host profile 挂载了大纲插件。App 可从 `history` / `session-snapshot` 的 `projections.values.turnOutline` 读取数组,每项为 `{turn, seq, prompt, response}`;`seq` 指向 `turn/start`,提示词和回复分别最多 50 / 120 个 UTF-16 code unit,回复在轮次结束后提交。正文分页和字节裁剪不会裁掉大纲。
729
+
730
+ 控制连接(兼容 legacy 单连接)的 `projection-baseline.projections[sessionId].values` 新增 `turnOutline`,缺少该投影时为 `null`。Host 大纲变化时发送完整替换值:
731
+
732
+ ```json
733
+ { "kind": "turn-outline-updated", "sessionId": "s1", "asOfSeq": 40,
734
+ "turnOutline": [{ "turn": 1, "seq": 10, "prompt": "调整输入框阴影", "response": "已减轻阴影。" }] }
735
+ ```
736
+
737
+ 该通知不受 `subscribe.sessionId` 过滤,不发往 conversation 连接。`[]` 为有效空大纲;`null` 使旧控制大纲失效并降级到已加载轮次。Host 控制流重建会整体替换基线,并为被移除会话发送 `null` 失效通知,沿用其旧缓存水位。控制基线只覆盖 Host 当前控制集合,缺少冷会话不表示会话被删除;进入冷会话仍从其历史快照读取大纲。新控制连接会获得最近缓存的大纲。
738
+
739
+ `asOfSeq` 是投影切点,不是连续通知序列。数组整体替换,不能 append;App 需处理控制基线代际与历史响应乱序。完整帧示例、触屏交互、分页跳转、版本重建和验收步骤见 [App 会话轮次导航接入指南](docs/turn-outline-app-integration.md)。
740
+
741
+ ### `history` 响应示例
742
+
709
743
  ```json
710
744
  → { "kind": "history", "sessionId": "session-abc", "events": [ ...原始事件... ],
711
- "historyFormatVersion": 3, "cursor": 200,
745
+ "historyFormatVersion": 4, "cursor": 200,
712
746
  "bytes": 3521, "view": "conversation", "hasMore": true, "nextBeforeSeq": 128,
713
747
  "projections": { "asOfSeq": 200, "values": { "tokenUsage": {...}, "contextPressure": {...}, "permissions": {...}, "sessionStats": {...} } } }
714
748
  ```
@@ -1108,6 +1142,8 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
1108
1142
  | `session-archives` | Host 的完整 Session 归档集合在连接初始化或 WebUI/App 归档后变化 |
1109
1143
  | `session-title-changed` | 任意客户端写入新的持久化 Session 名称 |
1110
1144
  | `tasks-updated` / `goal-updated` | 当前会话的任务列表或目标 projection 发生变化 |
1145
+ | `projection-baseline` | 控制投影集合,含 `todos/goal/turnOutline`;连接初始化及 Host 控制流重建时发送 |
1146
+ | `turn-outline-updated` | 指定 Session 的完整轮次大纲替换值或 `null` 失效通知;仅控制/legacy 连接 |
1111
1147
  | `schedule-changed` | Host 定时任务目录已变化;无载荷,重新请求 `schedule-catalog` 或当前 Session 的 `schedule-list` |
1112
1148
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
1113
1149
  | `approval-requested` / `approval-resolved` | Human-in-the-loop 操作审批请求与最终状态 |
@@ -1175,7 +1211,9 @@ DSH Web 的“深度求索中,用时 22 秒 ···”是客户端文案,不
1175
1211
 
1176
1212
  | 版本 | 新增 |
1177
1213
  |---|---|
1178
- | v0.10.2(当前源码) | 补齐 `deliverables/presented` 文件卡片实时字段;新增 `file-references-v1` 能力与 `file-resolve` 安全文件引用解析;复用现有分块下载支持移动端预览接入 |
1214
+ | v0.10.4(当前源码) | 精简 `sessions` 列表投影,仅保留 `asOfSeq` 和 `values.title`,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改 |
1215
+ | v0.10.3 | 适配 DSH 0.2.1-rc.2;为 Remote 请求及实时订阅补齐 Connection Peer,修复读取 `signal` 时的空值报错,并随网关停止释放 Peer;移动端协议保持不变 |
1216
+ | v0.10.2 | 补齐 `deliverables/presented` 文件卡片实时字段;新增 `file-references-v1` 能力与 `file-resolve` 安全文件引用解析;复用现有分块下载支持移动端预览接入 |
1179
1217
  | v0.10.1 | 新增官方 speech Remote 语音输入桥接;模型准备与状态同步;移动端音频转文字与取消转写;连接关闭时清理语音任务 |
1180
1218
  | v0.10.0 | 响应式居中控制面板与主题同步;自定义下拉菜单;独立配对模态窗口与缩放动画;按运行实例关闭重启提醒;在线设备折叠列表与移动端设备名称上报;Tunnel 配置交互优化 |
1181
1219
  | v0.9.0 | DSH 0.2.0-rc.2 插件管理;PC 端 Cloudflare Quick / 命名 Tunnel 及独立重启;插件私有 `cloudflared` 缓存及校验;配对入口选择指引;网关重启与 DSH Web 停止工具 |
package/README.md CHANGED
@@ -6,11 +6,15 @@
6
6
 
7
7
  DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排队消息同步及编辑/删除/Steer、Session 归档和重命名的双向同步、停止当前生成并稍后继续、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
8
8
 
9
- > 当前源码以 **DSH 0.2.0-rc.2** 为适配基线,使用 Session format 4;旧版历史游标需重新取得基线。
9
+ > 当前源码以 **DSH 0.2.1-rc.2** 为适配基线,需要 Connection Peer 接口,使用 Session format 4;旧版历史游标需重新取得基线。
10
10
  >
11
11
  > 实时流已改为独立 `assistant-stream` 帧,移动端需要按 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md) 更新订阅、缓存与分页处理。当前修改尚未发布。
12
12
  >
13
- > v0.10.2(当前源码):新增文件卡片事件透传与安全文件引用解析,复用现有分块下载,为移动端文件卡片和正文文件链接预览提供协议支持。App 接入方式见 [文件卡片与预览接入说明](docs/file-cards-mobile-integration.md)。
13
+ > v0.10.4(当前源码):精简会话列表投影,仅保留标题与投影切点,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改。
14
+ >
15
+ > v0.10.3:适配 DSH 0.2.1-rc.2,为 Remote 请求及实时订阅补齐 Connection Peer,修复 `Cannot read properties of undefined (reading 'signal')` 报错,并在网关停止时释放 Peer。移动端协议保持不变。
16
+ >
17
+ > v0.10.2:新增文件卡片事件透传与安全文件引用解析,复用现有分块下载,为移动端文件卡片和正文文件链接预览提供协议支持。App 接入方式见 [文件卡片与预览接入说明](docs/file-cards-mobile-integration.md)。
14
18
  >
15
19
  > v0.10.1:新增官方语音输入接入,支持语音模型准备与状态同步、移动端音频转文字、取消转写及连接关闭后的任务清理。通过官方 speech Remote 处理识别,移动网关无需打包 ASR 模型。
16
20
  >
@@ -24,7 +28,15 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
24
28
 
25
29
  ## 协议与 DSH 兼容层
26
30
 
27
- 移动端连接的是本项目维护的 `dsh-mobile-v1`,不是 DSH 的内部 Remote 协议。插件内部通过独立 Host Adapter 对接 DSH 0.2.0-rc.2 的 Remote Gateway;Session、Workspace、Settings、Commands、Goals、Schedule、Permission Presets、Plugin Manager 等 namespace 和严格参数只存在于该适配层。插件管理的请求帧及 DSH Mobile 页面方案见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
31
+ 移动端连接的是本项目维护的 `dsh-mobile-v1`,不是 DSH 的内部 Remote 协议。插件内部通过独立 Host Adapter 对接 DSH 0.2.1-rc.2 的 Remote Gateway;Session、Workspace、Settings、Commands、Goals、Schedule、Permission Presets、Plugin Manager 等 namespace 和严格参数只存在于该适配层。插件管理的请求帧及 DSH Mobile 页面方案见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
32
+
33
+ DSH 0.2.1-rc.2 要求每次 Remote 调用携带 `peer`。网关通过 `ctx.connection.createPeer()` 创建一个随插件释放的本机用户 Peer,普通请求、首帧读取和实时订阅都由 Host Adapter 注入它;移动端不能设置调用方身份。问答与审批继续由网关的 Host 监听器处理。旧网关漏传此字段时会出现 `Cannot read properties of undefined (reading 'signal')`,升级 DSH 后需重新安装本源码版本并重启 `dsh web`,手机无需更改协议。
34
+
35
+ 可用本机已安装的官方 DSH 验证此契约(真实 Gateway / Connection,业务服务使用测试数据,不读写用户会话):
36
+
37
+ ```bash
38
+ DSH_INSTALL_ROOT=/absolute/path/to/@deepseek-ai/dsh node test/host-official.test.mjs
39
+ ```
28
40
 
29
41
  配对鉴权和 `dsh-mobile-v1` / `hello.protocol = 3` 保持不变。新版实时 token 不占用持久事件的 `seq`:客户端显式订阅 `assistantStream: true`,接收原子的 `session-snapshot` 和独立增量;普通 `event` 只携带持久事件。未接入新订阅的客户端只能收到持久消息。
30
42
 
@@ -38,6 +50,8 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
38
50
 
39
51
  当前源码新增会话模式选择:App 创建空白 Session 后,可查询并修改该 Session 的 Agent preset;首次对话后锁定。通过 `session-agent-preset` 能力发现接入,详见 [App 接入说明](docs/session-agent-preset-app-integration.md)。App 界面需按此说明接入,本次源码尚未发布。
40
52
 
53
+ 当前源码新增 `turn-outline-v1`:透传 WebUI 使用的完整轮次大纲及实时更新,支持 App 在未加载全部正文时展示会话刻度、问答预览并按需分页跳转。控制连接重建和新设备连接会恢复大纲基线;Host 未提供大纲时可降级为已加载轮次导航。详见 [App 会话轮次导航接入指南](docs/turn-outline-app-integration.md)。App 界面需单独接入,本次源码尚未发布。
54
+
41
55
  ## 多网关第一阶段(当前源码)
42
56
 
43
57
  插件提供稳定 `gatewayId`、可配置 `gatewayName`、配对候选地址列表,以及可持久化的“关闭 / 临时开启 / 常驻开启”运行模式。一个 App 可以分别配对不同机器上的网关;客户端多网关管理仍需按 [App 对接说明](docs/multi-gateway-app-integration.md) 实现。本次源码尚未发布新的 npm 版本。
@@ -0,0 +1,196 @@
1
+ # App 会话轮次导航接入指南
2
+
3
+ ## 接入目标
4
+
5
+ 使用 DSH 的 `turnOutline` 投影,实现 WebUI 的会话刻度导航、问答预览和点击跳转。服务端提供整场会话的大纲,App 只加载当前需要的正文;无需先下载全部历史,也不依赖全文搜索或 WCDB。WCDB 可以用于后续离线缓存。
6
+
7
+ 适用基线为 DSH 0.2.1-rc.2、`dsh-mobile-v1`、`hello.protocol: 3`、Session format 4。本次是向后兼容的协议扩展;App 界面和本地存储需要单独实现。
8
+
9
+ ## 能力发现与数据结构
10
+
11
+ 检查 `hello.capabilities` 是否包含 `turn-outline-v1`。它表示网关支持大纲快照和控制流更新的传输,不保证 Host 已挂载 `@deepseek-ai/dsh-session-turn-outline`。官方 Web profile 已组合此插件,自定义 profile 可能没有。
12
+
13
+ 投影值为按 `turn` 严格递增的完整数组:
14
+
15
+ ```json
16
+ [
17
+ { "turn": 1, "seq": 10, "prompt": "调整输入框阴影", "response": "已减轻阴影,保留轻微悬浮感。" },
18
+ { "turn": 2, "seq": 30, "prompt": "再缩小一点", "response": "" }
19
+ ]
20
+ ```
21
+
22
+ | 字段 | 语义 |
23
+ |---|---|
24
+ | `turn` | Host 分配的非负轮次号;不要假定必须从 1 开始或连续 |
25
+ | `seq` | 该轮 `turn/start` 的持久事件序号,用于向前补齐历史 |
26
+ | `prompt` | 首条来源为 `user` 的人类文本预览,文本块以空格连接并折叠空白,最多 50 个 UTF-16 code unit,截断带省略号 |
27
+ | `response` | 最后一条有文本的助手消息预览,最多 120 个 UTF-16 code unit,在 `turn/end` 后提交 |
28
+
29
+ 预览直接来自正文,不是 AI 摘要。注入上下文和工具结果不填充提示词预览;同轮后续 steering 不覆盖首条提示词。纯图片输入可以没有提示词文本,应显示“第 N 轮”等本地化占位。空回复可能表示轮次仍在运行或该轮没有助手文本,不能只据此推断生成状态。
30
+
31
+ 建议共享模型中的 `turn`、`seq`、`asOfSeq` 使用 `Long`。JSON 数值按非负安全整数校验,`asOfSeq` 另允许空日志切点 `-1`。按网关 ID、会话 ID 和历史格式版本隔离缓存,不把其他网关相同 session ID 的记录混用。
32
+
33
+ ## 快照与实时更新
34
+
35
+ ### 当前会话快照
36
+
37
+ 沿用对话连接的现有原子订阅:
38
+
39
+ ```json
40
+ { "type": "subscribe", "sessionId": "s1", "assistantStream": true }
41
+ ```
42
+
43
+ `session-snapshot` 中读取 `projections.values.turnOutline`:
44
+
45
+ ```json
46
+ {
47
+ "kind": "session-snapshot",
48
+ "sessionId": "s1",
49
+ "subscriptionId": "sub-1",
50
+ "streamId": "stream-1",
51
+ "replace": true,
52
+ "historyFormatVersion": 4,
53
+ "cursor": 35,
54
+ "events": [],
55
+ "hasMore": true,
56
+ "nextBeforeSeq": 30,
57
+ "projections": {
58
+ "asOfSeq": 35,
59
+ "values": {
60
+ "turnOutline": [
61
+ { "turn": 1, "seq": 10, "prompt": "调整输入框阴影", "response": "已减轻阴影。" },
62
+ { "turn": 2, "seq": 30, "prompt": "再缩小一点", "response": "" }
63
+ ]
64
+ }
65
+ },
66
+ "assistantStream": { "revision": 0 }
67
+ }
68
+ ```
69
+
70
+ 示例省略正文内容。`events` 和大纲是两份独立数据:首帧正文限制为最新窗口,大纲仍包含全部已开始的轮次,不随正文预算裁剪。普通 `history` 响应也保留相同的投影。
71
+
72
+ 不建立实时订阅、只需要查询大纲时,可以在控制连接发送现有请求:
73
+
74
+ ```json
75
+ { "type": "history", "sessionId": "s1", "maxMessages": 1, "view": "conversation" }
76
+ ```
77
+
78
+ 这会返回少量最新正文和当前投影,不会加载全部历史。它不是无正文专用接口;不要用这样的响应替换已安装的完整聊天窗口,只提取投影即可。
79
+
80
+ ### 控制连接基线
81
+
82
+ 控制连接和 legacy 单连接初始化时收到 `projection-baseline`;Host 控制流重建后再次收到完整基线:
83
+
84
+ ```json
85
+ {
86
+ "kind": "projection-baseline",
87
+ "projections": {
88
+ "s1": {
89
+ "asOfSeq": 35,
90
+ "values": {
91
+ "todos": [],
92
+ "goal": null,
93
+ "turnOutline": [
94
+ { "turn": 1, "seq": 10, "prompt": "调整输入框阴影", "response": "已减轻阴影。" }
95
+ ]
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ 这是 Host 当前控制流所覆盖会话的集合,通常为已附着的会话,不是磁盘上所有历史会话的目录。冷会话进入页面后仍需从自身的 `history` 或 `session-snapshot` 取得投影。基线中缺少某个 session 不表示删除该会话或其本地正文。
103
+
104
+ App 应整体替换来自控制流的基线集合。首次连接时网关可能先发送空的缓存集合,稍后再发送上游基线;不能把首个空集合视为“没有历史会话”。重建后不在集合内的旧控制状态应失效,必要时为当前页面重新取得会话快照。
105
+
106
+ ### 大纲更新通知
107
+
108
+ ```json
109
+ {
110
+ "kind": "turn-outline-updated",
111
+ "sessionId": "s1",
112
+ "asOfSeq": 40,
113
+ "turnOutline": [
114
+ { "turn": 1, "seq": 10, "prompt": "调整输入框阴影", "response": "已减轻阴影。" },
115
+ { "turn": 2, "seq": 30, "prompt": "再缩小一点", "response": "已缩小阴影范围。" }
116
+ ]
117
+ }
118
+ ```
119
+
120
+ - 通知携带完整数组,整体替换对应会话的大纲,不能 append 或按数组下标拼接。
121
+ - 发往控制连接和 legacy 单连接,不发往 conversation 连接,也不受该连接的 `subscribe.sessionId` 过滤。必须按 `sessionId` 分发,不能直接写入当前页面。
122
+ - 新控制连接的 `projection-baseline` 包含网关最近收到的大纲;其他投影更新不会覆盖它。
123
+ - `asOfSeq` 是上游投影对应的持久日志切点,不是独立通知流水号,不能要求连续或用它推进对话事件游标。
124
+ - Host 每轮通常至多更新三次大纲:开轮、首条提示词、落定回复;流式 token 不会触发大纲逐字更新。已加载轮次可以使用本地消息提供即时预览。
125
+
126
+ 大纲值的清空与缺省规则:
127
+
128
+ | 值或情形 | App 行为 |
129
+ |---|---|
130
+ | `[]` | 有效的空大纲,整体替换旧数组 |
131
+ | `null` | 当前控制源不提供该大纲,清除其旧值,退回已加载轮次导航 |
132
+ | 控制基线中的投影缺失 | 网关会规范化为 `turnOutline: null` |
133
+ | 会话快照中该键缺失 | Host 不提供此投影,使用已加载轮次;不要伪造完整历史 |
134
+ | 重建基线不再包含旧 session | 控制状态失效;网关另发送该 session 的 `turn-outline-updated`,值为 `null` |
135
+
136
+ 会话从控制基线消失时发出的 `null` 是失效通知,使用旧缓存的水位,不代表新的日志事件。它与新基线一起处理,不应用普通增量的水位规则忽略失效。冷会话可重新读取自己的快照恢复大纲。
137
+
138
+ ## App 状态与乱序处理
139
+
140
+ 建议将以下状态放在 KMM 共享层,由 Android 和 iOS 分别渲染:
141
+
142
+ - `TurnOutlineStore`:服务器大纲、来源、投影水位及连接代际。
143
+ - `TurnNavigationIndex`:合并服务器大纲与已加载轮次,生成有正文锚点的 `loaded` 项及只有 `seq` 的 `unloaded` 项。
144
+ - `TurnJumpCoordinator`:目标轮次、目标 `seq`、加载状态、可取消的跳转代际。
145
+
146
+ 合并规则与 WebUI 一致:以 `turn` 为键取两侧并集并升序排列;有已加载节点则优先使用其正文锚点,非空的本地预览覆盖服务端预览,本地预览为空时回退服务端。没有可见正文节点时保留服务端 `seq`,标为未加载。渲染锚点应是稳定节点 ID,不是易变的列表下标。
147
+
148
+ 在同一连接代际内,对每个会话的 `turnOutline` 单独记录 `asOfSeq`,忽略水位更小的普通快照或更新,相同水位重复值幂等处理。不要把任务或 Goal 的水位作为大纲水位:不同投影可能更新于不同事件。
149
+
150
+ 收到新 `projection-baseline` 时开启新的控制基线代际并替换其集合;这是权威重建,可能使旧缓存失效。控制连接与对话连接各有代际,切网关、断线重连、订阅替换后应丢弃属于旧代际的回调。会话快照必须匹配当前 `sessionId/subscriptionId/streamId`,跨通道的旧历史响应不能覆盖新大纲或当前聊天窗口。
151
+
152
+ 历史格式改变或出现 `history-format-mismatch` 时,清理该会话旧大纲的 `seq`、分页游标、跳转任务及旧格式消息缓存,再取得新基线;不能只把旧坐标的版本号改成 4 后重试。
153
+
154
+ ## 点击轮次与加载历史
155
+
156
+ 已加载项直接滚动到该轮用户消息;无用户节点时滚动到该轮第一个可见节点。历史分页插入头部、图片或 Markdown 改变高度时,应保持语义锚点及其偏移,避免页面跳动。
157
+
158
+ 未加载项按以下步骤处理:
159
+
160
+ 1. 记录目标 `turn/seq` 和新的跳转代际,暂停自动跟随底部,显示该轮的加载状态。
161
+ 2. 复用当前历史窗口的 `nextBeforeSeq` 向前分页。请求示例:
162
+
163
+ ```json
164
+ { "type": "history", "sessionId": "s1", "beforeSeq": 300, "historyFormatVersion": 4, "maxMessages": 200, "view": "conversation" }
165
+ ```
166
+
167
+ 3. 按 `sessionId + seq` 合并、去重更早页,更新分页游标。目标 `turn/start` 进入连续补齐的历史覆盖范围后,再解析该轮消息锚点;不能仅凭本地最小 seq 判断已覆盖,因为重连缓存可能有缺口。
168
+ 4. 未到达目标且 `hasMore: true` 时继续,必须确认 `nextBeforeSeq` 严格减小。即使可见 `events` 为空,仍使用服务端返回的有效游标继续,不以数组为空判断结束。
169
+ 5. 布局提交后滚动到目标锚点,清除加载状态。用户再次选轮次时,只允许最新跳转执行滚动;已有历史请求可以完成并缓存,但不能把页面拉回旧目标。
170
+ 6. 会话切换、网络错误、历史到头或游标不前进时结束循环并允许重试。未能恢复目标节点时,不显示“跳转成功”。
171
+
172
+ 历史响应目前不提供独立请求 ID 的关联保证。同一会话的普通上拉加载与跳转加载需要串行协调,不要为同一 session 并发启动多个无法区分的 `history` 请求。已安装实时快照后,历史页只补充历史区域,不替换实时消息。
173
+
174
+ 网关当前只开放 `maxMessages`,未开放 WebUI 内部的 `turnWindow` 参数,也没有任意锚点附近直接读取的新接口。`200` 是 App 的起始批量建议,不是协议固定值;需要服从响应实际 `hasMore/nextBeforeSeq` 和字节预算。远距离跳转可能需要多次请求。
175
+
176
+ ## 触屏交互与降级
177
+
178
+ WebUI 使用固定间距刻度和虚拟列表,悬停或键盘焦点显示一行提示词与至多三行回复;点击才跳转。它在窄屏隐藏导航,App 应独立适配触屏:扩大触摸区域,按住或拖动时显示预览,松手确认跳转,并允许取消。轮次较多时虚拟化刻度,不必同时创建全部消息视图。
179
+
180
+ 没有 `turn-outline-v1`、Host 缺少投影或收到 `null` 时,只展示已加载轮次。纯图片轮次使用编号;隐藏的工具轨迹不填充问答预览。书签持久化、全文搜索、智能摘要与本功能独立,不由本协议扩展提供。
181
+
182
+ 完整大纲每次整值传输,大小随轮次数增长。正文预算不限制投影大小,大量会话或数千轮历史应关注客户端帧上限及内存,后续再根据实测引入大纲分页;不要把当前方案描述成恒定大小。
183
+
184
+ ## 验收与源码入口
185
+
186
+ - 正文只加载尾部窗口时,仍显示全部已知轮次及问答预览。
187
+ - 新轮次开轮、提示词落日志、回复落定后均能更新;流式期间没有逐 token 的整大纲写入。
188
+ - 已加载轮次立即定位;未加载轮次连续补页到目标,期间阅读位置稳定。
189
+ - 连续点击不同轮次、切会话、断线重连和历史版本改变后,旧回调不能触发错误滚动。
190
+ - `[]` 清空,`null` 降级,控制基线缺键或移除 session 后旧控制大纲失效;会话正文和磁盘冷会话不被误删。
191
+ - 控制连接收到更新,对话连接只接收自己的快照;新设备恢复最近的大纲,旧客户端忽略新增字段和帧仍可对话。
192
+ - 中文、表情、纯图片、无助手文本、steering 和中断轮次的占位与截断正确。
193
+
194
+ 网关自动测试覆盖 Host Adapter 的快照与历史分页投影保留、正文裁剪不裁掉大纲、能力发现、真实 WebSocket 的更新分流与订阅过滤、新连接恢复、空值及控制基线重建。App 触屏和滚动验收需在客户端实现后执行。
195
+
196
+ DSH 0.2.1-rc.2 的源码入口:`dsh-session-turn-outline` 的 `projection` 定义;`dsh-client-ui-chat` 的 `TurnNavigator`、`mergeTurnRailItems`、`ChatNavigation`;`dsh-api-session-controller` 的 `Session.loadThrough`。网关对应 [Host Adapter](../lib/dsh-host-adapter.mjs)、[控制流处理](../lib/index.mjs) 和 [协议说明](../PROTOCOL.md)。
@@ -13,7 +13,7 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- export const DSH_VERSION = '0.2.0-rc.2'
16
+ export const DSH_VERSION = '0.2.1-rc.2'
17
17
  export const SESSION_FORMAT_VERSION = 4
18
18
 
19
19
  export function readHistoryRecords(records) {
@@ -44,12 +44,12 @@ export function readSessionSnapshot(frame, sessionId) {
44
44
  }
45
45
  }
46
46
 
47
- async function firstStreamFrame(gateway, namespace, method, args, signal) {
47
+ async function firstStreamFrame(stream, namespace, method, args, signal) {
48
48
  const ownedAbort = new AbortController()
49
49
  const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
50
50
  let iterator
51
51
  try {
52
- const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
52
+ const iterable = await stream(namespace, method, args, streamSignal)
53
53
  const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
54
54
  if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
55
55
  iterator = iteratorFactory.call(iterable)
@@ -78,21 +78,28 @@ function sessionListArgs(request) {
78
78
  * descriptors. Its public methods intentionally match mobile-gateway domain
79
79
  * operations, not the upstream transport envelope.
80
80
  */
81
- export function createDshHostAdapter(typertGateway) {
81
+ export function createDshHostAdapter(typertGateway, peer) {
82
82
  if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
83
83
  throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
84
84
  }
85
+ if (!peer || !(peer.signal instanceof AbortSignal)) {
86
+ throw new Error('mobile-gateway requires a DSH Connection Peer (DSH 0.2.1-rc.2 or later)')
87
+ }
85
88
 
86
89
  const invoke = (namespace, method, args, signal) => typertGateway.invoke({
87
90
  namespace,
88
91
  method,
89
92
  args,
93
+ peer,
90
94
  ...(signal === undefined ? {} : { signal }),
91
95
  })
96
+ const stream = (namespace, method, args, signal) => typertGateway.stream({
97
+ namespace, method, args, peer, signal,
98
+ })
92
99
 
93
100
  const sessionSnapshot = async (request, signal) => {
94
101
  const frame = requireRecord(await firstStreamFrame(
95
- typertGateway,
102
+ stream,
96
103
  'session',
97
104
  'follow',
98
105
  requestArgs(request),
@@ -200,7 +207,7 @@ export function createDshHostAdapter(typertGateway) {
200
207
  return {
201
208
  speech: {
202
209
  catalog: (signal) => invoke('speech', 'catalog', {}, signal),
203
- follow: (signal) => typertGateway.stream({ namespace: 'speech', method: 'follow', args: {}, signal }),
210
+ follow: (signal) => stream('speech', 'follow', {}, signal),
204
211
  configure: (patch, signal) => invoke('speech', 'configure', { patch }, signal),
205
212
  prepare: (providerId, options, signal) => invoke('speech', 'prepare', {
206
213
  providerId, ...(options === undefined ? {} : { options }),
@@ -248,7 +255,7 @@ export function createDshHostAdapter(typertGateway) {
248
255
  },
249
256
  workspace: {
250
257
  async list(_payload = {}, signal) {
251
- const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
258
+ const frame = requireRecord(await firstStreamFrame(stream, 'workspace', 'follow', {}, signal), 'workspace/follow')
252
259
  if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
253
260
  return requireRecord(frame.value, 'workspace/follow baseline')
254
261
  },
@@ -371,18 +378,17 @@ export function createDshHostAdapter(typertGateway) {
371
378
  if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
372
379
  },
373
380
  openSessionStream(sessionId, signal) {
374
- return typertGateway.stream({
375
- namespace: 'session', method: 'follow',
381
+ return stream('session', 'follow',
376
382
  // Bound the opening window at the Host; older records remain available via session.page.
377
- args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
383
+ requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
378
384
  signal,
379
- })
385
+ )
380
386
  },
381
387
  openControlStream(signal) {
382
- return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
388
+ return stream('session', 'control', {}, signal)
383
389
  },
384
390
  openWorkspaceStream(signal) {
385
- return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
391
+ return stream('workspace', 'follow', {}, signal)
386
392
  },
387
393
  }
388
394
  }
package/lib/index.mjs CHANGED
@@ -116,6 +116,7 @@ import { assistantFinish, failureDetails, toolResultDetails, turnReasonDetails }
116
116
  // { "kind": "goal-clear", "sessionId", "cleared": true }
117
117
  // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
118
118
  // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
119
+ // { "kind": "turn-outline-updated", "sessionId", "asOfSeq", "turnOutline": [...] | null }
119
120
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
120
121
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
121
122
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
@@ -1433,7 +1434,18 @@ async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1433
1434
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1434
1435
  }
1435
1436
  if (msg.type === 'sessions') {
1436
- return proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
1437
+ const frame = await proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
1438
+ if (frame.kind !== 'sessions' || !Array.isArray(frame.items)) return frame
1439
+ // Lists need only titles; full projections can make tunnel frames many MiB.
1440
+ // Copy at the mobile boundary so Host data and history projections stay intact.
1441
+ return { ...frame, items: frame.items.map(item => {
1442
+ if (!item?.projections) return item
1443
+ const { asOfSeq, values } = item.projections
1444
+ return { ...item, projections: {
1445
+ ...(asOfSeq === undefined ? {} : { asOfSeq }),
1446
+ values: values && Object.hasOwn(values, 'title') ? { title: values.title } : {},
1447
+ } }
1448
+ }) }
1437
1449
  }
1438
1450
  if (msg.type === 'history') {
1439
1451
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
@@ -2193,13 +2205,19 @@ const plugin = {
2193
2205
  // Hard dependencies: the gateway registers on the host web server and
2194
2206
  // admits mobile messages through the Host Remote Gateway. Cordis must not
2195
2207
  // activate this row before either service exists.
2196
- inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
2208
+ inject: ['webServer', 'typertGateway', 'connection', 'agentDefaultModel'],
2197
2209
  // exported for local tests; Cordis ignores unknown plugin fields
2198
2210
  admitMessage,
2199
2211
  handleQuery,
2200
2212
  apply(ctx, config) {
2201
2213
  const webServer = ctx.webServer
2202
- const api = createDshHostAdapter(ctx.typertGateway)
2214
+ if (typeof ctx.connection?.createPeer !== 'function') {
2215
+ throw new Error('mobile-gateway requires DSH 0.2.1-rc.2 or later with Connection.createPeer')
2216
+ }
2217
+ // 已配对设备沿用本机用户权限;身份只由 Host 创建,不能由移动端参数指定。
2218
+ // 问答与审批仍由本插件的 Host 监听器处理,无需另开 Remote 交互流。
2219
+ const peer = ctx.connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' })
2220
+ const api = createDshHostAdapter(ctx.typertGateway, peer)
2203
2221
  const host = api
2204
2222
  const agentDefaultModel = ctx.agentDefaultModel
2205
2223
  const options = {
@@ -3090,6 +3108,7 @@ const plugin = {
3090
3108
  'assistant-stream-v1',
3091
3109
  'history-format-version',
3092
3110
  'projection-baseline',
3111
+ 'turn-outline-v1',
3093
3112
  'images',
3094
3113
  'session-create',
3095
3114
  'session-agent-preset',
@@ -3387,10 +3406,22 @@ const plugin = {
3387
3406
  log(`session queue forwarded: session=${sessionId} items=${items.length}`)
3388
3407
  }
3389
3408
 
3409
+ const projectionKinds = {
3410
+ todos: 'tasks-updated',
3411
+ goal: 'goal-updated',
3412
+ turnOutline: 'turn-outline-updated',
3413
+ }
3414
+ const projectionValues = (values = {}) => Object.fromEntries(
3415
+ Object.keys(projectionKinds).map(key => [key, values[key] ?? null]),
3416
+ )
3390
3417
  const projectionFrame = (sessionId, key, value, seq) => ({
3391
- kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
3418
+ kind: projectionKinds[key], sessionId, asOfSeq: seq,
3392
3419
  [key]: value,
3393
3420
  })
3421
+ const broadcastProjection = (sessionId, key, value, seq) => {
3422
+ const broadcast = key === 'turnOutline' ? broadcastSessionMetadataFrame : broadcastInteractionFrame
3423
+ broadcast(projectionFrame(sessionId, key, value, seq))
3424
+ }
3394
3425
 
3395
3426
  const installProjectionBaseline = value => {
3396
3427
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
@@ -3400,16 +3431,17 @@ const plugin = {
3400
3431
  throw new Error('invalid session projection baseline')
3401
3432
  }
3402
3433
  Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
3403
- value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
3434
+ value: { asOfSeq: baseline.asOfSeq,
3435
+ values: projectionValues(baseline.values) } })
3404
3436
  }
3405
3437
  const previous = sessionProjectionBaselines
3406
3438
  sessionProjectionBaselines = next
3407
3439
  broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
3408
3440
  for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
3409
3441
  const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
3410
- for (const key of ['todos', 'goal']) {
3411
- broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
3412
- baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
3442
+ for (const key of Object.keys(projectionKinds)) {
3443
+ broadcastProjection(sessionId, key, baseline?.values[key] ?? null,
3444
+ baseline?.asOfSeq ?? previous[sessionId].asOfSeq)
3413
3445
  }
3414
3446
  }
3415
3447
  }
@@ -3446,12 +3478,13 @@ const plugin = {
3446
3478
  installSessionQueueBaseline(frame.value?.projections)
3447
3479
  } else if (frame?.type === 'projection' && frame.key === 'inbox') {
3448
3480
  installSessionQueue(frame.sessionId, frame.value)
3449
- } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
3481
+ } else if (frame?.type === 'projection' && Object.hasOwn(projectionKinds, frame.key)) {
3450
3482
  const sessionId = String(frame.sessionId)
3451
3483
  const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
3484
+ const value = frame.value ?? null
3452
3485
  Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
3453
- value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
3454
- broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
3486
+ value: { asOfSeq: frame.seq, values: { ...projectionValues(previous?.values), [frame.key]: value } } })
3487
+ broadcastProjection(sessionId, frame.key, value, frame.seq)
3455
3488
  log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
3456
3489
  }
3457
3490
  }
@@ -3471,6 +3504,9 @@ const plugin = {
3471
3504
  if (waitTimer) clearTimeout(waitTimer)
3472
3505
  clearInterval(fileTransferExpiryTimer)
3473
3506
  backgroundStreamAbort.abort()
3507
+ peer.dispose().catch((error) => {
3508
+ log(`Host peer disposal failed: ${error && error.message ? error.message : String(error)}`)
3509
+ })
3474
3510
  speechInput.dispose()
3475
3511
  for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
3476
3512
  for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.10.2",
4
- "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.2 新增文件卡片事件透传与安全文件引用解析,复用分块下载为移动端文件卡片和正文文件链接预览提供协议支持。",
3
+ "version": "0.10.4",
4
+ "description": "DeepSeek Harness 移动网关,支持安全设备配对、实时会话同步、插件管理、官方语音输入、文件卡片与安全文件预览、局域网及 Cloudflare Tunnel,以及自适应深浅色控制面板。0.10.4 精简会话列表投影,仅保留标题与投影切点,修复大量会话通过公网隧道传输时超时的问题;保留会话元信息和历史完整投影,现有移动客户端无需配套修改。",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",