dsh-auto-model-routing 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,24 @@
12
12
  - appropriate/too-high/too-low分别统计;unknown不是满意,null撤销不计有效评价;未评价不能计为合适。多条消息评价冲突也应单独报告。
13
13
  - 按策略版本/映射版本/服务模型及depth/实际路由分组;旧缺字段独立unknown组,不混进新策略精确对照。
14
14
  - 以requestSeq和消息ID关联实际usage/延迟/返工证据;拿不到价格或token就未知。用户主观反馈不等于正确性oracle,适合率也不能单独证明质量或节省。
15
- - 本期只提供采集与可追溯性,不做报表、自动阈值调整、自动训练或换模重试。
15
+ - 按需观察报表见包内 skill `auto-model-routing-report`(`lib/usage-report.js`,只读);仍不做自动阈值调整、自动训练或换模重试。
16
+
17
+ ## 隐式事实与分布(2026-10-26)
18
+
19
+ 运行时只追加 `{type:'fact', version:1, sessionId, decisionId, fact, at}`。`fact` 仅为 `exposed` 或 `manual-switch`;按决策/类型保留首次事实,时间由 Host 生成,不收正文。曝光要求回复操作栏在前台、聚焦页面连续完整可见 3 秒;切 MANUAL 在清除运行态之前取当前主决策,只在切换成功后记录。写入失败只记录日志,不阻断主线。
20
+
21
+ 回合结束与继续不重复写账本,按需报告从已有日志推导。决策所在回合取最后一次关联调用的实际回合号,不是归属的用户回合。第一个后续用户回合的开始时间为继续时间;曝光时间必须早于继续时间,否则视为事后翻看,不算有效曝光。没有继续时曝光仍有效,但不能得到隐式无异议。
22
+
23
+ 每个比较子组的 `implicit.counts` 五类互斥,合计等于决策数:
24
+ 1. `explicit`:有显式评价;`implicit.explicit` 分类别,冲突单列。
25
+ 2. `negative`:无显式评价,且用户中止了决策最后一次关联调用所在回合,或切换 MANUAL;`implicit.negative` 分原因,两原因可重叠。
26
+ 3. `implicit-ok`:有效曝光、正常完成且用户继续,无负向事实。只表示这些行为同时出现,不证明资源分配合适。
27
+ 4. `inconclusive`:有效曝光但其它条件不足,包括未继续、结束未知或父会话取消。
28
+ 5. `unobserved`:没有有效曝光。
29
+
30
+ `implicit.exposedExplicit` 是有效曝光且有显式评价的参照计数。曝光不证明读过;用户中止或切 MANUAL 也可能因为换任务,不证明策略有错。旧数据不回填曝光,未知不能当正例。隐式信号、显式评价和将来的机器评审均应分开,不自动调策略。
31
+
32
+ D012 修复后,实际配置不变、不产生新 `request/header` 的回合也会绑定决策。`snapshot.requestSeq` 指向当时生效的请求头(可能来自更早回合),`requestTurn` 是应用时的当前回合。上线后记录覆盖率会提高;这是补缺口,不是策略改善,不能直接比较修复前后的适合率。
16
33
 
17
34
  ## 确定度
18
35
  UI称“判定确定度”,Choice公式为`(pmax - 1/n)/(1 - 1/n)`;n为该维候选数。三选一最高概率70%得到55%,不等于选中概率或任务成功率。插件直接展示服务返回值,分布独立保留,不从四舍五入后的百分比反推。官方定义:https://docs.typesafe.ai/confidence.md 。
@@ -46,15 +46,15 @@
46
46
 
47
47
  以下为核验时已安装的官方发布包位置,不是上游改动要求。临时 prefix 可按下方命令重建;其中源码行号仅适用于此 pin。
48
48
 
