dsh-subagent-profile 0.3.0 → 0.3.2

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  <div align="center">
5
5
  <b style="font-size: 1.15em;">Subagent dispatch, profiled — the right agent for the right task (preset / model / reasoning effort)</b><br /><br />
6
6
  <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" />
7
- <img alt="Version: v0.3.0" src="https://img.shields.io/badge/Version-v0.3.0-blue.svg" />
7
+ <img alt="Version: v0.3.2" src="https://img.shields.io/badge/Version-v0.3.2-blue.svg" />
8
8
  <img alt="npm" src="https://img.shields.io/npm/v/dsh-subagent-profile.svg" />
9
9
  <img alt="DSH" src="https://img.shields.io/badge/DSH-0.1.0--rc.6%20~%200.1.1--rc.2-blue.svg" />
10
10
  </div>
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <div align="center">
5
5
  <b style="font-size: 1.15em;">子 Agent 派发方案化插件 —— 用对的人(预设 / 模型 / 推理强度)干对的事</b><br /><br />
6
6
  <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" />
7
- <img alt="Version: v0.3.0" src="https://img.shields.io/badge/Version-v0.3.0-blue.svg" />
7
+ <img alt="Version: v0.3.2" src="https://img.shields.io/badge/Version-v0.3.2-blue.svg" />
8
8
  <img alt="npm" src="https://img.shields.io/npm/v/dsh-subagent-profile.svg" />
9
9
  <img alt="DSH" src="https://img.shields.io/badge/DSH-0.1.0--rc.6%20~%200.1.1--rc.2-blue.svg" />
10
10
  </div>
package/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // index.mjs — dsh-subagent-profile 宿主侧正式插件 bundle(只做装配)。
1
+ // index.mjs — dsh-subagent-profile 宿主侧正式插件 bundle(只做装配)。
2
2
  // 源自原型动态插件 `code.host` 主体;宿主导入面(registerTool/defineTool/
3
3
  // handle)收敛在 lib/core/shims.mjs,装配块按模块拆分驻留 lib/core/(catalog /
4
4
  // presets-sync / profiles-store / cost-guard / whitelist / intersection /
@@ -13,6 +13,8 @@ import { dshHome, createProfileStore } from './lib/core/profiles-store.mjs';
13
13
  import { createHttpRoutes } from './lib/core/http-routes.mjs';
14
14
  import { createProfileProvider } from './lib/core/profile-provider.mjs';
15
15
  import { createDispatchTool } from './lib/core/dispatch-tool.mjs';
16
+ import { createCatalogCache } from './lib/core/catalog-cache.mjs';
17
+ import { tierSortKey } from './lib/core/pure.mjs';
16
18
 
17
19
  export const name = 'dsh-subagent-profile';
18
20
  export const inject = ['subagents', 'tools', 'agents'];
@@ -98,12 +100,13 @@ function profileSectionText(store, gate, context) {
98
100
  if (!gate(context)) return '';
99
101
  const rows = [...store.profiles.values()]
100
102
  .filter((p) => p.enabled !== false)
103
+ .sort((a, b) => tierSortKey(a.tokenTier) - tierSortKey(b.tokenTier))
101
104
  .map((p) => {
102
105
  const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
103
106
  return `- ${p.id}: ${desc}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`;
104
107
  });
105
108
  if (rows.length === 0) return '';
106
- const note = '- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
109
+ const note = '- 选择方案时先匹配任务复杂度与方案描述的能力边界,成本只在能力都胜任的方案之间比较——复杂任务不得为省 token 改用能力不足的便宜方案。\n- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
107
110
  return `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}\n${note}`;
108
111
  }
109
112
 
