feat(api-access): replace model selector with a dual-pane layout

- Separate selected and unselected models with independent search and bulk actions
- Fix selection initialization and ignore stale discovery responses
- Improve keyboard navigation and responsive layouts
- Update translations and add model selection UI regression tests
This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent 2b41eab8df
commit 19a9b4fc09
9 files changed
+605 -84

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { useId, useMemo, useRef, useState } from 'react';
import { ArrowRight, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { ModelOption } from '../services/modelService';
type ModelSelectionPanelProps = {
models: ModelOption[];
selected: boolean;
loading: boolean;
onMove: (models: ModelOption[], selected: boolean) => void;
};
export function ModelSelectionPanel({ models, selected, loading, onMove }: ModelSelectionPanelProps) {
const { t } = useI18n();
const titleId = useId();
const searchRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visibleModels = useMemo(() => models.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
), [models, query]);
const searchLabel = t(selected ? 'apiAccess.modelDialog.searchSelected' : 'apiAccess.modelDialog.searchUnselected');
return (
<section className="model-transfer-panel" aria-labelledby={titleId} aria-busy={loading}>
<div className="model-transfer-panel-heading">
<h3 id={titleId}>{t(selected ? 'apiAccess.modelDialog.selected' : 'apiAccess.modelDialog.unselected')}</h3>
<span className="model-transfer-count">{query ? `${visibleModels.length} / ${models.length}` : models.length}</span>
<button
type="button"
className="secondary-button compact-button"
onClick={() => onMove(visibleModels, !selected)}
disabled={loading || visibleModels.length === 0}
>
{t(selected
? query ? 'apiAccess.modelDialog.removeResults' : 'apiAccess.modelDialog.removeAll'
: query ? 'apiAccess.modelDialog.addResults' : 'apiAccess.modelDialog.addAll')}
</button>
</div>
<div className="model-transfer-search">
<Search size={15} aria-hidden="true" />
<input ref={searchRef} value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={searchLabel} aria-label={searchLabel} />
{search ? (
<button type="button" className="icon-button quiet" onClick={() => { setSearch(''); searchRef.current?.focus(); }} aria-label={t('apiAccess.modelDialog.clearSearch')}>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
<div className="model-transfer-list">
{visibleModels.length ? visibleModels.map((model) => (
<button
type="button"
className="model-transfer-row"
key={model.name}
disabled={loading}
aria-label={t(selected ? 'apiAccess.modelDialog.removeModel' : 'apiAccess.modelDialog.addModel', { name: model.name })}
onClick={(event) => {
if (event.detail === 0) {
const next = event.currentTarget.nextElementSibling ?? event.currentTarget.previousElementSibling;
if (next instanceof HTMLElement) next.focus();
else searchRef.current?.focus();
}
onMove([model], !selected);
}}
>
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
{selected ? <X size={16} aria-hidden="true" /> : <ArrowRight size={16} aria-hidden="true" />}
</button>
)) : (
<div className="model-transfer-empty">
{t(query ? 'apiAccess.modelDialog.noMatch' : loading ? 'apiAccess.modelDialog.fetching' : selected ? 'apiAccess.modelDialog.emptySelected' : 'apiAccess.modelDialog.emptyUnselected')}
</div>
)}
</div>
</section>
);
}
+14
View File
@@ -1240,6 +1240,20 @@ export const jaOverrides = {
'apiAccess.modelDialog.summary': '{found} 件検出 · {selected} 件選択済み',
'apiAccess.modelDialog.deselectAll': 'すべて選択解除',
'apiAccess.modelDialog.selectVisible': '表示中をすべて選択',
'apiAccess.modelDialog.unselected': '未選択のモデル',
'apiAccess.modelDialog.selected': '選択済みのモデル',
'apiAccess.modelDialog.searchUnselected': '未選択のモデルを検索',
'apiAccess.modelDialog.searchSelected': '選択済みのモデルを検索',
'apiAccess.modelDialog.clearSearch': '検索をクリア',
'apiAccess.modelDialog.addAll': 'すべて追加',
'apiAccess.modelDialog.removeAll': 'すべて解除',
'apiAccess.modelDialog.addResults': '検索結果を追加',
'apiAccess.modelDialog.removeResults': '検索結果を解除',
'apiAccess.modelDialog.addModel': '{name} を追加',
'apiAccess.modelDialog.removeModel': '{name} を解除',
'apiAccess.modelDialog.emptySelected': 'まだモデルが選択されていません',
'apiAccess.modelDialog.emptyUnselected': '未選択のモデルはありません',
'apiAccess.modelDialog.chooseOne': 'モデルを1つ以上選択してください',
'common.clear': '消去',
'apiAccess.modelDialog.fetching': 'モデルを取得しています',
'apiAccess.modelDialog.fetchFailed': 'モデルの取得に失敗しました',
+14
View File
@@ -1240,6 +1240,20 @@ export const en: Record<MessageKey, string> = {
'apiAccess.modelDialog.summary': '{found} found · {selected} selected',
'apiAccess.modelDialog.deselectAll': 'Deselect All',
'apiAccess.modelDialog.selectVisible': 'Select Visible',
'apiAccess.modelDialog.unselected': 'Unselected Models',
'apiAccess.modelDialog.selected': 'Selected Models',
'apiAccess.modelDialog.searchUnselected': 'Search unselected models',
'apiAccess.modelDialog.searchSelected': 'Search selected models',
'apiAccess.modelDialog.clearSearch': 'Clear search',
'apiAccess.modelDialog.addAll': 'Add All',
'apiAccess.modelDialog.removeAll': 'Remove All',
'apiAccess.modelDialog.addResults': 'Add Results',
'apiAccess.modelDialog.removeResults': 'Remove Results',
'apiAccess.modelDialog.addModel': 'Add {name}',
'apiAccess.modelDialog.removeModel': 'Remove {name}',
'apiAccess.modelDialog.emptySelected': 'No models selected yet',
'apiAccess.modelDialog.emptyUnselected': 'No unselected models',
'apiAccess.modelDialog.chooseOne': 'Select at least one model',
'common.clear': 'Clear',
'apiAccess.modelDialog.fetching': 'Fetching models',
'apiAccess.modelDialog.fetchFailed': 'Failed to fetch models',
+14
View File
@@ -1239,6 +1239,20 @@ export const zhCN = {
'apiAccess.modelDialog.summary': '找到 {found} 个 · 已选择 {selected} 个',
'apiAccess.modelDialog.deselectAll': '取消全选',
'apiAccess.modelDialog.selectVisible': '全选当前',
'apiAccess.modelDialog.unselected': '未选模型',
'apiAccess.modelDialog.selected': '已选模型',
'apiAccess.modelDialog.searchUnselected': '搜索未选模型',
'apiAccess.modelDialog.searchSelected': '搜索已选模型',
'apiAccess.modelDialog.clearSearch': '清除搜索',
'apiAccess.modelDialog.addAll': '全部添加',
'apiAccess.modelDialog.removeAll': '全部移除',
'apiAccess.modelDialog.addResults': '添加搜索结果',
'apiAccess.modelDialog.removeResults': '移除搜索结果',
'apiAccess.modelDialog.addModel': '添加 {name}',
'apiAccess.modelDialog.removeModel': '移除 {name}',
'apiAccess.modelDialog.emptySelected': '暂无已选模型',
'apiAccess.modelDialog.emptyUnselected': '暂无未选模型',
'apiAccess.modelDialog.chooseOne': '请至少选择一个模型',
'common.clear': '清空',
'apiAccess.modelDialog.fetching': '正在获取模型',
'apiAccess.modelDialog.fetchFailed': '获取模型失败',
+86 -84
View File
@@ -1,5 +1,6 @@
import { createPortal } from 'react-dom';
import { useConfirmation } from '../components/ConfirmationDialog';
import { ModelSelectionPanel } from '../components/ModelSelectionPanel';
import {
type CSSProperties,
FormEvent,
@@ -28,7 +29,6 @@ import {
} from '@dnd-kit/sortable';
import { CSS as DndCss } from '@dnd-kit/utilities';
import {
Check,
Edit3,
Filter,
GripVertical,
@@ -1595,7 +1595,7 @@ type ApiProviderDialogProps = {
onSave: (draft: ProviderDraft, modelDiscoveryReady: boolean) => Promise<ProviderSaveResult>;
};
function ApiProviderDialog({
export function ApiProviderDialog({
activeCategory,
editingRow,
initialDraft,
@@ -1616,13 +1616,52 @@ function ApiProviderDialog({
() => activeCategory !== 'deepseek'
|| Boolean(editingRow && initialDraft.models.some((model) => model.name.trim())),
);
const [modelSearch, setModelSearch] = useState('');
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
() => new Set(mergeModelOptions(initialDraft.models).map((model) => model.name.toLowerCase())),
);
const modelCardRef = useRef<HTMLDivElement>(null);
const modelDialogRef = useRef<HTMLElement>(null);
const discoverySelectionInitializedRef = useRef(false);
const discoveryRequestRef = useRef(0);
const closeModelDiscovery = useCallback(() => {
discoveryRequestRef.current += 1;
setModelLoading(false);
setModelDiscoveryOpen(false);
}, []);
useEffect(() => () => { discoveryRequestRef.current += 1; }, []);
useEffect(() => {
if (!modelDiscoveryOpen) return;
const previousFocus = document.activeElement;
const dialog = modelDialogRef.current;
dialog?.querySelector<HTMLInputElement>('input')?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeModelDiscovery();
} else if (event.key === 'Tab') {
const controls = Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)') ?? []);
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) {
event.preventDefault();
first?.focus();
}
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
};
}, [modelDiscoveryOpen, closeModelDiscovery]);
const modelOptions = useMemo(
() => mergeModelOptions(discoveredModels, draft.models),
@@ -1634,16 +1673,12 @@ function ApiProviderDialog({
[draft.models],
);
const visibleModelOptions = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
if (!query) return modelOptions;
return modelOptions.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
);
}, [modelOptions, modelSearch]);
const allVisibleModelsSelected = visibleModelOptions.length > 0
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const selectedModels = useMemo(() => modelOptions.filter((model) =>
selectedModelNames.has(model.name.toLowerCase()),
), [modelOptions, selectedModelNames]);
const unselectedModels = useMemo(() => modelOptions.filter((model) =>
!selectedModelNames.has(model.name.toLowerCase()),
), [modelOptions, selectedModelNames]);
const updateTextField = (
field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
@@ -1651,6 +1686,8 @@ function ApiProviderDialog({
) => {
setFormError('');
if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') {
discoveryRequestRef.current += 1;
setModelLoading(false);
setModelError('');
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
}
@@ -1716,6 +1753,7 @@ function ApiProviderDialog({
setModelError(t('apiAccess.error.enterBaseUrl'));
return;
}
const requestId = ++discoveryRequestRef.current;
setModelLoading(true);
setModelError('');
try {
@@ -1736,21 +1774,24 @@ function ApiProviderDialog({
editingRow?.authIndex,
parseProviderHeaders(draft.headersText ?? ''),
);
if (requestId !== discoveryRequestRef.current) return;
const models = applyProviderPreset(
activeCategory,
{ ...draft, models: fetchedModels },
).models;
const selectionMode = discoverySelectionInitializedRef.current ? 'refresh' : 'initial';
const initialized = discoverySelectionInitializedRef.current;
setDiscoveredModels(models);
setSelectedModelNames((current) =>
reconcileModelSelection(models, draft.models, current, selectionMode));
initialized
? reconcileModelSelection(models, draft.models, current, 'refresh')
: modelSelectionForDiscovery(activeSection, draft.models, models, draft.excludedModelsText ?? ''));
discoverySelectionInitializedRef.current = true;
if (activeCategory === 'deepseek') setModelDiscoveryReady(true);
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
} catch (requestError) {
setModelError(requestErrorMessage(requestError));
if (requestId === discoveryRequestRef.current) setModelError(requestErrorMessage(requestError));
} finally {
setModelLoading(false);
if (requestId === discoveryRequestRef.current) setModelLoading(false);
}
};
@@ -1761,42 +1802,30 @@ function ApiProviderDialog({
setModelError(t('apiAccess.error.baseBeforeModels'));
return;
}
setModelSearch('');
discoverySelectionInitializedRef.current = false;
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
setSelectedModelNames(modelSelectionForDiscovery(activeSection, draft.models, discoveredModels, draft.excludedModelsText ?? ''));
setModelDiscoveryOpen(true);
void discoverModels();
};
const toggleModelSelection = (model: ModelOption) => {
const key = model.name.toLowerCase();
const moveModels = (models: ModelOption[], selected: boolean) => {
discoverySelectionInitializedRef.current = true;
setSelectedModelNames((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAllVisibleModels = () => {
setSelectedModelNames((current) => {
const next = new Set(current);
visibleModelOptions.forEach((model) => {
models.forEach((model) => {
const key = model.name.toLowerCase();
if (allVisibleModelsSelected) next.delete(key);
else next.add(key);
if (selected) next.add(key);
else next.delete(key);
});
return next;
});
};
const applyModelSelection = () => {
const models = modelOptions.filter((model) =>
selectedModelNames.has(model.name.toLowerCase()),
);
if (modelLoading || selectedModels.length === 0) return;
setDraft((current) => ({
...current,
models,
models: selectedModels,
excludedModelsText: activeSection === 'openai-compatibility'
? current.excludedModelsText
: exclusionsForModelSelection(
@@ -1805,7 +1834,7 @@ function ApiProviderDialog({
selectedModelNames,
),
}));
setModelDiscoveryOpen(false);
closeModelDiscovery();
};
const submit = async (event: FormEvent) => {
@@ -2025,63 +2054,36 @@ function ApiProviderDialog({
</div>
{modelDiscoveryOpen ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && setModelDiscoveryOpen(false)}>
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closeModelDiscovery()}>
<section ref={modelDialogRef} className="model-discovery-dialog model-transfer-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
<div className="model-discovery-header">
<div><h2 id="model-discovery-title">{t('apiAccess.modelDialog.title')}</h2><span>{t(definition.labelKey)}</span></div>
<button type="button" className="icon-button quiet" onClick={() => setModelDiscoveryOpen(false)} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={closeModelDiscovery} title={t('common.close')}><X size={18} /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input value={modelSearch} onChange={(event) => setModelSearch(event.currentTarget.value)} placeholder={t('agents.model.search')} />
<div className="model-transfer-summary">
<span role="status">{t('apiAccess.modelDialog.summary', { found: modelOptions.length, selected: selectedModels.length })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void discoverModels()} disabled={modelLoading}>
<RefreshCw size={15} className={modelLoading ? 'spin' : ''} />{t('common.refresh')}
</button>
</div>
<div className="model-discovery-toolbar">
<span>{t('apiAccess.modelDialog.summary', { found: modelOptions.length, selected: selectedModelNames.size })}</span>
<div>
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleModels} disabled={modelLoading || visibleModelOptions.length === 0}>{allVisibleModelsSelected ? t('apiAccess.modelDialog.deselectAll') : t('apiAccess.modelDialog.selectVisible')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setSelectedModelNames(new Set())} disabled={modelLoading || selectedModelNames.size === 0}>{t('common.clear')}</button>
{modelError ? (
<div className="model-discovery-inline-error" role="alert">
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
<span title={modelError}>{modelError}</span>
</div>
) : null}
<div className="model-transfer-panels">
<ModelSelectionPanel models={unselectedModels} selected={false} loading={modelLoading} onMove={moveModels} />
<ModelSelectionPanel models={selectedModels} selected loading={modelLoading} onMove={moveModels} />
</div>
<div className="model-discovery-content">
{modelLoading ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('apiAccess.modelDialog.fetching')}</div>
) : modelError && modelOptions.length === 0 ? (
<div className="model-discovery-message error"><strong>{t('apiAccess.modelDialog.fetchFailed')}</strong><span>{modelError}</span></div>
) : visibleModelOptions.length === 0 ? (
<div className="model-discovery-message"><strong>{modelOptions.length ? t('apiAccess.modelDialog.noMatch') : t('apiAccess.modelDialog.none')}</strong><span>{modelOptions.length ? t('apiAccess.modelDialog.tryKeyword') : t('apiAccess.modelDialog.checkCredentials')}</span></div>
) : (
<div className="model-discovery-results">
{modelError ? (
<div className="model-discovery-inline-error" role="alert">
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
<span title={modelError}>{modelError}</span>
</div>
) : null}
<div className="model-discovery-list">
{visibleModelOptions.map((model) => {
const checked = selectedModelNames.has(model.name.toLowerCase());
return (
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.name}>
<input type="checkbox" checked={checked} onChange={() => toggleModelSelection(model)} />
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
{checked ? <Check size={16} aria-hidden="true" /> : null}
</label>
);
})}
</div>
</div>
)}
</div>
<div className="model-discovery-actions">
<button type="button" className="secondary-button" onClick={() => setModelDiscoveryOpen(false)}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading}>{t('apiAccess.modelDialog.apply', { count: selectedModelNames.size })}</button>
<div className="model-discovery-actions model-transfer-actions">
{selectedModels.length === 0 ? <span>{t('apiAccess.modelDialog.chooseOne')}</span> : null}
<button type="button" className="secondary-button" onClick={closeModelDiscovery}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading || selectedModels.length === 0}>{t('apiAccess.modelDialog.apply', { count: selectedModels.length })}</button>
</div>
</section>
</div>
+193
View File
@@ -3643,7 +3643,9 @@ textarea:focus-visible {
.model-discovery-toolbar > div {
display: flex;
flex-wrap: wrap;
gap: 7px;
justify-content: flex-end;
}
.model-discovery-content {
@@ -3769,6 +3771,197 @@ textarea:focus-visible {
min-width: 112px;
}
.model-discovery-dialog.model-transfer-dialog {
display: flex;
flex-direction: column;
width: min(960px, 100%);
height: min(640px, calc(100dvh - 48px));
min-height: 0;
}
.model-transfer-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
}
.model-transfer-summary > button {
width: auto;
flex: 0 0 auto;
}
.model-transfer-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
flex: 1;
min-height: 0;
}
.model-transfer-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--theme-e2ddd3);
border-radius: 8px;
background: var(--theme-ffffff);
}
.model-transfer-panel-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
}
.model-transfer-panel-heading h3 {
margin: 0;
color: var(--theme-2d2a26);
font-size: var(--font-size-label);
}
.model-transfer-panel-heading button {
width: auto;
flex: 0 0 auto;
margin-left: auto;
}
.model-transfer-count {
border-radius: 5px;
padding: 2px 6px;
background: var(--theme-f4f2ec);
color: var(--theme-6d6760);
font-size: var(--font-size-meta);
font-variant-numeric: tabular-nums;
}
.model-transfer-search {
display: flex;
align-items: center;
gap: 8px;
margin: 0 10px 10px;
padding: 0 8px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 6px;
color: var(--theme-8a847c);
}
.model-transfer-search:focus-within {
outline: 2px solid var(--theme-9ca9df);
outline-offset: 1px;
}
.model-transfer-search input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--theme-2d2a26);
}
.model-transfer-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border-top: 1px solid var(--theme-eeece6);
}
.model-transfer-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 9px 12px;
border: 0;
border-bottom: 1px solid var(--theme-eeece6);
background: transparent;
color: var(--theme-6d6760);
text-align: left;
cursor: pointer;
}
.model-transfer-row:hover:not(:disabled) {
background: var(--theme-f4f2ec);
}
.model-transfer-row:focus-visible {
outline: 2px solid var(--theme-9ca9df);
outline-offset: -2px;
}
.model-transfer-row > span {
display: grid;
flex: 1;
min-width: 0;
gap: 2px;
}
.model-transfer-row strong,
.model-transfer-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-transfer-row strong {
color: var(--theme-2d2a26);
font-size: var(--font-size-label);
}
.model-transfer-row small {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
}
.model-transfer-empty {
display: grid;
place-content: center;
height: 100%;
padding: 16px;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
text-align: center;
}
.model-transfer-actions {
flex-wrap: wrap;
}
.model-transfer-actions > span {
margin-right: auto;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
}
@media (max-width: 600px) {
.model-discovery-dialog.model-transfer-dialog {
height: calc(100dvh - 24px);
padding: 12px;
}
.model-transfer-panels {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.model-transfer-actions > span {
width: 100%;
}
}
.model-discovery-dialog.auth-model-dialog {
grid-template-rows: auto 38px auto minmax(0, 1fr) auto auto;
height: min(720px, calc(100vh - 32px));
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Model selection fixture</title></head>
<body><div id="root"></div><script type="module" src="./model-selection.tsx"></script></body>
</html>
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { ApiProviderDialog, type ProviderDraft } from '../../src/pages/ApiAccessPage';
import '../../src/styles.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
const fixture = window as typeof window & {
fixtureCatalog: { name: string }[];
fixtureFailFetch: boolean;
fixtureHoldNext: boolean;
fixturePending: (() => void)[];
fixtureSaved: ProviderDraft | null;
};
fixture.fixtureCatalog = [
...Array.from({ length: 240 }, (_, i) => ({ name: `gpt-${String(i + 1).padStart(3, '0')}` })),
...Array.from({ length: 120 }, (_, i) => ({ name: `claude-${String(i + 1).padStart(3, '0')}` })),
...Array.from({ length: 40 }, (_, i) => ({ name: `deepseek-${String(i + 1).padStart(3, '0')}` })),
{ name: 'GPT-001' },
];
fixture.fixtureFailFetch = false;
fixture.fixtureHoldNext = false;
fixture.fixturePending = [];
fixture.fixtureSaved = null;
mockIPC(async (cmd, rawArgs) => {
if (cmd === 'set_app_locale') return null;
const args = rawArgs as { request?: { path?: string } };
if (cmd === 'management_request' && args.request?.path === '/api-call') {
const response = fixture.fixtureFailFetch
? { status_code: 503, body: 'Fixture discovery failed' }
: { status_code: 200, body: { data: fixture.fixtureCatalog.slice() } };
if (fixture.fixtureHoldNext) {
fixture.fixtureHoldNext = false;
await new Promise<void>((resolve) => fixture.fixturePending.push(resolve));
}
return response;
}
throw new Error(`Unhandled fixture command: ${cmd}`);
});
const scenario = params.get('scenario') ?? 'saved';
const initialDraft: ProviderDraft = {
name: '', apiKey: 'fixture-key', remark: '', baseUrl: 'https://models.example.test', priority: '',
models: scenario === 'saved' ? [{ name: ' gpt-001 ', alias: 'Focus' }, { name: 'custom-local', alias: 'Local alias' }] : [],
excludedModelsText: scenario === 'excluded' ? 'gpt-*' : '',
};
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<ApiProviderDialog
activeCategory={scenario === 'new' ? 'deepseek' : 'codex-api-key'}
editingRow={null}
initialDraft={initialDraft}
busy={false}
onClose={() => {}}
onSave={async (draft) => { fixture.fixtureSaved = draft; return { saved: true }; }}
/>
</I18nProvider>,
);
+141
View File
@@ -0,0 +1,141 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const { mkdirSync } = require('node:fs');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 820 } });
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(30000);
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/') ? route.continue() : route.abort());
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
const dialog = page.getByRole('dialog', { name: 'Select Models' });
const left = dialog.getByRole('region', { name: 'Unselected Models', exact: true });
const right = dialog.getByRole('region', { name: 'Selected Models', exact: true });
const rows = panel => panel.locator('.model-transfer-row');
const search = panel => panel.getByRole('textbox');
const ready = () => page.waitForFunction(() => {
const refresh = document.querySelector('.model-transfer-summary button');
return refresh && !refresh.disabled;
});
const counts = async (unselected, selected) => {
assert.equal(Number(await left.locator('.model-transfer-count').innerText()), unselected);
assert.equal(Number(await right.locator('.model-transfer-count').innerText()), selected);
};
const open = async (scenario) => {
await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?scenario=${scenario}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Fetch Models', exact: true }).click();
await ready();
};
await open('saved');
await counts(399, 2);
await search(right).fill('FOCUS');
assert.equal(await rows(right).count(), 1, 'Search matches saved aliases, ignoring case');
await right.getByRole('button', { name: 'Remove Results', exact: true }).click();
assert.equal(await rows(right).count(), 0);
await right.getByRole('button', { name: 'Clear search', exact: true }).click();
await counts(400, 1);
await search(left).fill(' GPT-00 ');
assert.equal(await rows(left).count(), 9);
await left.getByRole('button', { name: 'Add Results', exact: true }).click();
assert.equal(await rows(left).count(), 0);
assert.equal(await rows(right).count(), 10, 'A filter on one side does not filter the other side');
await search(right).fill('gpt-00');
await right.getByRole('button', { name: 'Remove Results', exact: true }).click();
await right.getByRole('button', { name: 'Clear search', exact: true }).click();
await left.getByRole('button', { name: 'Clear search', exact: true }).click();
await counts(400, 1);
await right.getByRole('button', { name: 'Remove All', exact: true }).click();
await counts(401, 0);
assert.equal(await dialog.getByRole('button', { name: 'Apply Selection (0)', exact: true }).isDisabled(), true);
await left.getByRole('button', { name: 'Add gpt-001', exact: true }).focus();
await page.keyboard.press('Enter');
assert.equal(await left.getByRole('button', { name: 'Add gpt-002', exact: true }).evaluate(el => el === document.activeElement), true);
await counts(400, 1);
await page.evaluate(() => { window.fixtureCatalog.push({ name: 'new-on-refresh' }); });
await dialog.getByRole('button', { name: 'Refresh', exact: true }).click();
await ready();
await counts(401, 1);
assert.equal(await left.getByRole('button', { name: 'Add new-on-refresh', exact: true }).count(), 1);
await search(left).fill('no-such-model');
await page.evaluate(() => { window.fixtureFailFetch = true; });
await dialog.getByRole('button', { name: 'Refresh', exact: true }).click();
await ready();
assert.match(await dialog.getByRole('alert').innerText(), /Fixture discovery failed/);
assert.equal(await rows(right).count(), 1, 'Failed refresh keeps selected models');
await left.getByRole('button', { name: 'Clear search', exact: true }).click();
await counts(401, 1);
await page.evaluate(() => { window.fixtureFailFetch = false; });
await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
const saved = await page.evaluate(() => window.fixtureSaved);
assert.deepEqual(saved.models, [{ name: 'gpt-001', alias: 'Focus' }]);
assert.ok(saved.excludedModelsText.includes('gpt-002'));
await page.getByRole('button', { name: 'Fetch Models', exact: true }).click();
await ready();
await right.getByRole('button', { name: 'Remove All', exact: true }).click();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Fetch Models', exact: true }).click();
await ready();
assert.equal(await rows(right).count(), 1, 'Cancelling discards pending selection edits');
await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).focus();
await page.keyboard.press('Tab');
assert.equal(await dialog.getByRole('button', { name: 'Close', exact: true }).evaluate(el => el === document.activeElement), true);
await page.keyboard.press('Escape');
assert.equal(await dialog.count(), 0);
assert.equal(await page.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate(el => el === document.activeElement), true);
await page.evaluate(() => { window.fixtureHoldNext = true; });
await page.getByRole('button', { name: 'Fetch Models', exact: true }).click();
await page.waitForFunction(() => window.fixturePending.length === 1);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('textbox', { name: 'Base URL', exact: true }).fill('https://new.example.test');
await page.evaluate(() => { window.fixtureCatalog = [{ name: 'fresh-model' }]; });
await page.getByRole('button', { name: 'Fetch Models', exact: true }).click();
await ready();
await counts(1, 1);
await page.evaluate(async () => {
window.fixturePending.shift()();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
await counts(1, 1);
assert.equal(await left.getByRole('button', { name: 'Add fresh-model', exact: true }).count(), 1, 'Cancelled response cannot replace the current list');
await open('excluded');
await counts(240, 160);
await open('new');
await counts(0, 400);
await right.getByRole('button', { name: 'Remove All', exact: true }).click();
await dialog.getByRole('button', { name: 'Refresh', exact: true }).click();
await ready();
await counts(400, 0);
await left.getByRole('button', { name: 'Add All', exact: true }).click();
await counts(0, 400);
assert.deepEqual(errors, []);
mkdirSync('misc', { recursive: true });
for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) {
await page.setViewportSize({ width, height });
await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`);
await page.locator('.model-config-heading button').click();
await ready();
const popup = page.locator('.model-transfer-dialog');
const rect = await popup.boundingBox();
assert.ok(rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height);
assert.equal(await popup.evaluate(el => el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight), false, 'Dialog must not overflow');
for (const panel of await popup.locator('.model-transfer-panel').all()) {
assert.equal(await panel.evaluate(el => el.scrollWidth > el.clientWidth), false, 'Panel must not overflow horizontally');
}
await page.screenshot({ path: `misc/model-selection-${locale}-${theme}.png` });
}
console.log('PASS: 400 models, independent searches, filtered transfers, empty selection recovery, keyboard focus, refresh errors, selection persistence, cancel, stale responses, exclusions, save, and responsive themes');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });