dsh-remote-bridge 2.2.2 → 2.2.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/docs/api/asyncapi.yaml +38 -3
- package/docs/api/openapi.yaml +1 -1
- package/lib/index.d.ts +973 -859
- package/lib/index.js +332 -28
- package/package.json +1 -1
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
|
|
@@ -389,7 +389,7 @@ var DshChatAdapter = class {
|
|
|
389
389
|
payload
|
|
390
390
|
}));
|
|
391
391
|
}
|
|
392
|
-
async prompt(principal, sessionId, parts, mode, clientTimeZone) {
|
|
392
|
+
async prompt(principal, sessionId, parts, mode, clientTimeZone, locale) {
|
|
393
393
|
await this.requireSession(principal, sessionId);
|
|
394
394
|
const hasText = parts.some((part) => part.type === "text" && part.text.trim() !== "");
|
|
395
395
|
const hasAttachment = parts.some((part) => part.type !== "text");
|
|
@@ -400,7 +400,8 @@ var DshChatAdapter = class {
|
|
|
400
400
|
sessionId,
|
|
401
401
|
mode,
|
|
402
402
|
content: parts,
|
|
403
|
-
...clientTimeZone === void 0 ? {} : { clientTimeZone }
|
|
403
|
+
...clientTimeZone === void 0 ? {} : { clientTimeZone },
|
|
404
|
+
...locale === void 0 ? {} : { locale }
|
|
404
405
|
}
|
|
405
406
|
}));
|
|
406
407
|
}
|
|
@@ -424,16 +425,45 @@ var DshChatAdapter = class {
|
|
|
424
425
|
/**
|
|
425
426
|
* 取回会话历史里某个图片附件的字节。
|
|
426
427
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
428
|
+
* **为什么会走内核的 `sessionController.attachment()`,而不自己调
|
|
429
|
+
* `ctx.attachments.readImage()`**:
|
|
430
|
+
* 1. 实测本机 profile 里 `ctx.attachments` 是 **MISSING**(拿不到注入属性)——
|
|
431
|
+
* 自己拼 ref 那条路根本走不通;
|
|
432
|
+
* 2. 内核那个方法内部一次做完三件事:校验该会话确实引用了这张图、
|
|
433
|
+
* 读出字节、用**会话事件里那条真实 ref** 做完整性校验
|
|
434
|
+
* (`mediaType` / `bytes` / `width` / `height` 四项逐项比对)。
|
|
435
|
+
* 绕开它就得把这三件事全复刻 —— 先前正是漏了第三件,于是每张图
|
|
436
|
+
* `ATTACHMENT_CORRUPT`:「字节读到了,却被校验拒之门外」。
|
|
437
|
+
*
|
|
438
|
+
* 因此 **sessionId 是必需参数**(内核要拿它读会话事件、找到那张图的真实 ref);
|
|
439
|
+
* 早先设计的「客户端把四项元数据带上来」是多余的 —— 内核手里本来就有。
|
|
431
440
|
*/
|
|
432
|
-
async readAttachment(principal, attachmentId) {
|
|
441
|
+
async readAttachment(principal, sessionId, attachmentId) {
|
|
433
442
|
if (attachmentId.trim() === "") throw new ApiError(400, "BODY_INVALID", "attachmentId is required.");
|
|
443
|
+
if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
|
|
444
|
+
await this.requireSession(principal, sessionId);
|
|
434
445
|
return unwrapDsh(await this.api.sessions.readAttachment({
|
|
435
446
|
rpcId: randomUUID(),
|
|
436
|
-
payload: {
|
|
447
|
+
payload: {
|
|
448
|
+
sessionId,
|
|
449
|
+
attachmentId
|
|
450
|
+
}
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* 取回一张**交接图片**(纯文本模型发图时落在工作区 `.dsh-images/` 的那份)。
|
|
455
|
+
* 与 [readAttachment] 同样要求会话归属;文件名在桥里还有一道白名单校验。
|
|
456
|
+
*/
|
|
457
|
+
async readHandoffImage(principal, sessionId, name) {
|
|
458
|
+
if (name.trim() === "") throw new ApiError(400, "BODY_INVALID", "name is required.");
|
|
459
|
+
if (sessionId.trim() === "") throw new ApiError(400, "BODY_INVALID", "sessionId is required.");
|
|
460
|
+
await this.requireSession(principal, sessionId);
|
|
461
|
+
return unwrapDsh(await this.api.sessions.readHandoffImage({
|
|
462
|
+
rpcId: randomUUID(),
|
|
463
|
+
payload: {
|
|
464
|
+
sessionId,
|
|
465
|
+
name
|
|
466
|
+
}
|
|
437
467
|
}));
|
|
438
468
|
}
|
|
439
469
|
async cancel(principal, sessionId) {
|
|
@@ -807,12 +837,30 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
807
837
|
}
|
|
808
838
|
};
|
|
809
839
|
}
|
|
810
|
-
if (eventType === "turn/start" || eventType === "turn/end")
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
840
|
+
if (eventType === "turn/start" || eventType === "turn/end") {
|
|
841
|
+
if (eventType === "turn/end") {
|
|
842
|
+
const failure = turnFailureOf(event);
|
|
843
|
+
if (failure !== void 0) {
|
|
844
|
+
const data = {
|
|
845
|
+
...base,
|
|
846
|
+
failure
|
|
847
|
+
};
|
|
848
|
+
if (failure.stopped === true) data.stopped = true;
|
|
849
|
+
return {
|
|
850
|
+
id: `${frame.rpcId}:failed`,
|
|
851
|
+
type: "chat.turn.failed",
|
|
852
|
+
time,
|
|
853
|
+
data
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
id: frame.rpcId,
|
|
859
|
+
type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
|
|
860
|
+
time,
|
|
861
|
+
data: base
|
|
862
|
+
};
|
|
863
|
+
}
|
|
816
864
|
return {
|
|
817
865
|
id: frame.rpcId,
|
|
818
866
|
type: "chat.session.event",
|
|
@@ -848,6 +896,39 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
|
848
896
|
data: payload
|
|
849
897
|
};
|
|
850
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* `turn/end` 里的终局失败摘要,取不到就返回 undefined(这轮是正常结束的)。
|
|
901
|
+
*
|
|
902
|
+
* 内核 `turn-error.js` 的 match 判据只认两种:`reason.kind === 'error'`,
|
|
903
|
+
* 以及 `aborted` 且取消原因是「退出 DeepSeek 登录」这个钩子。这里**同一判据**,
|
|
904
|
+
* 不多不少 ——
|
|
905
|
+
* - 不能少:漏掉任一形态,客户端就又回到「傻等」状态;
|
|
906
|
+
* - 不能多:`max-tokens` 是「回答被截断,已有输出保留」,官方单独渲染成黄色警示,
|
|
907
|
+
* 不是失败(客户端按普通 turn/end 收尾即可);普通取消(用户点了停止)更不是。
|
|
908
|
+
*
|
|
909
|
+
* `code === 'AUTH'` 时内核把 message 抹成空串(`displayFailure`),客户端那边
|
|
910
|
+
* 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
|
|
911
|
+
*/
|
|
912
|
+
function turnFailureOf(event) {
|
|
913
|
+
const reason = isRecord$1(event.data) ? event.data.reason : void 0;
|
|
914
|
+
if (!isRecord$1(reason)) return void 0;
|
|
915
|
+
const kind = reason.kind;
|
|
916
|
+
if (kind === "error") {
|
|
917
|
+
const error = reason.error;
|
|
918
|
+
if (!isRecord$1(error)) return { message: String(error ?? "本轮运行失败") };
|
|
919
|
+
const code = typeof error.code === "string" ? error.code : void 0;
|
|
920
|
+
const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
|
|
921
|
+
return code === void 0 ? { message } : {
|
|
922
|
+
message,
|
|
923
|
+
code
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (kind === "aborted" && isRecord$1(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
|
|
927
|
+
message: "Stopped because you signed out of DeepSeek.",
|
|
928
|
+
code: "ACCOUNT_SIGNED_OUT",
|
|
929
|
+
stopped: true
|
|
930
|
+
};
|
|
931
|
+
}
|
|
851
932
|
function isRecord$1(value) {
|
|
852
933
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
853
934
|
}
|
|
@@ -1732,6 +1813,194 @@ function sessionIdOf(summary) {
|
|
|
1732
1813
|
return typeof id === "string" ? id : void 0;
|
|
1733
1814
|
}
|
|
1734
1815
|
/**
|
|
1816
|
+
* 内核在 prompt 准入里会因为「模型看不到图」拒收整条消息
|
|
1817
|
+
* (`MODEL_DOES_NOT_SUPPORT_IMAGES`)。手机端拿不到桌面 WebUI 那套
|
|
1818
|
+
* 「粘贴即落成临时文件、路径进输入框」的浏览器半边,所以图片在手机上
|
|
1819
|
+
* **直接发不出去** —— 而用户明明装了识图插件。
|
|
1820
|
+
*
|
|
1821
|
+
* 这里补上手机端缺的那一步,但**刻意不引用任何插件的 API**:
|
|
1822
|
+
* 判定用内核自己的 `llm.resolveModelInfo()`(与内核拒收时查的是同一份元数据),
|
|
1823
|
+
* 动作只是「图片落成文件 + 消息里给路径」—— 任何「按路径读图」的工具都能接手。
|
|
1824
|
+
* 哪天换插件、甚至不用插件,这段代码都不需要改。
|
|
1825
|
+
*
|
|
1826
|
+
* 三条保守原则(宁可退回原样、也不要自作聪明):
|
|
1827
|
+
* 1. 元数据拿不到 / 解析不出模型 → **不动**(内核这时也不会拒,交给它);
|
|
1828
|
+
* 2. 模型声明了 image → **不动**(原生图片能显示缩略图,别抢它的活);
|
|
1829
|
+
* 3. 落盘失败 → **不动**(退回内核自己的报错,至少用户能看懂发生了什么)。
|
|
1830
|
+
*
|
|
1831
|
+
* 两个落点(按优先级):
|
|
1832
|
+
* - **会话工作目录下的 `.dsh-images/`**(首选):文件在用户的项目里,
|
|
1833
|
+
* 人和 agent 都找得到,也符合「产物放工作目录、别塞进 DSH 私有目录」;
|
|
1834
|
+
* - 解析不出工作目录时退回 DSH 目录([DEFAULT_IMAGE_HANDOFF_DIR])——
|
|
1835
|
+
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1836
|
+
*/
|
|
1837
|
+
const HANDOFF_SUBDIR = ".dsh-images";
|
|
1838
|
+
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
1839
|
+
const IMAGE_EXTENSIONS = {
|
|
1840
|
+
"image/png": "png",
|
|
1841
|
+
"image/jpeg": "jpg",
|
|
1842
|
+
"image/webp": "webp",
|
|
1843
|
+
"image/gif": "gif",
|
|
1844
|
+
"image/heic": "heic",
|
|
1845
|
+
"image/heif": "heif",
|
|
1846
|
+
"image/bmp": "bmp",
|
|
1847
|
+
"image/avif": "avif"
|
|
1848
|
+
};
|
|
1849
|
+
/** 扩展名优先按声明的 mediaType,认不出再从魔数嗅,最后 .bin。 */
|
|
1850
|
+
function handoffExtension(mediaType, bytes) {
|
|
1851
|
+
const declared = IMAGE_EXTENSIONS[mediaType.trim().toLowerCase()];
|
|
1852
|
+
if (declared !== void 0) return declared;
|
|
1853
|
+
if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80) return "png";
|
|
1854
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpg";
|
|
1855
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "webp";
|
|
1856
|
+
if (bytes.length >= 6 && bytes.toString("ascii", 0, 3) === "GIF") return "gif";
|
|
1857
|
+
return "bin";
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 把一张内联图片写成文件,返回 `{ absolute, relative }`。
|
|
1861
|
+
*
|
|
1862
|
+
* 文件名用**内容哈希**:同一张图重复发不会堆副本(与内核附件存储同一思路),
|
|
1863
|
+
* 也让「同一路径 → 同一内容」对下游工具成立(可安全缓存)。
|
|
1864
|
+
* `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
|
|
1865
|
+
* 目录 0700、文件 0600:这些字节可能包含用户隐私。
|
|
1866
|
+
*/
|
|
1867
|
+
function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
1868
|
+
const bytes = Buffer.from(data, "base64");
|
|
1869
|
+
if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
|
|
1870
|
+
mkdirSync(dir, {
|
|
1871
|
+
recursive: true,
|
|
1872
|
+
mode: 448
|
|
1873
|
+
});
|
|
1874
|
+
const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
|
|
1875
|
+
const absolute = join(dir, fileName);
|
|
1876
|
+
if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
|
|
1877
|
+
return {
|
|
1878
|
+
absolute,
|
|
1879
|
+
relative: workspace !== void 0 && absolute.startsWith(workspace + "/") ? absolute.slice(workspace.length + 1) : fileName
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
1883
|
+
const HANDOFF_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
1884
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
1885
|
+
png: "image/png",
|
|
1886
|
+
jpg: "image/jpeg",
|
|
1887
|
+
jpeg: "image/jpeg",
|
|
1888
|
+
webp: "image/webp",
|
|
1889
|
+
gif: "image/gif",
|
|
1890
|
+
heic: "image/heic",
|
|
1891
|
+
heif: "image/heif",
|
|
1892
|
+
bmp: "image/bmp",
|
|
1893
|
+
avif: "image/avif"
|
|
1894
|
+
};
|
|
1895
|
+
/**
|
|
1896
|
+
* 读出一张交接图片的字节(供手机端把它**显示**出来)。
|
|
1897
|
+
*
|
|
1898
|
+
* 只接受裸文件名,目录永远由「会话工作目录 + `.dsh-images`」(或兜底目录)推出,
|
|
1899
|
+
* 客户端给绝对路径也不认 —— 否则这条读文件的口子会变成任意文件读取。
|
|
1900
|
+
* 工作目录与 [writeHandoffFile] 用的是同一套解析,保证「写在哪、从哪读」。
|
|
1901
|
+
*/
|
|
1902
|
+
async function readHandoffImageFile(host, roots, fallbackDir, sessionId, name) {
|
|
1903
|
+
const fileName = name.split(/[/\\]/).pop() ?? name;
|
|
1904
|
+
const match = HANDOFF_NAME.exec(fileName);
|
|
1905
|
+
if (match === null) throw new Error("BAD_HANDOFF_NAME");
|
|
1906
|
+
const ext = (match[1] ?? "").toLowerCase();
|
|
1907
|
+
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1908
|
+
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1909
|
+
const target = [...workspace !== void 0 ? [join(workspace, HANDOFF_SUBDIR, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
|
|
1910
|
+
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
1911
|
+
return {
|
|
1912
|
+
bytes: new Uint8Array(readFileSync(target)),
|
|
1913
|
+
mediaType
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* 解析会话当前的工作目录(绝对路径),冷会话也能用。
|
|
1918
|
+
*
|
|
1919
|
+
* 不走 live agent(冷会话没有 agent):直接用 `sessionController.list()`
|
|
1920
|
+
* 里那条会话的 `cwd`(相对授权根的 wire 路径),再与数据库里的根拼出真实路径。
|
|
1921
|
+
* 解析不出来(没有授权根 / cwd 不在任何根下)就返回 undefined —— 交给上层兜底。
|
|
1922
|
+
*/
|
|
1923
|
+
async function sessionWorkspaceDir(host, roots, sessionId) {
|
|
1924
|
+
if (roots.length === 0) return void 0;
|
|
1925
|
+
const item = (await host.sessionController.list({}, new AbortController().signal)).items?.find((entry) => entry.sessionId === sessionId);
|
|
1926
|
+
const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
|
|
1927
|
+
if (cwd === void 0) return void 0;
|
|
1928
|
+
for (const root of roots) {
|
|
1929
|
+
const relative = wireRelative(root.realPath, cwd);
|
|
1930
|
+
if (relative !== void 0) return join(root.realPath, relative);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
|
|
1934
|
+
async function currentModelSelection(host, sessionId) {
|
|
1935
|
+
const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
|
|
1936
|
+
const catalog = await host.sessionController.modelCatalog();
|
|
1937
|
+
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
1938
|
+
const provider = current?.provider;
|
|
1939
|
+
const model = current?.model;
|
|
1940
|
+
if (typeof provider !== "string" || typeof model !== "string") return void 0;
|
|
1941
|
+
return {
|
|
1942
|
+
provider,
|
|
1943
|
+
model
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* 内核会不会因为「这个模型看不到图」拒收?判据与内核**逐字一致**:
|
|
1948
|
+
* `inputModalities` 已声明且不含 `image`。
|
|
1949
|
+
*
|
|
1950
|
+
* 元数据缺失(`undefined`)**不算**「确认看不到图」—— 内核在那种情况下也不拒,
|
|
1951
|
+
* 我们跟着不插手(拿不准就走原路)。
|
|
1952
|
+
*/
|
|
1953
|
+
async function modelIsConfirmedTextOnly(host, sessionId) {
|
|
1954
|
+
const selection = await currentModelSelection(host, sessionId);
|
|
1955
|
+
if (selection === void 0) return false;
|
|
1956
|
+
const modalities = (await host.llm.resolveModelInfo(selection.provider, selection.model))?.inputModalities;
|
|
1957
|
+
if (!Array.isArray(modalities)) return false;
|
|
1958
|
+
return !modalities.includes("image");
|
|
1959
|
+
}
|
|
1960
|
+
/** 从 `Accept-Language` 里挑一个我们支持的语种;认不出来就是中文。 */
|
|
1961
|
+
function handoffLocaleOf(acceptLanguage) {
|
|
1962
|
+
if (acceptLanguage === void 0) return "zh";
|
|
1963
|
+
for (const tag of acceptLanguage.split(",")) {
|
|
1964
|
+
const language = (tag.split(";")[0] ?? "").trim().toLowerCase();
|
|
1965
|
+
if (language.startsWith("zh")) return "zh";
|
|
1966
|
+
if (language.startsWith("en")) return "en";
|
|
1967
|
+
}
|
|
1968
|
+
return "zh";
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* 交接说明的第一行(给人看的那行)。
|
|
1972
|
+
*
|
|
1973
|
+
* 写的是**人话**,不是挂在用户眼前的祈使句 —— 早期版本写「请先用读图工具打开下面那行的
|
|
1974
|
+
* 绝对路径」,那是写给机器看的措辞,用户看了的反馈是看不懂。现在一句话说清两件事:
|
|
1975
|
+
* 模型看不到图、图存在哪;要看图该怎么办。语言跟随界面(见 [handoffLocaleOf])。
|
|
1976
|
+
* 第二行的 `[Image: source: …]` 是机器契约,跨语言不变。
|
|
1977
|
+
*/
|
|
1978
|
+
function handoffLine(index, name, relative, locale) {
|
|
1979
|
+
if (locale === "en") return `🖼️ Image ${index} "${name}" (this model can't see images, saved to ${relative}; to view it, open the path below with an image-reading tool)`;
|
|
1980
|
+
return `🖼️ 图片 ${index}「${name}」(当前模型看不到图,已存到 ${relative};要看图,请用识图工具打开下面那行的路径)`;
|
|
1981
|
+
}
|
|
1982
|
+
async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, locale, sessionId, content) {
|
|
1983
|
+
if (!content.some((part) => part.type === "image")) return content;
|
|
1984
|
+
try {
|
|
1985
|
+
if (!await modelIsConfirmedTextOnly(host, sessionId)) return content;
|
|
1986
|
+
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1987
|
+
const dir = workspace !== void 0 ? join(workspace, HANDOFF_SUBDIR) : fallbackDir;
|
|
1988
|
+
let index = 0;
|
|
1989
|
+
return content.map((part) => {
|
|
1990
|
+
if (part.type !== "image") return part;
|
|
1991
|
+
index += 1;
|
|
1992
|
+
const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
1993
|
+
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
|
|
1994
|
+
return {
|
|
1995
|
+
type: "text",
|
|
1996
|
+
text: [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n")
|
|
1997
|
+
};
|
|
1998
|
+
});
|
|
1999
|
+
} catch {
|
|
2000
|
+
return content;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
1735
2004
|
* Build the `apiProxy` replacement out of 0.2.x host services.
|
|
1736
2005
|
*
|
|
1737
2006
|
* Every method keeps the old `{ rpcId, payload }` request shape and the old
|
|
@@ -1739,12 +2008,15 @@ function sessionIdOf(summary) {
|
|
|
1739
2008
|
* `chat-adapter.ts` and `settings-adapter.ts` are written against.
|
|
1740
2009
|
*
|
|
1741
2010
|
* @param host - host services resolved from the plugin context.
|
|
2011
|
+
* @param host - host services resolved from the plugin context.
|
|
1742
2012
|
* @returns a `SettingsApiProxy` (i.e. `DshApiProxy` plus the settings surfaces).
|
|
1743
2013
|
*/
|
|
1744
|
-
function createDsh02ApiProxy(host) {
|
|
2014
|
+
function createDsh02ApiProxy(host, options = {}) {
|
|
1745
2015
|
const mux = new FrameHub();
|
|
1746
2016
|
const hostFrames = new FrameHub();
|
|
1747
|
-
const
|
|
2017
|
+
const imageHandoff = options.imageHandoff !== false;
|
|
2018
|
+
const handoffRoots = options.handoffRoots ?? [];
|
|
2019
|
+
const imageHandoffDir = options.imageHandoffDir ?? DEFAULT_IMAGE_HANDOFF_DIR;
|
|
1748
2020
|
const fileUploadService = optionalService(host, "fileUploads");
|
|
1749
2021
|
const approvals = new ApprovalBridge(host, mux);
|
|
1750
2022
|
approvals.attach();
|
|
@@ -1846,14 +2118,17 @@ function createDsh02ApiProxy(host) {
|
|
|
1846
2118
|
}),
|
|
1847
2119
|
prompt: (request) => call(async () => {
|
|
1848
2120
|
const requestId = randomUUID();
|
|
2121
|
+
const { locale, ...kernelPayload } = request.payload;
|
|
1849
2122
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
2123
|
+
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host, handoffRoots, imageHandoffDir, locale ?? "zh", request.payload.sessionId, request.payload.content) : request.payload.content;
|
|
1850
2124
|
const binding = receiptIds.length === 0 ? void 0 : await (async () => {
|
|
1851
2125
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
1852
2126
|
return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
|
|
1853
2127
|
})();
|
|
1854
2128
|
await host.sessionController.prompt({
|
|
1855
2129
|
requestId,
|
|
1856
|
-
...
|
|
2130
|
+
...kernelPayload,
|
|
2131
|
+
content
|
|
1857
2132
|
}, new AbortController().signal);
|
|
1858
2133
|
binding?.commit();
|
|
1859
2134
|
return { accepted: true };
|
|
@@ -1870,13 +2145,20 @@ function createDsh02ApiProxy(host) {
|
|
|
1870
2145
|
};
|
|
1871
2146
|
}),
|
|
1872
2147
|
readAttachment: (request) => call(async () => {
|
|
1873
|
-
|
|
1874
|
-
const
|
|
2148
|
+
const { sessionId, attachmentId } = request.payload;
|
|
2149
|
+
const value = await host.sessionController.attachment({
|
|
2150
|
+
sessionId,
|
|
2151
|
+
attachmentId
|
|
2152
|
+
});
|
|
1875
2153
|
return {
|
|
1876
|
-
bytes:
|
|
1877
|
-
mediaType:
|
|
2154
|
+
bytes: new Uint8Array(Buffer.from(value.data, "base64")),
|
|
2155
|
+
mediaType: value.attachment.mediaType
|
|
1878
2156
|
};
|
|
1879
2157
|
}),
|
|
2158
|
+
readHandoffImage: (request) => call(async () => {
|
|
2159
|
+
const { sessionId, name } = request.payload;
|
|
2160
|
+
return readHandoffImageFile(host, handoffRoots, imageHandoffDir, sessionId, name);
|
|
2161
|
+
}),
|
|
1880
2162
|
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
1881
2163
|
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
1882
2164
|
fork: (request) => call(() => host.sessionController.fork(request.payload)),
|
|
@@ -1888,7 +2170,7 @@ function createDsh02ApiProxy(host) {
|
|
|
1888
2170
|
const catalog = await host.sessionController.modelCatalog();
|
|
1889
2171
|
const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
|
|
1890
2172
|
return {
|
|
1891
|
-
current: selection?.
|
|
2173
|
+
current: selection?.next ?? selection?.lastUsed ?? catalog.default,
|
|
1892
2174
|
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
1893
2175
|
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
1894
2176
|
failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
|
|
@@ -3087,7 +3369,8 @@ var ApiRouter = class {
|
|
|
3087
3369
|
}
|
|
3088
3370
|
const parts = readPromptParts(body);
|
|
3089
3371
|
const mode = body.mode === "steer" ? "steer" : "queue";
|
|
3090
|
-
const
|
|
3372
|
+
const acceptLanguage = req.headers["accept-language"];
|
|
3373
|
+
const response = { message: await this.options.chat.prompt(principal, sessionId, parts, mode, typeof body.clientTimeZone === "string" ? body.clientTimeZone : void 0, handoffLocaleOf(Array.isArray(acceptLanguage) ? acceptLanguage[0] : acceptLanguage)) };
|
|
3091
3374
|
this.saveIdempotent(principal, body.clientRequestId, 202, response);
|
|
3092
3375
|
this.audit(principal, "chat.message.submit", void 0, void 0, requestId, {
|
|
3093
3376
|
sessionId,
|
|
@@ -3101,7 +3384,9 @@ var ApiRouter = class {
|
|
|
3101
3384
|
if (attachmentRoute !== null && method === "GET") {
|
|
3102
3385
|
this.options.auth.requireScope(principal, "files.read");
|
|
3103
3386
|
const attachmentId = decodeURIComponent(attachmentRoute[1]);
|
|
3104
|
-
const
|
|
3387
|
+
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
3388
|
+
if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
|
|
3389
|
+
const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
|
|
3105
3390
|
res.statusCode = 200;
|
|
3106
3391
|
res.setHeader("Content-Type", mediaType);
|
|
3107
3392
|
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
@@ -3109,6 +3394,19 @@ var ApiRouter = class {
|
|
|
3109
3394
|
res.end(Buffer.from(bytes));
|
|
3110
3395
|
return;
|
|
3111
3396
|
}
|
|
3397
|
+
if (pathname === "/api/v1/image-handoff" && method === "GET") {
|
|
3398
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
3399
|
+
const name = url.searchParams.get("name")?.trim();
|
|
3400
|
+
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
3401
|
+
if (!name || !sessionId) throw new ApiError(400, "BODY_INVALID", "name and sessionId query parameters are required.");
|
|
3402
|
+
const { bytes, mediaType } = await this.options.chat.readHandoffImage(principal, sessionId, name);
|
|
3403
|
+
res.statusCode = 200;
|
|
3404
|
+
res.setHeader("Content-Type", mediaType);
|
|
3405
|
+
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
3406
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
3407
|
+
res.end(Buffer.from(bytes));
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3112
3410
|
const attachmentsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/attachments$/);
|
|
3113
3411
|
if (attachmentsRoute !== null && method === "POST") {
|
|
3114
3412
|
this.options.auth.requireScope(principal, "chat.write");
|
|
@@ -4365,7 +4663,13 @@ async function apply(ctx, config = {}) {
|
|
|
4365
4663
|
const events = new WorkspaceEventBus();
|
|
4366
4664
|
const auth = new AuthService(database);
|
|
4367
4665
|
const files = new FileService(database, (event) => events.emit(event));
|
|
4368
|
-
const apiProxy = createDsh02ApiProxy(ctx
|
|
4666
|
+
const apiProxy = createDsh02ApiProxy(ctx, {
|
|
4667
|
+
handoffRoots: database.listRoots().map((root) => ({
|
|
4668
|
+
id: root.id,
|
|
4669
|
+
realPath: root.realPath
|
|
4670
|
+
})),
|
|
4671
|
+
imageHandoffDir: path.join(dataDir, "image-handoff")
|
|
4672
|
+
});
|
|
4369
4673
|
const chat = new DshChatAdapter(apiProxy, database, {
|
|
4370
4674
|
agents: ctx.agents,
|
|
4371
4675
|
commands: ctx.commands
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-bridge",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
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",
|