feat(ai-providers): confirm before leaving with unsaved changes

This commit is contained in:
Supra4E8C
2026-02-22 00:52:38 +08:00
parent 65b3ebbca9
commit 86a5b94cac
10 changed files with 522 additions and 23 deletions
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { BlockerFunction } from 'react-router';
import { useBlocker } from 'react-router';
import { useNotificationStore } from '@/stores';
type ConfirmationVariant = 'danger' | 'primary' | 'secondary';
export type UnsavedChangesDialog = {
title: string;
message: string;
confirmText: string;
cancelText: string;
variant?: ConfirmationVariant;
};
export type UseUnsavedChangesGuardOptions = {
enabled?: boolean;
shouldBlock: boolean | BlockerFunction;
dialog: UnsavedChangesDialog;
};
export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
const { enabled = true, shouldBlock, dialog } = options;
const { showConfirmation } = useNotificationStore();
const lastBlockedRef = useRef<string>('');
const shouldBlockFunction = useCallback<BlockerFunction>(
(args) => {
if (!enabled) return false;
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
},
[enabled, shouldBlock]
);
const blocker = useBlocker(shouldBlockFunction);
const blockedKey = useMemo(() => {
if (blocker.state !== 'blocked' || !blocker.location) return '';
return `${blocker.location.pathname}${blocker.location.search}${blocker.location.hash}`;
}, [blocker.location, blocker.state]);
useEffect(() => {
if (blocker.state !== 'blocked') {
lastBlockedRef.current = '';
return;
}
if (!blockedKey || lastBlockedRef.current === blockedKey) {
return;
}
lastBlockedRef.current = blockedKey;
showConfirmation({
title: dialog.title,
message: dialog.message,
confirmText: dialog.confirmText,
cancelText: dialog.cancelText,
variant: dialog.variant ?? 'danger',
onConfirm: () => blocker.proceed(),
onCancel: () => blocker.reset(),
});
}, [blockedKey, blocker, dialog, showConfirmation]);
}
+5 -1
View File
@@ -5,6 +5,8 @@
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"leave": "Leave",
"stay": "Stay",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
@@ -48,7 +50,9 @@
"custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)",
"invalid_provider_index": "Invalid provider index."
"invalid_provider_index": "Invalid provider index.",
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?"
},
"title": {
"main": "CLI Proxy API Management Center",
+5 -1
View File
@@ -5,6 +5,8 @@
"back": "Назад",
"cancel": "Отмена",
"confirm": "Подтвердить",
"leave": "Уйти",
"stay": "Остаться",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
@@ -48,7 +50,9 @@
"custom_headers_value_placeholder": "Значение заголовка",
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Псевдоним модели (необязательно)",
"invalid_provider_index": "Неверный индекс провайдера."
"invalid_provider_index": "Неверный индекс провайдера.",
"unsaved_changes_title": "Несохранённые изменения",
"unsaved_changes_message": "У вас есть несохранённые изменения. Если вы уйдёте, они будут потеряны. Выйти?"
},
"title": {
"main": "Центр управления CLI Proxy API",
+5 -1
View File
@@ -5,6 +5,8 @@
"back": "返回",
"cancel": "取消",
"confirm": "确认",
"leave": "离开",
"stay": "继续编辑",
"save": "保存",
"delete": "删除",
"edit": "编辑",
@@ -48,7 +50,9 @@
"custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)",
"invalid_provider_index": "无效的提供商索引。"
"invalid_provider_index": "无效的提供商索引。",
"unsaved_changes_title": "未保存的更改",
"unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?"
},
"title": {
"main": "CLI Proxy API Management Center",
+44 -2
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { ampcodeApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
@@ -24,6 +25,23 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ from: string; to: string }>>((acc, entry) => {
const from = String(entry?.name ?? '').trim();
const to = String(entry?.alias ?? '').trim();
if (!from && !to) return acc;
acc.push({ from, to });
return acc;
}, []);
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
JSON.stringify({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
export function AiProvidersAmpcodeEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -42,6 +60,9 @@ export function AiProvidersAmpcodeEditPage() {
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [baselineSignature, setBaselineSignature] = useState(() =>
buildAmpcodeSignature(buildAmpcodeFormState(null))
);
const initializedRef = useRef(false);
const mountedRef = useRef(false);
@@ -83,7 +104,9 @@ export function AiProvidersAmpcodeEditPage() {
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
setForm(initialForm);
setBaselineSignature(buildAmpcodeSignature(initialForm));
void (async () => {
try {
@@ -93,7 +116,9 @@ export function AiProvidersAmpcodeEditPage() {
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
const nextForm = buildAmpcodeFormState(ampcode);
setForm(nextForm);
setBaselineSignature(buildAmpcodeSignature(nextForm));
} catch (err: unknown) {
if (!mountedRef.current) return;
setError(getErrorMessage(err) || t('notification.refresh_failed'));
@@ -105,6 +130,23 @@ export function AiProvidersAmpcodeEditPage() {
})();
}, [clearCache, t, updateConfigValue]);
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const clearAmpcodeUpstreamApiKey = async () => {
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
+98 -3
View File
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { providersApi } from '@/services/api';
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
@@ -62,6 +63,59 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name) {
alias = alias || name;
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
if (!cloak) return null;
const mode = String(cloak.mode ?? '').trim().toLowerCase() || 'auto';
const strictMode = Boolean(cloak.strictMode);
const sensitiveWords = Array.isArray(cloak.sensitiveWords)
? cloak.sensitiveWords.map((word) => String(word ?? '').trim()).filter(Boolean)
: [];
return {
mode,
strictMode,
sensitiveWords: sensitiveWords.length ? sensitiveWords : null,
};
};
const buildClaudeSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -85,6 +139,8 @@ export function AiProvidersClaudeEditLayout() {
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
const [saving, setSaving] = useState(false);
const [baselineDraftKey, setBaselineDraftKey] = useState<string>('');
const [baselineSignature, setBaselineSignature] = useState<string>('');
const draftKey = useMemo(() => {
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
@@ -151,14 +207,19 @@ export function AiProvidersClaudeEditLayout() {
}, [draftKey, ensureDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [clearDraft, draftKey, location.state, navigate]);
}, [location.state, navigate]);
useEffect(() => {
return () => {
clearDraft(draftKey);
};
}, [clearDraft, draftKey]);
useEffect(() => {
let cancelled = false;
@@ -217,6 +278,40 @@ export function AiProvidersClaudeEditLayout() {
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
useEffect(() => {
if (resolvedLoading) return;
if (baselineDraftKey === draftKey) return;
setBaselineDraftKey(draftKey);
setBaselineSignature(currentSignature);
}, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]);
const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature;
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/claude/${params.index ?? ''}`;
}
return '/ai-providers/claude/new';
}, [hasIndexParam, params.index]);
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ nextLocation }) => {
const nextPath = nextLocation.pathname;
const isWithinRoot =
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
return isDirty && !isWithinRoot;
},
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
useEffect(() => {
if (resolvedLoading) return;
+66 -4
View File
@@ -9,11 +9,12 @@ import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
@@ -49,6 +50,45 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && alias === name) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersCodexEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -68,6 +108,7 @@ 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 [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -142,17 +183,38 @@ export function AiProvidersCodexEditPage() {
if (loading) return;
if (initialData) {
setForm({
const nextForm: ProviderFormState = {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
};
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
}, [initialData, loading]);
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const discoveredModelsFiltered = useMemo(() => {
+65 -4
View File
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
@@ -44,6 +45,44 @@ const stripGeminiModelResourceName = (value: string) => {
return String(value ?? '').trim().replace(/^\/?models\//i, '');
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && alias === name) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
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(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersGeminiEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -63,6 +102,7 @@ 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 [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -138,7 +178,7 @@ export function AiProvidersGeminiEditPage() {
if (initialData) {
const { headers, models, ...rest } = initialData;
setForm({
const nextForm: GeminiFormState = {
...rest,
headers: headersToEntries(headers),
modelEntries: modelsToEntries(models).map((entry) => ({
@@ -146,10 +186,14 @@ export function AiProvidersGeminiEditPage() {
name: stripGeminiModelResourceName(entry.name),
})),
excludedText: excludedModelsToText(initialData.excludedModels),
});
};
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
@@ -287,6 +331,23 @@ export function AiProvidersGeminiEditPage() {
setModelDiscoveryOpen(false);
};
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const handleSave = useCallback(async () => {
if (!canSave) return;
+111 -3
View File
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
@@ -62,6 +63,72 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: ModelEntry[]) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && (alias === '' || alias === name)) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const normalizeKeyHeaders = (headers: ApiKeyEntry['headers']) => {
if (!headers || typeof headers !== 'object') return [];
return Object.entries(headers)
.map(([key, value]) => ({ key: String(key ?? '').trim(), value: String(value ?? '').trim() }))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
};
const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
(entries ?? []).reduce<
Array<{
apiKey: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
}>
>((acc, entry) => {
const apiKey = String(entry?.apiKey ?? '').trim();
const proxyUrl = String(entry?.proxyUrl ?? '').trim();
const headers = normalizeKeyHeaders(entry?.headers);
if (!apiKey && !proxyUrl && headers.length === 0) return acc;
acc.push({ apiKey, proxyUrl, headers });
return acc;
}, []);
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
JSON.stringify({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
export function AiProvidersOpenAIEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -87,6 +154,8 @@ export function AiProvidersOpenAIEditLayout() {
() => !isCacheValid('openai-compatibility')
);
const [saving, setSaving] = useState(false);
const [baselineDraftKey, setBaselineDraftKey] = useState<string>('');
const [baselineSignature, setBaselineSignature] = useState<string>('');
const draftKey = useMemo(() => {
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
@@ -171,14 +240,19 @@ export function AiProvidersOpenAIEditLayout() {
}, [draftKey, ensureDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [clearDraft, draftKey, location.state, navigate]);
}, [location.state, navigate]);
useEffect(() => {
return () => {
clearDraft(draftKey);
};
}, [clearDraft, draftKey]);
useEffect(() => {
let cancelled = false;
@@ -374,6 +448,40 @@ export function AiProvidersOpenAIEditLayout() {
]);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
useEffect(() => {
if (resolvedLoading) return;
if (baselineDraftKey === draftKey) return;
setBaselineDraftKey(draftKey);
setBaselineSignature(currentSignature);
}, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]);
const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature;
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/openai/${params.index ?? ''}`;
}
return '/ai-providers/openai/new';
}, [hasIndexParam, params.index]);
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ nextLocation }) => {
const nextPath = nextLocation.pathname;
const isWithinRoot =
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
return isDirty && !isWithinRoot;
},
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
return (
<Outlet
+59 -4
View File
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -34,6 +35,38 @@ const parseIndexParam = (value: string | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
const alias = String(entry?.alias ?? '').trim();
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildVertexSignature = (form: VertexFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
});
export function AiProvidersVertexEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -53,6 +86,7 @@ export function AiProvidersVertexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -126,18 +160,39 @@ export function AiProvidersVertexEditPage() {
if (loading) return;
if (initialData) {
setForm({
const nextForm: VertexFormState = {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const handleSave = useCallback(async () => {
if (!canSave) return;