mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
2 Commits
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
@@ -177,8 +178,11 @@ export function VisualConfigEditor({
|
||||
onChange,
|
||||
}: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
|
||||
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
|
||||
const routingStrategyLabelId = useId();
|
||||
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
|
||||
const keepaliveInputId = useId();
|
||||
@@ -371,7 +375,7 @@ export function VisualConfigEditor({
|
||||
floatingElement.style.removeProperty('pointer-events');
|
||||
};
|
||||
|
||||
if (isMobile || !isFloatingSidebar || !anchorElement || !workspaceElement) {
|
||||
if (!shouldRenderFloatingSidebar || !anchorElement || !workspaceElement) {
|
||||
clearFloatingStyles();
|
||||
return undefined;
|
||||
}
|
||||
@@ -441,7 +445,7 @@ export function VisualConfigEditor({
|
||||
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
|
||||
clearFloatingStyles();
|
||||
};
|
||||
}, [isFloatingSidebar, isMobile]);
|
||||
}, [shouldRenderFloatingSidebar]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navList}>
|
||||
@@ -1042,7 +1046,7 @@ export function VisualConfigEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && isFloatingSidebar && typeof document !== 'undefined'
|
||||
{shouldRenderFloatingSidebar && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div ref={floatingSidebarRef} className={styles.floatingSidebarContainer}>
|
||||
<div className={styles.floatingSidebarRail}>{navContent}</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SelectionCheckbox({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
|
||||
{label ? <span className={textClassName}>{label}</span> : null}
|
||||
{label ? <div className={textClassName}>{label}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||
"gemini_models_fetch_error": "Failed to fetch Gemini models",
|
||||
"gemini_models_fetch_apply": "Add selected models",
|
||||
"model_discovery_select_visible": "Select current list",
|
||||
"model_discovery_clear_selection": "Clear selection",
|
||||
"model_discovery_selected_count": "{{count}} selected",
|
||||
"gemini_models_search_label": "Search models",
|
||||
"gemini_models_search_placeholder": "Filter by name, alias, or description",
|
||||
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
|
||||
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
|
||||
"gemini_models_fetch_apply": "Добавить выбранные модели",
|
||||
"model_discovery_select_visible": "Выбрать текущий список",
|
||||
"model_discovery_clear_selection": "Очистить выбор",
|
||||
"model_discovery_selected_count": "Выбрано: {{count}}",
|
||||
"gemini_models_search_label": "Поиск моделей",
|
||||
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
|
||||
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
|
||||
"gemini_models_fetch_error": "获取 Gemini 模型失败",
|
||||
"gemini_models_fetch_apply": "添加所选模型",
|
||||
"model_discovery_select_visible": "全选当前列表",
|
||||
"model_discovery_clear_selection": "清空已选",
|
||||
"model_discovery_selected_count": "已选 {{count}} 项",
|
||||
"gemini_models_search_label": "搜索模型",
|
||||
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
|
||||
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersClaudeModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchClaudeModelDiscovery = useCallback(async () => {
|
||||
setFetching(true);
|
||||
@@ -117,6 +126,22 @@ export function AiProvidersClaudeModelsPage() {
|
||||
void fetchClaudeModelDiscovery();
|
||||
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -145,6 +170,18 @@ export function AiProvidersClaudeModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -153,7 +190,7 @@ export function AiProvidersClaudeModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -219,9 +256,42 @@ export function AiProvidersClaudeModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.claude_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -231,29 +301,30 @@ export function AiProvidersClaudeModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
@@ -66,7 +67,9 @@ const buildCodexSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
websockets: Boolean(form.websockets),
|
||||
@@ -95,7 +98,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildCodexSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -119,7 +124,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -216,6 +223,16 @@ export function AiProvidersCodexEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -247,7 +264,10 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.codex_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -320,6 +340,22 @@ export function AiProvidersCodexEditPage() {
|
||||
void fetchCodexModelDiscovery();
|
||||
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -332,8 +368,22 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +424,9 @@ export function AiProvidersCodexEditPage() {
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||
editIndex !== null
|
||||
? t('notification.codex_config_updated')
|
||||
: t('notification.codex_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -407,7 +459,8 @@ export function AiProvidersCodexEditPage() {
|
||||
!invalidIndexParam &&
|
||||
!invalidIndex &&
|
||||
Boolean((form.baseUrl ?? '').trim());
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -514,7 +567,9 @@ export function AiProvidersCodexEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.codex_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -595,7 +650,9 @@ export function AiProvidersCodexEditPage() {
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.codex_models_fetch_url_label')}
|
||||
@@ -624,41 +681,86 @@ export function AiProvidersCodexEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
@@ -42,7 +43,9 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
};
|
||||
|
||||
const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
@@ -61,7 +64,9 @@ const buildGeminiSignature = (form: GeminiFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
@@ -89,7 +94,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildGeminiSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -113,7 +120,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -196,6 +205,16 @@ export function AiProvidersGeminiEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -225,7 +244,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.gemini_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -306,6 +328,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
void fetchGeminiModelDiscovery();
|
||||
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -318,8 +356,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +426,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue('gemini-api-key', nextList);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||
editIndex !== null
|
||||
? t('notification.gemini_key_updated')
|
||||
: t('notification.gemini_key_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -400,8 +454,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canOpenModelDiscovery =
|
||||
!disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -501,7 +557,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.gemini_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -572,14 +630,20 @@ export function AiProvidersGeminiEditPage() {
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyDiscoveredModels}
|
||||
disabled={!canApplyModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.gemini_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.gemini_models_fetch_url_label')}
|
||||
@@ -608,41 +672,86 @@ export function AiProvidersGeminiEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
@@ -98,6 +107,22 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -126,6 +151,18 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -134,7 +171,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -200,9 +237,42 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.openai_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -212,29 +282,30 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -327,15 +327,18 @@
|
||||
}
|
||||
|
||||
.modelDiscoveryRow {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin-top: 2px;
|
||||
@@ -348,6 +351,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelDiscoveryRowSelected {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-tertiary);
|
||||
@@ -376,6 +384,27 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionSummary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.openaiTestButtonSuccess {
|
||||
background-color: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
@@ -417,7 +446,9 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.statusBlockWrapper:hover &,
|
||||
.statusBlockWrapper.statusBlockActive & {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { parse as parseYaml, parseDocument } from 'yaml';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
@@ -36,6 +37,8 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
@@ -79,6 +82,7 @@ export function ConfigPage() {
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
const shouldRenderFloatingActions = isCurrentLayer;
|
||||
const hasVisualModeError = !!visualParseError;
|
||||
const hasVisualValidationErrors =
|
||||
activeTab === 'visual' &&
|
||||
@@ -358,7 +362,7 @@ export function ConfigPage() {
|
||||
|
||||
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined' || !shouldRenderFloatingActions) return;
|
||||
|
||||
const actionsEl = floatingActionsRef.current;
|
||||
if (!actionsEl) return;
|
||||
@@ -379,7 +383,7 @@ export function ConfigPage() {
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
document.documentElement.style.removeProperty('--config-action-bar-height');
|
||||
};
|
||||
}, []);
|
||||
}, [shouldRenderFloatingActions]);
|
||||
|
||||
// CodeMirror extensions
|
||||
const extensions = useMemo(
|
||||
@@ -622,7 +626,9 @@ export function ConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
{shouldRenderFloatingActions && typeof document !== 'undefined'
|
||||
? createPortal(floatingActions, document.body)
|
||||
: null}
|
||||
<DiffModal
|
||||
open={diffModalOpen}
|
||||
original={serverYaml}
|
||||
|
||||
Reference in New Issue
Block a user