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
7 Commits
@@ -35,6 +35,7 @@ import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
import type { Theme } from '@/types';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
@@ -117,6 +118,12 @@ const headerIcons = {
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
whiteTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
autoTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<defs>
|
||||
@@ -145,6 +152,39 @@ const headerIcons = {
|
||||
),
|
||||
};
|
||||
|
||||
const THEME_CARDS: Array<{
|
||||
key: Theme;
|
||||
labelKey: string;
|
||||
colors: { bg: string; card: string; border: string; text: string; textMuted: string };
|
||||
}> = [
|
||||
{
|
||||
key: 'auto',
|
||||
labelKey: 'theme.auto',
|
||||
colors: {
|
||||
bg: 'linear-gradient(135deg, #faf9f5 0 50%, #151412 50% 100%)',
|
||||
card: 'linear-gradient(135deg, #f0eee8 0 50%, #1d1b18 50% 100%)',
|
||||
border: '#bdb6ae',
|
||||
text: '#2d2a26',
|
||||
textMuted: 'linear-gradient(135deg, #a29c95 0 50%, #9c958d 50% 100%)',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'white',
|
||||
labelKey: 'theme.white',
|
||||
colors: { bg: '#ffffff', card: '#ffffff', border: '#e5e5e5', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
},
|
||||
{
|
||||
key: 'light',
|
||||
labelKey: 'theme.light',
|
||||
colors: { bg: '#faf9f5', card: '#f0eee8', border: '#e3e1db', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
labelKey: 'theme.dark',
|
||||
colors: { bg: '#151412', card: '#1d1b18', border: '#3a3530', text: '#f6f4f1', textMuted: '#9c958d' },
|
||||
},
|
||||
];
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
@@ -186,7 +226,7 @@ export function MainLayout() {
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
@@ -194,9 +234,11 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -304,6 +346,32 @@ export function MainLayout() {
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
@@ -319,8 +387,22 @@ export function MainLayout() {
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
setThemeMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleThemeMenu = useCallback(() => {
|
||||
setThemeMenuOpen((prev) => !prev);
|
||||
setLanguageMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
setTheme(nextTheme);
|
||||
setThemeMenuOpen(false);
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
@@ -566,13 +648,72 @@ export function MainLayout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div className="notification entering theme-menu-popover" role="menu" aria-label={t('theme.switch')}>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{ background: tc.colors.bg, border: `1px solid ${tc.colors.border}` }}
|
||||
>
|
||||
<div
|
||||
className="theme-card-header"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
|
||||
@@ -71,6 +71,10 @@ export function AmpcodeSection({
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_keys_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.upstreamApiKeys?.length || 0}</span>
|
||||
</div>
|
||||
{config?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||
|
||||
@@ -87,6 +87,7 @@ export function VertexSection({
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
@@ -140,6 +141,20 @@ export function VertexSection({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
|
||||
@@ -18,11 +18,17 @@ export interface OpenAIFormState {
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyEntry {
|
||||
upstreamApiKey: string;
|
||||
clientApiKeysText: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
upstreamApiKeyEntries: AmpcodeUpstreamApiKeyEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers' | 'models'> & {
|
||||
@@ -37,9 +43,10 @@ export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export interface ProviderSectionProps<TConfig> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
|
||||
@@ -168,9 +168,43 @@ export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMap
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const ampcodeUpstreamApiKeysToEntries = (
|
||||
mappings?: AmpcodeUpstreamApiKeyMapping[]
|
||||
): AmpcodeUpstreamApiKeyEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ upstreamApiKey: '', clientApiKeysText: '' }];
|
||||
}
|
||||
|
||||
return mappings.map((mapping) => ({
|
||||
upstreamApiKey: mapping.upstreamApiKey ?? '',
|
||||
clientApiKeysText: Array.isArray(mapping.apiKeys) ? mapping.apiKeys.join('\n') : '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToAmpcodeUpstreamApiKeys = (
|
||||
entries: AmpcodeUpstreamApiKeyEntry[]
|
||||
): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const upstreamApiKey = String(entry?.upstreamApiKey ?? '').trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const apiKeys = Array.from(new Set(parseTextList(String(entry?.clientApiKeysText ?? ''))));
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||
upstreamApiKeyEntries: ampcodeUpstreamApiKeysToEntries(ampcode?.upstreamApiKeys),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
ClaudeExtraUsage,
|
||||
ClaudeProfileResponse,
|
||||
ClaudeQuotaState,
|
||||
ClaudeQuotaWindow,
|
||||
ClaudeUsagePayload,
|
||||
@@ -29,6 +30,7 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CLAUDE_PROFILE_URL,
|
||||
CLAUDE_USAGE_URL,
|
||||
CLAUDE_REQUEST_HEADERS,
|
||||
CLAUDE_USAGE_WINDOW_KEYS,
|
||||
@@ -673,22 +675,69 @@ const buildClaudeQuotaWindows = (
|
||||
return windows;
|
||||
};
|
||||
|
||||
const CLAUDE_PLAN_TYPE_MAP: Record<string, string> = {
|
||||
default_claude_max_5x: 'plan_max5',
|
||||
default_claude_max_20x: 'plan_max20',
|
||||
default_claude_pro: 'plan_pro',
|
||||
default_claude_ai: 'plan_free',
|
||||
};
|
||||
|
||||
const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as ClaudeProfileResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as ClaudeProfileResponse;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
|
||||
if (!profile) return null;
|
||||
|
||||
const tier = normalizeStringValue(profile.organization?.rate_limit_tier);
|
||||
if (!tier) return null;
|
||||
|
||||
return CLAUDE_PLAN_TYPE_MAP[tier] ?? 'plan_unknown';
|
||||
};
|
||||
|
||||
const fetchClaudeQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('claude_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
});
|
||||
const [usageResult, profileResult] = await Promise.allSettled([
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_PROFILE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (usageResult.status === 'rejected') {
|
||||
throw usageResult.reason;
|
||||
}
|
||||
|
||||
const result = usageResult.value;
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
@@ -700,7 +749,16 @@ const fetchClaudeQuota = async (
|
||||
}
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload, t);
|
||||
return { windows, extraUsage: payload.extra_usage };
|
||||
const planType =
|
||||
profileResult.status === 'fulfilled' &&
|
||||
profileResult.value.statusCode >= 200 &&
|
||||
profileResult.value.statusCode < 300
|
||||
? resolveClaudePlanType(
|
||||
parseClaudeProfilePayload(profileResult.value.body ?? profileResult.value.bodyText)
|
||||
)
|
||||
: null;
|
||||
|
||||
return { windows, extraUsage: payload.extra_usage, planType };
|
||||
};
|
||||
|
||||
const renderClaudeItems = (
|
||||
@@ -712,8 +770,20 @@ const renderClaudeItems = (
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const extraUsage = quota.extraUsage ?? null;
|
||||
const planType = quota.planType ?? null;
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planType) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, t(`claude_quota.${planType}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (extraUsage && extraUsage.is_enabled) {
|
||||
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
|
||||
nodes.push(
|
||||
@@ -765,7 +835,7 @@ const renderClaudeItems = (
|
||||
|
||||
export const CLAUDE_CONFIG: QuotaConfig<
|
||||
ClaudeQuotaState,
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }
|
||||
> = {
|
||||
type: 'claude',
|
||||
i18nPrefix: 'claude_quota',
|
||||
@@ -779,6 +849,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
extraUsage: data.extraUsage,
|
||||
planType: data.planType,
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
|
||||
@@ -91,6 +91,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
? styles.antigravityCard
|
||||
: quotaType === 'claude'
|
||||
? styles.claudeCard
|
||||
: quotaType === 'codex'
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
|
||||
import {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG,
|
||||
KIMI_CONFIG
|
||||
} from '@/components/quota';
|
||||
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
@@ -17,6 +23,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
|
||||
|
||||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||
if (type === 'claude') return CLAUDE_CONFIG;
|
||||
if (type === 'codex') return CODEX_CONFIG;
|
||||
if (type === 'kimi') return KIMI_CONFIG;
|
||||
return GEMINI_CLI_CONFIG;
|
||||
@@ -35,6 +42,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const quota = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
|
||||
return state.geminiCliQuota[file.name] as QuotaState;
|
||||
@@ -42,6 +50,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const updateQuotaState = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'claude') return state.setClaudeQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
|
||||
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||
|
||||
@@ -12,9 +12,15 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
|
||||
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
|
||||
'antigravity',
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'kimi'
|
||||
]);
|
||||
|
||||
export const MIN_CARD_PAGE_SIZE = 3;
|
||||
export const MAX_CARD_PAGE_SIZE = 30;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import { authFilesApi, isAuthFileInvalidJsonObjectError } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
@@ -60,6 +60,16 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectionCount = selectedFiles.size;
|
||||
|
||||
const resolveStatusUpdateErrorMessage = useCallback(
|
||||
(err: unknown) => {
|
||||
if (isAuthFileInvalidJsonObjectError(err)) {
|
||||
return t('auth_files.prefix_proxy_invalid_json');
|
||||
}
|
||||
return err instanceof Error ? err.message : '';
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((name: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -337,10 +347,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||||
|
||||
try {
|
||||
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
|
||||
);
|
||||
await authFilesApi.setStatus(name, nextDisabled);
|
||||
await loadFiles();
|
||||
void refreshKeyStats().catch(() => {});
|
||||
showNotification(
|
||||
enabled
|
||||
? t('auth_files.status_enabled_success', { name })
|
||||
@@ -348,7 +357,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
'success'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
const errorMessage = resolveStatusUpdateErrorMessage(err);
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||||
);
|
||||
@@ -362,7 +371,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
});
|
||||
}
|
||||
},
|
||||
[showNotification, t]
|
||||
[loadFiles, refreshKeyStats, resolveStatusUpdateErrorMessage, showNotification, t]
|
||||
);
|
||||
|
||||
const batchSetStatus = useCallback(
|
||||
@@ -372,6 +381,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const targetNames = new Set(uniqueNames);
|
||||
const nextDisabled = !enabled;
|
||||
const previousDisabled = new Map(
|
||||
files.map((file) => [file.name, file.disabled === true] as const)
|
||||
);
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
@@ -385,31 +397,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failedNames = new Set<string>();
|
||||
const confirmedDisabled = new Map<string, boolean>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const name = uniqueNames[index];
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
confirmedDisabled.set(name, result.value.disabled);
|
||||
} else {
|
||||
failCount++;
|
||||
failedNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => {
|
||||
if (failedNames.has(file.name)) {
|
||||
return { ...file, disabled: !nextDisabled };
|
||||
}
|
||||
if (confirmedDisabled.has(file.name)) {
|
||||
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
if (successCount > 0) {
|
||||
await loadFiles();
|
||||
void refreshKeyStats().catch(() => {});
|
||||
} else {
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => {
|
||||
if (!targetNames.has(file.name)) return file;
|
||||
return { ...file, disabled: previousDisabled.get(file.name) === true };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||
@@ -422,7 +429,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
deselectAll();
|
||||
},
|
||||
[deselectAll, showNotification, t]
|
||||
[deselectAll, files, loadFiles, refreshKeyStats, showNotification, t]
|
||||
);
|
||||
|
||||
const batchDelete = useCallback(
|
||||
|
||||
@@ -260,8 +260,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
});
|
||||
|
||||
try {
|
||||
const file = new File([payload], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
await authFilesApi.saveText(name, payload);
|
||||
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||
await loadFiles();
|
||||
await loadKeyStats();
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||
"ampcode_upstream_api_keys_label": "Multi-upstream API key routing",
|
||||
"ampcode_upstream_api_keys_hint": "Bind different Amp upstream API keys to specific client API keys. Client keys can be separated by commas or new lines.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Add upstream mapping",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API key (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Client API keys, separated by commas or new lines",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream mapping #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Upstream mappings",
|
||||
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from model (source)",
|
||||
"ampcode_model_mappings_to_placeholder": "to model (target)",
|
||||
"ampcode_model_mappings_count": "Mappings Count",
|
||||
"ampcode_lists_overwrite_title": "Overwrite list settings",
|
||||
"ampcode_lists_overwrite_confirm": "Existing multi-upstream/model mapping lists could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"openai_title": "OpenAI Compatible Providers",
|
||||
"openai_add_button": "Add Provider",
|
||||
@@ -579,7 +588,13 @@
|
||||
"seven_day_sonnet": "7-day Sonnet",
|
||||
"seven_day_cowork": "7-day Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Extra Usage"
|
||||
"extra_usage_label": "Extra Usage",
|
||||
"plan_label": "Plan",
|
||||
"plan_unknown": "Unknown",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
@@ -1385,6 +1400,7 @@
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
"light": "Light",
|
||||
"white": "Pure White",
|
||||
"dark": "Dark",
|
||||
"switch_to_light": "Switch to light mode",
|
||||
"switch_to_dark": "Switch to dark mode",
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "Текущий официальный ключ Amp: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Очистить официальный ключ",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Очистить upstream API-ключ Ampcode (официальный Amp)?",
|
||||
"ampcode_upstream_api_keys_label": "Маршрутизация нескольких upstream API-ключей",
|
||||
"ampcode_upstream_api_keys_hint": "Привяжите разные upstream API-ключи Amp к указанным клиентским API-ключам. Клиентские ключи можно разделять запятыми или переводами строки.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Добавить upstream-сопоставление",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API-ключ (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Клиентские API-ключи, через запятую или с новой строки",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream-сопоставление #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Количество upstream-сопоставлений",
|
||||
"ampcode_force_model_mappings_label": "Принудительно применять сопоставления моделей",
|
||||
"ampcode_force_model_mappings_hint": "При включении сопоставления переопределяют локальные проверки доступности API-ключей.",
|
||||
"ampcode_model_mappings_label": "Сопоставления моделей (из → в)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "исходная модель",
|
||||
"ampcode_model_mappings_to_placeholder": "целевая модель",
|
||||
"ampcode_model_mappings_count": "Количество сопоставлений",
|
||||
"ampcode_lists_overwrite_title": "Перезаписать списки",
|
||||
"ampcode_lists_overwrite_confirm": "Существующие списки multi-upstream/сопоставлений моделей не удалось загрузить. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"ampcode_mappings_overwrite_confirm": "Не удалось загрузить существующие сопоставления. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"openai_title": "Совместимые с OpenAI провайдеры",
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
@@ -582,7 +591,13 @@
|
||||
"seven_day_sonnet": "7 дней Sonnet",
|
||||
"seven_day_cowork": "7 дней Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Дополнительное использование"
|
||||
"extra_usage_label": "Дополнительное использование",
|
||||
"plan_label": "План",
|
||||
"plan_unknown": "Неизвестно",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Квота Codex",
|
||||
@@ -1390,6 +1405,7 @@
|
||||
"theme": {
|
||||
"switch": "Тема",
|
||||
"light": "Светлая",
|
||||
"white": "Чисто-белая",
|
||||
"dark": "Тёмная",
|
||||
"switch_to_light": "Переключиться на светлую тему",
|
||||
"switch_to_dark": "Переключиться на тёмную тему",
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||
"ampcode_upstream_api_keys_label": "多上游 API Key 路由",
|
||||
"ampcode_upstream_api_keys_hint": "为指定客户端 API Key 绑定不同的 Amp 上游 API Key;客户端 key 可用逗号或换行分隔。",
|
||||
"ampcode_upstream_api_keys_add_btn": "添加多上游映射",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "上游 API Key(sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "客户端 API Keys,用逗号或换行分隔",
|
||||
"ampcode_upstream_api_keys_item_title": "上游映射 #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "多上游映射",
|
||||
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from 模型(原始)",
|
||||
"ampcode_model_mappings_to_placeholder": "to 模型(目标)",
|
||||
"ampcode_model_mappings_count": "映射数量",
|
||||
"ampcode_lists_overwrite_title": "覆盖列表配置",
|
||||
"ampcode_lists_overwrite_confirm": "当前未成功加载服务器已有多上游/模型映射配置,继续保存可能覆盖或清空这些列表,是否继续?",
|
||||
"ampcode_mappings_overwrite_confirm": "当前未成功加载服务器已有映射,继续保存可能覆盖或清空已有映射,是否继续?",
|
||||
"openai_title": "OpenAI 兼容提供商",
|
||||
"openai_add_button": "添加提供商",
|
||||
@@ -579,7 +588,13 @@
|
||||
"seven_day_sonnet": "7 天 Sonnet",
|
||||
"seven_day_cowork": "7 天 Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "额外用量"
|
||||
"extra_usage_label": "额外用量",
|
||||
"plan_label": "套餐",
|
||||
"plan_unknown": "未知",
|
||||
"plan_free": "免费版",
|
||||
"plan_pro": "专业版",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
@@ -1385,6 +1400,7 @@
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
"light": "亮色",
|
||||
"white": "纯白",
|
||||
"dark": "暗色",
|
||||
"switch_to_light": "切换到亮色模式",
|
||||
"switch_to_dark": "切换到暗色模式",
|
||||
|
||||
@@ -13,7 +13,11 @@ import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils';
|
||||
import {
|
||||
buildAmpcodeFormState,
|
||||
entriesToAmpcodeMappings,
|
||||
entriesToAmpcodeUpstreamApiKeys,
|
||||
} from '@/components/providers/utils';
|
||||
import type { AmpcodeFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -34,11 +38,18 @@ 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,
|
||||
}));
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -57,7 +68,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [modelMappingsDirty, setModelMappingsDirty] = useState(false);
|
||||
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
@@ -102,7 +114,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setModelMappingsDirty(false);
|
||||
setUpstreamApiKeysDirty(false);
|
||||
setError('');
|
||||
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
|
||||
setForm(initialForm);
|
||||
@@ -183,6 +196,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const upstreamApiKeys = entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries);
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
@@ -193,7 +207,15 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
await ampcodeApi.saveUpstreamApiKeys(upstreamApiKeys);
|
||||
} else {
|
||||
await ampcodeApi.deleteUpstreamApiKeys([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
@@ -207,23 +229,29 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
...previous,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
if (upstreamUrl) {
|
||||
next.upstreamUrl = upstreamUrl;
|
||||
} else {
|
||||
delete next.upstreamUrl;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
next.upstreamApiKeys = upstreamApiKeys;
|
||||
} else {
|
||||
delete next.upstreamApiKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
@@ -247,10 +275,10 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
if (!loaded && (modelMappingsDirty || upstreamApiKeysDirty)) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
title: t('ai_providers.ampcode_lists_overwrite_title'),
|
||||
message: t('ai_providers.ampcode_lists_overwrite_confirm'),
|
||||
variant: 'secondary',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
@@ -334,6 +362,98 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsHeader}>
|
||||
<label>{t('ai_providers.ampcode_upstream_api_keys_label')}</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: [
|
||||
...prev.upstreamApiKeyEntries,
|
||||
{ upstreamApiKey: '', clientApiKeysText: '' },
|
||||
],
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsList}>
|
||||
{(form.upstreamApiKeyEntries.length
|
||||
? form.upstreamApiKeyEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }]
|
||||
).map((entry, index, entries) => (
|
||||
<div key={index} className={layoutStyles.ampcodeUpstreamMappingCard}>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingCardTop}>
|
||||
<span className={layoutStyles.ampcodeUpstreamMappingTitle}>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_item_title', { index: index + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => {
|
||||
const nextEntries = prev.upstreamApiKeyEntries.filter((_, entryIndex) => entryIndex !== index);
|
||||
return {
|
||||
...prev,
|
||||
upstreamApiKeyEntries: nextEntries.length
|
||||
? nextEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }],
|
||||
};
|
||||
});
|
||||
}}
|
||||
disabled={loading || saving || disableControls || entries.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
value={entry.upstreamApiKey}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, upstreamApiKey: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
value={entry.clientApiKeysText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, clientApiKeysText: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
rows={3}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint">{t('ai_providers.ampcode_upstream_api_keys_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
@@ -349,7 +469,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setModelMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
|
||||
@@ -31,3 +31,45 @@
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCard {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
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 type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
@@ -26,7 +27,9 @@ const buildEmptyForm = (): VertexFormState => ({
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
@@ -54,6 +57,7 @@ const buildVertexSignature = (form: VertexFormState) =>
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersVertexEditPage() {
|
||||
@@ -153,6 +157,7 @@ export function AiProvidersVertexEditPage() {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
@@ -213,6 +218,7 @@ export function AiProvidersVertexEditPage() {
|
||||
return { name, alias };
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
@@ -343,6 +349,18 @@ export function AiProvidersVertexEditPage() {
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -314,6 +314,10 @@
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg, rgba(252, 228, 236, 0.18), rgba(252, 228, 236, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
}
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import { normalizeAmpcodeConfig, normalizeAmpcodeModelMappings } from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping } from '@/types';
|
||||
import {
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys,
|
||||
} from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
|
||||
|
||||
const serializeUpstreamApiKeyMappings = (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
mappings.map((mapping) => ({
|
||||
'upstream-api-key': mapping.upstreamApiKey,
|
||||
'api-keys': mapping.apiKeys,
|
||||
}));
|
||||
|
||||
export const ampcodeApi = {
|
||||
async getAmpcode(): Promise<AmpcodeConfig> {
|
||||
@@ -18,6 +28,19 @@ export const ampcodeApi = {
|
||||
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||
|
||||
async getUpstreamApiKeys(): Promise<AmpcodeUpstreamApiKeyMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/upstream-api-keys');
|
||||
const list = data?.['upstream-api-keys'] ?? data?.upstreamApiKeys ?? data?.items ?? data;
|
||||
return normalizeAmpcodeUpstreamApiKeys(list);
|
||||
},
|
||||
|
||||
saveUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.put('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
patchUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.patch('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
deleteUpstreamApiKeys: (upstreamApiKeys: string[]) =>
|
||||
apiClient.delete('/ampcode/upstream-api-keys', { data: { value: upstreamApiKeys } }),
|
||||
|
||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
|
||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||
@@ -34,4 +57,3 @@ export const ampcodeApi = {
|
||||
|
||||
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,39 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
const getStatusCode = (err: unknown): number | undefined => {
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if ('status' in err) return (err as StatusError).status;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
return { ...(parsed as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
const saveAuthFileText = async (name: string, text: string) => {
|
||||
const file = new File([text], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
};
|
||||
|
||||
export const isAuthFileInvalidJsonObjectError = (err: unknown): boolean =>
|
||||
err instanceof Error && err.message === AUTH_FILE_INVALID_JSON_OBJECT_ERROR;
|
||||
|
||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
@@ -105,8 +132,18 @@ const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
|
||||
export const authFilesApi = {
|
||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
async setStatus(name: string, disabled: boolean): Promise<AuthFileStatusResponse> {
|
||||
const json = await authFilesApi.downloadJsonObject(name);
|
||||
|
||||
if (disabled) {
|
||||
json.disabled = true;
|
||||
} else {
|
||||
delete json.disabled;
|
||||
}
|
||||
|
||||
await authFilesApi.saveJsonObject(name, json);
|
||||
return { status: disabled ? 'disabled' : 'enabled', disabled };
|
||||
},
|
||||
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData();
|
||||
@@ -126,6 +163,16 @@ export const authFilesApi = {
|
||||
return blob.text();
|
||||
},
|
||||
|
||||
async downloadJsonObject(name: string): Promise<Record<string, unknown>> {
|
||||
const rawText = await authFilesApi.downloadText(name);
|
||||
return parseAuthFileJsonObject(rawText);
|
||||
},
|
||||
|
||||
saveText: (name: string, text: string) => saveAuthFileText(name, text),
|
||||
|
||||
saveJsonObject: (name: string, json: Record<string, unknown>) =>
|
||||
saveAuthFileText(name, JSON.stringify(json)),
|
||||
|
||||
// OAuth 排除模型
|
||||
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
||||
const data = await apiClient.get('/oauth-excluded-models');
|
||||
|
||||
@@ -107,6 +107,9 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeVertexModelAliases(config.models);
|
||||
if (models && models.length) payload.models = models;
|
||||
if (config.excludedModels && config.excludedModels.length) {
|
||||
payload['excluded-models'] = config.excludedModels;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
OpenAIProviderConfig,
|
||||
ProviderKeyConfig,
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping
|
||||
} from '@/types';
|
||||
import type { Config } from '@/types/config';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
@@ -276,6 +277,33 @@ const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] =>
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
input.forEach((entry) => {
|
||||
if (!isRecord(entry)) return;
|
||||
|
||||
const upstreamApiKey = String(
|
||||
entry['upstream-api-key'] ?? entry.upstreamApiKey ?? entry['upstream_api_key'] ?? ''
|
||||
).trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const rawApiKeys = entry['api-keys'] ?? entry.apiKeys ?? entry['api_keys'] ?? [];
|
||||
const apiKeys = Array.isArray(rawApiKeys)
|
||||
? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
|
||||
: [];
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
|
||||
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
|
||||
if (!isRecord(sourceRaw)) return undefined;
|
||||
@@ -287,6 +315,13 @@ const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined =>
|
||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||
|
||||
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(
|
||||
source['upstream-api-keys'] ?? source.upstreamApiKeys ?? source['upstream_api_keys']
|
||||
);
|
||||
if (upstreamApiKeys.length) {
|
||||
config.upstreamApiKeys = upstreamApiKeys;
|
||||
}
|
||||
|
||||
const forceModelMappings = normalizeBoolean(
|
||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||
);
|
||||
@@ -420,5 +455,6 @@ export {
|
||||
normalizeHeaders,
|
||||
normalizeExcludedModels,
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys
|
||||
};
|
||||
|
||||
@@ -25,12 +25,28 @@ const getSystemTheme = (): ResolvedTheme => {
|
||||
return 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme) => {
|
||||
const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
|
||||
if (theme === 'auto') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
if (theme === 'white') {
|
||||
return 'white';
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme | 'white') => {
|
||||
if (resolved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved === 'white') {
|
||||
document.documentElement.setAttribute('data-theme', 'white');
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
@@ -40,14 +56,17 @@ export const useThemeStore = create<ThemeState>()(
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||
const resolved = resolveTheme(theme);
|
||||
applyTheme(resolved);
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
set({
|
||||
theme,
|
||||
resolvedTheme: resolved === 'white' ? 'light' : resolved,
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme: () => {
|
||||
const { theme, setTheme } = get();
|
||||
const order: Theme[] = ['light', 'dark', 'auto'];
|
||||
const order: Theme[] = ['light', 'white', 'dark', 'auto'];
|
||||
const currentIndex = order.indexOf(theme);
|
||||
const nextTheme = order[(currentIndex + 1) % order.length];
|
||||
setTheme(nextTheme);
|
||||
|
||||
@@ -251,6 +251,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.theme-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: $z-dropdown;
|
||||
padding: $spacing-sm $spacing-sm $spacing-xs;
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
border: 2px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 6px 6px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: border-color $transition-fast, background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-preview {
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-card-sidebar {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-content {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-card-line {
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
|
||||
&.short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.theme-menu-popover {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,59 @@
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 纯白主题
|
||||
[data-theme='white'] {
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-tertiary: #f6f6f6;
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
--text-tertiary: #a29c95;
|
||||
--text-quaternary: #c0bab3;
|
||||
--text-muted: var(--text-tertiary);
|
||||
|
||||
--border-color: #e5e5e5;
|
||||
--border-secondary: var(--border-color);
|
||||
--border-primary: #d9d9d9;
|
||||
--border-hover: #cccccc;
|
||||
|
||||
--primary-color: #8b8680;
|
||||
--primary-hover: #7f7a74;
|
||||
--primary-active: #726d67;
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
--info-color: var(--primary-color);
|
||||
|
||||
--warning-bg: rgba(198, 87, 70, 0.12);
|
||||
--warning-border: rgba(198, 87, 70, 0.35);
|
||||
--warning-text: var(--warning-color);
|
||||
|
||||
--success-badge-bg: #d1fae5;
|
||||
--success-badge-text: #065f46;
|
||||
--success-badge-border: #6ee7b7;
|
||||
|
||||
--failure-badge-bg: rgba(198, 87, 70, 0.14);
|
||||
--failure-badge-text: #8a3a30;
|
||||
--failure-badge-border: rgba(198, 87, 70, 0.35);
|
||||
|
||||
--count-badge-bg: rgba(139, 134, 128, 0.18);
|
||||
--count-badge-text: var(--primary-active);
|
||||
|
||||
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
|
||||
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
|
||||
|
||||
--radius-md: 8px;
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 深色主题(#191919)
|
||||
[data-theme='dark'] {
|
||||
// 极简暖灰:深色模式(提升对比度与层级)
|
||||
|
||||
@@ -7,10 +7,15 @@ export interface AmpcodeModelMapping {
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyMapping {
|
||||
upstreamApiKey: string;
|
||||
apiKeys: string[];
|
||||
}
|
||||
|
||||
export interface AmpcodeConfig {
|
||||
upstreamUrl?: string;
|
||||
upstreamApiKey?: string;
|
||||
upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
|
||||
modelMappings?: AmpcodeModelMapping[];
|
||||
forceModelMappings?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* 通用类型定义
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
|
||||
|
||||
@@ -132,6 +132,28 @@ export interface ClaudeUsagePayload {
|
||||
extra_usage?: ClaudeExtraUsage | null;
|
||||
}
|
||||
|
||||
export interface ClaudeProfileResponse {
|
||||
account?: {
|
||||
uuid?: string;
|
||||
full_name?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
has_claude_max?: boolean;
|
||||
has_claude_pro?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
organization?: {
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
organization_type?: string;
|
||||
billing_type?: string;
|
||||
rate_limit_tier?: string;
|
||||
has_extra_usage_enabled?: boolean;
|
||||
subscription_status?: string;
|
||||
subscription_created_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaudeQuotaWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -144,6 +166,7 @@ export interface ClaudeQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
windows: ClaudeQuotaWindow[];
|
||||
extraUsage?: ClaudeExtraUsage | null;
|
||||
planType?: string | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
|
||||
|
||||
// Claude API configuration
|
||||
export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
||||
|
||||
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
export const CLAUDE_REQUEST_HEADERS = {
|
||||
|
||||
Reference in New Issue
Block a user