@menghuan1918/dsh-apollo 0.3.0 → 0.4.0

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/src/client.js CHANGED
@@ -20,6 +20,12 @@ window.__ModuleLoader__.load({
20
20
  // 旧键并排注册,只为仍把依赖槽记成旧 unscoped 名的 profile 保留配置入口。
21
21
  const ROW_KEYS = ['@menghuan1918/dsh-apollo#apollo-skills', 'dsh-apollo#apollo-skills'];
22
22
  const CAPABILITIES_URL = '/apollo/api/capabilities';
23
+ const MODELS_URL = '/apollo/api/models';
24
+ const ROUTES_URL = '/apollo/api/routes';
25
+ /** 子代理模型分档(与 src/config.js 的 tiers 同步)。 */
26
+ const TIERS = ['fast', 'quality'];
27
+ const TIER_TITLE_KEYS = { fast: 'tierFast', quality: 'tierQuality' };
28
+ const TIER_HINT_KEYS = { fast: 'tierFastHint', quality: 'tierQualityHint' };
23
29
 
24
30
  /** Codex CLI 权限档(与 src/config.js 的 CODEX_PERMISSION_MODES 同步)。 */
25
31
  const CODEX = {
@@ -40,7 +46,7 @@ window.__ModuleLoader__.load({
40
46
  const BACKENDS = [CODEX, CLAUDE];
41
47
 
42
48
  const zh = {
43
- summary: '工程模式运行时设置:退出守卫、子代理 CLI 后端与接入范围',
49
+ summary: '工程模式运行时设置:退出守卫、子代理模型分档、CLI 后端与接入范围',
44
50
  title: '工程模式运行时设置',
45
51
  intro: '保存后即时生效,无需重启。配置写入当前 profile 的行配置(cordis.patch.yml)。',
46
52
  loading: '正在读取配置…',
@@ -50,9 +56,24 @@ window.__ModuleLoader__.load({
50
56
  readonly: '当前连接只读(设置镜像为进程内 memory),无法写入宿主配置。',
51
57
  exitGuardLabel: '退出守卫',
52
58
  exitGuardHint: '回合结束时若仍有运行中的子代理或后台命令,注入提醒强制继续处理;用户中断(Esc)始终放行。',
59
+ tiersTitle: '子代理模型分档',
60
+ tiersIntro: '工程模式提示词按这里的路由告诉主代理两档各用哪个模型;候选来自「插件 → 子代理 → 模型选择」白名单。',
61
+ tierFast: '快速档',
62
+ tierQuality: '质量档',
63
+ tierFastHint: '快速档:探索、侦察、机械分析、批量排查。',
64
+ tierQualityHint: '质量档:复杂实现、调试、审查、设计。',
65
+ tierInherit: '沿用父会话路由',
66
+ tierEffortDefault: '模型默认',
67
+ tierRouteLabel: '{tier}路由',
68
+ tierEffortLabel: '{tier} effort',
69
+ tierOffList: '(不在白名单) {route}',
70
+ tiersInactive: '「插件 → 子代理 → 模型选择」未开启或白名单为空,分档不会生效。',
71
+ routesLoading: '正在读取子代理模型白名单…',
72
+ routesError: '读取子代理模型白名单失败(不影响保存)。',
73
+ invalidTierRoute: '{backend}所选路由不在子代理模型白名单内,请改选或改回沿用父会话路由。',
53
74
  scopeTitle: '接入范围',
54
75
  scopeEng: '工程模式预设',
55
- scopeEngHint: 'eng / eng-noask 会话可以看到下面这些 CLI 子代理路由。',
76
+ scopeEngHint: 'eng / eng-ptc 会话可以看到下面这些 CLI 子代理路由。',
56
77
  scopeGlobal: '全部预设',
57
78
  scopeGlobalHint: '所有预设的会话都能看到这些路由(影响面更大)。',
58
79
  scopeNone: '两个范围都关闭时,这些 CLI 路由不会出现在任何会话里。',
@@ -71,6 +92,21 @@ window.__ModuleLoader__.load({
71
92
  modelsLabel: '模型与 effort',
72
93
  modelsHint: '每行一个模型:id + 允许的 effort(逗号分隔)+ 默认 effort。',
73
94
  modelsEmpty: '还没有配置模型;启用后端前至少加一个模型。',
95
+ discover: '自动发现模型',
96
+ discovering: '正在发现…',
97
+ discoverHint: '从本机 CLI 或 Anthropic 端点读取模型,只追加列表里还没有的条目。',
98
+ discoverDone: '已发现 {count} 个模型,新增 {added} 个。',
99
+ discoverFailed: '自动发现失败:{reason}',
100
+ discoverUnknown: '未知原因',
101
+ discoverReasonMissing: '没找到可执行文件',
102
+ discoverReasonFailed: 'CLI 命令执行失败',
103
+ discoverReasonNoCredentials: '没找到 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY',
104
+ discoverReasonUnauthorized: '模型端点拒绝了凭据(HTTP {status})',
105
+ discoverReasonHttp: '模型端点返回 HTTP {status}',
106
+ discoverReasonNetwork: '无法连接模型端点',
107
+ discoverReasonParse: '返回内容不是可解析的模型清单',
108
+ discoverReasonEmpty: '模型清单为空',
109
+ discoverReasonTooLarge: '返回内容过大',
74
110
  modelIdPlaceholder: '模型 id',
75
111
  modelEffortsPlaceholder: 'low, medium, high',
76
112
  modelDefaultEffort: '默认 effort',
@@ -98,7 +134,7 @@ window.__ModuleLoader__.load({
98
134
  };
99
135
 
100
136
  const en = {
101
- summary: 'Eng mode runtime settings: exit guard, CLI subagent backends, and scope',
137
+ summary: 'Eng mode runtime settings: exit guard, subagent model tiers, CLI backends, and scope',
102
138
  title: 'Eng mode runtime settings',
103
139
  intro: 'Saving applies immediately, no restart. Values persist in the active profile row config (cordis.patch.yml).',
104
140
  loading: 'Loading configuration…',
@@ -108,9 +144,24 @@ window.__ModuleLoader__.load({
108
144
  readonly: 'This connection is read-only (settings mirror is process-local memory); host configuration cannot be written.',
109
145
  exitGuardLabel: 'Exit guard',
110
146
  exitGuardHint: 'Blocks turn end while subagents or background jobs are still running; interrupt (Esc) always passes through.',
147
+ tiersTitle: 'Subagent model tiers',
148
+ tiersIntro: 'The eng mode prompt tells the main agent which route each tier uses; candidates come from the Plugins → Subagent → Model selection allowlist.',
149
+ tierFast: 'Fast tier',
150
+ tierQuality: 'Quality tier',
151
+ tierFastHint: 'Fast tier: exploration, reconnaissance, mechanical analysis, batch checks.',
152
+ tierQualityHint: 'Quality tier: complex implementation, debugging, review, design.',
153
+ tierInherit: 'Inherit parent route',
154
+ tierEffortDefault: 'Model default',
155
+ tierRouteLabel: '{tier} route',
156
+ tierEffortLabel: '{tier} effort',
157
+ tierOffList: '(not allowlisted) {route}',
158
+ tiersInactive: 'Plugins → Subagent → Model selection is off or its allowlist is empty, so the tiers take no effect.',
159
+ routesLoading: 'Loading the subagent model allowlist…',
160
+ routesError: 'Could not read the subagent model allowlist (saving still works).',
161
+ invalidTierRoute: 'The {backend} route is not in the subagent model allowlist: pick another or inherit the parent route.',
111
162
  scopeTitle: 'Scope',
112
163
  scopeEng: 'Eng preset',
113
- scopeEngHint: 'eng / eng-noask sessions see the CLI subagent routes below.',
164
+ scopeEngHint: 'eng / eng-ptc sessions see the CLI subagent routes below.',
114
165
  scopeGlobal: 'All presets',
115
166
  scopeGlobalHint: 'Every preset’s sessions see these routes (broader impact).',
116
167
  scopeNone: 'With both scopes off, these CLI routes appear in no session.',
@@ -129,6 +180,21 @@ window.__ModuleLoader__.load({
129
180
  modelsLabel: 'Models and efforts',
130
181
  modelsHint: 'One model per row: id + allowed efforts (comma separated) + default effort.',
131
182
  modelsEmpty: 'No model yet; add at least one before enabling the backend.',
183
+ discover: 'Discover models',
184
+ discovering: 'Discovering…',
185
+ discoverHint: 'Reads models from the local CLI or the Anthropic endpoint; appends only entries not already listed.',
186
+ discoverDone: 'Found {count} models, added {added}.',
187
+ discoverFailed: 'Discovery failed: {reason}',
188
+ discoverUnknown: 'unknown reason',
189
+ discoverReasonMissing: 'executable not found',
190
+ discoverReasonFailed: 'the CLI command failed',
191
+ discoverReasonNoCredentials: 'no ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY',
192
+ discoverReasonUnauthorized: 'the model endpoint rejected the credential (HTTP {status})',
193
+ discoverReasonHttp: 'the model endpoint answered HTTP {status}',
194
+ discoverReasonNetwork: 'the model endpoint could not be reached',
195
+ discoverReasonParse: 'the reply is not a parseable model list',
196
+ discoverReasonEmpty: 'the model list is empty',
197
+ discoverReasonTooLarge: 'the reply is too large',
132
198
  modelIdPlaceholder: 'model id',
133
199
  modelEffortsPlaceholder: 'low, medium, high',
134
200
  modelDefaultEffort: 'Default effort',
@@ -155,6 +221,19 @@ window.__ModuleLoader__.load({
155
221
  unavailableNote: 'Not writable: this configuration is unavailable on the current connection.',
156
222
  };
157
223
 
224
+ /** 宿主发现接口的 error.code → 文案键(未知 code 回退通用原因)。 */
225
+ const DISCOVERY_REASON_KEYS = {
226
+ missing: 'discoverReasonMissing',
227
+ failed: 'discoverReasonFailed',
228
+ 'no-credentials': 'discoverReasonNoCredentials',
229
+ unauthorized: 'discoverReasonUnauthorized',
230
+ http: 'discoverReasonHttp',
231
+ network: 'discoverReasonNetwork',
232
+ parse: 'discoverReasonParse',
233
+ empty: 'discoverReasonEmpty',
234
+ 'too-large': 'discoverReasonTooLarge',
235
+ };
236
+
158
237
  // ── 文案(locale 服务缺失时保持中文默认)───────────────────────────────
159
238
 
160
239
  function interpolate(text, params) {
@@ -178,6 +257,9 @@ window.__ModuleLoader__.load({
178
257
  '@media (prefers-reduced-motion:reduce){.apollo-pulse{animation:none}}',
179
258
  '.apollo-title{margin:0;font-size:14px;font-weight:500;line-height:22px}',
180
259
  '.apollo-hint{margin:0;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
260
+ '.apollo-hint[data-tone=error]{color:var(--dsw-alias-state-error-primary)}',
261
+ '.apollo-hint[data-tone=ok]{color:var(--dsw-alias-state-success-primary)}',
262
+ '.apollo-actions{display:flex;flex-wrap:wrap;gap:6px}',
181
263
  '.apollo-intro{margin:0;font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
182
264
  '.apollo-section{display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
183
265
  '.apollo-sectionHead{display:flex;flex-direction:column;gap:2px;padding-bottom:4px}',
@@ -208,7 +290,8 @@ window.__ModuleLoader__.load({
208
290
  '.apollo-models{display:flex;flex-direction:column;gap:6px}',
209
291
  '.apollo-model{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) minmax(0,132px) auto;gap:6px;align-items:center}',
210
292
  '.apollo-modelHead{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) minmax(0,132px) auto;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}',
211
- '@media (max-width:640px){.apollo-field,.apollo-model,.apollo-modelHead{grid-template-columns:minmax(0,1fr)}.apollo-fieldLabel{padding-top:0}}',
293
+ '.apollo-tier{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:6px}',
294
+ '@media (max-width:640px){.apollo-field,.apollo-model,.apollo-modelHead,.apollo-tier{grid-template-columns:minmax(0,1fr)}.apollo-fieldLabel{padding-top:0}}',
212
295
  '.apollo-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:28px;padding:0 10px;border:0;border-radius:var(--dsw-radius-sm);font:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);background:transparent;cursor:pointer}',
213
296
  '.apollo-button:disabled{opacity:.4;cursor:not-allowed}',
214
297
  '.apollo-button[data-variant=primary]{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-label-primary));color:var(--dsw-alias-label-primary-foreground,var(--dsw-alias-bg-layer-3))}',
@@ -267,17 +350,29 @@ window.__ModuleLoader__.load({
267
350
  return backend;
268
351
  }
269
352
 
353
+ /** 一个分档:provider 或 model 为空即整档清空(与 src/config.js 同规则)。 */
354
+ function tierShape(value) {
355
+ const source = isRecord(value) ? value : {};
356
+ const provider = stringOr(source.provider, '').trim();
357
+ const model = stringOr(source.model, '').trim();
358
+ if (provider === '' || model === '') return { provider: '', model: '', reasoningEffort: '' };
359
+ return { provider, model, reasoningEffort: stringOr(source.reasoningEffort, '').trim() };
360
+ }
361
+
270
362
  /** 把宿主快照值(或 undefined)规整成表单可安全读写的形状。 */
271
363
  function shape(value) {
272
364
  const source = isRecord(value) ? value : {};
273
365
  const subagents = isRecord(source.subagents) ? source.subagents : {};
366
+ const tiers = isRecord(source.tiers) ? source.tiers : {};
274
367
  const result = {
275
368
  exitGuard: source.exitGuard === true,
369
+ tiers: {},
276
370
  subagents: {
277
371
  eng: subagents.eng !== false,
278
372
  global: subagents.global === true,
279
373
  },
280
374
  };
375
+ for (const tier of TIERS) result.tiers[tier] = tierShape(tiers[tier]);
281
376
  for (const spec of BACKENDS) result.subagents[spec.cli] = backendShape(subagents[spec.cli], spec);
282
377
  return result;
283
378
  }
@@ -299,6 +394,10 @@ window.__ModuleLoader__.load({
299
394
  if (host.exitGuard !== current.exitGuard) {
300
395
  ops.push({ op: 'set', path: ['exitGuard'], value: current.exitGuard });
301
396
  }
397
+ for (const tier of TIERS) {
398
+ if (JSON.stringify(host.tiers[tier]) === JSON.stringify(current.tiers[tier])) continue;
399
+ ops.push({ op: 'set', path: ['tiers', tier], value: tierShape(current.tiers[tier]) });
400
+ }
302
401
  for (const key of ['eng', 'global']) {
303
402
  if (host.subagents[key] !== current.subagents[key]) {
304
403
  ops.push({ op: 'set', path: ['subagents', key], value: current.subagents[key] });
@@ -319,9 +418,21 @@ window.__ModuleLoader__.load({
319
418
  * 前端校验(宿主 schema 管不到跨字段关联,这里补齐):
320
419
  * 模型 id 非空且唯一;**启用的**后端必须有默认模型,且该模型的 effort
321
420
  * 列表与默认 effort 齐备——CLI 路由按这三元组选档,缺一项运行时就报错。
322
- * @returns `{ key, titleKey }`(key 用 `{backend}` 参数渲染后端名),或 null。
421
+ * 分档:白名单读取成功时,已选路由必须在白名单内(否则子代理派发会被策略拒绝)。
422
+ * @param current - 表单当前值。
423
+ * @param allowed - `routesOf()` 的结果;白名单未读到时为 null(不校验分档)。
424
+ * @returns `{ key, titleKey }`(key 用 `{backend}` 参数渲染后端/分档名),或 null。
323
425
  */
324
- function validate(current) {
426
+ function validate(current, allowed) {
427
+ if (allowed !== null && allowed !== undefined) {
428
+ for (const tier of TIERS) {
429
+ const value = current.tiers[tier];
430
+ if (value.provider === '' || value.model === '') continue;
431
+ if (findRoute(allowed.routes, value) === undefined) {
432
+ return { key: 'invalidTierRoute', titleKey: TIER_TITLE_KEYS[tier] };
433
+ }
434
+ }
435
+ }
325
436
  for (const spec of BACKENDS) {
326
437
  const backend = current.subagents[spec.cli];
327
438
  const titleKey = spec.cli === 'codex' ? 'codexTitle' : 'claudeTitle';
@@ -343,8 +454,8 @@ window.__ModuleLoader__.load({
343
454
 
344
455
  // ── 能力探针(host 半边的只读接口)─────────────────────────────────────
345
456
 
346
- /** 拉取 `/apollo/api/capabilities`;失败只降级显示,不阻塞表单。 */
347
- function useCapabilities() {
457
+ /** 拉取一个 `/apollo/api/*` 只读接口;失败只降级显示,不阻塞表单。 */
458
+ function useEndpoint(url) {
348
459
  const [state, setState] = useState({ status: 'loading' });
349
460
  useEffect(() => {
350
461
  let active = true;
@@ -354,7 +465,7 @@ window.__ModuleLoader__.load({
354
465
  };
355
466
  void (async () => {
356
467
  try {
357
- const res = await fetch(CAPABILITIES_URL, controller === undefined ? undefined : { signal: controller.signal });
468
+ const res = await fetch(url, controller === undefined ? undefined : { signal: controller.signal });
358
469
  const body = await res.json().catch(() => undefined);
359
470
  if (!res.ok || body?.ok !== true) throw new Error('HTTP ' + String(res.status));
360
471
  done({ status: 'ready', value: body.value });
@@ -366,10 +477,49 @@ window.__ModuleLoader__.load({
366
477
  active = false;
367
478
  controller?.abort();
368
479
  };
369
- }, []);
480
+ }, [url]);
370
481
  return state;
371
482
  }
372
483
 
484
+ /**
485
+ * `/apollo/api/routes` 的结果 → `{ enabled, routes }`;未读到(加载中/失败)时 null。
486
+ * 每条路由 `{ provider, model, efforts, defaultEffort }`。
487
+ */
488
+ function routesOf(state) {
489
+ if (state.status !== 'ready') return null;
490
+ const value = isRecord(state.value) ? state.value : {};
491
+ const routes = [];
492
+ for (const entry of Array.isArray(value.routes) ? value.routes : []) {
493
+ if (!isRecord(entry)) continue;
494
+ const provider = stringOr(entry.provider, '').trim();
495
+ const model = stringOr(entry.model, '').trim();
496
+ if (provider === '' || model === '') continue;
497
+ routes.push({
498
+ provider,
499
+ model,
500
+ efforts: Array.isArray(entry.efforts) ? entry.efforts.filter((item) => typeof item === 'string' && item !== '') : [],
501
+ defaultEffort: stringOr(entry.defaultEffort, ''),
502
+ });
503
+ }
504
+ return { enabled: value.enabled === true, routes };
505
+ }
506
+
507
+ function findRoute(routes, tier) {
508
+ return routes.find((route) => route.provider === tier.provider && route.model === tier.model);
509
+ }
510
+
511
+ /** 路由下拉的值:`provider/model`(按第一个 `/` 切回;provider id 不含 `/`)。 */
512
+ function routeKey(route) {
513
+ return route.provider === '' || route.model === '' ? '' : route.provider + '/' + route.model;
514
+ }
515
+
516
+ function parseRouteKey(key) {
517
+ const text = String(key ?? '');
518
+ const at = text.indexOf('/');
519
+ if (at <= 0 || at === text.length - 1) return { provider: '', model: '' };
520
+ return { provider: text.slice(0, at), model: text.slice(at + 1) };
521
+ }
522
+
373
523
  /** 一行探针结论:已检测到 <路径>(版本)/ 未找到。 */
374
524
  function detectionOf(capabilities, spec, backend, translate) {
375
525
  if (capabilities.status === 'loading') return translate('probing');
@@ -480,9 +630,64 @@ window.__ModuleLoader__.load({
480
630
  }, '−'));
481
631
  }
482
632
 
633
+ /**
634
+ * 一个分档:路由下拉(白名单)+ effort(路由有 effort 列表时下拉,否则文本)。
635
+ * 已保存但不在白名单里的路由仍作为选项保留(白名单读到时标注出来),不被静默改掉。
636
+ */
637
+ function TierRow({ tier, value, allowed, disabled, t: translate, onPatch }) {
638
+ const title = translate(TIER_TITLE_KEYS[tier]);
639
+ const routes = allowed === null ? [] : allowed.routes;
640
+ const current = routeKey(value);
641
+ const selected = findRoute(routes, value);
642
+ const routeOptions = [h('option', { key: '__inherit', value: '' }, translate('tierInherit'))];
643
+ for (const route of routes) {
644
+ routeOptions.push(h('option', { key: routeKey(route), value: routeKey(route) }, route.provider + ' / ' + route.model));
645
+ }
646
+ if (current !== '' && selected === undefined) {
647
+ const label = value.provider + ' / ' + value.model;
648
+ routeOptions.push(h('option', { key: current, value: current },
649
+ allowed === null ? label : translate('tierOffList', { route: label })));
650
+ }
651
+ const effortLabel = translate('tierEffortLabel', { tier: title });
652
+ const effort = selected !== undefined && selected.efforts.length > 0
653
+ ? h('select', {
654
+ className: 'apollo-select',
655
+ id: 'apollo-tier-' + tier + '-effort',
656
+ 'aria-label': effortLabel,
657
+ value: value.reasoningEffort,
658
+ disabled: disabled || current === '',
659
+ onChange: (event) => onPatch({ reasoningEffort: event.target.value }),
660
+ }, optionNodes(selected.efforts, value.reasoningEffort, translate('tierEffortDefault')))
661
+ : h('input', {
662
+ className: 'apollo-input',
663
+ type: 'text',
664
+ id: 'apollo-tier-' + tier + '-effort',
665
+ 'aria-label': effortLabel,
666
+ value: value.reasoningEffort,
667
+ placeholder: translate('tierEffortDefault'),
668
+ spellCheck: false,
669
+ autoComplete: 'off',
670
+ disabled: disabled || current === '',
671
+ onChange: (event) => onPatch({ reasoningEffort: event.target.value.trim() }),
672
+ });
673
+ return h(Field, { id: 'apollo-tier-' + tier + '-route', label: title, hint: translate(TIER_HINT_KEYS[tier]) },
674
+ h('div', { className: 'apollo-tier' },
675
+ h('select', {
676
+ className: 'apollo-select',
677
+ id: 'apollo-tier-' + tier + '-route',
678
+ 'aria-label': translate('tierRouteLabel', { tier: title }),
679
+ value: current,
680
+ disabled,
681
+ // 换路由即清空 effort(旧值未必属于新模型)。
682
+ onChange: (event) => onPatch({ ...parseRouteKey(event.target.value), reasoningEffort: '' }),
683
+ }, routeOptions),
684
+ effort));
685
+ }
686
+
483
687
  /** 一个 CLI 后端的配置卡片。 */
484
- function BackendCard({ spec, backend, disabled, capabilities, t: translate, onPatch, onModelPatch, onModelAdd, onModelRemove, generation }) {
688
+ function BackendCard({ spec, backend, disabled, capabilities, discovery, t: translate, onPatch, onModelPatch, onModelAdd, onModelRemove, onDiscover, generation }) {
485
689
  const title = translate(spec.cli === 'codex' ? 'codexTitle' : 'claudeTitle');
690
+ const status = discovery === undefined ? undefined : discovery[spec.cli];
486
691
  const profileNames = capabilities.status === 'ready' && Array.isArray(capabilities.value?.codexProfiles)
487
692
  ? capabilities.value.codexProfiles.filter((name) => typeof name === 'string')
488
693
  : [];
@@ -575,14 +780,27 @@ window.__ModuleLoader__.load({
575
780
  onPatch: (patch) => onModelPatch(index, patch),
576
781
  onRemove: () => onModelRemove(index),
577
782
  }))),
578
- h('div', null,
783
+ h('div', { className: 'apollo-actions' },
784
+ h('button', {
785
+ type: 'button',
786
+ id: 'apollo-' + spec.cli + '-discover',
787
+ className: 'apollo-button',
788
+ 'data-variant': 'outline',
789
+ disabled: disabled || status?.status === 'loading',
790
+ onClick: onDiscover,
791
+ }, status?.status === 'loading' ? translate('discovering') : translate('discover')),
579
792
  h('button', {
580
793
  type: 'button',
581
794
  className: 'apollo-button',
582
795
  'data-variant': 'outline',
583
796
  disabled,
584
797
  onClick: onModelAdd,
585
- }, translate('modelAdd')))));
798
+ }, translate('modelAdd'))),
799
+ h('p', {
800
+ className: 'apollo-hint',
801
+ ...(status?.status === 'error' ? { 'data-tone': 'error' }
802
+ : status?.status === 'ok' ? { 'data-tone': 'ok' } : {}),
803
+ }, status?.text ?? translate('discoverHint'))));
586
804
  return h('div', { className: backend.enabled ? 'apollo-card' : 'apollo-card apollo-cardOff' },
587
805
  h('div', { className: 'apollo-cardHead' },
588
806
  h('span', { className: 'apollo-cardTitle' }, title)),
@@ -591,19 +809,23 @@ window.__ModuleLoader__.load({
591
809
 
592
810
  /** 行配置页面主体。 */
593
811
  function ApolloConfigPage({ form, t: translate }) {
594
- const capabilities = useCapabilities();
812
+ const capabilities = useEndpoint(CAPABILITIES_URL);
813
+ const routesState = useEndpoint(ROUTES_URL);
814
+ const allowed = routesOf(routesState);
595
815
  const state = form?.state;
596
816
  const revision = state?.revision;
597
817
  const [draft, setDraft] = useState(null);
598
818
  const [generation, setGeneration] = useState(0);
599
819
  const [busy, setBusy] = useState(false);
600
820
  const [notice, setNotice] = useState(null);
821
+ /** 每个后端的发现状态:`{ status: 'loading' | 'ok' | 'error', text? }`。 */
822
+ const [discovery, setDiscovery] = useState({});
601
823
 
602
824
  const host = state?.status === 'ready' ? shape(state.value) : null;
603
825
  const live = draft !== null && draft.revision === revision ? draft.value : host;
604
826
  const writable = state?.status === 'ready' && state?.writable === true && state?.mode !== 'memory';
605
827
  const dirty = live !== null && host !== null && JSON.stringify(live) !== JSON.stringify(host);
606
- const invalid = live === null ? null : validate(live);
828
+ const invalid = live === null ? null : validate(live, allowed);
607
829
 
608
830
  if (state === undefined) {
609
831
  // 官方页面在镜像读到该命名空间前不传 form:这是正常加载窗口,
@@ -629,6 +851,10 @@ window.__ModuleLoader__.load({
629
851
  });
630
852
  setNotice(null);
631
853
  };
854
+ const patchTier = (tier, patch) => edit((current) => ({
855
+ ...current,
856
+ tiers: { ...current.tiers, [tier]: { ...current.tiers[tier], ...patch } },
857
+ }));
632
858
  const patchBackend = (spec, patch) => edit((current) => ({
633
859
  ...current,
634
860
  subagents: {
@@ -662,6 +888,71 @@ window.__ModuleLoader__.load({
662
888
  },
663
889
  };
664
890
  });
891
+ /** 发现失败原因:只认宿主给的 error.code,不回显可能含凭据的细节。 */
892
+ const discoveryReason = (error) => {
893
+ const key = DISCOVERY_REASON_KEYS[String(error?.code ?? '')];
894
+ if (key === undefined) return translate('discoverUnknown');
895
+ return translate(key, { status: String(error?.status ?? '') });
896
+ };
897
+ /** 拉取发现结果并并入草稿:保留已有条目,只追加新 id。 */
898
+ const discoverModels = async (spec) => {
899
+ if (discovery[spec.cli]?.status === 'loading' || !writable) return;
900
+ setDiscovery((previous) => ({ ...previous, [spec.cli]: { status: 'loading' } }));
901
+ try {
902
+ const response = await fetch(MODELS_URL + '?cli=' + encodeURIComponent(spec.cli));
903
+ const body = await response.json().catch(() => undefined);
904
+ if (!response.ok || body?.ok !== true) throw new Error('HTTP ' + String(response.status));
905
+ const value = isRecord(body.value) ? body.value : {};
906
+ const found = modelList(value.models).filter((model) => model.id.trim() !== '');
907
+ if (found.length === 0) {
908
+ setDiscovery((previous) => ({
909
+ ...previous,
910
+ [spec.cli]: {
911
+ status: 'error',
912
+ text: translate('discoverFailed', { reason: discoveryReason(value.error) }),
913
+ },
914
+ }));
915
+ return;
916
+ }
917
+ const known = new Set(live.subagents[spec.cli].models
918
+ .map((model) => model.id.trim()).filter((id) => id !== ''));
919
+ const added = found.filter((model) => !known.has(model.id)).length;
920
+ edit((current) => {
921
+ const backend = current.subagents[spec.cli];
922
+ const existing = new Set(backend.models
923
+ .map((model) => model.id.trim()).filter((id) => id !== ''));
924
+ const models = [...backend.models];
925
+ for (const model of found) {
926
+ if (existing.has(model.id)) continue;
927
+ existing.add(model.id);
928
+ models.push(model);
929
+ }
930
+ const defaultModel = backend.defaultModel.trim() !== '' ? backend.defaultModel : (models[0]?.id ?? '');
931
+ return {
932
+ ...current,
933
+ subagents: {
934
+ ...current.subagents,
935
+ [spec.cli]: { ...backend, models, defaultModel },
936
+ },
937
+ };
938
+ });
939
+ setDiscovery((previous) => ({
940
+ ...previous,
941
+ [spec.cli]: {
942
+ status: 'ok',
943
+ text: translate('discoverDone', { count: found.length, added }),
944
+ },
945
+ }));
946
+ } catch (error) {
947
+ setDiscovery((previous) => ({
948
+ ...previous,
949
+ [spec.cli]: {
950
+ status: 'error',
951
+ text: translate('discoverFailed', { reason: String(error?.message ?? error) }),
952
+ },
953
+ }));
954
+ }
955
+ };
665
956
  const save = async () => {
666
957
  if (!dirty || invalid !== null || !writable) return;
667
958
  const ops = diffOps(host, live);
@@ -714,6 +1005,26 @@ window.__ModuleLoader__.load({
714
1005
  disabled: !writable || busy,
715
1006
  onChange: (next) => edit((current) => ({ ...current, exitGuard: next })),
716
1007
  })),
1008
+ h('div', { className: 'apollo-section' },
1009
+ h('div', { className: 'apollo-sectionHead' },
1010
+ h('h3', { className: 'apollo-title' }, translate('tiersTitle')),
1011
+ h('p', { className: 'apollo-hint' }, translate('tiersIntro'))),
1012
+ routesState.status === 'loading'
1013
+ ? h('p', { className: 'apollo-hint' }, translate('routesLoading'))
1014
+ : allowed === null
1015
+ ? h('p', { className: 'apollo-note', 'data-tone': 'warn' }, translate('routesError'))
1016
+ : !allowed.enabled || allowed.routes.length === 0
1017
+ ? h('p', { className: 'apollo-note', 'data-tone': 'warn' }, translate('tiersInactive'))
1018
+ : null,
1019
+ ...TIERS.map((tier) => h(TierRow, {
1020
+ key: tier,
1021
+ tier,
1022
+ value: live.tiers[tier],
1023
+ allowed,
1024
+ disabled: !writable || busy,
1025
+ t: translate,
1026
+ onPatch: (patch) => patchTier(tier, patch),
1027
+ }))),
717
1028
  h('div', { className: 'apollo-section' },
718
1029
  h('div', { className: 'apollo-sectionHead' },
719
1030
  h('h3', { className: 'apollo-title' }, translate('scopeTitle'))),
@@ -742,12 +1053,14 @@ window.__ModuleLoader__.load({
742
1053
  backend: live.subagents[spec.cli],
743
1054
  disabled: !writable || busy,
744
1055
  capabilities,
1056
+ discovery,
745
1057
  t: translate,
746
1058
  generation,
747
1059
  onPatch: (patch) => patchBackend(spec, patch),
748
1060
  onModelPatch: (index, patch) => patchModel(spec, index, patch),
749
1061
  onModelAdd: () => addModel(spec),
750
1062
  onModelRemove: (index) => removeModel(spec, index),
1063
+ onDiscover: () => discoverModels(spec),
751
1064
  })),
752
1065
  h('div', { className: 'apollo-footer' },
753
1066
  h('button', {
package/src/config.js CHANGED
@@ -12,8 +12,8 @@
12
12
  * 声明,改动会让 Loader 正常重挂本行(重挂才需要重新读)。
13
13
  *
14
14
  * 可编辑范围(volatile,即 dsh-settings 投影出的行配置表单字段):
15
- * `exitGuard`、`subagents.eng`、`subagents.global`、`subagents.codex.*`、
16
- * `subagents.claude.*`。表单写入只能落在 volatile 路径上(dsh-settings 会拒绝
15
+ * `exitGuard`、`tiers.fast`、`tiers.quality`、`subagents.eng`、`subagents.global`、
16
+ * `subagents.codex.*`、`subagents.claude.*`。表单写入只能落在 volatile 路径上(dsh-settings 会拒绝
17
17
  * 其他路径),所以部署标志不会从网页上被改写。
18
18
  *
19
19
  * 持久化:DSH 的 configEditor 把表单写入落到活动 profile 的 cordis.patch.yml
@@ -94,6 +94,16 @@ function backendSchema(spec) {
94
94
  return z.object(dict).volatile()
95
95
  }
96
96
 
97
+ /** 子代理模型分档的一档:provider/model 为空表示沿用父会话路由。 */
98
+ const TierSchema = z.object({
99
+ provider: z.string().default(''),
100
+ model: z.string().default(''),
101
+ reasoningEffort: z.string().default(''),
102
+ }).volatile()
103
+
104
+ /** 分档名(提示词按此顺序渲染)。 */
105
+ export const TIER_NAMES = Object.freeze(['fast', 'quality'])
106
+
97
107
  /**
98
108
  * 本插件行的正式配置 schema。
99
109
  *
@@ -104,9 +114,14 @@ function backendSchema(spec) {
104
114
  export const Config = z.object({
105
115
  /** 退出守卫总开关(默认关闭,显式开启才拦回合结束)。 */
106
116
  exitGuard: z.boolean().default(false).volatile(),
117
+ /** 子代理模型分档(工程模式提示词按此渲染;路由须在 allowedModels 白名单内)。 */
118
+ tiers: z.object({
119
+ fast: TierSchema,
120
+ quality: TierSchema,
121
+ }),
107
122
  /** 子代理 CLI 后端的接入范围与每后端配置。 */
108
123
  subagents: z.object({
109
- /** 工程模式预设(`eng`/`eng-noask`)可见这些后端。 */
124
+ /** 工程模式预设(`eng`/`eng-ptc`)可见这些后端。 */
110
125
  eng: z.boolean().default(true).volatile(),
111
126
  /** 所有预设可见这些后端。 */
112
127
  global: z.boolean().default(false).volatile(),
@@ -212,20 +227,31 @@ function backendOf(value, spec) {
212
227
  return backend
213
228
  }
214
229
 
230
+ /** 一档读取:provider 或 model 任一为空则整档清空(= 沿用父会话路由)。 */
231
+ function tierOf(value) {
232
+ const source = isRecord(value) ? value : {}
233
+ const provider = stringOf(source.provider, '')
234
+ const model = stringOf(source.model, '')
235
+ if (provider === '' || model === '') return { provider: '', model: '', reasoningEffort: '' }
236
+ return { provider, model, reasoningEffort: stringOf(source.reasoningEffort, '') }
237
+ }
238
+
215
239
  /**
216
240
  * 把任意配置来源规整成普通 JSON(形状 EXACT,非法值回退默认)。
217
241
  *
218
242
  * 输入可以是 `entry.fiber.config`(volatile 字段是引用对象)、旧 JSON 文档、
219
243
  * 残缺对象或 `undefined`;返回值总是全新的、可安全改写的普通对象。
220
244
  * @param config - 任意配置值。
221
- * @returns `{ exitGuard, subagents: { eng, global, codex, claude }, trustedHosts, declareOwnsHost }`。
245
+ * @returns `{ exitGuard, tiers: { fast, quality }, subagents: { eng, global, codex, claude }, trustedHosts, declareOwnsHost }`。
222
246
  */
223
247
  export function normalizeConfig(config) {
224
248
  const projected = plain(config)
225
249
  const source = isRecord(projected) ? projected : {}
226
250
  const subagents = isRecord(source.subagents) ? source.subagents : {}
251
+ const tiers = isRecord(source.tiers) ? source.tiers : {}
227
252
  return {
228
253
  exitGuard: booleanOf(source.exitGuard, false),
254
+ tiers: { fast: tierOf(tiers.fast), quality: tierOf(tiers.quality) },
229
255
  subagents: {
230
256
  eng: booleanOf(subagents.eng, true),
231
257
  global: booleanOf(subagents.global, false),