dsh-mobile-plugin 0.0.0-stage → 0.1.57
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.md +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 网关传输层:旧 `apiProxy` 的信封形状 ⇄ 宿主 `typertGateway` 的 invoke/stream。
|
|
5
|
+
*
|
|
6
|
+
* 这是整座桥里唯一知道「宿主怎么调」的地方,四个上层文件(端点表 / bespoke 翻译 /
|
|
7
|
+
* 事件合成 / 装配)都只经它出入。三条不能改的语义:
|
|
8
|
+
* 1. `gateway.invoke()` **不返回信封**,直接返回业务值本体 —— 包回信封是这里的事。
|
|
9
|
+
* 2. 业务错误的 `code`/`message`/`details` **原样透传**,否则 `promptErrorObject`
|
|
10
|
+
* 的 code 白名单、`errorDetails` 的 details.reason 契约、agent-preset 的
|
|
11
|
+
* `agent-preset-not-found` 分支全部失效。
|
|
12
|
+
* 3. 网关自己抛的错(服务没装、方法没注册)一律收敛成 `host-unavailable`:对移动端
|
|
13
|
+
* 来说「宿主没这个能力」只有一种含义。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
|
|
18
|
+
// 网关层抛的「服务/方法不存在」类错误:前缀随版本变过(0.1.x 时期是无前缀的
|
|
19
|
+
// invocation-unavailable,0.2.0 是 gateway/invocation-unavailable),两种都认。
|
|
20
|
+
const GATEWAY_MISSING = new Set([
|
|
21
|
+
'invocation-unavailable',
|
|
22
|
+
'service-unavailable',
|
|
23
|
+
'method-unavailable',
|
|
24
|
+
'definition-unavailable',
|
|
25
|
+
'ambiguous-endpoint',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const UNAVAILABLE = Object.freeze({
|
|
29
|
+
code: 'host-unavailable',
|
|
30
|
+
message: '当前宿主版本没有提供该能力(需要 DSH 0.2.0 的 typertGateway)',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** 宿主 0.2.0 确实没有这个端点(不是版本问题,是没实现):如实回报,别假装成功。 */
|
|
34
|
+
function unsupported(what) {
|
|
35
|
+
return {
|
|
36
|
+
result: {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: {
|
|
39
|
+
code: 'host-unsupported',
|
|
40
|
+
message: `当前宿主版本没有提供${what},请在被控端使用更新版本的 DSH`,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 合成流(workspace 跟随 / 历史翻页取游标)没有天然截止时间,这里给一个上限。 */
|
|
47
|
+
const SYNTHETIC_DEADLINE_MS = 8000;
|
|
48
|
+
|
|
49
|
+
function deadline(signal, ms) {
|
|
50
|
+
const timer = AbortSignal.timeout(ms);
|
|
51
|
+
if (!signal) return timer;
|
|
52
|
+
try {
|
|
53
|
+
return typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timer]) : signal;
|
|
54
|
+
} catch {
|
|
55
|
+
return signal;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gatewayOf(ctx) {
|
|
60
|
+
try {
|
|
61
|
+
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function warn(ctx, message) {
|
|
68
|
+
try {
|
|
69
|
+
const logger = ctx && ctx.logger;
|
|
70
|
+
if (logger && typeof logger.warn === 'function') logger.warn(`[dsh-mobile-plugin] ${message}`);
|
|
71
|
+
} catch {
|
|
72
|
+
/* 桥接层的告警不能反过来弄挂宿主 */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pick(source, keys) {
|
|
77
|
+
const out = {};
|
|
78
|
+
if (!source || typeof source !== 'object') return out;
|
|
79
|
+
for (const key of keys) if (source[key] !== undefined) out[key] = source[key];
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ok(value) {
|
|
84
|
+
return { result: { ok: true, value } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function errorOf(error, fallbackCode) {
|
|
88
|
+
if (!error) return { ...UNAVAILABLE };
|
|
89
|
+
const code = typeof error.code === 'string' && error.code ? error.code : fallbackCode || 'host-call-failed';
|
|
90
|
+
if (GATEWAY_MISSING.has(code) || GATEWAY_MISSING.has(String(code).replace(/^gateway\//, ''))) {
|
|
91
|
+
return { ...UNAVAILABLE };
|
|
92
|
+
}
|
|
93
|
+
const message = typeof error.message === 'string' && error.message ? error.message : String(error);
|
|
94
|
+
const details = error.details;
|
|
95
|
+
const plain =
|
|
96
|
+
details && typeof details === 'object' && !Array.isArray(details) &&
|
|
97
|
+
(Object.getPrototypeOf(details) === Object.prototype || Object.getPrototypeOf(details) === null)
|
|
98
|
+
? details
|
|
99
|
+
: undefined;
|
|
100
|
+
return plain ? { code, message, details: plain } : { code, message };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(error, fallbackCode) {
|
|
104
|
+
return { result: { ok: false, error: errorOf(error, fallbackCode) } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function requestId(prefix) {
|
|
108
|
+
return `${prefix}-randomUUID-${crypto.randomUUID()}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* args 多一个键就会 gateway/arguments-invalid。这里按宿主文案(形如
|
|
113
|
+
* `unexpected "clientMessageId"`)把多余的键摘掉重试一次 —— 多出来的键宿主根本
|
|
114
|
+
* 不会读,摘掉是安全的,也免得为一个拼写差异让整个功能不可用。
|
|
115
|
+
*/
|
|
116
|
+
function dropUnexpectedArgs(args, message) {
|
|
117
|
+
if (!message || !message.includes('unexpected')) return null;
|
|
118
|
+
const clause = message.slice(message.indexOf('unexpected'));
|
|
119
|
+
const keys = [...clause.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
120
|
+
if (!keys.length) return null;
|
|
121
|
+
const next = { ...args };
|
|
122
|
+
let removed = 0;
|
|
123
|
+
for (const key of keys) {
|
|
124
|
+
if (!Object.hasOwn(next, key)) continue;
|
|
125
|
+
delete next[key];
|
|
126
|
+
removed += 1;
|
|
127
|
+
}
|
|
128
|
+
return removed ? next : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function invoke(ctx, namespace, method, args, signal) {
|
|
132
|
+
const gateway = gatewayOf(ctx);
|
|
133
|
+
if (!gateway) return { error: { ...UNAVAILABLE } };
|
|
134
|
+
const request = { namespace, method, args };
|
|
135
|
+
if (signal) request.signal = signal;
|
|
136
|
+
try {
|
|
137
|
+
return { value: await gateway.invoke(request) };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error && error.code === 'gateway/arguments-invalid') {
|
|
140
|
+
const retry = dropUnexpectedArgs(args, error.message);
|
|
141
|
+
if (retry) {
|
|
142
|
+
warn(ctx, `${namespace}/${method} 去掉多余参数后重试:${JSON.stringify(retry)}`);
|
|
143
|
+
try {
|
|
144
|
+
return { value: await gateway.invoke({ ...request, args: retry }) };
|
|
145
|
+
} catch (second) {
|
|
146
|
+
return { error: errorOf(second, 'host-call-failed') };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { error: errorOf(error, 'host-call-failed') };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function openStream(ctx, namespace, method, args, signal) {
|
|
155
|
+
const gateway = gatewayOf(ctx);
|
|
156
|
+
if (!gateway) throw Object.assign(new Error(UNAVAILABLE.message), { code: UNAVAILABLE.code });
|
|
157
|
+
const request = { namespace, method, args };
|
|
158
|
+
if (signal) request.signal = signal;
|
|
159
|
+
return gateway.stream(request);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function invokeEnvelope(ctx, namespace, method, args, signal) {
|
|
163
|
+
const result = await invoke(ctx, namespace, method, args, signal);
|
|
164
|
+
if (result.error) return fail(result.error);
|
|
165
|
+
return ok(result.value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 0.2.0 兼容桥:旧 `apiProxy` 调用形状 → 宿主 `typertGateway`。
|
|
5
|
+
*
|
|
6
|
+
* 背景:DSH 0.2.0 删掉了 `apiProxy` 服务(整个 app.asar 里零命中),宿主 RPC 全部改走
|
|
7
|
+
* `ctx.get('typertGateway')` 的 `invoke({namespace, method, args, signal})` / `stream(...)`。
|
|
8
|
+
* 但本插件 17 个移动端模块历史上都是这一套:
|
|
9
|
+
*
|
|
10
|
+
* 请求:`ctx.apiProxy.<ns>.<method>({ rpcId, method: '<ns>.<method>', payload }, signal)`
|
|
11
|
+
* 返回:`{ result: { ok: true, value } }` 或 `{ result: { ok: false, error } }`
|
|
12
|
+
*
|
|
13
|
+
* 与其逐个改 55 处调用点(每处都要对上宿主的 wire 参数名,而参数名错了只在运行时
|
|
14
|
+
* 报 `gateway/arguments-invalid`),这里提供**同名同形状**的服务,内部翻译成
|
|
15
|
+
* gateway 调用并把结果包回旧信封 —— 17 个文件一行都不用动,将来 DSH 若恢复真
|
|
16
|
+
* `apiProxy`,把这个目录删掉、apply 里那一行 provide 去掉即可。
|
|
17
|
+
*
|
|
18
|
+
* 本文件只剩装配:把端点表铺成 `apiProxy.<ns>.<method>` 的形状。实现分四份:
|
|
19
|
+
* host-bridge/transport.js 网关传输与信封翻译(唯一知道「宿主怎么调」的地方)
|
|
20
|
+
* host-bridge/endpoints.js 端点表(键 ⇄ 宿主 wire 名与参数形状)
|
|
21
|
+
* host-bridge/shims.js 没有 1:1 端点、需要跨端点合成的几处
|
|
22
|
+
* host-bridge/event-synthesis.js 旧设备级 events.mux ⇄ 0.2.0 拆开的三条流
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { ENDPOINTS } = require('./host-bridge/endpoints.js');
|
|
26
|
+
const { muxFrames } = require('./host-bridge/event-synthesis.js');
|
|
27
|
+
const { unsupported, UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
28
|
+
|
|
29
|
+
// ── 装配 ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 把端点表铺成旧的 `apiProxy.<ns>.<method>` 形状。
|
|
33
|
+
*
|
|
34
|
+
* 键表直接从 ENDPOINTS 推,不再手写一份 `<ns>` → 方法名数组:两份表迟早会对不上,
|
|
35
|
+
* 而对不上的后果不是报错,是 `ctx.apiProxy.<ns>.<method> is not a function` 这种在
|
|
36
|
+
* 别的模块、别的请求里炸出来的 TypeError(新增 sessions.projections 时就漏登记过一次,
|
|
37
|
+
* /session/stats 会跟着一起挂)。现在新增端点只改 ENDPOINTS 一处。
|
|
38
|
+
*/
|
|
39
|
+
function createApiProxy(ctx) {
|
|
40
|
+
const namespaces = {};
|
|
41
|
+
for (const key of Object.keys(ENDPOINTS)) {
|
|
42
|
+
const dot = key.indexOf('.');
|
|
43
|
+
if (dot <= 0) continue;
|
|
44
|
+
const name = key.slice(0, dot);
|
|
45
|
+
const method = key.slice(dot + 1);
|
|
46
|
+
if (!namespaces[name]) namespaces[name] = {};
|
|
47
|
+
const spec = ENDPOINTS[key];
|
|
48
|
+
namespaces[name][method] = (request, signal) => spec(ctx, (request && request.payload) || {}, signal);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...namespaces,
|
|
53
|
+
|
|
54
|
+
// 帧流:返回的是异步生成器,调用方直接 for await(不是 Promise)。
|
|
55
|
+
events: {
|
|
56
|
+
mux: (request, signal) => muxFrames(ctx, request || {}, signal),
|
|
57
|
+
host: (request, signal) => muxFrames(ctx, request || {}, signal),
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// 回答问题 / 审批。0.2.0 没有 approvals 命名空间,问题是 userQuestions/answer
|
|
61
|
+
// 且必须带 callId —— 而 callId 只在问题事件里出现过,本插件 0.1.x 的问题帧来自
|
|
62
|
+
// 旧 mux,形状对不上(见 README 的迁移说明),如实回报不支持,别让手机端干等。
|
|
63
|
+
respond: () => unsupported('远程回答提问/审批的接口'),
|
|
64
|
+
|
|
65
|
+
downloads: {
|
|
66
|
+
sessionLog: () => unsupported('会话日志导出接口'),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { createApiProxy, UNAVAILABLE };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「宿主设置」API:只读暴露宿主的模型提供方名单与设置命名空间。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端有完整的设置面板(默认模型、提供方配置、凭据是否已设),手机端此前一点都看不到,
|
|
7
|
+
* 只能看到一个「设置」页里全是 App 自己的偏好项。这里先把「看得到」补齐:
|
|
8
|
+
* settings.describe 与 llm.providers 都是只读接口,且宿主自己已经做了脱敏
|
|
9
|
+
* (secrets 只给出 path 与 set,从不返回值),所以可以原样转给手机。
|
|
10
|
+
*
|
|
11
|
+
* 命名空间里的 schema 字段是序列化后的 zod 树(几百个 uid/refs),对手机没有任何用处,
|
|
12
|
+
* 直接丢掉,只保留 value / base / user / applies / revision 与密钥槽。
|
|
13
|
+
*
|
|
14
|
+
* 写入侧(POST /host-settings/mutate)与桌面端同一口径:只走宿主的 settings.mutate,
|
|
15
|
+
* 用「按路径寻址的 op」(set / unset)+ expectedRevision 做乐观并发,绝不整段 replace。
|
|
16
|
+
* 桌面端就是这么写的:dsh-client-ui-settings/lib/client.js 一次只提交一个 op
|
|
17
|
+
* (`settings.mutate({ ns, ops: [op], expectedRevision })`),失败后重新 describe 再重试。
|
|
18
|
+
* 手机上一般一次改好几个字段,所以这里一次收一组 op,但语义与桌面端逐字段提交完全一致。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
22
|
+
const { unwrap } = require('./sessions.js');
|
|
23
|
+
|
|
24
|
+
const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
|
|
25
|
+
const HOST_SETTINGS_VALUE_DEPTH = 4; // 值递归深度上限
|
|
26
|
+
const HOST_SETTINGS_VALUE_KEYS = 40; // 每层键/元素数量上限
|
|
27
|
+
const HOST_SETTINGS_VALUE_CHARS = 4000; // 单个字符串长度上限
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 把任意设置值压成可安全 JSON 传输的形状。
|
|
31
|
+
*
|
|
32
|
+
* 只保留 JSON 原生类型:字符串超长截断、数组/对象限深限宽、函数与 symbol 一律丢弃成 null,
|
|
33
|
+
* bigint 转成字符串(JSON.stringify 遇到 bigint 会直接抛错)。
|
|
34
|
+
*/
|
|
35
|
+
function boundValue(value, depth = 0) {
|
|
36
|
+
if (value === null || value === undefined) return null;
|
|
37
|
+
const type = typeof value;
|
|
38
|
+
if (type === 'string') {
|
|
39
|
+
return value.length > HOST_SETTINGS_VALUE_CHARS ? `${value.slice(0, HOST_SETTINGS_VALUE_CHARS)}…` : value;
|
|
40
|
+
}
|
|
41
|
+
if (type === 'number') return Number.isFinite(value) ? value : null;
|
|
42
|
+
if (type === 'boolean') return value;
|
|
43
|
+
if (type === 'bigint') return String(value);
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
if (depth >= HOST_SETTINGS_VALUE_DEPTH) return [];
|
|
46
|
+
return value.slice(0, HOST_SETTINGS_VALUE_KEYS).map((entry) => boundValue(entry, depth + 1));
|
|
47
|
+
}
|
|
48
|
+
if (type === 'object') {
|
|
49
|
+
if (depth >= HOST_SETTINGS_VALUE_DEPTH) return {};
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const key of Object.keys(value).slice(0, HOST_SETTINGS_VALUE_KEYS)) {
|
|
52
|
+
out[key] = boundValue(value[key], depth + 1);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
// function / symbol:设置值里出现这些没有意义,丢掉比抛错好。
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从序列化的 zod 树里读出「这个命名空间的密钥写到哪个凭据引用上」。
|
|
62
|
+
*
|
|
63
|
+
* 宿主把两个标记留在 schema 的 meta 里:`.role('secret')` 标出密钥字段,
|
|
64
|
+
* `.role('credential-ref')` 标出「写着凭据引用名」的那个字段
|
|
65
|
+
* (见 dsh-web-search-deepseek/lib/index.js:240-241 的 apiKey / apiKeyEnv)。
|
|
66
|
+
* settings.describe 的 secrets 槽就是由 role('secret') 生成的,而真正写入时
|
|
67
|
+
* 要用的是 role('credential-ref') 指向字段的值(桌面端也是这么取的:
|
|
68
|
+
* dsh-client-ui-settings-plugins/lib/client.js 的 refOf() 读 snapshot.value.apiKeyEnv)。
|
|
69
|
+
*
|
|
70
|
+
* 这里按同一口径从 schema 推导,不写死任何命名空间;推导不出来就返回 null,
|
|
71
|
+
* 于是 App 不会给没有密钥槽的设置项画密码框。
|
|
72
|
+
*/
|
|
73
|
+
function credentialRefOf(schema, value) {
|
|
74
|
+
const refs = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema.refs : null;
|
|
75
|
+
if (!refs || typeof refs !== 'object' || Array.isArray(refs)) return null;
|
|
76
|
+
const root = refs[String(schema.uid)];
|
|
77
|
+
const dict = root && typeof root === 'object' ? root.dict : null;
|
|
78
|
+
if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return null;
|
|
79
|
+
|
|
80
|
+
let refField = '';
|
|
81
|
+
let refDefault = '';
|
|
82
|
+
let secretFields = 0;
|
|
83
|
+
for (const field of Object.keys(dict)) {
|
|
84
|
+
const entry = refs[String(dict[field])];
|
|
85
|
+
const meta = entry && typeof entry === 'object' ? entry.meta : null;
|
|
86
|
+
const role = meta && typeof meta === 'object' ? meta.role : undefined;
|
|
87
|
+
if (role === 'secret') { secretFields += 1; continue; }
|
|
88
|
+
if (role === 'credential-ref' && !refField) {
|
|
89
|
+
refField = field;
|
|
90
|
+
refDefault = typeof meta.default === 'string' ? meta.default.trim() : '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 只认「恰好一个密钥槽」的命名空间:凭据引用是命名空间级的,两个密钥槽共用一个引用
|
|
94
|
+
// 时没法知道该往哪个引用写,宁可让手机端不给入口,也不写错地方。
|
|
95
|
+
if (secretFields !== 1 || !refField) return null;
|
|
96
|
+
|
|
97
|
+
const declared = value && typeof value === 'object' && !Array.isArray(value) ? value[refField] : undefined;
|
|
98
|
+
const ref = typeof declared === 'string' && declared.trim() ? declared.trim() : refDefault;
|
|
99
|
+
return ref ? { ref, refField } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把 settings.describe 的一条命名空间视图压成手机需要的形状。
|
|
104
|
+
*
|
|
105
|
+
* 畸形条目(没有 ns,或不是对象)返回 null,由调用方过滤掉;settings.describe 的值来自宿主,
|
|
106
|
+
* 但回放/旧版本宿主仍可能给出意外形状,这里不抛错。
|
|
107
|
+
*/
|
|
108
|
+
function normalizeNamespace(item) {
|
|
109
|
+
if (!item || typeof item !== 'object') return null;
|
|
110
|
+
const ns = typeof item.ns === 'string' ? item.ns.trim() : '';
|
|
111
|
+
if (!ns) return null;
|
|
112
|
+
|
|
113
|
+
const secrets = Array.isArray(item.secrets)
|
|
114
|
+
? item.secrets
|
|
115
|
+
.map((slot) => {
|
|
116
|
+
if (!slot || typeof slot !== 'object') return null;
|
|
117
|
+
const path = Array.isArray(slot.path)
|
|
118
|
+
? slot.path.filter((part) => typeof part === 'string' && part !== '').join('.')
|
|
119
|
+
: '';
|
|
120
|
+
if (!path) return null;
|
|
121
|
+
// 宿主只给「这个密钥槽是否已设置」,永远不给值;这里保持同一口径。
|
|
122
|
+
return { path, set: slot.set === true };
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
const out = {
|
|
128
|
+
ns,
|
|
129
|
+
value: boundValue(item.value),
|
|
130
|
+
applies: item.applies === 'restart' ? 'restart' : 'live',
|
|
131
|
+
revision: typeof item.revision === 'number' && Number.isFinite(item.revision) ? item.revision : 0,
|
|
132
|
+
secrets,
|
|
133
|
+
};
|
|
134
|
+
if (item.base !== undefined) out.base = boundValue(item.base);
|
|
135
|
+
if (item.user !== undefined) out.user = boundValue(item.user);
|
|
136
|
+
// 只有「宿主确实报了密钥槽」且「schema 里能推导出凭据引用」时才给出引用名;
|
|
137
|
+
// 两者缺一,App 就不该显示写密钥的入口。
|
|
138
|
+
if (secrets.length > 0) {
|
|
139
|
+
const credential = credentialRefOf(item.schema, item.value);
|
|
140
|
+
if (credential) out.credentialRef = credential.ref;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 把 llm.providers 的一条提供方压成手机需要的形状;缺 provider 的条目丢掉。 */
|
|
146
|
+
function normalizeProvider(item) {
|
|
147
|
+
if (!item || typeof item !== 'object') return null;
|
|
148
|
+
const provider = typeof item.provider === 'string' ? item.provider.trim() : '';
|
|
149
|
+
if (!provider) return null;
|
|
150
|
+
const displayName = typeof item.displayName === 'string' && item.displayName.trim() ? item.displayName.trim() : provider;
|
|
151
|
+
return { provider, displayName, active: item.active === true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** GET /api/mobile/v1/host-settings —— 宿主设置命名空间(只读、已脱敏)。 */
|
|
155
|
+
async function handleHostSettings(ctx, req, res) {
|
|
156
|
+
// 宿主的 settings.describe 不接受取消信号(apiproxy 的 invoke 也只传 request),故只传信封。
|
|
157
|
+
const { value, error } = unwrap(await ctx.apiProxy.settings.describe({
|
|
158
|
+
rpcId: 'mobile',
|
|
159
|
+
method: 'settings.describe',
|
|
160
|
+
payload: {},
|
|
161
|
+
}));
|
|
162
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
163
|
+
|
|
164
|
+
const list = Array.isArray(value && value.namespaces) ? value.namespaces : [];
|
|
165
|
+
const namespaces = list
|
|
166
|
+
.map(normalizeNamespace)
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.slice(0, HOST_SETTINGS_MAX_NAMESPACES);
|
|
169
|
+
|
|
170
|
+
sendJson(res, 200, {
|
|
171
|
+
ok: true,
|
|
172
|
+
writable: value != null && value.writable === true,
|
|
173
|
+
hasDocument: value != null && value.hasDocument === true,
|
|
174
|
+
namespaces,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** GET /api/mobile/v1/llm/providers —— 宿主的模型提供方名单(只读)。 */
|
|
179
|
+
async function handleLlmProviders(ctx, req, res) {
|
|
180
|
+
const { value, error } = unwrap(await ctx.apiProxy.llm.providers({
|
|
181
|
+
rpcId: 'mobile',
|
|
182
|
+
method: 'llm.providers',
|
|
183
|
+
payload: {},
|
|
184
|
+
}));
|
|
185
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
186
|
+
|
|
187
|
+
const list = Array.isArray(value && value.providers) ? value.providers : [];
|
|
188
|
+
const providers = list.map(normalizeProvider).filter(Boolean);
|
|
189
|
+
|
|
190
|
+
sendJson(res, 200, { ok: true, providers });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const HOST_SETTINGS_MAX_OPS = 40; // 一次写入的 op 数量上限
|
|
194
|
+
const HOST_SETTINGS_MAX_PATH = 8; // 单个路径的层数上限
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 校验一条 op 的路径:非空字符串数组。
|
|
198
|
+
*
|
|
199
|
+
* 空路径等于改整个命名空间,手机端一律不许(要整段替换就得走 settings.replace,
|
|
200
|
+
* 这里不暴露那条路)。上层元素也只接受非空字符串,避免 `[0]` 这种数组下标意外命中。
|
|
201
|
+
*/
|
|
202
|
+
function normalizePath(path) {
|
|
203
|
+
if (!Array.isArray(path) || path.length === 0 || path.length > HOST_SETTINGS_MAX_PATH) return null;
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const part of path) {
|
|
206
|
+
if (typeof part !== 'string' || part === '') return null;
|
|
207
|
+
out.push(part);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 校验并原样重建 op 列表;任何一条不合法就整批拒绝。
|
|
214
|
+
*
|
|
215
|
+
* `value` 不在这里做类型校验:那是宿主 schema 的活,宿主的 settings-rejected 会带着
|
|
216
|
+
* 具体原因回来,比在这里猜准得多(值也可能就是 undefined)。
|
|
217
|
+
*/
|
|
218
|
+
function normalizeOps(value) {
|
|
219
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > HOST_SETTINGS_MAX_OPS) return null;
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const item of value) {
|
|
222
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
223
|
+
const path = normalizePath(item.path);
|
|
224
|
+
if (!path) return null;
|
|
225
|
+
if (item.op === 'unset') { out.push({ op: 'unset', path }); continue; }
|
|
226
|
+
if (item.op === 'set') { out.push({ op: 'set', path, value: item.value }); continue; }
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* POST /api/mobile/v1/host-settings/mutate(token)
|
|
234
|
+
* { ns, ops: [{op:'set',path:[...],value}|{op:'unset',path:[...]}], expectedRevision? }
|
|
235
|
+
* → { ok, namespace }
|
|
236
|
+
*
|
|
237
|
+
* 乐观并发:把 App 看到的 revision 原样带上,宿主在 revision 不匹配时回
|
|
238
|
+
* `settings-conflict`(details 里有 expected/actual),这里如实透传,App 才能提示
|
|
239
|
+
* 「别处改过了,刷新重试」。写入成功后宿主会回该命名空间的新视图,同样过一遍
|
|
240
|
+
* normalizeNamespace 再给手机。
|
|
241
|
+
*/
|
|
242
|
+
async function handleHostSettingsMutate(ctx, req, res) {
|
|
243
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
244
|
+
const ns = body && typeof body.ns === 'string' ? body.ns.trim() : '';
|
|
245
|
+
if (!ns) {
|
|
246
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { ns, ops }' } });
|
|
247
|
+
}
|
|
248
|
+
const ops = normalizeOps(body ? body.ops : undefined);
|
|
249
|
+
if (!ops) {
|
|
250
|
+
return sendJson(res, 400, {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: { code: 'bad-request', message: 'ops 必须是 [{op:"set",path:[…],value}|{op:"unset",path:[…]}] 的非空数组' },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const expectedRevision = body && typeof body.expectedRevision === 'number' && Number.isInteger(body.expectedRevision) && body.expectedRevision > 0
|
|
256
|
+
? body.expectedRevision
|
|
257
|
+
: undefined;
|
|
258
|
+
|
|
259
|
+
const { value, error } = unwrap(await ctx.apiProxy.settings.mutate({
|
|
260
|
+
rpcId: 'mobile',
|
|
261
|
+
method: 'settings.mutate',
|
|
262
|
+
payload: { ns, ops, ...(expectedRevision === undefined ? {} : { expectedRevision }) },
|
|
263
|
+
}));
|
|
264
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
265
|
+
|
|
266
|
+
const namespace = normalizeNamespace(value);
|
|
267
|
+
if (!namespace) {
|
|
268
|
+
return sendJson(res, 400, { ok: false, error: { code: 'internal', message: '宿主没有返回这个设置项的视图' } });
|
|
269
|
+
}
|
|
270
|
+
sendJson(res, 200, { ok: true, namespace });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
handleHostSettings,
|
|
275
|
+
handleHostSettingsMutate,
|
|
276
|
+
handleLlmProviders,
|
|
277
|
+
boundValue,
|
|
278
|
+
credentialRefOf,
|
|
279
|
+
normalizeNamespace,
|
|
280
|
+
normalizeProvider,
|
|
281
|
+
normalizeOps,
|
|
282
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 写操作幂等(requestId 重放)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要:手机侧的一次写可能在**从未到达宿主**的通道上失败,此时传输内核会换一条
|
|
7
|
+
* 通道重投同一次请求(见 app/src/api/core.ts 的 routeCandidates/request)。重投的语义
|
|
8
|
+
* 是「这次写还没执行过」,但内核无法证明这一点 —— 它只知道「第一个候选连不上」。
|
|
9
|
+
* 一旦第一候选其实已经把请求送到宿主、只是响应回来时通道断了,重投就会让同一次写
|
|
10
|
+
* 执行两遍。对「新建会话」这类非幂等写,用户看到的就是凭空多出来一个会话。
|
|
11
|
+
*
|
|
12
|
+
* 约定与 promptOnce 一致:只缓存**成功**结果;失败必须能被重试(一次瞬时错误不能被
|
|
13
|
+
* 固化成 5 分钟的静默无响应);没有 requestId 时直接执行,旧客户端行为不变。
|
|
14
|
+
*
|
|
15
|
+
* 存的是结果而不是「已执行」标记:重放要返回和第一次一样的东西,否则 App 会拿到
|
|
16
|
+
* 一个形状不同的响应(例如新建会话拿不到 sessionId)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { writeIdempotencyStateOf } = require('./session-cache.js');
|
|
20
|
+
|
|
21
|
+
/** 重放窗口:手机的一次写如果 5 分钟后才重投,那更可能是用户真的想再做一次。 */
|
|
22
|
+
const WRITE_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
|
|
23
|
+
const MAX_WRITE_IDEMPOTENCY_ENTRIES = 256;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 取出这次写请求的幂等 id。
|
|
27
|
+
*
|
|
28
|
+
* 优先 header:App 在构造请求头时生成一次,之后内核换通道重投用的还是同一个 init
|
|
29
|
+
* (同一个请求头),因此天然复用同一个 id。body.requestId 是给不方便加头的老客户端
|
|
30
|
+
* 的回退路径。
|
|
31
|
+
*
|
|
32
|
+
* @returns {string} 没有 id 时返回空串(调用方按「不做幂等」处理)
|
|
33
|
+
*/
|
|
34
|
+
function requestIdOf(req, body) {
|
|
35
|
+
const header = req && req.headers ? req.headers['x-dsh-request-id'] : undefined;
|
|
36
|
+
if (typeof header === 'string' && header.trim()) return header.trim().slice(0, 128);
|
|
37
|
+
const value = body && typeof body.requestId === 'string' ? body.requestId.trim() : '';
|
|
38
|
+
return value ? value.slice(0, 128) : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 执行一次幂等写。
|
|
43
|
+
*
|
|
44
|
+
* @param {object} ctx 插件上下文
|
|
45
|
+
* @param {string} deviceKey 设备维度(同一 requestId 在不同手机上互不影响)
|
|
46
|
+
* @param {string} requestId 请求 id;空串表示不做幂等,直接执行
|
|
47
|
+
* @param {() => Promise<{status:number, body:object}>} run 真正的写操作
|
|
48
|
+
* @returns {Promise<{status:number, body:object}>}
|
|
49
|
+
*/
|
|
50
|
+
async function withIdempotency(ctx, deviceKey, requestId, run) {
|
|
51
|
+
if (!requestId) return run();
|
|
52
|
+
const state = writeIdempotencyStateOf(ctx);
|
|
53
|
+
const key = `${deviceKey}\0${requestId}`;
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const existing = state.get(key);
|
|
56
|
+
if (existing && now - existing.createdAt < WRITE_IDEMPOTENCY_TTL_MS) return existing.result;
|
|
57
|
+
if (existing) state.delete(key);
|
|
58
|
+
|
|
59
|
+
// 并发同 id:两个请求共享同一个在途 promise,都拿到同一个结果。
|
|
60
|
+
const promise = Promise.resolve().then(run);
|
|
61
|
+
const record = { createdAt: now, result: promise };
|
|
62
|
+
state.set(key, record);
|
|
63
|
+
while (state.size > MAX_WRITE_IDEMPOTENCY_ENTRIES) state.delete(state.keys().next().value);
|
|
64
|
+
try {
|
|
65
|
+
const outcome = await promise;
|
|
66
|
+
record.result = outcome;
|
|
67
|
+
// 失败态(宿主内部错误/网关不可用)不缓存:它多半是瞬时故障,重投应当真的再试一次。
|
|
68
|
+
if (!outcome || typeof outcome.status !== 'number' || outcome.status >= 500) {
|
|
69
|
+
if (state.get(key) === record) state.delete(key);
|
|
70
|
+
}
|
|
71
|
+
return outcome;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (state.get(key) === record) state.delete(key);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { requestIdOf, withIdempotency, WRITE_IDEMPOTENCY_TTL_MS };
|