dsh-remote-bridge 2.2.3 → 2.3.0
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/README.en.md +27 -10
- package/README.md +32 -18
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +2 -2
- package/kotlin-sdk/build.gradle.kts +1 -1
- package/lib/client.cjs +960 -280
- package/lib/index.d.ts +1078 -940
- package/lib/index.js +511 -57
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path, { join } from "node:path";
|
|
2
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
2
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import os, { homedir, hostname, networkInterfaces } from "node:os";
|
|
@@ -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
|
}
|
|
@@ -1012,20 +1013,32 @@ var WorkspaceDatabase = class {
|
|
|
1012
1013
|
close() {
|
|
1013
1014
|
this.db.close();
|
|
1014
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* 导出 `roots` 的建表语句(只读,诊断与迁移测试用)。
|
|
1018
|
+
*
|
|
1019
|
+
* 存在的理由:迁移要判断「UNIQUE 还在不在」,单看 `PRAGMA table_info` 看不出来 ——
|
|
1020
|
+
* 表结构读出来一模一样,区别在约束上。这个方法让「重建真的发生了」可以被断言,
|
|
1021
|
+
* 而不是靠迁移没报错来推测。
|
|
1022
|
+
*/
|
|
1023
|
+
dumpSchema() {
|
|
1024
|
+
return this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'roots'").get()?.sql ?? "";
|
|
1025
|
+
}
|
|
1015
1026
|
getSetting(key) {
|
|
1016
1027
|
return this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value;
|
|
1017
1028
|
}
|
|
1018
1029
|
setSetting(key, value) {
|
|
1019
1030
|
this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
1020
1031
|
}
|
|
1021
|
-
async addRoot(inputPath, label) {
|
|
1032
|
+
async addRoot(inputPath, label, options) {
|
|
1022
1033
|
const realPath = await canonicalRoot(inputPath);
|
|
1023
|
-
|
|
1024
|
-
|
|
1034
|
+
if (options?.allowDuplicate !== true) {
|
|
1035
|
+
const duplicate = this.db.prepare("SELECT * FROM roots WHERE real_path = ?").get(realPath);
|
|
1036
|
+
if (duplicate !== void 0) return rootFromRow(duplicate);
|
|
1037
|
+
}
|
|
1025
1038
|
const id = randomUUID();
|
|
1026
1039
|
const createdAt = Date.now();
|
|
1027
1040
|
const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
|
|
1028
|
-
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at) VALUES(
|
|
1041
|
+
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
|
|
1029
1042
|
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
|
|
1030
1043
|
return {
|
|
1031
1044
|
id,
|
|
@@ -1035,13 +1048,52 @@ var WorkspaceDatabase = class {
|
|
|
1035
1048
|
};
|
|
1036
1049
|
}
|
|
1037
1050
|
listRoots() {
|
|
1038
|
-
return this.db.prepare("SELECT * FROM roots ORDER BY created_at, id").all().map(rootFromRow);
|
|
1051
|
+
return this.db.prepare("SELECT * FROM roots ORDER BY builtin DESC, created_at, id").all().map(rootFromRow);
|
|
1039
1052
|
}
|
|
1040
1053
|
getRoot(id) {
|
|
1041
1054
|
const row = this.db.prepare("SELECT * FROM roots WHERE id = ?").get(id);
|
|
1042
1055
|
return row === void 0 ? void 0 : rootFromRow(row);
|
|
1043
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* 登记「DSH 工作区」根 —— **幂等**。
|
|
1059
|
+
*
|
|
1060
|
+
* 用户要求:当前 DSH 的工作目录要显示成一个授权根,**不可删除、但可以正常访问**。
|
|
1061
|
+
* 它不是另起一张表,而是**一条普通的根**加一个 `builtin` 标记:配对、`files.*`
|
|
1062
|
+
* 授权、回收站、写盘全走现成逻辑,少一套需要同步维护的规则。
|
|
1063
|
+
*
|
|
1064
|
+
* ## 同目录已有一条普通根时,另起一条而不是复用它
|
|
1065
|
+
*
|
|
1066
|
+
* 这是实测踩到的现实情况:用户早于这个功能**自己**加过同一条目录(本机那条是
|
|
1067
|
+
* 10-08 手工加的),路径和 DSH 工作区一模一样。
|
|
1068
|
+
*
|
|
1069
|
+
* 初版在这里「尊重用户选择」直接返回,结果就是**内置项永远登记不上** —— 因为它
|
|
1070
|
+
* 每次都撞上那条已有的普通根然后什么都不做。界面上那个「不可删除」也就永远出不来。
|
|
1071
|
+
* 用户的选择和功能的正确性在这里是冲突的:用户要的是「这条要不可删」,不是
|
|
1072
|
+
* 「这条不能变」。
|
|
1073
|
+
*
|
|
1074
|
+
* 所以改成**另起一条**:用户原来那条保持可删(那是他的目录,他有权删),内置那条
|
|
1075
|
+
* 独立存在。代价是列表里同一个路径出现两行 —— 这是「两条记录、两种权限」的必然
|
|
1076
|
+
* 结果,比让功能失效或擅自改他的配置都更可接受。
|
|
1077
|
+
*
|
|
1078
|
+
* 幂等不再靠 UNIQUE(它已经被去掉了),而是靠「已存在 `builtin = 1` 的同路径就
|
|
1079
|
+
* 直接返回」。插件**每次启动都可以无脑调一次**。
|
|
1080
|
+
*
|
|
1081
|
+
* 只有传入路径确实存在才登记:工作区可能被用户删掉,这时留一条指向虚空的授权根,
|
|
1082
|
+
* 手机浏览时会一路报错,比不显示更糟。
|
|
1083
|
+
*/
|
|
1084
|
+
async ensureBuiltinRoot(realPath, label) {
|
|
1085
|
+
const builtin = this.db.prepare("SELECT * FROM roots WHERE real_path = ? AND builtin = 1").get(realPath);
|
|
1086
|
+
if (builtin !== void 0) return rootFromRow(builtin);
|
|
1087
|
+
const created = await this.addRoot(realPath, label, { allowDuplicate: true });
|
|
1088
|
+
this.db.prepare("UPDATE roots SET builtin = 1 WHERE id = ?").run(created.id);
|
|
1089
|
+
this.audit("admin", "loopback-admin", "root.builtin", created.id, void 0, { label });
|
|
1090
|
+
return {
|
|
1091
|
+
...created,
|
|
1092
|
+
builtin: true
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1044
1095
|
removeRoot(id) {
|
|
1096
|
+
if (this.getRoot(id)?.builtin === true) throw new ApiError(403, "ROOT_BUILTIN", "The DSH workspace root cannot be removed.");
|
|
1045
1097
|
if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
1046
1098
|
this.audit("admin", "loopback-admin", "root.remove", id);
|
|
1047
1099
|
}
|
|
@@ -1120,7 +1172,7 @@ var WorkspaceDatabase = class {
|
|
|
1120
1172
|
};
|
|
1121
1173
|
}
|
|
1122
1174
|
listDevices() {
|
|
1123
|
-
const rows = this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
1175
|
+
const rows = this.db.prepare("SELECT * FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC").all();
|
|
1124
1176
|
const rootQuery = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id");
|
|
1125
1177
|
return rows.map((row) => ({
|
|
1126
1178
|
id: row.id,
|
|
@@ -1158,9 +1210,60 @@ var WorkspaceDatabase = class {
|
|
|
1158
1210
|
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
|
|
1159
1211
|
return device;
|
|
1160
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* 撤销设备 = **直接删除这一行**(用户要求:已撤销的不显示,并自动从数据库中删除)。
|
|
1215
|
+
*
|
|
1216
|
+
* 原先是打 `revoked_at` 标记保留行,两处都不好:
|
|
1217
|
+
* - 界面上会出现一长串灰掉的「已撤销」,本机实测堆到了 32 条,全是历史测试与旧设备;
|
|
1218
|
+
* - 行留着就等于凭据的哈希还在库里,是个长期负担 —— 令牌已经没用了,留着没有意义。
|
|
1219
|
+
*
|
|
1220
|
+
* 删除是安全的,因为:
|
|
1221
|
+
* - `device_roots` 是 `ON DELETE CASCADE`,授权行自动清掉;
|
|
1222
|
+
* - `audit.actor_id` 是**普通字符串不是外键**,审计留痕(谁在什么时候撤了谁)一条不少;
|
|
1223
|
+
* - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
|
|
1224
|
+
* 它们依然登不进来 —— 两层保险不冲突。
|
|
1225
|
+
*/
|
|
1161
1226
|
revokeDevice(id) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1227
|
+
const active = this.getActiveDevice(id);
|
|
1228
|
+
if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1229
|
+
this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
|
|
1230
|
+
deviceId: id,
|
|
1231
|
+
name: active.name
|
|
1232
|
+
});
|
|
1233
|
+
if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
|
|
1237
|
+
*
|
|
1238
|
+
* 撤销改成直接删除之前,每次撤销都只是打个标记,于是库越用越臃肿 —— 本机实测堆到
|
|
1239
|
+
* **32 条**,全是历史测试探针和早已弃用的旧手机,界面上就是一长串灰字。
|
|
1240
|
+
*
|
|
1241
|
+
* 幂等:没有这种行时返回 0,不报错。插件每次启动调一次即可。
|
|
1242
|
+
* 走 `revokeDevice` 的删除语义,但**不能**借它写 `device.revoke` 审计 ——
|
|
1243
|
+
* 那会把「系统清理」记成管理员逐个撤销,审计会失真。清理本身只写一条汇总。
|
|
1244
|
+
*/
|
|
1245
|
+
/**
|
|
1246
|
+
* 仅供测试:造一行「撤销改成删除之前」的标记式撤销数据。
|
|
1247
|
+
*
|
|
1248
|
+
* 真实场景来自历史库(每次撤销只打 `revoked_at` 标记,于是越积越多)。测试需要个
|
|
1249
|
+
* 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
|
|
1250
|
+
* 并起一个自见其明的名字,免得有人把它当成正常 API。
|
|
1251
|
+
*/
|
|
1252
|
+
markRevokedForTest(id) {
|
|
1253
|
+
this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
|
|
1254
|
+
}
|
|
1255
|
+
purgeRevokedDevices() {
|
|
1256
|
+
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) return 0;
|
|
1257
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1258
|
+
try {
|
|
1259
|
+
const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
|
|
1260
|
+
this.db.exec("COMMIT");
|
|
1261
|
+
this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
|
|
1262
|
+
return Number(result.changes);
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
this.db.exec("ROLLBACK");
|
|
1265
|
+
throw error;
|
|
1266
|
+
}
|
|
1164
1267
|
}
|
|
1165
1268
|
insertTrash(record) {
|
|
1166
1269
|
this.db.prepare("INSERT INTO trash(id,root_id,relative_path,payload_path,kind,size,digest,created_at,status) VALUES(?,?,?,?,?,?,?,?,?)").run(record.id, record.rootId, record.relativePath, record.payloadPath, record.kind, record.size, record.digest, record.createdAt, record.status);
|
|
@@ -1198,7 +1301,7 @@ var WorkspaceDatabase = class {
|
|
|
1198
1301
|
this.db.exec(`
|
|
1199
1302
|
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
1200
1303
|
CREATE TABLE IF NOT EXISTS roots(
|
|
1201
|
-
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL
|
|
1304
|
+
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
1202
1305
|
);
|
|
1203
1306
|
CREATE TABLE IF NOT EXISTS devices(
|
|
1204
1307
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
|
@@ -1229,6 +1332,50 @@ var WorkspaceDatabase = class {
|
|
|
1229
1332
|
CREATE INDEX IF NOT EXISTS audit_time_idx ON audit(time DESC);
|
|
1230
1333
|
CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
|
|
1231
1334
|
`);
|
|
1335
|
+
if (!this.hasRootColumn("builtin")) this.db.exec("ALTER TABLE roots ADD COLUMN builtin INTEGER NOT NULL DEFAULT 0");
|
|
1336
|
+
if (this.rootsPathIsUnique()) this.dropRootsUniqueConstraint();
|
|
1337
|
+
}
|
|
1338
|
+
/** `roots.real_path` 上是否还带 UNIQUE 约束。 */
|
|
1339
|
+
rootsPathIsUnique() {
|
|
1340
|
+
const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'roots'").get();
|
|
1341
|
+
return /real_path\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(row?.sql ?? "");
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* 重建 `roots` 去掉 UNIQUE。
|
|
1345
|
+
*
|
|
1346
|
+
* 这是一次**有真实数据风险**的操作,所以每一步都写清楚理由:
|
|
1347
|
+
* - `PRAGMA foreign_keys=OFF`:`device_roots.root_id` 引用 `roots(id)`,而重建要
|
|
1348
|
+
* DROP 掉被引用的那张表。开着外键会直接失败。
|
|
1349
|
+
* - 事务:`new_roots` 建好、数据搬完、**最后**才 DROP 老表并改名。中途失败会整体
|
|
1350
|
+
* 回滚,原表原封不动。
|
|
1351
|
+
* - 搬数据用显式列名而不是 `SELECT *`:老表可能还没有 `builtin` 列(两种迁移顺序
|
|
1352
|
+
* 都可能出现),按列名搬才不会在某一台机器上错位。
|
|
1353
|
+
*/
|
|
1354
|
+
dropRootsUniqueConstraint() {
|
|
1355
|
+
const columns = this.hasRootColumn("builtin") ? "id, label, real_path, created_at, builtin" : "id, label, real_path, created_at, 0 AS builtin";
|
|
1356
|
+
this.db.exec("PRAGMA foreign_keys=OFF");
|
|
1357
|
+
try {
|
|
1358
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1359
|
+
this.db.exec(`
|
|
1360
|
+
CREATE TABLE roots_migrated(
|
|
1361
|
+
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL,
|
|
1362
|
+
created_at INTEGER NOT NULL, builtin INTEGER NOT NULL DEFAULT 0
|
|
1363
|
+
);
|
|
1364
|
+
INSERT INTO roots_migrated(${columns}) SELECT ${columns} FROM roots;
|
|
1365
|
+
DROP TABLE roots;
|
|
1366
|
+
ALTER TABLE roots_migrated RENAME TO roots;
|
|
1367
|
+
`);
|
|
1368
|
+
this.db.exec("COMMIT");
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
this.db.exec("ROLLBACK");
|
|
1371
|
+
throw error;
|
|
1372
|
+
} finally {
|
|
1373
|
+
this.db.exec("PRAGMA foreign_keys=ON");
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
/** `roots` 表是否已有指定列。 */
|
|
1377
|
+
hasRootColumn(column) {
|
|
1378
|
+
return this.db.prepare("PRAGMA table_info(roots)").all().some((entry) => entry.name === column);
|
|
1232
1379
|
}
|
|
1233
1380
|
};
|
|
1234
1381
|
function hashSecret(secret) {
|
|
@@ -1250,7 +1397,8 @@ function rootFromRow(row) {
|
|
|
1250
1397
|
id: row.id,
|
|
1251
1398
|
label: row.label,
|
|
1252
1399
|
realPath: row.real_path,
|
|
1253
|
-
createdAt: row.created_at
|
|
1400
|
+
createdAt: row.created_at,
|
|
1401
|
+
...row.builtin === 1 ? { builtin: true } : {}
|
|
1254
1402
|
};
|
|
1255
1403
|
}
|
|
1256
1404
|
function trashFromRow(row) {
|
|
@@ -1267,6 +1415,239 @@ function trashFromRow(row) {
|
|
|
1267
1415
|
};
|
|
1268
1416
|
}
|
|
1269
1417
|
//#endregion
|
|
1418
|
+
//#region src/host/image-handoff.ts
|
|
1419
|
+
/**
|
|
1420
|
+
* 交接目录的**出厂默认**。
|
|
1421
|
+
*
|
|
1422
|
+
* 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
|
|
1423
|
+
* 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
|
|
1424
|
+
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
1425
|
+
*/
|
|
1426
|
+
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
1427
|
+
/** settings 表里的键名。 */
|
|
1428
|
+
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
1429
|
+
/**
|
|
1430
|
+
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
1431
|
+
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
1432
|
+
*/
|
|
1433
|
+
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
1434
|
+
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
1435
|
+
const MAX_SUBDIR_CHARS = 64;
|
|
1436
|
+
/**
|
|
1437
|
+
* 把用户填的目录名收成一个安全值。
|
|
1438
|
+
*
|
|
1439
|
+
* 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
|
|
1440
|
+
* 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
|
|
1441
|
+
* 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
|
|
1442
|
+
*/
|
|
1443
|
+
function normalizeHandoffSubdir(value) {
|
|
1444
|
+
if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
|
|
1445
|
+
const trimmed = value.trim();
|
|
1446
|
+
if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
|
|
1447
|
+
if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
|
|
1448
|
+
if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
|
|
1449
|
+
return trimmed;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* 读出当前生效的目录名。
|
|
1453
|
+
*
|
|
1454
|
+
* 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
|
|
1455
|
+
* 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
|
|
1456
|
+
*/
|
|
1457
|
+
function readHandoffSubdir(database) {
|
|
1458
|
+
const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
|
|
1459
|
+
if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
|
|
1460
|
+
try {
|
|
1461
|
+
return normalizeHandoffSubdir(stored);
|
|
1462
|
+
} catch {
|
|
1463
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
|
|
1464
|
+
return DEFAULT_HANDOFF_SUBDIR;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/** 校验并保存,返回落库后的值。 */
|
|
1468
|
+
function saveHandoffSubdir(database, value) {
|
|
1469
|
+
const normalized = normalizeHandoffSubdir(value);
|
|
1470
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
1471
|
+
return normalized;
|
|
1472
|
+
}
|
|
1473
|
+
/** 设置面板要的那三个值。 */
|
|
1474
|
+
function imageHandoffView(database, fallbackDir) {
|
|
1475
|
+
return {
|
|
1476
|
+
subdir: readHandoffSubdir(database),
|
|
1477
|
+
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
1478
|
+
fallbackDir
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
|
|
1482
|
+
const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
1483
|
+
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
1484
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
1485
|
+
png: "image/png",
|
|
1486
|
+
jpg: "image/jpeg",
|
|
1487
|
+
jpeg: "image/jpeg",
|
|
1488
|
+
webp: "image/webp",
|
|
1489
|
+
gif: "image/gif",
|
|
1490
|
+
heic: "image/heic",
|
|
1491
|
+
heif: "image/heif",
|
|
1492
|
+
bmp: "image/bmp",
|
|
1493
|
+
avif: "image/avif"
|
|
1494
|
+
};
|
|
1495
|
+
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
1496
|
+
const MAX_BYTES = 33554432;
|
|
1497
|
+
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
1498
|
+
const MAX_NAME_CHARS = 4096;
|
|
1499
|
+
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
1500
|
+
function declaredMediaType(name) {
|
|
1501
|
+
const cut = name.lastIndexOf(".");
|
|
1502
|
+
if (cut === -1) return void 0;
|
|
1503
|
+
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
|
|
1507
|
+
* @param head - 文件前至少 12 个字节。
|
|
1508
|
+
*/
|
|
1509
|
+
function sniffMediaType(head) {
|
|
1510
|
+
const ascii = (offset, text) => {
|
|
1511
|
+
if (head.length < offset + text.length) return false;
|
|
1512
|
+
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
1513
|
+
return true;
|
|
1514
|
+
};
|
|
1515
|
+
const bytes = (...expected) => {
|
|
1516
|
+
if (head.length < expected.length) return false;
|
|
1517
|
+
return expected.every((byte, index) => head[index] === byte);
|
|
1518
|
+
};
|
|
1519
|
+
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
1520
|
+
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
1521
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
1522
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
1523
|
+
if (bytes(66, 77)) return "image/bmp";
|
|
1524
|
+
if (ascii(4, "ftyp")) {
|
|
1525
|
+
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
1526
|
+
if (brand.startsWith("avi")) return "image/avif";
|
|
1527
|
+
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
/** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */
|
|
1531
|
+
function loopbackHost(req) {
|
|
1532
|
+
const host = req.headers.host;
|
|
1533
|
+
if (typeof host !== "string" || host === "") return false;
|
|
1534
|
+
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
1535
|
+
const name = host.split(":")[0];
|
|
1536
|
+
return name === "127.0.0.1" || name === "localhost";
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* 请求是否来自某个插件自己服务的页面。
|
|
1540
|
+
*
|
|
1541
|
+
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
1542
|
+
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
1543
|
+
*/
|
|
1544
|
+
function pluginPageReferer(req) {
|
|
1545
|
+
const referer = req.headers.referer;
|
|
1546
|
+
if (typeof referer !== "string" || referer === "") return false;
|
|
1547
|
+
try {
|
|
1548
|
+
const url = new URL(referer);
|
|
1549
|
+
if (url.host !== req.headers.host) return false;
|
|
1550
|
+
return url.pathname.startsWith("/plugin/");
|
|
1551
|
+
} catch {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* 请求是否可以被回答。
|
|
1557
|
+
*
|
|
1558
|
+
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
1559
|
+
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
1560
|
+
*/
|
|
1561
|
+
function sameOrigin(req) {
|
|
1562
|
+
if (!loopbackHost(req)) return false;
|
|
1563
|
+
if (pluginPageReferer(req)) return true;
|
|
1564
|
+
const site = req.headers["sec-fetch-site"];
|
|
1565
|
+
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
1566
|
+
const origin = req.headers.origin;
|
|
1567
|
+
if (typeof origin !== "string" || origin === "") return true;
|
|
1568
|
+
try {
|
|
1569
|
+
return new URL(origin).host === req.headers.host;
|
|
1570
|
+
} catch {
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
1575
|
+
function refuse(res, status, reason) {
|
|
1576
|
+
const body = `${reason}\n`;
|
|
1577
|
+
res.writeHead(status, {
|
|
1578
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1579
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
1580
|
+
"cache-control": "no-store",
|
|
1581
|
+
"x-content-type-options": "nosniff"
|
|
1582
|
+
});
|
|
1583
|
+
res.end(body);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* 这条路由的处理器工厂。
|
|
1587
|
+
* @param options - 取图实现。
|
|
1588
|
+
* @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
|
|
1589
|
+
*/
|
|
1590
|
+
function createImageRouteHandler(options) {
|
|
1591
|
+
return async function serveImage(req, res) {
|
|
1592
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1593
|
+
refuse(res, 405, "method not allowed");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (!sameOrigin(req)) {
|
|
1597
|
+
refuse(res, 403, "cross-site request refused");
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1601
|
+
const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
|
|
1602
|
+
const rawName = url.searchParams.get("name") ?? "";
|
|
1603
|
+
if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
|
|
1604
|
+
refuse(res, 400, "sessionId and name are required");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const name = rawName.split(/[/\\]/).pop() ?? rawName;
|
|
1608
|
+
if (HANDOFF_FILE_NAME.exec(name) === null) {
|
|
1609
|
+
refuse(res, 404, "no such handoff image");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const declared = declaredMediaType(name);
|
|
1613
|
+
let image;
|
|
1614
|
+
try {
|
|
1615
|
+
image = await options.read(sessionId, name);
|
|
1616
|
+
} catch {
|
|
1617
|
+
refuse(res, 404, "no such handoff image");
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (image.bytes.byteLength === 0) {
|
|
1621
|
+
refuse(res, 404, "empty file");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (image.bytes.byteLength > MAX_BYTES) {
|
|
1625
|
+
refuse(res, 413, "image is too large to display");
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
1629
|
+
if (sniffed === void 0) {
|
|
1630
|
+
refuse(res, 415, "content is not a supported image");
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (declared !== void 0 && sniffed !== declared) {
|
|
1634
|
+
refuse(res, 415, "content does not match the file extension");
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
res.writeHead(200, {
|
|
1638
|
+
"content-type": sniffed,
|
|
1639
|
+
"content-length": String(image.bytes.byteLength),
|
|
1640
|
+
"cache-control": "private, max-age=300",
|
|
1641
|
+
"x-content-type-options": "nosniff"
|
|
1642
|
+
});
|
|
1643
|
+
if (req.method === "HEAD") {
|
|
1644
|
+
res.end();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
//#endregion
|
|
1270
1651
|
//#region src/host/dsh-0.2-bridge.ts
|
|
1271
1652
|
/**
|
|
1272
1653
|
* DSH 0.2.x host-service bridge.
|
|
@@ -1833,7 +2214,6 @@ function sessionIdOf(summary) {
|
|
|
1833
2214
|
* - 解析不出工作目录时退回 DSH 目录([DEFAULT_IMAGE_HANDOFF_DIR])——
|
|
1834
2215
|
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1835
2216
|
*/
|
|
1836
|
-
const HANDOFF_SUBDIR = ".dsh-images";
|
|
1837
2217
|
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
1838
2218
|
const IMAGE_EXTENSIONS = {
|
|
1839
2219
|
"image/png": "png",
|
|
@@ -1879,33 +2259,30 @@ function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
|
1879
2259
|
};
|
|
1880
2260
|
}
|
|
1881
2261
|
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
-
};
|
|
2262
|
+
/**
|
|
2263
|
+
* 交接文件名的白名单与媒体类型表现在住在 `image-handoff.ts` —— 设置面板、只读图片路由
|
|
2264
|
+
* 和这里读的是**同一份**判据,分开写两份迟早漂移。这里只是就近取个别名。
|
|
2265
|
+
*/
|
|
2266
|
+
const HANDOFF_NAME = HANDOFF_FILE_NAME;
|
|
1894
2267
|
/**
|
|
1895
2268
|
* 读出一张交接图片的字节(供手机端把它**显示**出来)。
|
|
1896
2269
|
*
|
|
1897
|
-
* 只接受裸文件名,目录永远由「会话工作目录 +
|
|
2270
|
+
* 只接受裸文件名,目录永远由「会话工作目录 + <识图路径>」(或兜底目录)推出,
|
|
1898
2271
|
* 客户端给绝对路径也不认 —— 否则这条读文件的口子会变成任意文件读取。
|
|
1899
|
-
* 工作目录与 [writeHandoffFile]
|
|
2272
|
+
* 工作目录与 [writeHandoffFile] 用的是同一套解析,保证「写在哪、从哪读」;
|
|
2273
|
+
* `<识图路径>` 也来自同一个地方(设置里的当前值),改设置后写读同步跟着走。
|
|
2274
|
+
*
|
|
2275
|
+
* **导出**是给同源只读图片路由(`image-handoff.ts`)用的:那条路由不校验设备 token,
|
|
2276
|
+
* 所以它更需要「目录不由客户端给」这条约束 —— 直接复用这里,而不是另写一套解析。
|
|
1900
2277
|
*/
|
|
1901
|
-
async function readHandoffImageFile(host, roots, fallbackDir, sessionId, name) {
|
|
2278
|
+
async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId, name) {
|
|
1902
2279
|
const fileName = name.split(/[/\\]/).pop() ?? name;
|
|
1903
2280
|
const match = HANDOFF_NAME.exec(fileName);
|
|
1904
2281
|
if (match === null) throw new Error("BAD_HANDOFF_NAME");
|
|
1905
2282
|
const ext = (match[1] ?? "").toLowerCase();
|
|
1906
2283
|
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1907
2284
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1908
|
-
const target = [...workspace !== void 0 ? [join(workspace,
|
|
2285
|
+
const target = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
|
|
1909
2286
|
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
1910
2287
|
return {
|
|
1911
2288
|
bytes: new Uint8Array(readFileSync(target)),
|
|
@@ -1956,32 +2333,43 @@ async function modelIsConfirmedTextOnly(host, sessionId) {
|
|
|
1956
2333
|
if (!Array.isArray(modalities)) return false;
|
|
1957
2334
|
return !modalities.includes("image");
|
|
1958
2335
|
}
|
|
2336
|
+
/** 从 `Accept-Language` 里挑一个我们支持的语种;认不出来就是中文。 */
|
|
2337
|
+
function handoffLocaleOf(acceptLanguage) {
|
|
2338
|
+
if (acceptLanguage === void 0) return "zh";
|
|
2339
|
+
for (const tag of acceptLanguage.split(",")) {
|
|
2340
|
+
const language = (tag.split(";")[0] ?? "").trim().toLowerCase();
|
|
2341
|
+
if (language.startsWith("zh")) return "zh";
|
|
2342
|
+
if (language.startsWith("en")) return "en";
|
|
2343
|
+
}
|
|
2344
|
+
return "zh";
|
|
2345
|
+
}
|
|
1959
2346
|
/**
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
* 同一行路径要同时伺候两类读者:
|
|
1963
|
-
* - **给人看**(第一行):图片序号、原始文件名、工作目录里的短路径;
|
|
1964
|
-
* - **给机器看**(第二行 `[Image: source: <绝对路径>]`):这是读图技能明确
|
|
1965
|
-
* 识别的占位形状之一(带图片扩展名的绝对路径同样触发),任何按路径读图的
|
|
1966
|
-
* 工具都能接手,不出现任何具体插件名。
|
|
2347
|
+
* 交接说明的第一行(给人看的那行)。
|
|
1967
2348
|
*
|
|
1968
|
-
*
|
|
2349
|
+
* 写的是**人话**,不是挂在用户眼前的祈使句 —— 早期版本写「请先用读图工具打开下面那行的
|
|
2350
|
+
* 绝对路径」,那是写给机器看的措辞,用户看了的反馈是看不懂。现在一句话说清两件事:
|
|
2351
|
+
* 模型看不到图、图存在哪;要看图该怎么办。语言跟随界面(见 [handoffLocaleOf])。
|
|
2352
|
+
* 第二行的 `[Image: source: …]` 是机器契约,跨语言不变。
|
|
1969
2353
|
*/
|
|
1970
|
-
|
|
2354
|
+
function handoffLine(index, name, relative, locale) {
|
|
2355
|
+
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)`;
|
|
2356
|
+
return `🖼️ 图片 ${index}「${name}」(当前模型看不到图,已存到 ${relative};要看图,请用识图工具打开下面那行的路径)`;
|
|
2357
|
+
}
|
|
2358
|
+
async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, locale, sessionId, content) {
|
|
1971
2359
|
if (!content.some((part) => part.type === "image")) return content;
|
|
1972
2360
|
try {
|
|
1973
2361
|
if (!await modelIsConfirmedTextOnly(host, sessionId)) return content;
|
|
1974
2362
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1975
|
-
const dir = workspace !== void 0 ? join(workspace,
|
|
2363
|
+
const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
|
|
1976
2364
|
let index = 0;
|
|
1977
2365
|
return content.map((part) => {
|
|
1978
2366
|
if (part.type !== "image") return part;
|
|
1979
2367
|
index += 1;
|
|
1980
2368
|
const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
1981
|
-
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : `图片 ${index}`;
|
|
2369
|
+
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
|
|
1982
2370
|
return {
|
|
1983
2371
|
type: "text",
|
|
1984
|
-
text: [
|
|
2372
|
+
text: [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n")
|
|
1985
2373
|
};
|
|
1986
2374
|
});
|
|
1987
2375
|
} catch {
|
|
@@ -2003,8 +2391,13 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2003
2391
|
const mux = new FrameHub();
|
|
2004
2392
|
const hostFrames = new FrameHub();
|
|
2005
2393
|
const imageHandoff = options.imageHandoff !== false;
|
|
2006
|
-
const
|
|
2394
|
+
const handoffRootsOf = () => {
|
|
2395
|
+
const value = options.handoffRoots;
|
|
2396
|
+
if (value === void 0) return [];
|
|
2397
|
+
return typeof value === "function" ? value() : value;
|
|
2398
|
+
};
|
|
2007
2399
|
const imageHandoffDir = options.imageHandoffDir ?? DEFAULT_IMAGE_HANDOFF_DIR;
|
|
2400
|
+
const handoffSubdirOf = options.handoffSubdir ?? (() => ".dsh-images");
|
|
2008
2401
|
const fileUploadService = optionalService(host, "fileUploads");
|
|
2009
2402
|
const approvals = new ApprovalBridge(host, mux);
|
|
2010
2403
|
approvals.attach();
|
|
@@ -2106,15 +2499,16 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2106
2499
|
}),
|
|
2107
2500
|
prompt: (request) => call(async () => {
|
|
2108
2501
|
const requestId = randomUUID();
|
|
2502
|
+
const { locale, ...kernelPayload } = request.payload;
|
|
2109
2503
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
2110
|
-
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host,
|
|
2504
|
+
const content = imageHandoff ? await handoffImagesForTextOnlyModel(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), locale ?? "zh", request.payload.sessionId, request.payload.content) : request.payload.content;
|
|
2111
2505
|
const binding = receiptIds.length === 0 ? void 0 : await (async () => {
|
|
2112
2506
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
2113
2507
|
return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
|
|
2114
2508
|
})();
|
|
2115
2509
|
await host.sessionController.prompt({
|
|
2116
2510
|
requestId,
|
|
2117
|
-
...
|
|
2511
|
+
...kernelPayload,
|
|
2118
2512
|
content
|
|
2119
2513
|
}, new AbortController().signal);
|
|
2120
2514
|
binding?.commit();
|
|
@@ -2144,7 +2538,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2144
2538
|
}),
|
|
2145
2539
|
readHandoffImage: (request) => call(async () => {
|
|
2146
2540
|
const { sessionId, name } = request.payload;
|
|
2147
|
-
return readHandoffImageFile(host,
|
|
2541
|
+
return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
|
|
2148
2542
|
}),
|
|
2149
2543
|
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
2150
2544
|
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
@@ -3028,7 +3422,7 @@ async function inspectResolvedPath(root, wirePath) {
|
|
|
3028
3422
|
}
|
|
3029
3423
|
//#endregion
|
|
3030
3424
|
//#region src/shared/version.ts
|
|
3031
|
-
const PLUGIN_VERSION = "2.
|
|
3425
|
+
const PLUGIN_VERSION = "2.3.0";
|
|
3032
3426
|
//#endregion
|
|
3033
3427
|
//#region src/host/router.ts
|
|
3034
3428
|
var ApiRouter = class {
|
|
@@ -3356,7 +3750,8 @@ var ApiRouter = class {
|
|
|
3356
3750
|
}
|
|
3357
3751
|
const parts = readPromptParts(body);
|
|
3358
3752
|
const mode = body.mode === "steer" ? "steer" : "queue";
|
|
3359
|
-
const
|
|
3753
|
+
const acceptLanguage = req.headers["accept-language"];
|
|
3754
|
+
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)) };
|
|
3360
3755
|
this.saveIdempotent(principal, body.clientRequestId, 202, response);
|
|
3361
3756
|
this.audit(principal, "chat.message.submit", void 0, void 0, requestId, {
|
|
3362
3757
|
sessionId,
|
|
@@ -3625,6 +4020,17 @@ var ApiRouter = class {
|
|
|
3625
4020
|
sendJson(res, 200, { items: this.options.database.listAudit(optionalInteger(url.searchParams.get("limit")) ?? 200) });
|
|
3626
4021
|
return;
|
|
3627
4022
|
}
|
|
4023
|
+
if (method === "GET" && pathname === "/manage/image") {
|
|
4024
|
+
sendJson(res, 200, imageHandoffView(this.options.database, this.imageFallbackDir()));
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
if (method === "PUT" && pathname === "/manage/image") {
|
|
4028
|
+
const body = await readJson(req, 16384);
|
|
4029
|
+
const subdir = saveHandoffSubdir(this.options.database, body.subdir);
|
|
4030
|
+
this.options.database.audit("admin", "loopback-admin", "image.subdir", void 0, void 0, { subdir }, requestId);
|
|
4031
|
+
sendJson(res, 200, imageHandoffView(this.options.database, this.imageFallbackDir()));
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
3628
4034
|
const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
|
|
3629
4035
|
if (method === "DELETE" && purgeRoute !== null) {
|
|
3630
4036
|
await this.options.files.purgeTrash(decodeURIComponent(purgeRoute[1]));
|
|
@@ -3722,6 +4128,10 @@ var ApiRouter = class {
|
|
|
3722
4128
|
const roots = this.options.database.listRoots();
|
|
3723
4129
|
return principal.rootIds === "all" ? roots : roots.filter((root) => principal.rootIds.has(root.id));
|
|
3724
4130
|
}
|
|
4131
|
+
/** 兜底交接目录:没配就用状态目录下的 `image-handoff`(与 `index.ts` 的默认一致)。 */
|
|
4132
|
+
imageFallbackDir() {
|
|
4133
|
+
return this.options.imageFallbackDir ?? path.join(this.options.database.dataDir, "image-handoff");
|
|
4134
|
+
}
|
|
3725
4135
|
audit(principal, action, rootId, relativePath, requestId, details = {}) {
|
|
3726
4136
|
this.options.database.audit(principal.kind, principal.id, action, rootId, relativePath, details, requestId);
|
|
3727
4137
|
}
|
|
@@ -4199,12 +4609,12 @@ var RemoteApiServer = class {
|
|
|
4199
4609
|
}
|
|
4200
4610
|
}
|
|
4201
4611
|
};
|
|
4202
|
-
function attachEmbeddedRoutes(webServer, router, remote) {
|
|
4612
|
+
function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
4203
4613
|
const disposers = [webServer.register({
|
|
4204
4614
|
kind: "prefix",
|
|
4205
4615
|
path: "/dsh-workspace-api",
|
|
4206
4616
|
handler: (req, res) => router.handle(req, res, "/dsh-workspace-api")
|
|
4207
|
-
})];
|
|
4617
|
+
}), ...extraRoutes.map((route) => webServer.register(route))];
|
|
4208
4618
|
return () => {
|
|
4209
4619
|
for (const dispose of disposers.reverse()) dispose();
|
|
4210
4620
|
};
|
|
@@ -4646,15 +5056,25 @@ async function apply(ctx, config = {}) {
|
|
|
4646
5056
|
const maxUploadBytes = config.maxUploadBytes ?? 20971520;
|
|
4647
5057
|
if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes < 1) throw new Error("dsh-remote-bridge: maxUploadBytes must be positive");
|
|
4648
5058
|
const database = new WorkspaceDatabase(dataDir);
|
|
5059
|
+
try {
|
|
5060
|
+
const purged = database.purgeRevokedDevices();
|
|
5061
|
+
if (purged > 0) ctx.logger.info(`dsh-remote-bridge: purged ${purged} revoked device(s)`);
|
|
5062
|
+
} catch (error) {
|
|
5063
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
|
|
5064
|
+
}
|
|
5065
|
+
await registerBuiltinWorkspaceRoot(ctx, database);
|
|
4649
5066
|
const events = new WorkspaceEventBus();
|
|
4650
5067
|
const auth = new AuthService(database);
|
|
4651
5068
|
const files = new FileService(database, (event) => events.emit(event));
|
|
5069
|
+
const imageHandoffDir = path.join(dataDir, "image-handoff");
|
|
5070
|
+
const handoffRootsOf = () => database.listRoots().map((root) => ({
|
|
5071
|
+
id: root.id,
|
|
5072
|
+
realPath: root.realPath
|
|
5073
|
+
}));
|
|
4652
5074
|
const apiProxy = createDsh02ApiProxy(ctx, {
|
|
4653
|
-
handoffRoots:
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
})),
|
|
4657
|
-
imageHandoffDir: path.join(dataDir, "image-handoff")
|
|
5075
|
+
handoffRoots: handoffRootsOf,
|
|
5076
|
+
imageHandoffDir,
|
|
5077
|
+
handoffSubdir: () => readHandoffSubdir(database)
|
|
4658
5078
|
});
|
|
4659
5079
|
const chat = new DshChatAdapter(apiProxy, database, {
|
|
4660
5080
|
agents: ctx.agents,
|
|
@@ -4678,7 +5098,8 @@ async function apply(ctx, config = {}) {
|
|
|
4678
5098
|
chat,
|
|
4679
5099
|
settings,
|
|
4680
5100
|
remote,
|
|
4681
|
-
maxUploadBytes
|
|
5101
|
+
maxUploadBytes,
|
|
5102
|
+
imageFallbackDir: imageHandoffDir
|
|
4682
5103
|
});
|
|
4683
5104
|
try {
|
|
4684
5105
|
await remote.start();
|
|
@@ -4686,7 +5107,12 @@ async function apply(ctx, config = {}) {
|
|
|
4686
5107
|
database.close();
|
|
4687
5108
|
throw error;
|
|
4688
5109
|
}
|
|
4689
|
-
const
|
|
5110
|
+
const imageRoute = createImageRouteHandler({ read: (sessionId, name) => readHandoffImageFile(ctx, handoffRootsOf(), imageHandoffDir, readHandoffSubdir(database), sessionId, name) });
|
|
5111
|
+
const disposeEmbedded = attachEmbeddedRoutes(ctx.webServer, router, remote, [{
|
|
5112
|
+
kind: "prefix",
|
|
5113
|
+
path: IMAGE_ROUTE,
|
|
5114
|
+
handler: imageRoute
|
|
5115
|
+
}]);
|
|
4690
5116
|
ctx.effect(() => async () => {
|
|
4691
5117
|
disposeEmbedded();
|
|
4692
5118
|
await remote.stop();
|
|
@@ -4695,5 +5121,33 @@ async function apply(ctx, config = {}) {
|
|
|
4695
5121
|
const status = remote.status();
|
|
4696
5122
|
ctx.logger.info(`dsh-remote-bridge v${PLUGIN_VERSION}: ${status.host}:${status.port} (${status.remoteEnabled ? "remote enabled" : "loopback only"})`);
|
|
4697
5123
|
}
|
|
5124
|
+
/**
|
|
5125
|
+
* 登记「DSH 工作区」为内置授权根。
|
|
5126
|
+
*
|
|
5127
|
+
* 取 `ctx.workspaceRegistry.list()` 的**第一个**工作区(用户明确要「只显示第一个」),
|
|
5128
|
+
* 登记成一条 `builtin` 根。四个必须静默失败的情形 —— 插件启动不该因为一个「锦上添花的
|
|
5129
|
+
* 默认根」而整个起不来:
|
|
5130
|
+
*
|
|
5131
|
+
* - 没有工作区(用户从没建过):什么都不做;
|
|
5132
|
+
* - 第一个工作区的路径已经不存在(用户把它删了):什么都不做,否则留下一条指向虚空的
|
|
5133
|
+
* 授权根,手机浏览时一路报错,比不显示更糟;
|
|
5134
|
+
* - 注册表自己抛了:同上,记一条 warn 就走。
|
|
5135
|
+
*
|
|
5136
|
+
* 目录存在性用 `stat` 判,且必须是**目录**而不是文件 —— `canonicalRoot` 之后如果指向一个
|
|
5137
|
+
* 普通文件,后续列目录会一路报错。
|
|
5138
|
+
*/
|
|
5139
|
+
async function registerBuiltinWorkspaceRoot(ctx, database) {
|
|
5140
|
+
try {
|
|
5141
|
+
const workspace = ctx.workspaceRegistry.list()[0];
|
|
5142
|
+
if (workspace === void 0) return;
|
|
5143
|
+
const target = workspace.path?.trim();
|
|
5144
|
+
if (target === void 0 || target === "") return;
|
|
5145
|
+
if (!(await stat(target)).isDirectory()) return;
|
|
5146
|
+
const root = await database.ensureBuiltinRoot(target, workspace.title?.trim() || "DSH 工作区");
|
|
5147
|
+
if (root !== void 0) ctx.logger.info(`dsh-remote-bridge: registered the DSH workspace as a built-in root: ${root.realPath}`);
|
|
5148
|
+
} catch (error) {
|
|
5149
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not register the DSH workspace root: ${String(error)}`));
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
4698
5152
|
//#endregion
|
|
4699
5153
|
export { apply, apply as default, inject, name };
|