dsh-mobile-plugin 0.4.9 → 0.5.1

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/icon.png ADDED
Binary file
package/lib/client.js CHANGED
@@ -224,6 +224,15 @@ window.__ModuleLoader__.load({
224
224
  /** 自检上报的出口与间隔:只为可观测性存在,不做任何状态变更。 */
225
225
  const CLIENT_HALF_ENDPOINT = '/api/relay/client-half';
226
226
  const CLIENT_HALF_REPORT_MS = 10000;
227
+ /**
228
+ * 宿主半边的自检口(由外壳注册,实现挂了它也还在)。
229
+ *
230
+ * 实现装载失败时整张 `/api/relay/*` 都 404,这一项只显示一句「插件宿主侧没有加载」,
231
+ * 真正的原因(等哪个服务超时、哪一代装不上)就没人看得见。这两条是那个原因唯一的出口;
232
+ * 第二条点一下就在原地重新装载一次,不必重启整个 DSH。
233
+ */
234
+ const HOST_HALF_ENDPOINT = '/api/relay/host-half';
235
+ const HOST_HALF_RELOAD_ENDPOINT = '/api/relay/host-half/reload';
227
236
 
228
237
  /**
229
238
  * 把 sessionStatus 的快照摊成 [id, status] 列表。
@@ -469,6 +478,10 @@ window.__ModuleLoader__.load({
469
478
  panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
470
479
  loadFailed: '读取宿主状态失败',
471
480
  loadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
481
+ hostHalfReason: '宿主侧报的原因:',
482
+ hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
483
+ hostHalfReload: '重新装载',
484
+ hostHalfReloadFailed: '重新装载没成:',
472
485
  webAccessOn: '公网网页管理已开放,到期时间',
473
486
  webAccessOff: '公网网页管理已关闭',
474
487
  webAccessOpen: '开放网页管理 2 小时',
@@ -487,6 +500,10 @@ window.__ModuleLoader__.load({
487
500
  panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
488
501
  loadFailed: 'Failed to read host status',
489
502
  loadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
503
+ hostHalfReason: 'Reported by the host: ',
504
+ hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
505
+ hostHalfReload: 'Reload now',
506
+ hostHalfReloadFailed: 'Reload failed: ',
490
507
  webAccessOn: 'Public web management is open until',
491
508
  webAccessOff: 'Public web management is closed',
492
509
  webAccessOpen: 'Open web management for 2 hours',
@@ -514,6 +531,10 @@ window.__ModuleLoader__.load({
514
531
  const [webAccessBusy, setWebAccessBusy] = useState(false);
515
532
  /** 最近一次失败的 HTTP 状态码(0 = 网络异常 / 还没失败过)。404 单独说。 */
516
533
  const [errorStatus, setErrorStatus] = useState(0);
534
+ /** 宿主半边自检的结果(外壳给的那份),404 时才有内容。 */
535
+ const [half, setHalf] = useState(null);
536
+ const [halfBusy, setHalfBusy] = useState(false);
537
+ const [halfError, setHalfError] = useState(null);
517
538
 
518
539
  useEffect(() => {
519
540
  let alive = true;
@@ -528,6 +549,8 @@ window.__ModuleLoader__.load({
528
549
  if (!alive) return;
529
550
  setError(null);
530
551
  setErrorStatus(0);
552
+ setHalf(null);
553
+ setHalfError(null);
531
554
  setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
532
555
  setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
533
556
  if (retryTimer !== null) {
@@ -549,6 +572,20 @@ window.__ModuleLoader__.load({
549
572
  clearTimer(refreshTimer);
550
573
  refreshTimer = null;
551
574
  }
575
+ // 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
576
+ // 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到就当没有,别报错。
577
+ if (problem?.status === 404) {
578
+ try {
579
+ const picture = await call(HOST_HALF_ENDPOINT);
580
+ if (!alive) return;
581
+ setHalf(picture && typeof picture.half === 'object' ? picture.half : null);
582
+ } catch {
583
+ if (!alive) return;
584
+ setHalf(null);
585
+ }
586
+ } else {
587
+ setHalf(null);
588
+ }
552
589
  // 无权的远程设置页不轮询;宿主还没启动等临时故障每 3s 重试。
553
590
  if (problem?.status !== 403 && problem?.status !== 423 && retryTimer === null) {
554
591
  retryTimer = setInterval(() => {
@@ -580,6 +617,27 @@ window.__ModuleLoader__.load({
580
617
  }
581
618
  };
582
619
 
620
+ /**
621
+ * 让外壳当场把实现再装一次。
622
+ *
623
+ * 装载失败那阵子,这是唯一不必重启整个 DSH 的出路;成不成以宿主回来的那份自检为准,
624
+ * 失败原因原样贴出来(它往往就是「等哪个服务超时」,过一会儿自己就好了)。
625
+ */
626
+ const reloadHalf = async () => {
627
+ if (halfBusy) return;
628
+ setHalfBusy(true);
629
+ setHalfError(null);
630
+ try {
631
+ const next = await postJson(HOST_HALF_RELOAD_ENDPOINT, {});
632
+ if (next && typeof next.half === 'object') setHalf(next.half);
633
+ if (next && next.ok === false) setHalfError(errorText(next, 200));
634
+ } catch (problem) {
635
+ setHalfError(String((problem && problem.message) || problem));
636
+ } finally {
637
+ setHalfBusy(false);
638
+ }
639
+ };
640
+
583
641
  const url = typeof panelUrl === 'string' ? panelUrl : '';
584
642
  const webAccessEnabled = Boolean(webAccess && webAccess.enabled);
585
643
  const expiresAt = webAccess && Number.isFinite(webAccess.expiresAt)
@@ -588,11 +646,32 @@ window.__ModuleLoader__.load({
588
646
  let webAccessStatus = t('webAccessUnavailable');
589
647
  if (webAccess) webAccessStatus = webAccessEnabled ? `${t('webAccessOn')} ${expiresAt}` : t('webAccessOff');
590
648
 
649
+ const missing = errorStatus === 404;
650
+
591
651
  return h('div', { className: 'dshm_root' }, [
592
652
  h('h2', { className: 'dshm_heading' }, t('heading')),
593
653
  error
594
- ? h('p', { className: 'dshm_error' }, errorStatus === 404 ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
654
+ ? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
655
+ : null,
656
+ // 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
657
+ // 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
658
+ missing && half && typeof half.lastError === 'string' && half.lastError
659
+ ? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
660
+ : null,
661
+ missing && half && half.retrying === true
662
+ ? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
663
+ : null,
664
+ missing
665
+ ? h('div', { className: 'dshm_actions' }, [
666
+ h('button', {
667
+ type: 'button',
668
+ className: 'dshm_btn',
669
+ disabled: halfBusy,
670
+ onClick: reloadHalf,
671
+ }, t('hostHalfReload')),
672
+ ])
595
673
  : null,
674
+ halfError ? h('p', { className: 'dshm_error' }, `${t('hostHalfReloadFailed')}${halfError}`) : null,
596
675
 
597
676
  // 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
598
677
  // 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ import { createRuntime } from './shell/runtime.js'
28
28
  import * as updater from './shell/updater.js'
29
29
  import { installedPackageDir, installedVersion, profileNodeModules, snapshotRunningGeneration } from './shell/manager.js'
30
30
  import { createDeviceOta } from './shell/device-ota.js'
31
+ import { installHostHalf } from './shell/half-route.js'
31
32
 
32
33
  export const name = 'dsh-mobile-plugin'
33
34
  export const inject = ['webServer', 'sessions']
@@ -118,16 +119,21 @@ export function apply(ctx) {
118
119
  }
119
120
  }, 'dsh-mobile-plugin: generation runtime')
120
121
 
122
+ const installedShellHash = () => updater.shellFingerprint(installedPackageDir())
123
+
124
+ // 宿主半边的自检口(实现在 `shell/half-route.js`):实现装载失败时整张控制面路由
125
+ // 都不在,只有外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
126
+ ctx.effect(() => installHostHalf(ctx, runtime, { installedVersion, installedShellHash, runningShellHash }), 'dsh-mobile-plugin: host half self check')
127
+
121
128
  ctx.reflect.provide('dshMobilePluginRuntime', {
122
129
  status: () => ({
123
130
  ...runtime.status(),
124
131
  /** 盘上装着的版本(市场装完立刻变,和运行中的版本对照就能看出「要不要换」)。 */
125
132
  installedVersion: installedVersion(),
126
- /** 盘上那份的外壳指纹与跑着的对不上 = 新版本连外壳一起改了,那部分要等下次启动才生效。 */
127
133
  runningShellHash,
128
- installedShellHash: updater.shellFingerprint(installedPackageDir()),
134
+ installedShellHash: installedShellHash(),
129
135
  shellChanged: (() => {
130
- const installed = updater.shellFingerprint(installedPackageDir())
136
+ const installed = installedShellHash()
131
137
  return installed !== null && runningShellHash !== null && installed !== runningShellHash
132
138
  })(),
133
139
  profileNodeModules: profileNodeModules(),
@@ -12,6 +12,11 @@
12
12
  */
13
13
 
14
14
  const { cutAssistantBlock, cutToolResultEvent, createToolNameIndex } = require('./wire-cut.js');
15
+ const {
16
+ reasoningStepSummary,
17
+ toolStepSummary,
18
+ toolStepState,
19
+ } = require('./step-index.js');
15
20
 
16
21
  /**
17
22
  * 从 session.list 摘要行取会话标题(与桌面端侧栏同源,不再从 history 窗口猜):
@@ -117,7 +122,14 @@ function buildToolResultMap(events) {
117
122
  return '';
118
123
  }).filter(Boolean).join('');
119
124
  const images = toolResultImages(parts);
120
- map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}) });
125
+ // `seq` 只给「按需取详情」用:手机点开这一步时拿它去回拉那一小窗事件。
126
+ map.set(src.callId, {
127
+ isError,
128
+ code,
129
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
130
+ ...(output ? { output } : {}),
131
+ ...(images.length ? { images } : {}),
132
+ });
121
133
  }
122
134
  return map;
123
135
  }
@@ -428,8 +440,59 @@ function deriveMessages(events) {
428
440
  // 命令行按 commandId 唯一:command/run 发出行,command/done 原地升级(保 run 的 name,位置随 done 事件 seq 归位)。
429
441
  const commandAt = new Map();
430
442
 
443
+ /**
444
+ * 每一轮的「过程清单」:一步一行摘要,正文仍然一个字节都不下发。
445
+ *
446
+ * 清单挂在**这一轮开头那条消息**上(App 的头条行就是它派生的),收口后点开清单再按
447
+ * `seq` / `resultSeq` 去 `/session/step` 取那一步的详情 —— 这就是「按需获取」的那一半。
448
+ * 只做摘要不做正文:一条推理正文平均几万字、一次 `write` 的参数就是整份文件,把它们
449
+ * 塞进消息响应里正是 2026-10-27 砍掉它的理由。
450
+ */
451
+ const stepsByTurn = new Map();
452
+ const openerByTurn = new Map();
453
+ let lastOpener = null;
454
+ const collectSteps = (content, e) => {
455
+ if (!Array.isArray(content)) return;
456
+ const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
457
+ if (turn === undefined) return;
458
+ if (!openerByTurn.has(turn) && lastOpener) openerByTurn.set(turn, lastOpener);
459
+ const list = stepsByTurn.get(turn) || [];
460
+ content.forEach((raw, index) => {
461
+ if (!raw || typeof raw !== 'object') return;
462
+ if (raw.type === 'reasoning') {
463
+ const text = typeof raw.text === 'string' ? raw.text : '';
464
+ // 空推理块(只有零宽字符那种)不算一步:App 点开一条什么都没有的行没有意义。
465
+ if (text.trim() === '') return;
466
+ list.push({
467
+ kind: 'think',
468
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
469
+ index,
470
+ summary: reasoningStepSummary(text),
471
+ });
472
+ return;
473
+ }
474
+ if (raw.type !== 'tool-call') return;
475
+ const callId = raw.id != null ? String(raw.id) : undefined;
476
+ const result = callId ? toolResults.get(callId) : undefined;
477
+ const rawArgs = typeof raw.arguments === 'string'
478
+ ? raw.arguments
479
+ : (raw.arguments != null ? JSON.stringify(raw.arguments) : '');
480
+ list.push({
481
+ kind: 'tool',
482
+ ...(callId ? { callId } : {}),
483
+ ...(typeof raw.name === 'string' && raw.name ? { name: raw.name } : {}),
484
+ summary: toolStepSummary(raw.name, rawArgs),
485
+ state: toolStepState(result),
486
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
487
+ ...(result && Number.isSafeInteger(result.seq) ? { resultSeq: result.seq } : {}),
488
+ });
489
+ });
490
+ if (list.length > 0) stepsByTurn.set(turn, list);
491
+ };
492
+
431
493
  // 普通消息(user/assistant)沿用原折叠逻辑。
432
494
  const pushMessage = (msg, e) => {
495
+ collectSteps(msg.content, e);
433
496
  const parsed = parseBlocks(msg.content, toolResults);
434
497
  // 工具产出的图挂成这条消息的图片附件:App 把它画在气泡上方(缩略图 + 文件名,点开全屏),
435
498
  // 与用户自己发的图同一种形状。工具卡里不再重复画同一张图 —— 一张图两处画会把字节也翻倍。
@@ -501,6 +564,8 @@ function deriveMessages(events) {
501
564
  if (timing) item.timing = timing;
502
565
  }
503
566
  out.push(item);
567
+ // 这一轮的过程清单挂在**开启这一轮的那条消息**上:App 的头条行按它派生。
568
+ if (item.role === 'user') lastOpener = item;
504
569
  };
505
570
 
506
571
  // 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
@@ -747,6 +812,19 @@ function deriveMessages(events) {
747
812
  }
748
813
  // tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
749
814
  }
815
+ // 过程清单按轮回填:`steps` 只带摘要与位置,正文要用户点开某一步时再去取。
816
+ if (stepsByTurn.size > 0) {
817
+ for (const [turn, steps] of stepsByTurn) {
818
+ const opener = openerByTurn.get(turn);
819
+ if (!opener || steps.length === 0) continue;
820
+ opener.turnProcess = {
821
+ ...(opener.turnProcess && typeof opener.turnProcess === 'object' ? opener.turnProcess : {}),
822
+ count: steps.length,
823
+ steps,
824
+ ...(Number.isSafeInteger(opener.seq) ? { startSeq: opener.seq } : {}),
825
+ };
826
+ }
827
+ }
750
828
  // 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
751
829
  // 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
752
830
  // 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
@@ -869,13 +947,19 @@ function thinEntryMessages(items, options = {}) {
869
947
  const endSeq = Number.isSafeInteger(last.seq) ? last.seq : opener.seq;
870
948
  const at = kept.indexOf(opener);
871
949
  if (at >= 0) {
950
+ // 清单(`steps`)随入口窗口一起下发:过程正文不在线上,这一行摘要就是用户
951
+ // 「值不值得点开」的全部依据,摘掉它这一轮就没法点开了。
952
+ const steps = opener.turnProcess && Array.isArray(opener.turnProcess.steps)
953
+ ? opener.turnProcess.steps
954
+ : null;
872
955
  kept[at] = {
873
956
  ...kept[at],
874
957
  turnProcess: {
875
958
  startSeq: opener.seq,
876
959
  endSeq,
877
- count: processCount,
960
+ count: steps ? steps.length : processCount,
878
961
  ...(typeof last.time === 'number' ? { endTime: last.time } : {}),
962
+ ...(steps ? { steps } : {}),
879
963
  },
880
964
  };
881
965
  }
@@ -16,6 +16,9 @@ const { unwrap } = require('./sessions.js');
16
16
 
17
17
  const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
18
18
  const HOST_SETTINGS_MAX_MODELS_PER_GROUP = 200; // 单个提供方下的模型条数上限
19
+ const HOST_SETTINGS_MAX_FIELDS = 40; // 单个命名空间透出的字段元数据条数上限
20
+ const HOST_SETTINGS_MAX_CHOICES = 40; // 单个字段的候选值上限
21
+ const HOST_SETTINGS_MAX_EFFORTS = 8; // 单个模型的推理强度档位上限
19
22
  const HOST_SETTINGS_VALUE_DEPTH = 4; // 值递归深度上限
20
23
  const HOST_SETTINGS_VALUE_KEYS = 40; // 每层键/元素数量上限
21
24
  const HOST_SETTINGS_VALUE_CHARS = 4000; // 单个字符串长度上限
@@ -51,6 +54,21 @@ function boundValue(value, depth = 0) {
51
54
  return null;
52
55
  }
53
56
 
57
+ /**
58
+ * 取出序列化 zod 树的根字段表。
59
+ *
60
+ * 宿主 `settings.describe` 里的 schema 是 schemastery 的 `{ uid, refs }` 形状:refs 以数字 id
61
+ * 存放每个节点,根节点的 `dict` 是「字段名 → 子节点 id」。凭据引用与字段控件都要先摸到这张表。
62
+ */
63
+ function schemaDict(schema) {
64
+ const refs = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema.refs : null;
65
+ if (!refs || typeof refs !== 'object' || Array.isArray(refs)) return null;
66
+ const root = refs[String(schema.uid)];
67
+ const dict = root && typeof root === 'object' ? root.dict : null;
68
+ if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return null;
69
+ return { refs, dict };
70
+ }
71
+
54
72
  /**
55
73
  * 从序列化的 zod 树里读出「这个命名空间的密钥写到哪个凭据引用上」。
56
74
  *
@@ -62,11 +80,9 @@ function boundValue(value, depth = 0) {
62
80
  * 没有密钥槽的设置项画密码框。
63
81
  */
64
82
  function credentialRefOf(schema, value) {
65
- const refs = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema.refs : null;
66
- if (!refs || typeof refs !== 'object' || Array.isArray(refs)) return null;
67
- const root = refs[String(schema.uid)];
68
- const dict = root && typeof root === 'object' ? root.dict : null;
69
- if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return null;
83
+ const shape = schemaDict(schema);
84
+ if (!shape) return null;
85
+ const { refs, dict } = shape;
70
86
 
71
87
  let refField = '';
72
88
  let refDefault = '';
@@ -90,6 +106,65 @@ function credentialRefOf(schema, value) {
90
106
  return ref ? { ref, refField } : null;
91
107
  }
92
108
 
109
+ /**
110
+ * 一个字段该画什么控件,按宿主 schema 的形状定。
111
+ *
112
+ * 手机端把设置项直接铺成卡片,靠这张表决定控件,不再一律给文本框:
113
+ * - `boolean` → 开关
114
+ * - `union` 且每一支都是常量(`z.union(['standard', 'compact'])`)→ 点选,候选就是那些常量
115
+ * - `number` → 数字输入(带宿主的 min / max / step)
116
+ * - `string` → 文本框
117
+ *
118
+ * 其它形状(嵌套对象 / 数组 / transform)不出现:那种字段在手机上本来就不给控件。只描述
119
+ * 根那一层的字段,不透整棵树——宿主 schema 里对手机没用的东西仍然留在插件这一侧。
120
+ */
121
+ function describeField(node, refs) {
122
+ const meta = node && typeof node === 'object' && node.meta && typeof node.meta === 'object' ? node.meta : {};
123
+ let type = '';
124
+ const out = {};
125
+ if (node && node.type === 'boolean') type = 'boolean';
126
+ else if (node && node.type === 'string') type = 'string';
127
+ else if (node && node.type === 'number') {
128
+ type = 'number';
129
+ if (typeof meta.min === 'number' && Number.isFinite(meta.min)) out.min = meta.min;
130
+ if (typeof meta.max === 'number' && Number.isFinite(meta.max)) out.max = meta.max;
131
+ if (typeof meta.step === 'number' && Number.isFinite(meta.step)) out.step = meta.step;
132
+ } else if (node && node.type === 'const') {
133
+ type = 'enum';
134
+ out.options = [node.value];
135
+ } else if (node && node.type === 'union') {
136
+ // 只有「每一支都是常量」才算候选集合;带 transform / 对象的联合没法枚举。
137
+ const options = [];
138
+ const list = Array.isArray(node.list) ? node.list : [];
139
+ let allConst = list.length > 0;
140
+ for (const uid of list) {
141
+ const child = refs[String(uid)];
142
+ if (child && child.type === 'const') options.push(child.value);
143
+ else { allConst = false; break; }
144
+ }
145
+ if (!allConst) return null;
146
+ type = 'enum';
147
+ out.options = options.slice(0, HOST_SETTINGS_MAX_CHOICES);
148
+ } else return null;
149
+ if (type === 'enum' && (!Array.isArray(out.options) || out.options.length < 2)) return null;
150
+ if (meta.required === true) out.required = true;
151
+ out.type = type;
152
+ return out;
153
+ }
154
+
155
+ /** 一个命名空间里各字段的控件形状;推不出来(老宿主 / 畸形 schema)就是 null。 */
156
+ function fieldsOf(schema) {
157
+ const shape = schemaDict(schema);
158
+ if (!shape) return null;
159
+ const fields = [];
160
+ for (const key of Object.keys(shape.dict).slice(0, HOST_SETTINGS_MAX_FIELDS)) {
161
+ const node = shape.refs[String(shape.dict[key])];
162
+ const field = describeField(node, shape.refs);
163
+ if (field) fields.push({ key, ...field });
164
+ }
165
+ return fields.length > 0 ? fields : null;
166
+ }
167
+
93
168
  /**
94
169
  * 把 settings.describe 的一条命名空间视图压成手机需要的形状。
95
170
  *
@@ -124,6 +199,10 @@ function normalizeNamespace(item) {
124
199
  };
125
200
  if (item.base !== undefined) out.base = boundValue(item.base);
126
201
  if (item.user !== undefined) out.user = boundValue(item.user);
202
+ // 每个字段该画什么控件(开关 / 点选 / 数字 / 文本框):只描述根这一层,宿主整棵 schema 的
203
+ // 其余部分仍然留在插件这一侧,不透给手机。推不出来就不给,App 回退成文本框。
204
+ const fields = fieldsOf(item.schema);
205
+ if (fields) out.fields = fields;
127
206
  // 只有「宿主确实报了密钥槽」且「schema 里能推导出凭据引用」时才给出引用名;
128
207
  // 两者缺一,App 就不该显示写密钥的入口。
129
208
  if (secrets.length > 0) {
@@ -181,6 +260,21 @@ async function handleLlmProviders(ctx, req, res) {
181
260
  sendJson(res, 200, { ok: true, providers });
182
261
  }
183
262
 
263
+ /**
264
+ * 一个模型支持的推理强度档位。
265
+ *
266
+ * 宿主给的是一张「id → 显示名」的表(如 `{ low: 'low', high: 'high', max: 'max' }`),偶尔是
267
+ * 数组。手机只要档位 id:设置里写的是 id,显示名归 App 的字典。没有就返回空数组。
268
+ */
269
+ function normalizeReasoningEfforts(value) {
270
+ const ids = Array.isArray(value)
271
+ ? value
272
+ : (value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : []);
273
+ return ids
274
+ .filter((id) => typeof id === 'string' && id !== '')
275
+ .slice(0, HOST_SETTINGS_MAX_EFFORTS);
276
+ }
277
+
184
278
  /** 把 llm.models 的一组渠道压成手机选择器要的形状;没有模型的组丢掉。 */
185
279
  function normalizeModelGroup(group) {
186
280
  if (!group || typeof group !== 'object') return null;
@@ -193,7 +287,10 @@ function normalizeModelGroup(group) {
193
287
  const id = typeof item.id === 'string' ? item.id.trim() : '';
194
288
  if (!id) return null;
195
289
  const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : id;
196
- return { id, name };
290
+ // 推理强度档位给设置页里的「推理强度」用:那个字段在宿主 schema 里只是普通字符串,
291
+ // 唯一知道有哪些档位的地方就是模型目录这一份。
292
+ const reasoningEfforts = normalizeReasoningEfforts(item.reasoningEfforts);
293
+ return { id, name, ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}) };
197
294
  })
198
295
  .filter(Boolean)
199
296
  .slice(0, HOST_SETTINGS_MAX_MODELS_PER_GROUP);