diff --git a/src/components/common/ConfirmationModal.tsx b/src/components/common/ConfirmationModal.tsx index 9a8ca0b..6471fc2 100644 --- a/src/components/common/ConfirmationModal.tsx +++ b/src/components/common/ConfirmationModal.tsx @@ -43,7 +43,11 @@ export function ConfirmationModal() { return ( -

{message}

+ {typeof message === 'string' ? ( +

{message}

+ ) : ( +
{message}
+ )}
); }); diff --git a/src/components/modelAlias/ModelMappingDiagram.module.scss b/src/components/modelAlias/ModelMappingDiagram.module.scss new file mode 100644 index 0000000..20c3194 --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagram.module.scss @@ -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); + } +} diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx new file mode 100644 index 0000000..209a181 --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -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; + allProviderModels?: Record; + 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(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(null); + const [lines, setLines] = useState([]); + const [draggedSource, setDraggedSource] = useState(null); + const [draggedAlias, setDraggedAlias] = useState(null); + const [dropTargetAlias, setDropTargetAlias] = useState(null); + const [dropTargetSource, setDropTargetSource] = useState(null); + const [tapSourceId, setTapSourceId] = useState(null); + const [tapAlias, setTapAlias] = useState(null); + const [extraAliases, setExtraAliases] = useState([]); + const [contextMenu, setContextMenu] = useState(null); + const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); + const [providerGroupHeights, setProviderGroupHeights] = useState>({}); + 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(null); + const [settingsSourceId, setSettingsSourceId] = useState(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 } + >(); + const aliasSet = new Set(); + + // 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(); + 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>(new Map()); + const sourceRefs = useRef>(new Map()); + const aliasRefs = useRef>(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 = {}; + + 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 ( +
+ {enableTapLinking && onUpdate && ( +
{t('oauth_model_alias.diagram_tap_hint')}
+ )} +
{ + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, 'background'); + }} + > + + {lines.map((line) => ( + + ))} + + + 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')} + /> + { + 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')} + /> + { + 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')} + /> +
+ + 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); + }} + /> + + { + setRenameValue(value); + setRenameError(''); + }} + onClose={() => setRenameState(null)} + onSubmit={handleRenameSubmit} + /> + { + setAddAliasValue(value); + setAddAliasError(''); + }} + onClose={() => setAddAliasOpen(false)} + onSubmit={handleAddAliasSubmit} + /> + setSettingsAlias(null)} + onToggleFork={handleToggleFork} + onUnlink={handleUnlinkSource} + /> + setSettingsSourceId(null)} + onToggleFork={handleToggleFork} + onUnlink={handleUnlinkSource} + /> +
+ ); +}); diff --git a/src/components/modelAlias/ModelMappingDiagramColumns.tsx b/src/components/modelAlias/ModelMappingDiagramColumns.tsx new file mode 100644 index 0000000..bd8f04b --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramColumns.tsx @@ -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; + getProviderColor: (provider: string) => string; + providerGroupHeights?: Record; + providerRefs: RefObject>; + 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {providerNodes.map(({ provider, sources }) => { + const collapsed = collapsedProviders.has(provider); + const groupHeight = collapsed ? undefined : providerGroupHeights[provider]; + return ( +
+
{ + 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); + }} + > + + + {provider} + + {sources.length} +
+
+ ); + })} +
+ ); +} + +interface SourceColumnProps { + providerNodes: ProviderNode[]; + collapsedProviders: Set; + sourceRefs: RefObject>; + 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {providerNodes.flatMap(({ provider, sources }) => { + if (collapsedProviders.has(provider)) return []; + return sources.map((source) => ( +
{ + 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); + }} + > + + {source.name} + +
0 ? 1 : 0.3 + }} + /> +
+ )); + })} +
+ ); +} + +interface AliasColumnProps { + aliasNodes: AliasNode[]; + aliasRefs: RefObject>; + 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, 'background'); + }} + > +
{label}
+ {aliasNodes.map((node) => ( +
{ + 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); + }} + > +
+ + {node.alias} + + {node.sources.length} +
+ ))} +
+ ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx b/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx new file mode 100644 index 0000000..390ccfc --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramContextMenu.tsx @@ -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(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 = () => ( +
+ {t('oauth_model_alias.diagram_add_alias')} +
+ ); + + const renderAlias = () => { + if (!data) return null; + return ( + <> +
onRenameAlias(data)}> + {t('oauth_model_alias.diagram_rename')} +
+
onOpenAliasSettings(data)}> + {t('oauth_model_alias.diagram_settings')} +
+
+
onDeleteAlias(data)}> + {t('oauth_model_alias.diagram_delete_alias')} +
+ + ); + }; + + const renderProvider = () => { + if (!data) return null; + return ( + <> +
onEditProvider(data)}> + {t('common.edit')} +
+
+
onDeleteProvider(data)}> + {t('oauth_model_alias.delete')} +
+ + ); + }; + + const renderSource = () => { + if (!data) return null; + return ( +
onOpenSourceSettings(data)}> + {t('oauth_model_alias.diagram_settings')} +
+ ); + }; + + return createPortal( +
e.stopPropagation()} + > + {type === 'background' && renderBackground()} + {type === 'alias' && renderAlias()} + {type === 'provider' && renderProvider()} + {type === 'source' && renderSource()} +
, + document.body + ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramModals.tsx b/src/components/modelAlias/ModelMappingDiagramModals.tsx new file mode 100644 index 0000000..6f8e2fb --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramModals.tsx @@ -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 ( + + + + + } + > + onChange(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') onSubmit(); + }} + error={error} + placeholder={t('oauth_model_alias.diagram_rename_placeholder')} + autoFocus + /> + + ); +} + +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 ( + + + + + } + > + onChange(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') onSubmit(); + }} + error={error} + placeholder={t('oauth_model_alias.diagram_add_placeholder')} + autoFocus + /> + + ); +} + +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 ( + + {t('common.close')} + + } + > + {alias ? ( + (() => { + const node = aliasNodes.find((n) => n.alias === alias); + if (!node || node.sources.length === 0) { + return
{t('oauth_model_alias.diagram_settings_empty')}
; + } + return ( +
+ {node.sources.map((source) => { + const entry = source.aliases.find((item) => item.alias === alias); + const forkEnabled = entry?.fork === true; + return ( +
+
+ {source.name} + + {alias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + onToggleFork(source.provider, source.name, alias, value)} + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ); + })} +
+ ); + })() + ) : null} +
+ ); +} + +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 ( + + {t('common.close')} + + } + > + {source ? ( + source.aliases.length === 0 ? ( +
{t('oauth_model_alias.diagram_settings_empty')}
+ ) : ( +
+ {source.aliases.map((entry) => ( +
+
+ {source.name} + + {entry.alias} +
+
+ + {t('oauth_model_alias.alias_fork_label')} + + onToggleFork(source.provider, source.name, entry.alias, value)} + ariaLabel={t('oauth_model_alias.alias_fork_label')} + /> + +
+
+ ))} +
+ ) + ) : null} +
+ ); +} diff --git a/src/components/modelAlias/ModelMappingDiagramTypes.ts b/src/components/modelAlias/ModelMappingDiagramTypes.ts new file mode 100644 index 0000000..704692d --- /dev/null +++ b/src/components/modelAlias/ModelMappingDiagramTypes.ts @@ -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 }; diff --git a/src/components/modelAlias/index.ts b/src/components/modelAlias/index.ts new file mode 100644 index 0000000..6375f4e --- /dev/null +++ b/src/components/modelAlias/index.ts @@ -0,0 +1,2 @@ +export { ModelMappingDiagram } from './ModelMappingDiagram'; +export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram'; diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index 7c76ea1..c58c6db 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -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,6 +35,8 @@ 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(null); const contentScrollerRef = useRef(null); @@ -62,7 +65,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; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index d4dc906..b175842 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -10,6 +10,7 @@ import type { AntigravityModelsPayload, AntigravityQuotaState, AuthFileItem, + CodexRateLimitInfo, CodexQuotaState, CodexUsageWindow, CodexQuotaWindow, @@ -17,7 +18,7 @@ import type { GeminiCliParsedBucket, GeminiCliQuotaBucketState, GeminiCliQuotaState, - KiroQuotaState + KiroQuotaState, } from '@/types'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { @@ -52,7 +53,7 @@ import { isDisabledAuthFile, isGeminiCliFile, isKiroFile, - isRuntimeOnlyAuthFile + isRuntimeOnlyAuthFile, } from '@/utils/quota'; import type { QuotaRenderHelpers } from './QuotaCard'; import styles from '@/pages/QuotaPage.module.scss'; @@ -149,7 +150,7 @@ const fetchAntigravityQuota = async ( method: 'POST', url, header: { ...ANTIGRAVITY_REQUEST_HEADERS }, - data: requestBody + data: requestBody, }); if (result.statusCode < 200 || result.statusCode >= 300) { @@ -196,6 +197,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[] = []; @@ -217,30 +227,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; @@ -264,14 +318,14 @@ const fetchCodexQuota = async ( const requestHeader: Record = { ...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) { @@ -308,7 +362,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) { @@ -327,7 +381,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) { @@ -341,7 +397,7 @@ const fetchGeminiCliQuota = async ( tokenType, remainingFraction, remainingAmount, - resetTime + resetTime, }; }) .filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null); @@ -373,11 +429,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 }, @@ -410,7 +462,6 @@ const renderCodexItems = ( }; const planLabel = getPlanLabel(planType); - const isFreePlan = normalizePlanType(planType) === 'free'; const nodes: ReactNode[] = []; if (planLabel) { @@ -424,17 +475,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')) @@ -494,7 +534,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; @@ -537,13 +577,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig ({ 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 = { @@ -589,13 +629,13 @@ export const GEMINI_CLI_CONFIG: QuotaConfig 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) { @@ -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 = (
diff --git a/src/components/ui/ToggleSwitch.module.scss b/src/components/ui/ToggleSwitch.module.scss new file mode 100644 index 0000000..09045cb --- /dev/null +++ b/src/components/ui/ToggleSwitch.module.scss @@ -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; +} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index 3fbaa62..7256c62 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -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} /> - - + + - {label && {label}} + {label && {label}} ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4948e8c..5f896dc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -422,7 +422,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", @@ -451,7 +456,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", @@ -566,11 +572,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 {{sourceModel}} ({{provider}}) to alias {{alias}}?", + "delete_alias_title": "Delete Alias", + "delete_alias_confirm": "Delete alias {{alias}} 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", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5f74b0a..b47ef3e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -422,7 +422,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 额度", @@ -451,7 +456,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", @@ -566,11 +572,43 @@ "save_failed": "更新模型别名失败", "delete": "删除提供商", "delete_confirm": "确定要删除 {{provider}} 的模型别名吗?", + "delete_link_title": "取消链接", + "delete_link_confirm": "确定取消 {{sourceModel}}({{provider}})到别名 {{alias}} 的映射?", + "delete_alias_title": "删除别名", + "delete_alias_confirm": "确定删除别名 {{alias}} 并取消所有关联模型的映射?", "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 版本", diff --git a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx index aee8a16..35e617c 100644 --- a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx +++ b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx @@ -40,7 +40,7 @@ const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({ id: generateId(), name: '', alias: '', - fork: false, + fork: true, }); const normalizeMappingEntries = ( diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 7ce60d2..471ae0c 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -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; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index abe76d1..f6cb03b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -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(['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 ( +
+
+
+ ); +} + 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([]); @@ -230,6 +285,10 @@ export function AuthFilesPage() { // OAuth 模型映射相关 const [modelAlias, setModelAlias] = useState>({}); const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null); + const [allProviderModels, setAllProviderModels] = useState>( + {} + ); + const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list'); const [prefixProxyEditor, setPrefixProxyEditor] = useState(null); @@ -237,10 +296,81 @@ export function AuthFilesPage() { const loadingKeyStatsRef = useRef(false); const excludedUnsupportedRef = useRef(false); const mappingsUnsupportedRef = useRef(false); + const diagramRef = useRef(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(); + + 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 = {}; + 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: ( + }} + /> + ), + 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: ( + }} + /> + ), + 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 = () => (
@@ -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) => Record + ) => { + 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; + 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 ( +
+ {quotaStatus === 'loading' ? ( +
{t(`${config.i18nPrefix}.loading`)}
+ ) : quotaStatus === 'idle' ? ( +
{t(`${config.i18nPrefix}.idle`)}
+ ) : quotaStatus === 'error' ? ( +
+ {t(`${config.i18nPrefix}.load_failed`, { + message: quotaErrorMessage + })} +
+ ) : quota ? ( + (config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode) + ) : ( +
{t(`${config.i18nPrefix}.idle`)}
+ )} +
+ ); + }; + // 渲染单个认证文件卡片 - 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 ( -
-
- - {getTypeLabel(item.type || 'unknown')} - - {item.name} -
+ const quotaType = + quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null; -
- - {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} - - - {t('auth_files.file_modified')}: {formatModified(item)} - -
+ const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly; + const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined; + const quotaRefreshing = quotaState?.status === 'loading'; -
- - {t('stats.success')}: {fileStats.success} - - - {t('stats.failure')}: {fileStats.failure} - -
+ const providerCardClass = + quotaType === 'antigravity' + ? styles.antigravityCard + : quotaType === 'codex' + ? styles.codexCard + : quotaType === 'gemini-cli' + ? styles.geminiCliCard + : ''; - {/* 状态监测栏 */} - {renderStatusBar(item)} - -
- {showModelsButton && ( - - )} - {!isRuntimeOnly && ( - <> - - - - - - )} - {!isRuntimeOnly && ( -
- void handleStatusToggle(item, value)} - /> + {getTypeLabel(item.type || 'unknown')} + + {item.name}
- )} - {isRuntimeOnly && ( -
- {t('auth_files.type_virtual') || '虚拟认证文件'} + +
+ + {t('auth_files.file_size')}: {item.size ? formatFileSize(item.size) : '-'} + + + {t('auth_files.file_modified')}: {formatModified(item)} + +
+ +
+ + {t('stats.success')}: {fileStats.success} + + + {t('stats.failure')}: {fileStats.failure} + +
+ + {/* 状态监测栏 */} + {renderStatusBar(item)} + + {showQuotaLayout && quotaType && renderQuotaSection(item, quotaType)} + +
+ {showModelsButton && ( + + )} + {!isRuntimeOnly && ( + <> + + + + + + )} + {!isRuntimeOnly && ( +
+ void handleStatusToggle(item, value)} + /> +
+ )} + {isRuntimeOnly && ( +
+ {t('auth_files.type_virtual') || '虚拟认证文件'} +
+ )} +
+
+ + {showQuotaLayout && quotaType && ( +
+
+ + {t('auth_files.card_tools_title')} + + +
+
{t('auth_files.quota_refresh_hint')}
)}
@@ -1311,7 +1849,11 @@ export function AuthFilesPage() { description={t('auth_files.search_empty_desc')} /> ) : ( -
{pageItems.map(renderFileCard)}
+
+ {pageItems.map(renderFileCard)} +
)} {/* 分页 */} @@ -1377,7 +1919,11 @@ export function AuthFilesPage() {
- +
+
+ + +
+ +
} > {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 ? ( + + ) : ( +
+
+

{t('oauth_model_alias.chart_title')}

+ +
+ +
+ ) ) : Object.keys(modelAlias).length === 0 ? ( ) : ( @@ -1625,7 +2224,6 @@ export function AuthFilesPage() {
)} -
); } diff --git a/src/stores/useNotificationStore.ts b/src/stores/useNotificationStore.ts index 17971c5..d0adb09 100644 --- a/src/stores/useNotificationStore.ts +++ b/src/stores/useNotificationStore.ts @@ -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';