dsh-mobile-plugin 0.5.9 → 0.5.11

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.
@@ -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;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  const { sendJson, readJsonBody } = require('../shared/http-util');
15
+ const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
15
16
  const { unwrap } = require('./sessions.js');
16
17
 
17
18
  const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
@@ -245,7 +246,12 @@ async function handleHostSettings(ctx, req, res) {
245
246
  });
246
247
  }
247
248
 
248
- /** GET /api/mobile/v1/llm/providers —— 宿主的模型提供方名单(只读)。 */
249
+ /**
250
+ * GET /api/mobile/v1/llm/providers —— 宿主的模型提供方名单(只读)。
251
+ *
252
+ * 带 `?rev=` 时按内容指纹走条件响应:名单没变就只回一句 `unchanged`(几十字节),
253
+ * 不再把整份重传一遍。手机那边把它当设备级目录缓存,进页面先摆上次那份、再拿指纹问一次。
254
+ */
249
255
  async function handleLlmProviders(ctx, req, res) {
250
256
  const { value, error } = unwrap(await ctx.apiProxy.llm.providers({
251
257
  rpcId: 'mobile',
@@ -257,7 +263,8 @@ async function handleLlmProviders(ctx, req, res) {
257
263
  const list = Array.isArray(value && value.providers) ? value.providers : [];
258
264
  const providers = list.map(normalizeProvider).filter(Boolean);
259
265
 
260
- sendJson(res, 200, { ok: true, providers });
266
+ const body = { ok: true, providers, revision: stableRevision(providers) };
267
+ sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
261
268
  }
262
269
 
263
270
  /**
@@ -303,6 +310,9 @@ function normalizeModelGroup(group) {
303
310
  *
304
311
  * 与 `/llm/providers` 的区别:那条只说「有哪些提供方 / 哪个在生效」,这条给出每个提供方
305
312
  * 下面真正可请求的模型 id。手机上没有活动会话时(比如从 Git 页打开选择器)也能拿到。
313
+ *
314
+ * 这一份是全仓最大的一份目录响应(每个提供方下面几十上百条模型),而它只在用户加渠道、
315
+ * 换模型时变,所以同样吃 `?rev=` 的条件响应:没变时几十字节说清,别整份重传。
306
316
  */
307
317
  async function handleLlmModels(ctx, req, res) {
308
318
  const { value, error } = unwrap(await ctx.apiProxy.llm.models({
@@ -314,7 +324,8 @@ async function handleLlmModels(ctx, req, res) {
314
324
 
315
325
  const list = Array.isArray(value && value.groups) ? value.groups : [];
316
326
  const groups = list.map(normalizeModelGroup).filter(Boolean).slice(0, HOST_SETTINGS_MAX_NAMESPACES);
317
- sendJson(res, 200, { ok: true, groups });
327
+ const body = { ok: true, groups, revision: stableRevision(groups) };
328
+ sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
318
329
  }
319
330
 
320
331
  const HOST_SETTINGS_MAX_OPS = 40; // 一次写入的 op 数量上限
@@ -133,6 +133,56 @@ function createTextImageBudget(limit = MAX_TEXT_IMAGES) {
133
133
  return { remaining: limit };
134
134
  }
135
135
 
136
+ /**
137
+ * JPEG 的 EXIF 朝向(0x0112),没有就返回 0。
138
+ *
139
+ * 手机相机竖着拍出来的照片,像素本身是横的,靠 EXIF 里的朝向告诉播放器「先转 90°」。
140
+ * 宽高只按像素读的话,正文里就会出现「横盒子装竖图」:App 按 `resizeMode="contain"` 画,
141
+ * 图缩成中间一条、两侧一大片空白(2026-10-31 平板实测)。所以 5–8 这四档要对调宽高。
142
+ *
143
+ * 只认 JPEG:PNG / WebP 里带 eXIf 的极少,不值得为它多扫一遍。
144
+ */
145
+ function jpegOrientation(buf) {
146
+ let at = 2;
147
+ while (at + 4 < buf.length) {
148
+ if (buf[at] !== 0xff) { at += 1; continue; }
149
+ const marker = buf[at + 1];
150
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
151
+ // 到扫描数据了,后面不会再有 APP 段。
152
+ if (marker === 0xda) return 0;
153
+ const length = buf.readUInt16BE(at + 2);
154
+ if (length < 2) return 0;
155
+ if (marker === 0xe1 && at + 10 <= buf.length && buf.toString('latin1', at + 4, at + 10) === 'Exif\0\0') {
156
+ return tiffOrientation(buf, at + 10);
157
+ }
158
+ at += 2 + length;
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ /** EXIF 里的 TIFF 头 → 朝向值;字节序按 `II` / `MM` 判,读不到返回 0。 */
164
+ function tiffOrientation(buf, base) {
165
+ if (base + 8 > buf.length) return 0;
166
+ const order = buf.toString('latin1', base, base + 2);
167
+ if (order !== 'II' && order !== 'MM') return 0;
168
+ const little = order === 'II';
169
+ const u16 = (at) => (little ? buf.readUInt16LE(at) : buf.readUInt16BE(at));
170
+ const u32 = (at) => (little ? buf.readUInt32LE(at) : buf.readUInt32BE(at));
171
+ if (u16(base + 2) !== 42) return 0;
172
+ const ifd = base + u32(base + 4);
173
+ if (ifd + 2 > buf.length) return 0;
174
+ const count = u16(ifd);
175
+ for (let i = 0; i < count; i += 1) {
176
+ const entry = ifd + 2 + i * 12;
177
+ if (entry + 12 > buf.length) return 0;
178
+ if (u16(entry) === 0x0112) {
179
+ const value = u16(entry + 8);
180
+ return value >= 1 && value <= 8 ? value : 0;
181
+ }
182
+ }
183
+ return 0;
184
+ }
185
+
136
186
  /** 从文件头解析宽高;认不出来返回 null(不是错误:App 那边照旧现量)。 */
137
187
  function imageSizeOf(buf) {
138
188
  if (buf.length >= 24 && buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') {
@@ -152,6 +202,10 @@ function imageSizeOf(buf) {
152
202
  return null;
153
203
  }
154
204
  if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
205
+ // 竖拍照片的像素本身是横的,朝向 5–8 时播放器会转 90° 再画 —— 宽高必须跟着对调,
206
+ // 否则正文那个盒子是横的、图是竖的(见 jpegOrientation)。
207
+ const orientation = jpegOrientation(buf);
208
+ const swap = orientation >= 5 && orientation <= 8;
155
209
  let at = 2;
156
210
  while (at + 9 < buf.length) {
157
211
  if (buf[at] !== 0xff) { at += 1; continue; }
@@ -159,7 +213,9 @@ function imageSizeOf(buf) {
159
213
  if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
160
214
  const length = buf.readUInt16BE(at + 2);
161
215
  if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
162
- return { width: buf.readUInt16BE(at + 7), height: buf.readUInt16BE(at + 5) };
216
+ const width = buf.readUInt16BE(at + 7);
217
+ const height = buf.readUInt16BE(at + 5);
218
+ return swap ? { width: height, height: width } : { width, height };
163
219
  }
164
220
  if (length < 2) return null;
165
221
  at += 2 + length;
@@ -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
+ };