From f4a6edf7ec1bb235b282c72043204edd78454a45 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 16 Jun 2026 05:05:52 -0400 Subject: [PATCH] refactor(web): unify settings dialogs into single dialog with sidebar Merge API integration, model settings, account settings and storage analysis into one SettingsDialog with a shadcn inner sidebar for section switching. Preserve existing ?action= query-param deep links (showModelSettings / showAccountSettings / showApiIntegrationSettings / showStorageAnalysis) by mapping each to a section. Extract reusable panels and keep ModelsDialog as a thin wrapper for the dynamic-form model picker. --- .../AccountSettingsDialog.tsx | 181 ----- .../AccountSettingsPanel.tsx | 170 +++++ ...tionDialog.tsx => ApiIntegrationPanel.tsx} | 457 ++++++------ .../components/home-sidebar/HomeSidebar.tsx | 121 ++-- .../components/models-dialog/ModelsDialog.tsx | 675 +----------------- .../components/models-dialog/ModelsPanel.tsx | 666 +++++++++++++++++ .../settings-dialog/SettingsDialog.tsx | 204 ++++++ .../StorageAnalysisDialog.tsx | 410 ----------- .../StorageAnalysisPanel.tsx | 390 ++++++++++ web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/es-ES.ts | 3 + web/src/i18n/locales/ja-JP.ts | 3 + web/src/i18n/locales/ru-RU.ts | 3 + web/src/i18n/locales/th-TH.ts | 3 + web/src/i18n/locales/vi-VN.ts | 3 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + 17 files changed, 1720 insertions(+), 1578 deletions(-) delete mode 100644 web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx create mode 100644 web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx rename web/src/app/home/components/api-integration-dialog/{ApiIntegrationDialog.tsx => ApiIntegrationPanel.tsx} (61%) create mode 100644 web/src/app/home/components/models-dialog/ModelsPanel.tsx create mode 100644 web/src/app/home/components/settings-dialog/SettingsDialog.tsx delete mode 100644 web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx create mode 100644 web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx deleted file mode 100644 index b658c9fa..00000000 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import * as React from 'react'; -import { useState, useEffect } from 'react'; -import { toast } from 'sonner'; -import { useTranslation } from 'react-i18next'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Item, - ItemMedia, - ItemContent, - ItemTitle, - ItemDescription, - ItemActions, -} from '@/components/ui/item'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { systemInfo } from '@/app/infra/http'; -import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; -import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; - -interface AccountSettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function AccountSettingsDialog({ - open, - onOpenChange, -}: AccountSettingsDialogProps) { - const { t } = useTranslation(); - const [accountType, setAccountType] = useState<'local' | 'space'>('local'); - const [hasPassword, setHasPassword] = useState(false); - const [userEmail, setUserEmail] = useState(''); - const [loading, setLoading] = useState(true); - const [spaceBindLoading, setSpaceBindLoading] = useState(false); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - - useEffect(() => { - if (open) { - loadUserInfo(); - } - }, [open]); - - async function loadUserInfo() { - setLoading(true); - try { - const info = await httpClient.getUserInfo(); - setAccountType(info.account_type); - setHasPassword(info.has_password); - setUserEmail(info.user); - } catch { - toast.error(t('common.error')); - } finally { - setLoading(false); - } - } - - const handleBindSpace = async () => { - setSpaceBindLoading(true); - try { - const token = localStorage.getItem('token'); - if (!token) { - toast.error(t('common.error')); - setSpaceBindLoading(false); - return; - } - const currentOrigin = window.location.origin; - const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; - // Pass token as state for security verification - const response = await httpClient.getSpaceAuthorizeUrl( - redirectUri, - token, - ); - window.location.href = response.authorize_url; - } catch { - toast.error(t('common.spaceLoginFailed')); - setSpaceBindLoading(false); - } - }; - - const handlePasswordDialogClose = (dialogOpen: boolean) => { - setPasswordDialogOpen(dialogOpen); - if (!dialogOpen) { - // Reload user info to update password status - loadUserInfo(); - } - }; - - return ( - <> - - - - {t('account.settings')} - {userEmail} - - - {loading ? ( -
- -
- ) : ( -
- {/* Password Item */} - - - - - - {t('account.passwordStatus')} - - {hasPassword - ? t('account.passwordSetDescription') - : t('account.setPasswordHint')} - - - - - - - - {/* Space Account Item */} - - - - - - {t('account.spaceStatus')} - - {accountType === 'space' - ? t('account.spaceBoundDescription') - : t('account.bindSpaceDescription')} - - - {accountType === 'local' && ( - - - - )} - -
- )} -
-
- - - - ); -} diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx new file mode 100644 index 00000000..5cf7e4c8 --- /dev/null +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Item, + ItemMedia, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from '@/components/ui/item'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { systemInfo } from '@/app/infra/http'; +import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; +import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; + +interface AccountSettingsPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; + onEmailResolved?: (email: string) => void; +} + +export default function AccountSettingsPanel({ + active, + onEmailResolved, +}: AccountSettingsPanelProps) { + const { t } = useTranslation(); + const [accountType, setAccountType] = useState<'local' | 'space'>('local'); + const [hasPassword, setHasPassword] = useState(false); + const [userEmail, setUserEmail] = useState(''); + const [loading, setLoading] = useState(true); + const [spaceBindLoading, setSpaceBindLoading] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + + useEffect(() => { + if (active) { + loadUserInfo(); + } + }, [active]); + + async function loadUserInfo() { + setLoading(true); + try { + const info = await httpClient.getUserInfo(); + setAccountType(info.account_type); + setHasPassword(info.has_password); + setUserEmail(info.user); + onEmailResolved?.(info.user); + } catch { + toast.error(t('common.error')); + } finally { + setLoading(false); + } + } + + const handleBindSpace = async () => { + setSpaceBindLoading(true); + try { + const token = localStorage.getItem('token'); + if (!token) { + toast.error(t('common.error')); + setSpaceBindLoading(false); + return; + } + const currentOrigin = window.location.origin; + const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; + // Pass token as state for security verification + const response = await httpClient.getSpaceAuthorizeUrl( + redirectUri, + token, + ); + window.location.href = response.authorize_url; + } catch { + toast.error(t('common.spaceLoginFailed')); + setSpaceBindLoading(false); + } + }; + + const handlePasswordDialogClose = (dialogOpen: boolean) => { + setPasswordDialogOpen(dialogOpen); + if (!dialogOpen) { + // Reload user info to update password status + loadUserInfo(); + } + }; + + return ( +
+ {userEmail && ( +

{userEmail}

+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {/* Password Item */} + + + + + + {t('account.passwordStatus')} + + {hasPassword + ? t('account.passwordSetDescription') + : t('account.setPasswordHint')} + + + + + + + + {/* Space Account Item */} + + + + + + {t('account.spaceStatus')} + + {accountType === 'space' + ? t('account.spaceBoundDescription') + : t('account.bindSpaceDescription')} + + + {accountType === 'local' && ( + + + + )} + +
+ )} + + +
+ ); +} diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx similarity index 61% rename from web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx rename to web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx index 8ac3f496..e45d5f50 100644 --- a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Copy, Check, Trash2, Plus } from 'lucide-react'; -import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { Dialog, DialogContent, @@ -55,20 +54,15 @@ interface Webhook { created_at: string; } -interface ApiIntegrationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; +interface ApiIntegrationPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; } -export default function ApiIntegrationDialog({ - open, - onOpenChange, -}: ApiIntegrationDialogProps) { +export default function ApiIntegrationPanel({ + active, +}: ApiIntegrationPanelProps) { const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const pathname = location.pathname; - const [searchParams] = useSearchParams(); const [activeTab, setActiveTab] = useState('apikeys'); const [apiKeys, setApiKeys] = useState([]); const [webhooks, setWebhooks] = useState([]); @@ -91,33 +85,7 @@ export default function ApiIntegrationDialog({ ); const [copiedKey, setCopiedKey] = useState(null); - // Sync URL with dialog state - useEffect(() => { - if (open) { - const params = new URLSearchParams(searchParams.toString()); - params.set('action', 'showApiIntegrationSettings'); - navigate(`${pathname}?${params.toString()}`, { - preventScrollReset: true, - }); - } - }, [open]); - - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && (deleteKeyId || deleteWebhookId)) { - return; - } - if (!newOpen) { - const params = new URLSearchParams(searchParams.toString()); - params.delete('action'); - const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; - navigate(newUrl, { preventScrollReset: true }); - } - onOpenChange(newOpen); - }; - - // 清理 body 样式,防止对话框关闭后页面无法交互 + // 清理 body 样式,防止嵌套对话框关闭后页面无法交互 useEffect(() => { if (!deleteKeyId && !deleteWebhookId) { const cleanup = () => { @@ -131,11 +99,11 @@ export default function ApiIntegrationDialog({ }, [deleteKeyId, deleteWebhookId]); useEffect(() => { - if (open) { + if (active) { loadApiKeys(); loadWebhooks(); } - }, [open]); + }, [active]); const loadApiKeys = async () => { setLoading(true); @@ -284,233 +252,216 @@ export default function ApiIntegrationDialog({ return ( <> - - - - {t('common.manageApiIntegration')} - +
+ + + + {t('common.apiKeys')} + + + {t('common.webhooks')} + + - - - - {t('common.apiKeys')} - - + {t('common.apiKeyHint')} +
+ +
+ +
- {/* API Keys Tab */} - -
- {t('common.apiKeyHint')} + {loading ? ( +
+ {t('common.loading')}
- -
- + ) : apiKeys.length === 0 ? ( +
+ {t('common.noApiKeys')}
- - {loading ? ( -
- {t('common.loading')} -
- ) : apiKeys.length === 0 ? ( -
- {t('common.noApiKeys')} -
- ) : ( -
- - - - - {t('common.name')} - - - {t('common.apiKeyValue')} - - - {t('common.actions')} - - - - - {apiKeys.map((item) => ( - - -
-
{item.name}
- {item.description && ( -
- {item.description} -
- )} -
-
- - - {maskApiKey(item.key)} - - - -
- - -
-
-
- ))} -
-
-
- )} - - - {/* Webhooks Tab */} - -
- {t('common.webhookHint')} -
- -
- -
- - {loading ? ( -
- {t('common.loading')} -
- ) : webhooks.length === 0 ? ( -
- {t('common.noWebhooks')} -
- ) : ( -
- - - - - {t('common.name')} - - - {t('common.webhookUrl')} - - - {t('common.webhookEnabled')} - - - {t('common.actions')} - - - - - {webhooks.map((webhook) => ( - - -
-
- {webhook.name} + ) : ( +
+
+ + + + {t('common.name')} + + + {t('common.apiKeyValue')} + + + {t('common.actions')} + + + + + {apiKeys.map((item) => ( + + +
+
{item.name}
+ {item.description && ( +
+ {item.description}
- {webhook.description && ( -
- {webhook.description} -
- )} -
-
- -
- - {webhook.url} - -
-
- - - handleToggleWebhook(webhook) - } - /> - - + )} + + + + + {maskApiKey(item.key)} + + + +
+ - - - ))} - -
-
- )} -
- +
+ + + ))} + + +
+ )} +
- - - -
-
+ {/* Webhooks Tab */} + +
+ {t('common.webhookHint')} +
+ +
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : webhooks.length === 0 ? ( +
+ {t('common.noWebhooks')} +
+ ) : ( +
+ + + + + {t('common.name')} + + + {t('common.webhookUrl')} + + + {t('common.webhookEnabled')} + + + {t('common.actions')} + + + + + {webhooks.map((webhook) => ( + + +
+
+ {webhook.name} +
+ {webhook.description && ( +
+ {webhook.description} +
+ )} +
+
+ +
+ + {webhook.url} + +
+
+ + handleToggleWebhook(webhook)} + /> + + + + +
+ ))} +
+
+
+ )} +
+ + {/* Create API Key Dialog */} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 2eff56cf..d1f3acd7 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog'; -import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; -import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog'; -import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog'; +import SettingsDialog, { + SettingsSection, + SETTINGS_ACTION_BY_SECTION, + SETTINGS_SECTION_BY_ACTION, +} from '@/app/home/components/settings-dialog/SettingsDialog'; import { GitHubRelease } from '@/app/infra/http/CloudServiceClient'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { toast } from 'sonner'; @@ -1548,17 +1549,10 @@ export default function HomeSidebar({ }, [pathname]); useEffect(() => { - if (searchParams.get('action') === 'showModelSettings') { - setModelsDialogOpen(true); - } - if (searchParams.get('action') === 'showAccountSettings') { - setAccountSettingsOpen(true); - } - if (searchParams.get('action') === 'showApiIntegrationSettings') { - setApiKeyDialogOpen(true); - } - if (searchParams.get('action') === 'showStorageAnalysis') { - setStorageAnalysisOpen(true); + const action = searchParams.get('action'); + if (action && SETTINGS_SECTION_BY_ACTION[action]) { + setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]); + setSettingsOpen(true); } }, [searchParams]); @@ -1567,15 +1561,14 @@ export default function HomeSidebar({ useState>(loadSectionState); const { theme, setTheme } = useTheme(); const { t } = useTranslation(); - const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); - const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsSection, setSettingsSection] = + useState('models'); const [latestRelease, setLatestRelease] = useState( null, ); const [hasNewVersion, setHasNewVersion] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false); - const [modelsDialogOpen, setModelsDialogOpen] = useState(false); - const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false); const [userEmail, setUserEmail] = useState(''); const [starCount, setStarCount] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); @@ -1600,51 +1593,28 @@ export default function HomeSidebar({ setShowScrollHint(false); }, 250); } - function handleModelsDialogChange(open: boolean) { - setModelsDialogOpen(open); - if (open) { - const params = new URLSearchParams(searchParams.toString()); - params.set('action', 'showModelSettings'); - navigate(`${pathname}?${params.toString()}`, { - preventScrollReset: true, - }); - } else { - const params = new URLSearchParams(searchParams.toString()); - params.delete('action'); - const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; - navigate(newUrl, { preventScrollReset: true }); - } + function openSettings(section: SettingsSection) { + setSettingsSection(section); + setSettingsOpen(true); + const params = new URLSearchParams(searchParams.toString()); + params.set('action', SETTINGS_ACTION_BY_SECTION[section]); + navigate(`${pathname}?${params.toString()}`, { + preventScrollReset: true, + }); } - function handleAccountSettingsChange(open: boolean) { - setAccountSettingsOpen(open); - if (open) { - const params = new URLSearchParams(searchParams.toString()); - params.set('action', 'showAccountSettings'); - navigate(`${pathname}?${params.toString()}`, { - preventScrollReset: true, - }); - } else { - const params = new URLSearchParams(searchParams.toString()); - params.delete('action'); - const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; - navigate(newUrl, { preventScrollReset: true }); - } + function handleSettingsSectionChange(section: SettingsSection) { + setSettingsSection(section); + const params = new URLSearchParams(searchParams.toString()); + params.set('action', SETTINGS_ACTION_BY_SECTION[section]); + navigate(`${pathname}?${params.toString()}`, { + preventScrollReset: true, + }); } - function handleStorageAnalysisChange(open: boolean) { - setStorageAnalysisOpen(open); - if (open) { - const params = new URLSearchParams(searchParams.toString()); - params.set('action', 'showStorageAnalysis'); - navigate(`${pathname}?${params.toString()}`, { - preventScrollReset: true, - }); - } else { + function handleSettingsOpenChange(open: boolean) { + setSettingsOpen(open); + if (!open) { const params = new URLSearchParams(searchParams.toString()); params.delete('action'); const newUrl = params.toString() @@ -1917,7 +1887,7 @@ export default function HomeSidebar({ setApiKeyDialogOpen(true)} + onClick={() => openSettings('apiIntegration')} tooltip={t('common.apiIntegration')} > @@ -1930,7 +1900,7 @@ export default function HomeSidebar({ handleModelsDialogChange(true)} + onClick={() => openSettings('models')} tooltip={t('models.title')} > @@ -2018,7 +1988,10 @@ export default function HomeSidebar({ {/* Account actions */} handleAccountSettingsChange(true)} + onClick={() => { + setUserMenuOpen(false); + openSettings('account'); + }} > {t('account.settings')} @@ -2026,7 +1999,7 @@ export default function HomeSidebar({ { setUserMenuOpen(false); - handleStorageAnalysisChange(true); + openSettings('storageAnalysis'); }} > @@ -2123,27 +2096,17 @@ export default function HomeSidebar({ - - - - ); } diff --git a/web/src/app/home/components/models-dialog/ModelsDialog.tsx b/web/src/app/home/components/models-dialog/ModelsDialog.tsx index ccb03b3c..dc27f317 100644 --- a/web/src/app/home/components/models-dialog/ModelsDialog.tsx +++ b/web/src/app/home/components/models-dialog/ModelsDialog.tsx @@ -1,677 +1,42 @@ -import { useState, useEffect } from 'react'; -import { Plus, Boxes } from 'lucide-react'; -import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; -import { ModelProvider } from '@/app/infra/entities/api'; +import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import ProviderForm from './component/provider-form/ProviderForm'; -import { ProviderCard } from './components'; -import { - ExtraArg, - ModelType, - ScanModelsResult, - SelectedScannedModel, - TestResult, - ProviderModels, - LANGBOT_MODELS_PROVIDER_REQUESTER, -} from './types'; -import { CustomApiError } from '@/app/infra/entities/common'; +import ModelsPanel from './ModelsPanel'; interface ModelsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } -type ExtraArgValue = string | number | boolean | Record; - -function convertExtraArgsToObject( - args: ExtraArg[], -): Record { - const obj: Record = {}; - args.forEach((arg) => { - if (!arg.key.trim()) return; - if (arg.type === 'number') { - obj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - obj[arg.key] = arg.value === 'true'; - } else if (arg.type === 'object') { - const raw = arg.value.trim() || '{}'; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error(`Invalid JSON for extra parameter "${arg.key}"`); - } - if ( - parsed === null || - typeof parsed !== 'object' || - Array.isArray(parsed) - ) { - throw new Error(`Extra parameter "${arg.key}" must be a JSON object`); - } - obj[arg.key] = parsed as Record; - } else { - obj[arg.key] = arg.value; - } - }); - return obj; -} - -function parseContextLength( - value: number | null | undefined, - invalidMessage: string, -): number | null { - if (value === undefined || value === null) return null; - if (!Number.isInteger(value) || value <= 0) { - throw new Error(invalidMessage); - } - return value; -} - +// Standalone Models dialog. The unified Settings dialog renders +// directly; this wrapper is kept for places that open Models on its own +// (e.g. the model picker inside dynamic forms). export default function ModelsDialog({ open, onOpenChange, }: ModelsDialogProps) { const { t } = useTranslation(); - - const [providers, setProviders] = useState([]); - const [accountType, setAccountType] = useState<'local' | 'space'>('local'); - const [spaceCredits, setSpaceCredits] = useState(null); - - // Expanded providers and their models - const [expandedProviders, setExpandedProviders] = useState>( - new Set(), - ); - const [providerModels, setProviderModels] = useState< - Record - >({}); - const [loadingProviders, setLoadingProviders] = useState>( - new Set(), - ); - - // Provider form modal - const [providerFormOpen, setProviderFormOpen] = useState(false); - const [editingProviderId, setEditingProviderId] = useState( - null, - ); - - // Map of requester name -> support_type[] (from requester manifests), - // used to restrict which model-type tabs are shown when adding models. - const [requesterSupportTypes, setRequesterSupportTypes] = useState< - Record - >({}); - - // Popover states - const [addModelPopoverOpen, setAddModelPopoverOpen] = useState( - null, - ); - const [editModelPopoverOpen, setEditModelPopoverOpen] = useState< - string | null - >(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState( - null, - ); - - // Form states - const [isSubmitting, setIsSubmitting] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - - // Track if providers have been loaded initially - const [providersLoaded, setProvidersLoaded] = useState(false); - - // Separate LangBot Models provider (hide when models service is disabled) - const langbotProvider = systemInfo.disable_models_service - ? undefined - : providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER); - const otherProviders = providers.filter( - (p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, - ); - - useEffect(() => { - if (open) { - loadUserInfo(); - loadProviders(); - loadRequesterSupportTypes(); - } - }, [open]); - - // Auto-expand LangBot Models when no external providers exist - useEffect(() => { - if (providersLoaded && langbotProvider && otherProviders.length === 0) { - if (!expandedProviders.has(langbotProvider.uuid)) { - setExpandedProviders(new Set([langbotProvider.uuid])); - if (!providerModels[langbotProvider.uuid]) { - loadProviderModels(langbotProvider.uuid); - } - } - } - }, [providersLoaded, providers]); - - async function loadUserInfo() { - try { - const userInfo = await httpClient.getUserInfo(); - setAccountType(userInfo.account_type); - if (userInfo.account_type === 'space') { - const creditsInfo = await httpClient.getSpaceCredits(); - setSpaceCredits(creditsInfo.credits); - } - } catch { - setAccountType('local'); - } - } - - async function loadProviders() { - try { - const resp = await httpClient.getModelProviders(); - setProviders(resp.providers); - setProvidersLoaded(true); - } catch (err) { - console.error('Failed to load providers', err); - toast.error(t('models.loadError')); - } - } - - async function loadRequesterSupportTypes() { - try { - const resp = await httpClient.getProviderRequesters(); - const map: Record = {}; - for (const r of resp.requesters) { - map[r.name] = r.spec?.support_type ?? []; - } - setRequesterSupportTypes(map); - } catch (err) { - console.error('Failed to load requester support types', err); - } - } - - async function loadProviderModels(providerUuid: string, silent = false) { - if (loadingProviders.has(providerUuid)) return; - - if (!silent) { - setLoadingProviders((prev) => new Set(prev).add(providerUuid)); - } - try { - const [llmResp, embeddingResp, rerankResp] = await Promise.all([ - httpClient.getProviderLLMModels(providerUuid), - httpClient.getProviderEmbeddingModels(providerUuid), - httpClient.getProviderRerankModels(providerUuid), - ]); - setProviderModels((prev) => ({ - ...prev, - [providerUuid]: { - llm: llmResp.models, - embedding: embeddingResp.models, - rerank: rerankResp.models, - }, - })); - } catch (err) { - console.error('Failed to load models', err); - } finally { - if (!silent) { - setLoadingProviders((prev) => { - const next = new Set(prev); - next.delete(providerUuid); - return next; - }); - } - } - } - - function toggleProvider(providerUuid: string) { - setExpandedProviders((prev) => { - const next = new Set(prev); - if (next.has(providerUuid)) { - next.delete(providerUuid); - } else { - next.add(providerUuid); - if (!providerModels[providerUuid]) { - loadProviderModels(providerUuid); - } - } - return next; - }); - } - - function handleCreateProvider() { - setEditingProviderId(null); - setProviderFormOpen(true); - } - - function handleEditProvider(providerId: string) { - setEditingProviderId(providerId); - setProviderFormOpen(true); - } - - async function handleDeleteProvider(providerId: string) { - try { - await httpClient.deleteModelProvider(providerId); - toast.success(t('models.providerDeleted')); - loadProviders(); - } catch (err) { - toast.error(t('models.providerDeleteError') + (err as Error).message); - } - } - - async function handleSpaceLogin() { - try { - const token = localStorage.getItem('token'); - if (!token) { - toast.error(t('common.error')); - return; - } - const currentOrigin = window.location.origin; - const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; - const response = await httpClient.getSpaceAuthorizeUrl( - redirectUri, - token, - ); - window.location.href = response.authorize_url; - } catch { - toast.error(t('common.spaceLoginFailed')); - } - } - - async function handleAddModel( - providerUuid: string, - modelType: ModelType, - name: string, - abilities: string[], - extraArgs: ExtraArg[], - contextLength?: number | null, - ) { - if (!name.trim()) { - toast.error(t('models.modelNameRequired')); - return; - } - setIsSubmitting(true); - try { - const extraArgsObj = convertExtraArgsToObject(extraArgs); - - if (modelType === 'llm') { - await httpClient.createProviderLLMModel({ - name, - provider_uuid: providerUuid, - abilities, - context_length: parseContextLength( - contextLength, - t('models.contextLengthInvalid'), - ), - extra_args: extraArgsObj, - } as never); - } else if (modelType === 'embedding') { - await httpClient.createProviderEmbeddingModel({ - name, - provider_uuid: providerUuid, - extra_args: extraArgsObj, - } as never); - } else { - await httpClient.createProviderRerankModel({ - name, - provider_uuid: providerUuid, - extra_args: extraArgsObj, - } as never); - } - setAddModelPopoverOpen(null); - loadProviderModels(providerUuid, true); - loadProviders(); - } catch (err) { - toast.error(t('models.createError') + (err as Error).message); - } finally { - setIsSubmitting(false); - } - } - - async function handleScanModels( - providerUuid: string, - modelType?: ModelType, - ): Promise { - try { - const resp = await httpClient.scanProviderModels(providerUuid, modelType); - return { - models: resp.models, - debug: resp.debug, - }; - } catch (err) { - toast.error(t('models.getModelListError') + (err as CustomApiError).msg); - return { models: [] }; - } - } - - async function handleAddScannedModels( - providerUuid: string, - modelType: ModelType, - models: SelectedScannedModel[], - ) { - if (models.length === 0) return; - - setIsSubmitting(true); - try { - for (const item of models) { - const effectiveType = item.model.type || modelType; - if (effectiveType === 'llm') { - await httpClient.createProviderLLMModel({ - name: item.model.name, - provider_uuid: providerUuid, - abilities: item.abilities, - context_length: item.model.context_length ?? null, - extra_args: {}, - } as never); - } else if (effectiveType === 'embedding') { - await httpClient.createProviderEmbeddingModel({ - name: item.model.name, - provider_uuid: providerUuid, - extra_args: {}, - } as never); - } else { - await httpClient.createProviderRerankModel({ - name: item.model.name, - provider_uuid: providerUuid, - extra_args: {}, - } as never); - } - } - setAddModelPopoverOpen(null); - loadProviderModels(providerUuid, true); - loadProviders(); - toast.success( - t('models.addSelectedModelsSuccess', { count: models.length }), - ); - } catch (err) { - toast.error(t('models.createError') + (err as CustomApiError).msg); - } finally { - setIsSubmitting(false); - } - } - - async function handleUpdateModel( - providerUuid: string, - modelId: string, - modelType: ModelType, - name: string, - abilities: string[], - extraArgs: ExtraArg[], - contextLength?: number | null, - ) { - if (!name.trim()) { - toast.error(t('models.modelNameRequired')); - return; - } - setIsSubmitting(true); - try { - const extraArgsObj = convertExtraArgsToObject(extraArgs); - - if (modelType === 'llm') { - await httpClient.updateProviderLLMModel(modelId, { - name, - provider_uuid: providerUuid, - abilities, - context_length: parseContextLength( - contextLength, - t('models.contextLengthInvalid'), - ), - extra_args: extraArgsObj, - } as never); - } else if (modelType === 'embedding') { - await httpClient.updateProviderEmbeddingModel(modelId, { - name, - provider_uuid: providerUuid, - extra_args: extraArgsObj, - } as never); - } else { - await httpClient.updateProviderRerankModel(modelId, { - name, - provider_uuid: providerUuid, - extra_args: extraArgsObj, - } as never); - } - setEditModelPopoverOpen(null); - loadProviderModels(providerUuid, true); - loadProviders(); - } catch (err) { - toast.error(t('models.saveError') + (err as Error).message); - } finally { - setIsSubmitting(false); - } - } - - async function handleDeleteModel( - providerUuid: string, - modelId: string, - modelType: ModelType, - ) { - try { - if (modelType === 'llm') { - await httpClient.deleteProviderLLMModel(modelId); - } else if (modelType === 'embedding') { - await httpClient.deleteProviderEmbeddingModel(modelId); - } else { - await httpClient.deleteProviderRerankModel(modelId); - } - toast.success(t('models.deleteSuccess')); - loadProviderModels(providerUuid, true); - loadProviders(); - } catch (err) { - toast.error(t('models.deleteError') + (err as Error).message); - } - } - - async function handleTestModel( - providerUuid: string, - name: string, - modelType: ModelType, - abilities: string[], - extraArgs: ExtraArg[], - ) { - setIsTesting(true); - setTestResult(null); - const startTime = Date.now(); - try { - const extraArgsObj = convertExtraArgsToObject(extraArgs); - - // Get the provider info - const provider = providers.find((p) => p.uuid === providerUuid); - const providerData = { - requester: provider?.requester || '', - base_url: provider?.base_url || '', - api_keys: provider?.api_keys || [], - }; - - if (modelType === 'llm') { - await httpClient.testLLMModel('_', { - uuid: '', - name, - provider_uuid: '', - provider: providerData, - abilities, - extra_args: extraArgsObj, - } as never); - } else if (modelType === 'embedding') { - await httpClient.testEmbeddingModel('_', { - uuid: '', - name, - provider_uuid: '', - provider: providerData, - extra_args: extraArgsObj, - } as never); - } else { - await httpClient.testRerankModel('_', { - uuid: '', - name, - provider_uuid: '', - provider: providerData, - extra_args: extraArgsObj, - } as never); - } - const duration = Date.now() - startTime; - setTestResult({ success: true, duration }); - } catch (err) { - console.error('Failed to test model', err); - toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg); - setTestResult(null); - } finally { - setIsTesting(false); - } - } - - function handleFormClose() { - setProviderFormOpen(false); - loadProviders(); - // Refresh expanded providers - expandedProviders.forEach((uuid) => loadProviderModels(uuid)); - } - - function renderProviderCard( - provider: ModelProvider, - isLangBotModels: boolean = false, - ) { - return ( - toggleProvider(provider.uuid)} - onEditProvider={() => handleEditProvider(provider.uuid)} - onDeleteProvider={() => handleDeleteProvider(provider.uuid)} - onSpaceLogin={handleSpaceLogin} - onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)} - onCloseAddModel={() => setAddModelPopoverOpen(null)} - onAddModel={(modelType, name, abilities, extraArgs, contextLength) => - handleAddModel( - provider.uuid, - modelType, - name, - abilities, - extraArgs, - contextLength, - ) - } - onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)} - onAddScannedModels={(modelType, models) => - handleAddScannedModels(provider.uuid, modelType, models) - } - onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)} - onCloseEditModel={() => setEditModelPopoverOpen(null)} - onUpdateModel={( - modelId, - modelType, - name, - abilities, - extraArgs, - contextLength, - ) => - handleUpdateModel( - provider.uuid, - modelId, - modelType, - name, - abilities, - extraArgs, - contextLength, - ) - } - onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)} - onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)} - onDeleteModel={(modelId, modelType) => - handleDeleteModel(provider.uuid, modelId, modelType) - } - onTestModel={(name, modelType, abilities, extraArgs) => - handleTestModel(provider.uuid, name, modelType, abilities, extraArgs) - } - isSubmitting={isSubmitting} - isTesting={isTesting} - testResult={testResult} - onResetTestResult={() => setTestResult(null)} - /> - ); - } + const [blocking, setBlocking] = useState(false); return ( - <> - { - if (!newOpen && providerFormOpen) return; - onOpenChange(newOpen); - }} - > - - - {t('models.title')} - - -
- {/* LangBot Models Card */} - {langbotProvider && renderProviderCard(langbotProvider, true)} - - {/* Add Provider Button */} -
- - {otherProviders.length === 0 - ? t( - systemInfo.disable_models_service - ? 'models.addProviderHintSimple' - : 'models.addProviderHint', - ) - : t('models.providerCount', { count: otherProviders.length })} - -
- -
-
- - {/* Provider List */} - {otherProviders.length === 0 ? ( -
- -

{t('models.noProviders')}

-
- ) : ( - otherProviders.map((p) => renderProviderCard(p)) - )} -
-
-
- - - - - - {editingProviderId - ? t('models.editProvider') - : t('models.addProvider')} - - - setProviderFormOpen(false)} - /> - - - + { + if (!newOpen && blocking) return; + onOpenChange(newOpen); + }} + > + + + {t('models.title')} + + + + ); } diff --git a/web/src/app/home/components/models-dialog/ModelsPanel.tsx b/web/src/app/home/components/models-dialog/ModelsPanel.tsx new file mode 100644 index 00000000..7dd32c13 --- /dev/null +++ b/web/src/app/home/components/models-dialog/ModelsPanel.tsx @@ -0,0 +1,666 @@ +import { useState, useEffect } from 'react'; +import { Plus, Boxes } from 'lucide-react'; +import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; +import { ModelProvider } from '@/app/infra/entities/api'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import ProviderForm from './component/provider-form/ProviderForm'; +import { ProviderCard } from './components'; +import { + ExtraArg, + ModelType, + ScanModelsResult, + SelectedScannedModel, + TestResult, + ProviderModels, + LANGBOT_MODELS_PROVIDER_REQUESTER, +} from './types'; +import { CustomApiError } from '@/app/infra/entities/common'; + +interface ModelsPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; + // Notify parent when a nested modal (provider form) should block outer-close. + onBlockingChange?: (blocking: boolean) => void; +} + +type ExtraArgValue = string | number | boolean | Record; + +function convertExtraArgsToObject( + args: ExtraArg[], +): Record { + const obj: Record = {}; + args.forEach((arg) => { + if (!arg.key.trim()) return; + if (arg.type === 'number') { + obj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + obj[arg.key] = arg.value === 'true'; + } else if (arg.type === 'object') { + const raw = arg.value.trim() || '{}'; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Invalid JSON for extra parameter "${arg.key}"`); + } + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + throw new Error(`Extra parameter "${arg.key}" must be a JSON object`); + } + obj[arg.key] = parsed as Record; + } else { + obj[arg.key] = arg.value; + } + }); + return obj; +} + +function parseContextLength( + value: number | null | undefined, + invalidMessage: string, +): number | null { + if (value === undefined || value === null) return null; + if (!Number.isInteger(value) || value <= 0) { + throw new Error(invalidMessage); + } + return value; +} + +export default function ModelsPanel({ + active, + onBlockingChange, +}: ModelsPanelProps) { + const { t } = useTranslation(); + + const [providers, setProviders] = useState([]); + const [accountType, setAccountType] = useState<'local' | 'space'>('local'); + const [spaceCredits, setSpaceCredits] = useState(null); + + // Expanded providers and their models + const [expandedProviders, setExpandedProviders] = useState>( + new Set(), + ); + const [providerModels, setProviderModels] = useState< + Record + >({}); + const [loadingProviders, setLoadingProviders] = useState>( + new Set(), + ); + + // Provider form modal + const [providerFormOpen, setProviderFormOpen] = useState(false); + const [editingProviderId, setEditingProviderId] = useState( + null, + ); + + // Map of requester name -> support_type[] (from requester manifests), + // used to restrict which model-type tabs are shown when adding models. + const [requesterSupportTypes, setRequesterSupportTypes] = useState< + Record + >({}); + + // Popover states + const [addModelPopoverOpen, setAddModelPopoverOpen] = useState( + null, + ); + const [editModelPopoverOpen, setEditModelPopoverOpen] = useState< + string | null + >(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState( + null, + ); + + // Form states + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + // Track if providers have been loaded initially + const [providersLoaded, setProvidersLoaded] = useState(false); + + // Separate LangBot Models provider (hide when models service is disabled) + const langbotProvider = systemInfo.disable_models_service + ? undefined + : providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER); + const otherProviders = providers.filter( + (p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + + useEffect(() => { + if (active) { + loadUserInfo(); + loadProviders(); + loadRequesterSupportTypes(); + } + }, [active]); + + // Notify parent of blocking state so it can guard outer-close. + useEffect(() => { + onBlockingChange?.(providerFormOpen); + }, [providerFormOpen, onBlockingChange]); + + // Auto-expand LangBot Models when no external providers exist + useEffect(() => { + if (providersLoaded && langbotProvider && otherProviders.length === 0) { + if (!expandedProviders.has(langbotProvider.uuid)) { + setExpandedProviders(new Set([langbotProvider.uuid])); + if (!providerModels[langbotProvider.uuid]) { + loadProviderModels(langbotProvider.uuid); + } + } + } + }, [providersLoaded, providers]); + + async function loadUserInfo() { + try { + const userInfo = await httpClient.getUserInfo(); + setAccountType(userInfo.account_type); + if (userInfo.account_type === 'space') { + const creditsInfo = await httpClient.getSpaceCredits(); + setSpaceCredits(creditsInfo.credits); + } + } catch { + setAccountType('local'); + } + } + + async function loadProviders() { + try { + const resp = await httpClient.getModelProviders(); + setProviders(resp.providers); + setProvidersLoaded(true); + } catch (err) { + console.error('Failed to load providers', err); + toast.error(t('models.loadError')); + } + } + + async function loadRequesterSupportTypes() { + try { + const resp = await httpClient.getProviderRequesters(); + const map: Record = {}; + for (const r of resp.requesters) { + map[r.name] = r.spec?.support_type ?? []; + } + setRequesterSupportTypes(map); + } catch (err) { + console.error('Failed to load requester support types', err); + } + } + + async function loadProviderModels(providerUuid: string, silent = false) { + if (loadingProviders.has(providerUuid)) return; + + if (!silent) { + setLoadingProviders((prev) => new Set(prev).add(providerUuid)); + } + try { + const [llmResp, embeddingResp, rerankResp] = await Promise.all([ + httpClient.getProviderLLMModels(providerUuid), + httpClient.getProviderEmbeddingModels(providerUuid), + httpClient.getProviderRerankModels(providerUuid), + ]); + setProviderModels((prev) => ({ + ...prev, + [providerUuid]: { + llm: llmResp.models, + embedding: embeddingResp.models, + rerank: rerankResp.models, + }, + })); + } catch (err) { + console.error('Failed to load models', err); + } finally { + if (!silent) { + setLoadingProviders((prev) => { + const next = new Set(prev); + next.delete(providerUuid); + return next; + }); + } + } + } + + function toggleProvider(providerUuid: string) { + setExpandedProviders((prev) => { + const next = new Set(prev); + if (next.has(providerUuid)) { + next.delete(providerUuid); + } else { + next.add(providerUuid); + if (!providerModels[providerUuid]) { + loadProviderModels(providerUuid); + } + } + return next; + }); + } + + function handleCreateProvider() { + setEditingProviderId(null); + setProviderFormOpen(true); + } + + function handleEditProvider(providerId: string) { + setEditingProviderId(providerId); + setProviderFormOpen(true); + } + + async function handleDeleteProvider(providerId: string) { + try { + await httpClient.deleteModelProvider(providerId); + toast.success(t('models.providerDeleted')); + loadProviders(); + } catch (err) { + toast.error(t('models.providerDeleteError') + (err as Error).message); + } + } + + async function handleSpaceLogin() { + try { + const token = localStorage.getItem('token'); + if (!token) { + toast.error(t('common.error')); + return; + } + const currentOrigin = window.location.origin; + const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; + const response = await httpClient.getSpaceAuthorizeUrl( + redirectUri, + token, + ); + window.location.href = response.authorize_url; + } catch { + toast.error(t('common.spaceLoginFailed')); + } + } + + async function handleAddModel( + providerUuid: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + contextLength?: number | null, + ) { + if (!name.trim()) { + toast.error(t('models.modelNameRequired')); + return; + } + setIsSubmitting(true); + try { + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + if (modelType === 'llm') { + await httpClient.createProviderLLMModel({ + name, + provider_uuid: providerUuid, + abilities, + context_length: parseContextLength( + contextLength, + t('models.contextLengthInvalid'), + ), + extra_args: extraArgsObj, + } as never); + } else if (modelType === 'embedding') { + await httpClient.createProviderEmbeddingModel({ + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.createProviderRerankModel({ + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } + setAddModelPopoverOpen(null); + loadProviderModels(providerUuid, true); + loadProviders(); + } catch (err) { + toast.error(t('models.createError') + (err as Error).message); + } finally { + setIsSubmitting(false); + } + } + + async function handleScanModels( + providerUuid: string, + modelType?: ModelType, + ): Promise { + try { + const resp = await httpClient.scanProviderModels(providerUuid, modelType); + return { + models: resp.models, + debug: resp.debug, + }; + } catch (err) { + toast.error(t('models.getModelListError') + (err as CustomApiError).msg); + return { models: [] }; + } + } + + async function handleAddScannedModels( + providerUuid: string, + modelType: ModelType, + models: SelectedScannedModel[], + ) { + if (models.length === 0) return; + + setIsSubmitting(true); + try { + for (const item of models) { + const effectiveType = item.model.type || modelType; + if (effectiveType === 'llm') { + await httpClient.createProviderLLMModel({ + name: item.model.name, + provider_uuid: providerUuid, + abilities: item.abilities, + context_length: item.model.context_length ?? null, + extra_args: {}, + } as never); + } else if (effectiveType === 'embedding') { + await httpClient.createProviderEmbeddingModel({ + name: item.model.name, + provider_uuid: providerUuid, + extra_args: {}, + } as never); + } else { + await httpClient.createProviderRerankModel({ + name: item.model.name, + provider_uuid: providerUuid, + extra_args: {}, + } as never); + } + } + setAddModelPopoverOpen(null); + loadProviderModels(providerUuid, true); + loadProviders(); + toast.success( + t('models.addSelectedModelsSuccess', { count: models.length }), + ); + } catch (err) { + toast.error(t('models.createError') + (err as CustomApiError).msg); + } finally { + setIsSubmitting(false); + } + } + + async function handleUpdateModel( + providerUuid: string, + modelId: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + contextLength?: number | null, + ) { + if (!name.trim()) { + toast.error(t('models.modelNameRequired')); + return; + } + setIsSubmitting(true); + try { + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + if (modelType === 'llm') { + await httpClient.updateProviderLLMModel(modelId, { + name, + provider_uuid: providerUuid, + abilities, + context_length: parseContextLength( + contextLength, + t('models.contextLengthInvalid'), + ), + extra_args: extraArgsObj, + } as never); + } else if (modelType === 'embedding') { + await httpClient.updateProviderEmbeddingModel(modelId, { + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.updateProviderRerankModel(modelId, { + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } + setEditModelPopoverOpen(null); + loadProviderModels(providerUuid, true); + loadProviders(); + } catch (err) { + toast.error(t('models.saveError') + (err as Error).message); + } finally { + setIsSubmitting(false); + } + } + + async function handleDeleteModel( + providerUuid: string, + modelId: string, + modelType: ModelType, + ) { + try { + if (modelType === 'llm') { + await httpClient.deleteProviderLLMModel(modelId); + } else if (modelType === 'embedding') { + await httpClient.deleteProviderEmbeddingModel(modelId); + } else { + await httpClient.deleteProviderRerankModel(modelId); + } + toast.success(t('models.deleteSuccess')); + loadProviderModels(providerUuid, true); + loadProviders(); + } catch (err) { + toast.error(t('models.deleteError') + (err as Error).message); + } + } + + async function handleTestModel( + providerUuid: string, + name: string, + modelType: ModelType, + abilities: string[], + extraArgs: ExtraArg[], + ) { + setIsTesting(true); + setTestResult(null); + const startTime = Date.now(); + try { + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + // Get the provider info + const provider = providers.find((p) => p.uuid === providerUuid); + const providerData = { + requester: provider?.requester || '', + base_url: provider?.base_url || '', + api_keys: provider?.api_keys || [], + }; + + if (modelType === 'llm') { + await httpClient.testLLMModel('_', { + uuid: '', + name, + provider_uuid: '', + provider: providerData, + abilities, + extra_args: extraArgsObj, + } as never); + } else if (modelType === 'embedding') { + await httpClient.testEmbeddingModel('_', { + uuid: '', + name, + provider_uuid: '', + provider: providerData, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.testRerankModel('_', { + uuid: '', + name, + provider_uuid: '', + provider: providerData, + extra_args: extraArgsObj, + } as never); + } + const duration = Date.now() - startTime; + setTestResult({ success: true, duration }); + } catch (err) { + console.error('Failed to test model', err); + toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg); + setTestResult(null); + } finally { + setIsTesting(false); + } + } + + function handleFormClose() { + setProviderFormOpen(false); + loadProviders(); + // Refresh expanded providers + expandedProviders.forEach((uuid) => loadProviderModels(uuid)); + } + + function renderProviderCard( + provider: ModelProvider, + isLangBotModels: boolean = false, + ) { + return ( + toggleProvider(provider.uuid)} + onEditProvider={() => handleEditProvider(provider.uuid)} + onDeleteProvider={() => handleDeleteProvider(provider.uuid)} + onSpaceLogin={handleSpaceLogin} + onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)} + onCloseAddModel={() => setAddModelPopoverOpen(null)} + onAddModel={(modelType, name, abilities, extraArgs, contextLength) => + handleAddModel( + provider.uuid, + modelType, + name, + abilities, + extraArgs, + contextLength, + ) + } + onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)} + onAddScannedModels={(modelType, models) => + handleAddScannedModels(provider.uuid, modelType, models) + } + onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)} + onCloseEditModel={() => setEditModelPopoverOpen(null)} + onUpdateModel={( + modelId, + modelType, + name, + abilities, + extraArgs, + contextLength, + ) => + handleUpdateModel( + provider.uuid, + modelId, + modelType, + name, + abilities, + extraArgs, + contextLength, + ) + } + onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)} + onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)} + onDeleteModel={(modelId, modelType) => + handleDeleteModel(provider.uuid, modelId, modelType) + } + onTestModel={(name, modelType, abilities, extraArgs) => + handleTestModel(provider.uuid, name, modelType, abilities, extraArgs) + } + isSubmitting={isSubmitting} + isTesting={isTesting} + testResult={testResult} + onResetTestResult={() => setTestResult(null)} + /> + ); + } + + return ( + <> +
+ {/* LangBot Models Card */} + {langbotProvider && renderProviderCard(langbotProvider, true)} + + {/* Add Provider Button */} +
+ + {otherProviders.length === 0 + ? t( + systemInfo.disable_models_service + ? 'models.addProviderHintSimple' + : 'models.addProviderHint', + ) + : t('models.providerCount', { count: otherProviders.length })} + +
+ +
+
+ + {/* Provider List */} + {otherProviders.length === 0 ? ( +
+ +

{t('models.noProviders')}

+
+ ) : ( + otherProviders.map((p) => renderProviderCard(p)) + )} +
+ + + + + + {editingProviderId + ? t('models.editProvider') + : t('models.addProvider')} + + + setProviderFormOpen(false)} + /> + + + + ); +} diff --git a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx new file mode 100644 index 00000000..f22fc779 --- /dev/null +++ b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from '@/components/ui/sidebar'; +import { cn } from '@/lib/utils'; +import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel'; +import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel'; +import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel'; +import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel'; + +// The set of settings sections shown in the unified dialog. The string values +// are also reused as the ?action= query param suffix so deep links keep working. +export type SettingsSection = + | 'account' + | 'apiIntegration' + | 'models' + | 'storageAnalysis'; + +// Map between a section id and its ?action= query value, so existing deep links +// (showAccountSettings, showApiIntegrationSettings, showModelSettings, +// showStorageAnalysis) continue to resolve to the right section. +export const SETTINGS_ACTION_BY_SECTION: Record = { + account: 'showAccountSettings', + apiIntegration: 'showApiIntegrationSettings', + models: 'showModelSettings', + storageAnalysis: 'showStorageAnalysis', +}; + +export const SETTINGS_SECTION_BY_ACTION: Record = + Object.fromEntries( + Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [ + action, + section as SettingsSection, + ]), + ); + +interface SettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + section: SettingsSection; + onSectionChange: (section: SettingsSection) => void; +} + +export default function SettingsDialog({ + open, + onOpenChange, + section, + onSectionChange, +}: SettingsDialogProps) { + const { t } = useTranslation(); + // A nested modal (e.g. the provider form) can request that we ignore + // outer-close until it is dismissed. + const [blocking, setBlocking] = useState(false); + + // Only the Models panel can raise a blocking nested modal. When we navigate + // away from it (or close the dialog) the panel unmounts without resetting, + // so clear the flag here to avoid getting stuck unable to close. + useEffect(() => { + if (section !== 'models' || !open) { + setBlocking(false); + } + }, [section, open]); + + const navItems: { + id: SettingsSection; + label: string; + icon: React.ReactNode; + }[] = [ + { + id: 'models', + label: t('models.title'), + icon: , + }, + { + id: 'apiIntegration', + label: t('common.apiIntegration'), + icon: , + }, + { + id: 'storageAnalysis', + label: t('storageAnalysis.title'), + icon: , + }, + { + id: 'account', + label: t('account.settings'), + icon: , + }, + ]; + + const activeLabel = + navItems.find((item) => item.id === section)?.label ?? + t('settingsDialog.title'); + + return ( + { + if (!newOpen && blocking) return; + onOpenChange(newOpen); + }} + > + + + {t('settingsDialog.title')} + + {activeLabel} + + + + + + +
+ {t('settingsDialog.title')} +
+ + {navItems.map((item) => ( + + onSectionChange(item.id)} + > + {item.icon} + {item.label} + + + ))} + +
+
+
+
+ +
+ {/* Mobile section switcher (sidebar is hidden on small screens) */} +
+ {navItems.map((item) => ( + + ))} +
+ +
+ {section === 'models' && ( + + )} + {section === 'apiIntegration' && ( + + )} + {section === 'storageAnalysis' && ( + + )} + {section === 'account' && ( + + )} +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx deleted file mode 100644 index 210b93de..00000000 --- a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx +++ /dev/null @@ -1,410 +0,0 @@ -'use client'; - -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { - AlertCircle, - Archive, - Clock, - Database, - FileWarning, - HardDrive, - RefreshCw, -} from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { backendClient } from '@/app/infra/http'; - -interface StorageSection { - key: string; - path: string; - exists: boolean; - size_bytes: number; - file_count: number; -} - -interface CleanupCandidate { - key?: string; - name?: string; - size_bytes: number; - modified_at?: string; - date?: string; -} - -interface StorageAnalysis { - generated_at: string; - cleanup_policy: { - uploaded_file_retention_days: number; - log_retention_days: number; - }; - sections: StorageSection[]; - database: { - type: string; - monitoring_counts: Record; - binary_storage: { - count: number; - size_bytes: number | null; - }; - }; - cleanup_candidates: { - uploaded_files: CleanupCandidate[]; - log_files: CleanupCandidate[]; - }; - tasks: Record; -} - -interface StorageAnalysisDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -function formatBytes(bytes: number | null | undefined): string { - if (bytes === null || bytes === undefined) { - return '-'; - } - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ['KB', 'MB', 'GB', 'TB']; - let value = bytes / 1024; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`; -} - -export default function StorageAnalysisDialog({ - open, - onOpenChange, -}: StorageAnalysisDialogProps) { - const { t } = useTranslation(); - const [analysis, setAnalysis] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadAnalysis = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await backendClient.get( - '/api/v1/system/storage-analysis', - ); - setAnalysis(result); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (open) { - loadAnalysis(); - } - }, [loadAnalysis, open]); - - const totalBytes = useMemo(() => { - return ( - analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0 - ); - }, [analysis]); - - const uploadedCandidateBytes = useMemo(() => { - return ( - analysis?.cleanup_candidates.uploaded_files.reduce( - (sum, item) => sum + item.size_bytes, - 0, - ) ?? 0 - ); - }, [analysis]); - - const logCandidateBytes = useMemo(() => { - return ( - analysis?.cleanup_candidates.log_files.reduce( - (sum, item) => sum + item.size_bytes, - 0, - ) ?? 0 - ); - }, [analysis]); - - return ( - - - - - - {t('storageAnalysis.dialogTitle')} - - - {t('storageAnalysis.description')} - - - -
-
- {analysis - ? t('storageAnalysis.generatedAt', { - time: new Date(analysis.generated_at).toLocaleString(), - }) - : t('storageAnalysis.loading')} -
- -
- - -
- {error && ( -
- - {error} -
- )} - - {analysis && ( - <> -
- } - /> - } - /> - } - /> - } - /> -
- -
-

- - {t('storageAnalysis.cleanupPolicy')} -

-
- - - -
-
- -
-

- {t('storageAnalysis.sections')} -

-
- {analysis.sections.map((section) => ( -
-
-
- {t(`storageAnalysis.sectionNames.${section.key}`)} -
-
- {section.path || '-'} -
-
- {section.exists ? ( - - ) : ( - - {t('storageAnalysis.missing')} - - )} -
- {formatBytes(section.size_bytes)} -
-
- {section.file_count} -
-
- ))} -
-
- -
- - -
- -
- - -
- - )} -
-
-
-
- ); -} - -function SummaryItem({ - label, - value, - icon, - meta, -}: { - label: string; - value: string; - icon: ReactNode; - meta?: string; -}) { - return ( -
-
- {icon} - {label} -
-
- {value} - {meta && {meta}} -
-
- ); -} - -function PolicyItem({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function MetricPanel({ - title, - values, -}: { - title: string; - values: Record; -}) { - return ( -
-

{title}

-
- {Object.entries(values).map(([key, value]) => ( -
- {key} - {value ?? '-'} -
- ))} -
-
- ); -} - -function CandidatePanel({ - title, - emptyText, - candidates, -}: { - title: string; - emptyText: string; - candidates: CleanupCandidate[]; -}) { - return ( -
-

- - {title} -

-
- {candidates.length === 0 ? ( -
- {emptyText} -
- ) : ( - candidates.slice(0, 8).map((candidate, index) => ( -
-
-
- {candidate.key ?? candidate.name} -
-
- {candidate.modified_at ?? candidate.date ?? '-'} -
-
-
- {formatBytes(candidate.size_bytes)} -
-
- )) - )} -
-
- ); -} diff --git a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx new file mode 100644 index 00000000..0488616c --- /dev/null +++ b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx @@ -0,0 +1,390 @@ +'use client'; + +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertCircle, + Archive, + Clock, + Database, + FileWarning, + HardDrive, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { backendClient } from '@/app/infra/http'; + +interface StorageSection { + key: string; + path: string; + exists: boolean; + size_bytes: number; + file_count: number; +} + +interface CleanupCandidate { + key?: string; + name?: string; + size_bytes: number; + modified_at?: string; + date?: string; +} + +interface StorageAnalysis { + generated_at: string; + cleanup_policy: { + uploaded_file_retention_days: number; + log_retention_days: number; + }; + sections: StorageSection[]; + database: { + type: string; + monitoring_counts: Record; + binary_storage: { + count: number; + size_bytes: number | null; + }; + }; + cleanup_candidates: { + uploaded_files: CleanupCandidate[]; + log_files: CleanupCandidate[]; + }; + tasks: Record; +} + +interface StorageAnalysisPanelProps { + // True when this panel is the active section and the dialog is open. + active: boolean; +} + +function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) { + return '-'; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} + +export default function StorageAnalysisPanel({ + active, +}: StorageAnalysisPanelProps) { + const { t } = useTranslation(); + const [analysis, setAnalysis] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadAnalysis = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await backendClient.get( + '/api/v1/system/storage-analysis', + ); + setAnalysis(result); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (active) { + loadAnalysis(); + } + }, [loadAnalysis, active]); + + const totalBytes = useMemo(() => { + return ( + analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0 + ); + }, [analysis]); + + const uploadedCandidateBytes = useMemo(() => { + return ( + analysis?.cleanup_candidates.uploaded_files.reduce( + (sum, item) => sum + item.size_bytes, + 0, + ) ?? 0 + ); + }, [analysis]); + + const logCandidateBytes = useMemo(() => { + return ( + analysis?.cleanup_candidates.log_files.reduce( + (sum, item) => sum + item.size_bytes, + 0, + ) ?? 0 + ); + }, [analysis]); + + return ( +
+
+
+ {analysis + ? t('storageAnalysis.generatedAt', { + time: new Date(analysis.generated_at).toLocaleString(), + }) + : t('storageAnalysis.loading')} +
+ +
+ + +
+ {error && ( +
+ + {error} +
+ )} + + {analysis && ( + <> +
+ } + /> + } + /> + } + /> + } + /> +
+ +
+

+ + {t('storageAnalysis.cleanupPolicy')} +

+
+ + + +
+
+ +
+

+ {t('storageAnalysis.sections')} +

+
+ {analysis.sections.map((section) => ( +
+
+
+ {t(`storageAnalysis.sectionNames.${section.key}`)} +
+
+ {section.path || '-'} +
+
+ {section.exists ? ( + + ) : ( + + {t('storageAnalysis.missing')} + + )} +
+ {formatBytes(section.size_bytes)} +
+
+ {section.file_count} +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+ + )} +
+
+
+ ); +} + +function SummaryItem({ + label, + value, + icon, + meta, +}: { + label: string; + value: string; + icon: ReactNode; + meta?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} + {meta && {meta}} +
+
+ ); +} + +function PolicyItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function MetricPanel({ + title, + values, +}: { + title: string; + values: Record; +}) { + return ( +
+

{title}

+
+ {Object.entries(values).map(([key, value]) => ( +
+ {key} + {value ?? '-'} +
+ ))} +
+
+ ); +} + +function CandidatePanel({ + title, + emptyText, + candidates, +}: { + title: string; + emptyText: string; + candidates: CleanupCandidate[]; +}) { + return ( +
+

+ + {title} +

+
+ {candidates.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + candidates.slice(0, 8).map((candidate, index) => ( +
+
+
+ {candidate.key ?? candidate.name} +
+
+ {candidate.modified_at ?? candidate.date ?? '-'} +
+
+
+ {formatBytes(candidate.size_bytes)} +
+
+ )) + )} +
+
+ ); +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 91f0b6f3..f65a477f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1386,6 +1386,9 @@ const enUS = { boxSessionCreated: 'Created', boxSessionLastUsed: 'Last used', }, + settingsDialog: { + title: 'Settings', + }, storageAnalysis: { title: 'Storage Analysis', description: 'Inspect storage usage and cleanup candidates', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 5f802815..564563f5 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1419,6 +1419,9 @@ const esES = { boxSessionCreated: 'Creado', boxSessionLastUsed: 'Último uso', }, + settingsDialog: { + title: 'Configuración', + }, storageAnalysis: { title: 'Análisis de almacenamiento', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index c8c65152..9769fa5c 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1392,6 +1392,9 @@ const jaJP = { boxSessionCreated: '作成日時', boxSessionLastUsed: '最終使用', }, + settingsDialog: { + title: '設定', + }, storageAnalysis: { title: 'ストレージ分析', description: 'ストレージ使用量とクリーンアップ候補を確認します', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 8ebb4fa2..f2829f77 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1395,6 +1395,9 @@ const ruRU = { boxSessionCreated: 'Создано', boxSessionLastUsed: 'Последнее использование', }, + settingsDialog: { + title: 'Настройки', + }, storageAnalysis: { title: 'Анализ хранилища', description: 'Проверьте использование хранилища и кандидатов на очистку', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index ac976402..c14bdad0 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1364,6 +1364,9 @@ const thTH = { boxSessionCreated: 'สร้างเมื่อ', boxSessionLastUsed: 'ใช้ล่าสุด', }, + settingsDialog: { + title: 'การตั้งค่า', + }, storageAnalysis: { title: 'วิเคราะห์พื้นที่จัดเก็บ', description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index be1e7754..495d4d14 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1388,6 +1388,9 @@ const viVN = { boxSessionCreated: 'Đã tạo', boxSessionLastUsed: 'Lần cuối sử dụng', }, + settingsDialog: { + title: 'Cài đặt', + }, storageAnalysis: { title: 'Phân tích lưu trữ', description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index f32f039a..590578e7 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1328,6 +1328,9 @@ const zhHans = { boxSessionCreated: '创建时间', boxSessionLastUsed: '最后使用', }, + settingsDialog: { + title: '设置', + }, storageAnalysis: { title: '存储分析', description: '查看存储占用和可清理文件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 539b34c4..4a8b3138 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1327,6 +1327,9 @@ const zhHant = { boxSessionCreated: '建立時間', boxSessionLastUsed: '最後使用', }, + settingsDialog: { + title: '設定', + }, storageAnalysis: { title: '儲存分析', description: '查看儲存占用和可清理檔案',