dsh-remote-bridge 2.2.2 → 2.2.3

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.
@@ -1,8 +1,15 @@
1
1
  asyncapi: 3.0.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile Events
4
- version: 2.2.2
5
- description: Live file and authorized DSH chat events. Reconnect clients must refresh REST state; unbounded replay is not provided.
4
+ version: 2.2.3
5
+ description: |
6
+ Live file and authorized DSH chat events. Reconnect clients must refresh
7
+ REST state; unbounded replay is not provided.
8
+
9
+ `chat.turn.failed` carries the terminal failure of a model turn (rate
10
+ limit, quota exhausted, invalid API key, signed-out stop). Without it a
11
+ remote client sees an ordinary `chat.turn.end` and keeps waiting on a run
12
+ that already failed.
6
13
  servers:
7
14
  local:
8
15
  host: 127.0.0.1:3090
@@ -32,7 +39,7 @@ components:
32
39
  id: { type: string }
33
40
  type:
34
41
  type: string
35
- examples: [file.changed, file.trashed, chat.message.delta, chat.message.committed, chat.session.status, chat.approval.requested, chat.approval.resolved, connection.ready]
42
+ examples: [file.changed, file.trashed, chat.message.delta, chat.message.committed, chat.session.status, chat.approval.requested, chat.approval.resolved, chat.turn.failed, connection.ready]
36
43
  time: { type: integer }
37
44
  data:
38
45
  oneOf:
@@ -40,6 +47,7 @@ components:
40
47
  - $ref: '#/components/schemas/ChatMessageCommitted'
41
48
  - $ref: '#/components/schemas/ChatApprovalRequested'
42
49
  - $ref: '#/components/schemas/ChatApprovalResolved'
50
+ - $ref: '#/components/schemas/ChatTurnFailed'
43
51
  - type: object
44
52
  additionalProperties: true
45
53
  schemas:
@@ -84,3 +92,30 @@ components:
84
92
  sessionId: { type: string }
85
93
  approvalId: { type: string }
86
94
  outcome: { type: string, enum: [allowed-once, rejected, cancelled, unavailable] }
95
+ ChatTurnFailed:
96
+ type: object
97
+ description: |
98
+ Terminal failure of a model turn. Emitted instead of a plain
99
+ `chat.turn.end` when the host reports `reason.kind === 'error'` (rate
100
+ limit, quota exhausted, invalid key) or an abort caused by signing out
101
+ of the DeepSeek account. Clients must stop their running indicator and
102
+ show the message; otherwise a failed run keeps looking like it is in
103
+ flight.
104
+ required: [sessionId, eventType, failure]
105
+ properties:
106
+ sessionId: { type: string }
107
+ eventType: { type: string, const: turn/end }
108
+ seq: { type: integer }
109
+ failure:
110
+ type: object
111
+ description: |
112
+ `message` is the host's own wording, forwarded verbatim so clients
113
+ can decide localization. `code` is present only when the host
114
+ supplies one; an empty string is meaningful and must not be
115
+ treated as absent. `stopped` marks an account-sign-out stop, which
116
+ is not a model fault.
117
+ required: [message]
118
+ properties:
119
+ message: { type: string }
120
+ code: { type: string }
121
+ stopped: { type: boolean, const: true }
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile API
4
- version: 2.2.2
4
+ version: 2.2.3
5
5
  description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
6
6
  servers:
7
7
  - url: http://127.0.0.1:3090/api/v1
