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.
This commit is contained in:
RockChinQ
2026-06-16 05:05:52 -04:00
parent f390980d0a
commit f4a6edf7ec
17 changed files with 1720 additions and 1578 deletions

View File

@@ -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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
</DialogContent>
</Dialog>
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</>
);
}

View File

@@ -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 (
<div className="px-6 py-5">
{userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)}
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</div>
);
}

View File

@@ -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<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -91,33 +85,7 @@ export default function ApiIntegrationDialog({
);
const [copiedKey, setCopiedKey] = useState<string | null>(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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<div className="flex h-full min-h-0 flex-col px-6 py-5">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-1 flex-col overflow-hidden"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger className="px-5 py-4 cursor-pointer" value="webhooks">
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full flex-1 flex flex-col overflow-hidden"
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="flex flex-1 flex-col space-y-4 overflow-hidden"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1 max-w-full">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="flex flex-1 flex-col space-y-4 overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="max-w-full flex-1 overflow-auto rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() => handleToggleWebhook(webhook)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>

View File

@@ -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<Record<string, boolean>>(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<SettingsSection>('models');
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
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<string>('');
const [starCount, setStarCount] = useState<number | null>(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({
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
@@ -1930,7 +1900,7 @@ export default function HomeSidebar({
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleModelsDialogChange(true)}
onClick={() => openSettings('models')}
tooltip={t('models.title')}
>
<Sparkles className="text-blue-500" />
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
{/* Account actions */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => handleAccountSettingsChange(true)}
onClick={() => {
setUserMenuOpen(false);
openSettings('account');
}}
>
<Settings />
{t('account.settings')}
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
openSettings('storageAnalysis');
}}
>
<HardDrive />
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
</SidebarFooter>
</Sidebar>
<AccountSettingsDialog
open={accountSettingsOpen}
onOpenChange={handleAccountSettingsChange}
/>
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
<SettingsDialog
open={settingsOpen}
onOpenChange={handleSettingsOpenChange}
section={settingsSection}
onSectionChange={handleSettingsSectionChange}
/>
<NewVersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</>
);
}

View File

@@ -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<string, unknown>;
function convertExtraArgsToObject(
args: ExtraArg[],
): Record<string, ExtraArgValue> {
const obj: Record<string, ExtraArgValue> = {};
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<string, unknown>;
} 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 <ModelsPanel />
// 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<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
new Set(),
);
const [providerModels, setProviderModels] = useState<
Record<string, ProviderModels>
>({});
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
new Set(),
);
// Provider form modal
const [providerFormOpen, setProviderFormOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
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<string, string[]>
>({});
// Popover states
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
null,
);
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
string | null
>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
null,
);
// Form states
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(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<string, string[]> = {};
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<ScanModelsResult> {
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 (
<ProviderCard
key={provider.uuid}
provider={provider}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onToggle={() => 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 (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
{/* LangBot Models Card */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
? t('models.editProvider')
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={() => setProviderFormOpen(false)}
/>
</DialogContent>
</Dialog>
</>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && blocking) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<ModelsPanel active={open} onBlockingChange={setBlocking} />
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string, unknown>;
function convertExtraArgsToObject(
args: ExtraArg[],
): Record<string, ExtraArgValue> {
const obj: Record<string, ExtraArgValue> = {};
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<string, unknown>;
} 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<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
new Set(),
);
const [providerModels, setProviderModels] = useState<
Record<string, ProviderModels>
>({});
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
new Set(),
);
// Provider form modal
const [providerFormOpen, setProviderFormOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
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<string, string[]>
>({});
// Popover states
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
null,
);
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
string | null
>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
null,
);
// Form states
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(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<string, string[]> = {};
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<ScanModelsResult> {
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 (
<ProviderCard
key={provider.uuid}
provider={provider}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onToggle={() => 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 (
<>
<div className="flex-1 overflow-auto px-6 py-5">
{/* LangBot Models Card */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
? t('models.editProvider')
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={() => setProviderFormOpen(false)}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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<SettingsSection, string> = {
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
storageAnalysis: 'showStorageAnalysis',
};
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
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: <Sparkles className="size-4" />,
},
{
id: 'apiIntegration',
label: t('common.apiIntegration'),
icon: <KeyRound className="size-4" />,
},
{
id: 'storageAnalysis',
label: t('storageAnalysis.title'),
icon: <HardDrive className="size-4" />,
},
{
id: 'account',
label: t('account.settings'),
icon: <Settings className="size-4" />,
},
];
const activeLabel =
navItems.find((item) => item.id === section)?.label ??
t('settingsDialog.title');
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && blocking) return;
onOpenChange(newOpen);
}}
>
<DialogContent
className="overflow-hidden p-0 sm:max-w-[56rem] [&>button:last-child]:z-20"
// The dialog itself is the scroll boundary; each panel manages its own
// internal scrolling.
>
<DialogTitle className="sr-only">
{t('settingsDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
<SidebarProvider className="items-start">
<Sidebar
collapsible="none"
className="hidden h-[80vh] w-56 shrink-0 border-r md:flex"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<div className="px-2 py-3 text-sm font-semibold">
{t('settingsDialog.title')}
</div>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={section === item.id}
onClick={() => onSectionChange(item.id)}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex h-[80vh] min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile section switcher (sidebar is hidden on small screens) */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
{navItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSectionChange(item.id)}
className={cn(
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
section === item.id
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground',
)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
onBlockingChange={setBlocking}
/>
)}
{section === 'apiIntegration' && (
<ApiIntegrationPanel
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
)}
{section === 'account' && (
<AccountSettingsPanel active={open && section === 'account'} />
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
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<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<HardDrive className="size-5 text-blue-500" />
{t('storageAnalysis.dialogTitle')}
</DialogTitle>
<DialogDescription>
{t('storageAnalysis.description')}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -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<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
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<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/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 (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</div>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -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',

View File

@@ -1419,6 +1419,9 @@ const esES = {
boxSessionCreated: 'Creado',
boxSessionLastUsed: 'Último uso',
},
settingsDialog: {
title: 'Configuración',
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:

View File

@@ -1392,6 +1392,9 @@ const jaJP = {
boxSessionCreated: '作成日時',
boxSessionLastUsed: '最終使用',
},
settingsDialog: {
title: '設定',
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',

View File

@@ -1395,6 +1395,9 @@ const ruRU = {
boxSessionCreated: 'Создано',
boxSessionLastUsed: 'Последнее использование',
},
settingsDialog: {
title: 'Настройки',
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',

View File

@@ -1364,6 +1364,9 @@ const thTH = {
boxSessionCreated: 'สร้างเมื่อ',
boxSessionLastUsed: 'ใช้ล่าสุด',
},
settingsDialog: {
title: 'การตั้งค่า',
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',

View File

@@ -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',

View File

@@ -1328,6 +1328,9 @@ const zhHans = {
boxSessionCreated: '创建时间',
boxSessionLastUsed: '最后使用',
},
settingsDialog: {
title: '设置',
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',

View File

@@ -1327,6 +1327,9 @@ const zhHant = {
boxSessionCreated: '建立時間',
boxSessionLastUsed: '最後使用',
},
settingsDialog: {
title: '設定',
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',