Compare commits

...

25 Commits
v1.3.2 ... dev

Author SHA1 Message Date
LTbinglingfeng
f833f0dfd2 fix(config): align visual editor with backend config semantics 2026-02-06 18:14:13 +08:00
hkfires
26fa1ea98e feat(logs): optimize log loading with auto-prepend functionality 2026-02-06 12:09:25 +08:00
hkfires
e568e4a2b5 feat(ui): show empty state for payload rules editor 2026-02-06 11:29:21 +08:00
hkfires
4a0386472d feat(ui): show success/failure in API usage stats 2026-02-06 11:23:50 +08:00
LTbinglingfeng
b9001c27c5 fix 2026-02-06 03:56:57 +08:00
LTbinglingfeng
e6e62e2992 feat(i18n): add internationalization support for visual config editor 2026-02-06 03:34:38 +08:00
LTbinglingfeng
f53d333198 fix(ui): center Config Panel action bar and move ProviderNav to bottom 2026-02-06 03:13:13 +08:00
LTbinglingfeng
adcf0b6582 refactor(nav): move Config Panel and remove Settings/API Keys pages 2026-02-06 02:47:37 +08:00
LTbinglingfeng
11c2498be6 feat: add visual configuration editor and YAML handling
- Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing.
- Added types for visual configuration in `visualConfig.ts`.
- Enhanced `ConfigPage` to support switching between visual and source editors.
- Introduced floating action buttons for save and reload actions.
- Updated translations for tab labels in English and Chinese.
- Styled the configuration page with new tab and floating action button styles.
2026-02-06 02:15:40 +08:00
LTbinglingfeng
7d41afb5f1 feat(auth-files): add quota management features and enhance UI layout 2026-02-05 02:22:23 +08:00
LTbinglingfeng
d4bc0bc622 fix(model-alias): restore diagram drag-and-drop and add touch tap-to-link fallback 2026-02-05 01:26:01 +08:00
LTbinglingfeng
5241d52b14 fix(model-alias): improve diagram mobile layout and refresh reliability 2026-02-05 00:55:03 +08:00
LTbinglingfeng
9887a78889 fix(layout): add scroll container for improved responsiveness and adjust container styles 2026-02-05 00:23:18 +08:00
LTbinglingfeng
759e369d42 fix(drag-and-drop): add data transfer for source and alias during drag events
fix(i18n): update view mode labels in Chinese localization
fix(auth-files): set fork to true in empty mapping entry and improve error handling in save/delete operations
2026-02-05 00:02:05 +08:00
Supra4E8C
db487dc49d Merge pull request #81 from thanhtunguet/main
Add diagram-based view mode for model alias mapping
2026-02-04 23:08:05 +08:00
LTbinglingfeng
a94a9791bc fix(ui): scope ToggleSwitch styles with CSS Modules to prevent label text collapsing into a vertical column 2026-02-04 22:00:57 +08:00
hkfires
473cece09e fix(quota): classify codex windows by 5-hour and weekly limits 2026-02-04 09:31:09 +08:00
Phạm Thanh Tùng
aebe95d221 Merge branch 'router-for-me:main' into main 2026-02-04 01:50:23 +07:00
LTbinglingfeng
08e8fe2edd fix(ProviderNav): remove unused provider icons and clean up code 2026-02-04 01:42:32 +08:00
Phạm Thanh Tùng
d9272d6d0e Merge branch 'router-for-me:main' into main 2026-02-01 17:20:32 +07:00
thanhtunguet
0d40eecbe7 refactor(ModelMappingDiagram): restructure component to utilize new modular columns and improve type definitions 2026-01-31 21:47:49 +07:00
thanhtunguet
ce47d6d985 style(ModelMappingDiagram): remove unused aliasItem class from SCSS file 2026-01-31 21:34:00 +07:00
thanhtunguet
01a69ff32b refactor(AuthFilesPage): optimize provider list generation using useMemo for better performance 2026-01-31 21:33:26 +07:00
thanhtunguet
fd1174e010 fix: update event type handling in ModelMappingDiagram for improved type safety 2026-01-31 21:07:06 +07:00
thanhtunguet
3e55d601a1 feat: enhance OAuth model alias management with new UI components and localization updates 2026-01-31 21:04:34 +07:00
40 changed files with 5457 additions and 1325 deletions

30
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
@@ -19,6 +20,7 @@
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -1242,6 +1244,18 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@openai/codex": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -4241,6 +4255,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,

View File

