dsh-auto-model-routing 0.4.1 → 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.
- package/CHANGELOG.md +9 -0
- package/README.md +8 -3
- package/README.zh.md +8 -2
- package/docs/feedback-evaluation.md +17 -0
- package/lib/client/index.d.ts +9 -1
- package/lib/client/index.js +93 -11
- package/lib/client/label.d.ts +1 -1
- package/lib/client/label.js +36 -1
- package/lib/client.js +160 -17
- package/lib/endpoints.d.ts +1 -0
- package/lib/endpoints.js +19 -0
- package/lib/feedback.d.ts +18 -0
- package/lib/feedback.js +21 -0
- package/lib/index.js +44 -10
- package/lib/policy-host.d.ts +10 -3
- package/lib/policy-host.js +65 -15
- package/lib/policy-store.d.ts +3 -0
- package/lib/policy-store.js +3 -2
- package/lib/policy-v2.d.ts +3 -2
- package/lib/policy-v2.js +7 -6
- package/lib/settings-host.js +3 -2
- package/lib/subagent-tool.js +3 -3
- package/lib/support.d.ts +25 -0
- package/lib/support.js +40 -0
- package/lib/usage-report.d.ts +19 -0
- package/lib/usage-report.js +62 -6
- package/package.json +17 -4
- package/skills/auto-model-routing-report/SKILL.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- AUTO now handles Claude and GPT model families only: selecting any other main model switches the session to MANUAL instead of failing the turn in the effort mapper.
|
|
6
|
+
- Support the renamed `dsh-magpie-connector` Magpie route as an exact policy transport (replacing `dsh-magpie`); `dsh-magpie-connect` remains supported.
|
|
7
|
+
- Fix the missing feedback entry on turns whose request configuration did not change: the main decision is marked applied from the effective request header instead of waiting for a new one.
|
|
8
|
+
- Record two implicit feedback facts as separate ledger events keyed by session and decision: reply exposure and a later switch to MANUAL. No message text is stored; routing is unchanged.
|
|
9
|
+
- The usage report adds a mutually exclusive implicit-signal breakdown per comparison group, with a fixed caveat that implicit no-objection is not the same as appropriate.
|
|
10
|
+
- Sync the AUTO-selected effort through the official selection and simplify the picker display.
|
|
11
|
+
|
|
3
12
|
## 0.4.1
|
|
4
13
|
|
|
5
14
|
- Add the on-demand `auto-model-routing-report` skill and read-only `lib/usage-report.js`: joins policy-v2 decisions to actual per-call usage by message ID, grouped by version × model × depth, with coverage gaps, sample thresholds and fixed caveats. Observational only; never writes, spawns or uses the network, and never prints message text, paths or session IDs.
|
package/README.md
CHANGED
|
@@ -55,7 +55,8 @@ Restart DSH and refresh the Web page. In **Settings → Auto model routing**, ch
|
|
|
55
55
|
|
|
56
56
|
### Main model stays fixed; children are selected separately
|
|
57
57
|
|
|
58
|
-
- **Main chat:** `lower / baseline / higher` moves at most one supported effort step below/at/above the model's verified recommended starting point. The cap defaults to `xhigh`; AUTO never selects `max`. It
|
|
58
|
+
- **Main chat:** `lower / baseline / higher` moves at most one supported effort step below/at/above the model's verified recommended starting point. The cap defaults to `xhigh`; AUTO never selects `max`. It keeps the main provider/model and synchronizes the selected effort through official `selectModel`, which also saves global defaults.
|
|
59
|
+
- **Claude/GPT families only:** AUTO manages models whose id (after any transport prefix) starts with `claude-` or `gpt-`. Selecting any other main model — DeepSeek, GLM, Grok, Gemini, Kimi, Qwen, … — switches the session to MANUAL and locks the chip: it shows an outlined info icon, and hovering the whole button explains the support scope with an issue/PR link to the repository. Switching back to a Claude/GPT model restores a clickable MANUAL chip; AUTO is never re-enabled automatically.
|
|
59
60
|
- **Fresh children:** capability is `bounded / analytical / frontier`; effort is judged separately. Claude slots are Haiku / Sonnet / Opus 5.5; GPT slots are Luna / Sol / Sol. The current catalog, supported efforts and official child allowlist must all permit the route. Magpie uses its exact provider and full family-prefixed model IDs, not a fallback to local transports.
|
|
60
61
|
- **Explicit choices win:** specifying a model or effort leaves only the other dimension to judge. Specifying both skips Jev and validates the route. Missing task scope stops delegation rather than guessing.
|
|
61
62
|
- **Official creation binds the route:** fresh `subagent` and fresh `spawn_teammate` paths bind an exact child identity before its first request. `route_subagent` is an optional preview—not proof of application and not a user constraint to copy into a subsequent call. Fork and unbound creation paths are unsupported; there is no silent substitution.
|
|
@@ -74,6 +75,12 @@ Preset model menus expose documented versions, not live availability guarantees.
|
|
|
74
75
|
|
|
75
76
|
**Certainty is not the selected probability.** Choice certainty is `(pmax − 1/n) / (1 − 1/n)`: with three candidates, a 70% top probability gives 55% certainty. The panel shows the service's returned certainty and full candidate distribution separately, including returned zeroes; missing values are marked unavailable. Neither number is task success rate.
|
|
76
77
|
|
|
78
|
+
### Magpie transports and picker display
|
|
79
|
+
|
|
80
|
+
The default policy accepts both exact transports, `dsh-magpie-connector` and `dsh-magpie-connect`. They are not aliases: full model ids, live model capabilities, supported effort/caps, the current allowlist and the parent's captured allowlist all retain their exact transport identity. Unknown families need an explicit model. With a root AUTO session and explicit `policy: legacy`, both Magpie previews reject before Jev; MANUAL still returns `routed: false` without judging or state writes. Already bound children replay their saved route.
|
|
81
|
+
|
|
82
|
+
AUTO applies the selected effort to the current main request and awaits official `selectModel` on the same provider/model when the effort changes. This also updates global defaults; the official picker shows one effort value. MANUAL requests pass through unchanged and bound children never write the main selection. A selection-sync failure is logged while the current request retains its selected effort. Magpie menu grouping still requires ohmydsh's managed five-package Host extension; stock rc.2 keeps a flat list. See [Magpie setup and display requirements](<../dsh-magpie-connector/README.md#model-menu-groups-and-effort>); local tests do not establish deployment or live GUI acceptance.
|
|
83
|
+
|
|
77
84
|
## Good to know
|
|
78
85
|
|
|
79
86
|
- **Failures are visible.** Main judgment failure disables AUTO with a reason; child judgment/route failure stops dispatch. No local classifier, automatic retry, upgrade or cross-service fallback.
|
|
@@ -83,7 +90,6 @@ Preset model menus expose documented versions, not live availability guarantees.
|
|
|
83
90
|
- **Local state:** configuration is under `$DSH_HOME/plugins/dsh-auto-model-routing/`; mode, child routes and feedback are under `$DSH_HOME/state/auto-model-routing/`. Feedback stores resource metadata, IDs, versions, returned distributions and timestamps—not task/reply text. Request event IDs allow later usage correlation; costs are not fabricated when unavailable. See [feedback and evaluation](docs/feedback-evaluation.md).
|
|
84
91
|
- **No automatic learning.** Human allocation feedback is observational. It does not change routing or prove quality/cost benefits. Old messages without decision bindings are not backfilled; old records with missing evaluation fields remain unknown.
|
|
85
92
|
- **Upgrade:** existing AUTO sessions use the new policy without a version switch or rewriting their stored mode. New chats still default to MANUAL unless configured otherwise. Historical four-tier mappings remain only for explicit `policy: "legacy"` rollback in plugin configuration; they are not the current policy or feedback menu.
|
|
86
|
-
|
|
87
93
|
## Architecture
|
|
88
94
|
<!-- section: architecture -->
|
|
89
95
|
|
|
@@ -101,7 +107,6 @@ These isolated-host recordings use mock Jev/model responses and show the previou
|
|
|
101
107
|

