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
21 Commits
@@ -2,7 +2,11 @@ import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import { animate } from 'motion/mini';
|
||||
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
|
||||
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||
import {
|
||||
PAGE_TRANSITION_LAYER_CONTEXT_VALUES,
|
||||
PageTransitionLayerContext,
|
||||
type LayerStatus,
|
||||
} from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
@@ -386,7 +390,9 @@ export function PageTransition({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||
<PageTransitionLayerContext.Provider
|
||||
value={PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status]}
|
||||
>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,20 @@ import { createContext, useContext } from 'react';
|
||||
|
||||
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
type PageTransitionLayerContextValue = {
|
||||
export type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
isCurrentLayer: boolean;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext =
|
||||
createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<LayerStatus, PageTransitionLayerContextValue> = {
|
||||
current: { status: 'current', isCurrentLayer: true },
|
||||
stacked: { status: 'stacked', isCurrentLayer: false },
|
||||
exiting: { status: 'exiting', isCurrentLayer: false },
|
||||
};
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,17 +106,16 @@
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
--glass-blur: 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) {
|
||||
.floatingActionSurface {
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
|
||||
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
|
||||
const shouldRenderFloatingAction = Boolean(floatingAction) && isCurrentLayer;
|
||||
const floatingActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo, type Ref } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
|
||||
type ConfigSourceEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
editorRef?: Ref<ReactCodeMirrorRef>;
|
||||
theme: 'light' | 'dark';
|
||||
editable: boolean;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export default function ConfigSourceEditor({
|
||||
value,
|
||||
onChange,
|
||||
editorRef,
|
||||
theme,
|
||||
editable,
|
||||
placeholder,
|
||||
}: ConfigSourceEditorProps) {
|
||||
const extensions = useMemo(
|
||||
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme={theme}
|
||||
editable={editable}
|
||||
placeholder={placeholder}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,8 +395,9 @@
|
||||
border-radius: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
--glass-blur: 14px;
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -179,7 +179,7 @@ export function VisualConfigEditor({
|
||||
}: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
|
||||
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
|
||||
@@ -334,6 +334,7 @@ export function VisualConfigEditor({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentLayer) return undefined;
|
||||
if (typeof IntersectionObserver === 'undefined') return undefined;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -357,10 +358,10 @@ export function VisualConfigEditor({
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
}, [isCurrentLayer, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (!isCurrentLayer || !isMobile) return;
|
||||
const scroller = mobileNavScrollerRef.current;
|
||||
const button = mobileNavButtonRefs.current[activeSectionId];
|
||||
if (!scroller || !button) return;
|
||||
@@ -378,7 +379,7 @@ export function VisualConfigEditor({
|
||||
left: targetLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [activeSectionId, isMobile]);
|
||||
}, [activeSectionId, isCurrentLayer, isMobile]);
|
||||
|
||||
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
|
||||
setActiveSectionId(sectionId);
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -33,7 +36,7 @@ interface ClaudeSectionProps {
|
||||
export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
usageDetailsBySource,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -56,13 +59,14 @@ export function ClaudeSection({
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -33,7 +36,7 @@ interface CodexSectionProps {
|
||||
export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
usageDetailsBySource,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -56,13 +59,14 @@ export function CodexSection({
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -33,7 +36,7 @@ interface GeminiSectionProps {
|
||||
export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
usageDetailsBySource,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -56,13 +59,14 @@ export function GeminiSection({
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -21,7 +21,7 @@ import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -34,7 +34,7 @@ interface OpenAISectionProps {
|
||||
export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
usageDetailsBySource,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -57,13 +57,13 @@ export function OpenAISection({
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
--glass-blur: 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
overflow-x: auto;
|
||||
@@ -104,8 +105,6 @@
|
||||
// 暗色主题适配
|
||||
:global([data-theme='dark']) {
|
||||
.navList {
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -33,7 +36,7 @@ interface VertexSectionProps {
|
||||
export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
usageDetailsBySource,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -56,13 +59,14 @@ export function VertexSection({
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
}, [configs, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
export const useProviderStats = () => {
|
||||
const keyStats = useUsageStatsStore((state) => state.keyStats);
|
||||
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
|
||||
const isLoading = useUsageStatsStore((state) => state.loading);
|
||||
const EMPTY_KEY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
|
||||
|
||||
export type UseProviderStatsOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const useProviderStats = (options: UseProviderStatsOptions = {}) => {
|
||||
const enabled = options.enabled ?? true;
|
||||
const keyStats = useUsageStatsStore((state) => (enabled ? state.keyStats : EMPTY_KEY_STATS));
|
||||
const usageDetails = useUsageStatsStore((state) =>
|
||||
enabled ? state.usageDetails : EMPTY_USAGE_DETAILS
|
||||
);
|
||||
const isLoading = useUsageStatsStore((state) => (enabled ? state.loading : false));
|
||||
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
|
||||
|
||||
// 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。
|
||||
@@ -20,7 +31,7 @@ export const useProviderStats = () => {
|
||||
|
||||
useInterval(() => {
|
||||
void refreshKeyStats().catch(() => {});
|
||||
}, 240_000);
|
||||
}, enabled ? 240_000 : null);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
|
||||
};
|
||||
|
||||
@@ -65,7 +65,13 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
<Button
|
||||
onClick={onSave}
|
||||
loading={editor?.saving === true}
|
||||
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
|
||||
disabled={
|
||||
disableControls ||
|
||||
editor?.saving === true ||
|
||||
!dirty ||
|
||||
!editor?.json ||
|
||||
Boolean(editor?.headersTouched && editor.headersError)
|
||||
}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
@@ -138,6 +144,20 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
/>
|
||||
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.headers_label')}</label>
|
||||
<textarea
|
||||
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
|
||||
value={editor.headersText}
|
||||
placeholder={t('auth_files.headers_placeholder')}
|
||||
rows={4}
|
||||
aria-invalid={Boolean(editor.headersError)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('headersText', e.target.value)}
|
||||
/>
|
||||
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
|
||||
<div className="hint">{t('auth_files.headers_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('auth_files.disable_cooling_label')}
|
||||
value={editor.disableCooling}
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
readCodexAuthFileWebsockets,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
type AuthFileHeaders = Record<string, string>;
|
||||
type AuthFileHeadersErrorKey =
|
||||
| 'auth_files.headers_invalid_json'
|
||||
| 'auth_files.headers_invalid_object'
|
||||
| 'auth_files.headers_invalid_value';
|
||||
|
||||
export type PrefixProxyEditorField =
|
||||
| 'prefix'
|
||||
| 'proxyUrl'
|
||||
@@ -21,7 +27,8 @@ export type PrefixProxyEditorField =
|
||||
| 'excludedModelsText'
|
||||
| 'disableCooling'
|
||||
| 'websockets'
|
||||
| 'note';
|
||||
| 'note'
|
||||
| 'headersText';
|
||||
|
||||
export type PrefixProxyEditorFieldValue = string | boolean;
|
||||
|
||||
@@ -43,6 +50,9 @@ export type PrefixProxyEditorState = {
|
||||
websockets: boolean;
|
||||
note: string;
|
||||
noteTouched: boolean;
|
||||
headersText: string;
|
||||
headersTouched: boolean;
|
||||
headersError: string | null;
|
||||
};
|
||||
|
||||
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||
@@ -64,7 +74,45 @@ export type UseAuthFilesPrefixProxyEditorResult = {
|
||||
handlePrefixProxySave: () => Promise<void>;
|
||||
};
|
||||
|
||||
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
|
||||
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const validateHeadersValue = (value: unknown): AuthFileHeadersErrorKey | null => {
|
||||
if (!isRecordObject(value)) {
|
||||
return 'auth_files.headers_invalid_object';
|
||||
}
|
||||
return Object.values(value).every((item) => typeof item === 'string')
|
||||
? null
|
||||
: 'auth_files.headers_invalid_value';
|
||||
};
|
||||
|
||||
const parseHeadersText = (
|
||||
text: string
|
||||
): { value: AuthFileHeaders | null; errorKey: AuthFileHeadersErrorKey | null } => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { value: null, errorKey: null };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return { value: null, errorKey: 'auth_files.headers_invalid_json' };
|
||||
}
|
||||
|
||||
const errorKey = validateHeadersValue(parsed);
|
||||
if (errorKey) {
|
||||
return { value: null, errorKey };
|
||||
}
|
||||
|
||||
return { value: parsed as AuthFileHeaders, errorKey: null };
|
||||
};
|
||||
|
||||
const buildPrefixProxyUpdatedText = (
|
||||
editor: PrefixProxyEditorState | null,
|
||||
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
|
||||
): string => {
|
||||
if (!editor?.json) return editor?.rawText ?? '';
|
||||
const next: Record<string, unknown> = { ...editor.json };
|
||||
if ('prefix' in next || editor.prefix.trim()) {
|
||||
@@ -104,6 +152,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
|
||||
}
|
||||
}
|
||||
|
||||
if (editor.headersTouched) {
|
||||
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
|
||||
if (errorKey) {
|
||||
throw new Error(resolveHeadersError(errorKey));
|
||||
}
|
||||
if (parsedHeaders) {
|
||||
next.headers = parsedHeaders;
|
||||
} else {
|
||||
delete next.headers;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
|
||||
);
|
||||
@@ -118,11 +178,18 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
|
||||
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||
|
||||
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
|
||||
const hasBlockingValidationError = Boolean(
|
||||
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
|
||||
);
|
||||
const prefixProxyUpdatedText =
|
||||
prefixProxyEditor?.json && !hasBlockingValidationError
|
||||
? buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key))
|
||||
: '';
|
||||
|
||||
const prefixProxyDirty =
|
||||
Boolean(prefixProxyEditor?.json) &&
|
||||
Boolean(prefixProxyEditor?.originalText) &&
|
||||
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
|
||||
(prefixProxyUpdatedText === '' || prefixProxyUpdatedText !== prefixProxyEditor?.originalText);
|
||||
|
||||
const closePrefixProxyEditor = () => {
|
||||
setPrefixProxyEditor(null);
|
||||
@@ -162,6 +229,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
websockets: false,
|
||||
note: '',
|
||||
noteTouched: false,
|
||||
headersText: '',
|
||||
headersTouched: false,
|
||||
headersError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -213,6 +283,14 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||
const websocketsValue = readCodexAuthFileWebsockets(json);
|
||||
const note = typeof json.note === 'string' ? json.note : '';
|
||||
const headers = json.headers;
|
||||
let headersText = '';
|
||||
let headersError: string | null = null;
|
||||
if (headers !== undefined) {
|
||||
headersText = JSON.stringify(headers, null, 2);
|
||||
const { errorKey } = parseHeadersText(headersText);
|
||||
headersError = errorKey ? t(errorKey) : null;
|
||||
}
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -231,6 +309,9 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
websockets: websocketsValue,
|
||||
note,
|
||||
noteTouched: false,
|
||||
headersText,
|
||||
headersTouched: false,
|
||||
headersError,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -256,6 +337,16 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
|
||||
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
|
||||
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
|
||||
if (field === 'headersText') {
|
||||
const headersText = String(value);
|
||||
const { errorKey } = parseHeadersText(headersText);
|
||||
return {
|
||||
...prev,
|
||||
headersText,
|
||||
headersTouched: true,
|
||||
headersError: errorKey ? t(errorKey) : null,
|
||||
};
|
||||
}
|
||||
return { ...prev, websockets: Boolean(value) };
|
||||
});
|
||||
};
|
||||
@@ -265,7 +356,15 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
if (!prefixProxyDirty) return;
|
||||
|
||||
const name = prefixProxyEditor.fileName;
|
||||
const payload = prefixProxyUpdatedText;
|
||||
let payload = '';
|
||||
try {
|
||||
payload = buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key));
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Invalid format';
|
||||
showNotification(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = new Blob([payload]).size;
|
||||
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||
showNotification(
|
||||
|
||||
@@ -8,17 +8,32 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
|
||||
return useMemo(() => {
|
||||
const cache = new Map<string, AuthFileStatusBarData>();
|
||||
|
||||
const usageDetailsByAuthIndex = new Map<string, UsageDetail[]>();
|
||||
usageDetails.forEach((detail) => {
|
||||
const authIndexKey = normalizeAuthIndex(detail.auth_index);
|
||||
if (!authIndexKey) return;
|
||||
|
||||
const list = usageDetailsByAuthIndex.get(authIndexKey);
|
||||
if (list) {
|
||||
list.push(detail);
|
||||
} else {
|
||||
usageDetailsByAuthIndex.set(authIndexKey, [detail]);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueAuthIndexKeys = new Set<string>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndexKey) return;
|
||||
uniqueAuthIndexKeys.add(authIndexKey);
|
||||
});
|
||||
|
||||
if (authIndexKey) {
|
||||
const filteredDetails = usageDetails.filter((detail) => {
|
||||
const detailAuthIndex = normalizeAuthIndex(detail.auth_index);
|
||||
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||
});
|
||||
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||
}
|
||||
uniqueAuthIndexKeys.forEach((authIndexKey) => {
|
||||
cache.set(
|
||||
authIndexKey,
|
||||
calculateStatusBarData(usageDetailsByAuthIndex.get(authIndexKey) ?? [])
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export type HeaderRefreshHandler = () => void | Promise<void>;
|
||||
|
||||
@@ -9,9 +9,19 @@ export const triggerHeaderRefresh = async () => {
|
||||
await activeHeaderRefreshHandler();
|
||||
};
|
||||
|
||||
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
|
||||
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null, enabled = true) => {
|
||||
const lastHandlerRef = useRef<HeaderRefreshHandler | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handler) return;
|
||||
const previousHandler = lastHandlerRef.current;
|
||||
lastHandlerRef.current = handler ?? null;
|
||||
|
||||
if (!enabled || !handler) {
|
||||
if (previousHandler && activeHeaderRefreshHandler === previousHandler) {
|
||||
activeHeaderRefreshHandler = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
activeHeaderRefreshHandler = handler;
|
||||
|
||||
@@ -20,5 +30,5 @@ export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
|
||||
activeHeaderRefreshHandler = null;
|
||||
}
|
||||
};
|
||||
}, [handler]);
|
||||
}, [enabled, handler]);
|
||||
};
|
||||
|
||||
+346
-43
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
@@ -189,7 +189,9 @@ export function getPayloadParamValidationError(
|
||||
}
|
||||
|
||||
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
|
||||
return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param))));
|
||||
return rules.some((rule) =>
|
||||
rule.params.some((param) => Boolean(getPayloadParamValidationError(param)))
|
||||
);
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
@@ -197,6 +199,72 @@ function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function arePayloadModelEntriesEqual(
|
||||
left: PayloadRule['models'],
|
||||
right: PayloadRule['models']
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (!a || !b) return false;
|
||||
if (a.id !== b.id || a.name !== b.name || a.protocol !== b.protocol) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function arePayloadParamEntriesEqual(
|
||||
left: PayloadRule['params'],
|
||||
right: PayloadRule['params']
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (!a || !b) return false;
|
||||
if (a.id !== b.id || a.path !== b.path || a.valueType !== b.valueType || a.value !== b.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function arePayloadRulesEqual(left: PayloadRule[], right: PayloadRule[]): boolean {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (!a || !b) return false;
|
||||
if (a.id !== b.id) return false;
|
||||
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
|
||||
if (!arePayloadParamEntriesEqual(a.params, b.params)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function arePayloadFilterRulesEqual(
|
||||
left: PayloadFilterRule[],
|
||||
right: PayloadFilterRule[]
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (!a || !b) return false;
|
||||
if (a.id !== b.id) return false;
|
||||
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
|
||||
if (a.params.length !== b.params.length) return false;
|
||||
for (let j = 0; j < a.params.length; j += 1) {
|
||||
if (a.params[j] !== b.params[j]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
|
||||
if (typeof raw === 'number') {
|
||||
return { valueType: 'number', value: String(raw) };
|
||||
@@ -426,15 +494,266 @@ function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array<Record<str
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
export function useVisualConfig() {
|
||||
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
type VisualConfigState = {
|
||||
visualValues: VisualConfigValues;
|
||||
baselineValues: VisualConfigValues;
|
||||
dirtyFields: Set<string>;
|
||||
visualParseError: string | null;
|
||||
};
|
||||
|
||||
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
const [visualParseError, setVisualParseError] = useState<string | null>(null);
|
||||
type VisualConfigAction =
|
||||
| {
|
||||
type: 'load_success';
|
||||
values: VisualConfigValues;
|
||||
}
|
||||
| {
|
||||
type: 'load_error';
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
type: 'set_values';
|
||||
values: Partial<VisualConfigValues>;
|
||||
};
|
||||
|
||||
function createInitialVisualConfigState(): VisualConfigState {
|
||||
const initialValues = deepClone(DEFAULT_VISUAL_VALUES);
|
||||
return {
|
||||
visualValues: initialValues,
|
||||
baselineValues: deepClone(initialValues),
|
||||
dirtyFields: new Set(),
|
||||
visualParseError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeVisualConfigValues(
|
||||
currentValues: VisualConfigValues,
|
||||
patch: Partial<VisualConfigValues>
|
||||
): VisualConfigValues {
|
||||
const nextValues: VisualConfigValues = { ...currentValues, ...patch } as VisualConfigValues;
|
||||
if (patch.streaming) {
|
||||
nextValues.streaming = { ...currentValues.streaming, ...patch.streaming };
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function getNextDirtyFields(
|
||||
currentDirtyFields: Set<string>,
|
||||
patch: Partial<VisualConfigValues>,
|
||||
nextValues: VisualConfigValues,
|
||||
baselineValues: VisualConfigValues
|
||||
): Set<string> {
|
||||
const nextDirtyFields = new Set(currentDirtyFields);
|
||||
const updateDirty = (key: string, isEqual: boolean) => {
|
||||
if (isEqual) {
|
||||
nextDirtyFields.delete(key);
|
||||
} else {
|
||||
nextDirtyFields.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'host')) {
|
||||
updateDirty('host', nextValues.host === baselineValues.host);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'port')) {
|
||||
updateDirty('port', nextValues.port === baselineValues.port);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'tlsEnable')) {
|
||||
updateDirty('tlsEnable', nextValues.tlsEnable === baselineValues.tlsEnable);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'tlsCert')) {
|
||||
updateDirty('tlsCert', nextValues.tlsCert === baselineValues.tlsCert);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'tlsKey')) {
|
||||
updateDirty('tlsKey', nextValues.tlsKey === baselineValues.tlsKey);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'rmAllowRemote')) {
|
||||
updateDirty('rmAllowRemote', nextValues.rmAllowRemote === baselineValues.rmAllowRemote);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'rmSecretKey')) {
|
||||
updateDirty('rmSecretKey', nextValues.rmSecretKey === baselineValues.rmSecretKey);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'rmDisableControlPanel')) {
|
||||
updateDirty(
|
||||
'rmDisableControlPanel',
|
||||
nextValues.rmDisableControlPanel === baselineValues.rmDisableControlPanel
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'rmPanelRepo')) {
|
||||
updateDirty('rmPanelRepo', nextValues.rmPanelRepo === baselineValues.rmPanelRepo);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'authDir')) {
|
||||
updateDirty('authDir', nextValues.authDir === baselineValues.authDir);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'apiKeysText')) {
|
||||
updateDirty('apiKeysText', nextValues.apiKeysText === baselineValues.apiKeysText);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'debug')) {
|
||||
updateDirty('debug', nextValues.debug === baselineValues.debug);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'commercialMode')) {
|
||||
updateDirty('commercialMode', nextValues.commercialMode === baselineValues.commercialMode);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'loggingToFile')) {
|
||||
updateDirty('loggingToFile', nextValues.loggingToFile === baselineValues.loggingToFile);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'logsMaxTotalSizeMb')) {
|
||||
updateDirty(
|
||||
'logsMaxTotalSizeMb',
|
||||
nextValues.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'usageStatisticsEnabled')) {
|
||||
updateDirty(
|
||||
'usageStatisticsEnabled',
|
||||
nextValues.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'proxyUrl')) {
|
||||
updateDirty('proxyUrl', nextValues.proxyUrl === baselineValues.proxyUrl);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'forceModelPrefix')) {
|
||||
updateDirty(
|
||||
'forceModelPrefix',
|
||||
nextValues.forceModelPrefix === baselineValues.forceModelPrefix
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'requestRetry')) {
|
||||
updateDirty('requestRetry', nextValues.requestRetry === baselineValues.requestRetry);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryCredentials')) {
|
||||
updateDirty(
|
||||
'maxRetryCredentials',
|
||||
nextValues.maxRetryCredentials === baselineValues.maxRetryCredentials
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryInterval')) {
|
||||
updateDirty(
|
||||
'maxRetryInterval',
|
||||
nextValues.maxRetryInterval === baselineValues.maxRetryInterval
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'wsAuth')) {
|
||||
updateDirty('wsAuth', nextValues.wsAuth === baselineValues.wsAuth);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchProject')) {
|
||||
updateDirty(
|
||||
'quotaSwitchProject',
|
||||
nextValues.quotaSwitchProject === baselineValues.quotaSwitchProject
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchPreviewModel')) {
|
||||
updateDirty(
|
||||
'quotaSwitchPreviewModel',
|
||||
nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
|
||||
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
|
||||
updateDirty(
|
||||
'payloadDefaultRules',
|
||||
arePayloadRulesEqual(nextValues.payloadDefaultRules, baselineValues.payloadDefaultRules)
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRawRules')) {
|
||||
updateDirty(
|
||||
'payloadDefaultRawRules',
|
||||
arePayloadRulesEqual(nextValues.payloadDefaultRawRules, baselineValues.payloadDefaultRawRules)
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRules')) {
|
||||
updateDirty(
|
||||
'payloadOverrideRules',
|
||||
arePayloadRulesEqual(nextValues.payloadOverrideRules, baselineValues.payloadOverrideRules)
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRawRules')) {
|
||||
updateDirty(
|
||||
'payloadOverrideRawRules',
|
||||
arePayloadRulesEqual(
|
||||
nextValues.payloadOverrideRawRules,
|
||||
baselineValues.payloadOverrideRawRules
|
||||
)
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadFilterRules')) {
|
||||
updateDirty(
|
||||
'payloadFilterRules',
|
||||
arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules)
|
||||
);
|
||||
}
|
||||
if (patch.streaming) {
|
||||
const streamingPatch = patch.streaming;
|
||||
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) {
|
||||
updateDirty(
|
||||
'streaming.keepaliveSeconds',
|
||||
nextValues.streaming.keepaliveSeconds === baselineValues.streaming.keepaliveSeconds
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'bootstrapRetries')) {
|
||||
updateDirty(
|
||||
'streaming.bootstrapRetries',
|
||||
nextValues.streaming.bootstrapRetries === baselineValues.streaming.bootstrapRetries
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'nonstreamKeepaliveInterval')) {
|
||||
updateDirty(
|
||||
'streaming.nonstreamKeepaliveInterval',
|
||||
nextValues.streaming.nonstreamKeepaliveInterval ===
|
||||
baselineValues.streaming.nonstreamKeepaliveInterval
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return nextDirtyFields;
|
||||
}
|
||||
|
||||
function visualConfigReducer(
|
||||
state: VisualConfigState,
|
||||
action: VisualConfigAction
|
||||
): VisualConfigState {
|
||||
switch (action.type) {
|
||||
case 'load_success':
|
||||
return {
|
||||
visualValues: action.values,
|
||||
baselineValues: deepClone(action.values),
|
||||
dirtyFields: new Set(),
|
||||
visualParseError: null,
|
||||
};
|
||||
case 'load_error':
|
||||
return {
|
||||
...state,
|
||||
visualParseError: action.error,
|
||||
};
|
||||
case 'set_values': {
|
||||
const nextValues = mergeVisualConfigValues(state.visualValues, action.values);
|
||||
const nextDirtyFields = getNextDirtyFields(
|
||||
state.dirtyFields,
|
||||
action.values,
|
||||
nextValues,
|
||||
state.baselineValues
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
visualValues: nextValues,
|
||||
dirtyFields: nextDirtyFields,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVisualConfig() {
|
||||
const [state, dispatch] = useReducer(
|
||||
visualConfigReducer,
|
||||
undefined,
|
||||
createInitialVisualConfigState
|
||||
);
|
||||
const { visualValues, visualParseError } = state;
|
||||
const visualDirty = state.dirtyFields.size > 0;
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
@@ -453,10 +772,6 @@ export function useVisualConfig() {
|
||||
]
|
||||
);
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||
}, [baselineValues, visualValues]);
|
||||
|
||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const document = parseDocument(yamlContent);
|
||||
@@ -483,14 +798,16 @@ export function useVisualConfig() {
|
||||
|
||||
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
|
||||
rmSecretKey:
|
||||
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
|
||||
typeof remoteManagement?.['secret-key'] === 'string'
|
||||
? remoteManagement['secret-key']
|
||||
: '',
|
||||
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
|
||||
rmPanelRepo:
|
||||
typeof remoteManagement?.['panel-github-repository'] === 'string'
|
||||
? remoteManagement['panel-github-repository']
|
||||
: typeof remoteManagement?.['panel-repo'] === 'string'
|
||||
? remoteManagement['panel-repo']
|
||||
: '',
|
||||
: '',
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: resolveApiKeysText(parsed),
|
||||
@@ -509,12 +826,9 @@ export function useVisualConfig() {
|
||||
wsAuth: Boolean(parsed['ws-auth']),
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(
|
||||
quotaExceeded?.['switch-preview-model'] ?? true
|
||||
),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
|
||||
routingStrategy:
|
||||
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
|
||||
@@ -529,13 +843,11 @@ export function useVisualConfig() {
|
||||
},
|
||||
};
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
setVisualParseError(null);
|
||||
dispatch({ type: 'load_success', values: newValues });
|
||||
return { ok: true as const };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid YAML';
|
||||
setVisualParseError(message);
|
||||
dispatch({ type: 'load_error', error: message });
|
||||
return { ok: false as const, error: message };
|
||||
}
|
||||
}, []);
|
||||
@@ -621,10 +933,7 @@ export function useVisualConfig() {
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'switch-preview-model'],
|
||||
values.quotaSwitchPreviewModel
|
||||
);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
@@ -635,9 +944,13 @@ export function useVisualConfig() {
|
||||
}
|
||||
|
||||
const keepaliveSeconds =
|
||||
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
|
||||
typeof values.streaming?.keepaliveSeconds === 'string'
|
||||
? values.streaming.keepaliveSeconds
|
||||
: '';
|
||||
const bootstrapRetries =
|
||||
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
|
||||
typeof values.streaming?.bootstrapRetries === 'string'
|
||||
? values.streaming.bootstrapRetries
|
||||
: '';
|
||||
const nonstreamKeepaliveInterval =
|
||||
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
|
||||
? values.streaming.nonstreamKeepaliveInterval
|
||||
@@ -652,11 +965,7 @@ export function useVisualConfig() {
|
||||
deleteIfMapEmpty(doc, ['streaming']);
|
||||
}
|
||||
|
||||
setIntFromStringInDoc(
|
||||
doc,
|
||||
['nonstream-keepalive-interval'],
|
||||
nonstreamKeepaliveInterval
|
||||
);
|
||||
setIntFromStringInDoc(doc, ['nonstream-keepalive-interval'], nonstreamKeepaliveInterval);
|
||||
|
||||
if (
|
||||
docHas(doc, ['payload']) ||
|
||||
@@ -719,13 +1028,7 @@ export function useVisualConfig() {
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
setVisualValuesState((prev) => {
|
||||
const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues;
|
||||
if (newValues.streaming) {
|
||||
next.streaming = { ...prev.streaming, ...newValues.streaming };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
dispatch({ type: 'set_values', values: newValues });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -597,6 +597,12 @@
|
||||
"note_placeholder": "Enter a note, e.g.: John's account",
|
||||
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
|
||||
"note_display": "Note",
|
||||
"headers_label": "Custom Headers (headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
|
||||
"headers_invalid_json": "Custom headers must be valid JSON.",
|
||||
"headers_invalid_object": "Custom headers must be a JSON object.",
|
||||
"headers_invalid_value": "Each custom header value must be a string.",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
|
||||
@@ -597,6 +597,12 @@
|
||||
"note_placeholder": "输入备注信息,例如:张三的账号",
|
||||
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
|
||||
"note_display": "备注",
|
||||
"headers_label": "自定义请求头(headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
|
||||
"headers_invalid_json": "自定义请求头必须是有效的 JSON。",
|
||||
"headers_invalid_object": "自定义请求头必须是 JSON 对象。",
|
||||
"headers_invalid_value": "每个自定义请求头的值都必须是字符串。",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,8 +9,10 @@ import type { ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ClaudeEditBaseline } from '@/stores/useClaudeEditDraftStore';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
@@ -89,19 +91,28 @@ const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
|
||||
};
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
const buildClaudeBaseline = (form: ProviderFormState): ClaudeEditBaseline => ({
|
||||
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),
|
||||
});
|
||||
|
||||
const areCloakConfigsEqual = (left: ClaudeEditBaseline['cloak'], right: ClaudeEditBaseline['cloak']) => {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
if (left.mode !== right.mode || left.strictMode !== right.strictMode) return false;
|
||||
if (left.sensitiveWords === null || right.sensitiveWords === null) {
|
||||
return left.sensitiveWords === right.sensitiveWords;
|
||||
}
|
||||
return areStringArraysEqual(left.sensitiveWords, right.sensitiveWords);
|
||||
};
|
||||
|
||||
export function AiProvidersClaudeEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
@@ -137,7 +148,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
const acquireDraft = useClaudeEditDraftStore((state) => state.acquireDraft);
|
||||
const releaseDraft = useClaudeEditDraftStore((state) => state.releaseDraft);
|
||||
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
|
||||
const setDraftBaselineSignature = useClaudeEditDraftStore((state) => state.setDraftBaselineSignature);
|
||||
const setDraftBaseline = useClaudeEditDraftStore((state) => state.setDraftBaseline);
|
||||
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
|
||||
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
|
||||
@@ -241,9 +252,9 @@ export function AiProvidersClaudeEditLayout() {
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
};
|
||||
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const baselineSignature = buildClaudeSignature(seededForm);
|
||||
const baseline = buildClaudeBaseline(seededForm);
|
||||
initDraft(draftKey, {
|
||||
baselineSignature,
|
||||
baseline,
|
||||
form: seededForm,
|
||||
testModel: available[0] || '',
|
||||
testStatus: 'idle',
|
||||
@@ -254,7 +265,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
|
||||
const emptyForm = buildEmptyForm();
|
||||
initDraft(draftKey, {
|
||||
baselineSignature: buildClaudeSignature(emptyForm),
|
||||
baseline: buildClaudeBaseline(emptyForm),
|
||||
form: emptyForm,
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
@@ -263,9 +274,50 @@ export function AiProvidersClaudeEditLayout() {
|
||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
|
||||
const baselineSignature = draft?.baselineSignature ?? '';
|
||||
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
|
||||
const baseline = draft?.baseline ?? null;
|
||||
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
|
||||
const normalizedModels = useMemo(
|
||||
() => normalizeClaudeModelEntries(form.modelEntries),
|
||||
[form.modelEntries]
|
||||
);
|
||||
const normalizedExcludedModels = useMemo(
|
||||
() => parseExcludedModels(form.excludedText ?? ''),
|
||||
[form.excludedText]
|
||||
);
|
||||
const normalizedCloak = useMemo(() => normalizeCloakConfig(form.cloak), [form.cloak]);
|
||||
const normalizedPriority = useMemo(() => {
|
||||
return form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null;
|
||||
}, [form.priority]);
|
||||
const isHeadersDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
|
||||
}, [baseline, normalizedHeaders]);
|
||||
const isModelsDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areModelEntriesEqual(baseline.models, normalizedModels);
|
||||
}, [baseline, normalizedModels]);
|
||||
const isExcludedModelsDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels);
|
||||
}, [baseline, normalizedExcludedModels]);
|
||||
const isCloakDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areCloakConfigsEqual(baseline.cloak, normalizedCloak);
|
||||
}, [baseline, normalizedCloak]);
|
||||
const isDirty =
|
||||
Boolean(draft?.initialized) &&
|
||||
baseline !== null &&
|
||||
(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 ||
|
||||
isCloakDirty);
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/claude/${params.index ?? ''}`;
|
||||
@@ -384,7 +436,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setDraftBaselineSignature(draftKey, buildClaudeSignature(form));
|
||||
setDraftBaseline(draftKey, buildClaudeBaseline(form));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||
@@ -403,7 +455,7 @@ export function AiProvidersClaudeEditLayout() {
|
||||
invalidIndex,
|
||||
invalidIndexParam,
|
||||
resolvedLoading,
|
||||
setDraftBaselineSignature,
|
||||
setDraftBaseline,
|
||||
saving,
|
||||
showNotification,
|
||||
t,
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -9,9 +9,10 @@ import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputList
|
||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import { areKeyValueEntriesEqual, areModelEntriesEqual } from '@/utils/compare';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
import type { KeyTestStatus, OpenAIEditBaseline } from '@/stores/useOpenAIEditDraftStore';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
@@ -103,18 +104,33 @@ const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
|
||||
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(),
|
||||
});
|
||||
const buildOpenAIBaseline = (form: OpenAIFormState, testModel: string): OpenAIEditBaseline => ({
|
||||
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(),
|
||||
});
|
||||
|
||||
const areNormalizedApiKeyEntriesEqual = (
|
||||
a: OpenAIEditBaseline['apiKeyEntries'],
|
||||
b: ReturnType<typeof normalizeApiKeyEntries>
|
||||
) => {
|
||||
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.apiKey !== right.apiKey || left.proxyUrl !== right.proxyUrl) return false;
|
||||
if (!areKeyValueEntriesEqual(left.headers, right.headers)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export function AiProvidersOpenAIEditLayout() {
|
||||
const { t } = useTranslation();
|
||||
@@ -152,7 +168,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
const acquireDraft = useOpenAIEditDraftStore((state) => state.acquireDraft);
|
||||
const releaseDraft = useOpenAIEditDraftStore((state) => state.releaseDraft);
|
||||
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
|
||||
const setDraftBaselineSignature = useOpenAIEditDraftStore((state) => state.setDraftBaselineSignature);
|
||||
const setDraftBaseline = useOpenAIEditDraftStore((state) => state.setDraftBaseline);
|
||||
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
|
||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||
@@ -286,9 +302,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
const baselineSignature = buildOpenAISignature(seededForm, initialTestModel);
|
||||
const baseline = buildOpenAIBaseline(seededForm, initialTestModel);
|
||||
initDraft(draftKey, {
|
||||
baselineSignature,
|
||||
baseline,
|
||||
form: seededForm,
|
||||
testModel: initialTestModel,
|
||||
testStatus: 'idle',
|
||||
@@ -298,7 +314,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
} else {
|
||||
const emptyForm = buildEmptyForm();
|
||||
initDraft(draftKey, {
|
||||
baselineSignature: buildOpenAISignature(emptyForm, ''),
|
||||
baseline: buildOpenAIBaseline(emptyForm, ''),
|
||||
form: emptyForm,
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
@@ -362,9 +378,45 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
|
||||
const baselineSignature = draft?.baselineSignature ?? '';
|
||||
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
|
||||
const baseline = draft?.baseline ?? null;
|
||||
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
|
||||
const normalizedModels = useMemo(
|
||||
() => normalizeModelEntries(form.modelEntries),
|
||||
[form.modelEntries]
|
||||
);
|
||||
const normalizedApiKeyEntries = useMemo(
|
||||
() => normalizeApiKeyEntries(form.apiKeyEntries),
|
||||
[form.apiKeyEntries]
|
||||
);
|
||||
const normalizedPriority = useMemo(() => {
|
||||
return form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null;
|
||||
}, [form.priority]);
|
||||
const normalizedTestModel = useMemo(() => String(testModel ?? '').trim(), [testModel]);
|
||||
const isHeadersDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
|
||||
}, [baseline, normalizedHeaders]);
|
||||
const isModelsDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areModelEntriesEqual(baseline.models, normalizedModels);
|
||||
}, [baseline, normalizedModels]);
|
||||
const isApiKeyEntriesDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return !areNormalizedApiKeyEntriesEqual(baseline.apiKeyEntries, normalizedApiKeyEntries);
|
||||
}, [baseline, normalizedApiKeyEntries]);
|
||||
const isDirty =
|
||||
Boolean(draft?.initialized) &&
|
||||
baseline !== null &&
|
||||
(baseline.name !== form.name.trim() ||
|
||||
baseline.priority !== normalizedPriority ||
|
||||
baseline.prefix !== form.prefix.trim() ||
|
||||
baseline.baseUrl !== form.baseUrl.trim() ||
|
||||
baseline.testModel !== normalizedTestModel ||
|
||||
isHeadersDirty ||
|
||||
isApiKeyEntriesDirty ||
|
||||
isModelsDirty);
|
||||
const editorRootPath = useMemo(() => {
|
||||
if (hasIndexParam) {
|
||||
return `/ai-providers/openai/${params.index ?? ''}`;
|
||||
@@ -445,7 +497,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
setDraftBaselineSignature(draftKey, buildOpenAISignature(form, testModel));
|
||||
setDraftBaseline(draftKey, buildOpenAIBaseline(form, testModel));
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||
@@ -460,7 +512,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
form,
|
||||
handleBack,
|
||||
providers,
|
||||
setDraftBaselineSignature,
|
||||
setDraftBaseline,
|
||||
showNotification,
|
||||
t,
|
||||
testModel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
withDisableAllModelsRule,
|
||||
withoutDisableAllModelsRule,
|
||||
} from '@/components/providers/utils';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
@@ -59,7 +61,16 @@ export function AiProvidersPage() {
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isSwitching = Boolean(configSwitchingKey);
|
||||
|
||||
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
|
||||
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats({
|
||||
enabled: isCurrentLayer,
|
||||
});
|
||||
const usageDetailsBySource = useMemo(
|
||||
() => indexUsageDetailsBySource(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
@@ -113,8 +124,12 @@ export function AiProvidersPage() {
|
||||
if (hasMounted.current) return;
|
||||
hasMounted.current = true;
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentLayer) return;
|
||||
void loadKeyStats().catch(() => {});
|
||||
}, [loadConfigs, loadKeyStats]);
|
||||
}, [isCurrentLayer, loadKeyStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||
@@ -130,7 +145,7 @@ export function AiProvidersPage() {
|
||||
config?.openaiCompatibility,
|
||||
]);
|
||||
|
||||
useHeaderRefresh(refreshKeyStats);
|
||||
useHeaderRefresh(refreshKeyStats, isCurrentLayer);
|
||||
|
||||
const openEditor = useCallback(
|
||||
(path: string) => {
|
||||
@@ -149,7 +164,7 @@ export function AiProvidersPage() {
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
await providersApi.deleteGeminiKey(entry.apiKey, entry.baseUrl);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
setGeminiKeys(next);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
@@ -282,14 +297,14 @@ export function AiProvidersPage() {
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
await providersApi.deleteCodexConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
@@ -314,7 +329,7 @@ export function AiProvidersPage() {
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
await providersApi.deleteVertexConfig(entry.apiKey, entry.baseUrl);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
updateConfigValue('vertex-api-key', next);
|
||||
@@ -362,7 +377,7 @@ export function AiProvidersPage() {
|
||||
<GeminiSection
|
||||
configs={geminiKeys}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -377,7 +392,7 @@ export function AiProvidersPage() {
|
||||
<CodexSection
|
||||
configs={codexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -392,7 +407,7 @@ export function AiProvidersPage() {
|
||||
<ClaudeSection
|
||||
configs={claudeConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -407,7 +422,7 @@ export function AiProvidersPage() {
|
||||
<VertexSection
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -432,7 +447,7 @@ export function AiProvidersPage() {
|
||||
<OpenAISection
|
||||
configs={openaiProviders}
|
||||
keyStats={keyStats}
|
||||
usageDetails={usageDetails}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -659,8 +659,9 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 85%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
--glass-blur: 8px;
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
@@ -1383,6 +1384,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prefixProxyTextareaInvalid {
|
||||
border-color: var(--danger-color);
|
||||
box-shadow: 0 0 0 3px rgba($error-color, 0.12);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1529,8 +1535,9 @@
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
--glass-blur: 12px;
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
|
||||
@@ -422,10 +422,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
--glass-blur: 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-width: inherit;
|
||||
|
||||
+16
-44
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { parse as parseYaml, parseDocument } from 'yaml';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -26,6 +23,8 @@ import styles from './ConfigPage.module.scss';
|
||||
|
||||
type ConfigEditorTab = 'visual' | 'source';
|
||||
|
||||
const LazyConfigSourceEditor = lazy(() => import('@/components/config/ConfigSourceEditor'));
|
||||
|
||||
function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
try {
|
||||
const parsed = parseYaml(yamlContent);
|
||||
@@ -39,7 +38,7 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
@@ -79,7 +78,7 @@ export function ConfigPage() {
|
||||
total: 0,
|
||||
});
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
|
||||
const floatingActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
@@ -405,12 +404,6 @@ export function ConfigPage() {
|
||||
};
|
||||
}, [shouldRenderFloatingActions]);
|
||||
|
||||
// CodeMirror extensions
|
||||
const extensions = useMemo(
|
||||
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
|
||||
[]
|
||||
);
|
||||
|
||||
// Status text
|
||||
const getStatusText = () => {
|
||||
if (disableControls) return t('config_management.status_disconnected');
|
||||
@@ -630,37 +623,16 @@ export function ConfigPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.editorWrapper}>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<LazyConfigSourceEditor
|
||||
editorRef={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,14 +72,15 @@
|
||||
gap: $spacing-lg;
|
||||
padding: $spacing-2xl $spacing-xl;
|
||||
border-radius: $radius-lg;
|
||||
--glass-blur: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--bg-primary) 92%, transparent),
|
||||
color-mix(in srgb, var(--bg-secondary) 80%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
overflow: hidden;
|
||||
animation: heroEnter 0.6s ease-out both;
|
||||
|
||||
@@ -212,10 +213,11 @@
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: $radius-full;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
--glass-blur: 8px;
|
||||
background: var(--glass-bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -469,7 +469,8 @@
|
||||
rgba(255, 237, 158, 0.58) 32%,
|
||||
rgba(255, 215, 91, 0) 72%
|
||||
);
|
||||
filter: blur(9px);
|
||||
--glass-blur: 9px;
|
||||
filter: var(--glass-filter);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
|
||||
@@ -78,9 +78,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
--glass-blur: 6px;
|
||||
background: var(--glass-bg-secondary);
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
}
|
||||
|
||||
.loadingOverlayContent {
|
||||
|
||||
@@ -28,6 +28,13 @@ const extractArrayPayload = (data: unknown, key: string): unknown[] => {
|
||||
return Array.isArray(candidate) ? candidate : [];
|
||||
};
|
||||
|
||||
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('api-key', apiKey.trim());
|
||||
params.set('base-url', (baseUrl ?? '').trim());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
Array.isArray(models)
|
||||
? models
|
||||
@@ -160,8 +167,8 @@ export const providersApi = {
|
||||
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
|
||||
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
|
||||
|
||||
deleteGeminiKey: (apiKey: string) =>
|
||||
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/codex-api-key');
|
||||
@@ -175,8 +182,8 @@ export const providersApi = {
|
||||
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
|
||||
|
||||
deleteCodexConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteCodexConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/claude-api-key');
|
||||
@@ -190,8 +197,8 @@ export const providersApi = {
|
||||
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
|
||||
|
||||
deleteClaudeConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteClaudeConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/vertex-api-key');
|
||||
@@ -205,8 +212,8 @@ export const providersApi = {
|
||||
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
|
||||
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
|
||||
|
||||
deleteVertexConfig: (apiKey: string) =>
|
||||
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
|
||||
deleteVertexConfig: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
||||
const data = await apiClient.get('/openai-compatibility');
|
||||
|
||||
@@ -14,9 +14,27 @@ import type { ProviderFormState } from '@/components/providers/types';
|
||||
|
||||
export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export type ClaudeCloakBaseline = {
|
||||
mode: string;
|
||||
strictMode: boolean;
|
||||
sensitiveWords: string[] | null;
|
||||
} | null;
|
||||
|
||||
export type ClaudeEditBaseline = {
|
||||
apiKey: string;
|
||||
priority: number | null;
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
proxyUrl: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
models: Array<{ name: string; alias: string }>;
|
||||
excludedModels: string[];
|
||||
cloak: ClaudeCloakBaseline;
|
||||
};
|
||||
|
||||
type ClaudeEditDraft = {
|
||||
initialized: boolean;
|
||||
baselineSignature: string;
|
||||
baseline: ClaudeEditBaseline | null;
|
||||
form: ProviderFormState;
|
||||
testModel: string;
|
||||
testStatus: ClaudeTestStatus;
|
||||
@@ -33,7 +51,7 @@ interface ClaudeEditDraftState {
|
||||
key: string,
|
||||
draft: Omit<ClaudeEditDraft, 'initialized'>
|
||||
) => void;
|
||||
setDraftBaselineSignature: (key: string, signature: string) => void;
|
||||
setDraftBaseline: (key: string, baseline: ClaudeEditBaseline) => void;
|
||||
setDraftForm: (
|
||||
key: string,
|
||||
action: SetStateAction<ProviderFormState>
|
||||
@@ -64,7 +82,7 @@ const buildEmptyForm = (): ProviderFormState => ({
|
||||
|
||||
const buildEmptyDraft = (): ClaudeEditDraft => ({
|
||||
initialized: false,
|
||||
baselineSignature: '',
|
||||
baseline: null,
|
||||
form: buildEmptyForm(),
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
@@ -124,14 +142,14 @@ export const useClaudeEditDraftStore = create<ClaudeEditDraftState>((set, get) =
|
||||
}));
|
||||
},
|
||||
|
||||
setDraftBaselineSignature: (key, signature) => {
|
||||
setDraftBaseline: (key, baseline) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, baselineSignature: signature },
|
||||
[key]: { ...existing, initialized: true, baseline },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -20,9 +20,24 @@ export type KeyTestStatus = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OpenAIEditBaseline = {
|
||||
name: string;
|
||||
priority: number | null;
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
apiKeyEntries: Array<{
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}>;
|
||||
models: Array<{ name: string; alias: string }>;
|
||||
testModel: string;
|
||||
};
|
||||
|
||||
export type OpenAIEditDraft = {
|
||||
initialized: boolean;
|
||||
baselineSignature: string;
|
||||
baseline: OpenAIEditBaseline | null;
|
||||
form: OpenAIFormState;
|
||||
testModel: string;
|
||||
testStatus: OpenAITestStatus;
|
||||
@@ -37,7 +52,7 @@ interface OpenAIEditDraftState {
|
||||
releaseDraft: (key: string) => void;
|
||||
ensureDraft: (key: string) => void;
|
||||
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
|
||||
setDraftBaselineSignature: (key: string, signature: string) => void;
|
||||
setDraftBaseline: (key: string, baseline: OpenAIEditBaseline) => void;
|
||||
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
|
||||
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
||||
@@ -62,7 +77,7 @@ const buildEmptyForm = (): OpenAIFormState => ({
|
||||
|
||||
const buildEmptyDraft = (): OpenAIEditDraft => ({
|
||||
initialized: false,
|
||||
baselineSignature: '',
|
||||
baseline: null,
|
||||
form: buildEmptyForm(),
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
@@ -123,14 +138,14 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
|
||||
}));
|
||||
},
|
||||
|
||||
setDraftBaselineSignature: (key, signature) => {
|
||||
setDraftBaseline: (key, baseline) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[key] ?? buildEmptyDraft();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: { ...existing, initialized: true, baselineSignature: signature },
|
||||
[key]: { ...existing, initialized: true, baseline },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
|
||||
--radius-md: 8px;
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
|
||||
// Glass effect (blurred surfaces)
|
||||
--glass-blur: 12px;
|
||||
--glass-backdrop-filter: blur(var(--glass-blur));
|
||||
--glass-filter: blur(var(--glass-blur));
|
||||
--glass-bg: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
--glass-bg-secondary: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
--glass-border: color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
}
|
||||
|
||||
// 纯白主题
|
||||
@@ -173,4 +181,16 @@
|
||||
|
||||
--radius-md: 8px;
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
|
||||
--glass-border: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px), (prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce) {
|
||||
:root {
|
||||
--glass-backdrop-filter: none;
|
||||
--glass-filter: none;
|
||||
--glass-bg: var(--bg-primary);
|
||||
--glass-bg-secondary: var(--bg-secondary);
|
||||
--glass-border: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { UsageDetail } from '@/utils/usage';
|
||||
|
||||
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
|
||||
|
||||
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
|
||||
|
||||
export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDetailsBySource {
|
||||
const map: UsageDetailsBySource = new Map();
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const sourceId = detail.source;
|
||||
if (!sourceId) return;
|
||||
|
||||
const bucket = map.get(sourceId);
|
||||
if (bucket) {
|
||||
bucket.push(detail);
|
||||
} else {
|
||||
map.set(sourceId, [detail]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForCandidates(
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
candidates: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const details = usageDetailsBySource.get(candidate);
|
||||
if (!details || details.length === 0) continue;
|
||||
|
||||
if (!firstDetails) {
|
||||
firstDetails = details;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!merged) {
|
||||
merged = [...firstDetails];
|
||||
}
|
||||
for (const detail of details) {
|
||||
merged.push(detail);
|
||||
}
|
||||
}
|
||||
|
||||
return merged ?? firstDetails ?? EMPTY_USAGE_DETAILS;
|
||||
}
|
||||
Reference in New Issue
Block a user