fix(ui-settings-models): keep model picker selection current and non-empty
Derive the picker's checked state from the list at render so a row drafted during the fetch is not removed. Disable Update list when it would empty a non-empty list, because an empty list serves the whole catalog. List a repeated candidate id once and treat rows without an id as unconfigured.
This commit is contained in:
1 parent
72a607dfee
commit
f150f2178d
7 files changed
+124
-41
No files matched your search
@@ -1,7 +1,7 @@
|
||||
- dialog "选择模型":
|
||||
- heading "选择模型" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 以下是模型提供商的可用模型。已勾选的模型会保留在列表中,取消勾选已配置的模型即可移除。
|
||||
- paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。
|
||||
- searchbox "搜索模型"
|
||||
- button "取消全选"
|
||||
- list:
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 3af73644655c8b7b
|
||||
zh: d689eba772100c63
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
|
||||
en: 5eec53c85e2ef492
|
||||
zh: 162904aa543d30ac
|
||||
en: c0b04b2b11af60d3
|
||||
zh: d195eec281e445cb
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs:
|
||||
en: 589397a35f82244e
|
||||
zh: 342fbceee2c173a2
|
||||
|
||||
@@ -63,7 +63,7 @@ A model row on a hand-declared pi-ai route also edits its **Reasoning levels**,
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
|
||||
### First-run dialogs
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
|
||||
|
||||
### 新增与删除提供商
|
||||
|
||||
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
|
||||
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
|
||||
|
||||
### 首次运行弹窗
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* candidates the user picks from, never configuration written behind them. The
|
||||
* picker opens with the configured models checked, so confirming it adds the
|
||||
* newly checked candidates and removes the unchecked configured ones in the
|
||||
* draft; configured rows the reply does not list are kept.
|
||||
* draft; configured rows the reply does not list are kept, and a confirmation
|
||||
* that would empty the list is refused.
|
||||
*
|
||||
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
|
||||
* with no readable listing) is not a dead end: the failure is shown next to the
|
||||
@@ -177,7 +178,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
const catalog = inheritedCatalog?.provider === catalogProvider ? inheritedCatalog?.models : undefined
|
||||
const inputDefaults = useMemo(() => new Map(catalog?.map(model => [model.id, model.inputModalities])), [catalog])
|
||||
const [candidates, setCandidates] = useState<readonly LlmDiscoveredModel[] | undefined>(undefined)
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
|
||||
// Candidates whose checkbox the user flipped from its default. The default
|
||||
// is read from the list at render, so a row drafted while the fetch was in
|
||||
// flight opens checked rather than being removed by a stale selection.
|
||||
const [flipped, setFlipped] = useState<ReadonlySet<string>>(new Set())
|
||||
const [candidateQuery, setCandidateQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
|
||||
// Capacities are edited as text, so a field's keystrokes are held here rather
|
||||
@@ -205,6 +209,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
* after them down, so per-row state follows its row.
|
||||
*/
|
||||
const forgetRows = (removed: ReadonlySet<number>): void => {
|
||||
if (removed.size === 0) return
|
||||
/** The surviving row's new position, or `undefined` for a removed row. */
|
||||
const moved = (at: number): number | undefined =>
|
||||
removed.has(at) ? undefined : at - [...removed].filter(gone => gone < at).length
|
||||
@@ -272,13 +277,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
setFailure(t('fetchEmpty'))
|
||||
return
|
||||
}
|
||||
// The picker opens on the current list so a few models can be added or
|
||||
// removed in one place. An empty list serves the whole catalog, so every
|
||||
// candidate starts checked.
|
||||
const known = new Set(models.map(model => textOf(model, 'id')))
|
||||
setCandidateQuery('')
|
||||
setCandidates(found)
|
||||
setPicked(new Set(found.filter(model => models.length === 0 || known.has(model.id)).map(model => model.id)))
|
||||
setFlipped(new Set())
|
||||
// A listing may repeat an id; the picker and the list hold each model once.
|
||||
setCandidates([...new Map(found.map(model => [model.id, model])).values()])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -286,55 +288,66 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
|
||||
const closePicker = (): void => {
|
||||
setCandidates(undefined)
|
||||
setPicked(new Set())
|
||||
setFlipped(new Set())
|
||||
setCandidateQuery('')
|
||||
}
|
||||
|
||||
// The picker opens on the current list so a few models can be added or
|
||||
// removed in one place. A list with no identified model serves the whole
|
||||
// catalog or nothing yet, so every candidate starts checked.
|
||||
const configuredIds = new Set(models.map(model => textOf(model, 'id')).filter(id => id.length > 0))
|
||||
const pickedByDefault = (id: string): boolean => configuredIds.size === 0 || configuredIds.has(id)
|
||||
const isPicked = (id: string): boolean => pickedByDefault(id) !== flipped.has(id)
|
||||
|
||||
const activeCandidates = candidates ?? []
|
||||
const listed = new Set(activeCandidates.map(candidate => candidate.id))
|
||||
// An unchecked candidate leaves the list. A row the listing does not name,
|
||||
// including a half-typed row without an id, is outside the picker and stays.
|
||||
const removedRows = new Set<number>()
|
||||
models.forEach((model, at) => {
|
||||
const id = textOf(model, 'id')
|
||||
if (listed.has(id) && !isPicked(id)) removedRows.add(at)
|
||||
})
|
||||
// A kept row the user already tuned wins over the provider's own numbers.
|
||||
const keptRows = models.filter((_model, at) => !removedRows.has(at))
|
||||
const keptIds = new Set(keptRows.map(model => textOf(model, 'id')))
|
||||
const addedRows = activeCandidates
|
||||
.filter(candidate => isPicked(candidate.id) && !keptIds.has(candidate.id))
|
||||
.map(adopt)
|
||||
// An empty list serves the whole catalog, so emptying a non-empty list
|
||||
// would restore every model instead of removing the unchecked ones.
|
||||
const emptiesList = models.length > 0 && keptRows.length === 0 && addedRows.length === 0
|
||||
|
||||
const adoptPicked = (): void => {
|
||||
/* v8 ignore next -- the dialog only renders with candidates loaded */
|
||||
if (candidates === undefined) return
|
||||
const listed = new Set(candidates.map(candidate => candidate.id))
|
||||
// An unchecked candidate leaves the list. A row the listing does not name,
|
||||
// including a half-typed row without an id, is outside the picker and stays.
|
||||
const removed = new Set<number>()
|
||||
models.forEach((model, at) => {
|
||||
const id = textOf(model, 'id')
|
||||
if (listed.has(id) && !picked.has(id)) removed.add(at)
|
||||
})
|
||||
const kept = models.filter((_model, at) => !removed.has(at))
|
||||
const keptIds = new Set(kept.map(model => textOf(model, 'id')))
|
||||
// A kept row the user already tuned wins over the provider's own numbers.
|
||||
const added = candidates.filter(candidate => picked.has(candidate.id) && !keptIds.has(candidate.id))
|
||||
onChange([...kept, ...added.map(adopt)])
|
||||
forgetRows(removed)
|
||||
onChange([...keptRows, ...addedRows])
|
||||
forgetRows(removedRows)
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const toggle = (id: string): void => {
|
||||
setPicked((current) => {
|
||||
setFlipped((current) => {
|
||||
const next = new Set(current)
|
||||
if (!next.delete(id)) next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeCandidates = candidates ?? []
|
||||
const normalizedCandidateQuery = candidateQuery.trim().toLowerCase()
|
||||
const visibleCandidates = normalizedCandidateQuery.length === 0
|
||||
? activeCandidates
|
||||
: activeCandidates.filter(candidate => candidate.id.toLowerCase().includes(normalizedCandidateQuery)
|
||||
|| candidate.name?.toLowerCase().includes(normalizedCandidateQuery) === true)
|
||||
const allVisibleCandidatesPicked = visibleCandidates.length > 0
|
||||
&& visibleCandidates.every(candidate => picked.has(candidate.id))
|
||||
&& visibleCandidates.every(candidate => isPicked(candidate.id))
|
||||
|
||||
const toggleVisibleCandidates = (): void => {
|
||||
// Only visible candidates change: a hidden checked one may be a configured
|
||||
// model, and clearing it would remove that model unseen.
|
||||
setPicked((current) => {
|
||||
const target = !allVisibleCandidatesPicked
|
||||
setFlipped((current) => {
|
||||
const next = new Set(current)
|
||||
const clear = visibleCandidates.every(candidate => current.has(candidate.id))
|
||||
for (const candidate of visibleCandidates) {
|
||||
if (clear) next.delete(candidate.id)
|
||||
if (pickedByDefault(candidate.id) === target) next.delete(candidate.id)
|
||||
else next.add(candidate.id)
|
||||
}
|
||||
return next
|
||||
@@ -435,7 +448,14 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={closePicker}>{t('cancel')}</Button>
|
||||
<Button variant="outline" onClick={adoptPicked}>{t('fetchAdopt')}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={emptiesList}
|
||||
title={emptiesList ? t('fetchKeepOne') : undefined}
|
||||
onClick={adoptPicked}
|
||||
>
|
||||
{t('fetchAdopt')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -466,7 +486,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
<label className={styles['candidateLabel']}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.has(candidate.id)}
|
||||
checked={isPicked(candidate.id)}
|
||||
onChange={() => { toggle(candidate.id) }}
|
||||
/>
|
||||
<span className={styles['candidateId']} title={candidate.name ?? candidate.id}>
|
||||
|
||||
@@ -101,12 +101,13 @@ export const en = {
|
||||
fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
|
||||
fetchEmpty: 'The provider listed no models. Add them by hand.',
|
||||
fetchTitle: 'Choose models',
|
||||
fetchDescription: 'These are the models this provider has available. Checked models will be in the list; uncheck a configured model to remove it.',
|
||||
fetchDescription: 'These are the models this provider has available. Checked models make up the list.',
|
||||
fetchSearch: 'Search models',
|
||||
fetchNoMatches: 'No matching models.',
|
||||
fetchSelectAll: 'Select all',
|
||||
fetchDeselectAll: 'Deselect all',
|
||||
fetchAdopt: 'Update list',
|
||||
fetchKeepOne: 'Keep at least one model checked.',
|
||||
customTag: 'Custom',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
|
||||
@@ -242,12 +243,13 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
|
||||
fetchEmpty: '该提供商没有列出任何模型,请手动添加。',
|
||||
fetchTitle: '选择模型',
|
||||
fetchDescription: '以下是模型提供商的可用模型。已勾选的模型会保留在列表中,取消勾选已配置的模型即可移除。',
|
||||
fetchDescription: '以下是模型提供商的可用模型,勾选的模型即为模型列表。',
|
||||
fetchSearch: '搜索模型',
|
||||
fetchNoMatches: '没有匹配的模型。',
|
||||
fetchSelectAll: '全选',
|
||||
fetchDeselectAll: '取消全选',
|
||||
fetchAdopt: '更新列表',
|
||||
fetchKeepOne: '请至少保留一个勾选的模型。',
|
||||
customTag: '自定义',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
|
||||
import { within, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, within, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
@@ -680,6 +680,67 @@ describe('endpoint interrogation', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('checks a row drafted while the fetch is in flight', async () => {
|
||||
const reply = Promise.withResolvers<ReturnType<typeof ok>>()
|
||||
const discover = vi.fn(() => reply.promise)
|
||||
const { mutate } = await mountSection({
|
||||
discover,
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'a' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'b' } })
|
||||
await act(async () => { reply.resolve(ok([{ id: 'a' }, { id: 'b' }, { id: 'c' }])) })
|
||||
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
||||
expect(boxes.map(box => box.checked)).toEqual([true, true, false])
|
||||
fireEvent.click(screen.getByText(en.fetchAdopt))
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b' }])
|
||||
})
|
||||
|
||||
it('refuses to empty the list, since an empty list serves the whole catalog', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok([{ id: 'a' }, { id: 'b' }, { id: 'c' }])))
|
||||
await mountSection({
|
||||
discover,
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'a' }, { id: 'b' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within_(dialog, en.fetchSelectAll))
|
||||
fireEvent.click(within_(dialog, en.fetchDeselectAll))
|
||||
const adopt = within_(dialog, en.fetchAdopt) as HTMLButtonElement
|
||||
expect(adopt.disabled).toBe(true)
|
||||
expect(adopt.title).toBe(en.fetchKeepOne)
|
||||
|
||||
// Removing several configured models at once still leaves one.
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'c' }))
|
||||
expect(adopt.disabled).toBe(false)
|
||||
fireEvent.click(adopt)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('c')
|
||||
expect(screen.queryByLabelText(`${en.modelId} 2`)).toBeNull()
|
||||
})
|
||||
|
||||
it('lists a repeated candidate id once and starts it checked beside a blank row', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok([{ id: 'x' }, { id: 'x', maxTokens: 64 }])))
|
||||
await mountSection({ discover, providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
await screen.findByText(en.fetchTitle)
|
||||
expect(screen.getAllByRole<HTMLInputElement>('checkbox', { name: 'x' }).map(box => box.checked)).toEqual([true])
|
||||
fireEvent.click(screen.getByText(en.fetchAdopt))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('x')
|
||||
expect(screen.queryByLabelText(`${en.modelId} 3`)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the rows editable when the provider cannot be interrogated', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(
|
||||
fail('https://proxy.example/v1/models answered 401; check the API key', 'llm/model-discovery-rejected'),
|
||||
|
||||
Reference in new issue
Block a user