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 +1 -1
- package/README.zh.md +1 -1
- package/index.mjs +23 -6
- package/lib/client.js +113 -18
- package/lib/core/catalog-cache.mjs +235 -0
- package/lib/core/catalog.mjs +2 -2
- package/lib/core/cost-guard.mjs +45 -31
- package/lib/core/delegation.mjs +26 -6
- package/lib/core/dispatch-schema.mjs +71 -0
- package/lib/core/dispatch-tool.mjs +99 -76
- package/lib/core/http-routes.mjs +48 -127
- package/lib/core/profile-provider.mjs +6 -5
- package/lib/core/profiles-store.mjs +3 -3
- package/lib/core/pure.mjs +33 -2
- package/lib/core/shims.mjs +87 -1
- package/package.json +3 -2
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
|
767
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/core/catalog.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
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/
|
|
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
|