|
|
102
108
|
|
|
103
109
|
</details>
|
|
104
|
-
|
|
105
110
|
## Development
|
|
106
111
|
|
|
107
112
|
```sh
|
package/README.zh.md
CHANGED
|
@@ -55,7 +55,8 @@ dsh plugin --profile web add prgrmrwy/dsh-auto-model-routing
|
|
|
55
55
|
|
|
56
56
|
### 主模型不换,子任务独立选配
|
|
57
57
|
|
|
58
|
-
- **主会话**:`lower / baseline / higher` 相对于具体模型已验证的推荐起点,在受支持序列降低一档、保持或提高一档。缺省上限 `xhigh`,自动不选 `max
|
|
58
|
+
- **主会话**:`lower / baseline / higher` 相对于具体模型已验证的推荐起点,在受支持序列降低一档、保持或提高一档。缺省上限 `xhigh`,自动不选 `max`;主 provider/model 不变;通过官方 `selectModel` 同步选定 effort,同时保存全局默认值。
|
|
59
|
+
- **仅支持 Claude / GPT 系列**:AUTO 只管理模型名(去掉 transport 前缀后)以 `claude-` 或 `gpt-` 开头的主模型。切换到其他主模型(DeepSeek、GLM、Grok、Gemini、Kimi、Qwen 等)会自动落为 MANUAL 并锁定芯片:芯片显示描边 info 图标,悬停整个按钮说明支持范围并提供仓库的 issue/PR 链接。切回 Claude / GPT 系列后芯片恢复可点击且保持 MANUAL,不会自动重新启用 AUTO。
|
|
59
60
|
- **fresh 子任务**:能力需求为 `bounded / analytical / frontier`,推理投入独立判断。Claude 能力槽为 Haiku / Sonnet / Opus 5.5;GPT 为 Luna / Sol / Sol。实际路由必须同时满足当前目录、支持的 effort 和官方子智能体允许列表。Magpie 保留真实 provider 与家族前缀完整模型 ID,不回退到本地入口。
|
|
60
61
|
- **尊重显式选择**:只指定模型或投入时,仅判断另一维;两者都指定则不请求 Jev,只校验路由。缺少任务目标或范围时停止委派,不猜。
|
|
61
62
|
- **官方创建绑定路由**:fresh `subagent` 和 fresh `spawn_teammate` 在子任务首次请求前绑定精确身份。`route_subagent` 只是可选预览,不代表已应用,也不能把推荐复制成用户约束。fork 和未绑定创建路径暂不支持,不静默替换。
|
|
@@ -74,6 +75,12 @@ dsh plugin --profile web add prgrmrwy/dsh-auto-model-routing
|
|
|
74
75
|
|
|
75
76
|
**判定确定度不等于选中概率**:Choice 公式为 `(pmax − 1/n) / (1 − 1/n)`,三个候选中最高概率 70% 对应确定度 55%。面板分别显示服务返回的确定度和完整分布,保留返回的 0%,缺值标未提供。两者都不是任务成功率。
|
|
76
77
|
|
|
78
|
+
### Magpie transport 与选择器展示
|
|
79
|
+
|
|
80
|
+
默认策略接受 `dsh-magpie-connector`、`dsh-magpie-connect` 两个精确 transport,不互为 alias:完整 model id、实时模型能力、支持 effort/cap、当前允许列表与父 captured allowlist 都按原 transport 校验。未知家族需显式 model。根会话 AUTO 且显式 `policy: legacy` 时,两种 Magpie 预览均在 Jev 前拒绝;MANUAL 仍直接返回 `routed: false`,不判档、不写状态。已绑定子实例恢复原路线。
|
|
81
|
+
|
|
82
|
+
AUTO 将选定 effort 应用于本次主请求;effort 改变时,等待官方 `selectModel` 同步同一 provider/model,并允许保存全局默认值。官方选择器只显示一个 effort。MANUAL 请求原样通过,已绑定子会话不写主选择;同步失败会记录告警,本次请求仍保留选定 effort。Magpie 菜单分组仍需 ohmydsh 受管五包 Host 扩展,stock rc.2 保持 flat 列表。见 [Magpie 配置与展示前提](<../dsh-magpie-connector/README.zh.md#模型菜单分组与-effort>);本地测试不等于已部署或 live GUI 验收。
|
|
83
|
+
|
|
77
84
|
## 须知
|
|
78
85
|
|
|
79
86
|
- **失败可见**:主判断失败会停用 AUTO 并显示原因,子判断或路由失败则停止派发。没有本地分类器、自动重试、自动升级或跨服务降级。
|
|
@@ -101,7 +108,6 @@ dsh plugin --profile web add prgrmrwy/dsh-auto-model-routing
|
|
|
101
108
|

|
|
102
109
|
|
|
103
110
|
</details>
|
|
104
|
-
|
|
105
111
|
## 开发验证
|
|
106
112
|
|
|
107
113
|
```sh
|
|
@@ -14,5 +14,22 @@
|
|
|
14
14
|
- 以requestSeq和消息ID关联实际usage/延迟/返工证据;拿不到价格或token就未知。用户主观反馈不等于正确性oracle,适合率也不能单独证明质量或节省。
|
|
15
15
|
- 按需观察报表见包内 skill `auto-model-routing-report`(`lib/usage-report.js`,只读);仍不做自动阈值调整、自动训练或换模重试。
|
|
16
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` 是应用时的当前回合。上线后记录覆盖率会提高;这是补缺口,不是策略改善,不能直接比较修复前后的适合率。
|
|
33
|
+
|
|
17
34
|
## 确定度
|
|
18
35
|
UI称“判定确定度”,Choice公式为`(pmax - 1/n)/(1 - 1/n)`;n为该维候选数。三选一最高概率70%得到55%,不等于选中概率或任务成功率。插件直接展示服务返回值,分布独立保留,不从四舍五入后的百分比反推。官方定义:https://docs.typesafe.ai/confidence.md 。
|
package/lib/client/index.d.ts
CHANGED
|
@@ -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;
|
package/lib/client/index.js
CHANGED
|
@@ -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 () => {
|
|
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: () => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
'aria-
|
|
129
|
-
|
|
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' },
|
|
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
|
}
|
package/lib/client/label.d.ts
CHANGED
|
@@ -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<
|
|
48
|
+
}): import("react").DetailedReactHTMLElement<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> | null;
|
|
49
49
|
export {};
|
package/lib/client/label.js
CHANGED
|
@@ -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',
|
|
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) } : {}),
|