dsh-plugin-mobile-gateway 0.10.1 → 0.10.2

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
@@ -651,6 +651,7 @@ let image = [
651
651
  | `history` | `sessionId`, `beforeSeq?`, `historyFormatVersion?`, `maxMessages?`, `maxBytes?`, `view?` | 历史事件页(见下) |
652
652
  | `attachment` | `sessionId`, `attachmentId` | 读取历史中属于该会话的图片字节 |
653
653
  | `file-list` | `sessionId`, `path?`, `requestId?` | 列出会话工作目录内的一层文件与文件夹 |
654
+ | `file-resolve` | `sessionId`, `path`, `requestId` | 将文件引用解析为工作目录相对路径及预览元数据;需 `file-references-v1` |
654
655
  | `file-download-open` | `sessionId`, `path`, `requestId` | 打开一个工作目录内的普通文件下载 |
655
656
  | `file-download-read` | `transferId`, `offset` | 拉取下载的下一块字节 |
656
657
  | `file-download-cancel` | `transferId` | 取消并关闭下载 |
@@ -702,7 +703,7 @@ WebUI、App 或其他客户端造成的变化通过以下帧主动推送:
702
703
  - 返回**原始 SessionEvent**(`{type, seq, time, data}`,方案A),可选裁剪
703
704
  - 图片不会内联进历史页。`user/message.data.content[]` 中的图片块为 `{ "type":"image", "attachment": ImageAttachmentRef }`;iOS 使用其中的 `attachmentId` 请求图片数据
704
705
  - `maxBytes`:单帧字节预算,默认 **4 MiB**;超预算保留最新部分并给出 `nextBeforeSeq` 续页(客户端 16 MiB 上限的安全余量)
705
- - `view: "conversation"`:**对话裁剪模式**——隐藏系统消息与 request/header、request/context,移除 Assistant 事件的内嵌 `data.stream`,`tool/result` 文本截断到 2000 字符
706
+ - `view: "conversation"`:**对话裁剪模式**——隐藏系统消息与 request/header、request/context,移除 Assistant 事件的内嵌 `data.stream`,同时保留 `data.finish`(结束类型和结构化失败原因);`tool/result` 原始文本块截断到 2000 字符,另用 `data.output/outputTruncated` 保留最多约 16000 字符的首尾输出
706
707
  - 分页:`hasMore` 为真时用 `beforeSeq: nextBeforeSeq, historyFormatVersion: 4` 请求更早一页
707
708
 
708
709
  ```json
@@ -754,6 +755,43 @@ iOS 发现尚未缓存的 `attachmentId` 后发送:
754
755
 
755
756
  iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建议以 `attachmentId` 为缓存键。不要把 Base64 长期保存在对话模型对象里。并发同步历史时可限制为 2~4 个附件请求,优先加载当前可见消息。
756
757
 
758
+ ### 文件卡片与正文文件引用
759
+
760
+ `hello.capabilities` 中的 `file-references-v1` 表示支持文件交付事件的完整实时字段以及 `file-resolve`。这是协议 3 的可选增量,不改变 Session V4 格式;关闭 `fileDownloadsEnabled` 后不公布此能力,解析请求返回 `file-download-disabled`。旧客户端可忽略新增能力与事件。
761
+
762
+ DSH `present` 工具成功后持久化 `deliverables/presented`,移动端根据这个事件渲染卡片,而非从回复文字猜测附件:
763
+
764
+ ```json
765
+ {
766
+ "kind": "event", "sessionId": "session-abc", "seq": 42, "time": 1786937352,
767
+ "event": {
768
+ "type": "deliverables/presented", "turn": 3, "callId": "present-1",
769
+ "files": [{ "path": "build/三亚国庆旅游攻略.pdf", "description": "A4 共 8 页" }]
770
+ }
771
+ }
772
+ ```
773
+
774
+ `history` / `session-snapshot` 中同一事件仍使用原始 `{type, seq, time, data: {turn, callId, files}}` 格式。按 `sessionId + seq` 合并历史和实时事件,再按 `turn` 将卡片放在本轮回复下面。文件引用保存的是路径,读取的是当前文件内容,并未复制成永久附件;文件以后可能被修改或删除。`description` 是模型提供的描述,页数并非结构化字段。
775
+
776
+ 点击卡片或正文中的本地文件链接后,通过**控制连接**解析:
777
+
778
+ ```json
779
+ { "type": "file-resolve", "requestId": "resolve-1", "sessionId": "session-abc", "path": "build/三亚国庆旅游攻略.pdf" }
780
+ → {
781
+ "kind": "file-resolved", "requestId": "resolve-1", "sessionId": "session-abc",
782
+ "path": "build/三亚国庆旅游攻略.pdf", "name": "三亚国庆旅游攻略.pdf",
783
+ "mediaType": "application/pdf", "size": 330752, "modifiedAt": 1786937352000
784
+ }
785
+ ```
786
+
787
+ - 输入为未做 URL 编码的文件系统路径,可以是相对路径或主机原生绝对路径;仅允许该会话工作目录内的普通文件,包括指向目录内文件的符号链接。会话 `cwd` 的已知路径别名会映射到真实工作目录。
788
+ - 输出 `path` 为规范化的工作目录相对路径,`size` 单位为字节,`modifiedAt` 为 Unix 毫秒。随后把输出 `path` 交给现有 `file-download-open`,完成分块下载及 SHA-256 校验后在 App 内预览。
789
+ - 解析不会创建 `transferId`,下载仍会重新验证路径与文件状态。卡片事件本身不会扩大目录读取权限;工作目录外的文件即使被 `present` 引用也返回 `file-not-allowed`。
790
+ - `..` 路径段、NUL、空路径返回 `bad-request`;缺失、目录、越界符号链接、超限文件分别返回 `file-not-found`、`file-not-regular`、`file-not-allowed`、`file-too-large`。响应和失败均回显字符串 `requestId`,失败另带 `requestType: "file-resolve"`。
791
+ - Markdown 链接由 App 解析,先提取路径并分离 `#L24` 等行号片段,再发送路径;不要把整个 Markdown、HTTP URL 或 `file://` URL 当作路径传入。行内代码只应将本轮已知文件路径及唯一匹配的文件名转换为可点击引用。
792
+
793
+ 两端具体接入点及验收清单见 [文件卡片与预览接入说明](docs/file-cards-mobile-integration.md)。
794
+
757
795
  ### 文件下载(图片、文档、IPA、APK 及其他普通文件)
758
796
 
759
797
  文件下载是独立于历史图片 `attachment` 的二进制传输通道。它不按扩展名做授权白名单:图片、PDF/Office 文档、`.ipa`、`.apk` 和其他**普通文件**均可下载;服务端仅根据扩展名给出 `mediaType`,以便移动端决定打开方式。
@@ -1083,12 +1121,19 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
1083
1121
  `event.type` 覆盖(精炼字段):
1084
1122
  - `user/message` → `{text, source, images?: ImageAttachmentRef[]}`
1085
1123
  - 实时 token 使用独立 `assistant-stream`,不是带持久 seq 的 `assistant/chunk`;详见 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md)。
