mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
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:
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+204
-253
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -55,20 +54,15 @@ interface Webhook {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiIntegrationDialogProps {
|
interface ApiIntegrationPanelProps {
|
||||||
open: boolean;
|
// True when this panel is the active section and the dialog is open.
|
||||||
onOpenChange: (open: boolean) => void;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ApiIntegrationDialog({
|
export default function ApiIntegrationPanel({
|
||||||
open,
|
active,
|
||||||
onOpenChange,
|
}: ApiIntegrationPanelProps) {
|
||||||
}: ApiIntegrationDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const pathname = location.pathname;
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [activeTab, setActiveTab] = useState('apikeys');
|
const [activeTab, setActiveTab] = useState('apikeys');
|
||||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||||
@@ -91,33 +85,7 @@ export default function ApiIntegrationDialog({
|
|||||||
);
|
);
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync URL with dialog state
|
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
|
||||||
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 样式,防止对话框关闭后页面无法交互
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deleteKeyId && !deleteWebhookId) {
|
if (!deleteKeyId && !deleteWebhookId) {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -131,11 +99,11 @@ export default function ApiIntegrationDialog({
|
|||||||
}, [deleteKeyId, deleteWebhookId]);
|
}, [deleteKeyId, deleteWebhookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (active) {
|
||||||
loadApiKeys();
|
loadApiKeys();
|
||||||
loadWebhooks();
|
loadWebhooks();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [active]);
|
||||||
|
|
||||||
const loadApiKeys = async () => {
|
const loadApiKeys = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -284,233 +252,216 @@ export default function ApiIntegrationDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<div className="flex h-full min-h-0 flex-col px-6 py-5">
|
||||||
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
|
<Tabs
|
||||||
<DialogHeader>
|
value={activeTab}
|
||||||
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
|
onValueChange={setActiveTab}
|
||||||
</DialogHeader>
|
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
|
{/* API Keys Tab */}
|
||||||
value={activeTab}
|
<TabsContent
|
||||||
onValueChange={setActiveTab}
|
value="apikeys"
|
||||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
className="flex flex-1 flex-col space-y-4 overflow-hidden"
|
||||||
>
|
>
|
||||||
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
|
{t('common.apiKeyHint')}
|
||||||
{t('common.apiKeys')}
|
</div>
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
<div className="flex justify-end">
|
||||||
className="px-5 py-4 cursor-pointer"
|
<Button
|
||||||
value="webhooks"
|
onClick={() => setShowCreateDialog(true)}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{t('common.webhooks')}
|
<Plus className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
{t('common.createApiKey')}
|
||||||
</TabsList>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Keys Tab */}
|
{loading ? (
|
||||||
<TabsContent
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
value="apikeys"
|
{t('common.loading')}
|
||||||
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')}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : apiKeys.length === 0 ? (
|
||||||
<div className="flex justify-end">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Button
|
{t('common.noApiKeys')}
|
||||||
onClick={() => setShowCreateDialog(true)}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{t('common.createApiKey')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{loading ? (
|
<div className="flex-1 overflow-auto rounded-md border">
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<Table>
|
||||||
{t('common.loading')}
|
<TableHeader>
|
||||||
</div>
|
<TableRow>
|
||||||
) : apiKeys.length === 0 ? (
|
<TableHead className="min-w-[120px]">
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
{t('common.name')}
|
||||||
{t('common.noApiKeys')}
|
</TableHead>
|
||||||
</div>
|
<TableHead className="min-w-[200px]">
|
||||||
) : (
|
{t('common.apiKeyValue')}
|
||||||
<div className="border rounded-md overflow-auto flex-1">
|
</TableHead>
|
||||||
<Table>
|
<TableHead className="w-[100px]">
|
||||||
<TableHeader>
|
{t('common.actions')}
|
||||||
<TableRow>
|
</TableHead>
|
||||||
<TableHead className="min-w-[120px]">
|
</TableRow>
|
||||||
{t('common.name')}
|
</TableHeader>
|
||||||
</TableHead>
|
<TableBody>
|
||||||
<TableHead className="min-w-[200px]">
|
{apiKeys.map((item) => (
|
||||||
{t('common.apiKeyValue')}
|
<TableRow key={item.id}>
|
||||||
</TableHead>
|
<TableCell>
|
||||||
<TableHead className="w-[100px]">
|
<div>
|
||||||
{t('common.actions')}
|
<div className="font-medium">{item.name}</div>
|
||||||
</TableHead>
|
{item.description && (
|
||||||
</TableRow>
|
<div className="text-sm text-muted-foreground">
|
||||||
</TableHeader>
|
{item.description}
|
||||||
<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>
|
</div>
|
||||||
{webhook.description && (
|
)}
|
||||||
<div
|
</div>
|
||||||
className="text-sm text-muted-foreground truncate"
|
</TableCell>
|
||||||
title={webhook.description}
|
<TableCell>
|
||||||
>
|
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||||
{webhook.description}
|
{maskApiKey(item.key)}
|
||||||
</div>
|
</code>
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
<div className="flex gap-2">
|
||||||
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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')}
|
title={t('common.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
</TableRow>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
)}
|
</div>
|
||||||
</TabsContent>
|
)}
|
||||||
</Tabs>
|
</TabsContent>
|
||||||
|
|
||||||
<DialogFooter>
|
{/* Webhooks Tab */}
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<TabsContent
|
||||||
{t('common.close')}
|
value="webhooks"
|
||||||
</Button>
|
className="flex flex-1 flex-col space-y-4 overflow-hidden"
|
||||||
</DialogFooter>
|
>
|
||||||
</DialogContent>
|
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
</Dialog>
|
{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 */}
|
{/* Create API Key Dialog */}
|
||||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||||
@@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
import SettingsDialog, {
|
||||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
SettingsSection,
|
||||||
|
SETTINGS_ACTION_BY_SECTION,
|
||||||
|
SETTINGS_SECTION_BY_ACTION,
|
||||||
|
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -1548,17 +1549,10 @@ export default function HomeSidebar({
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('action') === 'showModelSettings') {
|
const action = searchParams.get('action');
|
||||||
setModelsDialogOpen(true);
|
if (action && SETTINGS_SECTION_BY_ACTION[action]) {
|
||||||
}
|
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]);
|
||||||
if (searchParams.get('action') === 'showAccountSettings') {
|
setSettingsOpen(true);
|
||||||
setAccountSettingsOpen(true);
|
|
||||||
}
|
|
||||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
|
||||||
setApiKeyDialogOpen(true);
|
|
||||||
}
|
|
||||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
|
||||||
setStorageAnalysisOpen(true);
|
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -1567,15 +1561,14 @@ export default function HomeSidebar({
|
|||||||
useState<Record<string, boolean>>(loadSectionState);
|
useState<Record<string, boolean>>(loadSectionState);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
const [settingsSection, setSettingsSection] =
|
||||||
|
useState<SettingsSection>('models');
|
||||||
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
|
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
|
||||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
|
||||||
const [userEmail, setUserEmail] = useState<string>('');
|
const [userEmail, setUserEmail] = useState<string>('');
|
||||||
const [starCount, setStarCount] = useState<number | null>(null);
|
const [starCount, setStarCount] = useState<number | null>(null);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
@@ -1600,51 +1593,28 @@ export default function HomeSidebar({
|
|||||||
setShowScrollHint(false);
|
setShowScrollHint(false);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
function handleModelsDialogChange(open: boolean) {
|
function openSettings(section: SettingsSection) {
|
||||||
setModelsDialogOpen(open);
|
setSettingsSection(section);
|
||||||
if (open) {
|
setSettingsOpen(true);
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showModelSettings');
|
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||||
navigate(`${pathname}?${params.toString()}`, {
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
preventScrollReset: true,
|
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 handleAccountSettingsChange(open: boolean) {
|
function handleSettingsSectionChange(section: SettingsSection) {
|
||||||
setAccountSettingsOpen(open);
|
setSettingsSection(section);
|
||||||
if (open) {
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||||
params.set('action', 'showAccountSettings');
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
navigate(`${pathname}?${params.toString()}`, {
|
preventScrollReset: true,
|
||||||
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 handleStorageAnalysisChange(open: boolean) {
|
function handleSettingsOpenChange(open: boolean) {
|
||||||
setStorageAnalysisOpen(open);
|
setSettingsOpen(open);
|
||||||
if (open) {
|
if (!open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set('action', 'showStorageAnalysis');
|
|
||||||
navigate(`${pathname}?${params.toString()}`, {
|
|
||||||
preventScrollReset: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete('action');
|
params.delete('action');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
@@ -1917,7 +1887,7 @@ export default function HomeSidebar({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
onClick={() => setApiKeyDialogOpen(true)}
|
onClick={() => openSettings('apiIntegration')}
|
||||||
tooltip={t('common.apiIntegration')}
|
tooltip={t('common.apiIntegration')}
|
||||||
>
|
>
|
||||||
<KeyRound className="size-4 text-blue-500" />
|
<KeyRound className="size-4 text-blue-500" />
|
||||||
@@ -1930,7 +1900,7 @@ export default function HomeSidebar({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
onClick={() => handleModelsDialogChange(true)}
|
onClick={() => openSettings('models')}
|
||||||
tooltip={t('models.title')}
|
tooltip={t('models.title')}
|
||||||
>
|
>
|
||||||
<Sparkles className="text-blue-500" />
|
<Sparkles className="text-blue-500" />
|
||||||
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
|
|||||||
{/* Account actions */}
|
{/* Account actions */}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleAccountSettingsChange(true)}
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
openSettings('account');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
{t('account.settings')}
|
{t('account.settings')}
|
||||||
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
handleStorageAnalysisChange(true);
|
openSettings('storageAnalysis');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HardDrive />
|
<HardDrive />
|
||||||
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
|
|||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<AccountSettingsDialog
|
<SettingsDialog
|
||||||
open={accountSettingsOpen}
|
open={settingsOpen}
|
||||||
onOpenChange={handleAccountSettingsChange}
|
onOpenChange={handleSettingsOpenChange}
|
||||||
/>
|
section={settingsSection}
|
||||||
<ApiIntegrationDialog
|
onSectionChange={handleSettingsSectionChange}
|
||||||
open={apiKeyDialogOpen}
|
|
||||||
onOpenChange={setApiKeyDialogOpen}
|
|
||||||
/>
|
/>
|
||||||
<NewVersionDialog
|
<NewVersionDialog
|
||||||
open={versionDialogOpen}
|
open={versionDialogOpen}
|
||||||
onOpenChange={setVersionDialogOpen}
|
onOpenChange={setVersionDialogOpen}
|
||||||
release={latestRelease}
|
release={latestRelease}
|
||||||
/>
|
/>
|
||||||
<ModelsDialog
|
|
||||||
open={modelsDialogOpen}
|
|
||||||
onOpenChange={handleModelsDialogChange}
|
|
||||||
/>
|
|
||||||
<StorageAnalysisDialog
|
|
||||||
open={storageAnalysisOpen}
|
|
||||||
onOpenChange={handleStorageAnalysisChange}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,677 +1,42 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Boxes } from 'lucide-react';
|
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
|
||||||
import { ModelProvider } from '@/app/infra/entities/api';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ProviderForm from './component/provider-form/ProviderForm';
|
import ModelsPanel from './ModelsPanel';
|
||||||
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 ModelsDialogProps {
|
interface ModelsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
// Standalone Models dialog. The unified Settings dialog renders <ModelsPanel />
|
||||||
|
// directly; this wrapper is kept for places that open Models on its own
|
||||||
function convertExtraArgsToObject(
|
// (e.g. the model picker inside dynamic forms).
|
||||||
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 ModelsDialog({
|
export default function ModelsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ModelsDialogProps) {
|
}: ModelsDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [blocking, setBlocking] = useState(false);
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog
|
||||||
<Dialog
|
open={open}
|
||||||
open={open}
|
onOpenChange={(newOpen) => {
|
||||||
onOpenChange={(newOpen) => {
|
if (!newOpen && blocking) return;
|
||||||
if (!newOpen && providerFormOpen) return;
|
onOpenChange(newOpen);
|
||||||
onOpenChange(newOpen);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
|
||||||
<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">
|
||||||
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
|
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
<ModelsPanel active={open} onBlockingChange={setBlocking} />
|
||||||
|
</DialogContent>
|
||||||
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
|
</Dialog>
|
||||||
{/* 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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1386,6 +1386,9 @@ const enUS = {
|
|||||||
boxSessionCreated: 'Created',
|
boxSessionCreated: 'Created',
|
||||||
boxSessionLastUsed: 'Last used',
|
boxSessionLastUsed: 'Last used',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: 'Settings',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'Storage Analysis',
|
title: 'Storage Analysis',
|
||||||
description: 'Inspect storage usage and cleanup candidates',
|
description: 'Inspect storage usage and cleanup candidates',
|
||||||
|
|||||||
@@ -1419,6 +1419,9 @@ const esES = {
|
|||||||
boxSessionCreated: 'Creado',
|
boxSessionCreated: 'Creado',
|
||||||
boxSessionLastUsed: 'Último uso',
|
boxSessionLastUsed: 'Último uso',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: 'Configuración',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'Análisis de almacenamiento',
|
title: 'Análisis de almacenamiento',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1392,6 +1392,9 @@ const jaJP = {
|
|||||||
boxSessionCreated: '作成日時',
|
boxSessionCreated: '作成日時',
|
||||||
boxSessionLastUsed: '最終使用',
|
boxSessionLastUsed: '最終使用',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: '設定',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'ストレージ分析',
|
title: 'ストレージ分析',
|
||||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||||
|
|||||||
@@ -1395,6 +1395,9 @@ const ruRU = {
|
|||||||
boxSessionCreated: 'Создано',
|
boxSessionCreated: 'Создано',
|
||||||
boxSessionLastUsed: 'Последнее использование',
|
boxSessionLastUsed: 'Последнее использование',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: 'Настройки',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'Анализ хранилища',
|
title: 'Анализ хранилища',
|
||||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||||
|
|||||||
@@ -1364,6 +1364,9 @@ const thTH = {
|
|||||||
boxSessionCreated: 'สร้างเมื่อ',
|
boxSessionCreated: 'สร้างเมื่อ',
|
||||||
boxSessionLastUsed: 'ใช้ล่าสุด',
|
boxSessionLastUsed: 'ใช้ล่าสุด',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: 'การตั้งค่า',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||||
|
|||||||
@@ -1388,6 +1388,9 @@ const viVN = {
|
|||||||
boxSessionCreated: 'Đã tạo',
|
boxSessionCreated: 'Đã tạo',
|
||||||
boxSessionLastUsed: 'Lần cuối sử dụng',
|
boxSessionLastUsed: 'Lần cuối sử dụng',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: 'Cài đặt',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: 'Phân tích lưu trữ',
|
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',
|
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
|
||||||
|
|||||||
@@ -1328,6 +1328,9 @@ const zhHans = {
|
|||||||
boxSessionCreated: '创建时间',
|
boxSessionCreated: '创建时间',
|
||||||
boxSessionLastUsed: '最后使用',
|
boxSessionLastUsed: '最后使用',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: '设置',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: '存储分析',
|
title: '存储分析',
|
||||||
description: '查看存储占用和可清理文件',
|
description: '查看存储占用和可清理文件',
|
||||||
|
|||||||
@@ -1327,6 +1327,9 @@ const zhHant = {
|
|||||||
boxSessionCreated: '建立時間',
|
boxSessionCreated: '建立時間',
|
||||||
boxSessionLastUsed: '最後使用',
|
boxSessionLastUsed: '最後使用',
|
||||||
},
|
},
|
||||||
|
settingsDialog: {
|
||||||
|
title: '設定',
|
||||||
|
},
|
||||||
storageAnalysis: {
|
storageAnalysis: {
|
||||||
title: '儲存分析',
|
title: '儲存分析',
|
||||||
description: '查看儲存占用和可清理檔案',
|
description: '查看儲存占用和可清理檔案',
|
||||||
|
|||||||
Reference in New Issue
Block a user