Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	pyproject.toml
#	src/langbot/pkg/pipeline/controller.py
#	uv.lock
#	web/src/app/home/bots/components/bot-form/BotForm.tsx
#	web/src/app/home/components/home-sidebar/HomeSidebar.tsx
#	web/src/app/home/components/home-sidebar/SidebarDataContext.tsx
#	web/src/app/home/plugin-pages/page.tsx
#	web/src/app/infra/entities/api/index.ts
This commit is contained in:
huanghuoguoguo
2026-08-04 12:16:27 +08:00
75 changed files with 3809 additions and 713 deletions
@@ -8,6 +8,7 @@ import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
@@ -32,7 +33,6 @@ import {
Zap,
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
RefreshCcw,
@@ -113,6 +113,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 {
@@ -283,6 +288,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;
@@ -390,6 +403,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();
@@ -533,7 +547,7 @@ function NavItems({
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
@@ -580,6 +594,18 @@ function NavItems({
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const isAgents = categoryId === 'pipelines';
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') {
@@ -970,128 +996,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)}
@@ -1206,103 +1248,119 @@ function NavItems({
</div>
)}
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{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"
@@ -1747,6 +1805,13 @@ export default function HomeSidebar({
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
const showWorkspaceSwitcher =
workspaces.length > 1 ||
currentWorkspace?.workspace.source === 'cloud_projection';
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' &&
currentWorkspace?.permissions.includes('audit.view');
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -2025,9 +2090,11 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarHeader>
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
{showWorkspaceSwitcher && (
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
)}
{/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -2208,15 +2275,16 @@ export default function HomeSidebar({
<UsersRound />
{t('workspace.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
{canViewStorageAnalysis && (
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
{t('storageAnalysis.title')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -4,6 +4,7 @@ import React, {
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
@@ -51,9 +52,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>;
@@ -83,9 +86,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>(() => {
@@ -117,11 +147,14 @@ export function SidebarDataProvider({
}, []);
const refreshBots = useCallback(async () => {
const requestId = ++refreshRequestIds.current.bots;
try {
const [resp, adaptersResp] = await Promise.all([
httpClient.getBots(),
httpClient.getAdapters().catch(() => ({ adapters: [] })),
]);
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', true);
const legacyAdapterNames = new Set(
adaptersResp.adapters
.filter((adapter) => adapter.spec.legacy)
@@ -139,13 +172,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.getAgents();
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', true);
setPipelines(
resp.agents.map((p) => ({
id: p.uuid || '',
@@ -157,13 +195,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', false);
console.error('Failed to fetch agents 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 || '',
@@ -174,11 +217,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(),
@@ -186,6 +232,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>();
@@ -272,13 +321,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
@@ -288,13 +342,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,
@@ -304,11 +363,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(),
@@ -338,9 +408,11 @@ export function SidebarDataProvider({
pipelines,
knowledgeBases,
plugins,
pluginCount,
mcpServers,
skills,
pluginPages,
quotaDataLoaded,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
@@ -218,20 +218,22 @@ export default function ProviderCard({
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
{isWorkspaceOwner && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
@@ -133,12 +133,14 @@ export default function SettingsDialog({
const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
}
if (item.id === 'storageAnalysis') {
return canViewAudit;
return canViewStorageAnalysis;
}
return true;
});
@@ -146,11 +148,17 @@ export default function SettingsDialog({
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewAudit);
(section === 'storageAnalysis' && !canViewStorageAnalysis);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
}, [
canManageApiKeys,
canViewStorageAnalysis,
open,
section,
onSectionChange,
]);
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -256,7 +264,7 @@ export default function SettingsDialog({
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
{section === 'storageAnalysis' && canViewStorageAnalysis && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
@@ -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-left">
{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,
};
}
@@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
@@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent>
</Select>
)}