@@ -13,6 +13,7 @@
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
@@ -23,6 +24,7 @@
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {

View File

@@ -43,7 +43,11 @@ export function ConfirmationModal() {
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
<p style={{ margin: '1rem 0' }}>{message}</p>
{typeof message === 'string' ? (
<p style={{ margin: '1rem 0' }}>{message}</p>
) : (
<div style={{ margin: '1rem 0' }}>{message}</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}

View File

@@ -1,4 +1,12 @@
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import {
ReactNode,
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
@@ -31,6 +39,16 @@ type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
export function PageTransition({
render,
getRouteOrder,
@@ -363,7 +381,9 @@ export function PageTransition({
: undefined
}
>
{render(layer.location)}
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
);
});

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { Card } from '@/components/ui/Card';
interface ConfigSectionProps {
title: ReactNode;
description?: ReactNode;
className?: string;
}
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
return (
<Card title={title} className={className}>
{description && (
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
{description}
</p>
)}
{children}
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,10 @@ import {
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
@@ -40,8 +38,6 @@ import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
@@ -245,6 +241,37 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
};
updateContentCenter();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && contentRef.current
? new ResizeObserver(updateContentCenter)
: null;
if (resizeObserver && contentRef.current) {
resizeObserver.observe(contentRef.current);
}
window.addEventListener('resize', updateContentCenter);
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
@@ -357,14 +384,12 @@ export function MainLayout() {
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),

View File

@@ -0,0 +1,359 @@
@use '../../styles/variables' as *;
.scrollContainer {
width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.tapHint {
position: sticky;
left: 0;
z-index: 3;
font-size: 12px;
color: var(--text-secondary);
padding: 0 4px;
margin-bottom: 8px;
}
.container {
display: inline-flex;
position: relative;
min-width: 100%;
min-height: 300px;
justify-content: space-between;
padding: 20px 0;
user-select: none;
@media (max-width: 768px) {
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
min-width: max(100%, 960px);
padding: 12px 0;
}
}
// SVG layer for connection lines (behind columns so links are visible)
.connections {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
overflow: visible;
path {
fill: none;
stroke-width: 2;
}
}
.column {
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2;
flex: 0 0 auto;
&.providers {
align-items: flex-end;
min-width: 140px;
}
&.sources {
align-items: flex-start;
min-width: 200px;
}
&.aliases {
align-items: flex-start;
min-width: 200px;
}
}
.columnHeader {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 8px;
padding: 0 4px;
}
.item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 280px;
position: relative;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
z-index: 10;
}
&.dropTarget {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
border-width: 2px;
}
&.selected {
border-color: var(--primary-color);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
}
// Mindmap-style provider branch (root node)
.providerItem {
border-left: 3px solid transparent;
padding-left: 8px;
display: flex;
align-items: center;
gap: 8px;
.providerLabel {
font-weight: 600;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapseBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s, color 0.15s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
.chevronDown,
.chevronRight {
display: inline-block;
width: 0;
height: 0;
border-style: solid;
}
.chevronDown {
border-width: 5px 4px 0 4px;
border-color: currentColor transparent transparent transparent;
}
.chevronRight {
border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor;
}
}
.providerGroup {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.sourceItem,
.aliasItem {
cursor: grab;
&:active {
cursor: grabbing;
}
&.dragging {
opacity: 0.5;
border-style: dashed;
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
top: 50%;
margin-top: -3px;
flex-shrink: 0;
&.dotLeft {
left: -3px;
background: var(--text-tertiary);
}
}
.sourceItem .dot {
right: -3px;
}
.providerBadge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
margin-right: 8px;
font-weight: 500;
}
.itemName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemCount {
font-size: 11px;
color: var(--text-tertiary);
margin-left: 8px;
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: 10px;
}
.contextMenu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
min-width: 120px;
overflow: hidden;
padding: 4px 0;
.menuItem {
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.1s;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--bg-secondary);
}
&.danger {
color: var(--error-color);
&:hover {
background-color: var(--bg-error-light);
}
}
}
.menuDivider {
height: 1px;
margin: 4px 0;
background: var(--border-color);
padding: 0;
cursor: default;
pointer-events: none;
}
}
.settingsEmpty {
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
padding: $spacing-lg 0;
}
.settingsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(200px, 1fr) auto;
gap: $spacing-md;
align-items: center;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@media (max-width: 768px) {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
.settingsNames {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-primary);
min-width: 0;
}
.settingsSource,
.settingsAlias {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.settingsArrow {
color: var(--text-tertiary);
}
.settingsActions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.settingsLabel {
font-size: 12px;
color: var(--text-secondary);
}
.settingsDelete {
border: 0;
background: transparent;
color: var(--error-color);
padding: 6px;
border-radius: 6px;
cursor: pointer;
&:hover {
background: var(--bg-error-light);
}
}

View File

@@ -0,0 +1,659 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { OAuthModelAliasEntry } from '@/types';
import { useThemeStore } from '@/stores';
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
import {
AddAliasModal,
RenameAliasModal,
SettingsAliasModal,
SettingsSourceModal
} from './ModelMappingDiagramModals';
import type {
AliasNode,
AuthFileModelItem,
ContextMenuState,
DiagramLine,
SourceNode
} from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
export interface ModelMappingDiagramProps {
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels?: Record<string, AuthFileModelItem[]>;
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
onDeleteAlias?: (alias: string) => void;
onEditProvider?: (provider: string) => void;
onDeleteProvider?: (provider: string) => void;
className?: string;
}
const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
];
function getProviderColor(provider: string): string {
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
}
export interface ModelMappingDiagramRef {
collapseAll: () => void;
refreshLayout: () => void;
}
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
modelAlias,
allProviderModels = {},
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
className
}, ref) {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const enableTapLinking = useMemo(() => {
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
return (
window.matchMedia('(any-pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches
);
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<DiagramLine[]>([]);
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
const [tapAlias, setTapAlias] = useState<string | null>(null);
const [extraAliases, setExtraAliases] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameError, setRenameError] = useState('');
const [addAliasOpen, setAddAliasOpen] = useState(false);
const [addAliasValue, setAddAliasValue] = useState('');
const [addAliasError, setAddAliasError] = useState('');
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
const { aliasNodes, providerNodes } = useMemo(() => {
const sourceMap = new Map<
string,
{ provider: string; name: string; aliases: Map<string, boolean> }
>();
const aliasSet = new Set<string>();
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
Object.entries(modelAlias).forEach(([provider, mappings]) => {
(mappings ?? []).forEach((m) => {
const name = (m?.name || '').trim();
const alias = (m?.alias || '').trim();
if (!name || !alias) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (!sourceMap.has(pk)) {
sourceMap.set(pk, { provider, name, aliases: new Map() });
}
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
aliasSet.add(alias);
});
});
// 2. Unmapped models from allProviderModels (no mapping yet)
Object.entries(allProviderModels).forEach(([provider, models]) => {
(models ?? []).forEach((m) => {
const name = (m.id || '').trim();
if (!name) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (sourceMap.has(pk)) {
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
return;
}
sourceMap.set(pk, { provider, name, aliases: new Map() });
});
});
// 3. Source nodes: distinct by id = provider::name
const sources: SourceNode[] = Array.from(sourceMap.entries())
.map(([id, v]) => ({
id,
provider: v.provider,
name: v.name,
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
}))
.sort((a, b) => {
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
return a.name.localeCompare(b.name);
});
// 4. Extra aliases (no mapping yet)
extraAliases.forEach((alias) => aliasSet.add(alias));
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
.map((alias) => ({
id: alias,
alias,
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
}))
.sort((a, b) => {
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
return a.alias.localeCompare(b.alias);
});
// 6. Group sources by provider
const providerMap = new Map<string, SourceNode[]>();
sources.forEach((s) => {
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
providerMap.get(s.provider)!.push(s);
});
const providerNodesList = Array.from(providerMap.entries())
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
.sort((a, b) => a.provider.localeCompare(b.provider));
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
}, [modelAlias, allProviderModels, extraAliases]);
// Track element positions
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleProviderCollapse = (provider: string) => {
setCollapsedProviders((prev) => {
const next = new Set(prev);
if (next.has(provider)) next.delete(provider);
else next.add(provider);
return next;
});
};
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
const updateLines = useCallback(() => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLines: { path: string; color: string; id: string }[] = [];
const nextProviderGroupHeights: Record<string, number> = {};
const bezier = (
x1: number, y1: number,
x2: number, y2: number
) => {
const cpx1 = x1 + (x2 - x1) * 0.5;
const cpx2 = x2 - (x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
};
providerNodes.forEach(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
if (collapsed) return;
if (sources.length > 0) {
const firstEl = sourceRefs.current.get(sources[0].id);
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
if (firstEl && lastEl) {
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
if (height > 0) nextProviderGroupHeights[provider] = height;
}
}
const providerEl = providerRefs.current.get(provider);
if (!providerEl) return;
const providerRect = providerEl.getBoundingClientRect();
const px = providerRect.right - containerRect.left;
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
const color = getProviderColor(provider);
// Provider → Source (branch link, no dot)
sources.forEach((source) => {
const sourceEl = sourceRefs.current.get(source.id);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const sx = sourceRect.left - containerRect.left;
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
newLines.push({
id: `provider-${provider}-source-${source.id}`,
path: bezier(px, py, sx, sy),
color
});
});
// Source → Alias: one line per alias
sources.forEach((source) => {
if (!source.aliases || source.aliases.length === 0) return;
source.aliases.forEach((aliasEntry) => {
const sourceEl = sourceRefs.current.get(source.id);
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
if (!sourceEl || !aliasEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const aliasRect = aliasEl.getBoundingClientRect();
// Calculate coordinates relative to the container
const x1 = sourceRect.right - containerRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
const x2 = aliasRect.left - containerRect.left;
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
newLines.push({
id: `${source.id}-${aliasEntry.alias}`,
path: bezier(x1, y1, x2, y2),
color
});
});
});
});
setLines(newLines);
setProviderGroupHeights((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextProviderGroupHeights);
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
for (const key of nextKeys) {
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
return nextProviderGroupHeights;
}
}
return prev;
});
}, [providerNodes, collapsedProviders]);
useImperativeHandle(
ref,
() => ({
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
refreshLayout: () => updateLines()
}),
[providerNodes, updateLines]
);
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', updateLines);
};
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => updateLines());
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [updateLines]);
// Drag and Drop handlers
// 1. Source -> Alias
const handleDragStart = (e: DragEvent, source: SourceNode) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedSource(source);
e.dataTransfer.setData('text/plain', source.id);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOver = (e: DragEvent, alias: string) => {
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
e.preventDefault(); // Allow drop
e.dataTransfer.dropEffect = 'link';
setDropTargetAlias(alias);
};
const handleDragLeave = () => {
setDropTargetAlias(null);
};
const handleDrop = (e: DragEvent, alias: string) => {
e.preventDefault();
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
onUpdate(draggedSource.provider, draggedSource.name, alias);
}
setDraggedSource(null);
setDropTargetAlias(null);
};
// 2. Alias -> Source
const handleDragStartAlias = (e: DragEvent, alias: string) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedAlias(alias);
e.dataTransfer.setData('text/plain', alias);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setDropTargetSource(source.id);
};
const handleDragLeaveSource = () => {
setDropTargetSource(null);
};
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
e.preventDefault();
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
onUpdate(source.provider, source.name, draggedAlias);
}
setDraggedAlias(null);
setDropTargetSource(null);
};
const handleContextMenu = (
e: ReactMouseEvent,
type: 'alias' | 'background' | 'provider' | 'source',
data?: string
) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
type,
data
});
};
const closeContextMenu = () => setContextMenu(null);
const resolveSourceById = useCallback(
(id: string | null) => {
if (!id) return null;
for (const { sources } of providerNodes) {
const found = sources.find((source) => source.id === id);
if (found) return found;
}
return null;
},
[providerNodes]
);
const handleTapSelectSource = (source: SourceNode) => {
if (!onUpdate) return;
if (tapSourceId === source.id) {
setTapSourceId(null);
return;
}
if (tapAlias) {
onUpdate(source.provider, source.name, tapAlias);
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapSourceId(source.id);
setTapAlias(null);
};
const handleTapSelectAlias = (alias: string) => {
if (!onUpdate) return;
if (tapAlias === alias) {
setTapAlias(null);
return;
}
if (tapSourceId) {
const source = resolveSourceById(tapSourceId);
if (source) {
onUpdate(source.provider, source.name, alias);
}
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapAlias(alias);
setTapSourceId(null);
};
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
};
const handleToggleFork = (
provider: string,
sourceModel: string,
alias: string,
value: boolean
) => {
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
};
const handleAddAlias = () => {
closeContextMenu();
setAddAliasOpen(true);
setAddAliasValue('');
setAddAliasError('');
};
const handleAddAliasSubmit = () => {
const trimmed = addAliasValue.trim();
if (!trimmed) {
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
setExtraAliases(prev => [...prev, trimmed]);
setAddAliasOpen(false);
};
const handleRenameClick = (oldAlias: string) => {
closeContextMenu();
setRenameState({ oldAlias });
setRenameValue(oldAlias);
setRenameError('');
};
const handleRenameSubmit = () => {
const trimmed = renameValue.trim();
if (!trimmed) {
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (trimmed === renameState?.oldAlias) {
setRenameState(null);
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
}
setRenameState(null);
};
const handleDeleteClick = (alias: string) => {
closeContextMenu();
const node = aliasNodes.find(n => n.alias === alias);
if (!node) return;
if (node.sources.length === 0) {
setExtraAliases(prev => prev.filter(a => a !== alias));
} else {
if (onDeleteAlias) onDeleteAlias(alias);
}
};
return (
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
{enableTapLinking && onUpdate && (
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
)}
<div
className={styles.container}
ref={containerRef}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, 'background');
}}
>
<svg className={styles.connections}>
{lines.map((line) => (
<path
key={line.id}
d={line.path}
stroke={line.color}
strokeOpacity={isDark ? 0.4 : 0.3}
/>
))}
</svg>
<ProviderColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
getProviderColor={getProviderColor}
providerGroupHeights={providerGroupHeights}
providerRefs={providerRefs}
onToggleCollapse={toggleProviderCollapse}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_providers')}
expandLabel={t('oauth_model_alias.diagram_expand')}
collapseLabel={t('oauth_model_alias.diagram_collapse')}
/>
<SourceColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
sourceRefs={sourceRefs}
getProviderColor={getProviderColor}
selectedSourceId={enableTapLinking ? tapSourceId : null}
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
draggedSource={draggedSource}
dropTargetSource={dropTargetSource}
draggable={!!onUpdate}
onDragStart={handleDragStart}
onDragEnd={() => {
setDraggedSource(null);
setDropTargetAlias(null);
}}
onDragOver={handleDragOverSource}
onDragLeave={handleDragLeaveSource}
onDrop={handleDropOnSource}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_source_models')}
/>
<AliasColumn
aliasNodes={aliasNodes}
aliasRefs={aliasRefs}
dropTargetAlias={dropTargetAlias}
draggedAlias={draggedAlias}
selectedAlias={enableTapLinking ? tapAlias : null}
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
draggable={!!onUpdate}
onDragStart={handleDragStartAlias}
onDragEnd={() => {
setDraggedAlias(null);
setDropTargetSource(null);
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_aliases')}
/>
</div>
<DiagramContextMenu
contextMenu={contextMenu}
t={t}
onRequestClose={() => setContextMenu(null)}
onAddAlias={handleAddAlias}
onRenameAlias={handleRenameClick}
onOpenAliasSettings={(alias) => {
setContextMenu(null);
setSettingsAlias(alias);
}}
onDeleteAlias={handleDeleteClick}
onEditProvider={(provider) => {
setContextMenu(null);
onEditProvider?.(provider);
}}
onDeleteProvider={(provider) => {
setContextMenu(null);
onDeleteProvider?.(provider);
}}
onOpenSourceSettings={(sourceId) => {
setContextMenu(null);
setSettingsSourceId(sourceId);
}}
/>
<RenameAliasModal
open={!!renameState}
t={t}
value={renameValue}
error={renameError}
onChange={(value) => {
setRenameValue(value);
setRenameError('');
}}
onClose={() => setRenameState(null)}
onSubmit={handleRenameSubmit}
/>
<AddAliasModal
open={addAliasOpen}
t={t}
value={addAliasValue}
error={addAliasError}
onChange={(value) => {
setAddAliasValue(value);
setAddAliasError('');
}}
onClose={() => setAddAliasOpen(false)}
onSubmit={handleAddAliasSubmit}
/>
<SettingsAliasModal
open={Boolean(settingsAlias)}
t={t}
alias={settingsAlias}
aliasNodes={aliasNodes}
onClose={() => setSettingsAlias(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
<SettingsSourceModal
open={Boolean(settingsSourceId)}
t={t}
source={resolveSourceById(settingsSourceId)}
onClose={() => setSettingsSourceId(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
</div>
);
});

View File

@@ -0,0 +1,251 @@
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface ProviderColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
getProviderColor: (provider: string) => string;
providerGroupHeights?: Record<string, number>;
providerRefs: RefObject<Map<string, HTMLDivElement>>;
onToggleCollapse: (provider: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
label: string;
expandLabel: string;
collapseLabel: string;
}
export function ProviderColumn({
providerNodes,
collapsedProviders,
getProviderColor,
providerGroupHeights = {},
providerRefs,
onToggleCollapse,
onContextMenu,
label,
expandLabel,
collapseLabel
}: ProviderColumnProps) {
return (
<div
className={`${styles.column} ${styles.providers}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.map(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
return (
<div
key={provider}
className={styles.providerGroup}
style={groupHeight ? { height: groupHeight } : undefined}
>
<div
ref={(el) => {
if (el) providerRefs.current?.set(provider, el);
else providerRefs.current?.delete(provider);
}}
className={`${styles.item} ${styles.providerItem}`}
style={{ borderLeftColor: getProviderColor(provider) }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'provider', provider);
}}
>
<button
type="button"
className={styles.collapseBtn}
onClick={() => onToggleCollapse(provider)}
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
</button>
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
{provider}
</span>
<span className={styles.itemCount}>{sources.length}</span>
</div>
</div>
);
})}
</div>
);
}
interface SourceColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
getProviderColor: (provider: string) => string;
selectedSourceId?: string | null;
onSelectSource?: (source: SourceNode) => void;
draggedSource: SourceNode | null;
dropTargetSource: string | null;
draggable: boolean;
onDragStart: (e: DragEvent, source: SourceNode) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, source: SourceNode) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, source: SourceNode) => void;
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
label: string;
}
export function SourceColumn({
providerNodes,
collapsedProviders,
sourceRefs,
getProviderColor,
selectedSourceId,
onSelectSource,
draggedSource,
dropTargetSource,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: SourceColumnProps) {
return (
<div
className={`${styles.column} ${styles.sources}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.flatMap(({ provider, sources }) => {
if (collapsedProviders.has(provider)) return [];
return sources.map((source) => (
<div
key={source.id}
ref={(el) => {
if (el) sourceRefs.current?.set(source.id, el);
else sourceRefs.current?.delete(source.id);
}}
className={`${styles.item} ${styles.sourceItem} ${
draggedSource?.id === source.id ? styles.dragging : ''
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
selectedSourceId === source.id ? styles.selected : ''
}`}
onClick={() => onSelectSource?.(source)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, source)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, source)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, source)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'source', source.id);
}}
>
<span className={styles.itemName} title={source.name}>
{source.name}
</span>
<div
className={styles.dot}
style={{
background: getProviderColor(source.provider),
opacity: source.aliases.length > 0 ? 1 : 0.3
}}
/>
</div>
));
})}
</div>
);
}
interface AliasColumnProps {
aliasNodes: AliasNode[];
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
dropTargetAlias: string | null;
draggedAlias: string | null;
selectedAlias?: string | null;
onSelectAlias?: (alias: string) => void;
draggable: boolean;
onDragStart: (e: DragEvent, alias: string) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, alias: string) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, alias: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
label: string;
}
export function AliasColumn({
aliasNodes,
aliasRefs,
dropTargetAlias,
draggedAlias,
selectedAlias,
onSelectAlias,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: AliasColumnProps) {
return (
<div
className={`${styles.column} ${styles.aliases}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{aliasNodes.map((node) => (
<div
key={node.id}
ref={(el) => {
if (el) aliasRefs.current?.set(node.id, el);
else aliasRefs.current?.delete(node.id);
}}
className={`${styles.item} ${styles.aliasItem} ${
dropTargetAlias === node.alias ? styles.dropTarget : ''
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
selectedAlias === node.alias ? styles.selected : ''
}`}
onClick={() => onSelectAlias?.(node.alias)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, node.alias)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, node.alias)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node.alias)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'alias', node.alias);
}}
>
<div className={`${styles.dot} ${styles.dotLeft}`} />
<span className={styles.itemName} title={node.alias}>
{node.alias}
</span>
<span className={styles.itemCount}>{node.sources.length}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import type { ContextMenuState } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface DiagramContextMenuProps {
contextMenu: ContextMenuState | null;
t: TFunction;
onRequestClose: () => void;
onAddAlias: () => void;
onRenameAlias: (alias: string) => void;
onOpenAliasSettings: (alias: string) => void;
onDeleteAlias: (alias: string) => void;
onEditProvider: (provider: string) => void;
onDeleteProvider: (provider: string) => void;
onOpenSourceSettings: (sourceId: string) => void;
}
export function DiagramContextMenu({
contextMenu,
t,
onRequestClose,
onAddAlias,
onRenameAlias,
onOpenAliasSettings,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
onOpenSourceSettings
}: DiagramContextMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!contextMenu) return;
const handleClick = (event: globalThis.MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onRequestClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, onRequestClose]);
if (!contextMenu) return null;
const { type, data } = contextMenu;
const renderBackground = () => (
<div className={styles.menuItem} onClick={onAddAlias}>
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
</div>
);
const renderAlias = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
<span>{t('oauth_model_alias.diagram_rename')}</span>
</div>
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
</div>
</>
);
};
const renderProvider = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
<span>{t('common.edit')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
<span>{t('oauth_model_alias.delete')}</span>
</div>
</>
);
};
const renderSource = () => {
if (!data) return null;
return (
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
);
};
return createPortal(
<div
ref={menuRef}
className={styles.contextMenu}
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{type === 'background' && renderBackground()}
{type === 'alias' && renderAlias()}
{type === 'provider' && renderProvider()}
{type === 'source' && renderSource()}
</div>,
document.body
);
}

View File

@@ -0,0 +1,267 @@
import type { KeyboardEvent } from 'react';
import type { TFunction } from 'i18next';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconTrash2 } from '@/components/ui/icons';
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface RenameAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function RenameAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: RenameAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_rename_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_rename_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
autoFocus
/>
</Modal>
);
}
interface AddAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function AddAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: AddAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_add_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_add_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
autoFocus
/>
</Modal>
);
}
interface SettingsAliasModalProps {
open: boolean;
t: TFunction;
alias: string | null;
aliasNodes: AliasNode[];
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsAliasModal({
open,
t,
alias,
aliasNodes,
onClose,
onToggleFork,
onUnlink
}: SettingsAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{alias ? (
(() => {
const node = aliasNodes.find((n) => n.alias === alias);
if (!node || node.sources.length === 0) {
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
}
return (
<div className={styles.settingsList}>
{node.sources.map((source) => {
const entry = source.aliases.find((item) => item.alias === alias);
const forkEnabled = entry?.fork === true;
return (
<div key={source.id} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={forkEnabled}
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
);
})()
) : null}
</Modal>
);
}
interface SettingsSourceModalProps {
open: boolean;
t: TFunction;
source: SourceNode | null;
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsSourceModal({
open,
t,
source,
onClose,
onToggleFork,
onUnlink
}: SettingsSourceModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_source_title')}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{source ? (
source.aliases.length === 0 ? (
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
) : (
<div className={styles.settingsList}>
{source.aliases.map((entry) => (
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{entry.alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={entry.fork === true}
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
))}
</div>
)
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,33 @@
export interface AuthFileModelItem {
id: string;
display_name?: string;
type?: string;
owned_by?: string;
}
export interface SourceNode {
id: string; // unique: provider::name
provider: string;
name: string;
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
}
export interface AliasNode {
id: string; // alias
alias: string;
sources: SourceNode[];
}
export interface ProviderNode {
provider: string;
sources: SourceNode[];
}
export interface ContextMenuState {
x: number;
y: number;
type: 'alias' | 'background' | 'provider' | 'source';
data?: string;
}
export type DiagramLine = { path: string; color: string; id: string };

View File

@@ -0,0 +1,2 @@
export { ModelMappingDiagram } from './ModelMappingDiagram';
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';

View File

@@ -2,25 +2,34 @@
.navContainer {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
left: var(--content-center-x, 50%);
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.navList {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 8px;
display: inline-flex;
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
&::-webkit-scrollbar {
display: none;
}
}
.indicator {
@@ -29,7 +38,7 @@
left: 0;
pointer-events: none;
opacity: 0;
border-radius: 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -58,9 +67,10 @@
padding: 0;
border: none;
background: transparent;
border-radius: 10px;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
flex: 0 0 auto;
&:hover {
background: rgba(0, 0, 0, 0.06);
@@ -80,8 +90,8 @@
}
.icon {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
object-fit: contain;
}
@@ -110,42 +120,20 @@
}
}
// 小屏幕改为底部横向浮层
// 小屏幕进一步收紧尺寸
@media (max-width: 1200px) {
.navContainer {
top: auto;
right: auto;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
width: fit-content;
max-width: calc(100vw - 24px);
max-width: calc(100vw - 16px);
}
.navList {
display: inline-flex;
flex-direction: row;
gap: 6px;
padding: 8px 10px;
border-radius: 999px;
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
&::-webkit-scrollbar {
display: none;
}
}
.indicator {
border-radius: 999px;
}
.navItem {
width: 36px;
height: 36px;
border-radius: 999px;
flex: 0 0 auto;
}
.icon {

View File

@@ -1,6 +1,7 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -34,10 +35,13 @@ type ScrollContainer = HTMLElement | (Window & typeof globalThis);
export function ProviderNav() {
const location = useLocation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const contentScrollerRef = useRef<HTMLElement | null>(null);
const navListRef = useRef<HTMLDivElement | null>(null);
const navContainerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
gemini: null,
codex: null,
@@ -62,7 +66,7 @@ export function ProviderNav() {
location.pathname.length > 1 && location.pathname.endsWith('/')
? location.pathname.slice(0, -1)
: location.pathname;
const shouldShow = normalizedPathname === '/ai-providers';
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
const getHeaderHeight = useCallback(() => {
const header = document.querySelector('.main-header') as HTMLElement | null;
@@ -167,6 +171,31 @@ export function ProviderNav() {
updateIndicator(activeProvider);
}, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
useLayoutEffect(() => {
if (!shouldShow) return;
const el = navContainerRef.current;
if (!el) return;
const updateHeight = () => {
const height = el.getBoundingClientRect().height;
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
};
updateHeight();
window.addEventListener('resize', updateHeight);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
ro?.observe(el);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updateHeight);
document.documentElement.style.removeProperty('--provider-nav-height');
};
}, [shouldShow]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
@@ -201,7 +230,7 @@ export function ProviderNav() {
}, [activeProvider, shouldShow, updateIndicator]);
const navContent = (
<div className={styles.navContainer}>
<div className={styles.navContainer} ref={navContainerRef}>
<div className={styles.navList} ref={navListRef}>
<div
className={[

View File

@@ -10,13 +10,14 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
GeminiCliQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
@@ -47,7 +48,7 @@ import {
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
@@ -142,7 +143,7 @@ const fetchAntigravityQuota = async (
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBody
data: requestBody,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -189,6 +190,15 @@ const fetchAntigravityQuota = async (
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const FIVE_HOUR_SECONDS = 18000;
const WEEK_SECONDS = 604800;
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
} as const;
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
@@ -210,30 +220,74 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
resetLabel,
});
};
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
if (!window) return null;
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
};
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
for (const window of rawWindows) {
if (!window) continue;
const seconds = getWindowSeconds(window);
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
fiveHourWindow = window;
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
weeklyWindow = window;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
WINDOW_META.codeFiveHour.id,
WINDOW_META.codeFiveHour.labelKey,
rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
WINDOW_META.codeWeekly.id,
WINDOW_META.codeWeekly.labelKey,
rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
);
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
WINDOW_META.codeReviewFiveHour.labelKey,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
WINDOW_META.codeReviewWeekly.id,
WINDOW_META.codeReviewWeekly.labelKey,
codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
);
return windows;
@@ -257,14 +311,14 @@ const fetchCodexQuota = async (
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
'Chatgpt-Account-Id': accountId,
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
header: requestHeader,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -301,7 +355,7 @@ const fetchGeminiCliQuota = async (
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
data: JSON.stringify({ project: projectId }),
});
if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -320,7 +374,9 @@ const fetchGeminiCliQuota = async (
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount
);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
@@ -334,7 +390,7 @@ const fetchGeminiCliQuota = async (
tokenType,
remainingFraction,
remainingAmount,
resetTime
resetTime,
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
@@ -366,11 +422,7 @@ const renderAntigravityItems = (
h(
'div',
{ className: styleMap.quotaRowHeader },
h(
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
h(
'div',
{ className: styleMap.quotaMeta },
@@ -403,7 +455,6 @@ const renderCodexItems = (
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = [];
if (planLabel) {
@@ -417,17 +468,6 @@ const renderCodexItems = (
);
}
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
@@ -487,7 +527,7 @@ const renderGeminiCliItems = (
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
count: bucket.remainingAmount,
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
@@ -530,13 +570,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
status: 'error',
groups: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems
renderQuotaItems: renderAntigravityItems,
};
export const CODEX_CONFIG: QuotaConfig<
@@ -553,19 +593,19 @@ export const CODEX_CONFIG: QuotaConfig<
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType
planType: data.planType,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems
renderQuotaItems: renderCodexItems,
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
@@ -582,11 +622,11 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
status: 'error',
buckets: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems
renderQuotaItems: renderGeminiCliItems,
};

View File

@@ -8,6 +8,7 @@ interface ModalProps {
onClose: () => void;
footer?: ReactNode;
width?: number | string;
className?: string;
closeDisabled?: boolean;
}
@@ -39,6 +40,7 @@ export function Modal({
onClose,
footer,
width = 520,
className,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
@@ -110,7 +112,7 @@ export function Modal({
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
const modalContent = (
<div className={overlayClass}>

View File

@@ -0,0 +1,58 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
}
.labelLeft {
.label {
order: -1;
}
}
.disabled {
cursor: not-allowed;
}
.root input {
width: 0;
height: 0;
opacity: 0;
position: absolute;
}
.track {
width: 44px;
height: 24px;
background: var(--border-color);
border-radius: $radius-full;
position: relative;
transition: background $transition-fast;
}
.thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
background: #fff;
border-radius: $radius-full;
box-shadow: $shadow-sm;
transition: transform $transition-fast;
}
.root input:checked + .track {
background: var(--primary-color);
}
.root input:checked + .track .thumb {
transform: translateX(20px);
}
.label {
color: var(--text-primary);
font-weight: 600;
}

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent, ReactNode } from 'react';
import styles from './ToggleSwitch.module.scss';
interface ToggleSwitchProps {
checked: boolean;
@@ -21,7 +22,11 @@ export function ToggleSwitch({
onChange(event.target.checked);
};
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
const className = [
styles.root,
labelPosition === 'left' ? styles.labelLeft : '',
disabled ? styles.disabled : '',
]
.filter(Boolean)
.join(' ');
@@ -34,10 +39,10 @@ export function ToggleSwitch({
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track">
<span className="thumb" />
<span className={styles.track}>
<span className={styles.thumb} />
</span>
{label && <span className="label">{label}</span>}
{label && <span className={styles.label}>{label}</span>}
</label>
);
}

View File

@@ -39,10 +39,18 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
@@ -61,7 +69,13 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
{stats.requests} {t('usage_stats.requests_count')}
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>

View File

@@ -0,0 +1,458 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type {
PayloadFilterRule,
PayloadParamValueType,
PayloadRule,
VisualConfigValues,
} from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function extractApiKeyValue(raw: unknown): string | null {
if (typeof raw === 'string') {
const trimmed = raw.trim();
return trimmed ? trimmed : null;
}
const record = asRecord(raw);
if (!record) return null;
const candidates = [record['api-key'], record.apiKey, record.key, record.Key];
for (const candidate of candidates) {
if (typeof candidate === 'string') {
const trimmed = candidate.trim();
if (trimmed) return trimmed;
}
}
return null;
}
function parseApiKeysText(raw: unknown): string {
if (!Array.isArray(raw)) return '';
const keys: string[] = [];
for (const item of raw) {
const key = extractApiKeyValue(item);
if (key) keys.push(key);
}
return keys.join('\n');
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asRecord(parent[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
}
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
const value = asRecord(parent[key]);
if (!value) return;
if (Object.keys(value).length === 0) delete parent[key];
}
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
if (value) {
obj[key] = true;
return;
}
if (hasOwn(obj, key)) obj[key] = false;
}
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed !== '') {
obj[key] = safe;
return;
}
if (hasOwn(obj, key)) delete obj[key];
}
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key];
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
obj[key] = parsed;
return;
}
if (hasOwn(obj, key)) delete obj[key];
}
function deepClone<T>(value: T): T {
if (typeof structuredClone === 'function') return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
if (typeof raw === 'number') {
return { valueType: 'number', value: String(raw) };
}
if (typeof raw === 'boolean') {
return { valueType: 'boolean', value: String(raw) };
}
if (raw === null || typeof raw === 'object') {
try {
const json = JSON.stringify(raw, null, 2);
return { valueType: 'json', value: json ?? 'null' };
} catch {
return { valueType: 'json', value: String(raw) };
}
}
return { valueType: 'string', value: String(raw ?? '') };
}
function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: (rule as any)?.params
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => {
const parsedValue = parsePayloadParamValue(value);
return {
id: `param-${index}-${pIndex}`,
path,
valueType: parsedValue.valueType,
value: parsedValue.value,
};
})
: [],
}));
}
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-filter-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `filter-model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
}));
}
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params: Record<string, any> = {};
for (const param of rule.params || []) {
if (!param.path?.trim()) continue;
let value: any = param.value;
if (param.valueType === 'number') {
const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value;
} else if (param.valueType === 'boolean') {
value = param.value === 'true';
} else if (param.valueType === 'json') {
try {
value = JSON.parse(param.value);
} catch {
value = param.value;
}
}
params[param.path.trim()] = value;
}
return { models, params };
})
.filter((rule) => rule.models.length > 0);
}
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params = (Array.isArray(rule.params) ? rule.params : [])
.map((path) => String(path).trim())
.filter(Boolean);
return { models, params };
})
.filter((rule) => rule.models.length > 0);
}
export function useVisualConfig() {
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
}, [visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const parsed: any = parseYaml(yamlContent) || {};
const newValues: VisualConfigValues = {
host: parsed.host || '',
port: String(parsed.port ?? ''),
tlsEnable: Boolean(parsed.tls?.enable),
tlsCert: parsed.tls?.cert || '',
tlsKey: parsed.tls?.key || '',
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
rmPanelRepo:
parsed['remote-management']?.['panel-github-repository'] ??
parsed['remote-management']?.['panel-repo'] ??
'',
authDir: parsed['auth-dir'] || '',
apiKeysText: parseApiKeysText(parsed['api-keys']),
debug: Boolean(parsed.debug),
commercialMode: Boolean(parsed['commercial-mode']),
loggingToFile: Boolean(parsed['logging-to-file']),
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: parsed['proxy-url'] || '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
),
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
streaming: {
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
},
};
setVisualValuesState(newValues);
baselineValues.current = deepClone(newValues);
} catch {
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
}
}, []);
const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => {
try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
const values = visualValues;
setString(parsed, 'host', values.host);
setIntFromString(parsed, 'port', values.port);
if (
hasOwn(parsed, 'tls') ||
values.tlsEnable ||
values.tlsCert.trim() ||
values.tlsKey.trim()
) {
const tls = ensureRecord(parsed, 'tls');
setBoolean(tls, 'enable', values.tlsEnable);
setString(tls, 'cert', values.tlsCert);
setString(tls, 'key', values.tlsKey);
deleteIfEmpty(parsed, 'tls');
}
if (
hasOwn(parsed, 'remote-management') ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmPanelRepo.trim()
) {
const rm = ensureRecord(parsed, 'remote-management');
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
setString(rm, 'panel-github-repository', values.rmPanelRepo);
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
deleteIfEmpty(parsed, 'remote-management');
}
setString(parsed, 'auth-dir', values.authDir);
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
.filter(Boolean);
if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys;
} else if (hasOwn(parsed, 'api-keys')) {
delete parsed['api-keys'];
}
}
setBoolean(parsed, 'debug', values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
const quota = ensureRecord(parsed, 'quota-exceeded');
quota['switch-project'] = values.quotaSwitchProject;
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
deleteIfEmpty(parsed, 'quota-exceeded');
}
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing');
routing.strategy = values.routingStrategy;
deleteIfEmpty(parsed, 'routing');
}
const keepaliveSeconds =
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
const bootstrapRetries =
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
const nonstreamKeepaliveInterval =
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
? values.streaming.nonstreamKeepaliveInterval
: '';
const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming');
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
deleteIfEmpty(parsed, 'streaming');
}
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
if (
hasOwn(parsed, 'payload') ||
values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0
) {
const payload = ensureRecord(parsed, 'payload');
if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
} else if (hasOwn(payload, 'default')) {
delete payload.default;
}
if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
} else if (hasOwn(payload, 'override')) {
delete payload.override;
}
if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
} else if (hasOwn(payload, 'filter')) {
delete payload.filter;
}
deleteIfEmpty(parsed, 'payload');
}
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return currentYaml;
}
},
[visualValues]
);
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;
});
}, []);
return {
visualValues,
visualDirty,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues,
};
}
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'antigravity', label: 'Antigravity' },
] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔' },
{ value: 'json', label: 'JSON' },
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;

