perf(ai-providers): lighten dirty checks for edits

This commit is contained in:
Supra4E8C
2026-04-03 01:32:49 +08:00
Unverified
parent 4d3bd279a0
commit 73e260c37f
5 changed files with 300 additions and 84 deletions
+72 -21
View File
@@ -13,6 +13,7 @@ import { ampcodeApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { areStringArraysEqual } from '@/utils/compare';
import {
buildAmpcodeFormState,
entriesToAmpcodeMappings,
@@ -38,20 +39,52 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>
return acc;
}, []);
const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) =>
entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({
upstreamApiKey: entry.upstreamApiKey,
apiKeys: entry.apiKeys,
}));
type AmpcodeFormBaseline = {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
upstreamApiKeys: ReturnType<typeof entriesToAmpcodeUpstreamApiKeys>;
modelMappings: ReturnType<typeof normalizeMappingEntries>;
};
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
JSON.stringify({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: normalizeUpstreamApiKeyEntries(form),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const buildAmpcodeBaseline = (form: AmpcodeFormState): AmpcodeFormBaseline => ({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const areUpstreamApiKeysEqual = (
a: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[],
b: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.upstreamApiKey !== right.upstreamApiKey) return false;
if (!areStringArraysEqual(left.apiKeys, right.apiKeys)) return false;
}
return true;
};
const areModelMappingsEqual = (
a: readonly { from: string; to: string }[],
b: readonly { from: string; to: string }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.from !== right.from || left.to !== right.to) return false;
}
return true;
};
export function AiProvidersAmpcodeEditPage() {
const { t } = useTranslation();
@@ -72,9 +105,7 @@ export function AiProvidersAmpcodeEditPage() {
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [baselineSignature, setBaselineSignature] = useState(() =>
buildAmpcodeSignature(buildAmpcodeFormState(null))
);
const [baseline, setBaseline] = useState(() => buildAmpcodeBaseline(buildAmpcodeFormState(null)));
const initializedRef = useRef(false);
const mountedRef = useRef(false);
@@ -119,7 +150,7 @@ export function AiProvidersAmpcodeEditPage() {
setError('');
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
setForm(initialForm);
setBaselineSignature(buildAmpcodeSignature(initialForm));
setBaseline(buildAmpcodeBaseline(initialForm));
void (async () => {
try {
@@ -131,7 +162,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
const nextForm = buildAmpcodeFormState(ampcode);
setForm(nextForm);
setBaselineSignature(buildAmpcodeSignature(nextForm));
setBaseline(buildAmpcodeBaseline(nextForm));
} catch (err: unknown) {
if (!mountedRef.current) return;
setError(getErrorMessage(err) || t('notification.refresh_failed'));
@@ -143,8 +174,28 @@ export function AiProvidersAmpcodeEditPage() {
})();
}, [clearCache, t, updateConfigValue]);
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedUpstreamApiKeys = useMemo(
() => entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
[form.upstreamApiKeyEntries]
);
const normalizedModelMappings = useMemo(
() => normalizeMappingEntries(form.mappingEntries),
[form.mappingEntries]
);
const isUpstreamApiKeysDirty = useMemo(
() => !areUpstreamApiKeysEqual(baseline.upstreamApiKeys, normalizedUpstreamApiKeys),
[baseline.upstreamApiKeys, normalizedUpstreamApiKeys]
);
const isModelMappingsDirtyNormalized = useMemo(
() => !areModelMappingsEqual(baseline.modelMappings, normalizedModelMappings),
[baseline.modelMappings, normalizedModelMappings]
);
const isDirty =
baseline.upstreamUrl !== form.upstreamUrl.trim() ||
baseline.upstreamApiKey !== form.upstreamApiKey.trim() ||
baseline.forceModelMappings !== Boolean(form.forceModelMappings) ||
isUpstreamApiKeysDirty ||
isModelMappingsDirtyNormalized;
const canGuard = !loading && !saving;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -263,7 +314,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
allowNextNavigation();
setBaselineSignature(buildAmpcodeSignature(form));
setBaseline(buildAmpcodeBaseline(form));
handleBack();
} catch (err: unknown) {
const message = getErrorMessage(err);
+65 -23
View File
@@ -16,6 +16,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
@@ -63,21 +64,30 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
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 ?? ''),
});
type CodexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
websockets: boolean;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildCodexBaseline = (form: ProviderFormState): CodexFormBaseline => ({
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();
@@ -98,9 +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 [baseline, setBaseline] = useState(() => buildCodexBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -186,16 +194,50 @@ export function AiProvidersCodexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
}, [initialData, loading]);
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.websockets !== Boolean(form.websockets) ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -430,7 +472,7 @@ export function AiProvidersCodexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildCodexSignature(form));
setBaseline(buildCodexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+62 -22
View File
@@ -15,6 +15,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
@@ -60,20 +61,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
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 ?? ''),
});
type GeminiFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildGeminiBaseline = (form: GeminiFormState): GeminiFormBaseline => ({
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();
@@ -94,9 +103,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 [baseline, setBaseline] = useState(() => buildGeminiBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -185,12 +192,12 @@ export function AiProvidersGeminiEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
@@ -378,8 +385,41 @@ export function AiProvidersGeminiEditPage() {
setModelDiscoveryOpen(false);
};
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -432,7 +472,7 @@ export function AiProvidersGeminiEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildGeminiSignature(form));
setBaseline(buildGeminiBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+62 -18
View File
@@ -15,6 +15,7 @@ import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -47,18 +48,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildVertexSignature = (form: VertexFormState) =>
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 ?? ''),
});
type VertexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildVertexBaseline = (form: VertexFormState): VertexFormBaseline => ({
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 AiProvidersVertexEditPage() {
const { t } = useTranslation();
@@ -79,7 +90,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 [baseline, setBaseline] = useState(() => buildVertexBaseline(buildEmptyForm()));
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -160,18 +171,51 @@ export function AiProvidersVertexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -230,7 +274,7 @@ export function AiProvidersVertexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildVertexSignature(form));
setBaseline(buildVertexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+39
View File
@@ -0,0 +1,39 @@
export function areStringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function areKeyValueEntriesEqual(
a: readonly { key: string; value: string }[],
b: readonly { key: string; value: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.key !== right.key || left.value !== right.value) return false;
}
return true;
}
export function areModelEntriesEqual(
a: readonly { name: string; alias: string }[],
b: readonly { name: string; alias: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.name !== right.name || left.alias !== right.alias) return false;
}
return true;
}