dsh-mobile-plugin 0.5.10 → 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/impl.js CHANGED
@@ -28,7 +28,9 @@ const { deviceMetaFromHeaders } = require('./shared/device-headers.js')
28
28
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
29
29
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
30
30
  const { randomBytes } = require('node:crypto')
31
- const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
31
+ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
32
+ // 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
33
+ const { pairingPayload } = require('./relay/pairing-payload.cjs')
32
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
33
35
  const { createPreviewService } = require('./relay/preview.js')
34
36
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -71,7 +73,20 @@ export const name = 'dsh-mobile-plugin'
71
73
  //
72
74
  // 旧运行时那一支多一个 `apiProxy`:宿主自己提供它(`@deepseek-ai/dsh-host-apiproxy` 装上
73
75
  // 了,就一定会被提供),读 `ctx.apiProxy` 必须有 inject,否则 get trap 直接抛。
74
- export const inject = runtimeProvidesApiProxy() ? ['webServer', 'apiProxy'] : ['webServer']
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
75
90
 
76
91
  export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
77
92
  export const RemoteControlSettingsSchema = z.object({
@@ -93,6 +108,9 @@ export const RemoteControlSettingsSchema = z.object({
93
108
  preferredLanAddress: z.string().max(64).default(''),
94
109
  // 用户手填的直连地址(`manualLanUrl`,内网穿透域名)也随二维码一起走,见 lan-network.cjs。
95
110
  manualLanUrl: z.string().max(200).default(''),
111
+ // 用户手填的 IPv6 直连地址(`manualIpv6Url`,形如 `http://[240e::1]:18787`):与上一条并列,
112
+ // 只是拨的是 IPv6;网卡扫不到的那条固定地址靠它兜底,候选口径见 lan-network.cjs。
113
+ manualIpv6Url: z.string().max(200).default(''),
96
114
  })
97
115
 
98
116
 
@@ -111,6 +129,7 @@ function configOf(state) {
111
129
  registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
112
130
  preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
113
131
  manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
132
+ manualIpv6Url: typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : '',
114
133
  }
115
134
  }
116
135
 
@@ -166,44 +185,6 @@ function createConfigStore(state) {
166
185
  }
167
186
  }
168
187
 
169
- function pairingPayload(state, lanPort) {
170
- const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
171
- // 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
172
- const candidates = state.lanEnabled
173
- ? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
174
- : []
175
- const urls = candidates.map(candidate => candidate.url).filter(Boolean)
176
- const relayUrl = state.relayUrl || ''
177
- const payload = {
178
- type: 'dsh-pair',
179
- version: 1,
180
- name: state.name || os.hostname(),
181
- instanceId: state.instanceId,
182
- code: state.pairingCode,
183
- // 协议版本与一次性票据(§8.3(1))。
184
- //
185
- // `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
186
- // 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
187
- // 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
188
- //
189
- // 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
190
- proto: 2,
191
- ticket: state.pairingTicket || '',
192
- relayUrl,
193
- lanUrls: urls,
194
- // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
195
- // 依次重试而不用先回到公网中转去问。
196
- lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
197
- // IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
198
- // 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
199
- ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
200
- }
201
- return {
202
- payload,
203
- text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
204
- }
205
- }
206
-
207
188
  // 上报给 App 的候选地址(私网优先、没有私网才退公网)住在 `./relay/lan-network.cjs`
208
189
  // 的 `advertisedCandidates`:那是地址策略,不是装配。这里只把它转交给三处消费点。
209
190
  // 「手机上最近来过没有」同理,住在 `./mobile/activity.cjs`(守卫记、控制面状态读)。
@@ -264,10 +245,30 @@ function apply(ctx) {
264
245
  return undefined
265
246
  }
266
247
  }
