dsh-subagent-profile 0.3.0 → 0.3.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/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.1" src="https://img.shields.io/badge/Version-v0.3.1-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.1" src="https://img.shields.io/badge/Version-v0.3.1-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,6 +100,7 @@ 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})` : ''}`;
@@ -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}',
@@ -122,6 +125,7 @@ const ZH = {
122
125
  toolForeground: '前台',
123
126
  toolRunning: '运行中',
124
127
  tierZh: { cheap: '省 token(cheap)', balanced: '均衡(balanced)', premium: '高成本(premium)' },
128
+ stopReasonZh: { completed: '完成', 'max-tokens': '超出预算', aborted: '已中止', refusal: '已拒绝', error: '出错' },
125
129
  persistFail: '已保存但未持久化',
126
130
  persistFailHint: '(磁盘写入未成功,重启 dsh web 后该改动可能丢失)'
127
131
  }
@@ -225,7 +229,7 @@ function readDispatchRequest(block) {
225
229
  // 生效值来源——结构化 value 只存在于执行局部(dsh-tools 约定),且宿主未
226
230
  // 定义 output.presentationMeta(block.meta 为空)。解析成功返回
227
231
  // {kind, id, fields, ignored};任何一步不符预期返回 null,调用方回退请求值。
228
- const DISPATCH_RESULT_KEYS = ['profile', 'preset', 'provider', 'model', 'reasoningEffort', 'tokenTier']
232
+ const DISPATCH_RESULT_KEYS = ['profile', 'preset', 'provider', 'model', 'reasoningEffort', 'tokenTier', 'childTotalTokens', 'elapsedMs', 'stopReason']
229
233
  function parseDispatchText(text) {
230
234
  if (typeof text !== 'string') return null
231
235
  const firstLine = text.split('\n')[0] ?? ''
@@ -280,6 +284,44 @@ function readDispatchResult(block) {
280
284
  return null
281
285
  }
282
286
 
287
+ // childTotalTokens 文本后缀:宿主 render 行里的 `childTotalTokens=<number>` 经
288
+ // parseDispatchText 解析为字符串,此处归一为「≈N tokens」。仅前台/后台 completed
289
+ // 结算携带;无值返回空串(不显示)。渲染位置紧邻后续的耗时显示。
290
+ function tokensSuffix(result, block) {
291
+ let raw = null
292
+ if (result && result.fields && result.fields.childTotalTokens !== undefined && result.fields.childTotalTokens !== '') {
293
+ raw = result.fields.childTotalTokens
294
+ } else if (block && block.meta && typeof block.meta === 'object' && block.meta.childTotalTokens !== undefined) {
295
+ raw = block.meta.childTotalTokens
296
+ }
297
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' && raw !== '' ? Number(raw) : NaN)
298
+ return Number.isFinite(n) ? ` · ≈${n} tokens` : ''
299
+ }
300
+
301
+ // 耗时 + 结束原因后缀:宿主 render 行里的 `elapsedMs=<n>` / `stopReason=<enum>`
302
+ // 经 parseDispatchText 解析为字符串,此处归一为「耗时=<n>ms · stopReason=<中文映射>」。
303
+ // 仅前台 completed 结算携带;后台结算经 job 结果呈现(不进 dispatch 卡片),
304
+ // continuable 不结算、不携带。任一字段缺失即省略该段。
305
+ function observabilitySuffix(result, block) {
306
+ let elapsedRaw = null
307
+ let stopRaw = null
308
+ if (result && result.fields) {
309
+ if (result.fields.elapsedMs !== undefined && result.fields.elapsedMs !== '') elapsedRaw = result.fields.elapsedMs
310
+ if (result.fields.stopReason !== undefined && result.fields.stopReason !== '') stopRaw = result.fields.stopReason
311
+ } else if (block && block.meta && typeof block.meta === 'object') {
312
+ // 运行中(无结果)或旧版结果:回退调用块 meta(向前兼容,与 tokenTier 同口径)。
313
+ if (block.meta.elapsedMs !== undefined) elapsedRaw = block.meta.elapsedMs
314
+ if (block.meta.stopReason !== undefined) stopRaw = block.meta.stopReason
315
+ }
316
+ const ms = typeof elapsedRaw === 'number' ? elapsedRaw : (typeof elapsedRaw === 'string' && elapsedRaw !== '' ? Number(elapsedRaw) : NaN)
317
+ const stopZh = typeof stopRaw === 'string' ? (ZH.stopReasonZh[stopRaw] || stopRaw) : ''
318
+ if (!Number.isFinite(ms) && stopZh === '') return ''
319
+ let out = ''
320
+ if (Number.isFinite(ms)) out += ` · 耗时=${ms}ms`
321
+ if (stopZh !== '') out += ` · stopReason=${stopZh}`
322
+ return out
323
+ }
324
+
283
325
  // ── dispatch 工具卡(React 依赖经参数注入)─────────────────────────────────
284
326
 
285
327
  // 生效值:结果解析成功用结果(优先),字段缺失回退请求值。
@@ -331,7 +373,7 @@ function buildDispatchChips(el, chip, eff, result, block, ignored, request) {
331
373
  const m = rawOrInherit(eff.model)
332
374
  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
375
  chips.push(chip('effort', ZH.chipEffort, effortZh(eff.reasoningEffort), { dim: eff.reasoningEffort === '' || eff.reasoningEffort === '(default)' }))
334
- // tokenTier:宿主当前渲染结果尚未产出该字段;字段一旦出现即渲染(向前兼容)。
376
+ // tokenTier:宿主 render 行已产出;result.fields 缺失时回退 block.meta(向前兼容旧结果)。
335
377
  let tier = ''
336
378
  if (result && result.fields && typeof result.fields.tokenTier === 'string' && result.fields.tokenTier !== '') {
337
379
  tier = result.fields.tokenTier
@@ -377,7 +419,7 @@ function makeDispatchToolview(el, chip) {
377
419
  const chips = buildDispatchChips(el, chip, eff, result, block, ignored, request)
378
420
  const p = rawOrInherit(eff.provider)
379
421
  const m = rawOrInherit(eff.model)
380
- const text = `${ZH.toolLabel}:${kindLabel} · 方案=${profileZh(eff.profile)} · 预设=${presetZh(eff.preset)} · 提供方=${p} · 模型=${m} · 推理强度=${effortZh(eff.reasoningEffort)}`
422
+ const text = `${ZH.toolLabel}:${kindLabel} · 方案=${profileZh(eff.profile)} · 预设=${presetZh(eff.preset)} · 提供方=${p} · 模型=${m} · 推理强度=${effortZh(eff.reasoningEffort)}${tokensSuffix(result, block)}${observabilitySuffix(result, block)}`
381
423
  return el('div', { className: 'sap-toolview' },
382
424
  el('div', { className: 'sap-chips' }, chips),
383
425
  el('div', { className: 'sap-toolText' }, text)
@@ -404,7 +446,7 @@ function makeApplyWrite(s) {
404
446
  return applyWrite
405
447
  }
406
448
 
407
- function makeFormActions(s) {
449
+ function makeFormActions(s, loadEfforts, loadTools) {
408
450
  const setField = (key) => (event) => {
409
451
  const value = event && event.target ? event.target.value : ''
410
452
  s.setForm((prev) => {
@@ -412,8 +454,14 @@ function makeFormActions(s) {
412
454
  if (key === 'model') next.reasoningEffort = '' // 换模型时重置推理强度
413
455
  return next
414
456
  })
457
+ if (key === 'model' && value !== '') {
458
+ loadEfforts(value) // 选中模型 → 懒拉该模型 efforts
459
+ }
460
+ }
461
+ const setToolMode = (mode) => () => {
462
+ if (mode !== 'none') loadTools() // 工具限制面板启用 → 拉完整工具目录
463
+ s.setForm((prev) => ({ ...prev, toolMode: mode }))
415
464
  }
416
- const setToolMode = (mode) => () => s.setForm((prev) => ({ ...prev, toolMode: mode }))
417
465
  const toggleTool = (name) => () => {
418
466
  s.setForm((prev) => {
419
467
  const cur = Array.isArray(prev.toolList) ? prev.toolList : []
@@ -439,7 +487,7 @@ function makeFormActions(s) {
439
487
  return { setField, setToolMode, toggleTool, selectLayer, clearLayer, invertLayer }
440
488
  }
441
489
 
442
- function makeEditActions(s) {
490
+ function makeEditActions(s, loadEfforts, loadTools) {
443
491
  const openAdd = () => {
444
492
  s.setError('')
445
493
  s.setSavedNotice('')
@@ -454,6 +502,10 @@ function makeEditActions(s) {
454
502
  const allowArr = Array.isArray(tf.allow) ? tf.allow.slice() : []
455
503
  const denyArr = Array.isArray(tf.deny) ? tf.deny.slice() : []
456
504
  const toolMode = allowArr.length > 0 ? 'allow' : (denyArr.length > 0 ? 'deny' : 'none')
505
+ if (toolMode !== 'none') loadTools() // 编辑含 toolFilter 的 profile → 预拉工具目录
506
+ if (typeof profile.model === 'string' && profile.model !== '') {
507
+ loadEfforts(profile.model) // 编辑含 model 的 profile → 预拉该模型 efforts(下拉不空白)
508
+ }
457
509
  s.setForm({
458
510
  id: profile.id,
459
511
  description: typeof profile.description === 'string' ? profile.description : '',
@@ -547,10 +599,41 @@ function makeRowActions(api, s, refresh, applyWrite) {
547
599
  return { toggleEnabled, setProfileEnabled, resetAll, remove }
548
600
  }
549
601
 
550
- function makeProfilesActions(api, s, refresh, applyWrite) {
602
+ // 选中模型 懒拉 /options/efforts(按模型 id 存入 options.efforts)。
603
+ function makeEffortsLoader(api, setOptions) {
604
+ return (model) => {
605
+ const q = `?model=${encodeURIComponent(model)}`
606
+ api(`/options/efforts${q}`)
607
+ .then((data) => {
608
+ const list = Array.isArray(data.efforts) ? data.efforts : []
609
+ setOptions((prev) => ({ ...prev, efforts: { ...prev.efforts, [model]: list } }))
610
+ })
611
+ .catch(() => {})
612
+ }
613
+ }
614
+
615
+ // 工具限制面板启用 → 拉 /options/tools(完整工具目录)。
616
+ function makeToolsLoader(api, setTools) {
617
+ return () => {
618
+ api('/options/tools')
619
+ .then((data) => setTools(Array.isArray(data.tools) ? data.tools : []))
620
+ .catch(() => {})
621
+ }
622
+ }
623
+
624
+ // 版本探测 → 拉 /options/versions;warnings 非空时设置页顶部常驻 amber 提示条。
625
+ function makeVersionsLoader(api, setVersionWarnings) {
626
+ return () => {
627
+ api('/options/versions')
628
+ .then((data) => setVersionWarnings(Array.isArray(data.warnings) ? data.warnings : []))
629
+ .catch(() => {})
630
+ }
631
+ }
632
+
633
+ function makeProfilesActions(api, s, refresh, applyWrite, loadEfforts, loadTools) {
551
634
  return {
552
- ...makeFormActions(s),
553
- ...makeEditActions(s),
635
+ ...makeFormActions(s, loadEfforts, loadTools),
636
+ ...makeEditActions(s, loadEfforts, loadTools),
554
637
  ...makeSubmitActions(api, s, refresh, applyWrite),
555
638
  ...makeRowActions(api, s, refresh, applyWrite),
556
639
  }
@@ -700,11 +783,14 @@ function buildAddCard(el, s, actions, toolSection) {
700
783
  ),
701
784
  buildSelectField(el, s, actions.setField, '模型(子 agent 使用的模型)', 'model', s.options.models,
702
785
  (m) => el('option', { key: m.id, value: m.id }, m.name && m.name !== m.id ? `${m.name}(${m.providerName || m.provider})` : m.id)),
786
+ s.options.models.length === 0 ? el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, '未检测到模型目录(headless 部署)——派发成本护栏将按「兼容模式」开关决定放行或拒绝') : null,
703
787
  el('details', { className: 'sap-customized' },
704
788
  el('summary', { className: 'sap-customizedSummary' }, '高级选项'),
705
789
  el('div', { className: 'sap-customizedBody' },
790
+ el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, 'continuable 模式忽略 preset 换用与推理强度(子 Agent 继承父预设),派发结果以「ignored」标注'),
706
791
  buildSelectField(el, s, actions.setField, '目标预设(切换子 agent 使用的 Agent 预设)', 'preset', s.options.presets,
707
792
  (p) => el('option', { key: p.id, value: p.id }, p.name && p.name !== p.id ? `${p.name}(${p.id})` : p.id)),
793
+ s.options.presets.length === 0 ? el('p', { className: 'sap-metaValueDefault', style: { margin: '0' } }, '未检测到 system-trust 预设名册(rosterless)——目标预设下拉不可用') : null,
708
794
  buildSelectField(el, s, actions.setField, '提供方(模型服务商)', 'provider', providers,
709
795
  (p) => el('option', { key: p.id, value: p.id }, p.name && p.name !== p.id ? `${p.name}(${p.id})` : p.id)),
710
796
  s.form.model === ''
@@ -734,6 +820,9 @@ function buildSectionReturn(el, s, actions, rows, addCard) {
734
820
  el('span', null, s.enabled ? '已启用' : '已禁用')
735
821
  )
736
822
  ),
823
+ s.versionWarnings.length > 0
824
+ ? el('div', { className: 'sap-versionWarn' }, s.versionWarnings.map((warning, index) => el('span', { key: index }, warning)))
825
+ : null,
737
826
  el('p', { className: 'sap-intro' }, '在此管理「派发子 Agent」工具可用的 profile。内置方案可编辑、可删除、可点下方按钮批量重置回默认;每个方案可单独启用/禁用。'),
738
827
  el('div', { className: 'sap-addActions', style: { margin: '4px 0' } },
739
828
  el('button', { type: 'button', className: 'sap-secondaryButton', style: { height: '28px', padding: '0 12px', fontSize: '12px', borderRadius: '14px' }, onClick: actions.resetAll }, '重置所有内置方案')
@@ -763,32 +852,38 @@ function makeProfilesSection(el, api, chip, useState, useEffect, useCallback) {
763
852
  const [persistWarning, setPersistWarning] = useState('')
764
853
  const [form, setForm] = useState(EMPTY_FORM)
765
854
  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 }
855
+ const [versionWarnings, setVersionWarnings] = useState([])
856
+ const s = { profiles, options, enabled, adding, editingId, error, savedNotice, persistWarning, form, tools, versionWarnings,
857
+ setProfiles, setOptions, setEnabled, setAdding, setEditingId, setError, setSavedNotice, setPersistWarning, setForm, setTools, setVersionWarnings }
768
858
  const refresh = useCallback(() => {
769
859
  api('/list')
770
860
  .then((data) => setProfiles(Array.isArray(data.profiles) ? data.profiles : []))
771
861
  .catch((err) => setError(String((err && err.message) || err)))
772
862
  }, [])
773
863
  const loadOptions = useCallback(() => {
774
- api('/options')
864
+ api('/options/summary')
775
865
  .then((data) => {
776
866
  setEnabled(data.enabled !== false)
777
867
  setOptions({
778
868
  models: Array.isArray(data.models) ? data.models : [],
779
- efforts: data.efforts && typeof data.efforts === 'object' ? data.efforts : {},
869
+ efforts: {},
780
870
  presets: Array.isArray(data.presets) ? data.presets : []
781
871
  })
782
- setTools(Array.isArray(data.tools) ? data.tools : [])
783
872
  })
784
873
  .catch(() => {})
785
874
  }, [])
875
+ const loadEfforts = makeEffortsLoader(api, setOptions)
876
+ const loadTools = makeToolsLoader(api, setTools)
877
+ // 版本探测只挂载时拉一次:useCallback([]) 稳定引用,防 effect 依赖恒变
878
+ // 导致设置页打开期间无限重取(与 loadOptions 同款稳定化)。
879
+ const loadVersions = useCallback(() => makeVersionsLoader(api, setVersionWarnings)(), [])
786
880
  useEffect(() => {
787
881
  refresh()
788
882
  loadOptions()
789
- }, [refresh, loadOptions])
883
+ loadVersions()
884
+ }, [refresh, loadOptions, loadVersions])
790
885
  const applyWrite = makeApplyWrite(s)
791
- const actions = makeProfilesActions(api, s, refresh, applyWrite)
886
+ const actions = makeProfilesActions(api, s, refresh, applyWrite, loadEfforts, loadTools)
792
887
  const rows = buildProfileRows(el, chip, s, actions)
793
888
  const toolSection = buildToolSection(el, s, actions)
794
889
  return buildSectionReturn(el, s, actions, rows, buildAddCard(el, s, actions, toolSection))
@@ -0,0 +1,235 @@
1
+ // lib/core/catalog-cache.mjs — 进程级共享 catalog 快照:一次拉取 llm 目录
2
+ // (providers + 每 provider 的 models + 每模型 reasoning-effort 等级)、
3
+ // system-trust 预设名册与完整工具目录,按 TTL 缓存后同时喂给设置页 /options
4
+ // 三路由与 dispatch 的 cost guard。TTL 过期自动重拉;同 key 在途 Promise 去重;
5
+ // /options/refresh 手动清缓存兜底。
6
+ //
7
+ // 依赖边界(无 @deepseek-ai 依赖):仅 import 纯数据表
8
+ // lib/core/catalog.mjs(工具名 → 中文/分类,零依赖)。服务经注入 getter 传入:
9
+ // getLlm () => ctx.get('llm') (headless 下可为 undefined)
10
+ // getAgentPresets () => ctx.get('agentPresets')
11
+ // getTools () => ctx.tools
12
+ // cost guard 校验的是「父 Agent 的 llm 实例」;同一进程内父 ctx 与插件 ctx 读到的
13
+ // 是同一个 host llm 服务,故 getSnapshot 接受可选 llm 覆盖(父实例)时仍命中同一条目。
14
+ //
15
+ // 缓存只提速目录解析,不复检安全门:provider/model/reasoningEffort 的校验逻辑与
16
+ // fail-loud 文案留在 lib/core/cost-guard.mjs(逐字不变),此处仅提供目录数据。
17
+
18
+ import { TOOL_ZH, TOOL_CATEGORY } from './catalog.mjs';
19
+
20
+ const DEFAULT_TTL_MS = 60000;
21
+
22
+ // --- 目录构建(无缓存;行为与 http-routes 原 collector 逐字一致)----------------
23
+
24
+ // llm 目录:providers + 每 provider models + 每模型 efforts。listProviders 失败
25
+ // 记入 providersError(cost guard 据此判「目录为空」);每 provider 的 listModels
26
+ // 失败单独记录——cost guard 需要区分「空目录短路」与「listModels 抛错」的 fail-loud 文案。
27
+ async function collectLlmDirectory(llm) {
28
+ const models = [];
29
+ const efforts = Object.create(null);
30
+ const modelsByProvider = Object.create(null);
31
+ if (llm === undefined || typeof llm.listProviders !== 'function') {
32
+ return { providersError: undefined, providers: [], modelsByProvider, models, efforts };
33
+ }
34
+ let providers;
35
+ let providersError;
36
+ try {
37
+ providers = (await llm.listProviders()) ?? [];
38
+ } catch (error) {
39
+ providersError = error instanceof Error ? error : new Error(String(error));
40
+ return { providersError, providers: [], modelsByProvider, models, efforts };
41
+ }
42
+ for (const provider of providers) {
43
+ const providerId = provider && provider.id;
44
+ if (typeof providerId !== 'string') continue;
45
+ let entry;
46
+ try {
47
+ const modelList = await llm.listModels(providerId);
48
+ entry = { ok: true, models: modelList ?? [] };
49
+ } catch (error) {
50
+ entry = { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
51
+ }
52
+ modelsByProvider[providerId] = entry;
53
+ if (entry.ok !== true) continue; // 该 provider 目录拉取失败 → 跳过其 UI 目录
54
+ for (const model of entry.models) {
55
+ if (!model || typeof model.id !== 'string') continue;
56
+ models.push({ provider: providerId, providerName: provider.name ?? providerId, id: model.id, name: model.name ?? model.id });
57
+ try {
58
+ const info = await llm.resolveModelInfo(providerId, model.id);
59
+ const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
60
+ efforts[model.id] = effortsList.map((effort) => ({
61
+ id: effort.id,
62
+ name: effort.name ?? effort.id,
63
+ ...(effort.description !== undefined ? { description: effort.description } : {})
64
+ }));
65
+ } catch { /* 精确模型查询可能拒绝;跳过其 efforts */ }
66
+ }
67
+ }
68
+ return { providersError: undefined, providers, modelsByProvider, models, efforts };
69
+ }
70
+
71
+ // System-trust 预设名册(agentPresets 可选,fail-soft)。
72
+ async function collectSystemPresets(agentPresets) {
73
+ const presets = [];
74
+ if (agentPresets === undefined || typeof agentPresets.list !== 'function') return presets;
75
+ try {
76
+ const list = await agentPresets.list();
77
+ for (const preset of (list ?? [])) {
78
+ if (preset && preset.trust === 'system') {
79
+ presets.push({ id: preset.id, name: preset.name ?? preset.id });
80
+ }
81
+ }
82
+ } catch { /* presets roster unavailable; leave empty */ }
83
+ return presets;
84
+ }
85
+
86
+ // 完整工具目录 = global 层(部署插件)+ 每个 preset 的 standing scope
87
+ // (agent.cordis.yml 工具行)。工具按其来源打标:'global' 或 preset id——分组完全
88
+ // 动态,源自运行时名册。整体失败 fail-soft(返回空目录 + 告警),不破坏设置页。
89
+ async function collectToolsDirectory(getTools, getAgentPresets, logger) {
90
+ try {
91
+ const tools = [];
92
+ const seen = new Set();
93
+ const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
94
+ const layerOf = (source) => {
95
+ if (source === 'global') return 'plugin';
96
+ if (OFFICIAL_PRESETS.includes(source)) return 'core';
97
+ return 'custom';
98
+ };
99
+ const groupOf = (name, source) => {
100
+ const layer = layerOf(source);
101
+ if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
102
+ if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
103
+ return source;
104
+ };
105
+ const push = (schemas, source) => {
106
+ for (const s of (Array.isArray(schemas) ? schemas : [])) {
107
+ if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
108
+ seen.add(s.name);
109
+ tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
110
+ }
111
+ };
112
+ const toolsService = typeof getTools === 'function' ? getTools() : undefined;
113
+ if (toolsService && typeof toolsService.schemas === 'function') {
114
+ push(toolsService.schemas(), 'global');
115
+ const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
116
+ if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
117
+ const presets = await agentPresets.list();
118
+ for (const preset of (presets ?? [])) {
119
+ if (!preset || typeof preset.id !== 'string') continue;
120
+ try {
121
+ push(toolsService.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
122
+ } catch { /* 单个 preset 的 standing scope 不可用;跳过 */ }
123
+ }
124
+ }
125
+ }
126
+ return tools;
127
+ } catch (error) {
128
+ if (logger !== undefined && typeof logger.warn === 'function') {
129
+ logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
130
+ }
131
+ return [];
132
+ }
133
+ }
134
+
135
+ // 构建一份完整快照(无缓存)。`llm` 可选:cost guard 用它传入父 Agent 的 llm 实例。
136
+ export async function getCatalogSnapshot({ getLlm, getAgentPresets, getTools, llm, logger }) {
137
+ const effectiveLlm = llm !== undefined ? llm : (typeof getLlm === 'function' ? getLlm() : undefined);
138
+ const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
139
+ const dir = await collectLlmDirectory(effectiveLlm);
140
+ const presets = await collectSystemPresets(agentPresets);
141
+ const tools = await collectToolsDirectory(getTools, getAgentPresets, logger);
142
+ return {
143
+ llm: { providersError: dir.providersError, providers: dir.providers, modelsByProvider: dir.modelsByProvider },
144
+ models: dir.models,
145
+ efforts: dir.efforts,
146
+ presets,
147
+ tools,
148
+ };
149
+ }
150
+
151
+ // --- TTL 缓存(按 llm 实例分键;同 key 在途去重;hit/miss 计数走 host logger)----
152
+
153
+ const NO_LLM = Symbol('catalog-cache:no-llm');
154
+
155
+ function makeAudit(logger) {
156
+ if (logger !== undefined && typeof logger.info === 'function') {
157
+ return (outcome, hits, misses) => logger.info(`[dsh-subagent-profile] catalog cache ${outcome} (hit=${hits}, miss=${misses})`);
158
+ }
159
+ return () => {};
160
+ }
161
+
162
+ // 命中/未命中判定 + TTL 过期重拉;并发 cache-miss 复用同一条在途 Promise(只拉一次)。
163
+ async function resolveSnapshot(state, llm) {
164
+ // 无参时按注入的 getLlm() 取 llm 实例作 key——保证 /options 侧(无参)与
165
+ // cost guard 侧(传父实例)在同一 host llm 实例下命中同一条目。
166
+ const effectiveLlm = llm !== undefined ? llm : (typeof state.getLlm === 'function' ? state.getLlm() : undefined);
167
+ const key = effectiveLlm !== undefined ? effectiveLlm : NO_LLM;
168
+ const at = state.clock();
169
+ const entry = state.entries.get(key);
170
+ if (entry !== undefined) {
171
+ if (entry.snapshot !== undefined && entry.expiresAt > at) {
172
+ state.hits += 1;
173
+ state.audit('hit', state.hits, state.misses);
174
+ return entry.snapshot;
175
+ }
176
+ if (entry.inflight !== undefined) {
177
+ state.hits += 1;
178
+ state.audit('hit', state.hits, state.misses);
179
+ return entry.inflight;
180
+ }
181
+ }
182
+ state.misses += 1;
183
+ state.audit('miss', state.hits, state.misses);
184
+ const inflight = getCatalogSnapshot({
185
+ getLlm: state.getLlm,
186
+ getAgentPresets: state.getAgentPresets,
187
+ getTools: state.getTools,
188
+ llm: effectiveLlm,
189
+ logger: state.logger,
190
+ });
191
+ const fresh = { expiresAt: at + state.ttl, snapshot: undefined, inflight };
192
+ state.entries.set(key, fresh);
193
+ try {
194
+ const snapshot = await inflight;
195
+ fresh.snapshot = snapshot;
196
+ fresh.inflight = undefined;
197
+ // 目录错误态不缓存:providersError 意味着 listProviders 瞬时失败或目录为空,
198
+ // 缓存会让瞬时故障自愈延迟一个 TTL;每次重试既保持安全门保守又恢复更快。
199
+ if (snapshot.llm.providersError !== undefined) {
200
+ state.entries.delete(key);
201
+ }
202
+ return snapshot;
203
+ } catch (error) {
204
+ fresh.inflight = undefined;
205
+ state.entries.delete(key);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function invalidateSnapshot(state, llm) {
211
+ if (llm !== undefined) state.entries.delete(llm);
212
+ else state.entries.clear();
213
+ }
214
+
215
+ // 工厂返回 { getSnapshot, invalidate, stats }。getSnapshot(llm?) 接受可选 llm 覆盖;
216
+ // 无参时走注入的 getLlm()。stats() 供审计/测试读取 hit/miss 计数。
217
+ export function createCatalogCache({ getLlm, getAgentPresets, getTools, logger, ttlMs, now }) {
218
+ const state = {
219
+ getLlm,
220
+ getAgentPresets,
221
+ getTools,
222
+ logger,
223
+ ttl: typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_TTL_MS,
224
+ clock: typeof now === 'function' ? now : () => Date.now(),
225
+ entries: new Map(),
226
+ hits: 0,
227
+ misses: 0,
228
+ audit: makeAudit(logger),
229
+ };
230
+ return {
231
+ getSnapshot: (llm) => resolveSnapshot(state, llm),
232
+ invalidate: (llm) => invalidateSnapshot(state, llm),
233
+ stats: () => ({ hits: state.hits, misses: state.misses }),
234
+ };
235
+ }
@@ -1,6 +1,6 @@
1
- // lib/core/catalog.mjs — settings-page data tables moved verbatim from index.mjs
1
+ // lib/core/catalog.mjs — settings-page data tables moved verbatim from index.mjs
2
2
  // (import-free, no @deepseek-ai dependency — node builtins only, none used
3
- // here). The /options tool-directory builder itself lives in lib/core/http-routes.mjs:
3
+ // here). The /options tool-directory builder itself lives in lib/core/catalog-cache.mjs:
4
4
  // it reads ctx.tools/agentPresets (non-pure), so it is not part of this module.
5
5
 
6
6
  // Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter