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}
+ )}
+ + {provider} + + {sources.length} +
+ ); + })} + + ); +} + +interface SourceColumnProps { + providerNodes: ProviderNode[]; + collapsedProviders: Set; + sourceRefs: RefObject>; + getProviderColor: (provider: string) => string; + 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, + 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 : ''}`} + 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; + 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, + 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 : ''}`} + 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/ui/Modal.tsx b/src/components/ui/Modal.tsx index f129c89..45eff25 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -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) { @@ -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/i18n/locales/en.json b/src/i18n/locales/en.json index 76b7b26..82f948e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -544,11 +544,42 @@ "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.", + "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 03a9f68..d00d49d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -544,11 +544,42 @@ "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": "该别名暂无映射。", + "view_mode": "视图模式", + "view_mode_diagram": "Diagram", + "view_mode_list": "List", "provider_required": "请先填写提供商名称", "upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 7ce60d2..6bfb681 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -843,6 +843,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..0e91fa0 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 { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; @@ -10,9 +10,11 @@ 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, IconTrash2, @@ -230,6 +232,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,11 +243,74 @@ 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 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(() => { + 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]); + + + useEffect(() => { const persisted = readAuthFilesUiState(); if (!persisted) return; @@ -603,7 +672,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 +1062,205 @@ 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; + + try { + await Promise.all( + providersToUpdate.map(([provider, mappings]) => { + const nextMappings = mappings.map((m) => + (m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m + ); + return authFilesApi.saveOauthModelAlias(provider, 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 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 () => { + try { + await Promise.all( + 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); + }) + ); + await loadModelAlias(); + showNotification(t('oauth_model_alias.delete_success'), 'success'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error'); + } + }, + }); + }; + // 渲染标签筛选器 const renderFilterTags = () => (
@@ -1084,22 +1354,22 @@ export function AuthFilesPage() { }; // 渲染单个认证文件卡片 - 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 ( -
-
- +
+
- +
+
+ + +
+ +
} > {modelAliasError === 'unsupported' ? ( @@ -1408,6 +1702,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 +1952,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';