package/lib/index.d.ts CHANGED
@@ -220,16 +220,37 @@ interface DshApiProxy {
220
220
  *
221
221
  * `attachmentId` 形如 `sha256:<hex>`(**含冒号**,客户端必须 URL 编码)。
222
222
  * 返回 `Uint8Array` 而不是 base64:图片动辄上百 KB,多一次编解码纯属浪费。
223
+ *
224
+ * **必须带 `sessionId`**:内核要靠它读会话事件、取出这张图的真实 ref,
225
+ * 再据此做完整性校验(详见 chat-adapter 的 readAttachment 注释)。
223
226
  */
224
227
  readAttachment(request: {
225
228
  rpcId: string;
226
229
  payload: {
230
+ sessionId: string;
227
231
  attachmentId: string;
228
232
  };
229
233
  }): Promise<RpcResponse<{
230
234
  bytes: Uint8Array;
231
235
  mediaType: string;
232
236
  }>>;
237
+ /**
238
+ * 取回一张**交接图片**的字节(纯文本模型发图时,图片被落成工作区文件)。
239
+ *
240
+ * 与 [readAttachment] 的区别:那一个取内核附件存储里的图(走
241
+ * `sessionController.attachment`),这一个取插件自己写进 `.dsh-images/`
242
+ * 的交接件。手机端用它把图片**显示**给人看;消息里的路径文本照旧留给模型。
243
+ */
244
+ readHandoffImage(request: {
245
+ rpcId: string;
246
+ payload: {
247
+ sessionId: string;
248
+ name: string;
249
+ };
250
+ }): Promise<RpcResponse<{
251
+ bytes: Uint8Array;
252
+ mediaType: string;
253
+ }>>;
233
254
  cancel(request: {
234
255
  rpcId: string;
235
256
  payload: {
@@ -622,12 +643,28 @@ declare class DshChatAdapter {
622
643
  /**
623
644
  * 取回会话历史里某个图片附件的字节。
624
645
  *
625
- * 这里不做会话归属校验:附件 id 是内容哈希,拿到 id 就等于拿到内容,
626
- * 而且调用方必须已经通过 `files.read` 鉴权并主动持有该 id(它只出现在其有权读取的
627
- * 会话事件里)。多一层会话校验并不能提升安全性,却会让「在别的会话里引用同一张图」
628
- * 这种正常情况莫名失败。
646
+ * **为什么会走内核的 `sessionController.attachment()`,而不自己调
647
+ * `ctx.attachments.readImage()`**:
648
+ * 1. 实测本机 profile 里 `ctx.attachments` 是 **MISSING**(拿不到注入属性)——
649
+ * 自己拼 ref 那条路根本走不通;
650
+ * 2. 内核那个方法内部一次做完三件事:校验该会话确实引用了这张图、
651
+ * 读出字节、用**会话事件里那条真实 ref** 做完整性校验
652
+ * (`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
653
+ * 绕开它就得把这三件事全复刻 —— 先前正是漏了第三件,于是每张图
654
+ * `ATTACHMENT_CORRUPT`:「字节读到了,却被校验拒之门外」。
655
+ *
656
+ * 因此 **sessionId 是必需参数**(内核要拿它读会话事件、找到那张图的真实 ref);
657
+ * 早先设计的「客户端把四项元数据带上来」是多余的 —— 内核手里本来就有。
658
+ */
659
+ readAttachment(principal: Principal, sessionId: string, attachmentId: string): Promise<{
660
+ bytes: Uint8Array;
661
+ mediaType: string;
662
+ }>;
663
+ /**
664
+ * 取回一张**交接图片**(纯文本模型发图时落在工作区 `.dsh-images/` 的那份)。
665
+ * 与 [readAttachment] 同样要求会话归属;文件名在桥里还有一道白名单校验。
629
666
  */
630
- readAttachment(principal: Principal, attachmentId: string): Promise<{
667
+ readHandoffImage(principal: Principal, sessionId: string, name: string): Promise<{
631
668
  bytes: Uint8Array;
632
669
  mediaType: string;
633
670
  }>;
@@ -902,8 +939,21 @@ interface FileUploadsLike {
902
939
  * 否则历史里的图片一律显示不出来。
903
940
  */
904
941
  interface AttachmentStoreLike {
905
- readImage(ref: {
942
+ readImage(
943
+ /**
944
+ * 附件引用。
945
+ *
946
+ * `attachmentId` 是唯一的**定位**字段(内核用 `ID_PATTERN.exec()` 取出 sha256
947
+ * 再拼 `objects/<前两位>/<sha>`);而 `mediaType` / `bytes` / `width` / `height`
948
+ * 参与读后**完整性校验** —— 缺任何一项都会抛 `ATTACHMENT_CORRUPT`,
949
+ * 哪怕字节已经正确读出来了。
950
+ */
951
+ ref: {
906
952
  attachmentId: string;
953
+ mediaType?: string;
954
+ bytes?: number;
955
+ width?: number;
956
+ height?: number;
907
957
  }, signal?: AbortSignal): Promise<{
908
958
  ref: {
909
959
  mediaType: string;
@@ -978,6 +1028,33 @@ interface SessionControllerLike {
978
1028
  }): Promise<{
979
1029
  sessionId: string;
980
1030
  }>;
1031
+ /**
1032
+ * 取一张**被这个会话引用过**的图片字节(内核主机端方法,名字就叫 `attachment`)。
1033
+ *
1034
+ * 这是取历史图片的**正路**,它内部一次做完三件事:校验会话确实引用了这张图、
1035
+ * 调 `ctx.attachments.readImage`、用**会话事件里那条真实 ref**做完整性校验
1036
+ *(`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
1037
+ *
1038
+ * 为什么必须走它、不能自己调 `ctx.attachments`:实测本机这条 profile 里
1039
+ * `ctx.attachments` 是 **MISSING**(诊断日志
1040
+ * `ctx.attachments MISSING(所以改走内核 sessionController.readAttachment)`),
1041
+ * 自己拼 ref 那条路根本走不通;而且绕开内核就得把它内部三件事全复刻一遍,
1042
+ * 早先正是只传了 id、缺四项元数据,导致 `ATTACHMENT_CORRUPT`。
1043
+ *
1044
+ * 注意方法名:**主机端叫 `attachment`**,客户端契约里才叫 `readAttachment`
1045
+ *(`dsh-api-session-controller/lib/types/client/contract/session.d.ts`)。
1046
+ * `data` 是 **base64 字符串**(主机端实现里 `Buffer.from(stored.data).toString("base64")`),
1047
+ * 不是 Uint8Array —— 调用方要自己解码。
1048
+ */
1049
+ attachment(request: {
1050
+ sessionId: string;
1051
+ attachmentId: string;
1052
+ }): Promise<{
1053
+ attachment: {
1054
+ mediaType: string;
1055
+ };
1056
+ data: string;
1057
+ }>;
981
1058
  selectModel(request: {
982
1059
  sessionId: string;
983
1060
  provider: string;
@@ -1085,6 +1162,17 @@ interface LlmLike {
1085
1162
  error?: string;
1086
1163
  }[];
1087
1164
  discoverModels(settingsNs: string, request: unknown, signal?: AbortSignal): Promise<readonly unknown[]>;
1165
+ /**
1166
+ * 一个路由 + 模型 id 的**真实元数据**。
1167
+ *
1168
+ * 为什么必须用它、而不是自己猜模型能不能看图:内核拒绝「给纯文本模型发图片」时
1169
+ * 用的就是**这个方法**(`SessionController` 的 prompt 准入里
1170
+ * `this.ctx.llm.resolveModelInfo(...)` 再查 `inputModalities`)。入口要与内核
1171
+ * 得出同一个结论,只能查同一份元数据 —— 名字、前缀、家族表都是启发式,会漂。
1172
+ */
1173
+ resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
1174
+ inputModalities?: readonly string[];
1175
+ } | undefined>;
1088
1176
  }
1089
1177
  /** Host services the bridge consumes; assembled by `src/index.ts` from `ctx`. */
1090
1178
  interface Dsh02HostServices {
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import path from "node:path";
1
+ import path, { join } from "node:path";
2
2
  import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
4
- import { createReadStream, mkdirSync } from "node:fs";
4
+ import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { DatabaseSync } from "node:sqlite";
6
+ import os, { homedir, hostname, networkInterfaces } from "node:os";
6
7
  import { pipeline } from "node:stream/promises";
7
8
  import { isIP } from "node:net";
8
- import os, { hostname, networkInterfaces } from "node:os";
9
9
  import { createServer } from "node:http";
10
10
  import { WebSocket, WebSocketServer } from "ws";
11
11
  //#region src/shared/contracts.ts
@@ -424,16 +424,45 @@ var DshChatAdapter = class {
424
424
  /**
425
425
  * 取回会话历史里某个图片附件的字节。
426
426
  *
427
- * 这里不做会话归属校验:附件 id 是内容哈希,拿到 id 就等于拿到内容,
428
- * 而且调用方必须已经通过 `files.read` 鉴权并主动持有该 id(它只出现在其有权读取的
429
- * 会话事件里)。多一层会话校验并不能提升安全性,却会让「在别的会话里引用同一张图」
430
- * 这种正常情况莫名失败。
427
+ * **为什么会走内核的 `sessionController.attachment()`,而不自己调
428
+ * `ctx.attachments.readImage()`**:
429
+ * 1. 实测本机 profile 里 `ctx.attachments` 是 **MISSING**(拿不到注入属性)——
430
+ * 自己拼 ref 那条路根本走不通;
431
+ * 2. 内核那个方法内部一次做完三件事:校验该会话确实引用了这张图、
432
+ * 读出字节、用**会话事件里那条真实 ref** 做完整性校验
433
+ * (`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
434
+ * 绕开它就得把这三件事全复刻 —— 先前正是漏了第三件,于是每张图
435
+ * `ATTACHMENT_CORRUPT`:「字节读到了,却被校验拒之门外」。
436
+ *
437
+ * 因此 **sessionId 是必需参数**(内核要拿它读会话事件、找到那张图的真实 ref);
438
+ * 早先设计的「客户端把四项元数据带上来」是多余的 —— 内核手里本来就有。
431
439
  */
432
- async readAttachment(principal, attachmentId) {
440
+ async readAttachment(principal, sessionId, attachmentId) {
433
441
  if (attachmentId.trim() === "") throw new ApiError(400, "BODY_INVALID", "attachmentId is required.");
442
+ if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
443
+ await this.requireSession(principal, sessionId);
434
444
  return unwrapDsh(await this.api.sessions.readAttachment({
435
445
  rpcId: randomUUID(),
436
- payload: { attachmentId }
446
+ payload: {
447
+ sessionId,
448
+ attachmentId
449
+ }
450
+ }));
451
+ }
452
+ /**
453
+ * 取回一张**交接图片**(纯文本模型发图时落在工作区 `.dsh-images/` 的那份)。
454
+ * 与 [readAttachment] 同样要求会话归属;文件名在桥里还有一道白名单校验。
455
+ */
456
+ async readHandoffImage(principal, sessionId, name) {
457
+ if (name.trim() === "") throw new ApiError(400, "BODY_INVALID", "name is required.");
458
+ if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
459
+ await this.requireSession(principal, sessionId);
460
+ return unwrapDsh(await this.api.sessions.readHandoffImage({
461
+ rpcId: randomUUID(),
462
+ payload: {
463
+ sessionId,
464
+ name
465
+ }
437
466
  }));
438
467
  }
439
468
  async cancel(principal, sessionId) {
@@ -807,12 +836,30 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
807
836
  }
808
837
  };
809
838
  }
810
- if (eventType === "turn/start" || eventType === "turn/end") return {
811
- id: frame.rpcId,
812
- type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
813
- time,
814
- data: base
815
- };
839
+ if (eventType === "turn/start" || eventType === "turn/end") {
840
+ if (eventType === "turn/end") {
841
+ const failure = turnFailureOf(event);
842
+ if (failure !== void 0) {
843
+ const data = {
844
+ ...base,
845
+ failure
846
+ };
847
+ if (failure.stopped === true) data.stopped = true;
848
+ return {
849
+ id: `${frame.rpcId}:failed`,
850
+ type: "chat.turn.failed",
851
+ time,
852
+ data
853
+ };
854
+ }
855
+ }
856
+ return {
857
+ id: frame.rpcId,
858
+ type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
859
+ time,
860
+ data: base
861
+ };
862
+ }
816
863
  return {
817
864
  id: frame.rpcId,
818
865
  type: "chat.session.event",
@@ -848,6 +895,39 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
848
895
  data: payload
849
896
  };
850
897
  }
898
+ /**
899
+ * `turn/end` 里的终局失败摘要,取不到就返回 undefined(这轮是正常结束的)。
900
+ *
901
+ * 内核 `turn-error.js` 的 match 判据只认两种:`reason.kind === 'error'`,
902
+ * 以及 `aborted` 且取消原因是「退出 DeepSeek 登录」这个钩子。这里**同一判据**,
903
+ * 不多不少 ——
904
+ * - 不能少:漏掉任一形态,客户端就又回到「傻等」状态;
905
+ * - 不能多:`max-tokens` 是「回答被截断,已有输出保留」,官方单独渲染成黄色警示,
906
+ * 不是失败(客户端按普通 turn/end 收尾即可);普通取消(用户点了停止)更不是。
907
+ *
908
+ * `code === 'AUTH'` 时内核把 message 抹成空串(`displayFailure`),客户端那边
909
+ * 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
910
+ */
911
+ function turnFailureOf(event) {
912
+ const reason = isRecord$1(event.data) ? event.data.reason : void 0;
913
+ if (!isRecord$1(reason)) return void 0;
914
+ const kind = reason.kind;
915
+ if (kind === "error") {
916
+ const error = reason.error;
917
+ if (!isRecord$1(error)) return { message: String(error ?? "本轮运行失败") };
918
+ const code = typeof error.code === "string" ? error.code : void 0;
919
+ const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
920
+ return code === void 0 ? { message } : {
921
+ message,
922
+ code
923
+ };
924
+ }
925
+ if (kind === "aborted" && isRecord$1(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
926
+ message: "Stopped because you signed out of DeepSeek.",
927
+ code: "ACCOUNT_SIGNED_OUT",
928
+ stopped: true
929
+ };
930
+ }
851
931
  function isRecord$1(value) {
852
932
  return typeof value === "object" && value !== null && !Array.isArray(value);
853
933
  }
@@ -1732,6 +1812,183 @@ function sessionIdOf(summary) {
1732
1812
  return typeof id === "string" ? id : void 0;
1733
1813
  }
1734
1814
  /**
1815
+ * 内核在 prompt 准入里会因为「模型看不到图」拒收整条消息
1816
+ * (`MODEL_DOES_NOT_SUPPORT_IMAGES`)。手机端拿不到桌面 WebUI 那套
1817
+ * 「粘贴即落成临时文件、路径进输入框」的浏览器半边,所以图片在手机上
1818
+ * **直接发不出去** —— 而用户明明装了识图插件。
1819
+ *
1820
+ * 这里补上手机端缺的那一步,但**刻意不引用任何插件的 API**:
1821
+ * 判定用内核自己的 `llm.resolveModelInfo()`(与内核拒收时查的是同一份元数据),
1822
+ * 动作只是「图片落成文件 + 消息里给路径」—— 任何「按路径读图」的工具都能接手。
1823
+ * 哪天换插件、甚至不用插件,这段代码都不需要改。
1824
+ *
1825
+ * 三条保守原则(宁可退回原样、也不要自作聪明):
1826
+ * 1. 元数据拿不到 / 解析不出模型 → **不动**(内核这时也不会拒,交给它);
1827
+ * 2. 模型声明了 image → **不动**(原生图片能显示缩略图,别抢它的活);
1828
+ * 3. 落盘失败 → **不动**(退回内核自己的报错,至少用户能看懂发生了什么)。
1829
+ *
1830
+ * 两个落点(按优先级):
1831
+ * - **会话工作目录下的 `.dsh-images/`**(首选):文件在用户的项目里,
1832
+ * 人和 agent 都找得到,也符合「产物放工作目录、别塞进 DSH 私有目录」;
1833
+ * - 解析不出工作目录时退回 DSH 目录([DEFAULT_IMAGE_HANDOFF_DIR])——
1834
+ * 宁可落到私有目录也不能让「发消息」本身失败。
1835
+ */
1836
+ const HANDOFF_SUBDIR = ".dsh-images";
1837
+ const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
1838
+ const IMAGE_EXTENSIONS = {
1839
+ "image/png": "png",
1840
+ "image/jpeg": "jpg",
1841
+ "image/webp": "webp",
1842
+ "image/gif": "gif",
1843
+ "image/heic": "heic",
1844
+ "image/heif": "heif",
1845
+ "image/bmp": "bmp",
1846
+ "image/avif": "avif"
1847
+ };
1848
+ /** 扩展名优先按声明的 mediaType,认不出再从魔数嗅,最后 .bin。 */
1849
+ function handoffExtension(mediaType, bytes) {
1850
+ const declared = IMAGE_EXTENSIONS[mediaType.trim().toLowerCase()];
1851
+ if (declared !== void 0) return declared;
1852
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80) return "png";
1853
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpg";
1854
+ if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "webp";
1855
+ if (bytes.length >= 6 && bytes.toString("ascii", 0, 3) === "GIF") return "gif";
1856
+ return "bin";
1857
+ }
1858
+ /**
1859
+ * 把一张内联图片写成文件,返回 `{ absolute, relative }`。
1860
+ *
1861
+ * 文件名用**内容哈希**:同一张图重复发不会堆副本(与内核附件存储同一思路),
1862
+ * 也让「同一路径 → 同一内容」对下游工具成立(可安全缓存)。
1863
+ * `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
1864
+ * 目录 0700、文件 0600:这些字节可能包含用户隐私。
1865
+ */
1866
+ function writeHandoffFile(dir, workspace, mediaType, data) {
1867
+ const bytes = Buffer.from(data, "base64");
1868
+ if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
1869
+ mkdirSync(dir, {
1870
+ recursive: true,
1871
+ mode: 448
1872
+ });
1873
+ const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
1874
+ const absolute = join(dir, fileName);
1875
+ if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
1876
+ return {
1877
+ absolute,
1878
+ relative: workspace !== void 0 && absolute.startsWith(workspace + "/") ? absolute.slice(workspace.length + 1) : fileName
1879
+ };
1880
+ }
1881
+ /** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
1882
+ const HANDOFF_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
1883
+ const HANDOFF_MEDIA_TYPES = {
1884
+ png: "image/png",
1885
+ jpg: "image/jpeg",
1886
+ jpeg: "image/jpeg",
1887
+ webp: "image/webp",
1888
+ gif: "image/gif",
1889
+ heic: "image/heic",
1890
+ heif: "image/heif",
1891
+ bmp: "image/bmp",
1892
+ avif: "image/avif"
1893
+ };
1894
+ /**
1895
+ * 读出一张交接图片的字节(供手机端把它**显示**出来)。
1896
+ *
1897
+ * 只接受裸文件名,目录永远由「会话工作目录 + `.dsh-images`」(或兜底目录)推出,
1898
+ * 客户端给绝对路径也不认 —— 否则这条读文件的口子会变成任意文件读取。
1899
+ * 工作目录与 [writeHandoffFile] 用的是同一套解析,保证「写在哪、从哪读」。
1900
+ */
1901
+ async function readHandoffImageFile(host, roots, fallbackDir, sessionId, name) {
1902
+ const fileName = name.split(/[/\\]/).pop() ?? name;
1903
+ const match = HANDOFF_NAME.exec(fileName);
1904
+ if (match === null) throw new Error("BAD_HANDOFF_NAME");
1905
+ const ext = (match[1] ?? "").toLowerCase();
1906
+ const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
1907
+ const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1908
+ const target = [...workspace !== void 0 ? [join(workspace, HANDOFF_SUBDIR, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
1909
+ if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
1910
+ return {
1911
+ bytes: new Uint8Array(readFileSync(target)),
1912
+ mediaType
1913
+ };
1914
+ }
1915
+ /**
1916
+ * 解析会话当前的工作目录(绝对路径),冷会话也能用。
1917
+ *
1918
+ * 不走 live agent(冷会话没有 agent):直接用 `sessionController.list()`
1919
+ * 里那条会话的 `cwd`(相对授权根的 wire 路径),再与数据库里的根拼出真实路径。
1920
+ * 解析不出来(没有授权根 / cwd 不在任何根下)就返回 undefined —— 交给上层兜底。
1921
+ */
1922
+ async function sessionWorkspaceDir(host, roots, sessionId) {
1923
+ if (roots.length === 0) return void 0;
1924
+ const item = (await host.sessionController.list({}, new AbortController().signal)).items?.find((entry) => entry.sessionId === sessionId);
1925
+ const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
1926
+ if (cwd === void 0) return void 0;
1927
+ for (const root of roots) {
1928
+ const relative = wireRelative(root.realPath, cwd);
1929
+ if (relative !== void 0) return join(root.realPath, relative);
1930
+ }
1931
+ }
1932
+ /** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
1933
+ async function currentModelSelection(host, sessionId) {
1934
+ const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
1935
+ const catalog = await host.sessionController.modelCatalog();
1936
+ const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
1937
+ const provider = current?.provider;
1938
+ const model = current?.model;
1939
+ if (typeof provider !== "string" || typeof model !== "string") return void 0;
1940
+ return {
1941
+ provider,
1942
+ model
1943
+ };
1944
+ }
1945
+ /**
1946
+ * 内核会不会因为「这个模型看不到图」拒收?判据与内核**逐字一致**:
1947
+ * `inputModalities` 已声明且不含 `image`。
1948
+ *
1949
+ * 元数据缺失(`undefined`)**不算**「确认看不到图」—— 内核在那种情况下也不拒,
1950
+ * 我们跟着不插手(拿不准就走原路)。
1951
+ */
1952
+ async function modelIsConfirmedTextOnly(host, sessionId) {
1953
+ const selection = await currentModelSelection(host, sessionId);
1954
+ if (selection === void 0) return false;
1955
+ const modalities = (await host.llm.resolveModelInfo(selection.provider, selection.model))?.inputModalities;
1956
+ if (!Array.isArray(modalities)) return false;
1957
+ return !modalities.includes("image");
1958
+ }
1959
+ /**
1960
+ * 需要交接时,把每个 `image` 部件换成**两行文本**;否则原样返回。
1961
+ *
1962
+ * 同一行路径要同时伺候两类读者:
1963
+ * - **给人看**(第一行):图片序号、原始文件名、工作目录里的短路径;
1964
+ * - **给机器看**(第二行 `[Image: source: <绝对路径>]`):这是读图技能明确
1965
+ * 识别的占位形状之一(带图片扩展名的绝对路径同样触发),任何按路径读图的
1966
+ * 工具都能接手,不出现任何具体插件名。
1967
+ *
1968
+ * 任何一步抛错都退回原内容:这个功能的失败**不该**把「发消息」本身弄坏。
1969
+ */
1970
+ async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, sessionId, content) {
1971
+ if (!content.some((part) => part.type === "image")) return content;
1972
+ try {
1973
+ if (!await modelIsConfirmedTextOnly(host, sessionId)) return content;
1974
+ const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1975
+ const dir = workspace !== void 0 ? join(workspace, HANDOFF_SUBDIR) : fallbackDir;
1976
+ let index = 0;
1977
+ return content.map((part) => {
1978
+ if (part.type !== "image") return part;
1979
+ index += 1;
1980
+ const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
1981
+ const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : `图片 ${index}`;
1982
+ return {
1983
+ type: "text",
1984
+ text: [`🖼️ 图片 ${index}「${name}」(当前模型不支持图片输入,已保存到工作目录 ${relative};请先用读图工具打开下面那行的绝对路径,看清内容再回答)`, `[Image: source: ${absolute}]`].join("\n")
1985
+ };
1986
+ });
1987
+ } catch {
1988
+ return content;
1989
+ }
1990
+ }
1991
+ /**
1735
1992
  * Build the `apiProxy` replacement out of 0.2.x host services.
1736
1993
  *
1737
1994
  * Every method keeps the old `{ rpcId, payload }` request shape and the old
@@ -1739,12 +1996,15 @@ function sessionIdOf(summary) {
1739
1996
  * `chat-adapter.ts` and `settings-adapter.ts` are written against.
1740
1997
  *
1741
1998
  * @param host - host services resolved from the plugin context.
1999
+ * @param host - host services resolved from the plugin context.
1742
2000
  * @returns a `SettingsApiProxy` (i.e. `DshApiProxy` plus the settings surfaces).
1743
2001
  */
1744
- function createDsh02ApiProxy(host) {
2002
+ function createDsh02ApiProxy(host, options = {}) {
1745
2003
  const mux = new FrameHub();
1746
2004
  const hostFrames = new FrameHub();
1747
- const attachmentStore = optionalService(host, "attachments");
2005
+ const imageHandoff = options.imageHandoff !== false;
2006
+ const handoffRoots = options.handoffRoots ?? [];
2007
+ const imageHandoffDir = options.imageHandoffDir ?? DEFAULT_IMAGE_HANDOFF_DIR;
1748
2008
  const fileUploadService = optionalService(host, "fileUploads");
1749
2009
  const approvals = new ApprovalBridge(host, mux);
1750
2010
  approvals.attach();
@@ -1847,13 +2107,15 @@ function createDsh02ApiProxy(host) {
1847
2107
  prompt: (request) => call(async () => {
1848
2108
  const requestId = randomUUID();
1849
2109
  const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
2110
+ const content = imageHandoff ? await handoffImagesForTextOnlyModel(host, handoffRoots, imageHandoffDir, request.payload.sessionId, request.payload.content) : request.payload.content;
1850
2111
  const binding = receiptIds.length === 0 ? void 0 : await (async () => {
1851
2112
  if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
1852
2113
  return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
1853
2114
  })();
1854
2115
  await host.sessionController.prompt({
1855
2116
  requestId,
1856
- ...request.payload
2117
+ ...request.payload,
2118
+ content
1857
2119
  }, new AbortController().signal);
1858
2120
  binding?.commit();
1859
2121
  return { accepted: true };
@@ -1870,13 +2132,20 @@ function createDsh02ApiProxy(host) {
1870
2132
  };
1871
2133
  }),
1872
2134
  readAttachment: (request) => call(async () => {
1873
- if (attachmentStore == null) throw new Error("ATTACHMENT_STORE_UNAVAILABLE: this DSH build provides no ctx.attachments service, so stored image bytes cannot be read");
1874
- const stored = await attachmentStore.readImage({ attachmentId: request.payload.attachmentId }, new AbortController().signal);
2135
+ const { sessionId, attachmentId } = request.payload;
2136
+ const value = await host.sessionController.attachment({
2137
+ sessionId,
2138
+ attachmentId
2139
+ });
1875
2140
  return {
1876
- bytes: stored.data,
1877
- mediaType: stored.ref.mediaType
2141
+ bytes: new Uint8Array(Buffer.from(value.data, "base64")),
2142
+ mediaType: value.attachment.mediaType
1878
2143
  };
1879
2144
  }),
2145
+ readHandoffImage: (request) => call(async () => {
2146
+ const { sessionId, name } = request.payload;
2147
+ return readHandoffImageFile(host, handoffRoots, imageHandoffDir, sessionId, name);
2148
+ }),
1880
2149
  cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
1881
2150
  rename: (request) => call(() => host.sessionController.rename(request.payload)),
1882
2151
  fork: (request) => call(() => host.sessionController.fork(request.payload)),
@@ -1888,7 +2157,7 @@ function createDsh02ApiProxy(host) {
1888
2157
  const catalog = await host.sessionController.modelCatalog();
1889
2158
  const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
1890
2159
  return {
1891
- current: selection?.pending ?? selection?.lastUsed ?? catalog.default,
2160
+ current: selection?.next ?? selection?.lastUsed ?? catalog.default,
1892
2161
  routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
1893
2162
  groups: Array.isArray(catalog.groups) ? catalog.groups : [],
1894
2163
  failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
@@ -3101,7 +3370,9 @@ var ApiRouter = class {
3101
3370
  if (attachmentRoute !== null && method === "GET") {
3102
3371
  this.options.auth.requireScope(principal, "files.read");
3103
3372
  const attachmentId = decodeURIComponent(attachmentRoute[1]);
3104
- const { bytes, mediaType } = await this.options.chat.readAttachment(principal, attachmentId);
3373
+ const sessionId = url.searchParams.get("sessionId")?.trim();
3374
+ if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
3375
+ const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
3105
3376
  res.statusCode = 200;
3106
3377
  res.setHeader("Content-Type", mediaType);
3107
3378
  res.setHeader("Content-Length", String(bytes.byteLength));
@@ -3109,6 +3380,19 @@ var ApiRouter = class {
3109
3380
  res.end(Buffer.from(bytes));
3110
3381
  return;
3111
3382
  }
3383
+ if (pathname === "/api/v1/image-handoff" && method === "GET") {
3384
+ this.options.auth.requireScope(principal, "files.read");
3385
+ const name = url.searchParams.get("name")?.trim();
3386
+ const sessionId = url.searchParams.get("sessionId")?.trim();
3387
+ if (!name || !sessionId) throw new ApiError(400, "BODY_INVALID", "name and sessionId query parameters are required.");
3388
+ const { bytes, mediaType } = await this.options.chat.readHandoffImage(principal, sessionId, name);
3389
+ res.statusCode = 200;
3390
+ res.setHeader("Content-Type", mediaType);
3391
+ res.setHeader("Content-Length", String(bytes.byteLength));
3392
+ res.setHeader("Cache-Control", "private, max-age=3600");
3393
+ res.end(Buffer.from(bytes));
3394
+ return;
3395
+ }
3112
3396
  const attachmentsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/attachments$/);
3113
3397
  if (attachmentsRoute !== null && method === "POST") {
3114
3398
  this.options.auth.requireScope(principal, "chat.write");
@@ -4365,7 +4649,13 @@ async function apply(ctx, config = {}) {
4365
4649
  const events = new WorkspaceEventBus();
4366
4650
  const auth = new AuthService(database);
4367
4651
  const files = new FileService(database, (event) => events.emit(event));
4368
- const apiProxy = createDsh02ApiProxy(ctx);
4652
+ const apiProxy = createDsh02ApiProxy(ctx, {
4653
+ handoffRoots: database.listRoots().map((root) => ({
4654
+ id: root.id,
4655
+ realPath: root.realPath
4656
+ })),
4657
+ imageHandoffDir: path.join(dataDir, "image-handoff")
4658
+ });
4369
4659
  const chat = new DshChatAdapter(apiProxy, database, {
4370
4660
  agents: ctx.agents,
4371
4661
  commands: ctx.commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-bridge",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.19.0",