@@ -141,7 +144,7 @@ function registerSystemPromptSections(ctx, store, getEnabled) {
141
144
  // (listen) is async and may not be ready when this plugin's inject deps
142
145
  // resolve, so register inside an inject sub-scope that waits for it
143
146
  // (ctx.get would read undefined at apply time).
144
- function registerSettingsRoutes(ctx, store, getEnabled, setEnabled, syncTool) {
147
+ function registerSettingsRoutes(ctx, store, getEnabled, setEnabled, syncTool, catalog) {
145
148
  ctx.inject(['webServer'], (scope) => {
146
149
  scope.effect(createHttpRoutes({
147
150
  webServer: scope.webServer,
@@ -149,14 +152,24 @@ function registerSettingsRoutes(ctx, store, getEnabled, setEnabled, syncTool) {
149
152
  getEnabled,
150
153
  setEnabled,
151
154
  syncTool,
152
- getLlm: () => ctx.get('llm'),
153
- getAgentPresets: () => ctx.get('agentPresets'),
154
- getTools: () => ctx.tools,
155
+ catalog,
155
156
  logger: ctx.logger,
156
157
  }), 'dsh-subagent-profile: settings routes');
157
158
  });
158
159
  }
159
160
 
161
+ // 进程级共享 catalog 快照工厂:一处快照同时喂 /options 三路由与 dispatch 的
162
+ // cost guard(cost guard 经 parent.ctx.get('llm') 传父实例,同一 host llm 下
163
+ // 命中同一条目)。
164
+ function createSharedCatalog(ctx) {
165
+ return createCatalogCache({
166
+ getLlm: () => ctx.get('llm'),
167
+ getAgentPresets: () => ctx.get('agentPresets'),
168
+ getTools: () => ctx.tools,
169
+ logger: ctx.logger,
170
+ });
171
+ }
172
+
160
173
  export async function apply(ctx) {
161
174
  // Enable/disable switch (default on, runtime-toggled by the settings
162
175
  // page, persisted across restarts) + profile registry — lib/profiles-store
@@ -167,6 +180,8 @@ export async function apply(ctx) {
167
180
  store.loadProfiles();
168
181
  // Self-install the bundled "orchestrator" preset (idempotent, fail-soft).
169
182
  syncBundledPresetsToHome(ctx);
183
+ // 进程级共享 catalog 快照(/options 三路由 + dispatch cost guard 共用)。
184
+ const catalog = createSharedCatalog(ctx);
170
185
  // `dispatch` tool — lib/core/dispatch-tool.mjs: defineTool block (schema +
171
186
  // execute), the result-schema consistency lock and the syncTool
172
187
  // register/unregister logic.
@@ -179,6 +194,7 @@ export async function apply(ctx) {
179
194
  getService: (name) => ctx.get(name),
180
195
  logger: ctx.logger,
181
196
  subagents: ctx.subagents,
197
+ catalog,
182
198
  });
183
199
  // subagent-profiles service over the store's per-apply profiles Map.
184
200
  provideProfileService(ctx, store);
@@ -191,10 +207,11 @@ export async function apply(ctx) {
191
207
  store,
192
208
  getEnabled: () => enabled,
193
209
  logger: ctx.logger,
210
+ catalog,
194
211
  });
195
212
  if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
196
213
  // HTTP loopback routes for the Client settings UI — lib/core/http-routes.mjs.
197
- registerSettingsRoutes(ctx, store, () => enabled, (next) => { enabled = next; }, dispatch.syncTool);
214
+ registerSettingsRoutes(ctx, store, () => enabled, (next) => { enabled = next; }, dispatch.syncTool, catalog);
198
215
  // Teardown: unregister the dispatch tool (if still registered).
199
216
  ctx.effect(() => dispatch.dispose);
200
217
  }
package/lib/client.js CHANGED
@@ -14,8 +14,10 @@
14
14
  * bordered card per row with a layered name / description / meta layout, and a
15
15
  * dashed "+ 新增" button that expands an inline form card instead of a
16
16
  * permanently-visible input row. Dropdown options (preset / provider / model /
17
- * reasoning effort) are served by the host `/options` route from the live
18
- * presets roster and llm directory.
17
+ * reasoning effort) are served by the host `/options/summary` (models + presets,
18
+ * on settings open), `/options/efforts` (per-model, lazy on model select) and
19
+ * `/options/tools` (tool directory, lazy when the restriction panel opens)
20
+ * routes from the live presets roster and llm directory.
19
21
  *
20
22
  * 结构:纯数据(CSS/ZH 文案表)与纯函数(api/解析/文案映射等)驻留模块作用域
21
23
  * (load 调用之前的顶层声明);依赖 React 的组件经参数注入的 maker 函数装配
@@ -40,6 +42,7 @@ const CSS = [
40
42
  '.sap-rowActions{align-items:center;gap:4px;margin-left:auto;display:inline-flex}',
41
43
  '.sap-desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}',
42
44
  '.sap-warn{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}',
45
+ '.sap-versionWarn{background:var(--dsw-alias-state-warn-tertiary);border:1px solid var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);border-radius:8px;flex-direction:column;gap:4px;padding:10px 12px;font-size:12px;line-height:18px;margin:0;display:flex}',
43
46
  '.sap-chips{flex-wrap:wrap;gap:6px;display:flex}',
44
47
  '.sap-chip{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:6px;flex:none;align-items:center;gap:5px;padding:2px 8px;font-size:12px;line-height:18px;display:inline-flex}',
45
48
  '.sap-chipLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
@@ -86,6 +89,10 @@ const CSS = [
86
89
  '.sap-switch input:checked::before{left:18px}',
87
90
  '.sap-toolview{flex-direction:column;align-items:flex-start;gap:6px;padding:4px 8px;display:flex}',
88
91
  '.sap-toolText{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word}',
92
+ '.sap-toolSummary{align-items:center;gap:6px;flex-wrap:wrap;display:flex}',
93
+ '.sap-detailToggle{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);background:0 0;border-radius:10px;font:inherit;font-size:11px;line-height:16px;height:20px;padding:0 8px;cursor:pointer;flex:none;align-items:center;display:inline-flex}',
94
+ '.sap-detailToggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
95
+ '.sap-toolDetail{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);display:flex}',
89
96
  '.sap-toolModes{flex-wrap:wrap;gap:4px 16px;display:flex}',
90
97
  '.sap-toolGroups{flex-direction:column;gap:8px;display:flex}',
91
98
  '.sap-toolLayerCard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px}',
@@ -116,12 +123,21 @@ const ZH = {
116
123
  chipDisabled: '已禁用',
117
124
  ignoredMark: '已忽略',
118
125
  toolLabel: '派发子 Agent',
126
+ detailOpen: '详情',
127
+ detailClose: '收起',
119
128
  cont: 'continuable 子 Agent',
120
129
  canContinue: '可续跑',
121
130
  toolBackground: '后台任务',
122
131
  toolForeground: '前台',
123
132
  toolRunning: '运行中',
124
133
  tierZh: { cheap: '省 token(cheap)', balanced: '均衡(balanced)', premium: '高成本(premium)' },
134
+ stopReasonZh: { completed: '完成', 'max-tokens': '超出预算', aborted: '已中止', refusal: '已拒绝', error: '出错' },
135
+ cacheHit: '缓存命中',
136
+ usageInput: '输入',
137
+ usageOutput: '输出',
138
+ usageCacheRead: '缓存读',
139
+ usageCacheWrite: '缓存写',
140
+ usageReasoning: '推理',
125
141
  persistFail: '已保存但未持久化',
126
142
  persistFailHint: '(磁盘写入未成功,重启 dsh web 后该改动可能丢失)'
127
143
  }
@@ -225,7 +241,7 @@ function readDispatchRequest(block) {
225
241
  // 生效值来源——结构化 value 只存在于执行局部(dsh-tools 约定),且宿主未
226
242
  // 定义 output.presentationMeta(block.meta 为空)。解析成功返回
227
243
  // {kind, id, fields, ignored};任何一步不符预期返回 null,调用方回退请求值。
228
- const DISPATCH_RESULT_KEYS = ['profile', 'preset', 'provider', 'model', 'reasoningEffort', 'tokenTier']
244
+ const DISPATCH_RESULT_KEYS = ['profile', 'preset', 'provider', 'model', 'reasoningEffort', 'tokenTier', 'childTotalTokens', 'childUsage', 'elapsedMs', 'stopReason']
229
245
  function parseDispatchText(text) {
230
246
  if (typeof text !== 'string') return null
231
247
  const firstLine = text.split('\n')[0] ?? ''
@@ -280,6 +296,103 @@ function readDispatchResult(block) {
280
296
  return null
281
297
  }
282
298
 
299
+ // childTotalTokens 文本后缀:宿主 render 行里的 `childTotalTokens=<number>` 经
300
+ // parseDispatchText 解析为字符串,此处归一为「≈N tokens」。仅前台/后台 completed
301
+ // 结算携带;无值返回空串(不显示)。渲染位置紧邻后续的耗时显示。
302
+ function tokensSuffix(result, block) {
303
+ let raw = null
304
+ if (result && result.fields && result.fields.childTotalTokens !== undefined && result.fields.childTotalTokens !== '') {
305
+ raw = result.fields.childTotalTokens
306
+ } else if (block && block.meta && typeof block.meta === 'object' && block.meta.childTotalTokens !== undefined) {
307
+ raw = block.meta.childTotalTokens
308
+ }
309
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' && raw !== '' ? Number(raw) : NaN)
310
+ return Number.isFinite(n) ? ` · ≈${n} tokens` : ''
311
+ }
312
+
313
+ // 耗时 + 结束原因后缀:宿主 render 行里的 `elapsedMs=<n>` / `stopReason=<enum>`
314
+ // 经 parseDispatchText 解析为字符串,此处归一为「耗时=<n>ms · stopReason=<中文映射>」。
315
+ // 仅前台 completed 结算携带;后台结算经 job 结果呈现(不进 dispatch 卡片),
316
+ // continuable 不结算、不携带。任一字段缺失即省略该段。
317
+ function observabilitySuffix(result, block) {
318
+ let elapsedRaw = null
319
+ let stopRaw = null
320
+ if (result && result.fields) {
321
+ if (result.fields.elapsedMs !== undefined && result.fields.elapsedMs !== '') elapsedRaw = result.fields.elapsedMs
322
+ if (result.fields.stopReason !== undefined && result.fields.stopReason !== '') stopRaw = result.fields.stopReason
323
+ } else if (block && block.meta && typeof block.meta === 'object') {
324
+ // 运行中(无结果)或旧版结果:回退调用块 meta(向前兼容,与 tokenTier 同口径)。
325
+ if (block.meta.elapsedMs !== undefined) elapsedRaw = block.meta.elapsedMs
326
+ if (block.meta.stopReason !== undefined) stopRaw = block.meta.stopReason
327
+ }
328
+ const ms = typeof elapsedRaw === 'number' ? elapsedRaw : (typeof elapsedRaw === 'string' && elapsedRaw !== '' ? Number(elapsedRaw) : NaN)
329
+ const stopZh = typeof stopRaw === 'string' ? (ZH.stopReasonZh[stopRaw] || stopRaw) : ''
330
+ if (!Number.isFinite(ms) && stopZh === '') return ''
331
+ let out = ''
332
+ if (Number.isFinite(ms)) out += ` · 耗时=${ms}ms`
333
+ if (stopZh !== '') out += ` · stopReason=${stopZh}`
334
+ return out
335
+ }
336
+
337
+ // 把 childUsage 的(render 行 JSON 字符串或 block.meta 对象)归一为五段对象;
338
+ // 非 object / 非 number 段忽略,无任何有效段返回 null。可选字段(缓存读/写/推理)
339
+ // 缺值省略键,与 host 侧 collectChildUsage 输出对齐。
340
+ function parseChildUsage(raw) {
341
+ if (raw === undefined || raw === null || raw === '') return null
342
+ let usage = raw
343
+ if (typeof raw === 'string') {
344
+ try { usage = JSON.parse(raw) } catch { return null }
345
+ }
346
+ if (typeof usage !== 'object' || Array.isArray(usage)) return null
347
+ const out = {}
348
+ let any = false
349
+ for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
350
+ const value = usage[key]
351
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
352
+ out[key] = value
353
+ any = true
354
+ }
355
+ }
356
+ return any ? out : null
357
+ }
358
+
359
+ // 读取真实计费五段分解:result.fields.childUsage(render 行 JSON 串)优先,回退
360
+ // block.meta.childUsage(对象,向前兼容旧结果/未来 presentationMeta 投射)。
361
+ function usageFrom(result, block) {
362
+ if (result && result.fields && result.fields.childUsage !== undefined && result.fields.childUsage !== '') {
363
+ const parsed = parseChildUsage(result.fields.childUsage)
364
+ if (parsed !== null) return parsed
365
+ }
366
+ if (block && block.meta && typeof block.meta === 'object' && block.meta.childUsage !== undefined) {
367
+ return parseChildUsage(block.meta.childUsage)
368
+ }
369
+ return null
370
+ }
371
+
372
+ // 缓存命中后缀:childUsage.cacheReadTokens 存在且 >0 时追加「 · 缓存命中 M」到
373
+ // ≈N tokens 之后(前导分隔符与 tokensSuffix/observabilitySuffix 同口径,即使
374
+ // tokens 段缺失也不会粘在一起);无分解或缓存读为 0/缺失时返回空串。
375
+ function cacheHitSuffix(usage) {
376
+ if (usage === null) return ''
377
+ const cacheRead = usage.cacheReadTokens
378
+ return typeof cacheRead === 'number' && cacheRead > 0 ? ` · ${ZH.cacheHit} ${cacheRead}` : ''
379
+ }
380
+
381
+ // 五段分解行文案(纯函数,供卡片详情区渲染);无任何有效段返回空串。
382
+ function usageBreakdownText(usage) {
383
+ if (usage === null || typeof usage !== 'object') return ''
384
+ const parts = []
385
+ const push = (label, value) => {
386
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) parts.push(`${label} ${value}`)
387
+ }
388
+ push(ZH.usageInput, usage.inputTokens)
389
+ push(ZH.usageOutput, usage.outputTokens)
390
+ push(ZH.usageCacheRead, usage.cacheReadTokens)
391
+ push(ZH.usageCacheWrite, usage.cacheWriteTokens)
392
+ push(ZH.usageReasoning, usage.reasoningTokens)
393
+ return parts.join(' · ')
394
+ }
395
+
283
396
  // ── dispatch 工具卡(React 依赖经参数注入)─────────────────────────────────
284
397
 
285
398
  // 生效值:结果解析成功用结果(优先),字段缺失回退请求值。
@@ -331,7 +444,7 @@ function buildDispatchChips(el, chip, eff, result, block, ignored, request) {
331
444
  const m = rawOrInherit(eff.model)
332
445
  chips.push(chip('providerModel', `${ZH.chipProvider}·${ZH.chipModel}`, p === ZH.inherit && m === ZH.inherit ? ZH.inherit : `${p} / ${m}`, { dim: p === ZH.inherit && m === ZH.inherit }))
333
446
  chips.push(chip('effort', ZH.chipEffort, effortZh(eff.reasoningEffort), { dim: eff.reasoningEffort === '' || eff.reasoningEffort === '(default)' }))
334
- // tokenTier:宿主当前渲染结果尚未产出该字段;字段一旦出现即渲染(向前兼容)。
447
+ // tokenTier:宿主 render 行已产出;result.fields 缺失时回退 block.meta(向前兼容旧结果)。
335
448
  let tier = ''
336
449
  if (result && result.fields && typeof result.fields.tokenTier === 'string' && result.fields.tokenTier !== '') {
337
450
  tier = result.fields.tokenTier
@@ -367,7 +480,7 @@ function makeChip(el) {
367
480
  return chip
368
481
  }
369
482
 
370
- function makeDispatchToolview(el, chip) {
483
+ function makeDispatchToolview(el, chip, useState) {
371
484
  function DispatchToolview(props) {
372
485
  const block = props && props.block
373
486
  const request = readDispatchRequest(block)
@@ -375,12 +488,33 @@ function makeDispatchToolview(el, chip) {
375
488
  const settled = !!(block && typeof block === 'object' && 'kind' in block)
376
489
  const { eff, ignored, kindLabel } = resolveDispatchState(request, result, settled)
377
490
  const chips = buildDispatchChips(el, chip, eff, result, block, ignored, request)
378
- const p = rawOrInherit(eff.provider)
379
- const m = rawOrInherit(eff.model)
380
- const text = `${ZH.toolLabel}:${kindLabel} · 方案=${profileZh(eff.profile)} · 预设=${presetZh(eff.preset)} · 提供方=${p} · 模型=${m} · 推理强度=${effortZh(eff.reasoningEffort)}`
491
+ const usage = usageFrom(result, block)
492
+ const [open, setOpen] = useState(false)
493
+ // 摘要行只承载动态结果(成本/耗时/结束原因/缓存命中);静态配置(方案/预设/
494
+ // 提供方/模型/推理强度/档位)全部在 chip 行——避免同一组信息重复两遍。
495
+ const text = `${ZH.toolLabel}:${kindLabel}${tokensSuffix(result, block)}${cacheHitSuffix(usage)}${observabilitySuffix(result, block)}`
496
+ // 详情折叠入口:仅当存在真实计费分解(childUsage)时渲染;无分解数据
497
+ // (旧结果/无 usage 会话)不显示折叠区,向前兼容。
498
+ const detailToggle = usage === null ? null : el('button', {
499
+ type: 'button',
500
+ className: 'sap-detailToggle',
501
+ 'aria-expanded': open,
502
+ onClick: () => setOpen((prev) => !prev)
503
+ }, open ? ZH.detailClose : ZH.detailOpen)
504
+ // 无分解数据时保持原摘要行结构(零漂移);有分解时才加折叠入口与详情行。
505
+ const summary = usage === null
506
+ ? el('div', { className: 'sap-toolText' }, text)
507
+ : el('div', { className: 'sap-toolSummary' },
508
+ el('div', { className: 'sap-toolText' }, text),
509
+ detailToggle
510
+ )
511
+ const detailBody = open && usage !== null
512
+ ? el('div', { className: 'sap-toolDetail' }, usageBreakdownText(usage))
513
+ : null
381
514
  return el('div', { className: 'sap-toolview' },
382
515
  el('div', { className: 'sap-chips' }, chips),
383
- el('div', { className: 'sap-toolText' }, text)
516
+ summary,
517
+ detailBody
384
518
  )
385
519
  }
386
520
  return DispatchToolview
@@ -404,7 +538,7 @@ function makeApplyWrite(s) {
404
538
  return applyWrite
405
539
  }
406
540
 
407
- function makeFormActions(s) {
541
+ function makeFormActions(s, loadEfforts, loadTools) {
408
542
  const setField = (key) => (event) => {
409
543
  const value = event && event.target ? event.target.value : ''
410
544
  s.setForm((prev) => {
@@ -412,8 +546,14 @@ function makeFormActions(s) {
412
546
  if (key === 'model') next.reasoningEffort = '' // 换模型时重置推理强度
413
547
  return next
414
548
  })
549
+ if (key === 'model' && value !== '') {
550
+ loadEfforts(value) // 选中模型 → 懒拉该模型 efforts
551
+ }
552
+ }
553
+ const setToolMode = (mode) => () => {
554
+ if (mode !== 'none') loadTools() // 工具限制面板启用 → 拉完整工具目录
555
+ s.setForm((prev) => ({ ...prev, toolMode: mode }))
415
556
  }
416
- const setToolMode = (mode) => () => s.setForm((prev) => ({ ...prev, toolMode: mode }))
417
557
  const toggleTool = (name) => () => {
418
558
  s.setForm((prev) => {
419
559
  const cur = Array.isArray(prev.toolList) ? prev.toolList : []
@@ -439,7 +579,7 @@ function makeFormActions(s) {
439
579
  return { setField, setToolMode, toggleTool, selectLayer, clearLayer, invertLayer }
440
580
  }
441
581
 
442
- function makeEditActions(s) {
582
+ function makeEditActions(s, loadEfforts, loadTools) {
443
583
  const openAdd = () => {
444
584
  s.setError('')
445
585
  s.setSavedNotice('')
@@ -454,6 +594,10 @@ function makeEditActions(s) {
454
594
  const allowArr = Array.isArray(tf.allow) ? tf.allow.slice() : []
455
595
  const denyArr = Array.isArray(tf.deny) ? tf.deny.slice() : []
456
596
  const toolMode = allowArr.length > 0 ? 'allow' : (denyArr.length > 0 ? 'deny' : 'none')
597
+ if (toolMode !== 'none') loadTools() // 编辑含 toolFilter 的 profile → 预拉工具目录
598
+ if (typeof profile.model === 'string' && profile.model !== '') {
599
+ loadEfforts(profile.model) // 编辑含 model 的 profile → 预拉该模型 efforts(下拉不空白)
600
+ }
457
601
  s.setForm({
458
602
  id: profile.id,
459
603
  description: typeof profile.description === 'string' ? profile.description : '',
@@ -547,10 +691,41 @@ function makeRowActions(api, s, refresh, applyWrite) {
547
691
  return { toggleEnabled, setProfileEnabled, resetAll, remove }
548
692
  }
549
693
 
550
- function makeProfilesActions(api, s, refresh, applyWrite) {
694
+ // 选中模型 懒拉 /options/efforts(按模型 id 存入 options.efforts)。
695
+ function makeEffortsLoader(api, setOptions) {
696
+ return (model) => {
697
+ const q = `?model=${encodeURIComponent(model)}`
698
+ api(`/options/efforts${q}`)
699
+ .then((data) => {
700
+ const list = Array.isArray(data.efforts) ? data.efforts : []
701
+ setOptions((prev) => ({ ...prev, efforts: { ...prev.efforts, [model]: list } }))
702
+ })
703
+ .catch(() => {})
704
+ }
705
+ }
706
+
707
+ // 工具限制面板启用 → 拉 /options/tools(完整工具目录)。
708
+ function makeToolsLoader(api, setTools) {
709
+ return () => {
710
+ api('/options/tools')
711
+ .then((data) => setTools(Array.isArray(data.tools) ? data.tools : []))
712
+ .catch(() => {})
713
+ }
714
+ }
715
+
716
+ // 版本探测 → 拉 /options/versions;warnings 非空时设置页顶部常驻 amber 提示条。
717
+ function makeVersionsLoader(api, setVersionWarnings) {
718
+ return () => {
719
+ api('/options/versions')
720
+ .then((data) => setVersionWarnings(Array.isArray(data.warnings) ? data.warnings : []))
721
+ .catch(() => {})
722
+ }
723
+ }
724
+
725
+ function makeProfilesActions(api, s, refresh, applyWrite, loadEfforts, loadTools) {
551
726
  return {
552
- ...makeFormActions(s),
553
- ...makeEditActions(s),
727
+ ...makeFormActions(s, loadEfforts, loadTools),
728
+ ...makeEditActions(s, loadEfforts, loadTools),
554
729
  ...makeSubmitActions(api, s, refresh, applyWrite),
555
730
  ...makeRowActions(api, s, refresh, applyWrite),
556
731
  }
@@ -700,11 +875,14 @@ function buildAddCard(el, s, actions, toolSection) {
700
875
  ),
701
876
  buildSelectField(el, s, actions.setField, '模型(子 agent 使用的模型)', 'model', s.options.models,
702
877
  (m) => el('option', { key: m.id, value: m.id }, m.name && m.name !== m.id ? `${m.name}(${m.providerName || m.provider})` : m.id)),
878
+ s.options.models.length === 0 ? el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, '未检测到模型目录(headless 部署)——派发成本护栏将按「兼容模式」开关决定放行或拒绝') : null,
703
879
  el('details', { className: 'sap-customized' },
704
880
  el('summary', { className: 'sap-customizedSummary' }, '高级选项'),
705
881
  el('div', { className: 'sap-customizedBody' },
882
+ el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, 'continuable 模式忽略 preset 换用与推理强度(子 Agent 继承父预设),派发结果以「ignored」标注'),
706
883
  buildSelectField(el, s, actions.setField, '目标预设(切换子 agent 使用的 Agent 预设)', 'preset', s.options.presets,
707
884
  (p) => el('option', { key: p.id, value: p.id }, p.name && p.name !== p.id ? `${p.name}(${p.id})` : p.id)),
885
+ s.options.presets.length === 0 ? el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, '未检测到 system-trust 预设名册(rosterless)——目标预设下拉不可用') : null,
708
886
  buildSelectField(el, s, actions.setField, '提供方(模型服务商)', 'provider', providers,
709
887
  (p) => el('option', { key: p.id, value: p.id }, p.name && p.name !== p.id ? `${p.name}(${p.id})` : p.id)),
710
888
  s.form.model === ''
@@ -734,6 +912,9 @@ function buildSectionReturn(el, s, actions, rows, addCard) {
734
912
  el('span', null, s.enabled ? '已启用' : '已禁用')
735
913
  )
736
914
  ),
915
+ s.versionWarnings.length > 0
916
+ ? el('div', { className: 'sap-versionWarn' }, s.versionWarnings.map((warning, index) => el('span', { key: index }, warning)))
917
+ : null,
737
918
  el('p', { className: 'sap-intro' }, '在此管理「派发子 Agent」工具可用的 profile。内置方案可编辑、可删除、可点下方按钮批量重置回默认;每个方案可单独启用/禁用。'),
738
919
  el('div', { className: 'sap-addActions', style: { margin: '4px 0' } },
739
920
  el('button', { type: 'button', className: 'sap-secondaryButton', style: { height: '28px', padding: '0 12px', fontSize: '12px', borderRadius: '14px' }, onClick: actions.resetAll }, '重置所有内置方案')
@@ -763,32 +944,38 @@ function makeProfilesSection(el, api, chip, useState, useEffect, useCallback) {
763
944
  const [persistWarning, setPersistWarning] = useState('')
764
945
  const [form, setForm] = useState(EMPTY_FORM)
765
946
  const [tools, setTools] = useState([])
766
- const s = { profiles, options, enabled, adding, editingId, error, savedNotice, persistWarning, form, tools,
767
- setProfiles, setOptions, setEnabled, setAdding, setEditingId, setError, setSavedNotice, setPersistWarning, setForm, setTools }
947
+ const [versionWarnings, setVersionWarnings] = useState([])
948
+ const s = { profiles, options, enabled, adding, editingId, error, savedNotice, persistWarning, form, tools, versionWarnings,
949
+ setProfiles, setOptions, setEnabled, setAdding, setEditingId, setError, setSavedNotice, setPersistWarning, setForm, setTools, setVersionWarnings }
768
950
  const refresh = useCallback(() => {
769
951
  api('/list')
770
952
  .then((data) => setProfiles(Array.isArray(data.profiles) ? data.profiles : []))
771
953
  .catch((err) => setError(String((err && err.message) || err)))
772
954
  }, [])
773
955
  const loadOptions = useCallback(() => {
774
- api('/options')
956
+ api('/options/summary')
775
957
  .then((data) => {
776
958
  setEnabled(data.enabled !== false)
777
959
  setOptions({
778
960
  models: Array.isArray(data.models) ? data.models : [],
779
- efforts: data.efforts && typeof data.efforts === 'object' ? data.efforts : {},
961
+ efforts: {},
780
962
  presets: Array.isArray(data.presets) ? data.presets : []
781
963
  })
782
- setTools(Array.isArray(data.tools) ? data.tools : [])
783
964
  })
784
965
  .catch(() => {})
785
966
  }, [])
967
+ const loadEfforts = makeEffortsLoader(api, setOptions)
968
+ const loadTools = makeToolsLoader(api, setTools)
969
+ // 版本探测只挂载时拉一次:useCallback([]) 稳定引用,防 effect 依赖恒变
970
+ // 导致设置页打开期间无限重取(与 loadOptions 同款稳定化)。
971
+ const loadVersions = useCallback(() => makeVersionsLoader(api, setVersionWarnings)(), [])
786
972
  useEffect(() => {
787
973
  refresh()
788
974
  loadOptions()
789
- }, [refresh, loadOptions])
975
+ loadVersions()
976
+ }, [refresh, loadOptions, loadVersions])
790
977
  const applyWrite = makeApplyWrite(s)
791
- const actions = makeProfilesActions(api, s, refresh, applyWrite)
978
+ const actions = makeProfilesActions(api, s, refresh, applyWrite, loadEfforts, loadTools)
792
979
  const rows = buildProfileRows(el, chip, s, actions)
793
980
  const toolSection = buildToolSection(el, s, actions)
794
981
  return buildSectionReturn(el, s, actions, rows, buildAddCard(el, s, actions, toolSection))
@@ -809,7 +996,7 @@ window.__ModuleLoader__.load({
809
996
  const inject = ['slots']
810
997
 
811
998
  const chip = makeChip(el)
812
- const DispatchToolview = makeDispatchToolview(el, chip)
999
+ const DispatchToolview = makeDispatchToolview(el, chip, useState)
813
1000
  const ProfilesSection = makeProfilesSection(el, api, chip, useState, useEffect, useCallback)
814
1001
 
815
1002
  function apply(ctx) {