267
- if (!hasHostApiProxy) {
268
- // `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它。撞上时用对方那份
269
- // (同形状、同语义),不跟它抢 —— 抢的结果是我们自己整块装配失败,手机那边全断。
248
+ /**
249
+ * 装兼容桥与两条帧桥(审批 / 提问)。
250
+ *
251
+ * 三者都只在「宿主这一代没有真 apiProxy」时才装:旧宿主自带真服务,多 provide 一次会让
252
+ * 整棵树加载失败(`service "apiProxy" has been registered at <dsh-mobile-plugin>`)。
253
+ * 而 `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它,撞上时用对方那份。
254
+ */
255
+ const installCompatBridges = () => {
270
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?.()
271
272
  }
272
273
 
273
274
  const state = core.loadState()
@@ -342,8 +343,6 @@ function apply(ctx) {
342
343
  mount('sidecar', () => ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`))
343
344
  mount('镜像推送', () => ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`))
344
345
 
345
- if (!hasHostApiProxy) mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
346
- if (!hasHostApiProxy) mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
347
346
  ctx.effect(() => {
348
347
  // Profiles without the optional tools injection still support core remote control.
349
348
  try { installSessionCleanup(ctx) } catch (error) {
@@ -490,7 +489,8 @@ function apply(ctx) {
490
489
  // 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
491
490
  safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
492
491
  // IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
493
- safeIpv6Candidates: ipv6Candidates,
492
+ // 手填的那条同样缀在最后(只追加、不删网卡候选),手机每次核对连接信息都重新拿到它。
493
+ safeIpv6Candidates: (port) => withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url),
494
494
  lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
495
495
  // 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
496
496
  // 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 扩展开发包:让手机直接读作者放在电脑上的本地扩展目录(`$DSH_HOME/extension-dev`)。
5
+ *
6
+ * 扩展平时的产物走 App 分发系统,装到手机自己那份扩展目录里;要让作者边写边在手机上看,
7
+ * 得有一个「从电脑现读」的入口。这个目录就是那个入口:一个子目录一个包,`manifest.json`
8
+ * 必须在,目录名即 slug。
9
+ *
10
+ * 能力面只多了「读这一个固定目录」:根写死在 `dshHome()` 下面,路径由 slug 与包内相对
11
+ * 路径拼出来,取文件前先 resolve 再判前缀 —— 不接受的是一份任意路径,不是一个被拼坏的
12
+ * 字符串。仍然不暴露 shell、系统设置、写文件。
13
+ *
14
+ * 坏包一律**静默跳过**而不是报错:作者当前正在编辑的那个包大概率是半成品,它不该让整张
15
+ * 列表变红;超限(20MB / 200 个文件)同理,不列表就是答案。取文件那条才逐个回错误码。
16
+ */
17
+
18
+ const crypto = require('node:crypto');
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const { sendJson } = require('../shared/http-util');
23
+ const { dshHome } = require('../shared/paths');
24
+
25
+ /** 开发包根目录名。目录不存在时按需建出来 —— 作者要往里放东西。 */
26
+ const DEV_ROOT_NAME = 'extension-dev';
27
+ /** 目录名直接当 slug 用,所以只认这套字符;别的名字连读都不读。 */
28
+ const SLUG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
29
+ /** 整包上限:这是「现读现看」的通道,不是分发管道,太重的包手机也不该整份拉。 */
30
+ const MAX_PACKAGE_BYTES = 20 * 1024 * 1024;
31
+ const MAX_PACKAGE_FILES = 200;
32
+ /** 单个文件上限:一条 base64 响应要塞进手机内存,再大就该走别的路(发布到分发系统)。 */
33
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
34
+
35
+ function devRoot() {
36
+ return path.join(dshHome(), DEV_ROOT_NAME);
37
+ }
38
+
39
+ /** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
40
+ function fail(res, status, code, message) {
41
+ return sendJson(res, status, { ok: false, error: { code, message } });
42
+ }
43
+
44
+ /** manifest 里取一个非空字符串,缺了 / 类型不对 / 空串都退回默认值。 */
45
+ function textField(value, fallback) {
46
+ return typeof value === 'string' && value ? value : fallback;
47
+ }
48
+
49
+ function sha256Hex(raw) {
50
+ return crypto.createHash('sha256').update(raw).digest('hex');
51
+ }
52
+
53
+ /**
54
+ * 递归收集包内的普通文件,`path` 是相对包目录的 POSIX 路径。
55
+ *
56
+ * 名字以 `.` 开头的文件与目录一律不看:`.git`、`.DS_Store`、编辑器临时文件都属于作者自
57
+ * 己的现场,不是包内容。超限当场收手返回 false,让调用方把整包丢掉 —— 先收集完再判超限
58
+ * 等于把 500MB 的目录整个哈希一遍。
59
+ */
60
+ function collectFiles(directory, acc) {
61
+ let entries;
62
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return false; }
63
+ for (const entry of entries) {
64
+ if (entry.name.startsWith('.')) continue;
65
+ const full = path.join(directory, entry.name);
66
+ if (entry.isDirectory()) {
67
+ if (!collectFiles(full, acc)) return false;
68
+ continue;
69
+ }
70
+ if (!entry.isFile()) continue;
71
+ let raw;
72
+ try { raw = fs.readFileSync(full); } catch { continue; }
73
+ acc.bytes += raw.length;
74
+ acc.files.push({
75
+ path: path.relative(acc.root, full).split(path.sep).join('/'),
76
+ size: raw.length,
77
+ sha256: sha256Hex(raw),
78
+ });
79
+ if (acc.bytes > MAX_PACKAGE_BYTES || acc.files.length > MAX_PACKAGE_FILES) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * 扫一遍根目录,把每个子目录读成一个包描述。坏 manifest、超限、slug 不合规都直接跳过。
86
+ */
87
+ function listPackages(root) {
88
+ let entries;
89
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
90
+ const packages = [];
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory() || !SLUG_PATTERN.test(entry.name)) continue;
93
+ const directory = path.join(root, entry.name);
94
+ let manifest;
95
+ try {
96
+ manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
97
+ } catch { continue; }
98
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
99
+ const acc = { root: directory, files: [], bytes: 0 };
100
+ if (!collectFiles(directory, acc)) continue;
101
+ packages.push({
102
+ slug: entry.name,
103
+ name: textField(manifest.name, entry.name),
104
+ versionName: textField(manifest.versionName, textField(manifest.version, '0.0.0')),
105
+ apiVersion: textField(manifest.apiVersion, textField(manifest.api_version, '1')),
106
+ size: acc.bytes,
107
+ fileCount: acc.files.length,
108
+ files: acc.files,
109
+ });
110
+ }
111
+ return packages;
112
+ }
113
+
114
+ /**
115
+ * GET /api/mobile/v1/extension/dev/packages(token)
116
+ *
117
+ * 根目录不存在就建出来再回空列表:作者第一次打开开发包页时看到的是「目录已经在这儿了」,
118
+ * 而不是「宿主没给我建」。
119
+ */
120
+ async function handleDevPackages(_ctx, _req, res) {
121
+ const root = devRoot();
122
+ try { fs.mkdirSync(root, { recursive: true }); } catch { /* 建不出来就按空列表回,读的时候自然也没有 */ }
123
+ sendJson(res, 200, { ok: true, root, packages: listPackages(root) });
124
+ }
125
+
126
+ /**
127
+ * GET /api/mobile/v1/extension/dev/file?slug=…&path=…(token)
128
+ *
129
+ * 读一个包里的一个文件,回 base64 与 sha256。sha256 与列表里那条是同一份算法,手机据此
130
+ * 判断「这份和我列表上看到的是不是同一版」。
131
+ *
132
+ * 路径检查是两道:先挡明显不该出现的(绝对路径、`..` 段),再 resolve 之后判前缀 ——
133
+ * 后者才是真正判据,前缀比较必须带上分隔符,否则 `/x/extension-dev/foobar` 会被
134
+ * `/x/extension-dev/foo` 认成自己的子路径。
135
+ */
136
+ async function handleDevFile(_ctx, req, res) {
137
+ const url = new URL(req.url || '/', 'http://x');
138
+ const slug = (url.searchParams.get('slug') || '').trim();
139
+ const requested = (url.searchParams.get('path') || '').trim();
140
+ if (!slug || !requested || !SLUG_PATTERN.test(slug)) {
141
+ return fail(res, 400, 'bad-request', '需要合法的 slug 与 path(path 是相对包目录的路径)');
142
+ }
143
+
144
+ const directory = path.join(devRoot(), slug);
145
+ if (!fs.existsSync(path.join(directory, 'manifest.json'))) {
146
+ return fail(res, 404, 'dev-package-missing', `开发包目录里没有 ${slug} 这个包(缺 manifest.json)`);
147
+ }
148
+
149
+ if (path.isAbsolute(requested) || requested.split(/[\\/]/).includes('..')) {
150
+ return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
151
+ }
152
+ const target = path.resolve(directory, requested);
153
+ if (!target.startsWith(`${directory}${path.sep}`)) {
154
+ return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
155
+ }
156
+
157
+ let stats;
158
+ try { stats = fs.statSync(target); } catch { stats = null; }
159
+ if (!stats || !stats.isFile()) {
160
+ return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
161
+ }
162
+ if (stats.size > MAX_FILE_BYTES) {
163
+ return fail(res, 413, 'file-too-large', `单个文件上限 ${MAX_FILE_BYTES} 字节,这个超了`);
164
+ }
165
+
166
+ let raw;
167
+ try { raw = fs.readFileSync(target); } catch {
168
+ return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
169
+ }
170
+
171
+ const relative = path.relative(directory, target).split(path.sep).join('/');
172
+ sendJson(res, 200, {
173
+ ok: true,
174
+ path: relative,
175
+ size: raw.length,
176
+ sha256: sha256Hex(raw),
177
+ content: raw.toString('base64'),
178
+ });
179
+ }
180
+
181
+ module.exports = {
182
+ devRoot,
183
+ listPackages,
184
+ handleDevPackages,
185
+ handleDevFile,
186
+ MAX_PACKAGE_BYTES,
187
+ MAX_PACKAGE_FILES,
188
+ MAX_FILE_BYTES,
189
+ };
@@ -329,6 +329,14 @@ function retryFailure(failure) {
329
329
  return out.code !== undefined || out.message !== undefined ? out : null;
330
330
  }
331
331
 
332
+ /** 重试行属于哪一步:步骤一关就要把它作废(App 侧按这两个数认)。 */
333
+ function retryOwner(data) {
334
+ return {
335
+ ...(typeof data.turn === 'number' ? { turn: data.turn } : {}),
336
+ ...(typeof data.step === 'number' ? { step: data.step } : {}),
337
+ };
338
+ }
339
+
332
340
  /** 列表/概览的 lastMessage 预览只取有 role 的消息项(结构化行不参与预览)。 */
333
341
  function lastMessageOf(messages) {
334
342
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -834,6 +842,7 @@ function deriveMessages(events, sessionId, lookbackEvents) {
834
842
  failure: retryFailure(e.data.failure),
835
843
  seq,
836
844
  time,
845
+ ...retryOwner(e.data),
837
846
  };
838
847
  if (typeof e.data.delayMs === 'number') item.delayMs = e.data.delayMs;
839
848
  retryAt.set(id, out.length);
@@ -854,8 +863,17 @@ function deriveMessages(events, sessionId, lookbackEvents) {
854
863
  failure: null,
855
864
  seq,
856
865
  time,
866
+ ...retryOwner(e.data),
857
867
  });
858
868
  }
869
+ } else if (e.type === 'step/end' && e.data && typeof e.data.turn === 'number' && typeof e.data.step === 'number') {
870
+ // 这一步关了就把它里面还停在 scheduled 的重试标成 cancelled(桌面 `isClosed` 判的也是这一步
871
+ // 结束)。只认 turn/end 的话,重开这个会话会看到一条永远「等待重试」的旧行,和新的并排。
872
+ for (const index of retryAt.values()) {
873
+ const row = out[index];
874
+ if (row && row.kind === 'retry' && row.status === 'scheduled'
875
+ && row.turn === e.data.turn && row.step === e.data.step) row.status = 'cancelled';
876
+ }
859
877
  } else if (e.type === 'command/run' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '' && typeof e.data.name === 'string') {
860
878
  // 命令行:root 行(running)。同 commandId 重复 run(罕见)只更新位置字段。
861
879
  const id = e.data.commandId;
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 模型能力配置(移动端):公开参数快照 + 把用户选的那份写回这台电脑的渠道。
5
+ *
6
+ * 两条路由:
7
+ * - `GET /api/mobile/v1/model-meta`(token):整份公开参数快照(App 拉一次、落盘缓存,
8
+ * 之后搜索与命中都在 App 端做,断网 / 连不上 GitHub 都不影响)。另带 `overrides`:
9
+ * `llm-pi-ai` 里每条自建路由**已经显式写下的**能力字段,弹窗用它把当前值填对。
10
+ * - `POST /api/mobile/v1/model-meta/apply`(token):把一份配置写进那条自建渠道的模型。
11
+ *
12
+ * 为什么只在插件这一侧写:渠道真源是宿主的 `llm-pi-ai` 命名空间,手机没有直接读写宿主配置的
13
+ * 通道,这一层做的与「渠道管理」保存时完全一样 —— `settings.describe` 读出那一条路由、
14
+ * 合并这一份模型的能力字段、再按路径 `settings.mutate` 写回去(只写 `providers.<id>`,
15
+ * 不碰别的路由)。内置渠道(别的插件注册的 provider)不在这份清单里,**一律 409 说明改不了**,
16
+ * 不给一个点了必然失败的保存按钮留后路。
17
+ */
18
+
19
+ const { sendJson, readJsonBody } = require('../shared/http-util');
20
+ const { unwrap } = require('./envelope.js');
21
+ const { dropSessionModelsForDevice } = require('./session-cache.js');
22
+ const modelMeta = require('./model-meta.js');
23
+
24
+ /** 自建渠道写在这个命名空间里;与 `channels.js` 的 PI_AI_NS 同一个。 */
25
+ const PI_AI_NS = 'llm-pi-ai';
26
+
27
+ const MAX_PROVIDER_CHARS = 64;
28
+ const MAX_MODEL_CHARS = 200;
29
+ /** 上下文 / 输出上限的合理上限:再大就不是 token 数了(1 千万已经远超人能用的窗口)。 */
30
+ const MAX_TOKENS = 10_000_000;
31
+
32
+ /** `llm-pi-ai` 的 providers 字典;形状不对就当空表。 */
33
+ function piAiProvidersOf(value) {
34
+ const providers = value && typeof value === 'object' && !Array.isArray(value) ? value.providers : null;
35
+ return providers && typeof providers === 'object' && !Array.isArray(providers) ? providers : {};
36
+ }
37
+
38
+ async function readPiAiProviders(ctx) {
39
+ const describe = unwrap(await ctx.apiProxy.settings.describe({
40
+ rpcId: 'mobile',
41
+ method: 'settings.describe',
42
+ payload: {},
43
+ }));
44
+ if (describe.error) return { error: describe.error };
45
+ const namespaces = Array.isArray(describe.value && describe.value.namespaces) ? describe.value.namespaces : [];
46
+ const piAi = namespaces.find((entry) => entry && entry.ns === PI_AI_NS);
47
+ return { providers: piAiProvidersOf(piAi && piAi.value) };
48
+ }
49
+
50
+ /** 一条模型上「已经显式写过」的能力字段(只有写过的才算,缺省值不算用户的选择)。 */
51
+ function capabilityOf(model) {
52
+ if (!model || typeof model !== 'object') return undefined;
53
+ const caps = {};
54
+ if (Number(model.contextWindow) > 0) caps.contextWindow = Math.trunc(Number(model.contextWindow));
55
+ if (Number(model.maxTokens) > 0) caps.maxTokens = Math.trunc(Number(model.maxTokens));
56
+ if (Array.isArray(model.input) && model.input.includes('image')) caps.vision = true;
57
+ if (Array.isArray(model.reasoningEfforts)) {
58
+ const levels = model.reasoningEfforts.filter((level) => modelMeta.MODEL_LEVELS.includes(level));
59
+ if (levels.length > 0) caps.reasoning = levels;
60
+ } else if (model.reasoningEfforts && typeof model.reasoningEfforts === 'object') {
61
+ const levels = modelMeta.MODEL_LEVELS.filter((level) => model.reasoningEfforts[level] !== undefined);
62
+ if (levels.length === 0) caps.reasoning = [];
63
+ else caps.reasoning = levels;
64
+ } else if (model.reasoningEfforts === false) {
65
+ caps.reasoning = [];
66
+ }
67
+ return Object.keys(caps).length === 0 ? undefined : caps;
68
+ }
69
+
70
+ /** 路由发下来的那份配置里,哪些渠道已经写了能力字段。 */
71
+ function overridesOf(providers) {
72
+ const out = {};
73
+ for (const providerId of Object.keys(providers)) {
74
+ const profile = providers[providerId];
75
+ const models = profile && Array.isArray(profile.models) ? profile.models : [];
76
+ for (const model of models) {
77
+ const id = model && typeof model.id === 'string' ? model.id : '';
78
+ if (id === '') continue;
79
+ const caps = capabilityOf(model);
80
+ if (caps) out[`${providerId}\u0001${id}`] = caps;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * GET /api/mobile/v1/model-meta[?q=关键词]
88
+ *
89
+ * 不带 `q` 回整份快照(App 缓存那一份,约 450KB);带 `q` 只回这一搜的结果(给不缓存的一次性
90
+ * 查询用)。两种情况都带 `revision`:内容没变时 App 不必把整份重新落盘。
91
+ */
92
+ async function handleModelMeta(ctx, req, res) {
93
+ const url = new URL(req.url, 'http://mobile.local');
94
+ const query = url.searchParams.get('q') || '';
95
+ const limit = Number(url.searchParams.get('limit'));
96
+ const providers = await readPiAiProviders(ctx);
97
+ if (providers.error) return sendJson(res, 400, { ok: false, error: providers.error });
98
+ const overrides = overridesOf(providers.providers);
99
+ const revision = modelMeta.revision;
100
+ if (query.trim() !== '') {
101
+ sendJson(res, 200, { ok: true, revision, items: modelMeta.searchModelMeta(query, limit), overrides });
102
+ return;
103
+ }
104
+ const full = modelMeta.fullModelMeta();
105
+ sendJson(res, 200, { ok: true, revision: full.revision, total: full.total, items: full.items, overrides });
106
+ }
107
+
108
+ /** 一次提交里哪些字段是「用户真的给了」—— 缺的沿用那台电脑原来的值,别拿缺省值覆盖。 */
109
+ function normalizeApply(body) {
110
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
111
+ const model = typeof body.model === 'string' ? body.model.trim() : '';
112
+ if (provider === '' || provider.length > MAX_PROVIDER_CHARS) {
113
+ return { error: { code: 'bad-request', message: '渠道 id 不合法' } };
114
+ }
115
+ if (model === '' || model.length > MAX_MODEL_CHARS) {
116
+ return { error: { code: 'bad-request', message: '模型 id 不合法' } };
117
+ }
118
+ const patch = {};
119
+ if (body.contextWindow !== undefined) {
120
+ const value = Number(body.contextWindow);
121
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_TOKENS) {
122
+ return { error: { code: 'bad-request', message: '上下文窗口必须是正整数' } };
123
+ }
124
+ patch.contextWindow = value;
125
+ }
126
+ if (body.maxTokens !== undefined) {
127
+ const value = Number(body.maxTokens);
128
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_TOKENS) {
129
+ return { error: { code: 'bad-request', message: '输出上限必须是正整数' } };
130
+ }
131
+ patch.maxTokens = value;
132
+ }
133
+ if (body.vision !== undefined) {
134
+ if (typeof body.vision !== 'boolean') return { error: { code: 'bad-request', message: '读图能力必须是布尔值' } };
135
+ patch.vision = body.vision;
136
+ }
137
+ if (body.reasoning !== undefined) {
138
+ if (!Array.isArray(body.reasoning)) return { error: { code: 'bad-request', message: '推理档位必须是数组' } };
139
+ const levels = [];
140
+ for (const level of body.reasoning) {
141
+ if (!modelMeta.MODEL_LEVELS.includes(level)) {
142
+ return { error: { code: 'bad-request', message: `推理档位不认识:${String(level)}` } };
143
+ }
144
+ if (!levels.includes(level)) levels.push(level);
145
+ }
146
+ patch.reasoning = modelMeta.MODEL_LEVELS.filter((level) => levels.includes(level));
147
+ }
148
+ if (Object.keys(patch).length === 0) {
149
+ return { error: { code: 'bad-request', message: '没有要改的字段' } };
150
+ }
151
+ const context = patch.contextWindow;
152
+ const output = patch.maxTokens;
153
+ if (context !== undefined && output !== undefined && output >= context) {
154
+ return { error: { code: 'bad-request', message: '输出上限必须小于上下文窗口' } };
155
+ }
156
+ return { provider, model, patch };
157
+ }
158
+
159
+ /** 把一份能力配置合进那条模型(其它字段原样保留)。 */
160
+ function mergeModel(model, patch) {
161
+ const merged = { ...model };
162
+ if (patch.contextWindow !== undefined) merged.contextWindow = patch.contextWindow;
163
+ if (patch.maxTokens !== undefined) merged.maxTokens = patch.maxTokens;
164
+ if (patch.vision !== undefined) merged.input = patch.vision ? ['text', 'image'] : ['text'];
165
+ if (patch.reasoning !== undefined) {
166
+ merged.reasoningEfforts = patch.reasoning.length === 0
167
+ ? false
168
+ : Object.fromEntries(patch.reasoning.map((level) => [level, level]));
169
+ }
170
+ return merged;
171
+ }
172
+
173
+ /**
174
+ * POST /api/mobile/v1/model-meta/apply(token){ provider, model, contextWindow?, maxTokens?, vision?, reasoning? }
175
+ *
176
+ * 只写 `llm-pi-ai` 里真的存在的那条渠道的**那一个模型**:读出现有 profile、换掉这一条模型、
177
+ * 按路径 `set` 回去。不认识的能力字段与渠道层字段(`api` / `baseURL` / `apiKeyEnv`)原样保留,
178
+ * 所以改一次能力不会把桌面调好的地址或别人的路由冲掉。
179
+ */
180
+ async function handleModelMetaApply(ctx, req, res) {
181
+ const body = await readJsonBody(req, 64 * 1024);
182
+ if (!body || typeof body !== 'object') {
183
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要一份模型配置' } });
184
+ }
185
+ const normalized = normalizeApply(body);
186
+ if (normalized.error) return sendJson(res, 400, { ok: false, error: normalized.error });
187
+
188
+ const read = await readPiAiProviders(ctx);
189
+ if (read.error) return sendJson(res, 400, { ok: false, error: read.error });
190
+ const providers = read.providers;
191
+ const profile = providers[normalized.provider];
192
+ if (!profile) {
193
+ // 不是自建渠道:别的插件注册的 provider,手机这一侧没有归它管的配置。
194
+ return sendJson(res, 409, {
195
+ ok: false,
196
+ error: { code: 'model-meta-external', message: '这条渠道不归本机配置,改不了它的模型参数' },
197
+ });
198
+ }
199
+ const models = Array.isArray(profile.models) ? profile.models : [];
200
+ const index = models.findIndex((model) => model && model.id === normalized.model);
201
+ if (index < 0) {
202
+ return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这条渠道里没有这个模型' } });
203
+ }
204
+ const current = models[index];
205
+ const context = normalized.patch.contextWindow !== undefined ? normalized.patch.contextWindow : Number(current.contextWindow);
206
+ const output = normalized.patch.maxTokens !== undefined ? normalized.patch.maxTokens : Number(current.maxTokens);
207
+ if (Number(context) > 0 && Number(output) > 0 && output >= context) {
208
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '输出上限必须小于上下文窗口' } });
209
+ }
210
+
211
+ const nextModels = models.slice();
212
+ nextModels[index] = mergeModel(current, normalized.patch);
213
+ const write = unwrap(await ctx.apiProxy.settings.mutate({
214
+ rpcId: 'mobile',
215
+ method: 'settings.mutate',
216
+ payload: { ns: PI_AI_NS, ops: [{ op: 'set', path: ['providers', normalized.provider], value: { ...profile, models: nextModels } }] },
217
+ }));
218
+ if (write.error) return sendJson(res, 400, { ok: false, error: write.error });
219
+
220
+ // 模型目录(含能力字段投影)是设备级缓存、却按会话存:整批作废,别让别的会话继续吃旧值。
221
+ dropSessionModelsForDevice(ctx, req);
222
+ sendJson(res, 200, {
223
+ ok: true,
224
+ provider: normalized.provider,
225
+ model: normalized.model,
226
+ capability: capabilityOf(nextModels[index]) || {},
227
+ });
228
+ }
229
+
230
+ module.exports = {
231
+ handleModelMeta,
232
+ handleModelMetaApply,
233
+ normalizeApply,
234
+ mergeModel,
235
+ capabilityOf,
236
+ overridesOf,
237
+ PI_AI_NS,
238
+ MAX_TOKENS,
239
+ };