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:
1 parent
2b41eab8df
commit
19a9b4fc09
9 files changed
+605
-84
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': 'モデルの取得に失敗しました',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Vendored
+5
@@ -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>
|
||||
Vendored
+61
@@ -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>,
|
||||
);
|
||||
@@ -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; });
|
||||
Reference in new issue
Block a user