dsh-auto-model-routing 0.3.0 → 0.4.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +82 -24
  3. package/README.zh.md +82 -24
  4. package/docs/architecture-v2.en.json +26 -0
  5. package/docs/architecture-v2.en.svg +637 -0
  6. package/docs/architecture-v2.zh.json +281 -0
  7. package/docs/architecture-v2.zh.svg +637 -0
  8. package/docs/feedback-evaluation.md +18 -0
  9. package/docs/host-capability-report.md +91 -0
  10. package/docs/jev-policy-v2-validation.md +37 -0
  11. package/docs/magpie-live-acceptance.md +27 -0
  12. package/docs/policy-v2-development.md +68 -0
  13. package/docs/policy-v2-real-evaluation-plan.md +32 -0
  14. package/docs/routing-community-research-2026-10-24.md +98 -0
  15. package/docs/routing-intent-and-gaps.md +56 -0
  16. package/docs/routing-strategy-plan.md +101 -0
  17. package/lib/client/control.d.ts +9 -1
  18. package/lib/client/control.js +16 -1
  19. package/lib/client/index.d.ts +2 -0
  20. package/lib/client/index.js +2 -2
  21. package/lib/client/label.d.ts +4 -0
  22. package/lib/client/label.js +24 -8
  23. package/lib/client/settings-section.js +53 -3
  24. package/lib/client/settings-ui.d.ts +1 -1
  25. package/lib/client/strategy-details.d.ts +4 -0
  26. package/lib/client/strategy-details.js +8 -0
  27. package/lib/client.js +332 -47
  28. package/lib/decision-service.d.ts +39 -0
  29. package/lib/decision-service.js +28 -0
  30. package/lib/endpoints.d.ts +2 -1
  31. package/lib/endpoints.js +22 -1
  32. package/lib/feedback.d.ts +95 -0
  33. package/lib/feedback.js +67 -0
  34. package/lib/hints.js +1 -1
  35. package/lib/index.js +29 -7
  36. package/lib/jev.d.ts +27 -1
  37. package/lib/jev.js +51 -23
  38. package/lib/policy-host.d.ts +68 -0
  39. package/lib/policy-host.js +274 -0
  40. package/lib/policy-store.d.ts +96 -0
  41. package/lib/policy-store.js +60 -0
  42. package/lib/policy-v2-rubric.json +21 -0
  43. package/lib/policy-v2.d.ts +20 -0
  44. package/lib/policy-v2.js +53 -0
  45. package/lib/settings-contract.d.ts +4 -1
  46. package/lib/settings-contract.js +1 -1
  47. package/lib/settings-host.d.ts +2 -2
  48. package/lib/settings-host.js +26 -14
  49. package/lib/settings.js +14 -1
  50. package/lib/subagent-tool.js +32 -5
  51. package/lib/usage-report.d.ts +59 -0
  52. package/lib/usage-report.js +332 -0
  53. package/package.json +3 -3
  54. package/skills/auto-model-routing-report/SKILL.md +50 -0
  55. package/skills/subagent-model-routing/SKILL.md +15 -0
  56. package/lib/selection-projection.d.ts +0 -8
  57. package/lib/selection-projection.js +0 -7
@@ -9,8 +9,23 @@ export const panelOpenAfter = (event) => event === 'enter' || event === 'focus';
9
9
  const TIER_ORDER = ['simple', 'standard', 'complex', 'escalated'];
10
10
  const pct = (v) => `${Math.round(v * 100)}%`;
11
11
  export const TOAST_MS = 3000;
12
- /** Line 1: who judged, tier → (model) effort, duration. Line 2: the distribution in tier order, 0% tiers omitted. */
12
+ const spreadText = (order, values) => {
13
+ const entries = order.filter(key => values?.[key] !== undefined && Number.isFinite(values[key]) && values[key] >= 0 && values[key] <= 1);
14
+ return entries.length ? entries.map(key => `${key} ${pct(values[key])}`).join(' / ') : '未提供';
15
+ };
16
+ /** Shared by the persistent hover panel and transient decision hint. */
13
17
  export function toastText(d) {
18
+ if (d.depth)
19
+ return [
20
+ `${d.source === 'main' ? '主会话' : '子任务'} · ${d.status === 'applied' ? '已应用' : d.status === 'failed' ? '未应用' : d.status === 'unsupported' ? '不支持调整' : '已判断/待应用'}`,
21
+ ...(d.capability ? [`能力 ${d.capability} · 判定确定度 ${d.capabilityConfidence === undefined ? '未提供' : pct(d.capabilityConfidence)}`] : []),
22
+ ...(d.capability ? [`能力分布:${spreadText(['bounded', 'analytical', 'frontier', 'insufficient_context'], d.capabilityProbabilities)}`] : []),
23
+ `投入 ${d.depth} · 判定确定度 ${d.confidence === undefined ? '未提供' : pct(d.confidence)}`,
24
+ `投入分布:${spreadText(['lower', 'baseline', 'higher'], d.probabilities)}`,
25
+ `${d.provider ?? ''} / ${d.model ?? '待确定'} · ${d.effort}`,
26
+ `${d.decisionProvider ?? '未知服务'} / ${d.decisionModel ?? '未知模型'} · ${(d.ms / 1000).toFixed(2)}s`,
27
+ '确定度 = (最高概率 − 1/n) ÷ (1 − 1/n),n 为候选数;不是任务成功率',
28
+ ];
14
29
  const head = [
15
30
  d.source === 'main' ? '主会话判断' : '子会话判断',
16
31
  `${d.tier} → ${d.model ? `${d.model} · ` : ''}${d.effort}`,
@@ -3,6 +3,8 @@ import { type Decision, type Rpc, type Toast } from './control.js';
3
3
  import { type JevCategory } from '../hints.js';
4
4
  interface View {
5
5
  mode: 'AUTO' | 'MANUAL';
6
+ policy?: string;
7
+ depth?: string;
6
8
  effort?: string;
7
9
  reason?: string;
8
10
  disabled?: {
@@ -24,7 +24,7 @@ const CSS = `
24
24
  .amr-latest{margin:6px 0;padding:6px 0;border-top:1px solid var(--dsw-alias-border-l1);border-bottom:1px solid var(--dsw-alias-border-l1)}
25
25
  .amr-muted{color:var(--dsw-alias-label-tertiary)}
26
26
  .amr-error{color:var(--dsw-alias-state-error-primary)}
27
- .amr-toast{position:fixed;z-index:1100;transform:translate(-50%,-100%);padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg,#fff);color:var(--dsw-alias-label-secondary);box-shadow:var(--dsw-elevation-prominent);font-size:12px;line-height:18px;white-space:nowrap}
27
+ .amr-toast{position:fixed;z-index:1100;transform:translate(-50%,-100%);padding:4px 8px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg,#fff);color:var(--dsw-alias-label-secondary);box-shadow:var(--dsw-elevation-prominent);font-size:12px;line-height:18px;white-space:normal;max-width:min(480px,calc(100vw - 24px));overflow-wrap:anywhere}
28
28
  `;
29
29
  // Left-right swap arrows at the picker chevron's size; colour follows currentColor.
30
30
  function SwapIcon() {
@@ -128,7 +128,7 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
128
128
  'aria-label': `推理强度模式:${view?.mode ?? '加载中'},点击切换`,
129
129
  }, h('span', null, off ? `${view?.mode} · Jev 停用` : view?.mode ?? '…'), h(SwapIcon)), toast && !open && (typeof document === 'undefined'
130
130
  ? h('div', { className: 'amr-toast', role: 'status', style: { pointerEvents: 'none' } }, ...toast.lines.map((line, index) => h('div', { key: index, className: index ? 'amr-muted' : undefined }, line)))
131
- : createPortal(h('div', { className: 'amr-toast', role: 'status', style: { pointerEvents: 'none', left: place.left, top: place.top } }, h('style', null, CSS), ...toast.lines.map((line, index) => h('div', { key: index, className: index ? 'amr-muted' : undefined }, line))), document.body)), open && h('section', { className: 'amr-panel', 'aria-label': '自动路由状态' }, h('div', { className: 'amr-card' }, h('p', null, h('strong', null, view?.mode === 'AUTO' ? '自动调整推理强度' : '手动:不自动调整')), h('p', null, `本回合插件输出:${view?.effort ?? '未改写'} · ${view?.reason ?? '尚无决定'}`), view?.decision && h('div', { className: 'amr-latest' }, h('p', null, h('strong', null, '最近决策')), ...toastText(view.decision).map((line, index) => h('p', { key: index, className: index ? 'amr-muted' : undefined }, line))), h('p', { className: 'amr-muted' }, 'AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。'), off && h('p', { role: 'status', className: 'amr-error' }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`), h('p', { className: 'amr-muted' }, 'AUTO 会把任务文本发送给 OpenRouter/TypeSafe 判档。'), error && h('p', { role: 'alert', className: 'amr-error' }, error))));
131
+ : createPortal(h('div', { className: 'amr-toast', role: 'status', style: { pointerEvents: 'none', left: place.left, top: place.top } }, h('style', null, CSS), ...toast.lines.map((line, index) => h('div', { key: index, className: index ? 'amr-muted' : undefined }, line))), document.body)), open && h('section', { className: 'amr-panel', 'aria-label': '自动路由状态' }, h('div', { className: 'amr-card' }, h('p', null, h('strong', null, view?.mode === 'AUTO' ? '自动调整推理强度' : '手动:不自动调整')), h('p', null, `本回合插件输出:${view?.effort ?? '未改写'} · ${view?.reason ?? '尚无决定'}`), view?.decision && h('div', { className: 'amr-latest' }, h('p', null, h('strong', null, '最近决策')), ...toastText(view.decision).map((line, index) => h('p', { key: index, className: index ? 'amr-muted' : undefined }, line))), h('p', { className: 'amr-muted' }, view?.policy === 'policy-v2' ? `AUTO · ${view.depth ?? '尚无判断'};主模型不变,推理投入按请求应用;子任务自动选配。` : 'AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。'), off && h('p', { role: 'status', className: 'amr-error' }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`), h('p', { className: 'amr-muted' }, 'AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。'), error && h('p', { role: 'alert', className: 'amr-error' }, error))));
132
132
  }
133
133
  const labelUi = { Tooltip, Menu, Icon: IconGaugeOutlineRegular };
134
134
  export function apply(ctx) {
@@ -5,6 +5,10 @@ interface Labelled {
5
5
  tier: Tier;
6
6
  effort?: string;
7
7
  label?: Tier;
8
+ policy?: string;
9
+ depth?: string;
10
+ model?: string;
11
+ feedback?: string | null;
8
12
  }
9
13
  /** Official primitives, injected so the component carries no styles of its own (tests pass doubles). */
10
14
  export interface LabelUi {
@@ -1,27 +1,31 @@
1
1
  import { createElement as h, useEffect, useRef, useState } from 'react';
2
+ const FEEDBACK = { appropriate: '分配合适', 'too-high': '投入偏高', 'too-low': '投入不足', unknown: '无法判断' };
2
3
  const TIERS = ['simple', 'standard', 'complex', 'escalated'];
3
4
  const NAMES = { simple: '简单', standard: '标准', complex: '复杂', escalated: '升级' };
4
5
  const CSS = `.amr-label-action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:6px;display:inline-flex}.amr-label-action svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px))}.amr-label-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}`;
5
- async function callLabel(rpc, sessionId, messageId, label) {
6
- const result = await rpc.call('/api', 'auto-model-routing.label', { sessionId, messageId, ...(label ? { label } : {}) });
6
+ async function callLabel(rpc, sessionId, messageId, label, feedback) {
7
+ const result = await rpc.call('/api', 'auto-model-routing.label', { sessionId, messageId, ...(label ? { label } : {}), ...(feedback !== undefined ? { feedback } : {}) });
7
8
  if (!result.ok)
8
9
  throw new Error(result.error?.message ?? '标注请求失败');
9
10
  return result.value;
10
11
  }
11
12
  export function tooltipText(view) {
13
+ if (view.policy === 'policy-v2')
14
+ return `AUTO 分配:${view.depth} → ${view.effort} · ${view.model}${view.feedback ? ` · 你反馈:${FEEDBACK[view.feedback]}` : ' · 评价资源分配是否合适'}`;
12
15
  return `AUTO 判档:${NAMES[view.tier]}${view.effort ? ` → ${view.effort}` : ''}${view.label ? ` · 你标注:${NAMES[view.label]}` : ''}`;
13
16
  }
14
17
  /** Reply-bar tier button (design D13): shown only for replies of a judged turn; selecting a tier records a label. */
15
18
  export function LabelButton({ sessionId, messageId, rpc, ui }) {
16
19
  const [view, setView] = useState(undefined);
17
20
  const [open, setOpen] = useState(false);
21
+ const [error, setError] = useState('');
18
22
  const alive = useRef(true);
19
23
  useEffect(() => () => { alive.current = false; }, []);
20
24
  useEffect(() => {
21
25
  let active = true;
22
26
  setView(undefined);
23
27
  // One query per message; `{}` (or an error) renders nothing.
24
- callLabel(rpc, sessionId, messageId).then(value => { if (active && 'tier' in value)
28
+ callLabel(rpc, sessionId, messageId).then(value => { if (active && ('tier' in value || value.policy === 'policy-v2'))
25
29
  setView(value); }, () => { });
26
30
  return () => { active = false; };
27
31
  }, [rpc, sessionId, messageId]);
@@ -30,17 +34,29 @@ export function LabelButton({ sessionId, messageId, rpc, ui }) {
30
34
  const choose = (label) => {
31
35
  setOpen(false);
32
36
  // A failure leaves the shown label unchanged.
33
- callLabel(rpc, sessionId, messageId, label).then(value => { if (alive.current && 'tier' in value)
37
+ callLabel(rpc, sessionId, messageId, label).then(value => { if (alive.current && ('tier' in value || value.policy === 'policy-v2'))
34
38
  setView(value); }, () => { });
35
39
  };
40
+ const allocation = view.policy === 'policy-v2';
41
+ const chooseFeedback = (id) => {
42
+ setOpen(false);
43
+ setError('');
44
+ callLabel(rpc, sessionId, messageId, undefined, id === 'clear' ? null : id).then(value => { if (alive.current && value.policy === 'policy-v2')
45
+ setView(value); }, () => { if (alive.current)
46
+ setError('反馈未保存,请重试'); });
47
+ };
36
48
  const button = h('button', { type: 'button', className: 'amr-label-action', 'aria-label': tooltipText(view), onClick: () => setOpen(value => !value) }, h(ui.Icon));
37
49
  return h('span', null, h('style', null, CSS), h(ui.Menu, {
38
50
  open, side: 'top', portal: true, onClose: () => setOpen(false),
39
51
  // The official menu draws the check on the selected (labelled) row.
40
- ...(view.label ? { selectedId: view.label } : {}),
41
- items: [{ type: 'label', id: 'title', text: '这轮应该用哪档?' }, ...TIERS.map(tier => ({ id: tier, label: `${NAMES[tier]}${tier === view.tier ? ' · Jev' : ''}` }))],
42
- onSelect: id => { if (TIERS.includes(id))
52
+ ...((allocation ? view.feedback : view.label) ? { selectedId: (allocation ? view.feedback : view.label) } : {}),
53
+ items: allocation ? [{ type: 'label', id: 'title', text: '这次资源分配合适吗?' }, ...Object.entries(FEEDBACK).map(([id, label]) => ({ id, label })), ...(view.feedback ? [{ id: 'clear', label: '撤销反馈' }] : [])] : [{ type: 'label', id: 'title', text: '这轮应该用哪档?' }, ...TIERS.map(tier => ({ id: tier, label: `${NAMES[tier]}${tier === view.tier ? ' · Jev' : ''}` }))],
54
+ onSelect: id => { if (allocation) {
55
+ if (Object.hasOwn(FEEDBACK, id) || id === 'clear')
56
+ chooseFeedback(id);
57
+ }
58
+ else if (TIERS.includes(id))
43
59
  choose(id); },
44
60
  anchor: h(ui.Tooltip, { label: tooltipText(view), side: 'bottom', children: button }),
45
- }));
61
+ }), error && h('span', { role: 'alert' }, error));
46
62
  }
@@ -3,9 +3,14 @@ import { AUTO_EFFORTS, PROVIDERS } from '../settings-contract.js';
3
3
  import { selectableModels, supportedFamilyEfforts, validateChildRoute, validateFamilyMainEffort } from '../settings-validation.js';
4
4
  import { CHILD_MODELS, mapEffort, tiers } from '../effort.js';
5
5
  import { RoutePicker } from './route-picker.js';
6
+ import { DECISION_SERVICES, resolveDecisionService } from '../decision-service.js';
7
+ import { CATEGORY_HINTS } from '../hints.js';
8
+ import { StrategyDetails } from './strategy-details.js';
6
9
  const CSS = `
7
10
  .amr-settings{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);min-width:0;container-type:inline-size}.amr-settings-section{padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}.amr-settings h3{font-size:13px;font-weight:600;margin:0 0 12px}.amr-settings p{margin:6px 0;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}.amr-settings-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:14px 0}.amr-settings-row>div:first-child{min-width:0}.amr-settings-number{width:76px;text-align:right;font:inherit;font-variant-numeric:tabular-nums;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);color:inherit;background:var(--dsw-alias-bg-layer-1);padding:6px}.amr-settings-number:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary)}
8
11
  .amr-settings-table{border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-lg);margin:12px 0;min-width:0}.amr-settings-tier,.amr-settings-head{display:grid;grid-template-columns:minmax(85px,.65fr) minmax(0,1fr) minmax(0,1.5fr);gap:10px;padding:12px;align-items:start}.amr-settings-tier,.amr-settings-head{border-bottom:.5px solid var(--dsw-alias-border-l2)}.amr-settings-tier:last-child{border-bottom:0}.amr-settings-head{color:var(--dsw-alias-label-secondary)}.amr-settings-tier-name{display:flex;align-items:center;gap:4px;min-height:30px}.amr-settings-info,.amr-settings-reset{border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;padding:2px}.amr-settings-info{display:inline-flex}.amr-settings-reset{font-size:12px;margin-top:4px}.amr-settings-reset:hover{color:var(--dsw-alias-label-primary)}.amr-settings-info:focus-visible,.amr-settings-reset:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary)}.amr-settings .amr-settings-error{color:var(--dsw-alias-state-error-primary)}.amr-settings-cell{min-width:0}.amr-settings-cell-label{display:none}.amr-settings .amr-settings-segments{display:inline-grid;flex:none;width:max-content;max-width:100%;height:28px;box-sizing:border-box;gap:2px;padding:2px;border-radius:var(--dsw-radius-md)}.amr-settings .amr-settings-segments>button{height:24px;min-width:44px;padding:0 8px;border-radius:var(--dsw-radius-sm);font-size:12px;line-height:20px}.amr-settings .amr-settings-segments>[aria-hidden=true]{top:2px;bottom:2px;left:2px;height:calc(100% - 4px);width:calc((100% - 4px - 2px * (var(--dsh-segment-count) - 1)) / var(--dsh-segment-count))}.amr-settings .amr-settings-tabs{--dsh-segment-count:2}.amr-settings .amr-settings-tabs>[aria-hidden=true]{width:calc((100% - 6px) / 2)!important;transform:translateX(calc(var(--amr-tab-index,0) * (100% + 2px)))!important}.amr-settings .amr-settings-tabs:has([aria-selected=true]:last-child){--amr-tab-index:1}
12
+ .amr-test-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:12px 0 6px}.amr-test-row>span{font-size:12px;color:var(--dsw-alias-label-secondary)}.amr-test-button{height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}.amr-test-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.amr-test-button:disabled{opacity:.5;cursor:not-allowed}.amr-test-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
13
+ .amr-service-trigger,.amr-service-input{box-sizing:border-box;min-width:0;width:240px;max-width:100%;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}.amr-service-trigger{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer;text-align:left}.amr-service-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}.amr-service-trigger svg{color:var(--dsw-alias-label-tertiary);flex:none}.amr-service-trigger:focus-visible,.amr-service-input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
9
14
  @container(max-width:480px){.amr-settings-head{display:none}.amr-settings-tier{grid-template-columns:1fr;gap:8px}.amr-settings-cell{display:grid;grid-template-columns:minmax(110px,.7fr) minmax(0,1fr);align-items:center;gap:8px}.amr-settings-cell-label{display:block;color:var(--dsw-alias-label-secondary)}.amr-settings-cell p,.amr-settings-reset{grid-column:2}.amr-settings-row{flex-wrap:wrap}}
10
15
  `;
11
16
  async function request(rpc, action, payload = {}) {
@@ -24,9 +29,13 @@ export function RoutingSettings({ rpc, ui }) {
24
29
  const [patch, setPatch] = useState({});
25
30
  const [childDrafts, setChildDrafts] = useState({});
26
31
  const [provider, setProvider] = useState('claude');
32
+ const [serviceMenu, setServiceMenu] = useState(false);
33
+ const [modelMenu, setModelMenu] = useState(false);
27
34
  const [key, setKey] = useState('');
28
35
  const [removeKey, setRemoveKey] = useState(false);
29
36
  const [saving, setSaving] = useState(false);
37
+ const [testing, setTesting] = useState(false);
38
+ const [testResult, setTestResult] = useState();
30
39
  const [message, setMessage] = useState('');
31
40
  const [failed, setFailed] = useState(false);
32
41
  const [saveIssues, setSaveIssues] = useState([]);
@@ -41,6 +50,19 @@ export function RoutingSettings({ rpc, ui }) {
41
50
  return h('div', { className: 'amr-settings', role: 'status' }, message || '正在读取设置…');
42
51
  const settings = { ...view.settings, ...patch };
43
52
  const cap = settings.effortCap;
53
+ const serviceConfig = settings.decisionService ?? { provider: 'openrouter' };
54
+ const serviceProvider = serviceConfig.provider;
55
+ const decisionModel = serviceConfig.model ?? DECISION_SERVICES[serviceProvider].model;
56
+ // Preset choices are documented snapshots, not a live availability claim.
57
+ const decisionModels = [...new Set([...(serviceProvider === 'openrouter' ? ['typesafe/jev-1.13'] : ['jev-1.13.0', 'jev-1.14.0']), decisionModel])];
58
+ const changingService = serviceProvider !== (view.settings.decisionService?.provider ?? 'openrouter');
59
+ let invalidService = false;
60
+ try {
61
+ resolveDecisionService(serviceConfig);
62
+ }
63
+ catch {
64
+ invalidService = true;
65
+ }
44
66
  const invalidFamily = (p) => {
45
67
  const family = view.settings.mainEfforts?.[p];
46
68
  return family !== undefined && (!family || typeof family !== 'object' || Array.isArray(family) || Object.keys(family).some(key => !tiers.includes(key)));
@@ -105,7 +127,7 @@ export function RoutingSettings({ rpc, ui }) {
105
127
  let nextView = view;
106
128
  if (key || removeKey) {
107
129
  try {
108
- const response = await request(rpc, 'setKey', { value: removeKey ? null : key });
130
+ const response = await request(rpc, 'setKey', { value: removeKey ? null : key, ...(serviceProvider !== 'openrouter' ? { provider: serviceProvider } : {}) });
109
131
  if (response?.key)
110
132
  nextView = { ...nextView, key: response.key };
111
133
  setKey('');
@@ -140,8 +162,36 @@ export function RoutingSettings({ rpc, ui }) {
140
162
  const keyHint = !view.key.available ? 'DSH 凭据服务不可用。' : !view.key.writable ? '先自行保留现有 key,从启动环境(如 .env.local)删除 OPENROUTER_API_KEY 并重启,再在这里保存。' : view.key.configured ? '保存在 DSH 凭据存储,不写入设置文件;留空表示保持当前 key。' : '未配置时 AUTO 判档会停用;保存 key 后不会自动重启已停用的会话。';
141
163
  return h('div', { className: 'amr-settings' }, h('style', null, CSS), h(ui.SettingsForm, {
142
164
  labels: { unavailable: '无法读取设置', readOnly: '设置只读', saveFailed: '部分更改未保存,请检查提示', save: '保存', saving: '保存中…' },
143
- state: { available: true, writable: true, dirty, invalid: conflicts.length > 0 || invalidTimeout || invalidKey, saving, failed }, onSave: () => { void save(); }, onDiscard: () => { },
144
- children: h('fieldset', { disabled: saving, style: { border: 0, padding: 0, margin: 0, minWidth: 0 } }, view.configStatus === 'invalid' && h('p', { role: 'status' }, '设置文件无法解析或含无效字段,已按可解析值与默认值显示;保存会覆盖损坏部分'), message && h('p', { role: 'status', className: failed ? 'amr-settings-error' : undefined }, message), h('section', { className: 'amr-settings-section' }, h('h3', null, '判档服务'), h('p', null, 'AUTO 时每个用户回合请 Jev(经 OpenRouter)判断任务难度。'), h(ui.SettingsSecretField, { id: 'amr-key', label: 'OpenRouter API Key', hint: keyHint, text: key, disabled: saving || !view.key.available || !view.key.writable, onEdit: text => { setKey(text); setRemoveKey(false); }, configured: view.key.configured, stateLabel: keyState }), view.key.available && view.key.writable && view.key.configured && h('button', { type: 'button', className: 'amr-settings-reset', onClick: () => { setRemoveKey(!removeKey); setKey(''); } }, removeKey ? '取消删除 key' : '删除 key'), invalidKey && h('p', { className: 'amr-settings-error' }, 'API key 不能含空白,且不能超过 512 字符'), h('div', { className: 'amr-settings-row' }, h('div', null, h('label', { htmlFor: 'amr-timeout' }, '判档等待上限'), h('p', null, '1000–4500 毫秒;超时后该会话停用 AUTO。')), h('span', null, h('input', { id: 'amr-timeout', type: 'number', className: 'amr-settings-number', min: 1000, max: 4500, step: 1, value: Number.isNaN(settings.jevTimeoutMs) ? '' : settings.jevTimeoutMs, 'aria-invalid': invalidTimeout, onChange: e => setPatch(old => ({ ...old, jevTimeoutMs: e.currentTarget.value === '' ? NaN : Number(e.currentTarget.value) })) }), ' 毫秒'))), h('section', { className: 'amr-settings-section' }, h('h3', null, '会话'), h('div', { className: 'amr-settings-row' }, h('div', null, '新会话默认模式', h('p', null, settings.defaultMode === 'AUTO' ? '仅影响新会话;每个用户回合都会把消息发送给 OpenRouter/TypeSafe 请求 Jev。' : '仅影响新会话,不改变已有会话。')), h(ui.SegmentedControl, { id: 'amr-default-mode', className: 'amr-settings-segments', label: '新会话默认模式', value: settings.defaultMode, options: [{ value: 'MANUAL', label: 'MANUAL' }, { value: 'AUTO', label: 'AUTO' }], onChange: value => setPatch(old => ({ ...old, defaultMode: value })) })), h('div', { className: 'amr-settings-row' }, h('span', null, '判档提示'), h(ui.Switch, { checked: settings.decisionToast, label: '判档提示', onChange: value => setPatch(old => ({ ...old, decisionToast: value })) })), h('div', { className: 'amr-settings-row' }, h('span', null, '推理强度上限'), h(ui.SegmentedControl, { id: 'amr-cap', className: 'amr-settings-segments', label: '推理强度上限', value: cap, options: AUTO_EFFORTS.map(value => ({ value, label: value })), onChange: value => setPatch(old => ({ ...old, effortCap: value })) }))), h('section', { className: 'amr-settings-section' }, h('h3', null, '路由映射'), h(ui.SegmentedTabs, { className: 'amr-settings-segments amr-settings-tabs', label: '模型体系', value: provider, items: [{ value: 'claude', label: 'Claude', id: 'amr-tab-claude', panelId: 'amr-panel-claude' }, { value: 'codex', label: 'GPT', id: 'amr-tab-codex', panelId: 'amr-panel-codex' }], onChange: value => setProvider(value) }), h('div', { role: 'tabpanel', id: `amr-panel-${provider}`, 'aria-labelledby': `amr-tab-${provider}` }, h('p', null, '主会话推理等级适用于本体系所有模型;默认随实际模型计算。可选覆盖值为当前体系模型共同支持且不超过上限的等级。'), !supportedFamilyEfforts(view, provider, cap).length && h('p', { role: 'status' }, '当前体系没有共同支持且不超过上限的推理等级;主会话覆盖暂不可选,可恢复默认。'), familyUnresolved(provider) && h('p', { className: 'amr-settings-error', role: 'status' }, '主会话映射格式无效(含旧版按模型配置),不会按默认值执行。请重新配置或', h('button', { type: 'button', className: 'amr-settings-reset', onClick: () => setPatch(old => ({ ...old, mainEfforts: { ...old.mainEfforts, [provider]: null } })) }, '恢复本体系默认值')), h('p', null, '主会话保留当前模型,只调 effort;子会话在 route_subagent 判档后返回推荐 model + effort,不改既有子会话、不决定是否派发。'), view.childAllowlist.status !== 'enabled' || !selectableModels(view, provider, 'child').length ? h('p', { role: 'status' }, view.childAllowlist.status === 'unavailable' ? '无法读取子智能体允许列表;请检查官方子智能体设置。' : '请在官方子智能体设置启用并配置允许列表。') : null, h('div', { className: 'amr-settings-table' }, h('div', { className: 'amr-settings-head', 'aria-hidden': true }, h('span', null, '档位'), h('span', null, '主会话推理等级'), h('span', null, '子会话推荐')), ...tiers.map(tier => {
165
+ state: { available: true, writable: true, dirty, invalid: conflicts.length > 0 || invalidTimeout || invalidKey || invalidService, saving, failed }, onSave: () => { void save(); }, onDiscard: () => { },
166
+ children: h('fieldset', { disabled: saving || testing, style: { border: 0, padding: 0, margin: 0, minWidth: 0 } }, view.configStatus === 'invalid' && h('p', { role: 'status' }, '设置文件无法解析或含无效字段,已按可解析值与默认值显示;保存会覆盖损坏部分'), message && h('p', { role: 'status', className: failed ? 'amr-settings-error' : undefined }, message), h('section', { className: 'amr-settings-section' }, h('h3', null, '判档服务'), h('p', null, 'AUTO 把脱敏任务发送给所选决策服务;不跨服务自动回退。'), h('div', { className: 'amr-settings-row' }, h('span', null, '决策服务'), h(ui.Menu, { open: serviceMenu, portal: true, side: 'bottom', selectedId: serviceProvider, onClose: () => setServiceMenu(false),
167
+ items: Object.entries(DECISION_SERVICES).map(([id, service]) => ({ id, label: service.label })),
168
+ onSelect: id => { if (!Object.hasOwn(DECISION_SERVICES, id))
169
+ return; setKey(''); setRemoveKey(false); setPatch(old => ({ ...old, decisionService: { provider: id } })); setServiceMenu(false); },
170
+ anchor: h('button', { type: 'button', className: 'amr-service-trigger', 'aria-label': '决策服务', 'aria-haspopup': 'menu', 'aria-expanded': serviceMenu, onClick: () => setServiceMenu(!serviceMenu) }, h('span', null, DECISION_SERVICES[serviceProvider].label), h(ui.IconChevronDownOutlineRegular, { size: 14 })), })), h('p', null, changingService ? '切换服务后先保存,再管理该服务的独立 API key;不会沿用原服务的 key。' : `凭据引用:${DECISION_SERVICES[serviceProvider].credential}`), h('div', { className: 'amr-settings-row' }, h('span', null, '决策模型'), serviceProvider === 'custom'
171
+ ? h('input', { className: 'amr-service-input', 'aria-label': '决策模型', value: decisionModel, onChange: e => setPatch(old => ({ ...old, decisionService: { ...serviceConfig, model: e.currentTarget.value } })) })
172
+ : h(ui.Menu, { open: modelMenu, portal: true, side: 'bottom', selectedId: decisionModel, onClose: () => setModelMenu(false),
173
+ items: decisionModels.map(id => ({ id, label: id })),
174
+ onSelect: model => { if (!decisionModels.includes(model))
175
+ return; setPatch(old => ({ ...old, decisionService: { ...serviceConfig, model } })); setModelMenu(false); },
176
+ anchor: h('button', { type: 'button', className: 'amr-service-trigger', 'aria-label': '决策模型', 'aria-haspopup': 'menu', 'aria-expanded': modelMenu, onClick: () => setModelMenu(!modelMenu) }, h('span', null, decisionModel), h(ui.IconChevronDownOutlineRegular, { size: 14 })),
177
+ })), serviceProvider === 'custom' && h('label', { className: 'amr-settings-row' }, '兼容端点 ', h('input', { className: 'amr-service-input', 'aria-label': '兼容端点', value: serviceConfig.endpoint ?? '', placeholder: 'https://example.com/v1/systemone', onChange: e => setPatch(old => ({ ...old, decisionService: { ...serviceConfig, endpoint: e.currentTarget.value } })) })), h('p', null, serviceProvider === 'custom' ? '仅兼容 System One state/questions → answers;HTTPS 或回环 HTTP;禁止 URL 凭据、查询参数与重定向。' : `接口:${DECISION_SERVICES[serviceProvider].endpoint}`), h('div', { className: 'amr-test-row' }, h('button', { type: 'button', className: 'amr-test-button', disabled: dirty || saving || testing, 'aria-busy': testing, onClick: async () => {
178
+ if (testing)
179
+ return;
180
+ setTesting(true);
181
+ setTestResult(undefined);
182
+ try {
183
+ const r = await rpc.call('/api', 'auto-model-routing.testDecision', {});
184
+ const v = r.value;
185
+ const ok = r.ok && v?.kind === 'ok';
186
+ setTestResult({ failed: !ok, text: ok ? `连接成功 · ${v.ms} ms` : `连接失败:${v?.category ? CATEGORY_HINTS[v.category] ?? '服务不可用' : '无法连接决策服务,请重试'}` });
187
+ }
188
+ catch {
189
+ setTestResult({ failed: true, text: '连接测试失败,请检查网络后重试' });
190
+ }
191
+ finally {
192
+ setTesting(false);
193
+ }
194
+ } }, testing ? '测试中…' : '测试连接'), h('span', { role: 'status', 'aria-live': 'polite', className: testResult?.failed ? 'amr-settings-error' : undefined }, testing ? '正在请求所选决策服务…' : testResult?.text ?? (dirty ? '请先保存设置,再测试连接' : ''))), h('p', null, '测试会产生一次决策请求,不包含会话内容;请先保存设置。'), invalidService && h('p', { role: 'alert', className: 'amr-settings-error' }, '请填写有效模型和兼容端点'), h(ui.SettingsSecretField, { id: 'amr-key', label: `${DECISION_SERVICES[serviceProvider].label} API Key`, hint: changingService ? '请先保存服务选择' : keyHint, text: key, disabled: changingService || saving || !view.key.available || !view.key.writable, onEdit: text => { setKey(text); setRemoveKey(false); }, configured: !changingService && view.key.configured, stateLabel: changingService ? '保存后读取' : keyState }), !changingService && view.key.available && view.key.writable && view.key.configured && h('button', { type: 'button', className: 'amr-settings-reset', onClick: () => { setRemoveKey(!removeKey); setKey(''); } }, removeKey ? '取消删除 key' : '删除 key'), invalidKey && h('p', { className: 'amr-settings-error' }, 'API key 不能含空白,且不能超过 512 字符'), h('div', { className: 'amr-settings-row' }, h('div', null, h('label', { htmlFor: 'amr-timeout' }, '判档等待上限'), h('p', null, '1000–4500 毫秒;超时后该会话停用 AUTO。')), h('span', null, h('input', { id: 'amr-timeout', type: 'number', className: 'amr-settings-number', min: 1000, max: 4500, step: 1, value: Number.isNaN(settings.jevTimeoutMs) ? '' : settings.jevTimeoutMs, 'aria-invalid': invalidTimeout, onChange: e => setPatch(old => ({ ...old, jevTimeoutMs: e.currentTarget.value === '' ? NaN : Number(e.currentTarget.value) })) }), ' 毫秒'))), h('section', { className: 'amr-settings-section' }, h('h3', null, '会话'), h('p', { role: 'status' }, settings.policy === 'legacy' ? '当前使用旧策略回滚配置。' : 'AUTO 已使用新策略,无需额外启用。主模型不变;子任务独立选择模型能力与推理投入,并在创建时应用。MANUAL 不进行自动判档。'), h('div', { className: 'amr-settings-row' }, h('div', null, '新会话默认模式', h('p', null, settings.defaultMode === 'AUTO' ? '仅影响新会话;每个用户回合都会把脱敏任务发送给所选决策服务请求 Jev。' : '仅影响新会话,不改变已有会话。')), h(ui.SegmentedControl, { id: 'amr-default-mode', className: 'amr-settings-segments', label: '新会话默认模式', value: settings.defaultMode, options: [{ value: 'MANUAL', label: 'MANUAL' }, { value: 'AUTO', label: 'AUTO' }], onChange: value => setPatch(old => ({ ...old, defaultMode: value })) })), h('div', { className: 'amr-settings-row' }, h('span', null, '判档提示'), h(ui.Switch, { checked: settings.decisionToast, label: '判档提示', onChange: value => setPatch(old => ({ ...old, decisionToast: value })) })), h('div', { className: 'amr-settings-row' }, h('span', null, '推理强度上限'), h(ui.SegmentedControl, { id: 'amr-cap', className: 'amr-settings-segments', label: '推理强度上限', value: cap, options: AUTO_EFFORTS.map(value => ({ value, label: value })), onChange: value => setPatch(old => ({ ...old, effortCap: value })) }))), settings.policy !== 'legacy' && h('section', { className: 'amr-settings-section' }, h('h3', null, '自动选配'), h('p', null, '主会话仅调整推理投入;子任务按目标与范围选择合适的能力档,再确定投入。保留显式指定的模型和推理等级,不自动重试或升级。'), h('p', null, 'Magpie 使用完整模型 ID 与官方允许列表;目录不可用或目标不清楚时停止并提示。fresh 子任务受支持,fork 暂不支持。旧四档配置保留供回滚,不参与当前策略。'), h(StrategyDetails)), settings.policy === 'legacy' && h('section', { className: 'amr-settings-section' }, h('h3', null, '路由映射'), h(ui.SegmentedTabs, { className: 'amr-settings-segments amr-settings-tabs', label: '模型体系', value: provider, items: [{ value: 'claude', label: 'Claude', id: 'amr-tab-claude', panelId: 'amr-panel-claude' }, { value: 'codex', label: 'GPT', id: 'amr-tab-codex', panelId: 'amr-panel-codex' }], onChange: value => setProvider(value) }), h('div', { role: 'tabpanel', id: `amr-panel-${provider}`, 'aria-labelledby': `amr-tab-${provider}` }, h('p', null, '主会话推理等级适用于本体系所有模型;默认随实际模型计算。可选覆盖值为当前体系模型共同支持且不超过上限的等级。'), !supportedFamilyEfforts(view, provider, cap).length && h('p', { role: 'status' }, '当前体系没有共同支持且不超过上限的推理等级;主会话覆盖暂不可选,可恢复默认。'), familyUnresolved(provider) && h('p', { className: 'amr-settings-error', role: 'status' }, '主会话映射格式无效(含旧版按模型配置),不会按默认值执行。请重新配置或', h('button', { type: 'button', className: 'amr-settings-reset', onClick: () => setPatch(old => ({ ...old, mainEfforts: { ...old.mainEfforts, [provider]: null } })) }, '恢复本体系默认值')), h('p', null, '主会话保留当前模型,只调 effort;子会话在 route_subagent 判档后返回推荐 model + effort,不改既有子会话、不决定是否派发。'), view.childAllowlist.status !== 'enabled' || !selectableModels(view, provider, 'child').length ? h('p', { role: 'status' }, view.childAllowlist.status === 'unavailable' ? '无法读取子智能体允许列表;请检查官方子智能体设置。' : '请在官方子智能体设置启用并配置允许列表。') : null, h('div', { className: 'amr-settings-table' }, h('div', { className: 'amr-settings-head', 'aria-hidden': true }, h('span', null, '档位'), h('span', null, '主会话推理等级'), h('span', null, '子会话推荐')), ...tiers.map(tier => {
145
195
  const effort = mainValue(provider, tier);
146
196
  return h('div', { key: tier, className: 'amr-settings-tier', 'data-tier': tier }, h('div', { className: 'amr-settings-tier-name' }, tier, h(ui.Tooltip, { label: view.criteria[tier], side: 'top', portal: true, children: h('button', { type: 'button', className: 'amr-settings-info', 'aria-label': `${tier} 判档标准`, onFocus: () => { } }, h(ui.IconInfoOutlineRegular, { size: 14 })) })), cell('主会话推理等级', h(RoutePicker, { ui, catalog: view, provider, cap, mode: 'effort', label: `${tier} 主会话推理等级`, value: { model: '', reasoningEffort: effort === undefined ? undefined : typeof effort === 'string' ? effort : '无效配置' }, invalid: familyUnresolved(provider), disabled: saving, onChange: value => setMain(tier, value.reasoningEffort) }), mainExplicit(provider, tier), () => setMain(tier, null), issueText(`mainEfforts.${provider}.${tier}`)), cell('子会话推荐', h(RoutePicker, { ui, catalog: view, provider, cap, label: `${tier} 子会话推荐`, value: childValue(provider, tier), disabled: saving || !selectableModels(view, provider, 'child').length, onChange: value => setChild(tier, value) }), childExplicit(provider, tier), () => setChild(tier, null), issueText(`childRoutes.${provider}.${tier}`)));
147
197
  })), h('p', null, '官方允许列表是会话创建时的快照;已有会话仍可能拒绝新配置的推荐。'))), ...conflicts.filter(issue => !issue.path.startsWith(`childRoutes.${provider}.`) && !issue.path.startsWith(`mainEfforts.${provider}.`)).map(issue => h('p', { key: issue.path, role: 'status', className: 'amr-settings-error' }, `${issue.path}:${issue.message}`))),
@@ -1,3 +1,3 @@
1
1
  import type * as Primitives from '@deepseek-ai/dsh-client-ui-primitives';
2
2
  /** Shell-owned primitives are injected so DOM tests exercise draft behavior independently. */
3
- export type SettingsUi = Pick<typeof Primitives, 'SettingsForm' | 'SettingsSecretField' | 'SegmentedTabs' | 'SegmentedControl' | 'Switch' | 'Tag' | 'Tooltip' | 'IconInfoOutlineRegular' | 'IconChevronDownOutlineRegular' | 'IconChevronLeftOutlineRegular' | 'IconChevronRightOutlineRegular' | 'IconCheckOutlineRegular' | 'MenuSurface' | 'MenuGroup' | 'useAnchoredPosition' | 'focusWithoutRing'>;
3
+ export type SettingsUi = Pick<typeof Primitives, 'Menu' | 'SettingsForm' | 'SettingsSecretField' | 'SegmentedTabs' | 'SegmentedControl' | 'Switch' | 'Tag' | 'Tooltip' | 'IconInfoOutlineRegular' | 'IconChevronDownOutlineRegular' | 'IconChevronLeftOutlineRegular' | 'IconChevronRightOutlineRegular' | 'IconCheckOutlineRegular' | 'MenuSurface' | 'MenuGroup' | 'useAnchoredPosition' | 'focusWithoutRing'>;
@@ -0,0 +1,4 @@
1
+ /** Read-only: criteria and slots are imported from the executable policy, never copied. */
2
+ export declare function StrategyDetails(): import("react").DetailedReactHTMLElement<{
3
+ className: string;
4
+ }, HTMLElement>;
@@ -0,0 +1,8 @@
1
+ import { createElement as h } from 'react';
2
+ import questions from '../policy-v2-rubric.json' with { type: 'json' };
3
+ import { POLICY_MODELS, mappingVersion } from '../policy-v2.js';
4
+ import { targetEffort } from '../effort.js';
5
+ /** Read-only: criteria and slots are imported from the executable policy, never copied. */
6
+ export function StrategyDetails() {
7
+ return h('details', { className: 'amr-strategy' }, h('summary', { style: { cursor: 'pointer', padding: '8px 0' } }, '查看决策策略'), h('p', null, 'Agent 负责拆解、派发与验收;Jev 只做有限判断,AUTO 验证目录并应用资源配置。任务数量、风险和重要性不直接等于推理难度。以下为只读说明,不会修改设置。'), h('h4', null, '能力需求(仅子任务)'), h('p', null, 'bounded:方法明确、结果可验证的执行;analytical:需要调查、关联证据或选择方案;frontier:异常困难的原创推理或相互耦合的未知;insufficient_context:缺少目标或范围,停止并补充描述。'), h('h4', null, '推理投入(主会话与子任务分别判断)'), h('p', null, '主模型不变。以具体模型推荐投入为起点,lower / baseline / higher 分别在受支持序列中降低一档、保持、提高一档;受当前设置上限约束,自动不选 max。轻量模型不意味着自动降低投入。'), h('h4', null, '当前子模型能力映射'), ...Object.entries(POLICY_MODELS).map(([family, slots]) => h('div', { key: family }, h('strong', null, family === 'claude' ? 'Claude' : 'GPT'), h('ul', null, ...Object.entries(slots).map(([level, model]) => h('li', { key: level }, `${level} → ${model}(推荐起点 ${targetEffort(model, 'standard')})`))))), h('p', null, `映射版本:${mappingVersion}。Magpie 保留对应家族前缀与真实 provider,不跨入口回退;模型与 effort 必须同时通过当前目录及官方允许列表校验。其他家族需显式指定模型及可验证投入。`), h('h4', null, '约束、失败与恢复'), h('p', null, '显式指定的模型与投入优先;两者都指定时只校验、不请求 Jev。目标不足、目录失效或子判断失败时停止派发,不偷偷继承或换模。主判断失败则可见停用 AUTO。fresh 子任务创建时绑定精确身份;后续消息和恢复不重新选模。fork 暂不支持。MANUAL 停止新判断,已有绑定子任务仍保持初始路线。'), h('h4', null, 'Jev 实际判据原文'), h('p', null, '下面直接展示执行中使用的英文规则,避免翻译改变含义;两个问题相互独立,不能读取对方答案。'), ...Object.entries(questions).map(([dimension, question]) => h('section', { key: dimension, style: { overflowWrap: 'anywhere' } }, h('strong', null, dimension), h('p', null, question.instructions), h('dl', null, ...Object.entries(question.criteria).flatMap(([label, criterion]) => [h('dt', { key: `${label}-label` }, h('strong', null, label)), h('dd', { key: label, style: { margin: '4px 0 12px 12px' } }, criterion)])))));
8
+ }