1086
- - `assistant/attempt` → `{turn, step, stream[]}`
1087
- - `assistant/message` → `{turn, step, text, reasoning, toolCalls[], interrupted?, usage?}`
1124
+ - `assistant/attempt` → `{turn, step, stream[], finish?: {kind, failure?}}`
1125
+ - `assistant/message` → `{turn, step, text, reasoning, toolCalls[], interrupted?, usage?, finish?: {kind, failure?}}`
1088
1126
  - `session/title` → `{title, source?}`
1089
1127
  - `tool/call` → `{turn, step, callId, name, arguments}`
1090
- - `tool/result` → `{turn, step, callId, isError, preview(≤400字符)}`
1091
- - `turn/start|end` / `step/start|end` → `{turn, step, reason?}`
1128
+ - `deliverables/presented` → `{turn, callId, files: [{path, description?}]}`,用于本轮回复下方的文件卡片。
1129
+ - `tool/result` → `{turn, step, callId, isError, preview(≤400字符), output, outputTruncated, error?}`
1130
+ - `turn/start|end` / `step/start|end` → `{turn, step, reason?, reasonDetails?}`,保留原有 `reason` 字符串;`reasonDetails` 提供结构化终态错误或取消来源。
1131
+ - `llm/retry` → `{turn, step, retryId, retry, maxRetries?, delayMs, provider, mode, failure}`。
1132
+ - `llm/retry-started` → `{turn, step, retryId, retry}`。
1133
+
1134
+ `failure` 保留上游提供的 `message/code/status/providerRetryAfterMs/requestId` 等诊断字段。工具 `output` 同时包含标准输出、`[stderr]`、沙箱提示和退出状态标记;超过 16000 字符时保留开头和末尾并设置 `outputTruncated: true`。需要未裁剪的完整内容时,按对应 seq 请求不带 `view: "conversation"` 的历史。
1135
+
1136
+ 移动端必须区分工具失败、模型请求失败、重试和整轮终止:`assistant/attempt` 不代表整轮中断;重试用 `retryId` 合并状态,`llm/retry-started` 更新为已重试并保留失败详情;最终错误以 `turn/end.reason: "error"` 为准,`aborted` 显示取消,`interrupted` 显示中断。Shell 的非零退出码可能仅在输出末尾 `[exit code: N]` 中,Host 的 `isError` 仍为 false;展示时可以标记该工具失败,但不能据此终止整轮。
1092
1137
 
1093
1138
  ### 运行中计时提示
1094
1139
 
@@ -1130,7 +1175,8 @@ DSH Web 的“深度求索中,用时 22 秒 ···”是客户端文案,不
1130
1175
 
1131
1176
  | 版本 | 新增 |
1132
1177
  |---|---|
1133
- | v0.10.1(当前源码) | 新增官方 speech Remote 语音输入桥接;模型准备与状态同步;移动端音频转文字与取消转写;连接关闭时清理语音任务 |
1178
+ | v0.10.2(当前源码) | 补齐 `deliverables/presented` 文件卡片实时字段;新增 `file-references-v1` 能力与 `file-resolve` 安全文件引用解析;复用现有分块下载支持移动端预览接入 |
1179
+ | v0.10.1 | 新增官方 speech Remote 语音输入桥接;模型准备与状态同步;移动端音频转文字与取消转写;连接关闭时清理语音任务 |
1134
1180
  | v0.10.0 | 响应式居中控制面板与主题同步;自定义下拉菜单;独立配对模态窗口与缩放动画;按运行实例关闭重启提醒;在线设备折叠列表与移动端设备名称上报;Tunnel 配置交互优化 |
1135
1181
  | v0.9.0 | DSH 0.2.0-rc.2 插件管理;PC 端 Cloudflare Quick / 命名 Tunnel 及独立重启;插件私有 `cloudflared` 缓存及校验;配对入口选择指引;网关重启与 DSH Web 停止工具 |
1136
1182
  | v0.7.2 | 独立对话/控制连接;空 Session 创建;停止生成与稍后继续;排队消息同步及编辑/删除/Steer;App 归档/重命名 Session;WebUI 归档集合和名称变化实时同步到 App |
package/README.md CHANGED
@@ -10,7 +10,9 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
10
10
  >
11
11
  > 实时流已改为独立 `assistant-stream` 帧,移动端需要按 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md) 更新订阅、缓存与分页处理。当前修改尚未发布。
12
12
  >
13
- > v0.10.1(当前源码):新增官方语音输入接入,支持语音模型准备与状态同步、移动端音频转文字、取消转写及连接关闭后的任务清理。通过官方 speech Remote 处理识别,移动网关无需打包 ASR 模型。
13
+ > v0.10.2(当前源码):新增文件卡片事件透传与安全文件引用解析,复用现有分块下载,为移动端文件卡片和正文文件链接预览提供协议支持。App 接入方式见 [文件卡片与预览接入说明](docs/file-cards-mobile-integration.md)。
14
+ >
15
+ > v0.10.1:新增官方语音输入接入,支持语音模型准备与状态同步、移动端音频转文字、取消转写及连接关闭后的任务清理。通过官方 speech Remote 处理识别,移动网关无需打包 ASR 模型。
14
16
  >
