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));
|
||||
|
||||
Reference in new issue
Block a user