dsh-mobile-plugin 0.5.11 → 0.5.12
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/lib/client.js +825 -303
- package/lib/impl.js +37 -6
- package/lib/mobile/model-vendors.js +16 -1
- package/lib/mobile/session-models.js +5 -2
- package/lib/mobile/uploads.js +17 -7
- package/lib/panel/index.html +13 -11
- package/lib/routes/control-plane.js +48 -3
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -73,7 +73,20 @@ export const name = 'dsh-mobile-plugin'
|
|
|
73
73
|
//
|
|
74
74
|
// 旧运行时那一支多一个 `apiProxy`:宿主自己提供它(`@deepseek-ai/dsh-host-apiproxy` 装上
|
|
75
75
|
// 了,就一定会被提供),读 `ctx.apiProxy` 必须有 inject,否则 get trap 直接抛。
|
|
76
|
-
|
|
76
|
+
//
|
|
77
|
+
// **但「解析得到那个包」不等于「宿主一定会提供这个服务」**(2026-11-02 反馈 #11):新版宿主
|
|
78
|
+
// 上包名仍然解析得到,服务却已经删了。把 apiProxy 写进 inject 的代价是这一行**永远 pending**
|
|
79
|
+
// —— apply 整个不跑,一条路由都不注册,手机上连「装了但连不上」都看不到。所以 inject 只剩
|
|
80
|
+
// `webServer`,apiProxy 改由 apply 里按「过一小会儿真服务还没来就自己兜」决定,见下面的装配段。
|
|
81
|
+
export const inject = ['webServer']
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析到包之后,等真服务的宽限期。
|
|
85
|
+
*
|
|
86
|
+
* 旧宿主在启动过程中就会 provide 出 apiProxy(本插件通常比它先挂),所以这段时间只用来看
|
|
87
|
+
* 「服务到底来不来」。等不到就说明这是新版宿主:包在、服务没了,必须自己挂兼容桥。
|
|
88
|
+
*/
|
|
89
|
+
const API_PROXY_GRACE_MS = 3000
|
|
77
90
|
|
|
78
91
|
export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
|
|
79
92
|
export const RemoteControlSettingsSchema = z.object({
|
|
@@ -232,10 +245,30 @@ function apply(ctx) {
|
|
|
232
245
|
return undefined
|
|
233
246
|
}
|
|
234
247
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
/**
|
|
249
|
+
* 装兼容桥与两条帧桥(审批 / 提问)。
|
|
250
|
+
*
|
|
251
|
+
* 三者都只在「宿主这一代没有真 apiProxy」时才装:旧宿主自带真服务,多 provide 一次会让
|
|
252
|
+
* 整棵树加载失败(`service "apiProxy" has been registered at <dsh-mobile-plugin>`)。
|
|
253
|
+
* 而 `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它,撞上时用对方那份。
|
|
254
|
+
*/
|
|
255
|
+
const installCompatBridges = () => {
|
|
238
256
|
mount('apiProxy 兼容桥', () => ctx.reflect.provide('apiProxy', createApiProxy(ctx)))
|
|
257
|
+
mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
|
|
258
|
+
mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
|
|
259
|
+
}
|
|
260
|
+
if (!hasHostApiProxy) installCompatBridges()
|
|
261
|
+
else {
|
|
262
|
+
// 解析到包只说明「宿主可能是旧版」:新版宿主上包名还在、`apiProxy` 服务已经没了
|
|
263
|
+
// (2026-11-02 反馈 #11)。先给它一会儿把真服务 provide 出来(旧宿主在本插件装配
|
|
264
|
+
// 前后就会挂上),到点还没有就当作新版宿主,自己把兼容桥与两条帧桥装上 —— 否则
|
|
265
|
+
// 十几个移动端模块读到的是一个永远不来的服务,手机侧全程静默失败。
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
if (typeof ctx.get === 'function' && ctx.get('apiProxy')) return
|
|
268
|
+
warn('等不到宿主的 apiProxy,按新版宿主装配兼容桥')
|
|
269
|
+
installCompatBridges()
|
|
270
|
+
}, API_PROXY_GRACE_MS)
|
|
271
|
+
timer.unref?.()
|
|
239
272
|
}
|
|
240
273
|
|
|
241
274
|
const state = core.loadState()
|
|
@@ -310,8 +343,6 @@ function apply(ctx) {
|
|
|
310
343
|
mount('sidecar', () => ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`))
|
|
311
344
|
mount('镜像推送', () => ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`))
|
|
312
345
|
|
|
313
|
-
if (!hasHostApiProxy) mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
|
|
314
|
-
if (!hasHostApiProxy) mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
|
|
315
346
|
ctx.effect(() => {
|
|
316
347
|
// Profiles without the optional tools injection still support core remote control.
|
|
317
348
|
try { installSessionCleanup(ctx) } catch (error) {
|
|
@@ -102,4 +102,19 @@ function vendorLogoOf(vendor) {
|
|
|
102
102
|
return entry ? { name: entry.name, icon: entry.icon } : null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
/** 给已清洗的模型目录标厂商,并只返回这份目录实际用到的图标。 */
|
|
106
|
+
function decorateModelGroups(groups) {
|
|
107
|
+
const vendors = {};
|
|
108
|
+
for (const group of groups) {
|
|
109
|
+
for (const model of group.models) {
|
|
110
|
+
const vendor = identifyVendor({ modelName: model.name, model: model.id, providerName: group.name, provider: group.id });
|
|
111
|
+
if (!vendor) continue;
|
|
112
|
+
model.vendor = vendor;
|
|
113
|
+
const logo = vendorLogoOf(vendor);
|
|
114
|
+
if (logo) vendors[vendor] = logo;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return vendors;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { foldText, tokensOf, vendorOfText, identifyVendor, vendorLogoOf, decorateModelGroups, VENDOR_PATTERNS };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 模型目录:会话当前选择 + 可用模型分组 + 各档推理强度,外加「切模型」动作。
|
|
4
|
+
* 模型目录:会话当前选择 + 可用模型分组 + 厂商标识与图标 + 各档推理强度,外加「切模型」动作。
|
|
5
5
|
*
|
|
6
6
|
* 形状校验(safeModelsValue)与 TTL 缓存是这里的核心,两处都有非平凡的理由:
|
|
7
7
|
* 1. safeModelsValue 不是防御性洁癖——宿主是另一个进程,它给的形状会随版本变,
|
|
@@ -15,6 +15,7 @@ const { unwrap } = require('./envelope.js');
|
|
|
15
15
|
const { callSessions } = require('./rpc.js');
|
|
16
16
|
const { canonicalDeviceKey, sessionModelsStateOf, invalidateSessionModels } = require('./session-cache.js');
|
|
17
17
|
const { readModelOptions, safeModelOptions } = require('./session-model-options.js');
|
|
18
|
+
const { decorateModelGroups } = require('./model-vendors.js');
|
|
18
19
|
|
|
19
20
|
const SESSION_MODELS_CACHE_MS = 20000; // 模型目录短 TTL;成功后 stale-while-revalidate
|
|
20
21
|
|
|
@@ -91,6 +92,8 @@ function safeModelsValue(value) {
|
|
|
91
92
|
: {}),
|
|
92
93
|
})),
|
|
93
94
|
}));
|
|
95
|
+
const vendors = decorateModelGroups(result.groups);
|
|
96
|
+
if (Object.keys(vendors).length > 0) result.vendors = vendors;
|
|
94
97
|
}
|
|
95
98
|
// 模型之外的附加选项行(速度、输出详略……):行数与内容都由 session-model-options.js
|
|
96
99
|
// 那张描述表决定,App 只按收到的数组逐行画。空数组不下发(旧宿主没有这个字段)。
|
|
@@ -165,7 +168,7 @@ async function buildModelsSnapshot(ctx, sessionId) {
|
|
|
165
168
|
|
|
166
169
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
167
170
|
* 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
|
|
168
|
-
* 响应:{ ok, current, defaultModel, routable, groups, options?, failures }。
|
|
171
|
+
* 响应:{ ok, current, defaultModel, routable, groups, vendors?, options?, failures }。
|
|
169
172
|
* `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
|
|
170
173
|
* `options` 是当前渠道的附加选项行,没有就没有这个字段。
|
|
171
174
|
*/
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
*
|
|
18
18
|
* 顺序与幂等:chunk 只接受下一片,重复送达同一片按重传处理,跳号直接 409。
|
|
19
19
|
*
|
|
20
|
+
* 图片走两段:先按上面那套把字节传完拿 uploadId,`session/send` 再把 uploadId 换回内联
|
|
21
|
+
* base64 交给宿主 prompt。换的时候整份字节要读进内存 —— 宿主只认内联块,这条通道没有流式
|
|
22
|
+
* 入口。也正因为如此,单张图片的大小上限由桌面端的附件限制定,这里不设体积门槛。
|
|
23
|
+
*
|
|
20
24
|
* 收件根固定 `~/.dsh/mobile-inbox`,与工作区解耦(工作区被删不影响已收到的文件);
|
|
21
25
|
* `DSH_MOBILE_INBOX` 可覆盖,只给测试用。
|
|
22
26
|
*/
|
|
@@ -44,8 +48,13 @@ const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
|
|
|
44
48
|
const CHUNK_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
|
|
45
49
|
/** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
|
|
46
50
|
const NAME_MAX_CHARS = 120;
|
|
47
|
-
/**
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。
|
|
53
|
+
*
|
|
54
|
+
* 7 天,跟着手机侧的续传凭据走:凭据活多久,暂存就得留多久,否则手机回来续传的第一片必撞
|
|
55
|
+
* 404(判据见 `app/src/services/viewmodels/upload-resume.ts` 的 `HOST_STAGING_TTL_MS`)。
|
|
56
|
+
*/
|
|
57
|
+
const STAGING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
49
58
|
/** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
|
|
50
59
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
51
60
|
|
|
@@ -265,7 +274,7 @@ async function handleUploadBegin(ctx, req, res) {
|
|
|
265
274
|
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
|
|
266
275
|
return fail(res, 400, 'bad-request', '需要 { name }');
|
|
267
276
|
}
|
|
268
|
-
// 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满
|
|
277
|
+
// 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 7 天才清。
|
|
269
278
|
const destination = await resolveDestDir(ctx, body);
|
|
270
279
|
if (destination.status) return fail(res, destination.status, destination.code, destination.message);
|
|
271
280
|
const stagingDir = path.join(inboxRoot(), '.staging');
|
|
@@ -379,9 +388,6 @@ async function handleUploadAbort(_ctx, req, res) {
|
|
|
379
388
|
sendJson(res, 200, { ok: true });
|
|
380
389
|
}
|
|
381
390
|
|
|
382
|
-
/** 一张图片的明文上限:超过就按「传太大」拒,而不是把宿主内存撑爆。 */
|
|
383
|
-
const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
384
|
-
|
|
385
391
|
const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送';
|
|
386
392
|
|
|
387
393
|
/**
|
|
@@ -390,6 +396,10 @@ const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送
|
|
|
390
396
|
* 宿主的 `session.prompt` 只认 `{type:'image', mediaType, data}`,所以这一步要在宿主
|
|
391
397
|
* 那一跳之前完成:手机到宿主这一段只传 uploadId,字节早就在分片上传时落盘了。
|
|
392
398
|
*
|
|
399
|
+
* 整份读进内存是这条通道的形状决定的 —— 内联块本身就是一个 base64 字符串,没有按流交给宿主
|
|
400
|
+
* 的入口。也是因此这里不设体积门槛:单张多大由桌面端的附件限制定,真超了由宿主在 prompt 那
|
|
401
|
+
* 一跳用原话回绝;插件再拦一道,只会把「宿主说附件太大」变成一句假的「上传已过期」。
|
|
402
|
+
*
|
|
393
403
|
* @returns {Promise<string|null>} 有块补不上就回一句给用户看的话,全补上了回 null。
|
|
394
404
|
*/
|
|
395
405
|
async function inlineUploadedImageBlocks(content) {
|
|
@@ -400,7 +410,7 @@ async function inlineUploadedImageBlocks(content) {
|
|
|
400
410
|
if (!record) return UPLOAD_EXPIRED_MESSAGE;
|
|
401
411
|
try {
|
|
402
412
|
const bytes = await fsp.readFile(record.path);
|
|
403
|
-
if (!bytes.length
|
|
413
|
+
if (!bytes.length) return UPLOAD_EXPIRED_MESSAGE;
|
|
404
414
|
block.data = bytes.toString('base64');
|
|
405
415
|
if (!block.name) block.name = record.name;
|
|
406
416
|
} catch {
|