15
17
  > v0.10.0:移动设备面板改为响应式居中设置窗口,跟随主应用深浅色主题;统一自定义下拉菜单与连接状态高亮;配对二维码使用独立模态窗口并支持缩放进出动画;安装重启提醒可按网关运行实例关闭;可信设备默认仅显示在线设备,支持展开离线设备、移动端名称上报与同名编号;Tunnel 切换自动应用,命名配置编辑后保存。
16
18
  >
@@ -105,7 +105,7 @@
105
105
 
106
106
  `revision` 属于一次 Agent 生命周期,`index` 属于一次 attempt。不要把其中任何一个写入持久事件 seq。网关检查连续性;App 仍应按当前 streamId/attemptId 去重和隔离展示。
107
107
 
108
- 持久帧仍使用 `kind: "event"`,由同一个上游 follow 有序转发,带真实 seq 以及 subscriptionId/streamId。不会再与全局 `session/event` 重复转发。消息附带中断标识 `interrupted` 和可选 usage;失败 attempt 保留 turn/step 与紧凑 stream。`surfaceOp` 和 `sourceEventSeqs` 在存在时位于持久帧根节点。
108
+ 持久帧仍使用 `kind: "event"`,由同一个上游 follow 有序转发,带真实 seq 以及 subscriptionId/streamId。不会再与全局 `session/event` 重复转发。消息附带中断标识 `interrupted` 和可选 usage;失败 attempt 保留 turn/step 与紧凑 stream。实时与历史对话裁剪均另行保留 `finish: {kind, failure?}`,因此重连后仍可还原模型错误。`surfaceOp` 和 `sourceEventSeqs` 在存在时位于持久帧根节点。
109
109
 
110
110
  ## 4. 重连、切换与取消
111
111
 
@@ -153,3 +153,11 @@
153
153
  ## 验证范围
154
154
 
155
155
  仓库测试覆盖 rc.2 的参数封装、两种独立序号、缺口恢复、取消清理、真实 WebSocket 的原子基线/最终结算、重连前缀、历史裁剪、游标版本校验、工具失败与控制投影。真实 rc.2 Host 与 App 联调仍需在客户端接入后执行。普通文件上传、jobs 和 PTC 专用 UI 属于后续能力扩展。