49
- - [pre-step / request 类型](../../../node_modules/@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts#L293-L332):公开事件输入和 waterfall 返回契约。
50
- - [Loop 请求链](../../../node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js#L1164-L1220):实际调用 waterfall 后才 `llm.prepareCall`,记录 resolved request header。
51
- - [scopeTarget](../../../node_modules/@deepseek-ai/dsh-scope/lib/index.js#L315-L337):无 Agent 作用域标记的全局 listener 可观察不同子身份。
52
- - [SubagentStartRequest](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/types.d.ts#L136-L162):公开 `agentOptions`,不必通过工具 schema 推断宿主能力。
53
- - [continuation 创建](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L101-L176)、[冷恢复](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L354-L374):descriptor 与恢复选项。
54
- - [官方 Team 创建转发](/tmp/dsh-official-routing-probe.HImElm/node_modules/@deepseek-ai/dsh-experimental-agent-team/lib/index.js#L573-L582):只转发 prompt/parent,未传 agentOptions;并不妨碍插件在 child request 入口配置。
55
- - [默认选择副作用](../../../node_modules/@deepseek-ai/dsh-api-session-controller/lib/index.js#L720-L739):`selectModel` 还会 `saveSelection`。
56
-
57
- `resolveChildAgentOptions` 是 [官方公开导出](../../../node_modules/@deepseek-ai/dsh-subagent/lib/types/index.d.ts#L55),前期函数级实验使用内部文件路径不意味着函数本身是私有 API。本次整链探针没有导入任何内部 helper。
49
+ - pre-step / request 类型(`node_modules/@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts#L293-L332`):公开事件输入和 waterfall 返回契约。
50
+ - Loop 请求链(`node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js#L1164-L1220`):实际调用 waterfall 后才 `llm.prepareCall`,记录 resolved request header。
51
+ - scopeTarget(`node_modules/@deepseek-ai/dsh-scope/lib/index.js#L315-L337`):无 Agent 作用域标记的全局 listener 可观察不同子身份。
52
+ - SubagentStartRequest(`node_modules/@deepseek-ai/dsh-subagent/lib/types/types.d.ts#L136-L162`):公开 `agentOptions`,不必通过工具 schema 推断宿主能力。
53
+ - continuation 创建(`node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L101-L176`)、冷恢复(`node_modules/@deepseek-ai/dsh-subagent/lib/types/continuation.js#L354-L374`):descriptor 与恢复选项。
54
+ - 官方 Team 创建转发(`/tmp/dsh-official-routing-probe.HImElm/node_modules/@deepseek-ai/dsh-experimental-agent-team/lib/index.js#L573-L582`):只转发 prompt/parent,未传 agentOptions;并不妨碍插件在 child request 入口配置。
55
+ - 默认选择副作用(`node_modules/@deepseek-ai/dsh-api-session-controller/lib/index.js#L720-L739`):`selectModel` 还会 `saveSelection`。
56
+
57
+ `resolveChildAgentOptions` 是 官方公开导出(`node_modules/@deepseek-ai/dsh-subagent/lib/types/index.d.ts#L55`),前期函数级实验使用内部文件路径不意味着函数本身是私有 API。本次整链探针没有导入任何内部 helper。
58
58
 
59
59
  ## 重跑
60
60
 
@@ -12,10 +12,10 @@
12
12
 
13
13
  ## 当前证据
14
14
 
15
- - 当前规范:../../../openspec/specs/auto-model-routing/spec.md,固定主模型、共享四档 rubric、childRoutes 与派发规则。
15
+ - 当前规范:[主仓规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md)(本节是历史调研快照,不代表当前默认策略),固定主模型、共享四档 rubric、childRoutes 与派发规则。
16
16
  - 默认主模型起点与偏移:../src/effort.ts;Claude 子表 Haiku/Sonnet/Opus/Opus;GPT 子表 Luna/Sol/Sol/Sol。
17
17
  - 子工具:../src/subagent-tool.ts,单独判断子任务;指定模型时只调 effort;校验目录与允许列表。
18
- - 包内指导:../skills/subagent-model-routing/SKILL.md,主要约束选模、调用与复用;是否派发的场景主要位于 ../../../instructions/dsh-home.md。历史提交 14d68627(2026-10-22)已明确加入较大编码、独立复核、大量探索、可并行工作的派发规则,以及小修复/讨论不派发;这确认用户记忆中的委派要求确实曾落地,但不是所有简单任务强制派发。
18
+ - 包内指导:../skills/subagent-model-routing/SKILL.md,主要约束选模、调用与复用;是否派发的场景主要位于 [主仓环境指令](https://github.com/prgrmrwy/ohmydsh/blob/main/instructions/dsh-home.md)(可选背景,不是插件加载依赖)。历史提交 14d68627(2026-10-22)已明确加入较大编码、独立复核、大量探索、可并行工作的派发规则,以及小修复/讨论不派发;这确认用户记忆中的委派要求确实曾落地,但不是所有简单任务强制派发。
19
19
  - 来源与评估范围:../eval/README.md,现有回归是判档一致性,不是最终任务质量/速度/费用基准。
20
20
 
21
21
  ## Gap 与建议(待调研校准,不是实施授权)
@@ -95,7 +95,7 @@ Jev 的价值不仅是体验和性能;误判会损害质量。但策略质量
95
95
 
96
96
  - [核心诉求与差距](routing-intent-and-gaps.md)
97
97
  - [外部调研](routing-community-research-2026-10-24.md):历史调研建议,实施优先级以本修订为准。
98
- - [现行规范](../../../openspec/specs/auto-model-routing/spec.md):仍描述当前行为,本稿不静默替换它。
98
+ - [现行规范](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/auto-model-routing/spec.md):仍描述当前行为,本稿不静默替换它。
99
99
  - [Claude Code model configuration](https://code.claude.com/docs/en/model-config.md):opusplan 是规划阶段 Opus、执行阶段 Sonnet,不等于固定强主监督轻子。
100
100
  - [Claude Code subagents](https://code.claude.com/docs/en/sub-agents.md):主模型根据职责描述委派。
101
101
  - [Claude Code Agent Teams](https://code.claude.com/docs/en/agent-teams.md):Lead 组织共享任务与协作,不天然保证异构模型或低成本。
@@ -1,6 +1,11 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import { type Decision, type Rpc, type Toast } from './control.js';
3
3
  import { type JevCategory } from '../hints.js';
4
+ interface AutoSupport {
5
+ supported: boolean;
6
+ provider?: string;
7
+ model?: string;
8
+ }
4
9
  interface View {
5
10
  mode: 'AUTO' | 'MANUAL';
6
11
  policy?: string;
@@ -13,14 +18,17 @@ interface View {
13
18
  };
14
19
  decision?: Decision;
15
20
  decisionToast?: boolean;
21
+ autoSupport?: AutoSupport;
16
22
  }
23
+ type SubscribeSelection = (sessionId: string, onChange: () => void) => () => void;
17
24
  interface Props {
18
25
  sessionId: string;
19
26
  rpc: Rpc;
27
+ subscribeSelection?: SubscribeSelection;
20
28
  }
21
29
  export declare const name = "dsh-auto-model-routing/client";
22
30
  export declare const inject: string[];
23
- export declare function RoutingChip({ sessionId, rpc, initialView, initiallyOpen, initialToast }: Props & {
31
+ export declare function RoutingChip({ sessionId, rpc, initialView, initiallyOpen, initialToast, subscribeSelection }: Props & {
24
32
  initialView?: View;
25
33
  initiallyOpen?: boolean;
26
34
  initialToast?: Toast;
@@ -2,13 +2,14 @@ import { createElement as h, useEffect, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { callRouting, chipOrder, nextToast, panelOpenAfter, TOAST_MS, toastText } from './control.js';
4
4
  import { CATEGORY_HINTS } from '../hints.js';
5
- import { IconGaugeOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives';
5
+ import { IconInfoOutlineRegular, IconGaugeOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives';
6
6
  import { LabelButton } from './label.js';
7
7
  import * as settingsUi from '@deepseek-ai/dsh-client-ui-primitives';
8
8
  import { RoutingSettings } from './settings-section.js';
9
9
  import { installRoutingNavIcon } from './nav-icon.js';
10
10
  export const name = 'dsh-auto-model-routing/client';
11
11
  export const inject = ['slots', 'connection'];
12
+ const REPO_URL = 'https://github.com/prgrmrwy/dsh-auto-model-routing';
12
13
  // Same look as the official model picker trigger (dsh-client-ui-model-selection), values read from its bundle.
13
14
  const CSS = `
14
15
  .amr-root{position:relative;min-width:0;display:inline-flex}
@@ -17,6 +18,11 @@ const CSS = `
17
18
  .amr-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}
18
19
  .amr-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}
19
20
  .amr-icon{flex:none;color:var(--dsw-alias-label-caption)}
21
+ .amr-trigger[data-locked=true]{color:var(--dsw-alias-label-dimmed);cursor:default}
22
+ .amr-trigger[data-locked=true]:hover{background:0 0}
23
+ .amr-info{color:var(--dsw-alias-label-caption)}
24
+ .amr-card a{color:var(--dsw-alias-state-business-primary);text-decoration:none;overflow-wrap:anywhere}
25
+ .amr-card a:hover{text-decoration:underline}
20
26
  .amr-trigger[data-disabled=true],.amr-trigger[data-disabled=true] .amr-icon{color:var(--dsw-alias-state-error-primary)}
21
27
  .amr-panel{position:absolute;z-index:1100;bottom:100%;left:0;padding-bottom:6px}
22
28
  .amr-card{width:280px;padding:10px 12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg,#fff);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-size:12px;line-height:18px}
@@ -28,9 +34,9 @@ const CSS = `
28
34
  `;
29
35
  // Left-right swap arrows at the picker chevron's size; colour follows currentColor.
30
36
  function SwapIcon() {
31
- return h('svg', { className: 'amr-icon', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: 'M3 5.5h9.5M10 3l2.5 2.5L10 8' }), h('path', { d: 'M13 10.5H3.5M6 8l-2.5 2.5L6 13' }));
37
+ return h('svg', { className: 'amr-icon amr-swap', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: 'M3 5.5h9.5M10 3l2.5 2.5L10 8' }), h('path', { d: 'M13 10.5H3.5M6 8l-2.5 2.5L6 13' }));
32
38
  }
33
- export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast }) {
39
+ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
34
40
  const [view, setView] = useState(initialView);
35
41
  const [open, setOpen] = useState(initiallyOpen);
36
42
  const [busy, setBusy] = useState(false);
@@ -77,6 +83,28 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
77
83
  const hide = setTimeout(() => setToast(undefined), TOAST_MS);
78
84
  return () => clearTimeout(hide);
79
85
  }, [toast]);
86
+ const refreshRef = useRef(() => { });
87
+ // Optional acceleration from the official picker store; retried on every state read so a late service still binds.
88
+ const subscription = useRef(undefined);
89
+ const subscribeRef = useRef(undefined);
90
+ subscribeRef.current = subscribeSelection;
91
+ const ensureSubscription = () => {
92
+ const subscribe = subscribeRef.current;
93
+ if (!subscribe || subscription.current?.sessionId === sessionId)
94
+ return;
95
+ subscription.current?.stop();
96
+ subscription.current = undefined;
97
+ let handle;
98
+ try {
99
+ const stop = subscribe(sessionId, () => { if (handle)
100
+ clearTimeout(handle); handle = setTimeout(() => refreshRef.current(), 80); });
101
+ subscription.current = { sessionId, stop: () => { stop(); if (handle)
102
+ clearTimeout(handle); } };
103
+ }
104
+ catch {
105
+ subscription.current = undefined;
106
+ }
107
+ };
80
108
  useEffect(() => {
81
109
  let active = true;
82
110
  primed.current = false;
@@ -87,11 +115,20 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
87
115
  viewSession.current = sessionId;
88
116
  setView(value);
89
117
  setError('');
118
+ ensureSubscription();
90
119
  } }, reason => { if (active)
91
120
  setError(String(reason)); }); };
121
+ refreshRef.current = refresh;
92
122
  refresh();
93
123
  const poll = setInterval(refresh, 3000);
94
- return () => { active = false; clearInterval(poll); clearTimeout(timer.current); };
124
+ return () => {
125
+ active = false;
126
+ clearInterval(poll);
127
+ clearTimeout(timer.current);
128
+ subscription.current?.stop();
129
+ subscription.current = undefined;
130
+ refreshRef.current = () => { };
131
+ };
95
132
  }, [rpc, sessionId]);
96
133
  const on = (event) => () => {
97
134
  clearTimeout(timer.current);
@@ -119,19 +156,64 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
119
156
  };
120
157
  const off = view?.disabled;
121
158
  const hint = off ? CATEGORY_HINTS[off.category] ?? off.category : undefined;
159
+ const locked = view?.autoSupport?.supported === false;
160
+ const support = view?.autoSupport;
161
+ const lockedModel = `${support?.provider ?? '未知 provider'} / ${support?.model ?? '未知模型'}`;
122
162
  // The panel sits inside the root (padding bridges the gap), so moving onto it keeps it open.
123
163
  const place = anchor.current ?? { left: (typeof window === 'undefined' ? 0 : window.innerWidth / 2), top: (typeof window === 'undefined' ? 0 : window.innerHeight) - 96 };
164
+ const panel = locked
165
+ ? [
166
+ h('p', { key: 'locked-title' }, h('strong', null, 'AUTO 不支持当前模型')),
167
+ h('p', { key: 'locked-model' }, `${lockedModel} 不在自动路由支持范围,已切换为 MANUAL。`),
168
+ h('p', { key: 'locked-note' }, '目前仅支持 Claude / GPT 系列模型;其他模型策略欢迎提交 issue 或 PR:'),
169
+ h('p', { key: 'locked-link' }, h('a', { href: REPO_URL, target: '_blank', rel: 'noopener noreferrer' }, REPO_URL)),
170
+ h('p', { key: 'locked-hint', className: 'amr-muted' }, '切回 Claude / GPT 系列模型后,可重新点击启用 AUTO。'),
171
+ error && h('p', { key: 'error', role: 'alert', className: 'amr-error' }, error),
172
+ ]
173
+ : [
174
+ h('p', { key: 'title' }, h('strong', null, view?.mode === 'AUTO' ? '自动调整推理强度' : '手动:不自动调整')),
175
+ h('p', { key: 'output' }, `本回合插件输出:${view?.effort ?? '未改写'} · ${view?.reason ?? '尚无决定'}`),
176
+ view?.decision && h('div', { key: 'latest', 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))),
177
+ h('p', { key: 'policy', className: 'amr-muted' }, view?.policy === 'policy-v2' ? `AUTO · ${view.depth ?? '尚无判断'};主模型不变,推理投入按请求应用;子任务自动选配。` : 'AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。'),
178
+ off && h('p', { key: 'disabled', role: 'status', className: 'amr-error' }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`),
179
+ h('p', { key: 'data', className: 'amr-muted' }, 'AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。'),
180
+ error && h('p', { key: 'error', role: 'alert', className: 'amr-error' }, error),
181
+ ];
124
182
  return h('span', { ref: (el) => { rootRef.current = el; remember(); }, className: 'amr-root', onMouseEnter: on('enter'), onMouseLeave: on('leave'), onFocus: on('focus'), onBlur: on('blur') }, h('style', null, CSS), h('button', {
125
- type: 'button', className: 'amr-trigger', disabled: busy || !view, onClick: () => { void toggle(); },
126
- 'data-disabled': off ? 'true' : undefined, style: { border: 'none' },
127
- title: off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : '点击切换自动推理强度',
128
- 'aria-label': `推理强度模式:${view?.mode ?? '加载中'},点击切换`,
129
- }, h('span', null, off ? `${view?.mode} · Jev 停用` : view?.mode ?? '…'), h(SwapIcon)), toast && !open && (typeof document === 'undefined'
183
+ type: 'button', className: 'amr-trigger', disabled: busy || !view, onClick: () => { if (locked)
184
+ return; void toggle(); },
185
+ 'data-disabled': off ? 'true' : undefined, 'data-locked': locked ? 'true' : undefined,
186
+ 'aria-disabled': locked ? 'true' : undefined, style: { border: 'none' },
187
+ title: locked ? 'AUTO 仅支持 Claude / GPT 系列模型;悬停查看说明' : off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : '点击切换自动推理强度',
188
+ 'aria-label': locked ? `推理强度模式:${view?.mode ?? 'MANUAL'}(AUTO 不支持当前模型,不可切换)` : `推理强度模式:${view?.mode ?? '加载中'},点击切换`,
189
+ }, h('span', null, locked ? (view?.mode ?? 'MANUAL') : off ? `${view?.mode} · Jev 停用` : view?.mode ?? '…'), locked ? h(IconInfoOutlineRegular, { size: 14, className: 'amr-icon amr-info' }) : h(SwapIcon)), toast && !open && (typeof document === 'undefined'
130
190
  ? 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' }, 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))));
191
+ : 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' }, ...panel)));
132
192
  }
133
193
  const labelUi = { Tooltip, Menu, Icon: IconGaugeOutlineRegular };
134
194
  export function apply(ctx) {
195
+ // Optional acceleration only: the official picker's per-session store triggers an immediate state read.
196
+ // When the service is absent (or appears late) the chip's 3s poll remains the source of truth.
197
+ const pickerSubscriber = { current: undefined };
198
+ const subscribeSelection = (id, onChange) => pickerSubscriber.current ? pickerSubscriber.current(id, onChange) : () => { };
199
+ try {
200
+ const inject = ctx.inject;
201
+ if (typeof inject === 'function') {
202
+ inject.call(ctx, ['modelDirectories'], (scope) => {
203
+ const service = scope.modelDirectories;
204
+ if (!service)
205
+ return;
206
+ pickerSubscriber.current = (id, onChange) => { try {
207
+ return service.directoryFor(id).store.subscribe(onChange);
208
+ }
209
+ catch {
210
+ return () => { };
211
+ } };
212
+ return () => { pickerSubscriber.current = undefined; };
213
+ });
214
+ }
215
+ }
216
+ catch { /* polling stays the fallback */ }
135
217
  ctx.slots.inject('settings.section', () => ctx.slots.register({
136
218
  name: 'settings.section', id: 'auto-model-routing', order: 300,
137
219
  label: () => '自动模型路由',
@@ -145,6 +227,6 @@ export function apply(ctx) {
145
227
  }, LabelButton));
146
228
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
147
229
  name: 'conversation.input.right', id: 'auto-model-routing', order: chipOrder,
148
- inject: (sessionId) => ({ sessionId, rpc: ctx.connection.rpc }),
230
+ inject: (sessionId) => ({ sessionId, rpc: ctx.connection.rpc, subscribeSelection }),
149
231
  }, RoutingChip));
150
232
  }
@@ -45,5 +45,5 @@ export declare function LabelButton({ sessionId, messageId, rpc, ui }: {
45
45
  messageId: string;
46
46
  rpc: Rpc;
47
47
  ui: LabelUi;
48
- }): import("react").DetailedReactHTMLElement<import("react").HTMLAttributes<HTMLElement>, HTMLElement> | null;
48
+ }): import("react").DetailedReactHTMLElement<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> | null;
49
49
  export {};
@@ -19,6 +19,7 @@ export function LabelButton({ sessionId, messageId, rpc, ui }) {
19
19
  const [view, setView] = useState(undefined);
20
20
  const [open, setOpen] = useState(false);
21
21
  const [error, setError] = useState('');
22
+ const exposureTarget = useRef(null);
22
23
  const alive = useRef(true);
23
24
  useEffect(() => () => { alive.current = false; }, []);
24
25
  useEffect(() => {
@@ -29,6 +30,40 @@ export function LabelButton({ sessionId, messageId, rpc, ui }) {
29
30
  setView(value); }, () => { });
30
31
  return () => { active = false; };
31
32
  }, [rpc, sessionId, messageId]);
33
+ useEffect(() => {
34
+ const target = exposureTarget.current;
35
+ if (view?.policy !== 'policy-v2' || !target || typeof IntersectionObserver === 'undefined')
36
+ return;
37
+ let visible = false, sent = false;
38
+ let timer;
39
+ const update = () => {
40
+ clearTimeout(timer);
41
+ timer = undefined;
42
+ if (!sent && visible && document.visibilityState === 'visible' && document.hasFocus()) {
43
+ timer = setTimeout(() => {
44
+ if (!visible || document.visibilityState !== 'visible' || !document.hasFocus())
45
+ return;
46
+ sent = true;
47
+ void rpc.call('/api', 'auto-model-routing.observe', { sessionId, messageId }).catch(() => { });
48
+ }, 3000);
49
+ }
50
+ };
51
+ const observer = new IntersectionObserver(entries => {
52
+ visible = entries.some(entry => entry.isIntersecting && entry.intersectionRatio >= 1);
53
+ update();
54
+ }, { threshold: 1 });
55
+ observer.observe(target);
56
+ document.addEventListener('visibilitychange', update);
57
+ window.addEventListener('focus', update);
58
+ window.addEventListener('blur', update);
59
+ return () => {
60
+ clearTimeout(timer);
61
+ observer.disconnect();
62
+ document.removeEventListener('visibilitychange', update);
63
+ window.removeEventListener('focus', update);
64
+ window.removeEventListener('blur', update);
65
+ };
66
+ }, [view?.policy, rpc, sessionId, messageId]);
32
67
  if (!view)
33
68
  return null;
34
69
  const choose = (label) => {
@@ -46,7 +81,7 @@ export function LabelButton({ sessionId, messageId, rpc, ui }) {
46
81
  setError('反馈未保存,请重试'); });
47
82
  };
48
83
  const button = h('button', { type: 'button', className: 'amr-label-action', 'aria-label': tooltipText(view), onClick: () => setOpen(value => !value) }, h(ui.Icon));
49
- return h('span', null, h('style', null, CSS), h(ui.Menu, {
84
+ return h('span', { ref: exposureTarget }, h('style', null, CSS), h(ui.Menu, {
50
85
  open, side: 'top', portal: true, onClose: () => setOpen(false),
51
86
  // The official menu draws the check on the selected (labelled) row.
52
87
  ...((allocation ? view.feedback : view.label) ? { selectedId: (allocation ? view.feedback : view.label) } : {}),