/** * 模型选择器 —— 官方 `@deepseek-ai/dsh-client-ui-model-selection` 的**忠实移植**, * 唯一的功能增量是**每个模型行显示当前峰谷倍率徽章**。 * * ## 上游基线 * * **`0.1.7-rc.2`**(DSH tag `dsh-v0.1.7-rc.2`)。此前的移植基线是 `0.1.5-rc.1`, * 0.1.7 更换了 UI primitive 命名(`Icon*OutlineRegular`)、把 busy 判据从 * `status === 'selecting'` 改为 `pending !== null`、并引入 `retainedEffort`; * 不重基线会在 render 期拿到 `undefined` 图标组件(React「Element type is invalid」), * 也就是「用户换不了模型」那一类事故。升级 DSH 时须对照上游重移植。 * * ## 为什么是 fork * * 用户要求在模型选择器**内部**看到各模型倍率。官方注册 * `conversation.input.model` 时**不声明 `children`**、组件内 **0 处 `renderSlot`** * —— 该组件**内部没有任何扩展点**,因此只有「完整重写」一条路。官方包为 * **MIT**,法律上允许;本项目自行实现(不复制上游源码),仅在结构与交互语义上 * 逐项对齐,便于日后对照重移植。 * * ## 移植纪律(功能超集) * * 官方有的交互**一个都不能少**:两级菜单(模型 / 推理等级)、键盘导航 * (↑/↓ 循环、Escape/Shift+Tab 逐级返回、Tab 等同 Enter 落定)、焦点交还 * (drill 落到当前值行、返回落到打开的 cell)、`MenuSurface` portal 定位与 * 滚动/缩放重测量、外部点击与失焦关闭、加载/错误/警告/重试、`pending` 行内 * spinner 与触发器 spinner、`aria-*`、`RemoteResult` 语义(含 `session/writer-held` * 专用文案)、`retainedEffort`、官方 provider 排序、`deepseek-account` 展示名。 * * 本文件**仅有的偏离**(都在下方就地标注): * ① 每个模型行多一个倍率徽章(本插件的存在理由); * ② 行 `title` 在命中倍率时附加详情(原生 tooltip,纯增量信息); * ③ `select()` 的 rejection 兜底 —— 官方只 `.then()`,一旦 Promise 拒绝, * `pending` 永远不落定、整个菜单持续 disabled(用户再也点不动模型)。 * 这里把 rejection 也收敛成一次失败 toast。 * * 历史教训:本插件曾用**残缺的**替换实现接管该槽位,导致用户无法切换模型。 * 因此本次移植以「功能超集」为硬要求。守卫测试见 `test/bundle-contract.test.ts`。 */ import * as React from 'react'; import { type Translate } from './rate.js'; import type { MatchConfig, RateProfile } from '../matching.js'; /** 一条完整的模型选择(provider + provider 自己的 model id + 可选推理等级)。 */ export interface ModelSelection { provider: string; model: string; reasoningEffort?: string; } /** 目录里一个模型(只取本组件需要的字段)。 */ export interface DirectoryModel { id: string; name: string; reasoning?: { defaultEffort?: string; efforts: readonly { id: string; name: string; }[]; }; } /** 目录里一个 provider 分组。 */ export interface DirectoryGroup { id: string; name: string; models: readonly DirectoryModel[]; } /** 上一次目录加载里**局部失败**的 provider(仍然可用的分组保持可用)。 */ export interface DirectoryFailure { id: string; name: string; message: string; } /** * 模型目录快照 —— 字段对齐官方 `ModelDirectoryState`(0.1.7-rc.2)里本组件 * 真正读取的部分。 * * 注:官方 state 里的 `routable` 只被 `/model` 弹窗消费,本组件(composer 座) * 不读它,故不在此声明 —— 声明用不到的字段只会掩盖真实的依赖面。 */ export interface DirectoryState { /** 已保存的选择;即使其 provider / model 已离开目录也保留。 */ current: ModelSelection | null; /** 选中模型暂时不在目录里时保留的 effort 文案。 */ retainedEffort?: string; groups: readonly DirectoryGroup[]; failures: readonly DirectoryFailure[]; status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'; /** 最近一次 `select` 提交、尚未落定的选择;否则 null。 */ pending: ModelSelection | null; error: string | null; } /** * `select()` 的结果 —— 与官方 `RemoteResult` **结构对齐**的本地子集。 * * 刻意保留 `error.code` / `error.message` 而不是压成 boolean:官方用 * `code === 'session/writer-held'` 判定「会话被别的 DSH 占用」并给专用文案, * 压平之后就只剩一句无差别的失败提示。 */ export type SelectionOutcome = { readonly ok: true; readonly value?: unknown; } | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; }; }; /** 注入面(结构对齐官方 slot 契约,另加本插件的倍率数据面与文案)。 */ export interface ModelSelectInjected { /** 本会话是否支持 Agent 绑定的模型查看与选择(子代理会话为 false)。 */ available: boolean; /** 本会话的共享目录 store(与自带 `/model` 弹窗同一实例)。 */ directory: { getSnapshot: () => DirectoryState; subscribe: (fn: () => void) => () => void; }; /** 确保共享目录已加载(错误落在 store 上)。 */ load: () => void; /** 提交一次完整选择;本会话不可选择时返回 undefined。 */ select: (selection: ModelSelection) => Promise; /** 本插件的倍率数据面。 */ peakrate: { profiles: () => RateProfile[]; config: () => MatchConfig; }; /** 文案(本插件自己的 locale 命名空间)。 */ t: Translate; } /** 组件属性:owner share(locked)+ 注入面。 */ export type ModelSelectProps = ModelSelectInjected & { locked: boolean; }; /** * 渲染 composer 的模型座:触发器 + 两级菜单。 * * @param props - owner share + 注入面。 */ export declare function ModelSelect({ locked, available, directory, load, select, peakrate, t, }: ModelSelectProps): React.ReactElement | null;