View File

@@ -102,7 +102,7 @@
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics",
"config_management": "Config Management",
"config_management": "Config Panel",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
@@ -416,7 +416,12 @@
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
"card_tools_title": "Tools",
"quota_refresh_single": "Refresh quota",
"quota_refresh_hint": "Refresh quota for this credential only",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
"antigravity_quota": {
"title": "Antigravity Quota",
@@ -445,7 +450,8 @@
"fetch_all": "Fetch All",
"primary_window": "5-hour limit",
"secondary_window": "Weekly limit",
"code_review_window": "Code review limit",
"code_review_primary_window": "Code review 5-hour limit",
"code_review_secondary_window": "Code review weekly limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -543,11 +549,43 @@
"save_failed": "Failed to update model aliases",
"delete": "Delete Provider",
"delete_confirm": "Delete model aliases for {{provider}}?",
"delete_link_title": "Unlink mapping",
"delete_link_confirm": "Unlink mapping from <code>{{sourceModel}}</code> ({{provider}}) to alias <code>{{alias}}</code>?",
"delete_alias_title": "Delete Alias",
"delete_alias_confirm": "Delete alias <code>{{alias}}</code> and unmap all associated models?",
"delete_success": "Model aliases removed",
"delete_failed": "Failed to delete model aliases",
"no_models": "No model aliases",
"model_count": "{{count}} aliases",
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
"chart_title": "All mappings overview",
"diagram_providers": "Providers",
"diagram_source_models": "Source Models",
"diagram_aliases": "Aliases",
"diagram_expand": "Expand",
"diagram_collapse": "Collapse",
"diagram_add_alias": "Add Alias",
"diagram_rename": "Rename",
"diagram_rename_alias_title": "Rename alias",
"diagram_rename_alias_label": "New alias name",
"diagram_rename_placeholder": "Enter alias name...",
"diagram_delete_link": "Unlink from {{provider}} / {{name}}",
"diagram_delete_alias": "Delete alias",
"diagram_please_enter_alias": "Please enter an alias name.",
"diagram_alias_exists": "This alias already exists.",
"diagram_add_alias_title": "Add alias",
"diagram_add_alias_label": "Alias name",
"diagram_add_placeholder": "Enter new alias name...",
"diagram_rename_btn": "Rename",
"diagram_add_btn": "Add",
"diagram_settings": "Settings",
"diagram_settings_title": "Alias settings — {{alias}}",
"diagram_settings_source_title": "Source model settings",
"diagram_settings_empty": "No mappings for this alias yet.",
"diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.",
"view_mode": "View mode",
"view_mode_diagram": "Diagram",
"view_mode_list": "List",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
@@ -774,11 +812,11 @@
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},
"config_management": {
"title": "Config Management",
"title": "Config Panel",
"editor_title": "Configuration File",
"reload": "Reload",
"save": "Save",
"description": "View and edit the server-side config.yaml file. Validate the syntax before saving.",
"description": "Edit config.yaml via visual editor or source file",
"status_idle": "Waiting for action",
"status_loading": "Loading configuration...",
"status_loaded": "Configuration loaded",
@@ -795,7 +833,143 @@
"search_button": "Search",
"search_no_results": "No results",
"search_prev": "Previous",
"search_next": "Next"
"search_next": "Next",
"tabs": {
"visual": "Visual Editor",
"source": "Source File Editor"
},
"visual": {
"sections": {
"server": {
"title": "Server Configuration",
"description": "Basic server settings",
"host": "Host Address",
"port": "Port"
},
"tls": {
"title": "TLS/SSL Configuration",
"description": "HTTPS secure connection settings",
"enable": "Enable TLS",
"enable_desc": "Enable HTTPS secure connection",
"cert": "Certificate File Path",
"key": "Private Key File Path"
},
"remote": {
"title": "Remote Management",
"description": "Remote access and control panel settings",
"allow_remote": "Allow Remote Access",
"allow_remote_desc": "Allow management access from other hosts",
"disable_panel": "Disable Control Panel",
"disable_panel_desc": "Disable the built-in web control panel",
"secret_key": "Management Key",
"secret_key_placeholder": "Set management key",
"panel_repo": "Panel Repository"
},
"auth": {
"title": "Authentication Configuration",
"description": "API keys and authentication directory settings",
"auth_dir": "Auth Directory (auth-dir)",
"auth_dir_hint": "Directory path for authentication files (supports ~)"
},
"system": {
"title": "System Configuration",
"description": "Debug, logging, statistics, and performance settings",
"debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode",
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to rotating files",
"usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)"
},
"network": {
"title": "Network Configuration",
"description": "Proxy, retry, and routing settings",
"proxy_url": "Proxy URL",
"request_retry": "Request Retry Count",
"max_retry_interval": "Max Retry Interval (seconds)",
"routing_strategy": "Routing Strategy",
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
"strategy_fill_first": "Fill First",
"force_model_prefix": "Force Model Prefix",
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
"ws_auth": "WebSocket Authentication",
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
},
"quota": {
"title": "Quota Fallback",
"description": "Fallback strategy when quota is exceeded",
"switch_project": "Switch Project",
"switch_project_desc": "Automatically switch to another project when quota is exceeded",
"switch_preview_model": "Switch to Preview Model",
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded"
},
"streaming": {
"title": "Streaming Configuration",
"description": "Keepalive and bootstrap retry settings",
"keepalive_seconds": "Keepalive Seconds",
"keepalive_hint": "Set to 0 or leave empty to disable keepalive",
"bootstrap_retries": "Bootstrap Retries",
"bootstrap_hint": "Number of retries during stream startup (before first byte)",
"nonstream_keepalive": "Non-stream Keepalive Interval (seconds)",
"nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable",
"disabled": "Disabled"
},
"payload": {
"title": "Payload Configuration",
"description": "Default values, override rules, and filter rules",
"default_rules": "Default Rules",
"default_rules_desc": "Use these default values when parameters are not specified in the request",
"override_rules": "Override Rules",
"override_rules_desc": "Force override parameter values in the request",
"filter_rules": "Filter Rules",
"filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)"
}
},
"api_keys": {
"label": "API Keys List (api-keys)",
"add": "Add API Key",
"empty": "No API keys",
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
"edit_title": "Edit API Key",
"add_title": "Add API Key",
"input_label": "API Key",
"input_placeholder": "Paste your API key",
"input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface",
"error_empty": "Please enter an API key",
"error_invalid": "API key contains invalid characters"
},
"payload_rules": {
"rule": "Rule",
"models": "Applicable Models",
"model_name": "Model Name",
"provider_type": "Provider Type",
"add_model": "Add Model",
"params": "Parameter Settings",
"remove_params": "Remove Parameters",
"json_path": "JSON Path (e.g., temperature)",
"json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget",
"param_type": "Parameter Type",
"add_param": "Add Parameter",
"no_rules": "No rules",
"add_rule": "Add Rule",
"value_string": "String value",
"value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false",
"value_json": "JSON value",
"value_default": "Value"
},
"common": {
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"update": "Update",
"add": "Add"
}
}
},
"quota_management": {
"title": "Quota Management",

View File

@@ -102,7 +102,7 @@
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计",
"config_management": "配置管理",
"config_management": "配置面板",
"logs": "日志查看",
"system_info": "中心信息"
},
@@ -416,7 +416,12 @@
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
"card_tools_title": "配置面板",
"quota_refresh_single": "刷新额度",
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
"antigravity_quota": {
"title": "Antigravity 额度",
@@ -445,7 +450,8 @@
"fetch_all": "获取全部",
"primary_window": "5 小时限额",
"secondary_window": "周限额",
"code_review_window": "代码审查限额",
"code_review_primary_window": "代码审查 5 小时限额",
"code_review_secondary_window": "代码审查周限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -543,11 +549,43 @@
"save_failed": "更新模型别名失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
"delete_link_title": "取消链接",
"delete_link_confirm": "确定取消 <code>{{sourceModel}}</code>{{provider}})到别名 <code>{{alias}}</code> 的映射?",
"delete_alias_title": "删除别名",
"delete_alias_confirm": "确定删除别名 <code>{{alias}}</code> 并取消所有关联模型的映射?",
"delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型别名失败",
"no_models": "未配置模型别名",
"model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"chart_title": "全部映射概览",
"diagram_providers": "提供商",
"diagram_source_models": "源模型",
"diagram_aliases": "别名",
"diagram_expand": "展开",
"diagram_collapse": "收起",
"diagram_add_alias": "添加别名",
"diagram_rename": "重命名",
"diagram_rename_alias_title": "重命名别名",
"diagram_rename_alias_label": "新别名名称",
"diagram_rename_placeholder": "输入别名名称...",
"diagram_delete_link": "取消链接 {{provider}} / {{name}}",
"diagram_delete_alias": "删除别名",
"diagram_please_enter_alias": "请输入别名名称。",
"diagram_alias_exists": "该别名已存在。",
"diagram_add_alias_title": "添加别名",
"diagram_add_alias_label": "别名名称",
"diagram_add_placeholder": "输入新别名名称...",
"diagram_rename_btn": "重命名",
"diagram_add_btn": "添加",
"diagram_settings": "设置",
"diagram_settings_title": "别名设置 — {{alias}}",
"diagram_settings_source_title": "源模型设置",
"diagram_settings_empty": "该别名暂无映射。",
"diagram_tap_hint": "触摸设备上:先点选源模型,再点选别名即可建立映射。",
"view_mode": "视图模式",
"view_mode_diagram": "概览",
"view_mode_list": "管理",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
@@ -774,11 +812,11 @@
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},
"config_management": {
"title": "配置管理",
"title": "配置面板",
"editor_title": "配置文件",
"reload": "重新加载",
"save": "保存",
"description": "查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。",
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
"status_idle": "等待操作",
"status_loading": "加载配置中...",
"status_loaded": "配置已加载",
@@ -795,7 +833,143 @@
"search_button": "搜索",
"search_no_results": "无结果",
"search_prev": "上一个",
"search_next": "下一个"
"search_next": "下一个",
"tabs": {
"visual": "可视化编辑",
"source": "源文件编辑"
},
"visual": {
"sections": {
"server": {
"title": "服务器配置",
"description": "基础服务器设置",
"host": "主机地址",
"port": "端口"
},
"tls": {
"title": "TLS/SSL 配置",
"description": "HTTPS 安全连接设置",
"enable": "启用 TLS",
"enable_desc": "启用 HTTPS 安全连接",
"cert": "证书文件路径",
"key": "私钥文件路径"
},
"remote": {
"title": "远程管理",
"description": "远程访问和控制面板设置",
"allow_remote": "允许远程访问",
"allow_remote_desc": "允许从其他主机访问管理接口",
"disable_panel": "禁用控制面板",
"disable_panel_desc": "禁用内置的 Web 控制面板",
"secret_key": "管理密钥",
"secret_key_placeholder": "设置管理密钥",
"panel_repo": "面板仓库"
},
"auth": {
"title": "认证配置",
"description": "API 密钥与认证文件目录设置",
"auth_dir": "认证文件目录 (auth-dir)",
"auth_dir_hint": "存放认证文件的目录路径(支持 ~"
},
"system": {
"title": "系统配置",
"description": "调试、日志、统计与性能调试设置",
"debug": "调试模式",
"debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式",
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到滚动文件",
"usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)"
},
"network": {
"title": "网络配置",
"description": "代理、重试和路由设置",
"proxy_url": "代理 URL",
"request_retry": "请求重试次数",
"max_retry_interval": "最大重试间隔 (秒)",
"routing_strategy": "路由策略",
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
"strategy_fill_first": "填充优先 (Fill First)",
"force_model_prefix": "强制模型前缀",
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
"ws_auth": "WebSocket 认证",
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
},
"quota": {
"title": "配额回退",
"description": "配额耗尽时的回退策略",
"switch_project": "切换项目",
"switch_project_desc": "配额耗尽时自动切换到其他项目",
"switch_preview_model": "切换预览模型",
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型"
},
"streaming": {
"title": "流式传输配置",
"description": "Keepalive 与 bootstrap 重试设置",
"keepalive_seconds": "Keepalive 秒数",
"keepalive_hint": "设置为 0 或留空表示禁用 keepalive",
"bootstrap_retries": "Bootstrap 重试次数",
"bootstrap_hint": "流式传输启动时(首包前)的重试次数",
"nonstream_keepalive": "非流式 Keepalive 间隔 (秒)",
"nonstream_keepalive_hint": "非流式响应时每隔 N 秒发送空行以防止空闲超时,设置为 0 或留空表示禁用",
"disabled": "已禁用"
},
"payload": {
"title": "Payload 配置",
"description": "默认值、覆盖规则与过滤规则",
"default_rules": "默认规则",
"default_rules_desc": "当请求中未指定参数时,使用这些默认值",
"override_rules": "覆盖规则",
"override_rules_desc": "强制覆盖请求中的参数值",
"filter_rules": "过滤规则",
"filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数Request Sanitization"
}
},
"api_keys": {
"label": "API 密钥列表 (api-keys)",
"add": "添加 API 密钥",
"empty": "暂无 API 密钥",
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
"edit_title": "编辑 API 密钥",
"add_title": "添加 API 密钥",
"input_label": "API 密钥",
"input_placeholder": "粘贴你的 API 密钥",
"input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口",
"error_empty": "请输入 API 密钥",
"error_invalid": "API 密钥包含无效字符"
},
"payload_rules": {
"rule": "规则",
"models": "适用模型",
"model_name": "模型名称",
"provider_type": "供应商类型",
"add_model": "添加模型",
"params": "参数设置",
"remove_params": "移除参数",
"json_path": "JSON 路径 (如 temperature)",
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
"param_type": "参数类型",
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
"value_string": "字符串值",
"value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false",
"value_json": "JSON 值",
"value_default": "值"
},
"common": {
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"update": "更新",
"add": "添加"
}
}
},
"quota_management": {
"title": "配额管理",

View File

@@ -27,10 +27,9 @@
display: flex;
flex-direction: column;
gap: $spacing-xl;
@include mobile {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
padding-bottom: calc(
var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.section {

View File

@@ -1,56 +0,0 @@
@use '../styles/mixins' as *;
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.actions {
display: flex;
gap: $spacing-sm;
}
.emptyState {
text-align: center;
padding: $spacing-2xl;
color: var(--text-secondary);
i {
font-size: 48px;
margin-bottom: $spacing-md;
opacity: 0.5;
}
h3 {
margin: 0 0 $spacing-sm 0;
color: var(--text-primary);
}
p {
margin: 0;
}
}

View File

@@ -1,246 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [apiKeys, setApiKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
const loadApiKeys = useCallback(
async (force = false) => {
setLoading(true);
setError('');
try {
const result = (await fetchConfig('api-keys', force)) as string[] | undefined;
const list = Array.isArray(result) ? result : [];
setApiKeys(list);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
},
[fetchConfig, t]
);
useEffect(() => {
loadApiKeys();
}, [loadApiKeys]);
useEffect(() => {
if (Array.isArray(config?.apiKeys)) {
setApiKeys(config.apiKeys);
}
}, [config?.apiKeys]);
const openAddModal = () => {
setEditingIndex(null);
setInputValue('');
setModalOpen(true);
};
const openEditModal = (index: number) => {
setEditingIndex(index);
setInputValue(apiKeys[index] ?? '');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setInputValue('');
setEditingIndex(null);
};
const handleSave = async () => {
const trimmed = inputValue.trim();
if (!trimmed) {
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
return;
}
if (!isValidApiKeyCharset(trimmed)) {
showNotification(t('notification.api_key_invalid_chars'), 'error');
return;
}
const isEdit = editingIndex !== null;
const nextKeys = isEdit
? apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key))
: [...apiKeys, trimmed];
setSaving(true);
try {
if (isEdit && editingIndex !== null) {
await apiKeysApi.update(editingIndex, trimmed);
showNotification(t('notification.api_key_updated'), 'success');
} else {
await apiKeysApi.replace(nextKeys);
showNotification(t('notification.api_key_added'), 'success');
}
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const handleDelete = (index: number) => {
const apiKeyToDelete = apiKeys[index];
if (!apiKeyToDelete) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
showConfirmation({
title: t('common.delete'),
message: t('api_keys.delete_confirm'),
variant: 'danger',
onConfirm: async () => {
const latestKeys = useConfigStore.getState().config?.apiKeys;
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
const deleteIndex =
currentKeys[index] === apiKeyToDelete
? index
: currentKeys.findIndex((key) => key === apiKeyToDelete);
if (deleteIndex < 0) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
try {
await apiKeysApi.delete(deleteIndex);
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
}
});
};
const actionButtons = (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" size="sm" onClick={() => loadApiKeys(true)} disabled={loading}>
{t('common.refresh')}
</Button>
<Button size="sm" onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
</div>
);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('api_keys.title')}</h1>
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="flex-center" style={{ padding: '24px 0' }}>
<LoadingSpinner size={28} />
</div>
) : apiKeys.length === 0 ? (
<EmptyState
title={t('api_keys.empty_title')}
description={t('api_keys.empty_desc')}
action={
<Button onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
}
/>
) : (
<div className="item-list">
{apiKeys.map((key, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<div className="pill">#{index + 1}</div>
<div className="item-title">{t('api_keys.item_title')}</div>
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disableControls}>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(index)}
disabled={disableControls}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
)}
<Modal
open={modalOpen}
onClose={closeModal}
title={editingIndex !== null ? t('api_keys.edit_modal_title') : t('api_keys.add_modal_title')}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={saving}>
{editingIndex !== null ? t('common.update') : t('common.add')}
</Button>
</>
}
>
<Input
label={
editingIndex !== null ? t('api_keys.edit_modal_key_label') : t('api_keys.add_modal_key_label')
}
placeholder={
editingIndex !== null
? t('api_keys.edit_modal_key_label')
: t('api_keys.add_modal_key_placeholder')
}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={saving}
/>
</Modal>
</Card>
</div>
);
}

View File

@@ -40,7 +40,7 @@ const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
alias: '',
fork: false,
fork: true,
});
const normalizeMappingEntries = (

View File

@@ -184,6 +184,18 @@
}
}
.fileGridQuotaManaged {
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
@include tablet {
grid-template-columns: 1fr;
}
@include mobile {
grid-template-columns: 1fr;
}
}
.antigravityGrid {
display: grid;
gap: $spacing-md;
@@ -469,6 +481,66 @@
}
}
.fileCardLayout {
display: flex;
align-items: stretch;
gap: $spacing-md;
}
.fileCardLayoutQuota {
display: grid;
grid-template-columns: 1fr 156px;
gap: $spacing-md;
align-items: stretch;
@include mobile {
grid-template-columns: 1fr;
}
}
.fileCardMain {
display: flex;
flex-direction: column;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.fileCardSidebar {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-left: $spacing-md;
border-left: 1px dashed var(--border-color);
@include mobile {
border-left: none;
border-top: 1px dashed var(--border-color);
padding-left: 0;
padding-top: $spacing-md;
}
}
.fileCardSidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-xs;
}
.fileCardSidebarTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.fileCardSidebarHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.cardHeader {
display: flex;
align-items: center;
@@ -843,6 +915,49 @@
}
}
// OAuth 模型别名 - 映射概览
.aliasChartSection {
margin-bottom: $spacing-lg;
padding-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.aliasChartHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
.aliasChartTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.aliasChart {
width: 100%;
min-height: 120px;
}
.cardExtraButtons {
display: flex;
gap: $spacing-sm;
align-items: center;
}
.viewModeSwitch {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: 2px;
border-radius: $radius-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
// OAuth 模型映射表单
.mappingRow {
display: grid;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useEffect, useMemo, useRef, useState, useCallback, type ReactNode } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
@@ -10,17 +10,23 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
import {
IconBot,
IconCode,
IconChevronUp,
IconDownload,
IconInfo,
IconRefreshCw,
IconTrash2,
} from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import { getStatusFromError, resolveAuthProvider } from '@/utils/quota';
import {
calculateStatusBarData,
collectUsageDetails,
@@ -89,6 +95,49 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};
type QuotaProgressBarProps = {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
};
function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${fillClass}`}
style={{ width: `${widthPercent}%` }}
/>
</div>
);
}
type AuthFilesUiState = {
filter?: string;
search?: string;
@@ -193,6 +242,12 @@ export function AuthFilesPage() {
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
const codexQuota = useQuotaStore((state) => state.codexQuota);
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const navigate = useNavigate();
const [files, setFiles] = useState<AuthFileItem[]>([]);
@@ -230,6 +285,10 @@ export function AuthFilesPage() {
// OAuth 模型映射相关
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
{}
);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
@@ -237,10 +296,81 @@ export function AuthFilesPage() {
const loadingKeyStatsRef = useRef(false);
const excludedUnsupportedRef = useRef(false);
const mappingsUnsupportedRef = useRef(false);
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
const disableControls = connectionStatus !== 'connected';
const normalizedFilter = normalizeProviderKey(String(filter));
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
normalizedFilter as QuotaProviderType
)
? (normalizedFilter as QuotaProviderType)
: null;
const providerList = useMemo(() => {
const providers = new Set<string>();
Object.keys(modelAlias).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key) providers.add(key);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
const key = file.type.trim().toLowerCase();
if (key) providers.add(key);
}
if (typeof file.provider === 'string') {
const key = file.provider.trim().toLowerCase();
if (key) providers.add(key);
}
});
return Array.from(providers);
}, [files, modelAlias]);
useEffect(() => {
if (viewMode !== 'diagram') return;
let cancelled = false;
const loadAllModels = async () => {
if (providerList.length === 0) {
if (!cancelled) setAllProviderModels({});
return;
}
const results = await Promise.all(
providerList.map(async (provider) => {
try {
const models = await authFilesApi.getModelDefinitions(provider);
return { provider, models };
} catch {
return { provider, models: [] };
}
})
);
if (cancelled) return;
const nextModels: Record<string, AuthFileModelItem[]> = {};
results.forEach(({ provider, models }) => {
if (models.length > 0) {
nextModels[provider] = models;
}
});
setAllProviderModels(nextModels);
};
void loadAllModels();
return () => {
cancelled = true;
};
}, [providerList, viewMode]);
useEffect(() => {
const persisted = readAuthFilesUiState();
@@ -603,7 +733,9 @@ export function AuthFilesPage() {
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
} else {
// 删除筛选类型的文件
const filesToDelete = files.filter((f) => f.type === filter && !isRuntimeOnlyAuthFile(f));
const filesToDelete = files.filter(
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
);
if (filesToDelete.length === 0) {
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
@@ -991,6 +1123,247 @@ export function AuthFilesPage() {
});
};
const handleMappingUpdate = async (provider: string, sourceModel: string, newAlias: string) => {
if (!provider || !sourceModel || !newAlias) return;
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameTrim = sourceModel.trim();
const aliasTrim = newAlias.trim();
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
if (
currentMappings.some(
(m) =>
(m.name ?? '').trim().toLowerCase() === nameKey &&
(m.alias ?? '').trim().toLowerCase() === aliasKey
)
) {
return;
}
const nextMappings: OAuthModelAliasEntry[] = [
...currentMappings,
{ name: nameTrim, alias: aliasTrim, fork: true },
];
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
};
const handleDeleteLink = (provider: string, sourceModel: string, alias: string) => {
const nameTrim = sourceModel.trim();
const aliasTrim = alias.trim();
if (!provider || !nameTrim || !aliasTrim) return;
showConfirmation({
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_link_confirm"
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const normalizedProvider = normalizeProviderKey(provider);
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
const nextMappings = currentMappings.filter(
(m) =>
(m.name ?? '').trim().toLowerCase() !== nameKey ||
(m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === currentMappings.length) return;
try {
if (nextMappings.length === 0) {
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
} else {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
}
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
});
};
const handleToggleFork = async (
provider: string,
sourceModel: string,
alias: string,
fork: boolean
) => {
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = sourceModel.trim().toLowerCase();
const aliasKey = alias.trim().toLowerCase();
let changed = false;
const nextMappings = currentMappings.map((m) => {
const mName = (m.name ?? '').trim().toLowerCase();
const mAlias = (m.alias ?? '').trim().toLowerCase();
if (mName === nameKey && mAlias === aliasKey) {
changed = true;
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
}
return m;
});
if (!changed) return;
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
};
const handleRenameAlias = async (oldAlias: string, newAlias: string) => {
const oldTrim = oldAlias.trim();
const newTrim = newAlias.trim();
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
const oldKey = oldTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
);
if (providersToUpdate.length === 0) return;
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.map((m) =>
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
);
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
: t('oauth_model_alias.save_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.save_success'), 'success');
}
};
const handleDeleteAlias = (aliasName: string) => {
const aliasTrim = aliasName.trim();
if (!aliasTrim) return;
const aliasKey = aliasTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
);
if (providersToUpdate.length === 0) return;
showConfirmation({
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_alias_confirm"
values={{ alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.filter(
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === 0) {
return authFilesApi.deleteOauthModelAlias(provider);
}
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
: t('oauth_model_alias.delete_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.delete_success'), 'success');
}
},
});
};
// 渲染标签筛选器
const renderFilterTags = () => (
<div className={styles.filterTags}>
@@ -1083,128 +1456,293 @@ export function AuthFilesPage() {
);
};
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
const provider = resolveAuthProvider(file);
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
return provider as QuotaProviderType;
};
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
return GEMINI_CLI_CONFIG;
};
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
if (type === 'antigravity') return antigravityQuota[fileName];
if (type === 'codex') return codexQuota[fileName];
return geminiCliQuota[fileName];
};
const updateQuotaState = useCallback(
(
type: QuotaProviderType,
updater: (prev: Record<string, unknown>) => Record<string, unknown>
) => {
if (type === 'antigravity') {
setAntigravityQuota(updater as never);
return;
}
if (type === 'codex') {
setCodexQuota(updater as never);
return;
}
setGeminiCliQuota(updater as never);
},
[setAntigravityQuota, setCodexQuota, setGeminiCliQuota]
);
const refreshQuotaForFile = useCallback(
async (file: AuthFileItem, quotaType: QuotaProviderType) => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
const currentState = getQuotaState(quotaType, file.name);
if (currentState?.status === 'loading') return;
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
buildLoadingState: () => unknown;
buildSuccessState: (data: unknown) => unknown;
buildErrorState: (message: string, status?: number) => unknown;
};
updateQuotaState(quotaType, (prev) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
updateQuotaState(quotaType, (prev) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
updateQuotaState(quotaType, (prev) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(
t('auth_files.quota_refresh_failed', { name: file.name, message }),
'error'
);
}
},
[disableControls, getQuotaState, showNotification, t, updateQuotaState]
);
const renderQuotaSection = (item: AuthFileItem, quotaType: QuotaProviderType) => {
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
const quota = getQuotaState(quotaType, item.name) as
| { status?: string; error?: string; errorStatus?: number }
| undefined;
const quotaStatus = quota?.status ?? 'idle';
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
return (
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
) : (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
)}
</div>
);
};
// 渲染单个认证文件卡片
const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(item.type || 'unknown');
const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(item.type || 'unknown');
return (
<div
key={item.name}
className={`${styles.fileCard} ${item.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(item.type || 'unknown')}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
const quotaType =
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(item)}
</span>
</div>
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
const quotaRefreshing = quotaState?.status === 'loading';
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
{/* 状态监测栏 */}
{renderStatusBar(item)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showDetails(item)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
return (
<div
key={item.name}
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
>
<div
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
<IconInfo className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleDownload(item.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void openPrefixProxyEditor(item.name)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconCode className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(item.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === item.name}
>
{deleting === item.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!item.disabled}
disabled={disableControls || statusUpdating[item.name] === true}
onChange={(value) => void handleStatusToggle(item, value)}
/>
{getTypeLabel(item.type || 'unknown')}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>
{t('auth_files.type_virtual') || '虚拟认证文件'}
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(item)}
</span>
</div>
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
{/* 状态监测栏 */}
{renderStatusBar(item)}
{showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => showModels(item)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => showDetails(item)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
>
<IconInfo className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleDownload(item.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void openPrefixProxyEditor(item.name)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconCode className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(item.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === item.name}
>
{deleting === item.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!item.disabled}
disabled={disableControls || statusUpdating[item.name] === true}
onChange={(value) => void handleStatusToggle(item, value)}
/>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>
{t('auth_files.type_virtual') || '虚拟认证文件'}
</div>
)}
</div>
</div>
{showQuotaLayout && quotaType && (
<div className={styles.fileCardSidebar}>
<div className={styles.fileCardSidebarHeader}>
<span className={styles.fileCardSidebarTitle}>
{t('auth_files.card_tools_title')}
</span>
<Button
variant="secondary"
size="sm"
className={styles.iconButton}
onClick={() => void refreshQuotaForFile(item, quotaType)}
disabled={disableControls || item.disabled}
loading={quotaRefreshing}
title={t('auth_files.quota_refresh_single')}
aria-label={t('auth_files.quota_refresh_single')}
>
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
</Button>
</div>
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
</div>
)}
</div>
@@ -1311,7 +1849,11 @@ export function AuthFilesPage() {
description={t('auth_files.search_empty_desc')}
/>
) : (
<div className={styles.fileGrid}>{pageItems.map(renderFileCard)}</div>
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
>
{pageItems.map(renderFileCard)}
</div>
)}
{/* 分页 */}
@@ -1377,7 +1919,11 @@ export function AuthFilesPage() {
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openExcludedEditor(provider)}>
<Button
variant="secondary"
size="sm"
onClick={() => openExcludedEditor(provider)}
>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
@@ -1394,13 +1940,33 @@ export function AuthFilesPage() {
<Card
title={t('oauth_model_alias.title')}
extra={
<Button
size="sm"
onClick={() => openModelAliasEditor()}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.add')}
</Button>
<div className={styles.cardExtraButtons}>
<div className={styles.viewModeSwitch}>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_list')}
</Button>
<Button
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('diagram')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_diagram')}
</Button>
</div>
<Button
size="sm"
onClick={() => openModelAliasEditor()}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.add')}
</Button>
</div>
}
>
{modelAliasError === 'unsupported' ? (
@@ -1408,6 +1974,39 @@ export function AuthFilesPage() {
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : viewMode === 'diagram' ? (
Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.aliasChartSection}>
<div className={styles.aliasChartHeader}>
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => diagramRef.current?.collapseAll()}
disabled={disableControls || modelAliasError === 'unsupported'}
title={t('oauth_model_alias.diagram_collapse')}
aria-label={t('oauth_model_alias.diagram_collapse')}
>
<IconChevronUp size={16} />
</Button>
</div>
<ModelMappingDiagram
ref={diagramRef}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={handleMappingUpdate}
onDeleteLink={handleDeleteLink}
onToggleFork={handleToggleFork}
onRenameAlias={handleRenameAlias}
onDeleteAlias={handleDeleteAlias}
onEditProvider={openModelAliasEditor}
onDeleteProvider={deleteModelAlias}
className={styles.aliasChart}
/>
</div>
)
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
@@ -1625,7 +2224,6 @@ export function AuthFilesPage() {
</div>
)}
</Modal>
</div>
);
}

View File

@@ -6,6 +6,9 @@
display: flex;
flex-direction: column;
overflow-y: auto;
padding-bottom: calc(
var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.pageTitle {
@@ -21,6 +24,76 @@
margin: 0 0 $spacing-xl 0;
}
.tabBar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
margin-bottom: $spacing-lg;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: $radius-full;
width: fit-content;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@include mobile {
width: 100%;
.tabItem {
flex: 1;
}
}
}
.tabItem {
@include button-reset;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
background: transparent;
border: 1px solid transparent;
border-radius: $radius-full;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.tabActive {
color: var(--text-primary);
background: var(--bg-primary);
border-color: var(--border-color);
box-shadow: var(--shadow);
}
.content {
display: flex;
flex-direction: column;
@@ -242,6 +315,130 @@
}
}
.floatingActionContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.floatingActionList {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
max-width: inherit;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.floatingStatus {
font-size: 11px;
font-weight: 600;
padding: 5px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
text-align: center;
max-width: min(280px, 46vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.floatingActionButton {
@include button-reset;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active:not(:disabled) {
transform: scale(0.95);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
}
.dirtyDot {
position: absolute;
top: 8px;
right: 8px;
width: 7px;
height: 7px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
}
:global([data-theme='dark']) {
.floatingActionList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.floatingStatus {
background: rgba(255, 255, 255, 0.08);
}
.floatingActionButton {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
}
}
@media (max-width: 1200px) {
.floatingActionContainer {
bottom: calc(12px + env(safe-area-inset-bottom));
max-width: calc(100vw - 16px);
}
.floatingActionList {
gap: 6px;
padding: 8px 10px;
}
.floatingStatus {
display: none;
}
.floatingActionButton {
width: 38px;
height: 38px;
flex: 0 0 auto;
}
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, 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';
@@ -7,17 +8,35 @@ import { keymap } from '@codemirror/view';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons';
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const {
visualValues,
visualDirty,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues
} = useVisualConfig();
const [activeTab, setActiveTab] = useState<ConfigEditorTab>(() => {
const saved = localStorage.getItem('config-management:tab');
if (saved === 'visual' || saved === 'source') return saved;
return 'visual';
});
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -31,8 +50,10 @@ export function ConfigPage() {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const floatingControlsRef = useRef<HTMLDivElement>(null);
const editorWrapperRef = useRef<HTMLDivElement>(null);
const floatingActionsRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected';
const isDirty = dirty || visualDirty;
const loadConfig = useCallback(async () => {
setLoading(true);
@@ -41,13 +62,14 @@ export function ConfigPage() {
const data = await configFileApi.fetchConfigYaml();
setContent(data);
setDirty(false);
loadVisualValuesFromYaml(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(message);
} finally {
setLoading(false);
}
}, [t]);
}, [loadVisualValuesFromYaml, t]);
useEffect(() => {
loadConfig();
@@ -56,8 +78,12 @@ export function ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
await configFileApi.saveConfigYaml(content);
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
await configFileApi.saveConfigYaml(nextContent);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setContent(latestContent);
loadVisualValuesFromYaml(latestContent);
showNotification(t('config_management.save_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
@@ -72,6 +98,23 @@ export function ConfigPage() {
setDirty(true);
}, []);
const handleTabChange = useCallback((tab: ConfigEditorTab) => {
if (tab === activeTab) return;
if (tab === 'source') {
const nextContent = applyVisualChangesToYaml(content);
if (nextContent !== content) {
setContent(nextContent);
setDirty(true);
}
} else {
loadVisualValuesFromYaml(content);
}
setActiveTab(tab);
localStorage.setItem('config-management:tab', tab);
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]);
// Search functionality
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
if (!query || !editorRef.current?.view) return;
@@ -173,6 +216,8 @@ export function ConfigPage() {
// Keep floating controls from covering editor content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (activeTab !== 'source') return;
const controlsEl = floatingControlsRef.current;
const wrapperEl = editorWrapperRef.current;
if (!controlsEl || !wrapperEl) return;
@@ -192,6 +237,31 @@ export function ConfigPage() {
ro?.disconnect();
window.removeEventListener('resize', updatePadding);
};
}, [activeTab]);
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
const actionsEl = floatingActionsRef.current;
if (!actionsEl) return;
const updatePadding = () => {
const height = actionsEl.getBoundingClientRect().height;
document.documentElement.style.setProperty('--config-action-bar-height', `${height}px`);
};
updatePadding();
window.addEventListener('resize', updatePadding);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
ro?.observe(actionsEl);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--config-action-bar-height');
};
}, []);
// CodeMirror extensions
@@ -208,131 +278,185 @@ export function ConfigPage() {
if (loading) return t('config_management.status_loading');
if (error) return t('config_management.status_load_failed');
if (saving) return t('config_management.status_saving');
if (dirty) return t('config_management.status_dirty');
if (isDirty) return t('config_management.status_dirty');
return t('config_management.status_loaded');
};
const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty;
const getStatusClass = () => {
if (error) return styles.error;
if (dirty) return styles.modified;
if (isDirty) return styles.modified;
if (!loading && !saving) return styles.saved;
return '';
};
const floatingActions = (
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
<div className={styles.floatingActionList}>
<div className={`${styles.floatingStatus} ${styles.status} ${getStatusClass()}`}>{getStatusText()}</div>
<button
type="button"
className={styles.floatingActionButton}
onClick={loadConfig}
disabled={loading}
title={t('config_management.reload')}
aria-label={t('config_management.reload')}
>
<IconRefreshCw size={16} />
</button>
<button
type="button"
className={styles.floatingActionButton}
onClick={handleSave}
disabled={disableControls || loading || saving || !isDirty}
title={t('config_management.save')}
aria-label={t('config_management.save')}
>
<IconCheck size={16} />
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
</button>
</div>
</div>
);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('visual')}
disabled={saving || loading}
>
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('source')}
disabled={saving || loading}
>
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
</button>
</div>
<Card className={styles.configCard}>
<div className={styles.content}>
{/* Editor */}
{error && <div className="error-box">{error}</div>}
<div className={styles.editorWrapper} ref={editorWrapperRef}>
{/* Floating search controls */}
<div className={styles.floatingControls} ref={floatingControlsRef}>
<div className={styles.searchInputWrapper}>
<Input
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', {
defaultValue: '搜索配置内容...'
})}
disabled={disableControls || loading}
className={styles.searchInput}
rightElement={
<div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}>
{searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', { defaultValue: '无结果' })}
</span>
)}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<IconSearch size={16} />
</button>
</div>
}
/>
</div>
<div className={styles.searchActions}>
<Button
variant="secondary"
size="sm"
onClick={handlePrevMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_prev', { defaultValue: '上一个' })}
>
<IconChevronUp size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_next', { defaultValue: '下一个' })}
>
<IconChevronDown size={16} />
</Button>
</div>
</div>
<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
}}
{activeTab === 'visual' ? (
<VisualConfigEditor
values={visualValues}
disabled={disableControls || loading}
onChange={setVisualValues}
/>
</div>
) : (
<div className={styles.editorWrapper} ref={editorWrapperRef}>
{/* Floating search controls */}
<div className={styles.floatingControls} ref={floatingControlsRef}>
<div className={styles.searchInputWrapper}>
<Input
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', {
defaultValue: '搜索配置内容...'
})}
disabled={disableControls || loading}
className={styles.searchInput}
rightElement={
<div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}>
{searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', { defaultValue: '无结果' })}
</span>
)}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<IconSearch size={16} />
</button>
</div>
}
/>
</div>
<div className={styles.searchActions}>
<Button
variant="secondary"
size="sm"
onClick={handlePrevMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_prev', { defaultValue: '上一个' })}
>
<IconChevronUp size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_next', { defaultValue: '下一个' })}
>
<IconChevronDown size={16} />
</Button>
</div>
</div>
<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
}}
/>
</div>
)}
{/* Controls */}
<div className={styles.controls}>
<span className={`${styles.status} ${getStatusClass()}`}>
{getStatusText()}
</span>
<div className={styles.actions}>
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
{t('config_management.reload')}
</Button>
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
{t('config_management.save')}
</Button>
</div>
{!isLoadedStatus && (
<span className={`${styles.status} ${getStatusClass()}`}>
{getStatusText()}
</span>
)}
</div>
</div>
</Card>
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
</div>
);
}

