mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 10:46:03 +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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -55,20 +54,15 @@ interface Webhook {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiIntegrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface ApiIntegrationPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function ApiIntegrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ApiIntegrationDialogProps) {
|
||||
export default function ApiIntegrationPanel({
|
||||
active,
|
||||
}: ApiIntegrationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('apikeys');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
@@ -91,33 +85,7 @@ export default function ApiIntegrationDialog({
|
||||
);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
// Sync URL with dialog state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showApiIntegrationSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
|
||||
return;
|
||||
}
|
||||
if (!newOpen) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// 清理 body 样式,防止对话框关闭后页面无法交互
|
||||
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
|
||||
useEffect(() => {
|
||||
if (!deleteKeyId && !deleteWebhookId) {
|
||||
const cleanup = () => {
|
||||
@@ -131,11 +99,11 @@ export default function ApiIntegrationDialog({
|
||||
}, [deleteKeyId, deleteWebhookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (active) {
|
||||
loadApiKeys();
|
||||
loadWebhooks();
|
||||
}
|
||||
}, [open]);
|
||||
}, [active]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
@@ -284,233 +252,216 @@ export default function ApiIntegrationDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-full min-h-0 flex-col px-6 py-5">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex w-full flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
|
||||
{t('common.apiKeys')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="webhooks">
|
||||
{t('common.webhooks')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="flex flex-1 flex-col space-y-4 overflow-hidden"
|
||||
>
|
||||
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
|
||||
{t('common.apiKeys')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="px-5 py-4 cursor-pointer"
|
||||
value="webhooks"
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{t('common.webhooks')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1 max-w-full">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleWebhook(webhook)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="flex flex-1 flex-col space-y-4 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full flex-1 overflow-auto rounded-md border">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() => handleToggleWebhook(webhook)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
@@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
SETTINGS_ACTION_BY_SECTION,
|
||||
SETTINGS_SECTION_BY_ACTION,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -1548,17 +1549,10 @@ export default function HomeSidebar({
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') === 'showModelSettings') {
|
||||
setModelsDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showAccountSettings') {
|
||||
setAccountSettingsOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
const action = searchParams.get('action');
|
||||
if (action && SETTINGS_SECTION_BY_ACTION[action]) {
|
||||
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -1567,15 +1561,14 @@ export default function HomeSidebar({
|
||||
useState<Record<string, boolean>>(loadSectionState);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
|
||||
null,
|
||||
);
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1600,51 +1593,28 @@ export default function HomeSidebar({
|
||||
setShowScrollHint(false);
|
||||
}, 250);
|
||||
}
|
||||
function handleModelsDialogChange(open: boolean) {
|
||||
setModelsDialogOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showModelSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function openSettings(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
setSettingsOpen(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccountSettingsChange(open: boolean) {
|
||||
setAccountSettingsOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showAccountSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
function handleSettingsOpenChange(open: boolean) {
|
||||
setSettingsOpen(open);
|
||||
if (!open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
@@ -1917,7 +1887,7 @@ export default function HomeSidebar({
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setApiKeyDialogOpen(true)}
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
@@ -1930,7 +1900,7 @@ export default function HomeSidebar({
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleModelsDialogChange(true)}
|
||||
onClick={() => openSettings('models')}
|
||||
tooltip={t('models.title')}
|
||||
>
|
||||
<Sparkles className="text-blue-500" />
|
||||
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
|
||||
{/* Account actions */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAccountSettingsChange(true)}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('account');
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
openSettings('storageAnalysis');
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AccountSettingsDialog
|
||||
open={accountSettingsOpen}
|
||||
onOpenChange={handleAccountSettingsChange}
|
||||
/>
|
||||
<ApiIntegrationDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={handleSettingsOpenChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
/>
|
||||
<NewVersionDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
release={latestRelease}
|
||||
/>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,677 +1,42 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Boxes } from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProviderForm from './component/provider-form/ProviderForm';
|
||||
import { ProviderCard } from './components';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import ModelsPanel from './ModelsPanel';
|
||||
|
||||
interface ModelsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
function convertExtraArgsToObject(
|
||||
args: ExtraArg[],
|
||||
): Record<string, ExtraArgValue> {
|
||||
const obj: Record<string, ExtraArgValue> = {};
|
||||
args.forEach((arg) => {
|
||||
if (!arg.key.trim()) return;
|
||||
if (arg.type === 'number') {
|
||||
obj[arg.key] = Number(arg.value);
|
||||
} else if (arg.type === 'boolean') {
|
||||
obj[arg.key] = arg.value === 'true';
|
||||
} else if (arg.type === 'object') {
|
||||
const raw = arg.value.trim() || '{}';
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
|
||||
}
|
||||
obj[arg.key] = parsed as Record<string, unknown>;
|
||||
} else {
|
||||
obj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function parseContextLength(
|
||||
value: number | null | undefined,
|
||||
invalidMessage: string,
|
||||
): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Standalone Models dialog. The unified Settings dialog renders <ModelsPanel />
|
||||
// directly; this wrapper is kept for places that open Models on its own
|
||||
// (e.g. the model picker inside dynamic forms).
|
||||
export default function ModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ModelsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
Record<string, ProviderModels>
|
||||
>({});
|
||||
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Provider form modal
|
||||
const [providerFormOpen, setProviderFormOpen] = useState(false);
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Map of requester name -> support_type[] (from requester manifests),
|
||||
// used to restrict which model-type tabs are shown when adding models.
|
||||
const [requesterSupportTypes, setRequesterSupportTypes] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
// Popover states
|
||||
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Form states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
// Track if providers have been loaded initially
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
|
||||
// Separate LangBot Models provider (hide when models service is disabled)
|
||||
const langbotProvider = systemInfo.disable_models_service
|
||||
? undefined
|
||||
: providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER);
|
||||
const otherProviders = providers.filter(
|
||||
(p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUserInfo();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-expand LangBot Models when no external providers exist
|
||||
useEffect(() => {
|
||||
if (providersLoaded && langbotProvider && otherProviders.length === 0) {
|
||||
if (!expandedProviders.has(langbotProvider.uuid)) {
|
||||
setExpandedProviders(new Set([langbotProvider.uuid]));
|
||||
if (!providerModels[langbotProvider.uuid]) {
|
||||
loadProviderModels(langbotProvider.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const resp = await httpClient.getModelProviders();
|
||||
setProviders(resp.providers);
|
||||
setProvidersLoaded(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load providers', err);
|
||||
toast.error(t('models.loadError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequesterSupportTypes() {
|
||||
try {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const r of resp.requesters) {
|
||||
map[r.name] = r.spec?.support_type ?? [];
|
||||
}
|
||||
setRequesterSupportTypes(map);
|
||||
} catch (err) {
|
||||
console.error('Failed to load requester support types', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderModels(providerUuid: string, silent = false) {
|
||||
if (loadingProviders.has(providerUuid)) return;
|
||||
|
||||
if (!silent) {
|
||||
setLoadingProviders((prev) => new Set(prev).add(providerUuid));
|
||||
}
|
||||
try {
|
||||
const [llmResp, embeddingResp, rerankResp] = await Promise.all([
|
||||
httpClient.getProviderLLMModels(providerUuid),
|
||||
httpClient.getProviderEmbeddingModels(providerUuid),
|
||||
httpClient.getProviderRerankModels(providerUuid),
|
||||
]);
|
||||
setProviderModels((prev) => ({
|
||||
...prev,
|
||||
[providerUuid]: {
|
||||
llm: llmResp.models,
|
||||
embedding: embeddingResp.models,
|
||||
rerank: rerankResp.models,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to load models', err);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoadingProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerUuid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProvider(providerUuid: string) {
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(providerUuid)) {
|
||||
next.delete(providerUuid);
|
||||
} else {
|
||||
next.add(providerUuid);
|
||||
if (!providerModels[providerUuid]) {
|
||||
loadProviderModels(providerUuid);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateProvider() {
|
||||
setEditingProviderId(null);
|
||||
setProviderFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditProvider(providerId: string) {
|
||||
setEditingProviderId(providerId);
|
||||
setProviderFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteProvider(providerId: string) {
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId);
|
||||
toast.success(t('models.providerDeleted'));
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerDeleteError') + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSpaceLogin() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddModel(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
toast.error(t('models.modelNameRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
),
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.createError') + (err as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScanModels(
|
||||
providerUuid: string,
|
||||
modelType?: ModelType,
|
||||
): Promise<ScanModelsResult> {
|
||||
try {
|
||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||
return {
|
||||
models: resp.models,
|
||||
debug: resp.debug,
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(t('models.getModelListError') + (err as CustomApiError).msg);
|
||||
return { models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddScannedModels(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) {
|
||||
if (models.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const item of models) {
|
||||
const effectiveType = item.model.type || modelType;
|
||||
if (effectiveType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
context_length: item.model.context_length ?? null,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else if (effectiveType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
toast.success(
|
||||
t('models.addSelectedModelsSuccess', { count: models.length }),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t('models.createError') + (err as CustomApiError).msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateModel(
|
||||
providerUuid: string,
|
||||
modelId: string,
|
||||
modelType: ModelType,
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
toast.error(t('models.modelNameRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.updateProviderLLMModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
),
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.updateProviderEmbeddingModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.updateProviderRerankModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setEditModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.saveError') + (err as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteModel(
|
||||
providerUuid: string,
|
||||
modelId: string,
|
||||
modelType: ModelType,
|
||||
) {
|
||||
try {
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.deleteProviderLLMModel(modelId);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.deleteProviderEmbeddingModel(modelId);
|
||||
} else {
|
||||
await httpClient.deleteProviderRerankModel(modelId);
|
||||
}
|
||||
toast.success(t('models.deleteSuccess'));
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.deleteError') + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestModel(
|
||||
providerUuid: string,
|
||||
name: string,
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
// Get the provider info
|
||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||
const providerData = {
|
||||
requester: provider?.requester || '',
|
||||
base_url: provider?.base_url || '',
|
||||
api_keys: provider?.api_keys || [],
|
||||
};
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.testLLMModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
setTestResult({ success: true, duration });
|
||||
} catch (err) {
|
||||
console.error('Failed to test model', err);
|
||||
toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg);
|
||||
setTestResult(null);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormClose() {
|
||||
setProviderFormOpen(false);
|
||||
loadProviders();
|
||||
// Refresh expanded providers
|
||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
||||
}
|
||||
|
||||
function renderProviderCard(
|
||||
provider: ModelProvider,
|
||||
isLangBotModels: boolean = false,
|
||||
) {
|
||||
return (
|
||||
<ProviderCard
|
||||
key={provider.uuid}
|
||||
provider={provider}
|
||||
isLangBotModels={isLangBotModels}
|
||||
supportTypes={requesterSupportTypes[provider.requester]}
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onToggle={() => toggleProvider(provider.uuid)}
|
||||
onEditProvider={() => handleEditProvider(provider.uuid)}
|
||||
onDeleteProvider={() => handleDeleteProvider(provider.uuid)}
|
||||
onSpaceLogin={handleSpaceLogin}
|
||||
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
|
||||
onCloseAddModel={() => setAddModelPopoverOpen(null)}
|
||||
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
|
||||
handleAddModel(
|
||||
provider.uuid,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
|
||||
onAddScannedModels={(modelType, models) =>
|
||||
handleAddScannedModels(provider.uuid, modelType, models)
|
||||
}
|
||||
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
|
||||
onCloseEditModel={() => setEditModelPopoverOpen(null)}
|
||||
onUpdateModel={(
|
||||
modelId,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
) =>
|
||||
handleUpdateModel(
|
||||
provider.uuid,
|
||||
modelId,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}
|
||||
onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)}
|
||||
onDeleteModel={(modelId, modelType) =>
|
||||
handleDeleteModel(provider.uuid, modelId, modelType)
|
||||
}
|
||||
onTestModel={(name, modelType, abilities, extraArgs) =>
|
||||
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={() => setTestResult(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && providerFormOpen) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
|
||||
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
|
||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
|
||||
{/* LangBot Models Card */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
|
||||
{/* Add Provider Button */}
|
||||
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-[600px] p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProviderId
|
||||
? t('models.editProvider')
|
||||
: t('models.addProvider')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProviderForm
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && blocking) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
|
||||
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
|
||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ModelsPanel active={open} onBlockingChange={setBlocking} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
666
web/src/app/home/components/models-dialog/ModelsPanel.tsx
Normal file
666
web/src/app/home/components/models-dialog/ModelsPanel.tsx
Normal file
@@ -0,0 +1,666 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Boxes } from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProviderForm from './component/provider-form/ProviderForm';
|
||||
import { ProviderCard } from './components';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
// Notify parent when a nested modal (provider form) should block outer-close.
|
||||
onBlockingChange?: (blocking: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
function convertExtraArgsToObject(
|
||||
args: ExtraArg[],
|
||||
): Record<string, ExtraArgValue> {
|
||||
const obj: Record<string, ExtraArgValue> = {};
|
||||
args.forEach((arg) => {
|
||||
if (!arg.key.trim()) return;
|
||||
if (arg.type === 'number') {
|
||||
obj[arg.key] = Number(arg.value);
|
||||
} else if (arg.type === 'boolean') {
|
||||
obj[arg.key] = arg.value === 'true';
|
||||
} else if (arg.type === 'object') {
|
||||
const raw = arg.value.trim() || '{}';
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
|
||||
}
|
||||
obj[arg.key] = parsed as Record<string, unknown>;
|
||||
} else {
|
||||
obj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function parseContextLength(
|
||||
value: number | null | undefined,
|
||||
invalidMessage: string,
|
||||
): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function ModelsPanel({
|
||||
active,
|
||||
onBlockingChange,
|
||||
}: ModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
Record<string, ProviderModels>
|
||||
>({});
|
||||
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Provider form modal
|
||||
const [providerFormOpen, setProviderFormOpen] = useState(false);
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Map of requester name -> support_type[] (from requester manifests),
|
||||
// used to restrict which model-type tabs are shown when adding models.
|
||||
const [requesterSupportTypes, setRequesterSupportTypes] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
// Popover states
|
||||
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Form states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
// Track if providers have been loaded initially
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
|
||||
// Separate LangBot Models provider (hide when models service is disabled)
|
||||
const langbotProvider = systemInfo.disable_models_service
|
||||
? undefined
|
||||
: providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER);
|
||||
const otherProviders = providers.filter(
|
||||
(p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
// Notify parent of blocking state so it can guard outer-close.
|
||||
useEffect(() => {
|
||||
onBlockingChange?.(providerFormOpen);
|
||||
}, [providerFormOpen, onBlockingChange]);
|
||||
|
||||
// Auto-expand LangBot Models when no external providers exist
|
||||
useEffect(() => {
|
||||
if (providersLoaded && langbotProvider && otherProviders.length === 0) {
|
||||
if (!expandedProviders.has(langbotProvider.uuid)) {
|
||||
setExpandedProviders(new Set([langbotProvider.uuid]));
|
||||
if (!providerModels[langbotProvider.uuid]) {
|
||||
loadProviderModels(langbotProvider.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const resp = await httpClient.getModelProviders();
|
||||
setProviders(resp.providers);
|
||||
setProvidersLoaded(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load providers', err);
|
||||
toast.error(t('models.loadError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequesterSupportTypes() {
|
||||
try {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const r of resp.requesters) {
|
||||
map[r.name] = r.spec?.support_type ?? [];
|
||||
}
|
||||
setRequesterSupportTypes(map);
|
||||
} catch (err) {
|
||||
console.error('Failed to load requester support types', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderModels(providerUuid: string, silent = false) {
|
||||
if (loadingProviders.has(providerUuid)) return;
|
||||
|
||||
if (!silent) {
|
||||
setLoadingProviders((prev) => new Set(prev).add(providerUuid));
|
||||
}
|
||||
try {
|
||||
const [llmResp, embeddingResp, rerankResp] = await Promise.all([
|
||||
httpClient.getProviderLLMModels(providerUuid),
|
||||
httpClient.getProviderEmbeddingModels(providerUuid),
|
||||
httpClient.getProviderRerankModels(providerUuid),
|
||||
]);
|
||||
setProviderModels((prev) => ({
|
||||
...prev,
|
||||
[providerUuid]: {
|
||||
llm: llmResp.models,
|
||||
embedding: embeddingResp.models,
|
||||
rerank: rerankResp.models,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to load models', err);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoadingProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerUuid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProvider(providerUuid: string) {
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(providerUuid)) {
|
||||
next.delete(providerUuid);
|
||||
} else {
|
||||
next.add(providerUuid);
|
||||
if (!providerModels[providerUuid]) {
|
||||
loadProviderModels(providerUuid);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateProvider() {
|
||||
setEditingProviderId(null);
|
||||
setProviderFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditProvider(providerId: string) {
|
||||
setEditingProviderId(providerId);
|
||||
setProviderFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteProvider(providerId: string) {
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId);
|
||||
toast.success(t('models.providerDeleted'));
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerDeleteError') + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSpaceLogin() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddModel(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
toast.error(t('models.modelNameRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
),
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.createError') + (err as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScanModels(
|
||||
providerUuid: string,
|
||||
modelType?: ModelType,
|
||||
): Promise<ScanModelsResult> {
|
||||
try {
|
||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||
return {
|
||||
models: resp.models,
|
||||
debug: resp.debug,
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(t('models.getModelListError') + (err as CustomApiError).msg);
|
||||
return { models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddScannedModels(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) {
|
||||
if (models.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const item of models) {
|
||||
const effectiveType = item.model.type || modelType;
|
||||
if (effectiveType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
context_length: item.model.context_length ?? null,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else if (effectiveType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
toast.success(
|
||||
t('models.addSelectedModelsSuccess', { count: models.length }),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t('models.createError') + (err as CustomApiError).msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateModel(
|
||||
providerUuid: string,
|
||||
modelId: string,
|
||||
modelType: ModelType,
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
toast.error(t('models.modelNameRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.updateProviderLLMModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
),
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.updateProviderEmbeddingModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.updateProviderRerankModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setEditModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.saveError') + (err as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteModel(
|
||||
providerUuid: string,
|
||||
modelId: string,
|
||||
modelType: ModelType,
|
||||
) {
|
||||
try {
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.deleteProviderLLMModel(modelId);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.deleteProviderEmbeddingModel(modelId);
|
||||
} else {
|
||||
await httpClient.deleteProviderRerankModel(modelId);
|
||||
}
|
||||
toast.success(t('models.deleteSuccess'));
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
toast.error(t('models.deleteError') + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestModel(
|
||||
providerUuid: string,
|
||||
name: string,
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const extraArgsObj = convertExtraArgsToObject(extraArgs);
|
||||
|
||||
// Get the provider info
|
||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||
const providerData = {
|
||||
requester: provider?.requester || '',
|
||||
base_url: provider?.base_url || '',
|
||||
api_keys: provider?.api_keys || [],
|
||||
};
|
||||
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.testLLMModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
setTestResult({ success: true, duration });
|
||||
} catch (err) {
|
||||
console.error('Failed to test model', err);
|
||||
toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg);
|
||||
setTestResult(null);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormClose() {
|
||||
setProviderFormOpen(false);
|
||||
loadProviders();
|
||||
// Refresh expanded providers
|
||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
||||
}
|
||||
|
||||
function renderProviderCard(
|
||||
provider: ModelProvider,
|
||||
isLangBotModels: boolean = false,
|
||||
) {
|
||||
return (
|
||||
<ProviderCard
|
||||
key={provider.uuid}
|
||||
provider={provider}
|
||||
isLangBotModels={isLangBotModels}
|
||||
supportTypes={requesterSupportTypes[provider.requester]}
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onToggle={() => toggleProvider(provider.uuid)}
|
||||
onEditProvider={() => handleEditProvider(provider.uuid)}
|
||||
onDeleteProvider={() => handleDeleteProvider(provider.uuid)}
|
||||
onSpaceLogin={handleSpaceLogin}
|
||||
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
|
||||
onCloseAddModel={() => setAddModelPopoverOpen(null)}
|
||||
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
|
||||
handleAddModel(
|
||||
provider.uuid,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
|
||||
onAddScannedModels={(modelType, models) =>
|
||||
handleAddScannedModels(provider.uuid, modelType, models)
|
||||
}
|
||||
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
|
||||
onCloseEditModel={() => setEditModelPopoverOpen(null)}
|
||||
onUpdateModel={(
|
||||
modelId,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
) =>
|
||||
handleUpdateModel(
|
||||
provider.uuid,
|
||||
modelId,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}
|
||||
onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)}
|
||||
onDeleteModel={(modelId, modelType) =>
|
||||
handleDeleteModel(provider.uuid, modelId, modelType)
|
||||
}
|
||||
onTestModel={(name, modelType, abilities, extraArgs) =>
|
||||
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={() => setTestResult(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
{/* LangBot Models Card */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
|
||||
{/* Add Provider Button */}
|
||||
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-[600px] p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProviderId
|
||||
? t('models.editProvider')
|
||||
: t('models.addProvider')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProviderForm
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
web/src/app/home/components/settings-dialog/SettingsDialog.tsx
Normal file
204
web/src/app/home/components/settings-dialog/SettingsDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
|
||||
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
||||
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
||||
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
||||
|
||||
// The set of settings sections shown in the unified dialog. The string values
|
||||
// are also reused as the ?action= query param suffix so deep links keep working.
|
||||
export type SettingsSection =
|
||||
| 'account'
|
||||
| 'apiIntegration'
|
||||
| 'models'
|
||||
| 'storageAnalysis';
|
||||
|
||||
// Map between a section id and its ?action= query value, so existing deep links
|
||||
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
||||
// showStorageAnalysis) continue to resolve to the right section.
|
||||
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
||||
account: 'showAccountSettings',
|
||||
apiIntegration: 'showApiIntegrationSettings',
|
||||
models: 'showModelSettings',
|
||||
storageAnalysis: 'showStorageAnalysis',
|
||||
};
|
||||
|
||||
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
|
||||
Object.fromEntries(
|
||||
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
|
||||
action,
|
||||
section as SettingsSection,
|
||||
]),
|
||||
);
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section: SettingsSection;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
}
|
||||
|
||||
export default function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
onSectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// A nested modal (e.g. the provider form) can request that we ignore
|
||||
// outer-close until it is dismissed.
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
|
||||
// Only the Models panel can raise a blocking nested modal. When we navigate
|
||||
// away from it (or close the dialog) the panel unmounts without resetting,
|
||||
// so clear the flag here to avoid getting stuck unable to close.
|
||||
useEffect(() => {
|
||||
if (section !== 'models' || !open) {
|
||||
setBlocking(false);
|
||||
}
|
||||
}, [section, open]);
|
||||
|
||||
const navItems: {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: 'models',
|
||||
label: t('models.title'),
|
||||
icon: <Sparkles className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'apiIntegration',
|
||||
label: t('common.apiIntegration'),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'storageAnalysis',
|
||||
label: t('storageAnalysis.title'),
|
||||
icon: <HardDrive className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: t('account.settings'),
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
const activeLabel =
|
||||
navItems.find((item) => item.id === section)?.label ??
|
||||
t('settingsDialog.title');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && blocking) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 sm:max-w-[56rem] [&>button:last-child]:z-20"
|
||||
// The dialog itself is the scroll boundary; each panel manages its own
|
||||
// internal scrolling.
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('settingsDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
|
||||
|
||||
<SidebarProvider className="items-start">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden h-[80vh] w-56 shrink-0 border-r md:flex"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="px-2 py-3 text-sm font-semibold">
|
||||
{t('settingsDialog.title')}
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={section === item.id}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<main className="flex h-[80vh] min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile section switcher (sidebar is hidden on small screens) */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
|
||||
section === item.id
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{section === 'models' && (
|
||||
<ModelsPanel
|
||||
active={open && section === 'models'}
|
||||
onBlockingChange={setBlocking}
|
||||
/>
|
||||
)}
|
||||
{section === 'apiIntegration' && (
|
||||
<ApiIntegrationPanel
|
||||
active={open && section === 'apiIntegration'}
|
||||
/>
|
||||
)}
|
||||
{section === 'storageAnalysis' && (
|
||||
<StorageAnalysisPanel
|
||||
active={open && section === 'storageAnalysis'}
|
||||
/>
|
||||
)}
|
||||
{section === 'account' && (
|
||||
<AccountSettingsPanel active={open && section === 'account'} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
boxSessionLastUsed: 'Last used',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Settings',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Storage Analysis',
|
||||
description: 'Inspect storage usage and cleanup candidates',
|
||||
|
||||
@@ -1419,6 +1419,9 @@ const esES = {
|
||||
boxSessionCreated: 'Creado',
|
||||
boxSessionLastUsed: 'Último uso',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Configuración',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Análisis de almacenamiento',
|
||||
description:
|
||||
|
||||
@@ -1392,6 +1392,9 @@ const jaJP = {
|
||||
boxSessionCreated: '作成日時',
|
||||
boxSessionLastUsed: '最終使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
|
||||
@@ -1395,6 +1395,9 @@ const ruRU = {
|
||||
boxSessionCreated: 'Создано',
|
||||
boxSessionLastUsed: 'Последнее использование',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Настройки',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
|
||||
@@ -1364,6 +1364,9 @@ const thTH = {
|
||||
boxSessionCreated: 'สร้างเมื่อ',
|
||||
boxSessionLastUsed: 'ใช้ล่าสุด',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'การตั้งค่า',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
|
||||
@@ -1388,6 +1388,9 @@ const viVN = {
|
||||
boxSessionCreated: 'Đã tạo',
|
||||
boxSessionLastUsed: 'Lần cuối sử dụng',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Cài đặt',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Phân tích lưu trữ',
|
||||
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
|
||||
|
||||
@@ -1328,6 +1328,9 @@ const zhHans = {
|
||||
boxSessionCreated: '创建时间',
|
||||
boxSessionLastUsed: '最后使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '设置',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
|
||||
@@ -1327,6 +1327,9 @@ const zhHant = {
|
||||
boxSessionCreated: '建立時間',
|
||||
boxSessionLastUsed: '最後使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
|
||||
Reference in New Issue
Block a user