mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 12:20:58 +00:00
fix(web): disable quota-reached create actions (#2389)
* fix(web): disable quota-reached create actions * fix(web): close quota review gaps --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,8 @@ import type {
|
||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
|
||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||
|
||||
@@ -154,6 +156,12 @@ function AddExtensionContent() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||
const { extensions: extensionQuota, extensionsReached } =
|
||||
useWorkspaceQuotaStatus();
|
||||
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
|
||||
resource: t('sidebar.extensions'),
|
||||
max: extensionQuota.max,
|
||||
});
|
||||
|
||||
// Localized label for an extension type, used in the install dialog.
|
||||
const extensionTypeLabel = (type: string) =>
|
||||
@@ -344,23 +352,28 @@ function AddExtensionContent() {
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
if (extensionsReached) return;
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
},
|
||||
[extensionsReached],
|
||||
);
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (extensionsReached) return;
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
@@ -402,6 +415,7 @@ function AddExtensionContent() {
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (extensionsReached) return;
|
||||
if (!validateFileType(file)) {
|
||||
toast.error(t('addExtension.unsupportedFileType'));
|
||||
return;
|
||||
@@ -421,14 +435,15 @@ function AddExtensionContent() {
|
||||
setSkillUploadPreviewOpen(true);
|
||||
}
|
||||
},
|
||||
[t, setSelectedTaskId],
|
||||
[extensionsReached, t, setSelectedTaskId],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (extensionsReached) return;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
}, [extensionsReached]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -455,12 +470,13 @@ function AddExtensionContent() {
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (extensionsReached) return;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
[extensionsReached, uploadFile],
|
||||
);
|
||||
|
||||
function handleMCPCreated(_serverName: string) {
|
||||
@@ -490,7 +506,8 @@ function AddExtensionContent() {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
toast.error(t('limitation.quotaCheckFailed'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -630,9 +647,11 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubConfirm() {
|
||||
if (!selectedAsset || !selectedRelease) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
||||
return;
|
||||
}
|
||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
@@ -664,9 +683,11 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubSkillConfirm() {
|
||||
if (!githubSkillInfo) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await httpClient.installSkillFromGithub(
|
||||
githubURL.trim(),
|
||||
@@ -726,17 +747,24 @@ function AddExtensionContent() {
|
||||
setPopoverOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={extensionsReached}
|
||||
aria-disabled={extensionsReached}
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</WorkspaceQuotaTooltip>
|
||||
<PopoverContent
|
||||
forceMount
|
||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||
@@ -745,9 +773,19 @@ function AddExtensionContent() {
|
||||
{/* ===== Menu View ===== */}
|
||||
{popoverView === 'menu' && (
|
||||
<div className="space-y-4 p-4">
|
||||
{extensionsReached && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
{extensionQuotaTooltip}
|
||||
</div>
|
||||
)}
|
||||
{/* File upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
aria-disabled={extensionsReached}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
extensionsReached
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||
@@ -777,7 +815,8 @@ function AddExtensionContent() {
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('mcp')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -796,7 +835,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('github')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -815,7 +855,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setPopoverOpen(false);
|
||||
@@ -882,6 +923,7 @@ function AddExtensionContent() {
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
size="sm"
|
||||
disabled={extensionsReached}
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
@@ -946,6 +988,7 @@ function AddExtensionContent() {
|
||||
className="w-full"
|
||||
onClick={handleGithubAddressSubmit}
|
||||
disabled={
|
||||
extensionsReached ||
|
||||
!githubURL.trim() ||
|
||||
fetchingReleases ||
|
||||
fetchingSkillPreview
|
||||
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleGithubConfirm}>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubSkillConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
|
||||
<MarketPage
|
||||
installPlugin={handleInstallPlugin}
|
||||
headerActions={extensionActions}
|
||||
installDisabled={extensionsReached}
|
||||
installDisabledTooltip={extensionQuotaTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<Button
|
||||
onClick={handleModalConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
|
||||
{pluginUploadPreviewFile && (
|
||||
<PluginLocalPreviewPanel
|
||||
file={pluginUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setPluginUploadPreviewOpen(false);
|
||||
setPluginUploadPreviewFile(null);
|
||||
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
|
||||
{skillUploadPreviewFile && (
|
||||
<SkillZipPreviewPanel
|
||||
file={skillUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setSkillUploadPreviewOpen(false);
|
||||
setSkillUploadPreviewFile(null);
|
||||
|
||||
@@ -109,6 +109,11 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||
import { FeedbackPopoverContent } from './FeedbackPopover';
|
||||
import {
|
||||
type WorkspaceQuotaItem,
|
||||
useWorkspaceQuotaStatus,
|
||||
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
|
||||
// Compare two version strings, returns true if v1 > v2
|
||||
function compareVersions(v1: string, v2: string): boolean {
|
||||
@@ -279,6 +284,14 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
|
||||
count: 0,
|
||||
max: -1,
|
||||
reached: false,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
async function waitForMCPRefreshTask(taskId: number) {
|
||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||
|
||||
@@ -386,6 +399,7 @@ function NavItems({
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const sidebarData = useSidebarData();
|
||||
const quotaStatus = useWorkspaceQuotaStatus();
|
||||
const { state: sidebarState, isMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
@@ -529,19 +543,33 @@ function NavItems({
|
||||
if (config.id === 'add-extension' && !canManageResources) {
|
||||
return null;
|
||||
}
|
||||
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
||||
const quota =
|
||||
config.id === 'add-extension'
|
||||
? quotaStatus.extensions
|
||||
: UNLIMITED_QUOTA;
|
||||
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
|
||||
return (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => onChildClick(config)}
|
||||
tooltip={config.name}
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => {
|
||||
if (!quota.disabled) onChildClick(config);
|
||||
}}
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
tooltip={quota.disabled ? undefined : config.name}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</WorkspaceQuotaTooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -575,6 +603,18 @@ function NavItems({
|
||||
const isSkill = categoryId === 'skills';
|
||||
const isBot = categoryId === 'bots';
|
||||
const isMCP = categoryId === 'mcp';
|
||||
const quota =
|
||||
categoryId === 'bots'
|
||||
? quotaStatus.bots
|
||||
: categoryId === 'pipelines'
|
||||
? quotaStatus.pipelines
|
||||
: categoryId === 'knowledge'
|
||||
? quotaStatus.knowledgeBases
|
||||
: categoryId === 'plugins' ||
|
||||
categoryId === 'mcp' ||
|
||||
categoryId === 'skills'
|
||||
? quotaStatus.extensions
|
||||
: UNLIMITED_QUOTA;
|
||||
|
||||
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||
if (item.extensionType === 'mcp') {
|
||||
@@ -907,128 +947,144 @@ function NavItems({
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1 px-2">
|
||||
<span className="text-sm font-medium">{config.name}</span>
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||
{renderEntityList(true)}
|
||||
@@ -1096,103 +1152,119 @@ function NavItems({
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
pluginCount: number;
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
quotaDataLoaded: boolean;
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
|
||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
|
||||
const refreshRequestIds = useRef({
|
||||
bots: 0,
|
||||
pipelines: 0,
|
||||
knowledgeBases: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
skills: 0,
|
||||
});
|
||||
const quotaResourceLoaded = useRef({
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
});
|
||||
const setQuotaResourceLoaded = useCallback(
|
||||
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
|
||||
quotaResourceLoaded.current[resource] = loaded;
|
||||
setQuotaDataLoaded(
|
||||
Object.values(quotaResourceLoaded.current).every(Boolean),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||
useState<boolean>(() => {
|
||||
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
|
||||
}, []);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.bots;
|
||||
try {
|
||||
const resp = await httpClient.getBots();
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', true);
|
||||
setBots(
|
||||
resp.bots.map((bot) => ({
|
||||
id: bot.uuid || '',
|
||||
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', false);
|
||||
console.error('Failed to fetch bots for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.pipelines;
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', true);
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', false);
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshKnowledgeBases = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', true);
|
||||
setKnowledgeBases(
|
||||
resp.bases.map((kb) => ({
|
||||
id: kb.uuid || '',
|
||||
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', false);
|
||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.plugins;
|
||||
try {
|
||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
|
||||
.getMarketplacePlugins(1, 100)
|
||||
.catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', true);
|
||||
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
||||
|
||||
// Build marketplace version lookup: "author/name" -> latest_version
|
||||
const marketplaceVersions = new Map<string, string>();
|
||||
@@ -241,13 +290,18 @@ export function SidebarDataProvider({
|
||||
}
|
||||
setPluginPages(pages);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', false);
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshMCPServers = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.mcpServers;
|
||||
try {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', true);
|
||||
setMCPServers(
|
||||
resp.servers.map((server) => ({
|
||||
id: server.name, // Keep __ for API calls
|
||||
@@ -257,13 +311,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', false);
|
||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshSkills = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.skills;
|
||||
try {
|
||||
const resp = await httpClient.getSkills();
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', true);
|
||||
setSkills(
|
||||
resp.skills.map((skill) => ({
|
||||
id: skill.name,
|
||||
@@ -273,11 +332,22 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', false);
|
||||
console.error('Failed to fetch skills for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
quotaResourceLoaded.current = {
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
};
|
||||
setQuotaDataLoaded(false);
|
||||
await Promise.all([
|
||||
refreshBots(),
|
||||
refreshPipelines(),
|
||||
@@ -307,9 +377,11 @@ export function SidebarDataProvider({
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
pluginPages,
|
||||
quotaDataLoaded,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
||||
|
||||
export function WorkspaceQuotaTooltip({
|
||||
quota,
|
||||
resource,
|
||||
children,
|
||||
side = 'top',
|
||||
}: {
|
||||
quota: WorkspaceQuotaItem;
|
||||
resource: string;
|
||||
children: ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!quota.disabled) return children;
|
||||
const message = quota.loading
|
||||
? t('limitation.quotaLoadingTooltip')
|
||||
: t('limitation.createDisabledTooltip', {
|
||||
resource,
|
||||
max: quota.max,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-disabled="true"
|
||||
aria-label={message}
|
||||
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className="max-w-72 text-center">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export interface WorkspaceQuotaItem {
|
||||
count: number;
|
||||
max: number;
|
||||
reached: boolean;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceQuotaStatus {
|
||||
bots: WorkspaceQuotaItem;
|
||||
pipelines: WorkspaceQuotaItem;
|
||||
knowledgeBases: WorkspaceQuotaItem;
|
||||
extensions: WorkspaceQuotaItem;
|
||||
botsReached: boolean;
|
||||
pipelinesReached: boolean;
|
||||
knowledgeBasesReached: boolean;
|
||||
extensionsReached: boolean;
|
||||
}
|
||||
|
||||
function quotaItem(
|
||||
count: number,
|
||||
max: number | undefined,
|
||||
loaded: boolean,
|
||||
): WorkspaceQuotaItem {
|
||||
const normalizedMax = typeof max === 'number' ? max : -1;
|
||||
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
||||
return {
|
||||
count,
|
||||
max: normalizedMax,
|
||||
reached,
|
||||
loading: !loaded,
|
||||
disabled: !loaded || reached,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
||||
const {
|
||||
bots,
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
quotaDataLoaded,
|
||||
} = useSidebarData();
|
||||
const limitation = systemInfo.limitation;
|
||||
|
||||
const botQuota = quotaItem(
|
||||
bots.length,
|
||||
limitation?.max_bots,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const pipelineQuota = quotaItem(
|
||||
pipelines.length,
|
||||
limitation?.max_pipelines,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const knowledgeBaseQuota = quotaItem(
|
||||
knowledgeBases.length,
|
||||
limitation?.max_knowledge_bases,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const extensionQuota = quotaItem(
|
||||
pluginCount + mcpServers.length + skills.length,
|
||||
limitation?.max_extensions,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
|
||||
return {
|
||||
bots: botQuota,
|
||||
pipelines: pipelineQuota,
|
||||
knowledgeBases: knowledgeBaseQuota,
|
||||
extensions: extensionQuota,
|
||||
botsReached: botQuota.disabled,
|
||||
pipelinesReached: pipelineQuota.disabled,
|
||||
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
||||
extensionsReached: extensionQuota.disabled,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
type PluginLocalPreview = Awaited<
|
||||
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
||||
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
|
||||
file: File;
|
||||
onInstallStarted?: () => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
|
||||
file,
|
||||
onInstallStarted,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: PluginLocalPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
||||
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
|
||||
}, [loadPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
setInstalling(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={quota.disabled || !preview || previewing || installing}
|
||||
>
|
||||
{installing
|
||||
? t('plugins.installing')
|
||||
: t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters {
|
||||
function MarketPageContent({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -847,6 +851,8 @@ function MarketPageContent({
|
||||
lists={recommendationLists}
|
||||
tagNames={tagNames}
|
||||
onInstall={handleInstallPlugin}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -876,6 +882,8 @@ function MarketPageContent({
|
||||
cardVO={plugin}
|
||||
onInstall={handleInstallPlugin}
|
||||
tagNames={tagNames}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -915,9 +923,13 @@ function MarketPageContent({
|
||||
export default function MarketPage({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -932,6 +944,8 @@ export default function MarketPage({
|
||||
<MarketPageContent
|
||||
installPlugin={installPlugin}
|
||||
headerActions={headerActions}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -54,11 +54,15 @@ function RecommendationListRow({
|
||||
list,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
isLast,
|
||||
}: {
|
||||
list: RecommendationList;
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -263,6 +267,8 @@ function RecommendationListRow({
|
||||
cardVO={pluginToVO(plugin, t)}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -277,10 +283,14 @@ export function RecommendationLists({
|
||||
lists,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
lists: RecommendationList[];
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
if (!lists || lists.length === 0) return null;
|
||||
|
||||
@@ -292,6 +302,8 @@ export function RecommendationLists({
|
||||
list={list}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
isLast={index === lists.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
+26
-3
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
onInstall,
|
||||
tagNames = {},
|
||||
installDisabled = false,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
cardVO: PluginMarketCardVO;
|
||||
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
||||
tagNames?: Record<string, string>;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
|
||||
|
||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||
const handleInstallClick = () => {
|
||||
if (installDisabled) return;
|
||||
onInstall?.(cardVO);
|
||||
};
|
||||
|
||||
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const cardContent = (
|
||||
<div
|
||||
role="button"
|
||||
role={installDisabled ? 'group' : 'button'}
|
||||
tabIndex={0}
|
||||
aria-disabled={installDisabled}
|
||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
||||
installDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
|
||||
}`}
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!installDisabled || !installDisabledTooltip) return cardContent;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 text-center">
|
||||
{installDisabledTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { Skill } from '@/app/infra/entities/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
interface PreviewSkill extends Skill {
|
||||
source_path?: string;
|
||||
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
|
||||
file: File;
|
||||
onImported: (skillNames: string[]) => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
|
||||
file,
|
||||
onImported,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: SkillZipPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
if (selectedPaths.length === 0) return;
|
||||
|
||||
setInstalling(true);
|
||||
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
quota.disabled ||
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -328,6 +328,7 @@ export interface SystemLimitation {
|
||||
max_bots: number;
|
||||
max_pipelines: number;
|
||||
max_extensions: number;
|
||||
max_knowledge_bases?: number;
|
||||
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
||||
|
||||
@@ -1678,6 +1678,12 @@ const enUS = {
|
||||
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||
maxExtensionsReached:
|
||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
||||
quotaLoadingTooltip:
|
||||
'Workspace usage is still loading. Please wait before creating a resource.',
|
||||
quotaCheckFailed:
|
||||
'Unable to verify the current workspace quota. Please try again.',
|
||||
createDisabledTooltip:
|
||||
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
|
||||
@@ -1634,6 +1634,12 @@ const esES = {
|
||||
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
||||
maxExtensionsReached:
|
||||
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
|
||||
quotaLoadingTooltip:
|
||||
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
|
||||
quotaCheckFailed:
|
||||
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
|
||||
createDisabledTooltip:
|
||||
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Crea un Bot con pasos guiados',
|
||||
|
||||
@@ -1685,6 +1685,12 @@ const jaJP = {
|
||||
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
||||
maxExtensionsReached:
|
||||
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
||||
quotaLoadingTooltip:
|
||||
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
|
||||
quotaCheckFailed:
|
||||
'現在のワークスペース上限を確認できません。もう一度お試しください。',
|
||||
createDisabledTooltip:
|
||||
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'ガイド付きステップでボットを作成',
|
||||
|
||||
@@ -1609,6 +1609,12 @@ const ruRU = {
|
||||
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
||||
maxExtensionsReached:
|
||||
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
||||
quotaLoadingTooltip:
|
||||
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
|
||||
quotaCheckFailed:
|
||||
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
|
||||
createDisabledTooltip:
|
||||
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Создать бота с пошаговым руководством',
|
||||
|
||||
@@ -1576,6 +1576,12 @@ const thTH = {
|
||||
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
||||
maxExtensionsReached:
|
||||
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
||||
quotaLoadingTooltip:
|
||||
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
|
||||
quotaCheckFailed:
|
||||
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
|
||||
createDisabledTooltip:
|
||||
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
||||
|
||||
@@ -1602,6 +1602,12 @@ const viVN = {
|
||||
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
|
||||
maxExtensionsReached:
|
||||
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
|
||||
quotaLoadingTooltip:
|
||||
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
|
||||
quotaCheckFailed:
|
||||
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
|
||||
createDisabledTooltip:
|
||||
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
||||
|
||||
@@ -1606,6 +1606,10 @@ const zhHans = {
|
||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||
maxExtensionsReached:
|
||||
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
|
||||
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
|
||||
createDisabledTooltip:
|
||||
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
|
||||
@@ -1531,6 +1531,10 @@ const zhHant = {
|
||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||
maxExtensionsReached:
|
||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
|
||||
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
|
||||
createDisabledTooltip:
|
||||
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: '透過引導步驟建立機器人',
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function fulfill(
|
||||
route: Parameters<Parameters<import('@playwright/test').Page['route']>[1]>[0],
|
||||
data: unknown,
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped(data),
|
||||
});
|
||||
}
|
||||
|
||||
test('quota-reached create actions are disabled and explain the current limit', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.route('**/api/v1/system/info', (route) =>
|
||||
fulfill(route, {
|
||||
debug: false,
|
||||
version: 'quota-e2e',
|
||||
edition: 'community',
|
||||
cloud_service_url: 'https://space.langbot.app',
|
||||
enable_marketplace: true,
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
limitation: {
|
||||
max_bots: 2,
|
||||
max_pipelines: 3,
|
||||
max_extensions: 3,
|
||||
max_knowledge_bases: 2,
|
||||
},
|
||||
outbound_ips: [],
|
||||
wizard_status: 'completed',
|
||||
wizard_progress: null,
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/platform/bots**', (route) =>
|
||||
fulfill(route, {
|
||||
bots: Array.from({ length: 2 }, (_, index) => ({
|
||||
uuid: `bot-${index}`,
|
||||
name: `Bot ${index + 1}`,
|
||||
description: '',
|
||||
adapter: 'aiocqhttp',
|
||||
enable: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/pipelines**', (route) =>
|
||||
fulfill(route, {
|
||||
pipelines: Array.from({ length: 3 }, (_, index) => ({
|
||||
uuid: `pipeline-${index}`,
|
||||
name: `Pipeline ${index + 1}`,
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/knowledge/bases**', (route) =>
|
||||
fulfill(route, {
|
||||
bases: Array.from({ length: 2 }, (_, index) => ({
|
||||
uuid: `kb-${index}`,
|
||||
name: `Knowledge ${index + 1}`,
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins**', (route) =>
|
||||
fulfill(route, { plugins: [] }),
|
||||
);
|
||||
await page.route('**/api/v1/mcp/servers**', (route) =>
|
||||
fulfill(route, {
|
||||
servers: Array.from({ length: 3 }, (_, index) => ({
|
||||
name: `mcp-${index}`,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
runtime_info: { status: 'connected' },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/skills**', (route) =>
|
||||
fulfill(route, { skills: [] }),
|
||||
);
|
||||
|
||||
await page.goto('/home/bots');
|
||||
|
||||
const botCreate = page.getByRole('button', {
|
||||
name: 'Create Bots',
|
||||
exact: true,
|
||||
});
|
||||
const pipelineCreate = page.getByRole('button', {
|
||||
name: 'Create Pipelines',
|
||||
exact: true,
|
||||
});
|
||||
const knowledgeCreate = page.getByRole('button', {
|
||||
name: 'Create Knowledge',
|
||||
exact: true,
|
||||
});
|
||||
const addExtension = page.getByRole('button', {
|
||||
name: 'Add Extension',
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(botCreate).toBeDisabled();
|
||||
await expect(pipelineCreate).toBeDisabled();
|
||||
await expect(knowledgeCreate).toBeDisabled();
|
||||
await expect(addExtension).toBeDisabled();
|
||||
|
||||
const botQuotaTrigger = botCreate.locator('..');
|
||||
await botQuotaTrigger.hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await botQuotaTrigger.focus();
|
||||
await expect(botQuotaTrigger).toBeFocused();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto('/home/add-extension');
|
||||
const manualAdd = page.getByRole('button', { name: 'Manual Add' });
|
||||
await expect(manualAdd).toBeDisabled();
|
||||
await manualAdd.locator('..').hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const quotaPath = path.join(
|
||||
root,
|
||||
'src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts',
|
||||
);
|
||||
const sidebarPath = path.join(
|
||||
root,
|
||||
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||
);
|
||||
const tooltipPath = path.join(
|
||||
root,
|
||||
'src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx',
|
||||
);
|
||||
const addExtensionPath = path.join(root, 'src/app/home/add-extension/page.tsx');
|
||||
const marketPath = path.join(
|
||||
root,
|
||||
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
|
||||
);
|
||||
const marketCardPath = path.join(
|
||||
root,
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
|
||||
);
|
||||
const recommendationPath = path.join(
|
||||
root,
|
||||
'src/app/home/plugins/components/plugin-market/RecommendationLists.tsx',
|
||||
);
|
||||
const zhPath = path.join(root, 'src/i18n/locales/zh-Hans.ts');
|
||||
|
||||
test('workspace quota hook exposes reached states for every creatable resource', () => {
|
||||
assert.equal(
|
||||
fs.existsSync(quotaPath),
|
||||
true,
|
||||
'workspace quota hook is missing',
|
||||
);
|
||||
const source = fs.readFileSync(quotaPath, 'utf8');
|
||||
for (const token of [
|
||||
'botsReached',
|
||||
'pipelinesReached',
|
||||
'knowledgeBasesReached',
|
||||
'extensionsReached',
|
||||
'max_bots',
|
||||
'max_pipelines',
|
||||
'max_knowledge_bases',
|
||||
'max_extensions',
|
||||
]) {
|
||||
assert.match(source, new RegExp(token));
|
||||
}
|
||||
});
|
||||
|
||||
test('sidebar quota-disables create controls and renders a tooltip', () => {
|
||||
const source = fs.readFileSync(sidebarPath, 'utf8');
|
||||
const tooltip = fs.readFileSync(tooltipPath, 'utf8');
|
||||
assert.match(source, /useWorkspaceQuotaStatus/);
|
||||
assert.match(source, /quota\.disabled/);
|
||||
assert.match(source, /disabled=\{quota\.disabled\}/);
|
||||
assert.match(source, /WorkspaceQuotaTooltip/);
|
||||
assert.match(tooltip, /TooltipContent/);
|
||||
assert.match(tooltip, /limitation\.createDisabledTooltip/);
|
||||
assert.match(tooltip, /limitation\.quotaLoadingTooltip/);
|
||||
assert.match(tooltip, /tabIndex=\{0\}/);
|
||||
assert.match(source, /config\.id === 'add-extension'/);
|
||||
});
|
||||
|
||||
test('add-extension page disables all install entry points at the quota', () => {
|
||||
const page = fs.readFileSync(addExtensionPath, 'utf8');
|
||||
const market = fs.readFileSync(marketPath, 'utf8');
|
||||
const card = fs.readFileSync(marketCardPath, 'utf8');
|
||||
const recommendations = fs.readFileSync(recommendationPath, 'utf8');
|
||||
|
||||
assert.match(page, /extensionsReached/);
|
||||
assert.match(page, /installDisabled=\{extensionsReached\}/);
|
||||
assert.match(page, /disabled=\{extensionsReached/);
|
||||
assert.match(page, /limitation\.createDisabledTooltip/);
|
||||
assert.match(market, /installDisabled/);
|
||||
assert.match(card, /installDisabled/);
|
||||
assert.match(card, /disabled=\{installDisabled\}/);
|
||||
assert.match(card, /TooltipContent/);
|
||||
assert.match(recommendations, /installDisabled=\{installDisabled\}/);
|
||||
assert.match(
|
||||
recommendations,
|
||||
/installDisabledTooltip=\{installDisabledTooltip\}/,
|
||||
);
|
||||
assert.match(page, /quota=\{extensionQuota\}/);
|
||||
});
|
||||
|
||||
test('extension confirmation checks fail closed and enter an in-flight state first', () => {
|
||||
const page = fs.readFileSync(addExtensionPath, 'utf8');
|
||||
|
||||
assert.match(page, /limitation\.quotaCheckFailed/);
|
||||
assert.doesNotMatch(page, /If we can't check, let backend handle it/);
|
||||
assert.match(
|
||||
page,
|
||||
/setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
page,
|
||||
/setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('quota tooltip copy is localized in Simplified Chinese', () => {
|
||||
const source = fs.readFileSync(zhPath, 'utf8');
|
||||
assert.match(source, /createDisabledTooltip/);
|
||||
assert.match(source, /已达到.*上限/);
|
||||
assert.match(source, /删除.*后再/);
|
||||
});
|
||||
Reference in New Issue
Block a user