View File

@@ -172,12 +172,12 @@ export function DashboardPage() {
const quickStats: QuickStat[] = [
{
label: t('nav.api_keys'),
label: t('dashboard.management_keys'),
value: stats.apiKeys ?? '-',
icon: <IconKey size={24} />,
path: '/api-keys',
path: '/config',
loading: loading && stats.apiKeys === null,
sublabel: t('dashboard.management_keys')
sublabel: t('nav.config_management')
},
{
label: t('nav.ai_providers'),
@@ -309,7 +309,7 @@ export function DashboardPage() {
</div>
)}
</div>
<Link to="/settings" className={styles.viewMoreLink}>
<Link to="/config" className={styles.viewMoreLink}>
{t('dashboard.edit_settings')}
</Link>
</div>

View File

@@ -1,4 +1,4 @@
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
@@ -643,6 +643,29 @@ export function LogsPage() {
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const prependVisibleLines = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (pendingPrependScrollRef.current) return;
if (isSearching) return;
setLogState((prev) => {
if (prev.visibleFrom <= 0) {
return prev;
}
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
return {
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
};
});
}, [isSearching]);
const handleLogScroll = () => {
const node = logViewerRef.current;
if (!node) return;
@@ -651,14 +674,7 @@ export function LogsPage() {
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
}));
prependVisibleLines();
};
useLayoutEffect(() => {
@@ -671,6 +687,53 @@ export function LogsPage() {
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (!canLoadMore) return;
if (isSearching) return;
if (pendingPrependScrollRef.current) return;
const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1;
if (hasVerticalOverflow) return;
prependVisibleLines();
}, [canLoadMore, isSearching, prependVisibleLines]);
useEffect(() => {
if (loading) return;
if (activeTab !== 'logs') return;
const raf = window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
return () => {
window.cancelAnimationFrame(raf);
};
}, [
activeTab,
loading,
tryAutoLoadMoreUntilScrollable,
filteredLines.length,
showRawLogs,
logState.visibleFrom,
]);
useEffect(() => {
if (activeTab !== 'logs') return;
const onResize = () => {
window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [activeTab, tryAutoLoadMoreUntilScrollable]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {

View File

@@ -1,164 +0,0 @@
@use '../../styles/mixins' as *;
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.grid {
display: grid;
gap: $spacing-lg;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
}
.settingInfo {
flex: 1;
h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 $spacing-xs 0;
}
p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .slider {
background-color: var(--primary-color);
&:before {
transform: translateX(24px);
}
}
&:focus + .slider {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: $transition-fast;
border-radius: $radius-full;
&:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: $transition-fast;
border-radius: $radius-full;
}
}
.formGroup {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.buttonGroup {
display: flex;
gap: $spacing-sm;
}
.retryRow {
display: flex;
align-items: flex-end;
gap: $spacing-md;
flex-wrap: wrap;
:global(.form-group) {
margin-bottom: 0;
}
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.retryRowAligned {
align-items: flex-start;
.retryButton {
margin-top: calc(1.5em + #{$spacing-xs});
}
@include mobile {
align-items: stretch;
.retryButton {
margin-top: 0;
}
}
}
.retryRowInputGrow {
:global(.form-group) {
flex: 1 1 0;
min-width: 0;
}
.retryInput {
width: 100%;
}
}
.retryInput {
width: 140px;
@include mobile {
width: 100%;
}
}
.retryButton {
@include mobile {
width: 100%;
}
}

View File

@@ -1,477 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { configApi } from '@/services/api';
import type { Config } from '@/types';
import styles from './Settings/Settings.module.scss';
type PendingKey =
| 'debug'
| 'proxy'
| 'retry'
| 'logsMaxSize'
| 'forceModelPrefix'
| 'routingStrategy'
| 'switchProject'
| 'switchPreview'
| 'usage'
| 'loggingToFile'
| 'wsAuth';
export function SettingsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0);
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState('');
const disableControls = connectionStatus !== 'connected';
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
fetchConfig(),
configApi.getLogsMaxTotalSizeMb(),
configApi.getForceModelPrefix(),
configApi.getRoutingStrategy(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value as Config;
setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
}
if (prefixResult.status === 'fulfilled') {
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
}
if (routingResult.status === 'fulfilled' && routingResult.value) {
setRoutingStrategy(String(routingResult.value));
updateConfigValue('routing/strategy', String(routingResult.value));
}
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
load();
}, [fetchConfig, t, updateConfigValue]);
useEffect(() => {
if (config) {
setProxyValue(config.proxyUrl ?? '');
if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry);
}
if (typeof config.logsMaxTotalSizeMb === 'number') {
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
}
if (config.routingStrategy) {
setRoutingStrategy(config.routingStrategy);
}
}
}, [config]);
const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value }));
};
const toggleSetting = async (
section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
value: boolean,
updater: (val: boolean) => Promise<any>,
successMessage: string
) => {
const previous = (() => {
switch (rawKey) {
case 'debug':
return config?.debug ?? false;
case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false;
case 'logging-to-file':
return config?.loggingToFile ?? false;
case 'ws-auth':
return config?.wsAuth ?? false;
case 'force-model-prefix':
return config?.forceModelPrefix ?? false;
default:
return false;
}
})();
setPendingFlag(section, true);
updateConfigValue(rawKey, value);
try {
await updater(value);
clearCache(rawKey);
showNotification(successMessage, 'success');
} catch (err: any) {
updateConfigValue(rawKey, previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag(section, false);
}
};
const handleProxyUpdate = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', proxyValue);
try {
await configApi.updateProxyUrl(proxyValue.trim());
clearCache('proxy-url');
showNotification(t('notification.proxy_updated'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleProxyClear = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', '');
try {
await configApi.clearProxyUrl();
clearCache('proxy-url');
setProxyValue('');
showNotification(t('notification.proxy_cleared'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleRetryUpdate = async () => {
const previous = config?.requestRetry ?? 0;
const parsed = Number(retryValue);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setRetryValue(previous);
return;
}
setPendingFlag('retry', true);
updateConfigValue('request-retry', parsed);
try {
await configApi.updateRequestRetry(parsed);
clearCache('request-retry');
showNotification(t('notification.retry_updated'), 'success');
} catch (err: any) {
setRetryValue(previous);
updateConfigValue('request-retry', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('retry', false);
}
};
const handleLogsMaxTotalSizeUpdate = async () => {
const previous = config?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(logsMaxTotalSizeMb);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setLogsMaxTotalSizeMb(previous);
return;
}
const normalized = Math.max(0, parsed);
setPendingFlag('logsMaxSize', true);
updateConfigValue('logs-max-total-size-mb', normalized);
try {
await configApi.updateLogsMaxTotalSizeMb(normalized);
clearCache('logs-max-total-size-mb');
showNotification(t('notification.logs_max_total_size_updated'), 'success');
} catch (err: any) {
setLogsMaxTotalSizeMb(previous);
updateConfigValue('logs-max-total-size-mb', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('logsMaxSize', false);
}
};
const handleRoutingStrategyUpdate = async () => {
const strategy = routingStrategy.trim();
if (!strategy) {
showNotification(t('login.error_invalid'), 'error');
return;
}
const previous = config?.routingStrategy ?? 'round-robin';
setPendingFlag('routingStrategy', true);
updateConfigValue('routing/strategy', strategy);
try {
await configApi.updateRoutingStrategy(strategy);
clearCache('routing/strategy');
showNotification(t('notification.routing_strategy_updated'), 'success');
} catch (err: any) {
setRoutingStrategy(previous);
updateConfigValue('routing/strategy', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('routingStrategy', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('basic_settings.title')}</h1>
<div className={styles.grid}>
<Card>
{error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading}
onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
}
/>
<ToggleSwitch
label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading}
onChange={(value) =>
toggleSetting(
'usage',
'usage-statistics-enabled',
value,
configApi.updateUsageStatistics,
t('notification.usage_statistics_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) =>
toggleSetting(
'loggingToFile',
'logging-to-file',
value,
configApi.updateLoggingToFile,
t('notification.logging_to_file_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading}
onChange={(value) =>
toggleSetting(
'wsAuth',
'ws-auth',
value,
configApi.updateWsAuth,
t('notification.ws_auth_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.force_model_prefix_enable')}
checked={config?.forceModelPrefix ?? false}
disabled={disableControls || pending.forceModelPrefix || loading}
onChange={(value) =>
toggleSetting(
'forceModelPrefix',
'force-model-prefix',
value,
configApi.updateForceModelPrefix,
t('notification.force_model_prefix_updated')
)
}
/>
</div>
</Card>
<Card title={t('basic_settings.proxy_title')}>
<Input
label={t('basic_settings.proxy_url_label')}
placeholder={t('basic_settings.proxy_url_placeholder')}
value={proxyValue}
onChange={(e) => setProxyValue(e.target.value)}
disabled={disableControls || loading}
/>
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="secondary" onClick={handleProxyClear} disabled={disableControls || pending.proxy || loading}>
{t('basic_settings.proxy_clear')}
</Button>
<Button onClick={handleProxyUpdate} loading={pending.proxy} disabled={disableControls || loading}>
{t('basic_settings.proxy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.retry_title')}>
<div className={styles.retryRow}>
<Input
label={t('basic_settings.retry_count_label')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={retryValue}
onChange={(e) => setRetryValue(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleRetryUpdate}
loading={pending.retry}
disabled={disableControls || loading}
>
{t('basic_settings.retry_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.logs_max_total_size_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<Input
label={t('basic_settings.logs_max_total_size_label')}
hint={t('basic_settings.logs_max_total_size_hint')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={logsMaxTotalSizeMb}
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleLogsMaxTotalSizeUpdate}
loading={pending.logsMaxSize}
disabled={disableControls || loading}
>
{t('basic_settings.logs_max_total_size_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.routing_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<div className="form-group">
<label>{t('basic_settings.routing_strategy_label')}</label>
<select
className="input"
value={routingStrategy}
onChange={(e) => setRoutingStrategy(e.target.value)}
disabled={disableControls || loading}
>
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
</select>
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
</div>
<Button
className={styles.retryButton}
onClick={handleRoutingStrategyUpdate}
loading={pending.routingStrategy}
disabled={disableControls || loading}
>
{t('basic_settings.routing_strategy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.quota_switch_project')}
checked={quotaSwitchProject}
disabled={disableControls || pending.switchProject || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchProject ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchProject: value };
setPendingFlag('switchProject', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchProject(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_project_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchProject: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchProject', false);
}
})()
}
/>
<ToggleSwitch
label={t('basic_settings.quota_switch_preview')}
checked={quotaSwitchPreview}
disabled={disableControls || pending.switchPreview || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchPreviewModel ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchPreviewModel: value };
setPendingFlag('switchPreview', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchPreviewModel(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_preview_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchPreviewModel: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchPreview', false);
}
})()
}
/>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { Navigate, useRoutes, type Location } from 'react-router-dom';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AiProvidersAmpcodeEditPage } from '@/pages/AiProvidersAmpcodeEditPage';
import { AiProvidersClaudeEditPage } from '@/pages/AiProvidersClaudeEditPage';
@@ -24,8 +22,8 @@ import { SystemPage } from '@/pages/SystemPage';
const mainRoutes = [
{ path: '/', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/settings', element: <Navigate to="/config" replace /> },
{ path: '/api-keys', element: <Navigate to="/config" replace /> },
{ path: '/ai-providers/gemini/new', element: <AiProvidersGeminiEditPage /> },
{ path: '/ai-providers/gemini/:index', element: <AiProvidersGeminiEditPage /> },
{ path: '/ai-providers/codex/new', element: <AiProvidersCodexEditPage /> },

View File

@@ -4,13 +4,14 @@
*/
import { create } from 'zustand';
import type { ReactNode } from 'react';
import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
message: ReactNode;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';

103
src/types/visualConfig.ts Normal file
View File

@@ -0,0 +1,103 @@
export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json';
export type PayloadParamEntry = {
id: string;
path: string;
valueType: PayloadParamValueType;
value: string;
};
export type PayloadModelEntry = {
id: string;
name: string;
protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity';
};
export type PayloadRule = {
id: string;
models: PayloadModelEntry[];
params: PayloadParamEntry[];
};
export type PayloadFilterRule = {
id: string;
models: PayloadModelEntry[];
params: string[];
};
export interface StreamingConfig {
keepaliveSeconds: string;
bootstrapRetries: string;
nonstreamKeepaliveInterval: string;
}
export type VisualConfigValues = {
host: string;
port: string;
tlsEnable: boolean;
tlsCert: string;
tlsKey: string;
rmAllowRemote: boolean;
rmSecretKey: string;
rmDisableControlPanel: boolean;
rmPanelRepo: string;
authDir: string;
apiKeysText: string;
debug: boolean;
commercialMode: boolean;
loggingToFile: boolean;
logsMaxTotalSizeMb: string;
usageStatisticsEnabled: boolean;
proxyUrl: string;
forceModelPrefix: boolean;
requestRetry: string;
maxRetryInterval: string;
quotaSwitchProject: boolean;
quotaSwitchPreviewModel: boolean;
routingStrategy: 'round-robin' | 'fill-first';
wsAuth: boolean;
payloadDefaultRules: PayloadRule[];
payloadOverrideRules: PayloadRule[];
payloadFilterRules: PayloadFilterRule[];
streaming: StreamingConfig;
};
export const makeClientId = () => {
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
};
export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
host: '',
port: '',
tlsEnable: false,
tlsCert: '',
tlsKey: '',
rmAllowRemote: false,
rmSecretKey: '',
rmDisableControlPanel: false,
rmPanelRepo: '',
authDir: '',
apiKeysText: '',
debug: false,
commercialMode: false,
loggingToFile: false,
logsMaxTotalSizeMb: '',
usageStatisticsEnabled: false,
proxyUrl: '',
forceModelPrefix: false,
requestRetry: '',
maxRetryInterval: '',
quotaSwitchProject: true,
quotaSwitchPreviewModel: true,
routingStrategy: 'round-robin',
wsAuth: false,
payloadDefaultRules: [],
payloadOverrideRules: [],
payloadFilterRules: [],
streaming: {
keepaliveSeconds: '',
bootstrapRetries: '',
nonstreamKeepaliveInterval: '',
},
};

View File

@@ -54,9 +54,11 @@ export interface UsageDetail {
export interface ApiStats {
endpoint: string;
totalRequests: number;
successCount: number;
failureCount: number;
totalTokens: number;
totalCost: number;
models: Record<string, { requests: number; tokens: number }>;
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
}
const TOKENS_PER_PRICE_UNIT = 1_000_000;
@@ -542,28 +544,65 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
const result: ApiStats[] = [];
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
const models: Record<string, { requests: number; tokens: number }> = {};
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
let derivedSuccessCount = 0;
let derivedFailureCount = 0;
let totalCost = 0;
const modelsData = apiData?.models || {};
Object.entries(modelsData as Record<string, any>).forEach(([modelName, modelData]) => {
models[modelName] = {
requests: modelData.total_requests || 0,
tokens: modelData.total_tokens || 0
};
const details = Array.isArray(modelData.details) ? modelData.details : [];
const hasExplicitCounts =
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
let successCount = 0;
let failureCount = 0;
if (hasExplicitCounts) {
successCount += Number(modelData.success_count) || 0;
failureCount += Number(modelData.failure_count) || 0;
}
const price = modelPrices[modelName];
if (price) {
const details = Array.isArray(modelData.details) ? modelData.details : [];
if (details.length > 0 && (!hasExplicitCounts || price)) {
details.forEach((detail: any) => {
totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
if (!hasExplicitCounts) {
if (detail?.failed === true) {
failureCount += 1;
} else {
successCount += 1;
}
}
if (price) {
totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
}
});
}
models[modelName] = {
requests: modelData.total_requests || 0,
successCount,
failureCount,
tokens: modelData.total_tokens || 0
};
derivedSuccessCount += successCount;
derivedFailureCount += failureCount;
});
const hasApiExplicitCounts =
typeof apiData?.success_count === 'number' || typeof apiData?.failure_count === 'number';
const successCount = hasApiExplicitCounts
? (Number(apiData?.success_count) || 0)
: derivedSuccessCount;
const failureCount = hasApiExplicitCounts
? (Number(apiData?.failure_count) || 0)
: derivedFailureCount;
result.push({
endpoint: maskUsageSensitiveValue(endpoint) || endpoint,
totalRequests: apiData.total_requests || 0,
successCount,
failureCount,
totalTokens: apiData.total_tokens || 0,
totalCost,
models