156
+
157
+
158
+ ## 错误详情与重试展示
159
+
160
+ - 实时 `tool/result` 新增 `output/outputTruncated`,历史 conversation 模式在 `data` 内返回相同字段。优先展示 `output`,回退到 `preview`,无正文时展示 `error`。输出较长时保留首尾,退出状态和沙箱拒绝提示不会被仅取开头的摘要截掉。
161
+ - `assistant/attempt.finish` 表示单次请求结束原因,不能一律显示“生成已中断”。`llm/retry` 携带 `retryId/retry/maxRetries?/delayMs/failure`,按重试链更新状态;`llm/retry-started` 表示等待结束、开始重试,不表示请求成功。
162
+ - `turn/end` 保留原来的字符串 `reason`,新增 `reasonDetails`:`error` 展示生成失败及 `reasonDetails.error`,`aborted` 展示取消,`interrupted` 展示异常中断。工具执行失败后模型仍可继续运行。
163
+ - 这些字段均为向后兼容的可选扩展,Session format 和订阅时钟不变。原始历史仍保留完整 token 流,裁剪历史只保留结束原因摘要。
@@ -0,0 +1,111 @@
1
+ # 移动端文件卡片、正文引用与预览接入
2
+
3
+ 基于本机 DSH `0.2.0-rc.2` 和当前 `dsh-mobile` 代码检查,2026-10-15。
4
+
5
+ ## 数据来源与网关调整
6
+
7
+ 截图中的两个入口来源不同,但应共用一个移动端文件打开流程:
8
+
9
+ | 入口 | 数据来源 | 移动端行为 |
10
+ | --- | --- | --- |
11
+ | 回复底部文件卡片 | `present` 成功后产生的持久事件 `deliverables/presented` | 按本轮交付的文件列表渲染图标、文件名和描述 |
12
+ | 正文蓝色文件名 | Markdown 文件链接 | 拦截文件链接,交给文件打开流程 |
13
+ | 正文行内代码文件路径 | 桌面端用本轮产生或交付的文件集合匹配行内代码 | 仅对已知路径或唯一匹配文件名提供点击预览 |
14
+
15
+ 上游事件结构已经存在,不需要增加 `ContentBlock.type = file`,也不应拿只支持图片的 `attachment` 接口读取 PDF:
16
+
17
+ ```json
18
+ {
19
+ "type": "deliverables/presented",
20
+ "seq": 42,
21
+ "time": 1786937352,
22
+ "data": {
23
+ "turn": 3,
24
+ "callId": "present-1",
25
+ "files": [{ "path": "build/三亚国庆旅游攻略.pdf", "description": "A4 共 8 页" }]
26
+ }
27
+ }
28
+ ```
29
+
30
+ 上游 `@deepseek-ai/dsh-tool-present/lib/types/types.d.ts` 定义上述数据;工具实现只在成功后追加事件。`@deepseek-ai/dsh-client-ui-deliverables` 将交付结果归到对应 `turn` 的回复末尾,并匹配正文文件引用。它的 `/api/present.open` 是让**主机桌面**打开文件的接口,不能当作移动端预览接口。
31
+
32
+ 本次网关实现:
33
+
34
+ 1. `lib/index.mjs` 的 `buildWireEvent` 保留实时事件的 `turn/callId/files`。原实现落入默认分支,只返回事件类型。历史和 opening snapshot 原本就保留该事件,无须转换历史格式。
35
+ 2. 新增能力 `file-references-v1`,保持 WebSocket `protocol: 3`、`historyFormatVersion: 4`。
36
+ 3. 新增控制请求 `file-resolve`,将工作目录内的相对路径或主机原生绝对路径转换为现有下载协议需要的相对路径,并返回文件元数据。
37
+ 4. 文件读取继续使用 `file-download-open/read/cancel`,保留分块、取消、连接归属及最终 SHA-256 校验。
38
+
39
+ 卡片只保证声明时文件存在,文件内容没有被快照保存。路径越界、文件删除或文件过大时应显示可恢复的错误,不能用任意绝对路径下载绕过工作目录限制。
40
+
41
+ ## App 共享层改动
42
+
43
+ 下面路径均相对于 `dsh-mobile` 仓库。本次未修改 App 代码。
44
+
45
+ | 位置 | 改动 |
46
+ | --- | --- |
47
+ | `shared/.../protocol/GatewayDtos.kt` | 增加 `PresentedFile(path, description?)`;`GatewayEvent` 增加 `files`;`RawSessionEvent.normalizedEvent()` 显式处理 `deliverables/presented`,保留 `turn/callId/files` |
48
+ | `shared/.../protocol/GatewayWireDecoder.kt` | 确认新事件和 `file-resolved` 可解码;文件元数据复用已有 frame 字段,补齐缺失字段 |
49
+ | `shared/.../projection/ConversationProjection.kt` | 按会话与 `turn` 收集交付文件,产出本轮回复下方的卡片数据;同时更新原生桥接使用的快照模型 |
50
+ | `shared/.../gateway/GatewayProtocol.kt` | 增加 `fileResolve(sessionId, path, requestId)` 请求,走控制连接;读取 `hello.capabilities` |
51
+ | `shared/.../facade/SharedWorkspaceFileStore.kt` | 增加解析阶段及其请求关联;收到 `file-resolved` 后调用已有 `download`,purpose 使用 `preview` |
52
+
53
+ 实时事件读取 `frame.event.files`,历史读取 `raw.data.files`,两者最终必须进入同一数据模型。只修改实时 DTO 会导致重新进入会话后卡片消失;只修改历史映射会导致文件生成后不能立即显示。
54
+
55
+ 事件按 `gatewayId + sessionId + seq` 幂等合并。同一事件内卡片以 `seq + index` 标识;同一轮重复交付同一原始路径时可以更新描述,保持首次出现顺序。对历史分页中暂时缺少对应 assistant 的交付事件先保留,后续合并时挂载,不能丢弃。不要改变持久化事件 seq 或把卡片存为新的 assistant 文本消息。
56
+
57
+ 统一打开流程应显式携带 `gatewayId/sessionId`,不能在异步回调中重新读取当前选中会话。现有 Android `openWorkspaceFile` 从 `snapshot.selectedSessionId` 取会话,需要改为接受来源会话,并在来源网关失效或会话切换时正确取消/丢弃回调。
58
+
59
+ ## 卡片与链接统一打开流程
60
+
61
+ ```text
62
+ 卡片 / Markdown 文件链接 / 已知行内文件引用
63
+ → openConversationFile(gatewayId, sessionId, sourcePath)
64
+ → control: file-resolve(requestId, sessionId, path)
65
+ ← file-resolved(path, name, mediaType, size, modifiedAt)
66
+ → 原有 file-download-open/read
67
+ → 临时文件落盘、长度与 SHA-256 校验、原子提交
68
+ → 展示应用内预览
69
+ ```
70
+
71
+ `file-resolve` 的完整 JSON 示例与错误码见仓库根目录 `PROTOCOL.md` 的“文件卡片与正文文件引用”。解析不需要先遍历目录,因此文件位于 `build/` 下时也可以直接打开。
72
+
73
+ 正文链接处理规则:
74
+
75
+ - 使用 Markdown 解析器返回的链接目标,不扫描整段文本;解析 `<...>`、中文和空格,URL 编码仅解码一次,保留真正文件名中的特殊字符。
76
+ - 相对文件路径和主机绝对路径走解析接口。明确的 `file://` 链接仅提取合法本地路径后交给网关,绝不能在手机本地直接打开主机路径。Windows 主机的盘符/UNC 路径不能误判为普通 URI scheme。
77
+ - `https://` / `http://` 继续作为网页处理。其他 scheme 按 App 已有白名单处理。`dsh-resource://` 属于上游另一套资源地址,当前新增接口不接收;如需支持,须先实现明确的地址语法和会话范围校验,不能当作普通路径。
78
+ - 只分离已识别的源码行号片段,例如 `#L24`、`#L24-L30`;它不是文件名的一部分。不要无条件剥离文件名中的 `#`。
79
+ - 行内代码优先精确匹配本轮已知路径。仅当 basename 在本轮唯一时才允许缩写匹配;歧义或未知文本维持普通代码样式。不要使每个包含 `.pdf` 的代码字符串都变成文件卡片。
80
+
81
+ 缓存应隔离 `gatewayId + sessionId + canonicalPath`,结合解析返回的 `modifiedAt/size` 判断是否需要重取;下载的最终 SHA-256 用于确认传输完整性。元数据不能保证跨时间内容不变,不能只凭旧路径缓存永远打开旧文件。长文件下载进度与取消由现有下载状态机提供,Base64 不进入聊天消息模型。
82
+
83
+ ## Android 接入
84
+
85
+ `androidApp/.../ui/DshMarkdownText.kt` 当前用 Markwon 渲染,尚无传入聊天文件打开器的入口。增加文件链接回调,并在 Markwon 的链接处理层区分网页和文件目标。需要将当前 `gatewayId/sessionId` 安全地传到回调;列表复用时更新回调,避免使用旧消息的会话。
86
+
87
+ 聊天消息/本轮回复组件增加文件卡片,文件名来自路径 basename,描述使用 `description`。大小可在解析后显示;“共 8 页”目前只是描述文字,没有独立 `pageCount` 协议字段,不应伪造。点击卡片与正文链接走同一个打开器。
88
+
89
+ `androidApp/.../ui/WorkspaceFilesUi.kt` 已有下载完成监听:代码类文件应用内预览,PDF 等其他文件通过 `ACTION_VIEW` 外部打开。应把下载完成处理和预览承载从文件列表弹层提取到可供聊天页调用的位置,避免聊天页下载成功却没有监听器展示预览,也避免重复监听造成重复弹窗。
90
+
91
+ 要满足截图 PDF 的“直接弹出预览”,增加应用内 PDF 弹层或页面,可基于系统 [PdfRenderer](https://developer.android.com/reference/android/graphics/pdf/PdfRenderer) 对本地文件分页渲染。渲染放在后台执行,限制页面位图缓存,离开页面释放页、renderer 和文件描述符;大文档不要一次渲染全部页面。保留“另存为/用其他应用打开”作为辅助操作。其他不支持的文件显示下载/外部打开选项;聊天文件预览不应自动触发 APK 安装流程。
92
+
93
+ ## iOS 接入
94
+
95
+ `DeepSeekHarnessMobile/Views/ConversationView.swift` 的 `MarkdownContent` 使用 MarkdownUI。通过链接打开处理拦截本地文件引用并交给统一打开器;已知行内代码引用需要在解析/渲染层匹配,普通 `openURL` 处理不会自动让代码片段变成链接。
96
+
97
+ `DeepSeekHarnessMobile/Views/RootView.swift` 已有 `WorkspaceQuickLookPreview`、下载结果和预览绑定。将其提取为共享预览组件,并在聊天页可以触达的层级展示 sheet;无需重写已有 [QLPreviewController](https://developer.apple.com/documentation/quicklook/qlpreviewcontroller) 桥接。共享层新增卡片模型后,同步更新 Swift 原生桥接和会话渲染快照,否则 Kotlin DTO 有数据而 Swift UI 仍然看不到卡片。
98
+
99
+ ## 兼容与验收
100
+
101
+ 不支持 `file-references-v1` 的旧网关,可对已确认安全的相对路径复用原有文件下载;对于无法解析的绝对路径给出升级提示。上游未调用 `present` 时,不自动把所有 Markdown 链接生成底部卡片,正文文件链接仍可以单独预览。
102
+
103
+ 网关自动化覆盖历史事件保留、实时字段、解析元数据、中文/空格、相对/绝对路径、目录、缺失、越界、符号链接和原有分块 SHA-256 下载。App 实现后需要补充:
104
+
105
+ 1. 实时产生 PDF 后立即出现卡片,历史重入、重连、分页和多网关同名文件不会丢失或重复。
106
+ 2. 卡片、正文链接及已知行内代码路径均可打开同一 PDF,手机没有外部 PDF 阅读器时仍可预览。
107
+ 3. 多个文件、同 basename 不同目录、多个回合时归属正确。
108
+ 4. 下载中切换会话/网关、断线或关闭预览时无跨会话弹窗、残留临时文件及重复监听。
109
+ 5. 文件修改/删除、超限、越界、校验失败时显示真实错误,允许重试或取消。
110
+
111
+ 本次自动化使用模拟 rc.2 Host 和真实本地 WebSocket;尚未完成真实桌面 DSH 到移动 App 的端到端联调。
@@ -217,3 +217,59 @@ iOS 实时采样转换原先只在下一块数据暂未到达时返回 noDataNow
217
217
  验证:公共层 268 项通过,六项新音频回归覆盖首尾静音、原样保留正常发音、固定增益、增益上限、近零/持续噪声保护以及官方限额。Android 8 项交互回归通过,最终共享代码 Android 编译通过;iOS 使用最终框架的 12 项回归通过,ktlint 和 git diff --check 通过。真实编译 KMM 产生的 WAV 经网关测试适配器送入本地官方原生识别器,五个短句的三档音量正样本全部完整,静音和持续噪声负样本均为空;检测到的候选增益误识别问题已通过峰均比条件修正。
218
218
 
219
219
  详细证据与范围见 [短句离线证据](voice-input-short-utterance-evidence/README.md)。没有修改官方 VAD 阈值或运行插件配置;没有进行用户真机录音/设备签名远端联调,因此不能宣称所有短句问题完全消失。日志:/tmp/dsh-short-speech-20261013-final-gradle.log、/tmp/dsh-short-speech-20261013-gradle.log、/tmp/dsh-short-speech-20261013-final-ios.log。
220
+
221
+ ## 抬手即退出蒙层,后台完成转写(2026-10-14)
222
+
223
+ 此前 UI 使用 SharedVoiceInputSnapshot.active 作为蒙层显示条件,该属性包含 stopping 和 transcribing,因此抬手后还会显示整理录音/转写状态。新增公共层 overlayVisible,只有 authorizing、starting、recording 显示录音蒙层;active 继续表示后台任务有效,用于请求 ID、超时、取消及防止同时启动另一项转写。正常松手切换到 stopping 时立即开始原有 280ms 淡出,之后保持普通输入框;不显示录音收尾、转写文案或处理按钮。后台阶段 status 返回空字符串,平台不保留并行 ASR 业务状态。
224
+
225
+ Android 和 iOS 的蒙层、底部普通输入区隐藏及触摸保护改用 overlayVisible;Android 文本框恢复 enabled,焦点与键盘由用户主动点击决定。后台阶段不让语音长按接管原生文本编辑;成功后按原有草稿冲突策略回填,不主动聚焦。Android 淡出缓存只更新仍可见的录音快照,避免退出时闪过处理状态或切换颜色;两端删除转写阶段的取消按钮。异常 Alert、无人声短 Toast 和录音尾段/采样转换收尾保持原行为。
226
+
227
+ 验证:公共层新增正常松手后蒙层关闭而请求仍有效的回归,并覆盖启动阶段松手;Android 8 项原生交互测试通过,包含真实 up 后淡出、后台文本框可用、无处理文案、持续长按不弹键盘及回填后编辑;iOS 新增真实 SwiftUI 蒙层生命周期回归,抬手后 onDisappear 发生、后台转写随后回填而不自动弹键盘。两端构建、相关 Kotlin ktlint 和 git diff --check 通过。日志:/tmp/dsh-background-transcription-20261014-gradle.log、/tmp/dsh-background-transcription-20261014-ios.log。测试使用模拟器本地会话和采集替身,没有真实远端 ASR 联调。
228
+
229
+ ## 晕影外侧背景色渐变(2026-10-14)
230
+
231
+ 在彩色椭圆蒙层后方增加独立的外层渐变。浅色使用白色,深色使用当前页面背景色,避免明亮白边。外层横向半径为宽度的 0.98,较原彩色层的 0.8 更宽;纵向半径沿用蒙层高度的 0.6。归一化半径 0.78 到 1 使用 33 点 smoothstep 渐变,边缘斜率平滑归零。白色衬层先淡化边缘下的会话文字,再与内层蓝色/红色渐变融合,保持底部覆盖、40% 高度和原有入退场动画。
232
+
233
+ 验证:两端编译通过;Android 原生椭圆、淡入淡出及导航区覆盖两项回归通过,iOS 浅深色四态截图单测通过。额外生成浅深色文字重叠预览,目视检查文字逐渐淡化及深色无亮白边;更新八张状态预览、两张文字背景示例和 Android 全屏截图。Android 面板 ktlint、git diff --check 通过。未修改 KMM 录音、转写或状态机。日志:/tmp/dsh-voice-outer-halo-20261014-android.log、/tmp/dsh-voice-outer-halo-20261014-ios.log。
234
+
235
+ ## 提前采集与真实麦克风就绪门控(2026-10-14)
236
+
237
+ 此前长按确认后才启动硬件,且蒙层先于麦克风实际收到 PCM 显示;用户按动画开始说话时,硬件尚未就绪。Android 读取循环还会逐块等待主线程执行音量回调,首绘等 UI 工作可能阻塞后续采集。本次将共享层的 captureStarted 统一定义为首批 PCM 已写入缓冲,只有长按已确认且首批采样已收到,才进入 recording、显示蒙层并触发开始触感。尚在授权或硬件启动时保护输入,但不假显示可录音状态。
238
+
239
+ 已授权、会话连接且官方语音服务就绪时,真实手指 down 立即进入公共层 priming 并预采集,80ms 长按确认复用同一录音 ID 和已有样本;短按、手势失败或取消丢弃预采集,不发送转写请求。未授权的短按不请求权限,长按后才走原授权流程。UIKit 用原生手势的 touchesBegan/Ended/Cancelled 传递触摸边界,Android 在指针手势 finally 清理未确认的预采集;仍由 KMM 管理状态、录音 ID、权限结果、松手收尾和迟到回调隔离。
240
+
241
+ 会话内已授权时提前准备硬件资源,空闲时不启动麦克风或保存音频;离开会话或进入后台释放。Android 复用停止后的 AudioRecord,按公共层 20ms 窗口读取,写入 PCM 后只向主线程排队就绪和音量通知,读取线程不等待 UI;资源 generation 防止释放后的旧任务重新缓存设备。iOS 串行队列提前 prepare AVAudioEngine,申请 10ms I/O 时长并按 20ms 请求 tap 缓冲,保留转换器 EOS 收尾。系统偏好和 tap 大小不保证设备实际采用该周期,真实首批 PCM 才是就绪依据。[Android 读取接口](https://developer.android.com/reference/android/media/AudioRecord)、[Apple I/O 时长偏好](https://developer.apple.com/documentation/avfaudio/avaudiosession/setpreferrediobufferduration(_:))、[Apple engine.prepare](https://developer.apple.com/documentation/avfaudio/avaudioengine/prepare())。
242
+
243
+ 验证:最终公共层 274 项、Android 11 项相关回归、iOS 15 项相关回归全部通过;覆盖按下提前采集、首采样前不显示蒙层、长按复用启动、首部样本保留、短按丢弃、取消后的迟到回调和原生编辑行为。两端构建、相关 Kotlin ktlint 和客户端 git diff --check 通过。日志:/tmp/dsh-mic-readiness-20261014-common-final.log、/tmp/dsh-mic-readiness-20261014-gradle.log、/tmp/dsh-mic-readiness-20261014-final-gradle.log、/tmp/dsh-mic-readiness-20261014-verified-ios.log;中途新增公共层用例曾将原有 stopTicker 清理效果误判为多余,修正断言后最终全部通过。
244
+
245
+ 这些测试使用本地会话和采集替身,不包含真机麦克风冷启动或设备签名远端联调。新增 first-pcm 日志用于实体设备核对触摸、硬件启动和实际收到采样之间的耗时;iOS 该耗时包含音频会话配置、激活及 engine 启动。预览说明已同步采集与显示时机,蒙层外观保持原参数。
246
+
247
+ ## iOS 进入会话时预热崩溃(2026-10-14)
248
+
249
+ 用户真机日志在进入会话后出现 `com.apple.coreaudio.avfaudio`,断言为 `inputNode != nullptr || outputNode != nullptr`。上一轮新增的 IOSVoiceRecorder.prepare 在新建 AVAudioEngine 上直接调用 prepare,没有先访问按需创建的输入节点;Swift try? 只能处理 NSError/Swift Error,不能兜住该 NSException。此前采集替身的 prepare 默认无操作,没有覆盖真实引擎的这个初始化条件。
250
+
251
+ 修复为配置音频会话后先建立 engine.inputNode,再检查硬件输入格式的采样率及声道数;不可用时跳过空闲预热,实际长按仍走原有启动与错误处理。格式有效时才执行 engine.prepare。release 补充 engine.stop 后 reset,并停用可能被 prepare 隐式激活的音频会话。保留按下预采集、首批 PCM 就绪后显示蒙层及 KMM 状态管理。
252
+
253
+ 新增真实 AVAudioEngine 回归,不使用采集替身,覆盖冷引擎、重复预热、释放后再预热三个循环,确认输入节点存在、engine 未运行且没有录音缓冲。修复前该用例复现与用户日志一致的断言并失败;修复后 iOS 语音与输入框共 16 项测试通过,构建与 git diff --check 通过。本机模拟器原生输入格式为 0 Hz,验证了不可用格式安全退出路径及节点建立;格式有效时的真机预热及带设备签名的远端会话仍需真机复测。日志:/tmp/dsh-mic-prewarm-crash-20261014-before.log、/tmp/dsh-mic-prewarm-crash-20261014-after.log。
254
+
255
+ ## 按录音启动成功反馈,不再等待首批 PCM(2026-10-14)
256
+
257
+ 用户反馈首 PCM 门控使蒙层再次变慢。首批缓冲交付和采样转换不等于硬件开始采集;将二者合并会在系统已经录音后增加显示等待。本次保留 KMM 的长按确认与硬件就绪两个条件,平台的 ready/started 改为系统录音启动成功并确认运行状态:iOS engine.start 成功且 isRunning,Android startRecording 成功且 RECORDSTATE_RECORDING。此时系统已启用音频流,后续首批缓冲继续完整写入;不把蒙层提前到硬件尚未启动的阶段。first-pcm 仅作为独立诊断日志,80ms 手势判定、160ms 入场与200ms 尾段保持原值。
258
+
259
+ iOS prepareSession 提前配置并激活会话,准备缓存复用到下一次手指 down;正常 stop 保留会话激活,release 在离开/后台停用会话与释放引擎。中断、路由变化及媒体服务重置会使准备缓存失效,下次启动重新准备;空闲不运行引擎或写入音频。新增受锁硬件请求 token,使短按撤销的排队 start 直接跳过,不再等待预热完成后把过期录音启动一遍。采集业务、草稿与 ASR 状态仍由 KMM 负责,平台只保存硬件准备与命令有效性。
260
+
261
+ 验证:iOS 17 项、Android 11 项相关原生回归通过,覆盖录音运行但尚无音量回调时显示蒙层、首部采样保留、短按丢弃、取消排队启动、真实引擎冷预热/释放后再预热以及原生文本编辑。两端构建、Android Kotlin ktlint 与 git diff --check 通过;未改变共享业务状态机,因此不重复运行全部公共层测试。没有真机分位耗时或远端联调。日志:/tmp/dsh-mic-start-confirmation-20261014-ios.log、/tmp/dsh-mic-start-confirmation-20261014-android.log。第三方 IPA 的静态复查、REA Evidence ID、超时限制及未确认项单独保留在被忽略的 `voice-input-ipa-evidence/startup-20261014/README.md`。
262
+
263
+ ## 长按、上滑与抬手三个触感节点(2026-10-14)
264
+
265
+ 按最新要求,KMM 在有效长按 press 成立时立即发出 hapticStart,不等待硬件 ready 或蒙层出现;captureStarted 只更新采集状态,不重复震动。上滑跨入取消区发出 hapticCancel,留在区域内连续移动不重复,滑回后再次进入才重新反馈;有效长按 release 发出一次 hapticRelease,正常转写和上滑取消后抬手均适用。短按和系统自动停止不模拟长按/抬手触感。
266
+
267
+ Android 保留平台 LONG_PRESS、CLOCK_TICK、KEYBOARD_TAP 三种映射,iOS 保留原 impact/selection 映射,并在真实 down 时 prepare 开始触感生成器。状态与去重均在 KMM,两端只执行硬件效果。公共层 274 项、Android 三项采集平台回归、iOS 17 项语音/原生输入回归通过;新 Android 用例验证硬件仍处于 starting 时已经收到长按触感,以及完整的 start/cancel/release 顺序。Kotlin ktlint 与 git diff --check 通过。日志:/tmp/dsh-voice-three-haptics-20261014-gradle.log、/tmp/dsh-voice-three-haptics-20261014-ios.log。模拟器验证触发事件和去重,实体马达强度没有实测。
268
+
269
+ ## iOS 录音期间触感被系统抑制(2026-10-14)
270
+
271
+ 用户反馈 iOS 三个节点均感受不到震动。检查发现 IOSVoiceRecorder 配置了音频输入,但未调用 setAllowHapticsAndSystemSoundsDuringRecording(true)。该开关默认 false,音频输入期间系统可抑制 UIKit 触感;此前公共层和平台替身只证明事件发出,没有检查真实音频会话配置。来源:[Apple 录音触感开关](https://developer.apple.com/documentation/AVFAudio/AVAudioSession/allowHapticsAndSystemSoundsDuringRecording),本机 AVAudioSession.h 第 111–116 行也明确说明默认值 NO。
272
+
273
+ prepareSession 在配置 record 类别后、激活会话和启动预采集前,明确允许录音触感;准备缓存的复用条件也检查 allowHapticsAndSystemSoundsDuringRecording,避免其他配置关闭后仍跳过恢复。三个原有触感节点和强度保持原值,新增 Debug 日志 VoiceInputHaptic 的 cue、recordingAllowed 字段用于核对系统配置;日志只说明请求发出和开关状态,不证明实体马达实际播放。
274
+
275
+ 新增真实 AVAudioSession 回归,主动从 false 开始,覆盖冷准备、缓存复用、释放后重新准备,并在测试结束恢复原开关。修复前四个断言均失败,修复后该用例与原有语音/输入框共 18 项测试全部通过;构建、git diff --check 通过。日志:/tmp/dsh-ios-recording-haptics-20261014-before.log、/tmp/dsh-ios-recording-haptics-20261014-after.log。本轮修复明确的系统配置缺失,模拟器无法验证实体马达效果,真机仍需使用新构建复测。
@@ -0,0 +1,33 @@
1
+ # 按下、蒙层与麦克风启动的静态复查
2
+
3
+ 分析日期:2026-10-14。仅使用此前用户提供的豆包 12.2.1、DeepSeek 2.5.1 iOS IPA;没有第三方 App 运行、真实麦克风计时、网络或 Android 包观测。全部材料位于被 Git 忽略的 Voice Input Evidence 目录。
4
+
5
+ ## 来源和分析边界
6
+
7
+ - DeepSeek 主程序 SHA-256:`027b12d8d3c3becb5c6e203b09d2d3387f57747f9f2a299ca90f194cab9e8c20`。本轮重新打开同一提取目标,REA inspect_macho 返回 `ev_c96cdf0dac4ab8f520e94b93dbee01a57ce337a76934895ca925e4a8cddc9f5c`。
8
+ - 豆包主程序 SHA-256:`8f81ead52957545efb22c2be3c03717d7fef4803b090d25a2daa0db613fbc47e`。本轮 REA inspect_macho:`ev_c722028d1f958a0925e343c10f723cd54d09844b654f56c9a229dcf137ffa652`;原提取记录 `ev_adf3fc5bce309b0fcea619cdaf3d36b5766996f265fa8a7046639bf25a6319b8` 保持有效。
9
+ - 本轮 REA/Ghidra analyze_function(0x1004c8b34) 在 MCP 300 秒上限超时;证据摘要查询没有已完成 dossier,未将不完整反编译称为成功。随后关闭原生会话。符号级派发工具不能解码实例字段和相对指针,因此这部分明确使用本机 LLVM 与结构解析补充,没有虚构 REA ID。
10
+
11
+ ## DeepSeek:触摸开始与蒙层判定存在独立通道
12
+
13
+ Objective-C 相对方法表解码显示 `InputAreaView.VoiceGesture` 的 `touchesBegan:withEvent:` IMP 为 `0x1004c6eb8`,另有 `__onLongPress:` (`0x1004c6e18`) 和 `touchesEnded:withEvent:` (`0x1004c70b4`)。实例字段分别包含 `onLongPress`、`onTouchesEndedAndFailed`、`onTouchesBegan`,后者实例偏移 40,偏移变量地址 `0x10134aa88`。
14
+
15
+ 在 `deepseek-touch-began.asm` 的 `0x1004c6f18..0x1004c6f4c`,真实 touchesBegan 路径加载该偏移与回调函数,在 `blr x25` 直接调用;之后才调用父类 touchesBegan (`0x1004c6fa8`)。这证明触摸开始回调无需等待 UILongPressGestureRecognizer 到达 began;尚未完整恢复这只闭包的所有业务分派,不能直接将它等同为硬件开始录音。
16
+
17
+ 既有阈值汇编已核验 `voice_gesture_duration_ms` 与 `voice_mask_appear_duration_ms` 两个不同配置键,`0x1004c8b34` 配置回调包含 150/100ms 回退以及一只手势可选择 1ms 的提前判定分支。输入模式和在线配置未执行确认,不能称它所有入口均 1ms 或没有延时。
18
+
19
+ `deepseek-preconfigure.asm` 的 `0x1007e310c` 引用 `[AudioSession] preconfigure(` 日志,在 `0x1007e31f8` 调用 `0x100684284`,传布尔参数 1。后者在 `0x1006842e0..0x1006842f8` 通过 selector ref `0x1012e4b30` 调用 `setCategory:mode:options:error:`,参数 options=0x20;另有参数 0 时 options=0x24 的分支。这是包内音频会话预配置代码的静态证据,不能推断其真实进入页面/按下的调用时间,也没有证明提前激活或一直录音。
20
+
21
+ ## 豆包:文本输入区和专用语音按钮采用不同门槛
22
+
23
+ 复核此前按住说话的最短汇编证据:文本蒙层独立 tap 编辑与 longpress 语音入口,`0x1078f0964..0x1078f0974` 为 `minimumPressDuration=0.15`;专用 `BasicInputVoiceView` 入口 `0x108fee80c` 则传 0 秒。不能把专用按钮的 0ms 套用到可点击编辑的文本区。
24
+
25
+ 录音器模块存在 AudioQueue、AudioUnit、AVAudioEngine 三种实现及 `prepareBuffers`、`setupAudioUnit`、`audioEngineReady` 等准备相关元数据;此前已找到长按语音补全与开始/结束会话入口。元数据和字符串没有充分证明当前 UI 路径选择了哪一后端、是否预热、何时显示蒙层或是否等首帧,因此这些保持未确认,不能据此宣称豆包真实硬件零启动时间。
26
+
27
+ ## 对本客户端的解释与工程调整
28
+
29
+ 源码确认上一版将蒙层绑定到首批 16kHz PCM 写入,除硬件启动还额外等待批次交付及转换器首输出;这是客户端自己增加的串行等待。两端现在在系统录音启动成功且运行状态已确认时通知 KMM,不等第一批音频交付。iOS 必须 engine.start 成功且 isRunning=true,Android 必须 startRecording 成功且 recordingState=RECORDSTATE_RECORDING;首 PCM 日志独立保留。真实 down 的预采集、80ms tap/longpress 区分、原音频缓冲、200ms 松手尾段和转换器 EOS 排空保持。
30
+
31
+ iOS 将音频会话激活提前到会话内准备,正常停止单次录音保留会话激活;路由变化、系统中断或媒体服务重置使准备缓存失效,离开/后台才完整释放。短按撤销在串行队列执行 start 前检查硬件命令 token,避免旧启动排队拖慢下一次手势。这些是本项目工程选择,没有声称第三方 App 完全采用同一实现。
32
+
33
+ 测试:iOS 17 项、Android 11 项相关原生回归通过,包括启动完成但尚无音量/转换样本时显示蒙层、预采集首部字节保留、短按撤销排队启动、冷引擎与释放后重新准备,以及真实原生编辑行为。构建、Android ktlint、git diff --check 通过。日志为 `/tmp/dsh-mic-start-confirmation-20261014-ios.log` 与 `/tmp/dsh-mic-start-confirmation-20261014-android.log`。没有实体设备启动时长或设备签名远端联调,不能保证所有真机硬件在 80ms 内启动。